features_pressure.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. #!/usr/bin/env python3
  2. """Unit-relative (generalized) pressure features for cross-unit PKS models.
  3. Absolute oil-pressure levels differ between units, so a shared model trained on
  4. raw values treats one unit's normal level as another unit's anomaly. These
  5. features express each window relative to its own window and its own unit's
  6. normal baseline (median/MAD), so the same model can be shared across units.
  7. Baselines must be computed from training-split normal windows only.
  8. """
  9. from __future__ import annotations
  10. from collections import defaultdict
  11. from statistics import median
  12. MIN_BASELINE_SAMPLES = 24
  13. # Absolute oil-pressure features dropped from the generalized feature set.
  14. ABSOLUTE_FEATURES = ("oil_mean", "oil_min", "oil_max", "oil_range", "oil_slope")
  15. # Fixed feature order for the shared model.
  16. FEATURE_NAMES = (
  17. "oil_last_relative_to_window",
  18. "oil_range_rel",
  19. "oil_slope_rel",
  20. "oil_min_dev",
  21. "oil_max_dev",
  22. "oil_low_ratio_mean",
  23. "oil_low_low_ratio_mean",
  24. "running_coverage_mean",
  25. "running_coverage_min",
  26. "oil_rel_cycle_mean",
  27. "oil_rel_cycle_min",
  28. "oil_rel_cycle_last",
  29. "oil_rel_recent_mean",
  30. "oil_rel_recent_last",
  31. "oil_slope_rel_cycle",
  32. "oil_decline_hours",
  33. "oil_drawdown_cycle",
  34. "oil_range_rel_cycle",
  35. "oil_mean_z",
  36. "oil_min_z",
  37. "oil_last_z",
  38. "oil_slope_z",
  39. )
  40. # Features computed in build_datasets from the run cycle and passed through as-is.
  41. CYCLE_RELATIVE_FEATURES = (
  42. "oil_rel_cycle_mean",
  43. "oil_rel_cycle_min",
  44. "oil_rel_cycle_last",
  45. "oil_rel_recent_mean",
  46. "oil_rel_recent_last",
  47. "oil_slope_rel_cycle",
  48. "oil_decline_hours",
  49. "oil_drawdown_cycle",
  50. "oil_range_rel_cycle",
  51. )
  52. def feature_names() -> list[str]:
  53. return list(FEATURE_NAMES)
  54. def _safe_ratio(numerator, denominator):
  55. if numerator is None or denominator is None or denominator == 0:
  56. return None
  57. return numerator / denominator
  58. def _last_hour_oil(sample: dict):
  59. hours = sample.get("source_hours") or []
  60. if not hours:
  61. return None
  62. return hours[-1].get("oil_avg")
  63. def compute_unit_baselines(samples: list[dict]) -> dict[str, dict]:
  64. """Compute per-unit normal oil-pressure median/MAD from training normal windows.
  65. Raises if a unit has fewer than MIN_BASELINE_SAMPLES training normal windows
  66. or a degenerate MAD, because the normalization would be unstable.
  67. """
  68. by_unit = defaultdict(list)
  69. for sample in samples:
  70. if sample["label"] != 0:
  71. continue
  72. value = sample["features"].get("oil_mean")
  73. if value is not None:
  74. by_unit[sample["unit"]].append(value)
  75. if not by_unit:
  76. raise RuntimeError("训练集没有正常窗口,无法计算机组基准")
  77. baselines = {}
  78. for unit, values in sorted(by_unit.items()):
  79. if len(values) < MIN_BASELINE_SAMPLES:
  80. raise RuntimeError(
  81. f"{unit} 训练正常窗口仅 {len(values)} 个,少于 {MIN_BASELINE_SAMPLES} 个,无法计算稳定基准"
  82. )
  83. center = float(median(values))
  84. mad = float(median([abs(value - center) for value in values]))
  85. if mad <= 0:
  86. raise RuntimeError(f"{unit} 正常油压 MAD=0,无法归一化")
  87. baselines[unit] = {"median": center, "mad": mad, "normal_samples": len(values)}
  88. return baselines
  89. def generalized_features(sample: dict, baselines: dict[str, dict]) -> dict:
  90. features = sample["features"]
  91. unit = sample["unit"]
  92. if unit not in baselines:
  93. raise KeyError(f"缺少 {unit} 的机组基准")
  94. center = baselines[unit]["median"]
  95. mad = baselines[unit]["mad"]
  96. oil_mean = features.get("oil_mean")
  97. oil_min = features.get("oil_min")
  98. oil_max = features.get("oil_max")
  99. oil_range = features.get("oil_range")
  100. oil_slope = features.get("oil_slope")
  101. last_oil = _last_hour_oil(sample)
  102. result = {
  103. "oil_last_relative_to_window": features.get("oil_last_relative_to_window"),
  104. "oil_range_rel": _safe_ratio(oil_range, oil_mean),
  105. "oil_slope_rel": _safe_ratio(oil_slope, oil_mean),
  106. "oil_min_dev": _safe_ratio(oil_min - oil_mean, oil_mean) if oil_min is not None and oil_mean is not None else None,
  107. "oil_max_dev": _safe_ratio(oil_max - oil_mean, oil_mean) if oil_max is not None and oil_mean is not None else None,
  108. "oil_low_ratio_mean": features.get("oil_low_ratio_mean"),
  109. "oil_low_low_ratio_mean": features.get("oil_low_low_ratio_mean"),
  110. "running_coverage_mean": features.get("running_coverage_mean"),
  111. "running_coverage_min": features.get("running_coverage_min"),
  112. "oil_mean_z": (oil_mean - center) / mad if oil_mean is not None else None,
  113. "oil_min_z": (oil_min - center) / mad if oil_min is not None else None,
  114. "oil_last_z": (last_oil - center) / mad if last_oil is not None else None,
  115. "oil_slope_z": oil_slope / mad if oil_slope is not None else None,
  116. }
  117. for name in CYCLE_RELATIVE_FEATURES:
  118. result[name] = features.get(name)
  119. return result
  120. def feature_matrix(samples: list[dict], baselines: dict[str, dict]) -> list[dict]:
  121. return [generalized_features(sample, baselines) for sample in samples]