local_labels.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  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 = "30D"
  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. thresholds: dict[str, float] = {}
  65. anomaly_thresholds: dict[str, float] = {}
  66. severe_thresholds: dict[str, float] = {}
  67. for cylinder, group in frame.groupby("cylinder", sort=False):
  68. normal = group[(group["baseline_ok"]) & (~group["transition"])]["score"].dropna()
  69. if normal.empty:
  70. normal = group["score"].dropna()
  71. median = float(normal.median()) if not normal.empty else 1.0
  72. mad = float((normal - median).abs().median()) if not normal.empty else 0.0
  73. mad = max(mad, 0.25)
  74. candidate = max(3.5, median + 6.0 * mad)
  75. anomaly = max(5.0, median + 10.0 * mad)
  76. severe = max(7.0, median + 14.0 * mad)
  77. thresholds[cylinder] = candidate
  78. anomaly_thresholds[cylinder] = max(candidate, anomaly)
  79. severe_thresholds[cylinder] = max(candidate, anomaly, severe)
  80. frame["threshold"] = frame["cylinder"].map(thresholds)
  81. frame["anomaly_threshold"] = frame["cylinder"].map(anomaly_thresholds)
  82. frame["severe_threshold"] = frame["cylinder"].map(severe_thresholds)
  83. frame["hit"] = frame["baseline_ok"] & (frame["score"] >= frame["threshold"])
  84. point_labels = labels._extract_segments(frame, 15)
  85. return frame, point_labels, thresholds