generalized_features_vibration.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. #!/usr/bin/env python3
  2. """Unit-relative (generalized) vibration features for cross-unit PKS models.
  3. Absolute vibration levels differ between units, so a shared model trained on raw
  4. values treats one unit's normal level as another unit's anomaly. These features
  5. express each window relative to its own unit's recent robust baseline (the same
  6. 7*24-hour baseline the scanner rule uses) and to its own unit's normal
  7. distribution (median/MAD), so one model can be shared across units.
  8. Baselines must be computed from training-split normal windows only.
  9. """
  10. from __future__ import annotations
  11. from collections import defaultdict
  12. from statistics import median
  13. MIN_BASELINE_SAMPLES = 24
  14. SIDES = ("coupling", "chain")
  15. # Absolute vibration features dropped from the generalized feature set.
  16. ABSOLUTE_FEATURES = tuple(
  17. f"{side}_{kind}_{stat}"
  18. for side in SIDES
  19. for kind in ("avg", "peak")
  20. for stat in ("mean", "max", "range")
  21. ) + tuple(f"{side}_{kind}_slope" for side in SIDES for kind in ("avg", "peak"))
  22. # Fixed feature order for the shared model.
  23. FEATURE_NAMES = tuple(
  24. name
  25. for side in SIDES
  26. for name in (
  27. f"{side}_avg_dev",
  28. f"{side}_peak_dev",
  29. f"{side}_avg_rel_last",
  30. f"{side}_peak_rel_last",
  31. f"{side}_avg_rel_mean",
  32. f"{side}_peak_rel_max",
  33. f"{side}_avg_z",
  34. f"{side}_peak_z",
  35. f"{side}_avg_slope_z",
  36. f"{side}_high_ratio_mean",
  37. f"{side}_high_high_ratio_mean",
  38. )
  39. ) + ("both_sides_rising", "running_coverage_mean", "running_coverage_min")
  40. def feature_names() -> list[str]:
  41. return list(FEATURE_NAMES)
  42. def compute_unit_baselines(samples: list[dict]) -> dict[str, dict]:
  43. """Compute per-unit normal median/MAD for each side from training normal windows.
  44. Raises if a unit has fewer than MIN_BASELINE_SAMPLES training normal windows
  45. or a degenerate MAD, because the normalization would be unstable.
  46. """
  47. by_unit = defaultdict(list)
  48. for sample in samples:
  49. if sample["label"] != 0:
  50. continue
  51. by_unit[sample["unit"]].append(sample["features"])
  52. if not by_unit:
  53. raise RuntimeError("训练集没有正常窗口,无法计算机组基准")
  54. baselines = {}
  55. for unit, features in sorted(by_unit.items()):
  56. if len(features) < MIN_BASELINE_SAMPLES:
  57. raise RuntimeError(
  58. f"{unit} 训练正常窗口仅 {len(features)} 个,少于 {MIN_BASELINE_SAMPLES} 个,无法计算稳定基准"
  59. )
  60. unit_baseline = {"normal_samples": len(features)}
  61. for side in SIDES:
  62. for kind in ("avg", "peak"):
  63. key = f"{side}_{kind}_mean"
  64. values = [item[key] for item in features if item.get(key) is not None]
  65. if len(values) < MIN_BASELINE_SAMPLES:
  66. raise RuntimeError(f"{unit} {key} 有效正常窗口不足,无法计算稳定基准")
  67. center = float(median(values))
  68. mad = float(median([abs(value - center) for value in values]))
  69. if mad <= 0:
  70. raise RuntimeError(f"{unit} {key} 的 MAD=0,无法归一化")
  71. unit_baseline[f"{side}_{kind}"] = {"median": center, "mad": mad}
  72. baselines[unit] = unit_baseline
  73. return baselines
  74. def generalized_features(sample: dict, baselines: dict[str, dict]) -> dict:
  75. features = sample["features"]
  76. unit = sample["unit"]
  77. if unit not in baselines:
  78. raise KeyError(f"缺少 {unit} 的机组基准")
  79. unit_baseline = baselines[unit]
  80. result = {}
  81. for side in SIDES:
  82. avg = unit_baseline[f"{side}_avg"]
  83. peak = unit_baseline[f"{side}_peak"]
  84. avg_mean = features.get(f"{side}_avg_mean")
  85. peak_mean = features.get(f"{side}_peak_mean")
  86. avg_slope = features.get(f"{side}_avg_slope")
  87. result[f"{side}_avg_dev"] = features.get(f"{side}_avg_baseline_deviation")
  88. result[f"{side}_peak_dev"] = features.get(f"{side}_peak_baseline_deviation")
  89. result[f"{side}_avg_rel_last"] = features.get(f"{side}_avg_rel_last")
  90. result[f"{side}_peak_rel_last"] = features.get(f"{side}_peak_rel_last")
  91. result[f"{side}_avg_rel_mean"] = features.get(f"{side}_avg_rel_mean")
  92. result[f"{side}_peak_rel_max"] = features.get(f"{side}_peak_rel_max")
  93. result[f"{side}_avg_z"] = (avg_mean - avg["median"]) / avg["mad"] if avg_mean is not None else None
  94. result[f"{side}_peak_z"] = (peak_mean - peak["median"]) / peak["mad"] if peak_mean is not None else None
  95. result[f"{side}_avg_slope_z"] = avg_slope / avg["mad"] if avg_slope is not None else None
  96. result[f"{side}_high_ratio_mean"] = features.get(f"{side}_high_ratio_mean")
  97. result[f"{side}_high_high_ratio_mean"] = features.get(f"{side}_high_high_ratio_mean")
  98. result["both_sides_rising"] = features.get("both_sides_rising")
  99. result["running_coverage_mean"] = features.get("running_coverage_mean")
  100. result["running_coverage_min"] = features.get("running_coverage_min")
  101. return result
  102. def feature_matrix(samples: list[dict], baselines: dict[str, dict]) -> list[dict]:
  103. return [generalized_features(sample, baselines) for sample in samples]