local_labels.py 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  1. """动态局部基线:标签前最多 14 天、至少 1 天有效运行数据。"""
  2. from __future__ import annotations
  3. import numpy as np
  4. import pandas as pd
  5. from . import config, labels
  6. MIN_BASELINE_POINTS = 96
  7. WINDOW = "14D"
  8. GAP_HOURS = 2
  9. TRANSITION_HOURS = 1
  10. def _transition_mask(group: pd.DataFrame) -> pd.Series:
  11. times = group["sample_time"].reset_index(drop=True)
  12. gaps = times.diff().dt.total_seconds() / 3600.0
  13. starts = gaps > GAP_HOURS
  14. mask = starts.copy()
  15. for offset in range(1, int(TRANSITION_HOURS * 4) + 1):
  16. mask |= starts.shift(offset, fill_value=False)
  17. return mask
  18. def _local_baseline(frame: pd.DataFrame) -> pd.DataFrame:
  19. columns = ["rms_log", "kurt", "hf_ratio", "env_1x_log", "env_2x_log",
  20. "resid_rms_log", "resid_kurt", "resid_hf_ratio", "resid_env_1x_log", "resid_env_2x_log"]
  21. for column in columns:
  22. frame[f"base_{column}"] = np.nan
  23. frame[f"scale_{column}"] = np.nan
  24. frame["transition"] = False
  25. for _, group in frame.groupby("cylinder", sort=False):
  26. group = group.sort_values("sample_time")
  27. original_index = group.index
  28. mask = _transition_mask(group)
  29. frame.loc[original_index, "transition"] = mask.to_numpy()
  30. series_group = group.set_index("sample_time")
  31. for column in columns:
  32. series = series_group[column].where(~mask.to_numpy())
  33. median = series.rolling(WINDOW, min_periods=MIN_BASELINE_POINTS).median().shift(1)
  34. q1 = series.rolling(WINDOW, min_periods=MIN_BASELINE_POINTS).quantile(0.25).shift(1)
  35. q3 = series.rolling(WINDOW, min_periods=MIN_BASELINE_POINTS).quantile(0.75).shift(1)
  36. scale = (q3 - q1) / 1.349
  37. fallback = series.rolling(WINDOW, min_periods=MIN_BASELINE_POINTS).std().shift(1)
  38. scale = scale.where(scale > 1e-9, fallback)
  39. frame.loc[original_index, f"base_{column}"] = median.to_numpy()
  40. frame.loc[original_index, f"scale_{column}"] = scale.to_numpy()
  41. return frame
  42. def build_local_labels(frame: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame, dict[str, float]]:
  43. frame = labels._prepare_raw(frame)
  44. frame = _local_baseline(frame)
  45. score_columns = [f"resid_{name}" for name in config.RESIDUAL_FEATURES]
  46. for column in score_columns:
  47. frame[f"z_{column}"] = (frame[column] - frame[f"base_{column}"]) / frame[f"scale_{column}"]
  48. frame[f"z_{column}"] = frame[f"z_{column}"].replace([np.inf, -np.inf], np.nan).clip(-20, 20)
  49. z_columns = [f"z_{column}" for column in score_columns]
  50. z_values = frame[z_columns].abs()
  51. frame["score"] = z_values.max(axis=1).fillna(0.0)
  52. frame["driver"] = z_values.fillna(0.0).idxmax(axis=1).str.replace("z_", "", regex=False)
  53. frame.loc[frame["score"] <= 0, "driver"] = "none"
  54. baseline_columns = [f"base_{column}" for column in score_columns] + [f"scale_{column}" for column in score_columns]
  55. frame["baseline_ok"] = frame[baseline_columns].notna().all(axis=1)
  56. frame["rms_ratio"] = np.power(10.0, frame["rms_log"] - frame["base_rms_log"])
  57. frame["kurt_ratio"] = frame["kurt"] / frame["base_kurt"]
  58. frame["contrast"] = 0.0
  59. for _, group in frame.groupby("cylinder", sort=False):
  60. group = group.sort_values("sample_time")
  61. score = group.set_index("sample_time")["score"]
  62. local = score.rolling("24h", min_periods=4).median().shift(1)
  63. frame.loc[group.index, "contrast"] = (score - local).fillna(0.0).to_numpy()
  64. normal_mask = ~labels.event_window_mask(frame["sample_time"])
  65. thresholds: dict[str, float] = {}
  66. for cylinder, group in frame.groupby("cylinder", sort=False):
  67. normal = group.loc[labels._normal_mask_for(normal_mask, group.index), "score"].dropna()
  68. quantile = float(normal.quantile(config.SCORE_QUANTILE)) if len(normal) else config.MIN_SCORE_Z
  69. thresholds[cylinder] = max(config.MIN_SCORE_Z, quantile)
  70. frame["threshold"] = frame["cylinder"].map(thresholds)
  71. frame["hit"] = (
  72. frame["baseline_ok"]
  73. & (frame["score"] >= frame["threshold"])
  74. & (frame["contrast"] >= config.MIN_CONTRAST)
  75. )
  76. point_labels = labels._extract_segments(frame, 15)
  77. return frame, point_labels, thresholds