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)))
|