scan_bins.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. #!/usr/bin/env python3
  2. """Full-timeline unsupervised change scan over the per-bin/-cycle features.
  3. For every ``(point, bin, metric)`` the whole available time series is searched
  4. for the single best two-segment split (a step change), scored as the between
  5. segment mean difference over the pooled standard deviation (Cohen's d). The
  6. point-wide (all-bins) part is removed per metric so only the bins that move
  7. *differently* rank high -- the same local/whole decomposition used by
  8. ``event_bins.py``, but now applied to the entire timeline without needing a
  9. known event or a pre/post window.
  10. Output: a ranked candidate list and a CSV. Read-only.
  11. """
  12. from __future__ import annotations
  13. import argparse
  14. import csv
  15. from collections import defaultdict
  16. from datetime import datetime
  17. from pathlib import Path
  18. import numpy as np
  19. from bins_common import ANGLE_METRICS, BIN_METRICS, CYCLE_METRICS, VOLATILE, apply_normalization
  20. from event_bins import EVENTS, load
  21. MIN_DEG = 1.0 # minimum pooled scale for angle metrics
  22. def best_split(times, values, min_days: int = 3):
  23. """Best two-segment step (mean difference / pooled std). Returns (score, index)."""
  24. n = len(values)
  25. if n < 8:
  26. return None, None
  27. x = np.asarray(values, dtype=float)
  28. csum = np.cumsum(x)
  29. csum2 = np.cumsum(x ** 2)
  30. total, total_sq = float(csum[-1]), float(csum2[-1])
  31. best_score, best_i = 0.0, None
  32. min_seg = max(5, n // 12)
  33. for i in range(min_seg, n - min_seg):
  34. if (times[i - 1] - times[0]).days < min_days or (times[-1] - times[i]).days < min_days:
  35. continue
  36. left, right = i, n - i
  37. sl, sl2 = float(csum[i - 1]), float(csum2[i - 1])
  38. mean_l, mean_r = sl / left, (total - sl) / right
  39. var_l = max(sl2 / left - mean_l ** 2, 0.0)
  40. var_r = max((total_sq - sl2) / right - mean_r ** 2, 0.0)
  41. pooled = float(np.sqrt(0.5 * (var_l + var_r)))
  42. if pooled <= 1e-12:
  43. continue
  44. score = abs(mean_r - mean_l) / pooled
  45. if score > best_score:
  46. best_score, best_i = score, i
  47. return (best_score, best_i) if best_i is not None else (None, None)
  48. def daily_series(rows, getter):
  49. return sorted(((r["time"], getter(r)) for r in rows), key=lambda t: t[0])
  50. def main() -> None:
  51. parser = argparse.ArgumentParser(description="全时间轴 分箱特征 无监督变点扫描")
  52. parser.add_argument("--unit", required=True, choices=("7", "8", "9"))
  53. parser.add_argument("--csv", nargs="+", required=True)
  54. parser.add_argument("--top", type=int, default=40)
  55. parser.add_argument("--z", type=float, default=5.0, help="最小 |Cohen's d| 阈值")
  56. parser.add_argument("--normalize", choices=("none", "span"), default="none",
  57. help="span: 用本周期 p_span 归一再扫(压工况)")
  58. parser.add_argument("--out", default="")
  59. args = parser.parse_args()
  60. rows = []
  61. for path in args.csv:
  62. rows.extend(load(path))
  63. apply_normalization(rows, args.normalize)
  64. print(f"载入 {len(rows):,} 行,测点 {len({r['point_name'] for r in rows})},归一 {args.normalize}")
  65. # records[(point,metric)][bin] = (time, score)
  66. bin_scores = defaultdict(dict)
  67. cycle_scores = defaultdict(list)
  68. points = sorted({(r["cylinder"], r["side"]) for r in rows})
  69. for cylinder, side in points:
  70. point_rows = [r for r in rows if r["cylinder"] == cylinder and r["side"] == side]
  71. # per-bin
  72. by_bin = defaultdict(list)
  73. for r in point_rows:
  74. by_bin[r["bin"]].append(r)
  75. for name in BIN_METRICS:
  76. for b, items in by_bin.items():
  77. series = daily_series(items, lambda r, nm=name: r[nm])
  78. series = [(t, v) for t, v in series if v is not None]
  79. times = [t for t, _ in series]
  80. values = [v for _, v in series]
  81. score, idx = best_split(times, values)
  82. if score is None:
  83. continue
  84. bin_scores[(f"{cylinder}缸{side}", name)][b] = (score, times[idx], np.mean(values[:idx]), np.mean(values[idx:]))
  85. # whole-cycle
  86. for name in CYCLE_METRICS:
  87. if name in ANGLE_METRICS:
  88. continue
  89. series = daily_series(point_rows, lambda r, nm=name: r[nm])
  90. series = [(t, v) for t, v in series if v is not None]
  91. times = [t for t, _ in series]
  92. values = [v for _, v in series]
  93. score, idx = best_split(times, values)
  94. if score is not None:
  95. cycle_scores[f"{cylinder}缸{side}"].append((name, score, times[idx], np.mean(values[:idx]), np.mean(values[idx:])))
  96. records = []
  97. for (point, metric), per_bin in bin_scores.items():
  98. if len(per_bin) < 4:
  99. continue
  100. raw = np.asarray([v[0] for v in per_bin.values()], dtype=float)
  101. median_score = float(np.median(raw))
  102. for b, (score, split_time, before, after) in per_bin.items():
  103. local = score - median_score # remove whole-point level shift
  104. if local >= args.z:
  105. records.append({
  106. "family": "bin", "point": point, "bin": b,
  107. "metric": metric, "score": local, "raw_score": score,
  108. "split": split_time, "before": before, "after": after,
  109. "volatile": metric in VOLATILE,
  110. })
  111. for point, items in cycle_scores.items():
  112. for name, score, split_time, before, after in items:
  113. if score >= args.z:
  114. records.append({
  115. "family": "cycle", "point": point, "bin": "", "metric": name,
  116. "score": score, "raw_score": score, "split": split_time,
  117. "before": before, "after": after, "volatile": False,
  118. })
  119. records.sort(key=lambda r: -r["score"])
  120. print(f"候选 {len(records)} 条(分箱局部分数 >= {args.z},整周期 >= {args.z})")
  121. print(f"{'#':>3} {'家族':<6} {'测点':<9} {'角度箱':<8} {'指标':<13} {'分数':>6} {'突变时间':<17} {'前':>9} {'后':>9}")
  122. for i, r in enumerate(records[: args.top], 1):
  123. binst = f"{r['bin'] * 10:g}-{r['bin'] * 10 + 10:g}°" if r["family"] == "bin" else "整周期"
  124. near = min(EVENTS, key=lambda e: abs((datetime.fromisoformat(e[1]) - r["split"]).days))
  125. gap = abs((datetime.fromisoformat(near[1]) - r["split"]).days)
  126. print(f"{i:>3} {r['family']:<6} {r['point']:<9} {binst:<8} {r['metric']:<13} {r['score']:>6.1f} "
  127. f"{r['split']:%Y-%m-%d %H:%M} {r['before']:>9.4g} {r['after']:>9.4g} ~{near[0]}号{near[1]}({gap}天)")
  128. if args.out and records:
  129. out = Path(args.out)
  130. out.parent.mkdir(parents=True, exist_ok=True)
  131. with out.open("w", encoding="utf-8-sig", newline="") as handle:
  132. writer = csv.DictWriter(handle, fieldnames=["family", "point", "bin", "metric", "score", "raw_score", "split", "before", "after", "volatile"])
  133. writer.writeheader()
  134. for r in records:
  135. writer.writerow({**r, "split": r["split"].isoformat()})
  136. print(f"明细 -> {out}")
  137. print(f"时间:{datetime.now():%Y-%m-%d %H:%M:%S}")
  138. if __name__ == "__main__":
  139. main()