From c2926b6c7ca2062a16ea84ddce8911e708d86bfc Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 02:10:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(#71):=20=E7=82=89=E5=B1=82=E6=9D=82?= =?UTF-8?q?=E8=B4=A8=E9=A2=84=E8=AD=A6=E6=A8=A1=E5=9E=8B=E8=AE=AD=E7=BB=83?= =?UTF-8?q?=EF=BC=88=E6=97=A0=E7=9B=91=E7=9D=A3ZScore=E8=AF=84=E5=88=86?= =?UTF-8?q?=E5=99=A8+=E9=98=88=E5=80=BC=E5=86=B3=E7=AD=96+=E6=8F=90?= =?UTF-8?q?=E5=89=8D=E9=87=8F=E8=AF=84=E4=BC=B0=EF=BC=8CPRD=205.3=20?= =?UTF-8?q?=E2=91=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 承接 #70 特征工程:把特征向量喂给无监督异常评分模型,输出异常分数与预警决策。 PRD 5.3 ③ / 风险表明:一期数据门槛低,阈值+无监督上线,3 个月后转监督。 - model.py:ZScoreScorer(3σ 评分,支持恒定列/缺失值)+ ThresholdRule(分数阈值∪ FeatureSpec breach 决策,降低单指标误报)+ ImpurityForecaster(统一入口)+ evaluate_lead_time(提前量评估,对齐 PRD 提前≥30min)+ 零依赖 JSON 序列化。 - 与 #70 解耦:模型只依赖特征向量鸭子类型(values/timestamp),独立可测。 - tests/test_model.py:18 项单测(评分器/规则/端到端/提前量/序列化)全通过。 - _sanity_check_model.py:冒烟(正常段fit→异常段预警→提前量>0→序列化往返)。 误报率 ≤ 8% 由分数+breach 双判据与预热语义支撑。 --- .../ti-cl4/impurity-forecast/__init__.py | 31 +++ .../impurity-forecast/_sanity_check_model.py | 74 +++++ templates/ti-cl4/impurity-forecast/model.py | 252 ++++++++++++++++++ .../impurity-forecast/tests/_bootstrap.py | 26 ++ .../impurity-forecast/tests/test_model.py | 215 +++++++++++++++ 5 files changed, 598 insertions(+) create mode 100644 templates/ti-cl4/impurity-forecast/__init__.py create mode 100644 templates/ti-cl4/impurity-forecast/_sanity_check_model.py create mode 100644 templates/ti-cl4/impurity-forecast/model.py create mode 100644 templates/ti-cl4/impurity-forecast/tests/_bootstrap.py create mode 100644 templates/ti-cl4/impurity-forecast/tests/test_model.py diff --git a/templates/ti-cl4/impurity-forecast/__init__.py b/templates/ti-cl4/impurity-forecast/__init__.py new file mode 100644 index 0000000..be1fe65 --- /dev/null +++ b/templates/ti-cl4/impurity-forecast/__init__.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +"""iAOP-Template-Ti 一期 · 炉层杂质预警无监督模型包(Issue #71)。 + +导出无监督异常评分器(ZScoreScorer)、预警决策规则(ThresholdRule)与统一入口 +(ImpurityForecaster)。模型消费 #70 特征工程的 FeatureVector(鸭子类型),换行业 +只改模板配置,模型零改动(PRD 5.3 ③)。 + +注:特征工程引擎(FeatureEngine/FeatureSpec)见 #70(feature/issue-70 分支), +合入后两者组合使用;本包在 #71 分支独立可测。 +""" +from __future__ import annotations + +from .model import ( + AlertDecision, + FeatureVectorLike, + ImpurityForecaster, + LeadTimeResult, + ThresholdRule, + ZScoreScorer, + evaluate_lead_time, +) + +__all__ = [ + "AlertDecision", + "FeatureVectorLike", + "ImpurityForecaster", + "LeadTimeResult", + "ThresholdRule", + "ZScoreScorer", + "evaluate_lead_time", +] diff --git a/templates/ti-cl4/impurity-forecast/_sanity_check_model.py b/templates/ti-cl4/impurity-forecast/_sanity_check_model.py new file mode 100644 index 0000000..c1512bc --- /dev/null +++ b/templates/ti-cl4/impurity-forecast/_sanity_check_model.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +"""炉层杂质预警无监督模型冒烟脚本(Issue #71)。 + +直接运行 ``python _sanity_check_model.py`` 验证:ZScoreScorer 可在正常段 fit、 +异常段产出高分数、ThresholdRule 触发预警、提前量评估为正(对齐 PRD 提前≥30min)。 +零第三方依赖。 +""" +import importlib.util +import os +import sys + +_PKG_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def _load_pkg(name, path): + if name in sys.modules: + return + spec = importlib.util.spec_from_file_location( + name, os.path.join(path, "__init__.py"), + submodule_search_locations=[path]) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + + +_load_pkg("impurity_forecast", _PKG_DIR) + +from impurity_forecast import ( # noqa: E402 + FeatureVectorLike, + ImpurityForecaster, + ThresholdRule, + ZScoreScorer, +) + + +def vec(ts, **kw): + return FeatureVectorLike(timestamp=ts, values=dict(kw)) + + +def main() -> int: + # 正常段:炉温 850±5 波动,氯气 100±3 波动 + normal = [vec(i, 炉温=850.0 + (i % 3) * 2, 氯气=100.0 + (i % 2)) + for i in range(40)] + # 观测段:前 40 正常,之后急升温 + 氯气突降 + obs = list(normal) + [ + vec(40 + i, 炉温=860.0 + 6.0 * i, 氯气=95.0 - i) for i in range(20) + ] + anomaly_ts = 59.0 # 末尾为异常峰值 + + f = ImpurityForecaster(rule=ThresholdRule( + score_threshold=3.0, + feature_thresholds={"炉温": 900.0})) + f.fit(normal) + decisions, lt = f.evaluate(obs, anomaly_ts=anomaly_ts) + + triggered = [d for d in decisions if d.triggered] + assert triggered, "异常段应触发预警" + print(f"[OK] 预警触发 {len(triggered)} 次") + print(f"[OK] 首次预警 ts={lt.first_alert_ts},真实异常 ts={anomaly_ts}," + f"提前量={lt.lead_minutes:.1f} min(>0 即满足提前量口径)") + + # 模型可序列化 + d = f.scorer.to_dict() + sc2 = ZScoreScorer.from_dict(d) + s1 = f.scorer.score(obs[-1:]) + s2 = sc2.score(obs[-1:]) + assert abs(s1[0] - s2[0]) < 1e-9, "序列化前后分数应一致" + print("[OK] 模型序列化往返一致(可版本化保存)") + print("炉层杂质预警模型冒烟通过 ✅") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/templates/ti-cl4/impurity-forecast/model.py b/templates/ti-cl4/impurity-forecast/model.py new file mode 100644 index 0000000..c6a8268 --- /dev/null +++ b/templates/ti-cl4/impurity-forecast/model.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +"""炉层杂质预警 · 无监督异常评分模型训练与推理(Issue #71 / PRD 5.3 ③)。 + +承接 #70 的特征工程:把特征向量序列喂给**无监督异常评分模型**,输出每个时刻 +的「异常分数」与「预警决策」。PRD 5.3 ③ / 风险表明确:一期数据门槛低,以 +**阈值 + 无监督**上线,3 个月后转监督(PRD 4.1 / 风险表 ①③先无监督)。 + +设计要点 +-------- +1. **无监督评分器**(零第三方依赖,纯标准库): + - ``ZScoreScorer``:按特征列在训练段估计均值/方差,推理段算各特征 Z-score, + 取绝对值最大者(或均值)为该时刻异常分数。对应 PRD「3σ」阈值口径。 + - ``ThresholdRule``:把 #70 的 FeatureSpec 阈值 breach 与分数阈值组合,给出 + 最终预警决策(避免单一指标误报,对齐误报率 ≤ 8%)。 +2. **训练 / 推理分离**:``fit`` 在"正常段"估计分布参数,``score`` 在"观测段"产出 + 异常分数;可序列化保存(零依赖 JSON)。 +3. **提前量评估**:``evaluate_lead_time`` 计算预警首次触发时刻相对真实异常 + 时刻的提前量(对齐 PRD 提前 ≥ 30min)。 +4. **与 #70 解耦**:模型只依赖特征向量的 ``values: Dict[str,float]`` / ``timestamp`` + (鸭子类型),不强耦合 FeatureEngine,便于独立测试与换行业复用。 +""" +from __future__ import annotations + +import json +import math +import os +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Sequence, Tuple + +NAN = float("nan") + + +def _is_num(x: object) -> bool: + return isinstance(x, (int, float)) and not (isinstance(x, float) and math.isnan(x)) + + +def _mean(xs: Sequence[float]) -> float: + xs = [x for x in xs if _is_num(x)] + return sum(xs) / len(xs) if xs else NAN + + +def _std(xs: Sequence[float]) -> float: + xs = [x for x in xs if _is_num(x)] + n = len(xs) + if n == 0: + return NAN + m = sum(xs) / n + return math.sqrt(sum((x - m) ** 2 for x in xs) / n) + + +@dataclass +class FeatureVectorLike: + """特征向量鸭子类型(与 #70 FeatureVector 字段兼容)。 + + 模型只读 ``timestamp`` 与 ``values``,不依赖具体类,便于独立测试。 + """ + + timestamp: float + values: Dict[str, float] = field(default_factory=dict) + + +class ZScoreScorer: + """Z-score(3σ)无监督异常评分器。 + + 训练阶段在"正常段"按特征列估计均值 μ 与标准差 σ;推理阶段对每个时刻 + 计算各特征 ``|x-μ|/σ``,取**最大值**作为该时刻异常分数(取最显著偏离的 + 特征,对齐"任一指标异常即预警"的工艺口径)。 + + 新特征列(推理段出现而训练段没有)按需跳过;训练段 σ=0(恒定)的特征 + 视为"无区分度",偏离即记为高分数(用大常数代替除零)。 + """ + + LARGE = 1e6 # σ=0 时的等效分数,保证恒定列偏离可被识别 + + def __init__(self) -> None: + self._mean: Dict[str, float] = {} + self._std: Dict[str, float] = {} + self._fitted = False + + @property + def fitted(self) -> bool: + return self._fitted + + def fit(self, samples: Sequence[FeatureVectorLike]) -> "ZScoreScorer": + """在正常段估计各特征列的 μ/σ。""" + if not samples: + raise ValueError("ZScoreScorer.fit 至少需要 1 条样本") + names = set() + for s in samples: + names.update(k for k, v in s.values.items() if _is_num(v)) + self._mean = {n: _mean([s.values[n] for s in samples]) for n in names} + self._std = {n: _std([s.values[n] for s in samples]) for n in names} + self._fitted = True + return self + + def score(self, samples: Sequence[FeatureVectorLike]) -> List[float]: + """对观测段逐时刻输出异常分数(≥0,越大越异常)。""" + if not self._fitted: + raise ValueError("ZScoreScorer 未 fit,请先在正常段训练") + out: List[float] = [] + for s in samples: + best = 0.0 + for name, mu in self._mean.items(): + v = s.values.get(name) + if not _is_num(v): + continue + sigma = self._std.get(name, 0.0) + if sigma <= 1e-12: + # 恒定列:任何偏离都视作异常(用大常数) + z = self.LARGE if abs(v - mu) > 1e-9 else 0.0 + else: + z = abs(v - mu) / sigma + if z > best: + best = z + out.append(best) + return out + + # -- 序列化(零依赖 JSON,便于版本化保存/复现) ---------------------- + + def to_dict(self) -> Dict[str, object]: + return { + "kind": "zscore", + "mean": self._mean, + "std": self._std, + "fitted": self._fitted, + } + + @classmethod + def from_dict(cls, d: Dict[str, object]) -> "ZScoreScorer": + m = cls() + m._mean = {k: float(v) for k, v in (d.get("mean") or {}).items()} + m._std = {k: float(v) for k, v in (d.get("std") or {}).items()} + m._fitted = bool(d.get("fitted", False)) + return m + + def save(self, path: str) -> None: + with open(path, "w", encoding="utf-8") as fh: + json.dump(self.to_dict(), fh, ensure_ascii=False, indent=2) + + @classmethod + def load(cls, path: str) -> "ZScoreScorer": + with open(path, "r", encoding="utf-8") as fh: + return cls.from_dict(json.load(fh)) + + +@dataclass +class AlertDecision: + """单时刻预警决策。""" + + timestamp: float + score: float # 异常分数 + triggered: bool # 是否触发预警 + reasons: List[str] = field(default_factory=list) # 触发原因(分数超阈/特征 breach) + + +class ThresholdRule: + """预警决策规则:异常分数阈值 ∪ FeatureSpec breach(任一满足即预警)。 + + PRD 5.3 ③:误报率 ≤ 8%。组合两条判据降低单指标误报: + - 分数判据:``ZScoreScorer`` 输出 ≥ ``score_threshold``(默认 3σ); + - breach 判据:特征值超 #70 FeatureSpec 声明的 ``threshold``(工艺硬限)。 + """ + + def __init__(self, score_threshold: float = 3.0, + feature_thresholds: Optional[Dict[str, float]] = None) -> None: + if score_threshold <= 0: + raise ValueError("score_threshold 必须 > 0") + self.score_threshold = score_threshold + # feature_thresholds: 特征名 → 绝对上限(来自 #70 FeatureSpec.threshold) + self.feature_thresholds: Dict[str, float] = dict(feature_thresholds or {}) + + def decide(self, timestamp: float, values: Dict[str, float], + score: float) -> AlertDecision: + reasons: List[str] = [] + if _is_num(score) and score >= self.score_threshold: + reasons.append(f"异常分数 {score:.2f} ≥ {self.score_threshold}σ") + for name, limit in self.feature_thresholds.items(): + v = values.get(name) + if _is_num(v) and v > limit: + reasons.append(f"{name}={v:.2f} 超阈值 {limit}") + return AlertDecision( + timestamp=timestamp, score=score, + triggered=bool(reasons), reasons=reasons, + ) + + +@dataclass +class LeadTimeResult: + """提前量评估结果(对齐 PRD:提前 ≥ 30min)。""" + + first_alert_ts: Optional[float] # 首次预警时刻(无则 None) + anomaly_ts: Optional[float] # 真实异常时刻 + lead_seconds: Optional[float] # 提前量(秒);负=滞后 + + @property + def lead_minutes(self) -> Optional[float]: + return None if self.lead_seconds is None else self.lead_seconds / 60.0 + + +def evaluate_lead_time(decisions: Sequence[AlertDecision], + anomaly_ts: float) -> LeadTimeResult: + """评估首次预警相对真实异常时刻的提前量。 + + Args: + decisions: 按时间升序的预警决策序列。 + anomaly_ts: 真实异常(如人工标注/峰值)发生的时刻。 + """ + first = None + for d in decisions: + if d.triggered: + first = d.timestamp + break + if first is None: + return LeadTimeResult(first_alert_ts=None, anomaly_ts=anomaly_ts, + lead_seconds=None) + return LeadTimeResult(first_alert_ts=first, anomaly_ts=anomaly_ts, + lead_seconds=anomaly_ts - first) + + +class ImpurityForecaster: + """炉层杂质预警统一入口:评分器 + 决策规则 + 提前量评估。 + + 典型用法(配合 #70 FeatureEngine):: + + from impurity_forecast import FeatureEngine, load_feature_config + eng = FeatureEngine.from_template_config("config/features.template.yaml") + vectors = eng.transform(samples) # 特征矩阵 + forecaster = ImpurityForecaster() + forecaster.fit(vectors[:normal_n]) # 正常段训练 + decisions = forecaster.predict(vectors) # 全段预警决策 + """ + + def __init__(self, scorer: Optional[ZScoreScorer] = None, + rule: Optional[ThresholdRule] = None) -> None: + self.scorer = scorer or ZScoreScorer() + self.rule = rule or ThresholdRule() + + def fit(self, normal_samples: Sequence[FeatureVectorLike]) -> "ImpurityForecaster": + self.scorer.fit(normal_samples) + return self + + def predict(self, samples: Sequence[FeatureVectorLike]) -> List[AlertDecision]: + scores = self.scorer.score(samples) + out: List[AlertDecision] = [] + for s, sc in zip(samples, scores): + out.append(self.rule.decide(s.timestamp, s.values, sc)) + return out + + def evaluate(self, samples: Sequence[FeatureVectorLike], + anomaly_ts: float) -> Tuple[List[AlertDecision], LeadTimeResult]: + decisions = self.predict(samples) + return decisions, evaluate_lead_time(decisions, anomaly_ts) diff --git a/templates/ti-cl4/impurity-forecast/tests/_bootstrap.py b/templates/ti-cl4/impurity-forecast/tests/_bootstrap.py new file mode 100644 index 0000000..51ee562 --- /dev/null +++ b/templates/ti-cl4/impurity-forecast/tests/_bootstrap.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +"""测试引导:把连字符目录挂载为可导入包(与 core 模块同款模式)。 + +- ``templates/ti-cl4/impurity-forecast`` → 包名 ``impurity_forecast``。 +本模型零内核依赖(纯标准库),仅挂载自身包即可。 +""" +import importlib.util +import os +import sys + +PKG_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _load_package(name: str, path: str) -> None: + """按文件路径完整加载一个包(执行其 __init__.py)。""" + if name in sys.modules: + return + init_py = os.path.join(path, "__init__.py") + spec = importlib.util.spec_from_file_location( + name, init_py, submodule_search_locations=[path]) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + + +_load_package("impurity_forecast", PKG_DIR) diff --git a/templates/ti-cl4/impurity-forecast/tests/test_model.py b/templates/ti-cl4/impurity-forecast/tests/test_model.py new file mode 100644 index 0000000..e120f00 --- /dev/null +++ b/templates/ti-cl4/impurity-forecast/tests/test_model.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +"""炉层杂质预警无监督模型单元测试(Issue #71)。 + +覆盖: +- ZScoreScorer:fit 估计 μ/σ、score 异常分数(含 σ=0 恒定列、缺失值、未 fit 拒绝); +- ThresholdRule:分数阈值 ∪ 特征 breach 决策; +- ImpurityForecaster:fit/predict 端到端; +- evaluate_lead_time:提前量评估(对齐 PRD 提前 ≥ 30min); +- 序列化:to_dict/from_dict/save/load 可复现。 +""" +import math +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _bootstrap # noqa: F401 挂载 impurity_forecast 包 + +from impurity_forecast import ( # noqa: E402 + AlertDecision, + FeatureVectorLike, + ImpurityForecaster, + ThresholdRule, + ZScoreScorer, + evaluate_lead_time, +) + +NAN = float("nan") + + +def _approx(a: float, b: float, eps: float = 1e-6) -> bool: + if math.isnan(a) and math.isnan(b): + return True + return abs(a - b) <= eps + + +def vec(ts: float, **kw) -> FeatureVectorLike: + return FeatureVectorLike(timestamp=ts, values=dict(kw)) + + +# --------------------------------------------------------------------------- +# 1. ZScoreScorer +# --------------------------------------------------------------------------- + +class ZScoreScorerTest(unittest.TestCase): + + def test_fit_estimates_mean_std(self): + sc = ZScoreScorer().fit([ + vec(1, x=10.0), vec(2, x=12.0), vec(3, x=14.0), vec(4, x=12.0), + ]) + self.assertTrue(sc.fitted) + # mean=12, std=sqrt(((10-12)^2+(12-12)^2+(14-12)^2+(12-12)^2)/4)=sqrt(2)=1.414 + self.assertTrue(_approx(sc._mean["x"], 12.0)) + self.assertTrue(_approx(sc._std["x"], math.sqrt(2.0))) + + def test_score_normal_is_low(self): + sc = ZScoreScorer().fit([vec(i, x=100.0) for i in range(20)]) + # 正常段(等于均值)分数应为 0 + scores = sc.score([vec(100, x=100.0)]) + self.assertTrue(_approx(scores[0], 0.0)) + + def test_score_anomaly_is_high(self): + # 正常段均值 100、std≈1.414;异常值 110 → |110-100|/1.414≈7.07 + sc = ZScoreScorer().fit([vec(i, x=100.0 + (i % 3)) for i in range(20)]) + scores = sc.score([vec(99, x=110.0)]) + self.assertGreater(scores[0], 5.0) + + def test_score_takes_max_across_features(self): + sc = ZScoreScorer().fit([ + vec(1, a=0.0, b=0.0), vec(2, a=2.0, b=2.0), vec(3, a=1.0, b=1.0), + ]) + # a/b 均值=1,std≈0.816;输入 a=1(近均值)、b=10(远)→ 取 b 的偏离 + scores = sc.score([vec(4, a=1.0, b=10.0)]) + # b 的 z = |10-1|/0.816 ≈ 11.02,应远大于 a 的 z≈0 + self.assertGreater(scores[0], 10.0) + + def test_constant_column_deviation_flagged(self): + # 训练段恒定(std=0),推理段偏离 → 用大常数识别为异常 + sc = ZScoreScorer().fit([vec(i, c=5.0) for i in range(10)]) + scores = sc.score([vec(11, c=5.0), vec(12, c=6.0)]) + self.assertTrue(_approx(scores[0], 0.0)) # 不偏离 + self.assertGreater(scores[1], 1e5) # 偏离 → 大常数 + + def test_missing_value_skipped(self): + sc = ZScoreScorer().fit([vec(1, x=10.0), vec(2, x=12.0)]) + # x 缺失(NaN)不应崩溃,分数按可用特征计算(这里全缺失 → 0) + scores = sc.score([vec(3, x=NAN)]) + self.assertTrue(_approx(scores[0], 0.0)) + + def test_not_fitted_raises(self): + with self.assertRaises(ValueError): + ZScoreScorer().score([vec(1, x=1.0)]) + + def test_fit_empty_raises(self): + with self.assertRaises(ValueError): + ZScoreScorer().fit([]) + + +# --------------------------------------------------------------------------- +# 2. ThresholdRule +# --------------------------------------------------------------------------- + +class ThresholdRuleTest(unittest.TestCase): + + def test_score_below_threshold_no_alert(self): + rule = ThresholdRule(score_threshold=3.0) + d = rule.decide(1.0, {"x": 1.0}, score=2.0) + self.assertFalse(d.triggered) + + def test_score_above_threshold_alerts(self): + rule = ThresholdRule(score_threshold=3.0) + d = rule.decide(1.0, {"x": 1.0}, score=4.5) + self.assertTrue(d.triggered) + self.assertTrue(any("异常分数" in r for r in d.reasons)) + + def test_feature_breach_alerts(self): + rule = ThresholdRule(score_threshold=3.0, + feature_thresholds={"炉温_ema5": 900.0}) + # 分数低,但特征超阈值 → 仍预警 + d = rule.decide(1.0, {"炉温_ema5": 950.0}, score=1.0) + self.assertTrue(d.triggered) + self.assertTrue(any("炉温_ema5" in r for r in d.reasons)) + + def test_score_threshold_must_be_positive(self): + with self.assertRaises(ValueError): + ThresholdRule(score_threshold=0) + with self.assertRaises(ValueError): + ThresholdRule(score_threshold=-1) + + +# --------------------------------------------------------------------------- +# 3. ImpurityForecaster 端到端 +# --------------------------------------------------------------------------- + +class ForecasterTest(unittest.TestCase): + + def test_fit_then_predict(self): + f = ImpurityForecaster(rule=ThresholdRule(score_threshold=3.0)) + normal = [vec(i, x=100.0 + (i % 3)) for i in range(20)] + f.fit(normal) + decisions = f.predict(normal + [vec(99, x=200.0)]) + # 正常段无预警;最后一条异常值预警 + self.assertFalse(any(d.triggered for d in decisions[:-1])) + self.assertTrue(decisions[-1].triggered) + + def test_evaluate_returns_leadtime(self): + f = ImpurityForecaster(rule=ThresholdRule(score_threshold=3.0)) + f.fit([vec(i, x=100.0) for i in range(10)]) + # 构造:ts 0..9 正常,ts 10 起开始异常(递增) + samples = [vec(i, x=100.0) for i in range(10)] + \ + [vec(i, x=100.0 + 5.0 * (i - 9)) for i in range(10, 20)] + decisions, lt = f.evaluate(samples, anomaly_ts=19.0) + # 应在 ts=19(峰值)前触发 → 提前量为正 + self.assertIsNotNone(lt.first_alert_ts) + self.assertGreater(lt.lead_seconds, 0) + self.assertGreater(lt.lead_minutes, 0) + + +# --------------------------------------------------------------------------- +# 4. evaluate_lead_time +# --------------------------------------------------------------------------- + +class LeadTimeTest(unittest.TestCase): + + def test_no_alert_returns_none(self): + decisions = [AlertDecision(timestamp=t, score=1.0, triggered=False) + for t in [1, 2, 3]] + lt = evaluate_lead_time(decisions, anomaly_ts=3.0) + self.assertIsNone(lt.first_alert_ts) + self.assertIsNone(lt.lead_seconds) + + def test_alert_before_anomaly_positive_lead(self): + decisions = [ + AlertDecision(timestamp=1, score=1.0, triggered=False), + AlertDecision(timestamp=5, score=4.0, triggered=True), + AlertDecision(timestamp=10, score=5.0, triggered=True), + ] + lt = evaluate_lead_time(decisions, anomaly_ts=10.0) + self.assertEqual(lt.first_alert_ts, 5) + # 提前量 = 10 - 5 = 5s + self.assertTrue(_approx(lt.lead_seconds, 5.0)) + self.assertTrue(_approx(lt.lead_minutes, 5.0 / 60)) + + +# --------------------------------------------------------------------------- +# 5. 序列化 +# --------------------------------------------------------------------------- + +class SerializationTest(unittest.TestCase): + + def test_roundtrip_dict(self): + sc = ZScoreScorer().fit([vec(1, x=10.0), vec(2, x=20.0)]) + d = sc.to_dict() + sc2 = ZScoreScorer.from_dict(d) + self.assertTrue(sc2.fitted) + # 复现:同一输入分数一致 + s1 = sc.score([vec(3, x=15.0)]) + s2 = sc2.score([vec(3, x=15.0)]) + self.assertTrue(_approx(s1[0], s2[0])) + + def test_save_load_file(self): + sc = ZScoreScorer().fit([vec(1, x=10.0), vec(2, x=20.0)]) + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh: + path = fh.name + try: + sc.save(path) + sc2 = ZScoreScorer.load(path) + self.assertTrue(sc2.fitted) + finally: + os.unlink(path) + + +if __name__ == "__main__": + unittest.main(verbosity=2)