新增 templates/ti-cl4/quality-forecast/serve.py: - PredictionService:特征抽取→模型预测→告警判定全链路,部署前 validate - Thresholds/AlarmLevel:纯度min/杂质max 双向阈值,normal/warning/critical 三级 - CockpitView:渲染 kpi_card+alarm_panel+explanation_list(对齐 cockpit-layout-v1) 含 top-3 特征贡献溯源,满足 PRD 可解释可溯源要求 - DriftMonitor:累计 R²/MAE,超阈值触发重训信号(PRD §5.3/§9) - DeploymentBundle:模型+特征清单+阈值+置信带整体序列化往返 - config/thresholds.template.yaml:TiCl₄纯度下限 99.2 阈值模板 - 14 用例(累计 53 用例)全通过;纯标准库零运行时依赖。
345 lines
14 KiB
Python
345 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Ti-1 氯化车间质量预测 · 模型部署到内核并接入驾驶舱(Issue #73 / PRD §5.3 ① / §5.5)。
|
||
|
||
承接 #69(模型训练)产出的 ``QualityModel``:把"训练好的模型 → 可调用的预测服务
|
||
→ 驾驶舱可展示的预测+可解释结果"这条链路**模板化、可测试**。
|
||
|
||
PRD 设计口径
|
||
------------
|
||
- 架构表(PRD §5.3):``质量预测 | 预测 | 入:DCS实时数据+LIMS;
|
||
出:质量指标预测值(纯度/杂质) | ① 质量预测 | 中``。
|
||
- 驾驶舱接入(PRD §5.5):预测结果以 kpi_card / alarm_panel 形式渲染,
|
||
支持按模板渲染四状态流程视图;移动端交接班摘要引用预测结论。
|
||
- 模型漂移(PRD §5.3 / §9 NFR):质保期监测准确率/误报率,触发重训。
|
||
|
||
本模块交付
|
||
----------
|
||
1. **预测服务 ``PredictionService``**:加载(或内存持有)QualityModel + 特征清单,
|
||
``predict(samples)`` 返回 ``Prediction``(预测值 + 置信区间 + 命中阈值判定)。
|
||
2. **阈值告警 ``QualityAlarm``**:按超参包的 ``thresholds``(纯度下限/杂质上限)
|
||
判定告警等级(normal/warning/critical),供驾驶舱 alarm_panel。
|
||
3. **驾驶舱视图 ``CockpitView``**:把预测结果 + explain() 渲染为驾驶舱布局
|
||
片段(kpi_card / alarm_panel / 可解释溯源列表),对齐 iAOP-cockpit-layout-v1。
|
||
4. **模型漂移监测 ``DriftMonitor``**:累计预测的 R²/MAE,超阈值触发重训信号。
|
||
|
||
设计要点
|
||
--------
|
||
- **零运行时依赖**(纯标准库):与 #68/#69 一致。
|
||
- **可解释接入驾驶舱**:预测结果附带 top-N 特征贡献(来自 #69 explain),
|
||
满足 PRD"要求结果可解释、可溯源,要引用依据"。
|
||
- **可校验**:部署前 ``PredictionService.validate`` 检查模型已训练、特征清单
|
||
与模型特征名对齐。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import math
|
||
import os
|
||
from dataclasses import dataclass, field
|
||
from enum import Enum
|
||
from typing import Any, Dict, List, Optional, Sequence
|
||
|
||
from .features import FeatureExtractor, FeatureMatrix, FeatureSpec, Sample
|
||
from .model import Evaluation, ModelRecipe, QualityModel
|
||
|
||
NAN = float("nan")
|
||
|
||
|
||
class ServeError(ValueError):
|
||
"""模型部署/预测服务错误。"""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 告警等级
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class AlarmLevel(str, Enum):
|
||
NORMAL = "normal" # 正常
|
||
WARNING = "warning" # 预警(接近阈值)
|
||
CRITICAL = "critical" # 超限(质量不达标)
|
||
|
||
|
||
@dataclass
|
||
class Thresholds:
|
||
"""质量阈值(超参包外置)。"""
|
||
|
||
target: str
|
||
min_value: Optional[float] = None # 纯度下限(低于则告警)
|
||
max_value: Optional[float] = None # 杂质上限(高于则告警)
|
||
warning_band: float = 0.0 # 预警带(距阈值多少开始 warning)
|
||
|
||
@classmethod
|
||
def from_dict(cls, d: Dict[str, Any]) -> "Thresholds":
|
||
return cls(
|
||
target=str(d.get("target", "")).strip(),
|
||
min_value=(float(d["min"]) if d.get("min") is not None else None),
|
||
max_value=(float(d["max"]) if d.get("max") is not None else None),
|
||
warning_band=float(d.get("warning_band", 0.0)),
|
||
)
|
||
|
||
def validate(self) -> List[str]:
|
||
errs = []
|
||
if not self.target:
|
||
errs.append("thresholds.target 不能为空")
|
||
if self.min_value is None and self.max_value is None:
|
||
errs.append("thresholds 至少需 min 或 max 之一")
|
||
return errs
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 预测结果
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass
|
||
class Prediction:
|
||
"""单条预测结果。"""
|
||
|
||
target: str
|
||
value: float
|
||
unit: str
|
||
level: str # AlarmLevel 值
|
||
message: str
|
||
confidence_band: float # ±置信带(基于训练 RMSE)
|
||
explanation: List[Dict[str, Any]] = field(default_factory=list)
|
||
timestamp: float = 0.0
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"target": self.target, "value": round(self.value, 4),
|
||
"unit": self.unit, "level": self.level, "message": self.message,
|
||
"confidence_band": round(self.confidence_band, 4),
|
||
"lower": (round(self.value - self.confidence_band, 4)
|
||
if not math.isnan(self.value) else None),
|
||
"upper": (round(self.value + self.confidence_band, 4)
|
||
if not math.isnan(self.value) else None),
|
||
"explanation": self.explanation,
|
||
"timestamp": self.timestamp,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 预测服务
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class PredictionService:
|
||
"""质量预测部署服务:特征抽取 + 模型预测 + 告警判定。"""
|
||
|
||
def __init__(self, model: QualityModel, extractor: FeatureExtractor,
|
||
thresholds: Optional[Thresholds] = None,
|
||
*, confidence_rmse: float = 0.0):
|
||
self.model = model
|
||
self.extractor = extractor
|
||
self.thresholds = thresholds
|
||
self.confidence_rmse = confidence_rmse # 训练 RMSE,作为置信带
|
||
self.validate()
|
||
|
||
def validate(self) -> None:
|
||
errs: List[str] = []
|
||
if not self.model.fitted:
|
||
errs.append("模型未训练,无法部署")
|
||
# 特征清单与模型特征名对齐
|
||
model_names = self.model.recipe.feature_names
|
||
ext_names = self.extractor.names
|
||
if model_names and model_names != ext_names:
|
||
errs.append(
|
||
f"特征清单与模型特征名不一致: 清单={ext_names} 模型={model_names}")
|
||
if self.thresholds:
|
||
errs.extend(self.thresholds.validate())
|
||
if (self.model.recipe.target
|
||
and self.thresholds.target
|
||
and self.thresholds.target != self.model.recipe.target):
|
||
errs.append(
|
||
f"阈值 target({self.thresholds.target}) 与模型 "
|
||
f"target({self.model.recipe.target}) 不一致")
|
||
if errs:
|
||
raise ServeError("部署校验失败:\n " + "\n ".join(errs))
|
||
|
||
def predict(self, samples: Sequence[Sample]) -> List[Prediction]:
|
||
"""对时序样本预测(每个样本时刻一条预测)。"""
|
||
matrix: FeatureMatrix = self.extractor.extract(samples)
|
||
# 模型特征名顺序(若清单与模型一致,直接用矩阵列)
|
||
X = matrix.rows
|
||
preds_raw = self.model.predict(X)
|
||
explanation = self.model.explain()
|
||
out: List[Prediction] = []
|
||
# 样本时刻(extract 保留输入顺序)
|
||
ts_list = [s.ts for s in sorted(samples, key=lambda s: s.ts)]
|
||
for i, raw in enumerate(preds_raw):
|
||
level, msg = self._classify(raw)
|
||
ts = ts_list[i] if i < len(ts_list) else 0.0
|
||
out.append(Prediction(
|
||
target=self.model.recipe.target, value=raw,
|
||
unit=self.model.recipe.unit, level=level, message=msg,
|
||
confidence_band=self.confidence_rmse,
|
||
explanation=explanation, timestamp=ts))
|
||
return out
|
||
|
||
def _classify(self, value: float) -> tuple:
|
||
"""按阈值判定告警等级。"""
|
||
if math.isnan(value) or self.thresholds is None:
|
||
return AlarmLevel.NORMAL.value, "无阈值或预测缺失,未判定"
|
||
th = self.thresholds
|
||
band = th.warning_band
|
||
if th.min_value is not None:
|
||
if value < th.min_value:
|
||
return AlarmLevel.CRITICAL.value, (
|
||
f"{th.target}={value:.3f} 低于下限 {th.min_value}")
|
||
if value < th.min_value + band:
|
||
return AlarmLevel.WARNING.value, (
|
||
f"{th.target}={value:.3f} 接近下限 {th.min_value}")
|
||
if th.max_value is not None:
|
||
if value > th.max_value:
|
||
return AlarmLevel.CRITICAL.value, (
|
||
f"{th.target}={value:.3f} 超过上限 {th.max_value}")
|
||
if value > th.max_value - band:
|
||
return AlarmLevel.WARNING.value, (
|
||
f"{th.target}={value:.3f} 接近上限 {th.max_value}")
|
||
return AlarmLevel.NORMAL.value, f"{th.target}={value:.3f} 达标"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 驾驶舱视图
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class CockpitView:
|
||
"""把预测结果渲染为驾驶舱布局片段(对齐 iAOP-cockpit-layout-v1)。"""
|
||
|
||
WIDGET_TYPES = {"kpi_card", "alarm_panel", "explanation_list"}
|
||
|
||
def __init__(self, title: str = "质量预测"):
|
||
self.title = title
|
||
|
||
def render(self, prediction: Prediction,
|
||
*, position: Optional[Dict[str, int]] = None) -> Dict[str, Any]:
|
||
"""渲染单个预测为 kpi_card + alarm_panel + 可解释溯源列表。"""
|
||
pos = position or {"x": 0, "y": 0, "w": 6, "h": 2}
|
||
level_color = {
|
||
AlarmLevel.NORMAL.value: "green",
|
||
AlarmLevel.WARNING.value: "yellow",
|
||
AlarmLevel.CRITICAL.value: "red",
|
||
}[prediction.level]
|
||
# top-3 特征贡献(可溯源)
|
||
top = sorted(prediction.explanation,
|
||
key=lambda e: e.get("importance", 0), reverse=True)[:3]
|
||
return {
|
||
"$schema": "iAOP-cockpit-layout-v1",
|
||
"title": self.title,
|
||
"widgets": [
|
||
{
|
||
"type": "kpi_card",
|
||
"metric": prediction.target,
|
||
"label": self.title,
|
||
"value": round(prediction.value, 3),
|
||
"unit": prediction.unit,
|
||
"level": prediction.level,
|
||
"color": level_color,
|
||
**pos,
|
||
"description": prediction.message,
|
||
},
|
||
{
|
||
"type": "alarm_panel",
|
||
"metric": prediction.target,
|
||
"level": prediction.level,
|
||
"message": prediction.message,
|
||
"color": level_color,
|
||
"x": pos["x"], "y": pos["y"] + pos["h"],
|
||
"w": pos["w"], "h": 1,
|
||
"description": "质量预测告警面板",
|
||
},
|
||
{
|
||
"type": "explanation_list",
|
||
"metric": prediction.target,
|
||
"items": top,
|
||
"x": pos["x"], "y": pos["y"] + pos["h"] + 1,
|
||
"w": pos["w"], "h": 2,
|
||
"description": "预测依据(top-3 特征贡献,可溯源)",
|
||
},
|
||
],
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 模型漂移监测
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class DriftMonitor:
|
||
"""累计预测的评估指标,超阈值触发重训信号(PRD §5.3 / §9)。"""
|
||
|
||
def __init__(self, *, min_r2: float = 0.8, max_mae: float = 1.0):
|
||
self.min_r2 = min_r2
|
||
self.max_mae = max_mae
|
||
self.history: List[Evaluation] = []
|
||
|
||
def record(self, evaluation: Evaluation) -> None:
|
||
self.history.append(evaluation)
|
||
|
||
def should_retrain(self) -> tuple:
|
||
"""最近一次评估是否触发重训。返回 (是否重训, 原因)。"""
|
||
if not self.history:
|
||
return False, "无评估记录"
|
||
last = self.history[-1]
|
||
if math.isnan(last.r2):
|
||
return True, f"R² 异常(NaN),建议重训"
|
||
if last.r2 < self.min_r2:
|
||
return True, f"R²={last.r2:.3f} < {self.min_r2},模型退化"
|
||
if last.mae > self.max_mae:
|
||
return True, f"MAE={last.mae:.3f} > {self.max_mae},误差超限"
|
||
return False, f"R²={last.r2:.3f} MAE={last.mae:.3f} 达标"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 部署包加载
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass
|
||
class DeploymentBundle:
|
||
"""模型部署包:模型 + 特征清单 + 阈值 + 置信带,可整体序列化。"""
|
||
|
||
model: QualityModel
|
||
extractor: FeatureExtractor
|
||
thresholds: Optional[Thresholds] = None
|
||
confidence_rmse: float = 0.0
|
||
|
||
def to_service(self) -> PredictionService:
|
||
return PredictionService(
|
||
self.model, self.extractor, self.thresholds,
|
||
confidence_rmse=self.confidence_rmse)
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"model": self.model.to_dict(),
|
||
"feature_specs": [
|
||
{"name": s.name, "source": s.source, "transform": s.transform,
|
||
"window": s.window, "denominator": s.denominator,
|
||
"meaning": s.meaning, "unit": s.unit}
|
||
for s in self.extractor.specs],
|
||
"thresholds": ({
|
||
"target": self.thresholds.target,
|
||
"min": self.thresholds.min_value,
|
||
"max": self.thresholds.max_value,
|
||
"warning_band": self.thresholds.warning_band}
|
||
if self.thresholds else None),
|
||
"confidence_rmse": self.confidence_rmse,
|
||
}
|
||
|
||
@classmethod
|
||
def from_dict(cls, d: Dict[str, Any]) -> "DeploymentBundle":
|
||
from .features import FeatureSpec as FS
|
||
model = QualityModel.from_dict(d["model"])
|
||
specs = [FS(name=s["name"], source=s["source"],
|
||
transform=s.get("transform", "raw"),
|
||
window=float(s.get("window", 60.0)),
|
||
denominator=s.get("denominator"),
|
||
meaning=s.get("meaning", ""), unit=s.get("unit", ""))
|
||
for s in d.get("feature_specs", [])]
|
||
ext = FeatureExtractor(specs, strict=False)
|
||
th_raw = d.get("thresholds")
|
||
th = Thresholds.from_dict(th_raw) if th_raw else None
|
||
return cls(model, ext, th, float(d.get("confidence_rmse", 0.0)))
|