stat_clean.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. # -*- coding: utf-8 -*-
  2. """stat_clean.py — 统计清洗层 (opt-in; CL-1 契约闸之上的第二道, 2026-07-11 并回).
  3. 来源: gym CLEAN 赛道 C1-C4 四轮训练的池首组合 full3 (hampel + frozen + scale2) + 备胎 scale(v1)
  4. 的生产化移植 — 预注册 EV: EXP-C1-01/C2-01/C3-01/C4-01; 冻结套件 clean_frozen_v0/v1;
  5. 三轮独立 lite 审 (审计留痕 outputs/_genebank/审计_2026-07-11_lite.md + EXP 追加区)。
  6. vendored 自 gym/clean_algos/statpack.py (src 不依赖 gym; 数值语义逐位对齐, 见 DP 测试)。
  7. 纪律 (硬编码非文档):
  8. - **mark-not-delete**: 只置 NaN, 不删行, 不改好值; 带内持续漂移 (W091 +2.1℃ 型故障前兆) 结构上
  9. 不吃 — 属诊断域 (温度线/NBM), 清洗层保留 (EXP-C2/C3 preserve gate ≥0.98 实证, 幅度无关)。
  10. - **效力域守卫** (每臂的"在哪灵/在哪反"来自 gym 机器记录):
  11. · hampel: 带内单点尖刺; σ floor 防量化死区。
  12. · frozen: exact-repeat 卡滞 (含 exact-0 零膨胀); 量化粗通道会假冻结 (CNV 照妖镜已验 ydk=0)。
  13. · scale(v1, 自身尾随): 块长≲尾随窗/2; 永久型漏 (0.046); **共模免疫** (参照自身)。
  14. · scale2 (fleet 中位): 长块/永久全捕 (0.992/0.996); 需兄弟机 ≥min_fleet_n;
  15. **>半数同向共模 = 反转 (坏台全逃+好台全杀, EXP-C4-01)** → 由 kill 熔断兜底。
  16. - **kill 熔断 (C4 教训生产化)**: 任一通道 × 滚动 30d 标记率 > fuse_cap (默认 0.10) →
  17. 该通道本次**拒清洗** (零置 NaN) + 响亮进 report — 宁漏不屠 (生产无 truth mask,
  18. 熔断是反转域的唯一防线)。
  19. - **opt-in**: 不接入 apply_contract_gate 默认路径; 上游应已过 CL-1 (契约 sentinel/valid_range)。
  20. - **channels 选择责任 (EXP-C5-01 实证)**: frozen/hampel 语义只适用**连续物理传感通道** (温度/风速类);
  21. 功率/状态码/设定值类有物理合法恒值与阶跃 (停机恒功率被 frozen 吃 = mima 实测 conc 0.29) — 勿入
  22. channels。恒值合法性本质是契约属性, 通道级语义域收紧在 C5-02 排期; 当前由调用方按此纪律选通道。
  23. - **混合病理日 (EXP-C6-01 逮 → C6-02 已修)**: scale2 日判据 中位→**占比** (q=0.30) + 孤立混合日
  24. min_days=1 + 极端格连段 ≥1h 直捕 (带日合格 ≥24 守卫) — 混族块残余 100%→带隙级 (gym 实测)。
  25. **残余 known-limit (带隙)**: 3-5 格的 5× 连段落在 hampel (1-2 格) 与极端连段 (≥6 格) 之间 —
  26. E2E 实测可致下游 z 偏 ~0.6σ (EXP-C6-02 s1); 多病并发通道建议 scale2+frozen+hampel 全开并读 report。
  27. 接口:
  28. from src.sop.stat_clean import stat_clean
  29. df_clean, report = stat_clean(df, channels=[...], time_col="时间", turbine_col="风机名称")
  30. report: per 通道 {flags_by_arm, coverage(scale2), fuse_tripped, n_marked}; 顶层 totals + params。
  31. """
  32. from __future__ import annotations
  33. import numpy as np
  34. import pandas as pd
  35. DEFAULTS = dict(
  36. hampel_window=13, hampel_k=6.0, hampel_sigma_floor=0.5,
  37. frozen_min_run=12,
  38. scale_log_thresh=0.7, scale_min_days=2, scale_trailing_days=30,
  39. scale_min_trailing_level=5.0, scale2_min_fleet_n=5,
  40. scale2_day_frac_q=0.30, scale2_min_cells_day=24, scale2_min_days=1,
  41. use_hampel=True, use_frozen=True, use_scale2=True, use_scale_v1=False,
  42. fuse_cap=0.10, fuse_window_days=30, fuse_min_days=7,
  43. )
  44. def _hampel_flags(s: pd.Series, window: int, k: float, floor: float) -> np.ndarray:
  45. med = s.rolling(window, center=True, min_periods=5).median()
  46. mad = (s - med).abs().rolling(window, center=True, min_periods=5).median()
  47. sigma = (1.4826 * mad).clip(lower=floor)
  48. return ((s - med).abs() > k * sigma).to_numpy() & s.notna().to_numpy()
  49. def _frozen_flags(s: pd.Series, min_run: int) -> np.ndarray:
  50. grp = (s != s.shift()).cumsum()
  51. run_len = s.groupby(grp).transform("size")
  52. return (run_len >= min_run).to_numpy() & s.notna().to_numpy()
  53. def _cell_ratio_gate(s: pd.Series, ref: np.ndarray, log_thresh: float) -> np.ndarray:
  54. """日级标记内的格级比值闸 (EXP-C5-03 修2, 与 gym statpack 镜像): 边界日正常格释放。"""
  55. with np.errstate(divide="ignore", invalid="ignore"):
  56. r = np.abs(np.log10(np.where((ref > 0) & (s.to_numpy() > 0), s.to_numpy() / ref, np.nan)))
  57. return np.nan_to_num(r, nan=0.0) >= log_thresh
  58. def _trail_two_pass(dm: pd.Series, trailing_days: int, min_trailing_level: float,
  59. log_thresh: float) -> pd.Series:
  60. """两遍尾随 (EXP-C5-03 修2b, gym 镜像): pass1 粗筛异常日, pass2 参照排除之。"""
  61. mp = max(5, trailing_days // 3)
  62. trail1 = dm.rolling(trailing_days, min_periods=mp).median().shift(1)
  63. r1 = np.abs(np.log10((dm / trail1).where((trail1 >= min_trailing_level) & (dm > 0))))
  64. dm_clean = dm.where(~(r1 >= log_thresh))
  65. return dm_clean.rolling(trailing_days, min_periods=mp).median().shift(1)
  66. def _scale_v1_flags(s: pd.Series, days: pd.Series, log_thresh: float, min_days: int,
  67. trailing_days: int, min_trailing_level: float) -> np.ndarray:
  68. dm = s.groupby(days.values).median()
  69. trail = _trail_two_pass(dm, trailing_days, min_trailing_level, log_thresh)
  70. ratio = dm / trail
  71. bad_day = (np.abs(np.log10(ratio.where((trail >= min_trailing_level) & (dm > 0)))) >= log_thresh)
  72. bad_day = bad_day.fillna(False)
  73. grp = (bad_day != bad_day.shift()).cumsum()
  74. run = bad_day.groupby(grp).transform("sum")
  75. bad_day = bad_day & (run >= min_days)
  76. day_flag = days.map(bad_day).fillna(False).to_numpy() & s.notna().to_numpy()
  77. cell_ref = days.map(trail).to_numpy(dtype="float64")
  78. return day_flag & _cell_ratio_gate(s, cell_ref, log_thresh)
  79. def _scale2_flags(d: pd.DataFrame, ch: str, time_col: str, turbine_col: str,
  80. log_thresh: float, min_days: int, min_level: float,
  81. min_fleet_n: int, day_frac_q: float = 0.30,
  82. min_cells_day: int = 24) -> tuple[np.ndarray, float]:
  83. s = pd.to_numeric(d[ch], errors="coerce")
  84. g = s.groupby(d[time_col].values)
  85. fm = g.transform("median")
  86. fn = g.transform("count")
  87. ok = (fm >= min_level) & (fn >= min_fleet_n) & (s > 0)
  88. logr = pd.Series(np.where(ok, np.log10(s / fm), np.nan), index=d.index)
  89. day = d[time_col].dt.floor("D")
  90. key = pd.MultiIndex.from_arrays([d[turbine_col].values, day.values])
  91. # EXP-C6-02 根修 (gym 镜像): 日入围 中位→占比, 去"当日同质"假设 (混合病理日稀释洞)
  92. grp_keys = [d[turbine_col].values, day.values]
  93. bad_cell = (logr.abs() >= log_thresh)
  94. elig = logr.notna()
  95. frac = bad_cell.groupby(grp_keys).sum() / elig.groupby(grp_keys).sum().clip(lower=1)
  96. n_elig = elig.groupby(grp_keys).sum()
  97. bad_day = ((frac >= day_frac_q) & (n_elig >= min_cells_day)).fillna(False)
  98. bd = bad_day.reset_index()
  99. bd.columns = ["tid", "day", "bad"]
  100. bd = bd.sort_values(["tid", "day"])
  101. grp = (bd["bad"] != bd.groupby("tid")["bad"].shift()).cumsum()
  102. run = bd.groupby(["tid", grp])["bad"].transform("sum")
  103. bd["bad2"] = bd["bad"] & (run >= min_days)
  104. lookup = bd.set_index(["tid", "day"])["bad2"]
  105. flags = lookup.reindex(key).fillna(False).to_numpy()
  106. finite = s.notna().to_numpy()
  107. coverage = float(ok.sum() / max(int(finite.sum()), 1))
  108. flags = flags & _cell_ratio_gate(s, fm.to_numpy(dtype="float64"), log_thresh)
  109. # 修E (EXP-C6-02, gym 镜像): 极端格连段 ≥6 (1h) 直接标 — 捕块边缘碎片; 守卫=当日合格格 ≥min_cells_day
  110. ext = bad_cell.fillna(False)
  111. run_id = pd.Series(ext.to_numpy(), index=d.index).groupby(
  112. d[turbine_col].values).transform(lambda x: (x != x.shift()).cumsum())
  113. run_len = ext.groupby([d[turbine_col].values, run_id.to_numpy()]).transform("size")
  114. day_ok = n_elig.reindex(key).fillna(0).to_numpy() >= min_cells_day
  115. flags |= (ext & (run_len >= 6)).to_numpy() & day_ok
  116. return flags & finite, coverage
  117. def _fuse_check(flags: np.ndarray, finite: np.ndarray, day: np.ndarray,
  118. cap: float, window_days: int, min_days: int) -> tuple[bool, float, bool]:
  119. """滚动窗标记率 max > cap → 熔断 (C4: 集中屠杀防线; 全域均值会稀释, 必须用集中度)。
  120. 返回 (tripped, conc_max, evaluable)。终审 F-F1 修: 数据 < min_days 天时滚动窗不可评估 —
  121. "无法评估 ≠ 无风险": 此时只要有任何标记即视同熔断 (宁漏不屠), evaluable=False 响亮出 report。"""
  122. s_k = pd.Series(flags.astype("int64")).groupby(day).sum()
  123. s_n = pd.Series(finite.astype("int64")).groupby(day).sum()
  124. roll = (s_k.rolling(window_days, min_periods=min_days).sum()
  125. / s_n.rolling(window_days, min_periods=min_days).sum().clip(lower=1))
  126. if not roll.notna().any():
  127. return bool(flags.any()), float("nan"), False
  128. conc = float(roll.max())
  129. return conc > cap, conc, True
  130. _POS = "_stat_clean_pos__"
  131. # 修1b (EXP-C5-03): +转速类 — 双稳态 regime-switching 通道, hampel/frozen 语义不适用 (镜像 gym statpack)
  132. _NON_CONTINUOUS_RX = r"功率|power|状态|state|mode|setpoint|设定|指令|码|counter|电量|发电量|转速|rpm|rotor.?speed|gen(erator)?.?speed"
  133. def stat_clean(df: pd.DataFrame, channels: list[str], time_col: str, turbine_col: str,
  134. params: dict | None = None) -> tuple[pd.DataFrame, dict]:
  135. """统计清洗 (mark-not-delete)。返回 (df_clean, report); **行数/行序/index 逐位不变**
  136. (终审 F-F2/F-F3 修: 位置法还原, 对重复/乱序 index 均成立)。"""
  137. import re as _re
  138. p = {**DEFAULTS, **(params or {})}
  139. # F-F4 临时闸 (C5-02 契约语义域落地前): 非连续物理通道 (恒值/阶跃合法) 须显式豁免
  140. if not p.get("allow_non_continuous"):
  141. bad = [c for c in channels if _re.search(_NON_CONTINUOUS_RX, str(c), _re.I)]
  142. if bad:
  143. raise ValueError(
  144. f"channels 含非连续物理通道 {bad}: 功率/状态/设定类恒值合法, frozen/hampel 语义不适用 "
  145. "(EXP-C5-01 缺陷①: 停机恒功率被吃)。确需处理传 params={'allow_non_continuous': True}")
  146. assert _POS not in df.columns, f"保留列名冲突: {_POS}"
  147. d = df.copy()
  148. if not np.issubdtype(d[time_col].dtype, np.datetime64):
  149. d[time_col] = pd.to_datetime(d[time_col])
  150. d[_POS] = np.arange(len(d))
  151. d = d.sort_values([turbine_col, time_col], kind="mergesort")
  152. inv = np.argsort(d[_POS].to_numpy(), kind="stable") # 位置逆排 → 输入行序精确还原
  153. day_arr = d[time_col].dt.floor("D").values
  154. days_ser = d[time_col].dt.floor("D")
  155. report = {"params": {k: v for k, v in p.items()}, "channels": {}, "discipline":
  156. "mark-not-delete; 熔断=宁漏不屠 (>半数共模反转防线, EXP-C4-01); opt-in 于 CL-1 之上"}
  157. for ch in channels:
  158. if ch not in d.columns:
  159. report["channels"][ch] = {"error": "column missing"}
  160. continue
  161. col = pd.to_numeric(d[ch], errors="coerce")
  162. finite = col.notna().to_numpy()
  163. flags = np.zeros(len(d), dtype=bool)
  164. by_arm, coverage = {}, None
  165. for _, idx in d.groupby(turbine_col, sort=False).indices.items():
  166. s = col.iloc[idx]
  167. if p["use_hampel"]:
  168. f = _hampel_flags(s, p["hampel_window"], p["hampel_k"], p["hampel_sigma_floor"])
  169. flags[idx] |= f
  170. by_arm["hampel"] = by_arm.get("hampel", 0) + int(f.sum())
  171. if p["use_frozen"]:
  172. f = _frozen_flags(s, p["frozen_min_run"])
  173. flags[idx] |= f
  174. by_arm["frozen"] = by_arm.get("frozen", 0) + int(f.sum())
  175. if p["use_scale_v1"]:
  176. f = _scale_v1_flags(s, days_ser.iloc[idx], p["scale_log_thresh"],
  177. p["scale_min_days"], p["scale_trailing_days"],
  178. p["scale_min_trailing_level"])
  179. flags[idx] |= f
  180. by_arm["scale_v1"] = by_arm.get("scale_v1", 0) + int(f.sum())
  181. if p["use_scale2"]:
  182. dd = d.copy()
  183. dd[ch] = col
  184. f, coverage = _scale2_flags(dd, ch, time_col, turbine_col, p["scale_log_thresh"],
  185. p["scale2_min_days"], p["scale_min_trailing_level"],
  186. p["scale2_min_fleet_n"],
  187. day_frac_q=p["scale2_day_frac_q"],
  188. min_cells_day=p["scale2_min_cells_day"])
  189. flags |= f
  190. by_arm["scale2"] = int(f.sum())
  191. n_coerced = int((col.isna() & pd.notna(d[ch])).sum()) # to_numeric 强转损失, 响亮记账 (F-F5⑤)
  192. tripped, conc, evaluable = _fuse_check(flags, finite, day_arr, p["fuse_cap"],
  193. p["fuse_window_days"], p["fuse_min_days"])
  194. if tripped:
  195. report["channels"][ch] = {
  196. "fuse_tripped": True, "fuse_evaluable": evaluable,
  197. "conc_max": (round(conc, 4) if evaluable else None),
  198. "flags_by_arm": by_arm, "n_marked": 0, "coverage": coverage,
  199. "n_coerced_kept_raw": n_coerced,
  200. "note": ("熔断: 滚动窗标记率超 fuse_cap → 本通道拒清洗 (疑共模反转/参数失配), 人工复核"
  201. if evaluable else
  202. "熔断(不可评估): 数据 < fuse_min_days 天, 集中度无法评估且存在标记 → 拒清洗 (宁漏不屠, F-F1)")}
  203. continue
  204. d[ch] = col.where(~flags)
  205. report["channels"][ch] = {"fuse_tripped": False, "fuse_evaluable": evaluable,
  206. "conc_max": round(conc, 4),
  207. "flags_by_arm": by_arm, "n_marked": int(flags.sum()),
  208. "coverage": coverage, "n_coerced_to_nan": n_coerced}
  209. report["total_marked"] = int(sum(c.get("n_marked", 0) for c in report["channels"].values()))
  210. d = d.iloc[inv].drop(columns=[_POS]) # F-F2/F-F3: 位置法还原 — 行数/行序/index 逐位=输入
  211. return d, report