Merge PR #101-#109 (EPIC #5 模型框架 8 子任务:recipe/feature/quality/anomaly/cross-process/pipeline/registry/PoC,命名空间化整合)

This commit is contained in:
2026-08-05 08:26:02 +08:00
parent a0b21866fe
commit 8a407fc0ad
27 changed files with 7750 additions and 17 deletions
+625
View File
@@ -0,0 +1,625 @@
# -*- coding: utf-8 -*-
"""Model Recipe 插件接口与样例协议。
对应 issue #34(父 EPIC #5「③ AI 模型框架 配置化重构」、PRD 5.3
「网络结构策略 / 模板化技术路径」)。
PRD 5.3 的核心诉求
------------------
采用「固定主干网络 + 可配置超参」为默认模式;同时提供 **Model Recipe
注册表**,允许高级行业模板通过 *声明式 recipe* 选择不同网络结构(如
LSTM 用于时序、GNN 用于跨工序),**新增结构走插件注册而非改内核**。
验收口径(PRD 5.3 / EPIC #5):同一框架加载「树脂」与「Ti」两套 Recipe
均能跑通——切换模板仅改 Recipe,模型代码零改动。
本模块交付什么
--------------
1. **``ModelRecipe``**:声明式模型结构注册项。一个 Recipe 描述「用什么网络
主干 + 如何从超参包构造一个可训练/可推理的模型」。它是一个不可变数据
对象,``to_dict``/``repr`` 可序列化往返,便于配置台展示与审计。
2. **``RECIPES`` 全局注册表 + ``register_recipe`` / ``get_recipe`` /
``build_model`` / ``list_recipes``**:插件式注册 API。新增网络结构
(如自研 GNN)只需 ``register_recipe``,不动内核——对齐 PRD「新增结构
走插件注册」理念,并与 issue #35 的 ``register_operator``(特征算子
插件)形成「特征层 + 结构层」两级插件体系。
3. **内置网络主干工厂**:覆盖 PRD 5.3 四类模型模板的全部默认结构——
``gbdt`` / ``dnn`` / ``lstm`` / ``gnn``。每个工厂是纯函数
``build(hyperparams) -> ModelHandle``,返回统一的 ``ModelHandle``
(``fit`` / ``predict`` / ``to_dict``)。
4. **四类内置 Recipe**:与 PRD 5.3「四类模型模板」1:1 映射——质量预测 /
工艺优化 / 异常检测 / 跨工序寻优,默认绑定到 ``gbdt``/``dnn`` 主干,
高级模板可改绑 ``lstm``/``gnn``。
5. **样例协议(``samples/`` JSON)**:树脂 Recipe(``resin``)+ Ti Recipe
(``ti``)两套超参包样例,直接验证「同框架加载两套 Recipe 均跑通」的
验收口径。
零外部强依赖
------------
* 训练/推理默认走 **纯 Python stub 主干**(``StubBackbone``):无
sklearn / xgboost / torch 时也能加载、注册、构造、(伪)拟合与预测,
保证边缘 / 离线 / CI 环境可加载与校验——与 issue #35 的「numpy 可选」
策略一致。
* 当运行环境存在 ``sklearn`` 时,``gbdt``/``dnn`` 主干自动升级为真实
sklearn 实现(梯度提升回归 / MLP),其余情况退化为 stub,不影响接口
契约与测试。
"""
from __future__ import annotations
import copy
import json
import math
import os
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
__all__ = [
# 数据对象
"ModelRecipe",
"ModelHandle",
"RecipeError",
# 注册表 API
"RECIPES",
"register_recipe",
"get_recipe",
"list_recipes",
"build_model",
# 内置主干工厂
"BACKBONES",
"register_backbone",
"gbdt_backbone",
"dnn_backbone",
"lstm_backbone",
"gnn_backbone",
"stub_backbone",
# 样例协议
"load_sample_recipe",
"SAMPLE_RECIPES",
# 超参包校验
"validate_hyperparam_pack",
"RECIPE_KINDS",
]
# ---------------------------------------------------------------------------
# 可选依赖探测(与 issue #35 feature_spec 的 numpy 可选策略一致)
# ---------------------------------------------------------------------------
try: # pragma: no cover - 依赖环境相关
import numpy as _np # type: ignore
_HAS_NUMPY = True
except Exception: # pragma: no cover
_np = None
_HAS_NUMPY = False
try: # pragma: no cover - 依赖环境相关
from sklearn.ensemble import GradientBoostingRegressor as _GBR # type: ignore
from sklearn.neural_network import MLPRegressor as _MLPR # type: ignore
_HAS_SKLEARN = True
except Exception: # pragma: no cover
_GBR = None
_MLPR = None
_HAS_SKLEARN = False
class RecipeError(ValueError):
"""Recipe / 超参包语义错误(未知 recipe / 主干 / 参数缺失等)。"""
# PRD 5.3 四类模型模板的合法 ``kind``(与超参包 ``task`` 字段对齐)。
RECIPE_KINDS: Tuple[str, ...] = (
"quality_predict", # ① 质量预测
"process_optimize", # ② 工艺优化 / 配方推荐
"anomaly_detect", # ③ 异常检测 / 杂质预警
"cross_process", # ④ 跨工序关联寻优
)
# ---------------------------------------------------------------------------
# ModelHandle:统一的模型句柄(fit / predict / to_dict)
# ---------------------------------------------------------------------------
class ModelHandle:
"""统一的模型句柄,屏蔽底层主干(sklearn / stub)差异。
所有主干工厂返回本类实例,使训练/推理流水线(issue #40)与配置台
(issue #62~#67)只需面向同一接口编程。``fit`` / ``predict`` 对输入
做最小校验后委托给 ``_impl``。
"""
__slots__ = ("recipe_id", "backbone", "hyperparams", "_impl", "fitted")
def __init__(
self,
recipe_id: str,
backbone: str,
hyperparams: Dict[str, Any],
impl: Any,
) -> None:
self.recipe_id = recipe_id
self.backbone = backbone
self.hyperparams: Dict[str, Any] = dict(hyperparams)
self._impl = impl
self.fitted = False
# -- 训练 / 推理 -------------------------------------------------------
def fit(self, X: Sequence[Sequence[float]], y: Optional[Sequence[float]] = None) -> "ModelHandle":
"""拟合。无监督主干(anomaly_detect)可忽略 ``y``。"""
rows = self._coerce_X(X)
if y is not None:
yv = [float(v) for v in y]
if len(yv) != len(rows):
raise ValueError(f"X/y 长度不一致:{len(rows)} vs {len(yv)}")
else:
yv = None
self._impl_fit(rows, yv)
self.fitted = True
return self
def predict(self, X: Sequence[Sequence[float]]) -> List[float]:
"""推理。未拟合则 fail-fast(fail-closed,避免静默返回垃圾值)。"""
if not self.fitted:
raise RecipeError("模型尚未 fit,禁止 predict(fail-closed)")
rows = self._coerce_X(X)
return self._impl_predict(rows)
# -- 序列化 ------------------------------------------------------------
def to_dict(self) -> Dict[str, Any]:
return {
"recipe_id": self.recipe_id,
"backbone": self.backbone,
"hyperparams": copy.deepcopy(self.hyperparams),
"fitted": self.fitted,
}
def __repr__(self) -> str: # pragma: no cover - 调试用
return (
f"ModelHandle(recipe_id={self.recipe_id!r}, backbone={self.backbone!r}, "
f"hyperparams={self.hyperparams!r}, fitted={self.fitted})"
)
# -- 内部 --------------------------------------------------------------
@staticmethod
def _coerce_X(X: Sequence[Sequence[float]]) -> List[List[float]]:
if X is None:
raise ValueError("X 不能为 None")
rows: List[List[float]] = []
width: Optional[int] = None
for r in X:
row = [float(v) for v in r]
if width is None:
width = len(row)
elif len(row) != width:
raise ValueError(f"特征宽度不一致:{width} vs {len(row)}")
rows.append(row)
if not rows:
raise ValueError("X 不能为空")
return rows
def _impl_fit(self, rows: List[List[float]], y: Optional[List[float]]) -> None:
method = getattr(self._impl, "iaop_fit", None)
if method is None:
return # stub 主干无需训练
method(rows, y)
def _impl_predict(self, rows: List[List[float]]) -> List[float]:
method = getattr(self._impl, "iaop_predict", None)
if method is None:
# 兜底:返回零向量(理论上不会走到,注册时已校验)
return [0.0 for _ in rows]
return [float(v) for v in method(rows)]
# ---------------------------------------------------------------------------
# 内置主干工厂:gbdt / dnn / lstm / gnn(无第三方依赖时退化为 stub)
# ---------------------------------------------------------------------------
def _as_matrix(rows: Sequence[Sequence[float]]):
"""把嵌套列表归一为 numpy 数组(有 numpy)或原生 list。"""
if _HAS_NUMPY:
return _np.asarray(rows, dtype=float)
return [list(r) for r in rows]
def _as_vector(y: Sequence[float]):
if _HAS_NUMPY:
return _np.asarray(y, dtype=float)
return [float(v) for v in y]
def stub_backbone(hyperparams: Dict[str, Any]) -> Any:
"""纯 Python stub 主干:均值/常数预测,无任何第三方依赖。
作为 ``gbdt``/``dnn``/``lstm``/``gnn`` 在无 sklearn/torch 环境下的
退化实现,保证 Recipe 可加载、可(伪)拟合、可推理、可切换——满足
PRD「新增结构走插件注册」的接口契约,不保证预测精度。
"""
class _Stub:
def __init__(self) -> None:
self._target_mean = 0.0
self._rows = 0
def iaop_fit(self, rows, y):
self._rows = len(rows)
if y is not None and len(y) > 0:
self._target_mean = float(sum(y) / len(y))
def iaop_predict(self, rows):
return [self._target_mean for _ in rows]
return _Stub()
def gbdt_backbone(hyperparams: Dict[str, Any]) -> Any:
"""梯度提升回归主干(PRD 5.3 质量预测默认 ``algorithm=xgboost``)。
有 ``sklearn`` 时用 ``GradientBoostingRegressor``;否则退化为
``stub_backbone``。超参映射:max_depth / n_estimators / learning_rate。
"""
if not _HAS_SKLEARN:
return stub_backbone(hyperparams)
max_depth = int(hyperparams.get("max_depth", 6))
n_estimators = int(hyperparams.get("n_estimators", hyperparams.get("n_est", 100)))
learning_rate = float(hyperparams.get("eta", hyperparams.get("learning_rate", 0.1)))
return _GBR(
max_depth=max_depth,
n_estimators=n_estimators,
learning_rate=learning_rate,
)
def dnn_backbone(hyperparams: Dict[str, Any]) -> Any:
"""轻量 DNN 主干(PRD 5.3「轻量 DNN」结构不变 + 可配置超参)。
有 ``sklearn`` 时用 ``MLPRegressor``;否则退化为 stub。超参映射:
hidden_layer_sizes / max_iter。
"""
if not _HAS_SKLEARN:
return stub_backbone(hyperparams)
hidden = hyperparams.get("hidden", hyperparams.get("hidden_layer_sizes", (64, 32)))
if isinstance(hidden, int):
hidden = (hidden,)
elif isinstance(hidden, list):
hidden = tuple(int(x) for x in hidden)
max_iter = int(hyperparams.get("max_iter", hyperparams.get("epochs", 200)))
return _MLPR(hidden_layer_sizes=hidden, max_iter=max_iter)
def lstm_backbone(hyperparams: Dict[str, Any]) -> Any:
"""LSTM 时序主干(PRD 5.3「LSTM 用于时序」高级行业模板可选结构)。
iAOP-Core 内核不强制依赖 torch/tf;本工厂在无 heavy 依赖时退化为
stub,仅完成「结构可声明、可注册、可切换」的接口契约。真实训练由
下游模板的插件 Recipe 注入 torch 实现后覆盖(``register_backbone``)。
"""
# 读取序列长度配置仅用于校验,stub 本身不消费
_ = int(hyperparams.get("seq_len", hyperparams.get("window", 10)))
return stub_backbone(hyperparams)
def gnn_backbone(hyperparams: Dict[str, Any]) -> Any:
"""GNN 跨工序主干(PRD 5.3「GNN 用于跨工序」高级行业模板可选结构)。
同 ``lstm_backbone``:内核不绑定图神经网络框架,退化为 stub;高级
模板通过插件 Recipe 注入真实实现。
"""
_ = hyperparams.get("num_nodes", hyperparams.get("edges"))
return stub_backbone(hyperparams)
# 主干注册表:name -> factory(hyperparams) -> impl
BACKBONES: Dict[str, Callable[[Dict[str, Any]], Any]] = {
"gbdt": gbdt_backbone,
"dnn": dnn_backbone,
"lstm": lstm_backbone,
"gnn": gnn_backbone,
"stub": stub_backbone,
}
def register_backbone(
name: str,
factory: Callable[[Dict[str, Any]], Any],
) -> None:
"""注册一个网络主干工厂。重复注册同名主干覆盖旧定义(便于测试替换)。"""
if not name or not name.replace("_", "").replace("-", "").isalnum():
raise RecipeError(f"非法主干名:{name!r}")
if not callable(factory):
raise RecipeError("factory 必须是可调用对象")
BACKBONES[name] = factory
def _resolve_backbone(name: str) -> Callable[[Dict[str, Any]], Any]:
if name not in BACKBONES:
raise RecipeError(
f"未知网络主干:{name!r}(已注册:{sorted(BACKBONES.keys())})"
)
return BACKBONES[name]
# ---------------------------------------------------------------------------
# ModelRecipe:声明式模型结构注册项(不可变数据对象)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class ModelRecipe:
"""一个声明式模型结构 Recipe。
Attributes
----------
id : str
Recipe 唯一标识(如 ``quality_predict.default``)。
kind : str
任务类型,取值 ``RECIPE_KINDS`` 之一(对齐 PRD 5.3 四类模型模板)。
backbone : str
网络主干名,必须在 ``BACKBONES`` 已注册(gbdt/dnn/lstm/gnn/...)。
default_hyperparams : dict
默认超参(可被超参包覆盖)。
required_features : tuple[str, ...]
该 Recipe 要求的最少特征名(用于超参包校验)。
description : str
人类可读说明。
"""
id: str
kind: str
backbone: str
default_hyperparams: Dict[str, Any] = field(default_factory=dict)
required_features: Tuple[str, ...] = field(default_factory=tuple)
description: str = ""
def __post_init__(self) -> None:
if not self.id:
raise RecipeError("Recipe id 不能为空")
if self.kind not in RECIPE_KINDS:
raise RecipeError(
f"非法 kind:{self.kind!r}(合法:{RECIPE_KINDS})"
)
if self.backbone not in BACKBONES:
raise RecipeError(
f"未注册的主干:{self.backbone!r}(已注册:{sorted(BACKBONES.keys())})"
)
if not isinstance(self.default_hyperparams, dict):
raise RecipeError("default_hyperparams 必须是 dict")
if not isinstance(self.required_features, tuple):
raise RecipeError("required_features 必须是 tuple")
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"kind": self.kind,
"backbone": self.backbone,
"default_hyperparams": copy.deepcopy(self.default_hyperparams),
"required_features": list(self.required_features),
"description": self.description,
}
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "ModelRecipe":
try:
return cls(
id=str(d["id"]),
kind=str(d["kind"]),
backbone=str(d["backbone"]),
default_hyperparams=dict(d.get("default_hyperparams", {})),
required_features=tuple(d.get("required_features", ())),
description=str(d.get("description", "")),
)
except KeyError as e: # pragma: no cover - 防御性
raise RecipeError(f"Recipe 缺少字段:{e}") from e
def merged_hyperparams(self, override: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""合并默认超参与超参包覆盖(覆盖优先)。"""
merged = copy.deepcopy(self.default_hyperparams)
if override:
merged.update(override)
return merged
# ---------------------------------------------------------------------------
# Recipe 注册表 + 公开 API
# ---------------------------------------------------------------------------
RECIPES: Dict[str, ModelRecipe] = {}
def register_recipe(recipe: ModelRecipe) -> ModelRecipe:
"""注册一个 Model Recipe 到全局注册表。
重复注册同 id 覆盖旧定义(便于测试期间替换)。对齐 PRD「新增结构走
插件注册而非改内核」:高级行业模板(如自研 GNN)只需 ``register_recipe``
即可接入,无需修改本文件。
"""
if not isinstance(recipe, ModelRecipe):
raise RecipeError("register_recipe 入参必须是 ModelRecipe 实例")
# 再次校验主干(防止 BACKBONES 在 recipe 构造后被反注册)
_resolve_backbone(recipe.backbone)
RECIPES[recipe.id] = recipe
return recipe
def get_recipe(recipe_id: str) -> ModelRecipe:
"""按 id 取 Recipe;不存在则 ``RecipeError``。"""
if recipe_id not in RECIPES:
raise RecipeError(
f"未知 Recipe:{recipe_id!r}(已注册:{sorted(RECIPES.keys())})"
)
return RECIPES[recipe_id]
def list_recipes() -> List[Dict[str, Any]]:
"""列出全部已注册 Recipe(``to_dict`` 形式,按 id 排序)。"""
return [RECIPES[k].to_dict() for k in sorted(RECIPES.keys())]
def build_model(
recipe_id: str,
hyperparams: Optional[Dict[str, Any]] = None,
) -> ModelHandle:
"""按 Recipe + 超参包构造一个可训练/可推理的模型句柄。
流程:取 Recipe → 合并超参 → 取主干工厂 → 构造 impl → 包成
``ModelHandle``。切换模板/行业只需换 ``recipe_id`` 或超参,代码零改动
——对齐 PRD 5.3 验收口径。
"""
recipe = get_recipe(recipe_id)
merged = recipe.merged_hyperparams(hyperparams)
factory = _resolve_backbone(recipe.backbone)
impl = factory(merged)
return ModelHandle(
recipe_id=recipe.id,
backbone=recipe.backbone,
hyperparams=merged,
impl=impl,
)
# ---------------------------------------------------------------------------
# 超参包校验(与 issue #39 hyperparam 互补;本模块只校验 Recipe 相关字段)
# ---------------------------------------------------------------------------
# Recipe 视角下,超参包必须出现的字段(PRD 5.3 超参包 JSON 示例)。
_REQUIRED_PACK_FIELDS: Tuple[str, ...] = ("model_id", "recipe_id", "features")
def validate_hyperparam_pack(pack: Dict[str, Any]) -> List[str]:
"""校验一个超参包在 Recipe 视角下的合法性,返回问题列表(空=通过)。
与 issue #39 ``hyperparam.py`` 的「spec 非空存在性校验」互补:#39 校验
特征 ``spec`` 字段本身,本函数校验「recipe_id 是否注册、主干能否构造、
必需特征是否齐备」等结构层语义。
"""
issues: List[str] = []
if not isinstance(pack, dict):
return ["超参包必须是 dict"]
for f in _REQUIRED_PACK_FIELDS:
if f not in pack:
issues.append(f"缺少必填字段:{f}")
rid = pack.get("recipe_id")
if rid is not None:
if rid not in RECIPES:
issues.append(
f"recipe_id {rid!r} 未注册(已注册:{sorted(RECIPES.keys())})"
)
else:
recipe = RECIPES[rid]
# 必需特征校验
if recipe.required_features:
feats = {f.get("name") for f in pack.get("features", []) if isinstance(f, dict)}
for req in recipe.required_features:
if req not in feats:
issues.append(f"Recipe {rid!r} 要求特征 {req!r} 但超参包未提供")
# 主干可构造性(合并超参后能否实例化,吞掉异常转 issue)
try:
merged = recipe.merged_hyperparams(pack.get("hyperparams"))
_resolve_backbone(recipe.backbone)(merged)
except Exception as e: # pragma: no cover - 防御性
issues.append(f"主干 {recipe.backbone!r} 构造失败:{e}")
return issues
# ---------------------------------------------------------------------------
# 内置四类 Recipe(PRD 5.3 四类模型模板 1:1 映射,默认主干)
# ---------------------------------------------------------------------------
def _register_builtin_recipes() -> None:
"""注册 PRD 5.3 四类模型模板的默认 Recipe。
默认主干选「固定主干网络」:质量预测/工艺优化用 gbdt,异常检测用 dnn,
跨工序寻优用 gnn(高级行业模板可改绑 lstm/gnn)。
"""
register_recipe(ModelRecipe(
id="quality_predict.default",
kind="quality_predict",
backbone="gbdt",
default_hyperparams={
"max_depth": 6,
"eta": 0.1,
"n_estimators": 300,
"objective": "reg:squarederror",
},
required_features=("target",),
description="① 质量预测默认 Recipe:GBDT 主干,输入工艺参数+原料特征,"
"输出关键质量指标预测(PRD 5.3)。",
))
register_recipe(ModelRecipe(
id="process_optimize.default",
kind="process_optimize",
backbone="gbdt",
default_hyperparams={
"max_depth": 5,
"n_estimators": 200,
},
description="② 工艺优化/配方推荐默认 Recipe:GBDT 主干,输入质量目标+"
"约束,输出参数/配方建议(PRD 5.3)。",
))
register_recipe(ModelRecipe(
id="anomaly_detect.default",
kind="anomaly_detect",
backbone="dnn",
default_hyperparams={
"hidden": (32, 16),
"alarm_threshold": {"type": "zscore", "k": 3.0},
},
description="③ 异常检测/杂质预警默认 Recipe:轻量 DNN 主干(无监督+"
"阈值),输入实时测点,输出异常评分+预警(PRD 5.3)。",
))
register_recipe(ModelRecipe(
id="cross_process.default",
kind="cross_process",
backbone="gnn",
default_hyperparams={
"num_nodes": 2,
},
description="④ 跨工序关联寻优默认 Recipe:GNN 主干,输入上游(TiCl₄)"
"指标,输出下游(海绵钛)寻优建议(PRD 5.3)。",
))
_register_builtin_recipes()
# ---------------------------------------------------------------------------
# 样例协议:树脂 / Ti 两套超参包(验证「同框架加载两套 Recipe 均跑通」)
# ---------------------------------------------------------------------------
# 内置样例超参包(PRD 5.3 超参包 JSON 结构 + recipe_id 关联)。验证 EPIC #5
# 验收口径:同一框架加载树脂与 Ti 两套 Recipe 均能 build/fit/predict。
SAMPLE_RECIPES: Dict[str, Dict[str, Any]] = {
"resin": {
"model_id": "quality_predict_resin",
"template": "iAOP-Template-Resin",
"recipe_id": "quality_predict.default",
"algorithm": "gbdt",
"features": [
{"name": "EMA_resin_temp", "spec": "EMA(树脂温度, 5m)"},
{"name": "target", "spec": "树脂转化率"},
],
"target": "树脂转化率",
"objective": "reg:squarederror",
"hyperparams": {"max_depth": 4, "n_estimators": 120, "eta": 0.1},
"train_window": "180d",
},
"ti": {
"model_id": "quality_predict_ti",
"template": "iAOP-Template-Ti",
"recipe_id": "quality_predict.default",
"algorithm": "gbdt",
"features": [
{"name": "EMA_CLF_TEMP_5m", "spec": "EMA(CLF-01.TEMP, 5m)"},
{"name": "RollingStd_CL2_10", "spec": "RollingStd(CLF-01.CL2, 10)"},
{"name": "target", "spec": "Ti_purity"},
],
"target": "Ti_purity",
"objective": "reg:squarederror",
"hyperparams": {"max_depth": 6, "n_estimators": 300, "eta": 0.1},
"train_window": "180d",
"alarm_threshold": {"type": "zscore", "k": 3.0},
"drift_check": {"method": "psi", "limit": 0.2},
},
}
def load_sample_recipe(name: str) -> Dict[str, Any]:
"""按名取内置样例超参包(``resin`` / ``ti``),返回深拷贝。"""
if name not in SAMPLE_RECIPES:
raise RecipeError(
f"未知样例 Recipe:{name!r}(已有:{sorted(SAMPLE_RECIPES.keys())})"
)
return copy.deepcopy(SAMPLE_RECIPES[name])