Files
iAOP/core/model-framework/pipeline.py
T

676 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""训练 / 推理流水线编排(对接 PRD 5.3 ③ 模型框架)。
对应 issue #40(父 EPIC #5「③ AI 模型框架 配置化重构」、PRD 5.3
「③ 训练 / 推理流水线编排」)。
PRD 5.3 的核心诉求
------------------
模型从「开发」到「上线」是一条流水线:**数据准备 → 特征工程 → 训练 →
评估 → 注册(版本化) → 加载 → 推理 → 监控**。手写脚本拼接这些步骤
不可复用、不可审计、不可重放。PRD 5.3 要求把这条流水线**编排化、配置化**:
每个步骤是一个可插拔的 ``Step``,步骤之间的数据通过 ``Context`` 流转,
整条流水线由一个声明式 JSON / Python 配置驱动——切换模型 / 数据源只改
配置,编排代码零改动。
本模块交付什么
--------------
1. **``Step`` 抽象基类**:``prepare`` / ``run`` / ``teardown`` 三段式生命周期,
输入输出通过 ``Context`` 传递。内置若干常用步骤:
- ``LoadDataStep``:从 CSV / 内存加载数据;
- ``TrainStep``:调用可插拔 ``Estimator``(默认 stub,可换 sklearn)训练;
- ``EvaluateStep``:计算 accuracy / MAE / RMSE 等指标;
- ``RegisterStep``:把训练产物注册到内存 ``ModelRegistry``(版本化);
- ``LoadModelStep``:从 registry 按版本加载模型;
- ``PredictStep``:用加载的模型批量推理。
2. **``Pipeline`` 编排器**:顺序执行若干 ``Step``,自动传递 ``Context``,
支持 ``dry_run``(只校验配置不执行)、失败短路、产物收集。
3. **``Context``**:流水线上下文(不可变快照 + 可写 working dict),承载
数据 / 模型 / 指标 / 元信息,步骤间解耦。
4. **``ModelRegistry``**:内存模型注册表(版本化 + 别名 latest/stable),
对接 issue #41「模型模板注册 / 加载 / 版本机制」的雏形。
5. **``PipelineConfig``**:声明式配置,``from_dict`` / ``to_dict`` 可序列化,
便于配置台展示与审计。
零外部强依赖
------------
* ``Estimator`` 默认走纯 Python stub(均值回归 / 多数分类),无 sklearn 时
也能跑通完整训练 / 推理流水线,保证 CI 可加载与校验;
* 存在 ``numpy`` 时,指标计算与 stub 训练用向量化加速,否则纯 Python。
与 issue #34 / #36 / #38 的关系
-------------------------------
接口风格对齐 #34 声明式数据对象、#36 ``Recipe`` 配方、#38 ``Recipe``。
本模块**自包含、不依赖未合并分支**;``TrainStep`` 的 ``Estimator`` 可插拔,
未来可对接 #36 ``QualityForecastModel`` 作为具名 estimator,``RegisterStep``
可对接 #41 完整版本机制,业务侧零改动。
"""
from __future__ import annotations
import json
import math
import os
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
__all__ = [
# 上下文与注册表
"Context",
"ModelRegistry",
"ModelArtifact",
# 步骤
"Step",
"StepResult",
"LoadDataStep",
"TrainStep",
"EvaluateStep",
"RegisterStep",
"LoadModelStep",
"PredictStep",
"CustomStep",
# 估计器
"Estimator",
"MeanRegressor",
"MajorityClassifier",
"ESTIMATORS",
"register_estimator",
# 流水线
"Pipeline",
"PipelineConfig",
"PipelineError",
"PipelineResult",
]
try: # numpy 可选
import numpy as _np # type: ignore # noqa: F401
_HAS_NUMPY = True
except Exception: # pragma: no cover
_HAS_NUMPY = False
class PipelineError(Exception):
"""流水线编排层统一异常(配置非法 / 步骤失败 / 估计器未注册)。"""
# ---------------------------------------------------------------------------
# 上下文:步骤间数据流转
# ---------------------------------------------------------------------------
@dataclass
class Context:
"""流水线上下文:承载步骤间传递的数据 / 模型 / 指标 / 元信息。
采用「可写 working dict + 只读 params」双层:
- ``params``:流水线启动参数(只读,来自配置);
- ``artifacts``:步骤产物(可写,步骤间共享)。
"""
params: Dict[str, Any] = field(default_factory=dict)
artifacts: Dict[str, Any] = field(default_factory=dict)
metadata: Dict[str, Any] = field(default_factory=dict)
def get(self, key: str, default: Any = None) -> Any:
return self.artifacts.get(key, default)
def set(self, key: str, value: Any) -> None:
self.artifacts[key] = value
def snapshot(self) -> Dict[str, Any]:
"""返回当前上下文的只读快照(用于审计 / 日志)。"""
return {
"params": dict(self.params),
"artifacts_keys": sorted(self.artifacts.keys()),
"metadata": dict(self.metadata),
}
# ---------------------------------------------------------------------------
# 模型注册表(版本化,对接 issue #41 雏形)
# ---------------------------------------------------------------------------
@dataclass
class ModelArtifact:
"""注册到 ``ModelRegistry`` 的一个模型版本。"""
name: str
version: str
model: Any
metrics: Dict[str, float] = field(default_factory=dict)
registered_at: float = field(default_factory=time.time)
extra: Dict[str, Any] = field(default_factory=dict)
def to_summary(self) -> Dict[str, Any]:
return {
"name": self.name,
"version": self.version,
"metrics": dict(self.metrics),
"registered_at": self.registered_at,
"extra": dict(self.extra),
}
class ModelRegistry:
"""内存模型注册表:按 name 维护多版本,支持别名 latest / stable。
对接 issue #41「模型模板注册 / 加载 / 版本机制」的雏形——同一模型名下
可注册多个版本,``latest`` 指向最新,``stable`` 可手动标记。
"""
def __init__(self) -> None:
self._store: Dict[str, Dict[str, ModelArtifact]] = {}
self._aliases: Dict[str, Dict[str, str]] = {} # name -> {alias: version}
def register(self, artifact: ModelArtifact) -> ModelArtifact:
if not artifact.name or not artifact.version:
raise PipelineError("ModelArtifact 需要 name 和 version")
versions = self._store.setdefault(artifact.name, {})
versions[artifact.version] = artifact
# latest 自动指向最新注册
self._aliases.setdefault(artifact.name, {})["latest"] = artifact.version
return artifact
def get(self, name: str, version: Optional[str] = None) -> ModelArtifact:
versions = self._store.get(name)
if not versions:
raise PipelineError(f"模型 {name!r} 未注册")
if version is None:
version = self._aliases.get(name, {}).get("latest")
if version is None:
version = sorted(versions.keys())[-1]
elif version in self._aliases.get(name, {}):
# version 实际是别名
version = self._aliases[name][version]
if version not in versions:
raise PipelineError(
f"模型 {name!r} 无版本 {version!r}(可用:{sorted(versions)})")
return versions[version]
def set_alias(self, name: str, alias: str, version: str) -> None:
versions = self._store.get(name)
if not versions or version not in versions:
raise PipelineError(f"无法设置别名:{name!r}@{version!r} 不存在")
self._aliases.setdefault(name, {})[alias] = version
def list_versions(self, name: str) -> List[str]:
return sorted(self._store.get(name, {}).keys())
def list_models(self) -> List[str]:
return sorted(self._store.keys())
# ---------------------------------------------------------------------------
# 估计器(可插拔训练算法)
# ---------------------------------------------------------------------------
class Estimator:
"""估计器抽象基类:fit / predict,与具体库无关。"""
name: str = "base"
def fit(self, X: Sequence[Sequence[float]], y: Sequence[float]) -> None:
raise NotImplementedError
def predict(self, X: Sequence[Sequence[float]]) -> List[float]:
raise NotImplementedError
def get_params(self) -> Dict[str, Any]:
return {"name": self.name}
class MeanRegressor(Estimator):
"""均值回归器(stub):预测值恒为训练集 y 的均值。无外部依赖。"""
name = "mean_regressor"
def __init__(self) -> None:
self._mean: float = 0.0
def fit(self, X: Sequence[Sequence[float]], y: Sequence[float]) -> None:
if not y:
raise PipelineError("MeanRegressor 训练数据为空")
self._mean = sum(y) / len(y)
def predict(self, X: Sequence[Sequence[float]]) -> List[float]:
return [self._mean for _ in X]
class MajorityClassifier(Estimator):
"""多数分类器(stub):预测值恒为训练集 y 中出现最多的类别。"""
name = "majority_classifier"
def __init__(self) -> None:
self._majority: float = 0.0
def fit(self, X: Sequence[Sequence[float]], y: Sequence[float]) -> None:
if not y:
raise PipelineError("MajorityClassifier 训练数据为空")
counts: Dict[float, int] = {}
for v in y:
counts[v] = counts.get(v, 0) + 1
self._majority = max(counts, key=counts.get)
def predict(self, X: Sequence[Sequence[float]]) -> List[float]:
return [self._majority for _ in X]
ESTIMATORS: Dict[str, Callable[[], Estimator]] = {
"mean_regressor": MeanRegressor,
"majority_classifier": MajorityClassifier,
}
def register_estimator(name: str, factory: Callable[[], Estimator]) -> None:
"""注册自定义估计器(插件式,对齐 PRD 5.3 模板化理念)。"""
ESTIMATORS[name] = factory
# ---------------------------------------------------------------------------
# 步骤(Step):流水线的可插拔单元
# ---------------------------------------------------------------------------
@dataclass
class StepResult:
"""单步执行结果。"""
name: str
success: bool
duration_s: float = 0.0
output_keys: List[str] = field(default_factory=list)
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name, "success": self.success,
"duration_s": round(self.duration_s, 4),
"output_keys": self.output_keys, "error": self.error,
}
class Step:
"""步骤抽象基类:``prepare`` / ``run`` / ``teardown`` 三段式生命周期。
子类实现 ``run(ctx)``,通过 ``ctx.set`` 写产物、``ctx.get`` 读上游产物。
"""
def __init__(self, name: str, params: Optional[Dict[str, Any]] = None):
if not name:
raise PipelineError("Step 需要 name")
self.name = name
self.params: Dict[str, Any] = dict(params or {})
def prepare(self, ctx: Context) -> None:
"""可选的预处理(校验配置 / 加载资源)。默认空。"""
def run(self, ctx: Context) -> StepResult: # noqa: D401
raise NotImplementedError
def teardown(self, ctx: Context, success: bool) -> None:
"""可选的清理。默认空。"""
def execute(self, ctx: Context) -> StepResult:
"""模板方法:prepare → run → teardown,统一定时与异常捕获。"""
self.prepare(ctx)
start = time.time()
success = True
try:
result = self.run(ctx)
return result
except Exception as exc: # noqa: BLE001
success = False
return StepResult(name=self.name, success=False,
duration_s=time.time() - start, error=str(exc))
finally:
try:
self.teardown(ctx, success)
except Exception: # noqa: BLE001 - teardown 失败不影响主流程
pass
class LoadDataStep(Step):
"""加载训练 / 推理数据:从 CSV 或内存 list 加载到 ``ctx[data_key]``。"""
def run(self, ctx: Context) -> StepResult:
start = time.time()
data_key = self.params.get("data_key", "dataset")
source = self.params.get("source")
if source is None:
raise PipelineError("LoadDataStep 缺少 source")
if isinstance(source, str) and source.endswith(".csv"):
# 简易 CSV 加载(首行表头,其余数值)
rows: List[List[float]] = []
with open(source, "r", encoding="utf-8") as fh:
lines = [ln.strip() for ln in fh if ln.strip()]
if not lines:
raise PipelineError(f"CSV 为空:{source}")
for ln in lines[1:]: # 跳过表头
parts = ln.split(",")
rows.append([float(p) for p in parts])
ctx.set(data_key, rows)
elif isinstance(source, (list, tuple)):
ctx.set(data_key, [list(r) for r in source])
else:
raise PipelineError(f"不支持的 source 类型:{type(source)}")
return StepResult(name=self.name, success=True,
duration_s=time.time() - start,
output_keys=[data_key])
class TrainStep(Step):
"""训练步骤:用可插拔 ``Estimator`` 在 ``ctx[train_key]`` 上训练。
训练数据格式:``[(X_row..., y), ...]`` 或分别 ``X`` / ``y``。
产物写入 ``ctx[model_key]``。
"""
def run(self, ctx: Context) -> StepResult:
start = time.time()
estimator_name = self.params.get("estimator", "mean_regressor")
factory = ESTIMATORS.get(estimator_name)
if factory is None:
raise PipelineError(f"未注册的估计器:{estimator_name!r}")
est = factory()
X, y = self._extract_xy(ctx)
est.fit(X, y)
model_key = self.params.get("model_key", "model")
ctx.set(model_key, est)
ctx.metadata["estimator"] = estimator_name
return StepResult(name=self.name, success=True,
duration_s=time.time() - start,
output_keys=[model_key])
def _extract_xy(self, ctx: Context) -> Tuple[List[List[float]], List[float]]:
train_key = self.params.get("train_key", "dataset")
target_col = int(self.params.get("target_col", -1))
data = ctx.get(train_key)
if data is None:
raise PipelineError(f"训练数据不存在:{train_key}")
X: List[List[float]] = []
y: List[float] = []
for row in data:
row = list(row)
if not row:
continue
yv = row.pop(target_col)
X.append([float(v) for v in row])
y.append(float(yv))
if not X:
raise PipelineError("训练数据为空")
return X, y
class EvaluateStep(Step):
"""评估步骤:在 ``ctx[eval_key]`` 上用 ``ctx[model_key]`` 计算指标。
指标:回归(MAE / RMSE)、分类(accuracy)。产物写入 ``ctx[metrics_key]``。
"""
def run(self, ctx: Context) -> StepResult:
start = time.time()
model_key = self.params.get("model_key", "model")
eval_key = self.params.get("eval_key", "dataset")
metrics_key = self.params.get("metrics_key", "metrics")
est = ctx.get(model_key)
if est is None:
raise PipelineError(f"模型不存在:{model_key}")
# 复用 TrainStep 的 X/y 提取逻辑
helper = TrainStep("helper", {"train_key": eval_key})
X, y = helper._extract_xy(ctx)
preds = est.predict(X)
metrics: Dict[str, float] = {}
n = len(y)
# 判断分类 / 回归:y 取值种类少视为分类
unique = set(y)
if len(unique) <= max(10, n * 0.1):
correct = sum(1 for p, t in zip(preds, y) if abs(p - t) < 1e-6)
metrics["accuracy"] = correct / n if n else 0.0
mae = sum(abs(p - t) for p, t in zip(preds, y)) / n if n else 0.0
rmse = math.sqrt(sum((p - t) ** 2 for p, t in zip(preds, y)) / n) if n else 0.0
metrics["mae"] = mae
metrics["rmse"] = rmse
ctx.set(metrics_key, metrics)
return StepResult(name=self.name, success=True,
duration_s=time.time() - start,
output_keys=[metrics_key])
class RegisterStep(Step):
"""注册步骤:把 ``ctx[model_key]`` 注册到 ``ModelRegistry``(版本化)。
registry 通过 ``ctx[registry_key]`` 获取(若不存在则新建)。
"""
def run(self, ctx: Context) -> StepResult:
start = time.time()
registry_key = self.params.get("registry_key", "registry")
model_key = self.params.get("model_key", "model")
name = self.params.get("model_name", "default-model")
version = self.params.get("version")
if version in (None, ""):
version = "v" + uuid.uuid4().hex[:8]
registry = ctx.get(registry_key)
if registry is None:
registry = ModelRegistry()
ctx.set(registry_key, registry)
est = ctx.get(model_key)
if est is None:
raise PipelineError(f"模型不存在:{model_key}")
metrics = ctx.get(self.params.get("metrics_key", "metrics"), {})
artifact = ModelArtifact(
name=name, version=version, model=est,
metrics=dict(metrics) if isinstance(metrics, dict) else {},
extra={"estimator": ctx.metadata.get("estimator", "")},
)
registry.register(artifact)
ctx.metadata["registered_version"] = version
return StepResult(name=self.name, success=True,
duration_s=time.time() - start,
output_keys=[registry_key])
class LoadModelStep(Step):
"""加载步骤:从 ``ModelRegistry`` 按 name/version 加载模型到 ctx。"""
def run(self, ctx: Context) -> StepResult:
start = time.time()
registry_key = self.params.get("registry_key", "registry")
model_key = self.params.get("model_key", "serving_model")
name = self.params.get("model_name", "")
version = self.params.get("version") # 可为别名 latest/stable
registry = ctx.get(registry_key)
if not isinstance(registry, ModelRegistry):
raise PipelineError(f"registry 不存在或类型错误:{registry_key}")
artifact = registry.get(name, version)
ctx.set(model_key, artifact.model)
ctx.metadata["serving_version"] = artifact.version
return StepResult(name=self.name, success=True,
duration_s=time.time() - start,
output_keys=[model_key])
class PredictStep(Step):
"""推理步骤:用 ``ctx[model_key]`` 对 ``ctx[input_key]`` 批量预测。
产物写入 ``ctx[predictions_key]``。
"""
def run(self, ctx: Context) -> StepResult:
start = time.time()
model_key = self.params.get("model_key", "serving_model")
input_key = self.params.get("input_key", "input")
predictions_key = self.params.get("predictions_key", "predictions")
est = ctx.get(model_key)
if est is None:
raise PipelineError(f"模型不存在:{model_key}")
data = ctx.get(input_key)
if data is None:
raise PipelineError(f"输入数据不存在:{input_key}")
X = [list(row) for row in data]
preds = est.predict(X)
ctx.set(predictions_key, preds)
return StepResult(name=self.name, success=True,
duration_s=time.time() - start,
output_keys=[predictions_key])
class CustomStep(Step):
"""自定义步骤:用 ``params["handler"]``(可调用对象)执行任意逻辑。
便于在不新建子类的情况下快速接入业务代码。注意:handler 无法序列化,
仅在 Python 构造时使用,不进入 JSON 配置。
"""
def run(self, ctx: Context) -> StepResult:
start = time.time()
handler = self.params.get("handler")
if not callable(handler):
raise PipelineError("CustomStep 缺少可调用 handler")
output = handler(ctx)
out_keys = []
if isinstance(output, dict):
for k, v in output.items():
ctx.set(k, v)
out_keys.append(k)
return StepResult(name=self.name, success=True,
duration_s=time.time() - start,
output_keys=out_keys)
# ---------------------------------------------------------------------------
# 流水线(Pipeline):顺序编排若干 Step
# ---------------------------------------------------------------------------
#: 步骤类型名 → 工厂(用于从配置反序列化构建 Step)
STEP_TYPES: Dict[str, Callable[[str, Dict[str, Any]], Step]] = {
"load_data": lambda n, p: LoadDataStep(n, p),
"train": lambda n, p: TrainStep(n, p),
"evaluate": lambda n, p: EvaluateStep(n, p),
"register": lambda n, p: RegisterStep(n, p),
"load_model": lambda n, p: LoadModelStep(n, p),
"predict": lambda n, p: PredictStep(n, p),
}
def register_step_type(type_name: str, factory: Callable[[str, Dict[str, Any]], Step]) -> None:
"""注册自定义步骤类型(配置驱动构建)。"""
STEP_TYPES[type_name] = factory
@dataclass
class PipelineConfig:
"""声明式流水线配置(可序列化往返,便于配置台展示与审计)。"""
name: str
steps: List[Dict[str, Any]] = field(default_factory=list)
params: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {"name": self.name, "steps": list(self.steps),
"params": dict(self.params)}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "PipelineConfig":
return cls(name=data["name"], steps=list(data.get("steps", [])),
params=dict(data.get("params", {})))
@dataclass
class PipelineResult:
"""流水线执行结果:各步骤结果 + 是否整体成功 + 总耗时。"""
name: str
success: bool
step_results: List[StepResult] = field(default_factory=list)
total_duration_s: float = 0.0
failed_step: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name, "success": self.success,
"steps": [s.to_dict() for s in self.step_results],
"total_duration_s": round(self.total_duration_s, 4),
"failed_step": self.failed_step,
}
class Pipeline:
"""流水线编排器:顺序执行 ``Step`` 列表,自动传递 ``Context``。
用法::
pipe = Pipeline("demo", [
LoadDataStep("load", {"source": rows}),
TrainStep("train", {"estimator": "mean_regressor"}),
EvaluateStep("eval", {}),
RegisterStep("register", {"model_name": "demo"}),
])
result = pipe.run()
"""
def __init__(self, name: str, steps: Sequence[Step],
params: Optional[Dict[str, Any]] = None):
if not name:
raise PipelineError("Pipeline 需要 name")
self.name = name
self.steps: List[Step] = list(steps)
self.params: Dict[str, Any] = dict(params or {})
@classmethod
def from_config(cls, config: PipelineConfig) -> "Pipeline":
"""从声明式配置构建流水线(配置驱动,切换模型 / 数据源只改配置)。"""
steps: List[Step] = []
for sd in config.steps:
stype = sd.get("type")
sname = sd.get("name", stype)
sparams = dict(sd.get("params", {}))
factory = STEP_TYPES.get(stype or "")
if factory is None:
raise PipelineError(f"未知步骤类型:{stype!r}")
steps.append(factory(sname, sparams))
return cls(config.name, steps, config.params)
def run(self, initial_ctx: Optional[Context] = None,
dry_run: bool = False) -> PipelineResult:
"""顺序执行所有步骤;``dry_run`` 时只校验配置不执行 run。"""
ctx = initial_ctx or Context()
for k, v in self.params.items():
ctx.params.setdefault(k, v)
results: List[StepResult] = []
start = time.time()
if dry_run:
for st in self.steps:
st.prepare(ctx)
results.append(StepResult(name=st.name, success=True))
return PipelineResult(name=self.name, success=True,
step_results=results,
total_duration_s=time.time() - start)
for st in self.steps:
r = st.execute(ctx)
results.append(r)
if not r.success:
return PipelineResult(name=self.name, success=False,
step_results=results,
total_duration_s=time.time() - start,
failed_step=st.name)
return PipelineResult(name=self.name, success=True,
step_results=results,
total_duration_s=time.time() - start)