Merge PR #101-#109 (EPIC #5 模型框架 8 子任务:recipe/feature/quality/anomaly/cross-process/pipeline/registry/PoC,命名空间化整合)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
@@ -1,16 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""测试引导:把 `core/model-framework` 以包名 `model_framework` 挂载到 sys.modules。
|
||||
"""测试引导:把连字符目录 ``core/model-framework`` 加载为可导入包
|
||||
``model_framework``,使测试可 ``from model_framework import ...``。
|
||||
|
||||
目录名 `model-framework` 含连字符,无法直接以包名 import;挂载后模块内相对导入
|
||||
(`from .hyperparam import ...`)在 unittest 发现机制下可正常解析。
|
||||
与仓库内各 core 模块的测试引导同款模式(importlib 完整加载包,执行
|
||||
``__init__.py``,保持顶层导出可用)。
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
MF_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, MF_DIR)
|
||||
if "model_framework" not in sys.modules:
|
||||
pkg = types.ModuleType("model_framework")
|
||||
pkg.__path__ = [MF_DIR]
|
||||
sys.modules["model_framework"] = pkg
|
||||
PKG_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
|
||||
def _load_package(name: str, path: str) -> None:
|
||||
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("model_framework", PKG_DIR)
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""``anomaly_detection`` 单元测试(issue #37)。
|
||||
|
||||
覆盖:
|
||||
- 配方(Recipe)不可变性 / 序列化往返 / 非法主干、非法阈值策略与越界校验;
|
||||
- 主干工厂注册表 + 自定义主干注册(PRD 5.3「新增结构走插件注册」);
|
||||
- stub / iforest / lof 三类主干的 fit/decision_function 契约;
|
||||
- 固定主干 + 配方加载:同框架加载 Ti / 树脂两套配方均跑通(PRD 5.3
|
||||
验收口径);
|
||||
- Metrics 验收口径(PRD 5.3 / 里程碑:检出率 ≥ 95%、误报率 ≤ 5%);
|
||||
- 阈值策略(contamination 高分位 / sigma Nσ 法则);
|
||||
- 零外部强依赖:无 sklearn 时 stub 退化仍可加载与校验。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
import _bootstrap # noqa: E402 注册 model_framework 包
|
||||
|
||||
from model_framework.anomaly_detection import ( # noqa: E402
|
||||
AnomalyDetectionError,
|
||||
AnomalyDetectionModel,
|
||||
BACKBONES,
|
||||
Metrics,
|
||||
ModelHandle,
|
||||
Recipe,
|
||||
build_from_recipe,
|
||||
iforest_backbone,
|
||||
list_sample_recipes,
|
||||
load_recipe,
|
||||
lof_backbone,
|
||||
register_backbone,
|
||||
sample_recipe_path,
|
||||
stub_backbone,
|
||||
)
|
||||
|
||||
|
||||
def _normal_dataset(n=40, n_feat=2, seed=0):
|
||||
"""构造一组「正常」样本(围绕均值的确定性点)。"""
|
||||
X = []
|
||||
for i in range(n):
|
||||
row = []
|
||||
for j in range(n_feat):
|
||||
base = float(i % 7) + 1.0 + 0.1 * j
|
||||
row.append(base)
|
||||
X.append(row)
|
||||
return X
|
||||
|
||||
|
||||
def _labeled_dataset(n_normal=40, n_anomaly=5, n_feat=2):
|
||||
"""构造正常 + 离群点数据集,返回 (X, y_true),1=异常。"""
|
||||
X = _normal_dataset(n_normal, n_feat)
|
||||
y = [0] * n_normal
|
||||
for k in range(n_anomaly):
|
||||
# 明显远离正常区的离群点
|
||||
X.append([100.0 + k for _ in range(n_feat)])
|
||||
y.append(1)
|
||||
return X, y
|
||||
|
||||
|
||||
class TestRecipe(unittest.TestCase):
|
||||
"""配方数据对象与校验。"""
|
||||
|
||||
def test_defaults_and_immutability(self):
|
||||
r = Recipe(name="t")
|
||||
self.assertEqual(r.backbone, "iforest")
|
||||
self.assertEqual(r.threshold_policy, "contamination")
|
||||
self.assertAlmostEqual(r.recall_floor, 0.95)
|
||||
self.assertAlmostEqual(r.false_alarm_ceil, 0.05)
|
||||
with self.assertRaises(Exception):
|
||||
r.name = "other" # frozen
|
||||
|
||||
def test_roundtrip(self):
|
||||
r = Recipe(name="t", backbone="lof",
|
||||
hyperparams={"n_neighbors": 15},
|
||||
feature_columns=("a", "b"),
|
||||
threshold_policy="sigma",
|
||||
contamination=0.1, sigma=2.5,
|
||||
recall_floor=0.9, false_alarm_ceil=0.1,
|
||||
industry="树脂", notes="n")
|
||||
d = r.to_dict()
|
||||
r2 = Recipe.from_dict(d)
|
||||
self.assertEqual(r, r2)
|
||||
# JSON 往返
|
||||
r3 = Recipe.from_dict(json.loads(json.dumps(d)))
|
||||
self.assertEqual(r, r3)
|
||||
|
||||
def test_invalid_backbone_raises(self):
|
||||
with self.assertRaises(AnomalyDetectionError):
|
||||
Recipe(name="t", backbone="svm")
|
||||
|
||||
def test_invalid_threshold_policy_raises(self):
|
||||
with self.assertRaises(AnomalyDetectionError):
|
||||
Recipe(name="t", threshold_policy="quantile")
|
||||
|
||||
def test_contamination_out_of_range(self):
|
||||
with self.assertRaises(AnomalyDetectionError):
|
||||
Recipe(name="t", contamination=0.0)
|
||||
with self.assertRaises(AnomalyDetectionError):
|
||||
Recipe(name="t", contamination=1.0)
|
||||
|
||||
def test_sigma_nonpositive_raises(self):
|
||||
with self.assertRaises(AnomalyDetectionError):
|
||||
Recipe(name="t", sigma=0)
|
||||
|
||||
def test_recall_floor_out_of_range(self):
|
||||
with self.assertRaises(AnomalyDetectionError):
|
||||
Recipe(name="t", recall_floor=1.5)
|
||||
|
||||
def test_missing_name(self):
|
||||
with self.assertRaises(AnomalyDetectionError):
|
||||
Recipe(name="")
|
||||
|
||||
def test_load_recipe_from_file(self):
|
||||
path = sample_recipe_path("recipe.ti.json")
|
||||
r = load_recipe(path)
|
||||
self.assertEqual(r.name, "ti-cl4-furnace-impurity-anomaly")
|
||||
self.assertEqual(r.backbone, "iforest")
|
||||
self.assertIn("furnace_temp", r.feature_columns)
|
||||
|
||||
|
||||
class TestBackbones(unittest.TestCase):
|
||||
"""主干工厂与注册表。"""
|
||||
|
||||
def test_builtin_backbones_registered(self):
|
||||
for name in ("iforest", "lof", "stub"):
|
||||
self.assertIn(name, BACKBONES)
|
||||
|
||||
def test_register_custom_backbone(self):
|
||||
class _Custom(ModelHandle):
|
||||
def __init__(self, p):
|
||||
super().__init__("custom", p)
|
||||
self._v = 1.0
|
||||
|
||||
def _fit_impl(self, X):
|
||||
self._v = sum(sum(r) for r in X) / (len(X) * len(X[0]))
|
||||
|
||||
def _score_one(self, row):
|
||||
# 离均值越远分数越高
|
||||
return abs(sum(float(v) for v in row) - self._v)
|
||||
|
||||
register_backbone("custom_test", lambda p: _Custom(p))
|
||||
m = AnomalyDetectionModel(backbone="custom_test")
|
||||
X = _normal_dataset()
|
||||
m.fit(X)
|
||||
self.assertEqual(len(m.predict(X)), len(X))
|
||||
# 清理避免污染其它用例
|
||||
BACKBONES.pop("custom_test", None)
|
||||
|
||||
def test_unknown_backbone_raises(self):
|
||||
with self.assertRaises(AnomalyDetectionError):
|
||||
AnomalyDetectionModel(backbone="not_a_backbone")
|
||||
|
||||
def test_stub_score_is_deterministic_and_nonneg(self):
|
||||
h = stub_backbone({})
|
||||
X = _normal_dataset()
|
||||
h.fit(X)
|
||||
s1 = h.decision_function(X)
|
||||
s2 = h.decision_function(X)
|
||||
self.assertEqual(s1, s2)
|
||||
self.assertTrue(all(isinstance(v, float) for v in s1))
|
||||
self.assertTrue(all(v >= 0 for v in s1))
|
||||
|
||||
def test_decision_before_fit_raises(self):
|
||||
h = stub_backbone({})
|
||||
with self.assertRaises(AnomalyDetectionError):
|
||||
h.decision_function([[1.0, 2.0]])
|
||||
|
||||
def test_fit_empty_raises(self):
|
||||
h = stub_backbone({})
|
||||
with self.assertRaises(AnomalyDetectionError):
|
||||
h.fit([])
|
||||
|
||||
def test_iforest_factory_runs_with_or_without_sklearn(self):
|
||||
# 无论 sklearn 是否存在都不应报错
|
||||
h = iforest_backbone({"n_estimators": 20})
|
||||
X = _normal_dataset()
|
||||
h.fit(X)
|
||||
scores = h.decision_function(X)
|
||||
self.assertEqual(len(scores), len(X))
|
||||
|
||||
|
||||
class TestModelContract(unittest.TestCase):
|
||||
"""模型 fit/decision_function/predict 契约。"""
|
||||
|
||||
def test_fit_predict_shapes(self):
|
||||
m = AnomalyDetectionModel(backbone="stub")
|
||||
X = _normal_dataset(20)
|
||||
m.fit(X)
|
||||
self.assertTrue(m.fitted)
|
||||
self.assertIsNotNone(m.threshold)
|
||||
preds = m.predict(X)
|
||||
self.assertEqual(len(preds), len(X))
|
||||
self.assertTrue(all(p in (0, 1) for p in preds))
|
||||
|
||||
def test_predict_before_fit_raises(self):
|
||||
m = AnomalyDetectionModel(backbone="stub")
|
||||
with self.assertRaises(AnomalyDetectionError):
|
||||
m.predict([[1.0, 2.0]])
|
||||
|
||||
def test_decision_before_fit_raises(self):
|
||||
m = AnomalyDetectionModel(backbone="stub")
|
||||
with self.assertRaises(AnomalyDetectionError):
|
||||
m.decision_function([[1.0, 2.0]])
|
||||
|
||||
def test_fit_empty_raises(self):
|
||||
m = AnomalyDetectionModel(backbone="stub")
|
||||
with self.assertRaises(AnomalyDetectionError):
|
||||
m.fit([])
|
||||
|
||||
def test_to_dict_roundtrip_meta(self):
|
||||
m = AnomalyDetectionModel(backbone="iforest",
|
||||
hyperparams={"n_estimators": 5},
|
||||
feature_columns=["a"],
|
||||
threshold_policy="sigma", sigma=2.0)
|
||||
d = m.to_dict()
|
||||
self.assertEqual(d["recipe_meta"]["backbone"], "iforest")
|
||||
self.assertEqual(d["recipe_meta"]["threshold_policy"], "sigma")
|
||||
self.assertIn("handle", d)
|
||||
|
||||
def test_threshold_contamination_isolate_outliers(self):
|
||||
"""contamination 阈值应把注入的离群点判为异常。"""
|
||||
m = AnomalyDetectionModel(
|
||||
backbone="stub", threshold_policy="contamination",
|
||||
contamination=0.10)
|
||||
X, y_true = _labeled_dataset(n_normal=40, n_anomaly=5)
|
||||
m.fit(X)
|
||||
preds = m.predict(X)
|
||||
# 注入的 5 个离群点应被全部判异常
|
||||
self.assertEqual(sum(preds[40:]), 5)
|
||||
|
||||
def test_threshold_sigma_isolate_outliers(self):
|
||||
"""sigma 阈值也应把注入的极端离群点判为异常。"""
|
||||
m = AnomalyDetectionModel(
|
||||
backbone="stub", threshold_policy="sigma", sigma=2.0)
|
||||
X, y_true = _labeled_dataset(n_normal=40, n_anomaly=5)
|
||||
m.fit(X)
|
||||
preds = m.predict(X)
|
||||
self.assertEqual(sum(preds[40:]), 5)
|
||||
|
||||
|
||||
class TestMetrics(unittest.TestCase):
|
||||
"""验收口径(PRD 5.3:检出率 ≥ 95%、误报率 ≤ 5%)。"""
|
||||
|
||||
def test_perfect_predictions_pass(self):
|
||||
y = [1, 1, 0, 0, 0]
|
||||
met = Metrics.compute(y, y, recall_floor=0.95, false_alarm_ceil=0.05)
|
||||
self.assertAlmostEqual(met.recall, 1.0)
|
||||
self.assertAlmostEqual(met.false_alarm_rate, 0.0)
|
||||
self.assertAlmostEqual(met.f1, 1.0)
|
||||
self.assertTrue(met.passed)
|
||||
|
||||
def test_all_miss_fails(self):
|
||||
y_true = [1, 1, 0, 0]
|
||||
y_pred = [0, 0, 0, 0] # 漏检全部异常
|
||||
met = Metrics.compute(y_true, y_pred)
|
||||
self.assertAlmostEqual(met.recall, 0.0)
|
||||
self.assertFalse(met.passed)
|
||||
|
||||
def test_high_false_alarm_fails(self):
|
||||
y_true = [1, 0, 0, 0, 0]
|
||||
y_pred = [1, 1, 1, 1, 1] # 全判异常:检出但误报爆表
|
||||
met = Metrics.compute(y_true, y_pred, false_alarm_ceil=0.05)
|
||||
self.assertAlmostEqual(met.recall, 1.0)
|
||||
self.assertGreater(met.false_alarm_rate, 0.05)
|
||||
self.assertFalse(met.passed)
|
||||
|
||||
def test_length_mismatch_raises(self):
|
||||
with self.assertRaises(AnomalyDetectionError):
|
||||
Metrics.compute([1, 0], [1])
|
||||
|
||||
def test_empty_raises(self):
|
||||
with self.assertRaises(AnomalyDetectionError):
|
||||
Metrics.compute([], [])
|
||||
|
||||
def test_no_anomaly_in_true_recall_zero_div_safe(self):
|
||||
# 无真实异常时 recall 定义为 0,不应抛 ZeroDivision
|
||||
met = Metrics.compute([0, 0, 0], [0, 0, 0])
|
||||
self.assertEqual(met.recall, 0.0)
|
||||
self.assertEqual(met.n_anomaly_true, 0)
|
||||
|
||||
def test_evaluate_end_to_end(self):
|
||||
m = AnomalyDetectionModel(backbone="stub", threshold_policy="sigma",
|
||||
sigma=2.0)
|
||||
X, y_true = _labeled_dataset(n_normal=40, n_anomaly=5)
|
||||
m.fit(X)
|
||||
met = m.evaluate(X, y_true)
|
||||
self.assertIsInstance(met, Metrics)
|
||||
# 离群点应被检出(stub 在极端离群点上召回=1)
|
||||
self.assertEqual(met.recall, 1.0)
|
||||
|
||||
|
||||
class TestSampleRecipes(unittest.TestCase):
|
||||
"""样例协议:同框架加载 Ti / 树脂两套配方均跑通(PRD 5.3 验收口径)。"""
|
||||
|
||||
def test_samples_present(self):
|
||||
names = list_sample_recipes()
|
||||
self.assertIn("recipe.ti.json", names)
|
||||
self.assertIn("recipe.resin.json", names)
|
||||
|
||||
def test_build_from_each_sample_runs(self):
|
||||
for name in ("recipe.ti.json", "recipe.resin.json"):
|
||||
m = build_from_recipe(sample_recipe_path(name))
|
||||
self.assertIn(
|
||||
m.recipe_meta["backbone"], ("iforest", "lof", "stub"))
|
||||
feat = m.recipe_meta["feature_columns"]
|
||||
n_feat = len(feat)
|
||||
self.assertGreater(n_feat, 0)
|
||||
X = [[float(i + j) for j in range(n_feat)] for i in range(30)]
|
||||
# 注入离群点
|
||||
for k in range(3):
|
||||
X.append([100.0 + k for _ in range(n_feat)])
|
||||
y_true = [0] * 30 + [1] * 3
|
||||
m.fit(X)
|
||||
preds = m.predict(X)
|
||||
self.assertEqual(len(preds), len(y_true))
|
||||
met = m.evaluate(X, y_true)
|
||||
self.assertIsInstance(met, Metrics)
|
||||
|
||||
def test_two_recipes_share_same_code(self):
|
||||
"""切换模板仅改配方,模型代码零改动(PRD 5.3)。"""
|
||||
m1 = build_from_recipe(sample_recipe_path("recipe.ti.json"))
|
||||
m2 = build_from_recipe(sample_recipe_path("recipe.resin.json"))
|
||||
self.assertEqual(type(m1), type(m2))
|
||||
self.assertNotEqual(m1.recipe_meta.get("recipe_name"),
|
||||
m2.recipe_meta.get("recipe_name"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,340 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""跨工序寻优模型模板化单元测试(issue #38)。
|
||||
|
||||
覆盖:
|
||||
- 数据对象(DecisionVariable / Stage / Constraint / Objective / Recipe)的
|
||||
构造、校验、序列化往返;
|
||||
- 受限表达式求值 ``_safe_eval``(拒绝危险内建/属性访问);
|
||||
- 四种求解器(grid / random / analytic / stub)的可行解搜索与目标最大化;
|
||||
- 主干 ``CrossProcessOptimizer.optimize`` + ``build_from_recipe``;
|
||||
- 采纳率口径(PRD 5.3 ≥ 60%)与可解释建议(StageSuggestion 方向);
|
||||
- 样例配方(Ti / 树脂)均能加载并寻优跑通(验收口径)。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
if HERE not in sys.path:
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
import _bootstrap # noqa: E402 加载 model_framework 包
|
||||
|
||||
from model_framework.cross_process_optimizer import ( # noqa: E402
|
||||
Constraint,
|
||||
CrossProcessOptError,
|
||||
CrossProcessOptimizer,
|
||||
DecisionVariable,
|
||||
Objective,
|
||||
OptimizationResult,
|
||||
Recipe,
|
||||
Stage,
|
||||
StageSuggestion,
|
||||
SOLVERS,
|
||||
build_from_recipe,
|
||||
list_sample_recipes,
|
||||
load_recipe,
|
||||
register_solver,
|
||||
sample_recipe_path,
|
||||
stub_solver,
|
||||
)
|
||||
|
||||
|
||||
def _two_stage_recipe(solver: str = "grid") -> Recipe:
|
||||
"""构造一个简单的两工序寻优配方用于测试。"""
|
||||
s1 = Stage(
|
||||
name="upstream",
|
||||
decision_vars=(
|
||||
DecisionVariable("u_temp", 100, 200, step=20, default=120),
|
||||
),
|
||||
transfer_vars=("u_yield",),
|
||||
proxy="(u_temp - 100) / 100",
|
||||
)
|
||||
s2 = Stage(
|
||||
name="downstream",
|
||||
decision_vars=(
|
||||
DecisionVariable("d_pressure", 1, 5, step=1, default=2),
|
||||
),
|
||||
transfer_vars=("quality",),
|
||||
proxy="u_yield * 0.5 + d_pressure * 0.1",
|
||||
)
|
||||
return Recipe(
|
||||
name="test-recipe",
|
||||
stages=(s1, s2),
|
||||
constraints=(
|
||||
Constraint("u_temp", "<=", 200, label="安全上限"),
|
||||
Constraint("d_pressure", ">=", 1, label="压力下限"),
|
||||
),
|
||||
objective=Objective("quality", "max", label="质量"),
|
||||
solver=solver,
|
||||
acceptance_floor=0.6,
|
||||
)
|
||||
|
||||
|
||||
class TestDataObjects(unittest.TestCase):
|
||||
"""数据对象构造、校验、序列化往返。"""
|
||||
|
||||
def test_decision_variable_grid_points(self):
|
||||
v = DecisionVariable("x", 0, 10, step=2)
|
||||
self.assertEqual(v.grid_points(), [0, 2, 4, 6, 8, 10])
|
||||
|
||||
def test_decision_variable_rejects_invalid_range(self):
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
DecisionVariable("x", 10, 0)
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
DecisionVariable("x", 0, 10, step=0)
|
||||
|
||||
def test_decision_variable_roundtrip(self):
|
||||
v = DecisionVariable("x", 1.5, 3.5, step=0.5, unit="MPa", default=2.0)
|
||||
v2 = DecisionVariable.from_dict(v.to_dict())
|
||||
self.assertEqual(v, v2)
|
||||
|
||||
def test_constraint_operators(self):
|
||||
ns = {"x": 5}
|
||||
self.assertTrue(Constraint("x", "<=", 5).satisfied(ns))
|
||||
self.assertTrue(Constraint("x", ">=", 5).satisfied(ns))
|
||||
self.assertTrue(Constraint("x", "==", 5).satisfied(ns))
|
||||
self.assertFalse(Constraint("x", "<=", 4).satisfied(ns))
|
||||
self.assertFalse(Constraint("x", ">=", 6).satisfied(ns))
|
||||
|
||||
def test_constraint_rejects_bad_op(self):
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
Constraint("x", "!=", 0)
|
||||
|
||||
def test_objective_score_min_inverts(self):
|
||||
obj = Objective("x", "min")
|
||||
# 最小化:x=5 的标准化分数应为 -5(越大越好 = 越小原值)
|
||||
self.assertAlmostEqual(obj.score({"x": 5}), -5.0)
|
||||
|
||||
def test_objective_rejects_bad_sense(self):
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
Objective("x", "avg")
|
||||
|
||||
def test_recipe_requires_stages(self):
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
Recipe(name="x", stages=())
|
||||
|
||||
def test_recipe_rejects_bad_solver(self):
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
Recipe(name="x", stages=(Stage(name="s"),), solver="magic")
|
||||
|
||||
def test_recipe_rejects_bad_acceptance(self):
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
Recipe(name="x", stages=(Stage(name="s"),), acceptance_floor=1.5)
|
||||
|
||||
def test_recipe_roundtrip(self):
|
||||
r = _two_stage_recipe()
|
||||
r2 = Recipe.from_dict(r.to_dict())
|
||||
self.assertEqual(r, r2)
|
||||
self.assertEqual(r2.stages[0].decision_vars[0].name, "u_temp")
|
||||
|
||||
|
||||
class TestSafeEval(unittest.TestCase):
|
||||
"""受限表达式求值安全性。"""
|
||||
|
||||
def test_safe_eval_basic(self):
|
||||
from model_framework.cross_process_optimizer import _safe_eval
|
||||
self.assertAlmostEqual(_safe_eval("1 + 2 * 3", {}), 7.0)
|
||||
self.assertAlmostEqual(_safe_eval("x + y", {"x": 1, "y": 2}), 3.0)
|
||||
self.assertAlmostEqual(_safe_eval("min(x, y)", {"x": 1, "y": 2}), 1.0)
|
||||
|
||||
def test_safe_eval_rejects_empty(self):
|
||||
from model_framework.cross_process_optimizer import _safe_eval
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
_safe_eval("", {})
|
||||
|
||||
def test_safe_eval_rejects_builtins(self):
|
||||
"""禁止访问 __import__ / open / 任意内建(沙箱保护)。"""
|
||||
from model_framework.cross_process_optimizer import _safe_eval
|
||||
with self.assertRaises(Exception):
|
||||
_safe_eval("__import__('os')", {})
|
||||
with self.assertRaises(Exception):
|
||||
_safe_eval("open('x')", {})
|
||||
|
||||
|
||||
class TestSolvers(unittest.TestCase):
|
||||
"""四种求解器的可行解搜索与目标最大化。"""
|
||||
|
||||
def test_grid_solver_finds_feasible(self):
|
||||
r = _two_stage_recipe("grid")
|
||||
opt = CrossProcessOptimizer(r)
|
||||
res = opt.optimize()
|
||||
self.assertIsInstance(res, OptimizationResult)
|
||||
self.assertGreater(res.feasible_count, 0)
|
||||
self.assertGreaterEqual(res.objective_score, res.baseline_score)
|
||||
|
||||
def test_grid_solver_no_feasible_raises(self):
|
||||
# 矛盾约束:温度必须同时 <= 100 且 >= 200
|
||||
r = Recipe(
|
||||
name="infeasible",
|
||||
stages=(Stage(name="s",
|
||||
decision_vars=(DecisionVariable("x", 100, 300, step=50, default=150),)),),
|
||||
constraints=(Constraint("x", "<=", 100), Constraint("x", ">=", 200)),
|
||||
objective=Objective("x", "max"),
|
||||
solver="grid",
|
||||
)
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
CrossProcessOptimizer(r).optimize()
|
||||
|
||||
def test_random_solver_finds_feasible(self):
|
||||
r = _two_stage_recipe("random")
|
||||
res = CrossProcessOptimizer(r).optimize(seed=42)
|
||||
self.assertGreater(res.feasible_count, 0)
|
||||
self.assertEqual(res.solver, "random")
|
||||
|
||||
def test_random_solver_uses_solver_params(self):
|
||||
r = _two_stage_recipe("random")
|
||||
r = Recipe.from_dict({**r.to_dict(),
|
||||
"solver_params": {"n_samples": 50, "seed": 7}})
|
||||
res = CrossProcessOptimizer(r).optimize()
|
||||
self.assertGreater(res.feasible_count, 0)
|
||||
|
||||
def test_analytic_solver_single_var(self):
|
||||
# 单变量线性最大化目标:应在 high 边界取得最优
|
||||
r = Recipe(
|
||||
name="single",
|
||||
stages=(Stage(name="s",
|
||||
decision_vars=(DecisionVariable("x", 0, 10, step=1, default=2),)),),
|
||||
objective=Objective("x", "max", label="越大越好"),
|
||||
solver="analytic",
|
||||
)
|
||||
res = CrossProcessOptimizer(r).optimize()
|
||||
self.assertEqual(res.objective_score, 10.0)
|
||||
# 建议把 x 从默认 2 上调到 10
|
||||
sug = res.suggestions[0]
|
||||
self.assertEqual(sug.new_value, 10.0)
|
||||
self.assertEqual(sug.direction, "上调")
|
||||
|
||||
def test_analytic_falls_back_to_grid_for_multi_var(self):
|
||||
r = _two_stage_recipe("analytic")
|
||||
res = CrossProcessOptimizer(r).optimize()
|
||||
# 多变量时 analytic 退化为 grid,仍能跑通
|
||||
self.assertGreater(res.feasible_count, 0)
|
||||
|
||||
def test_analytic_no_feasible_raises(self):
|
||||
r = Recipe(
|
||||
name="bad",
|
||||
stages=(Stage(name="s",
|
||||
decision_vars=(DecisionVariable("x", 0, 10, step=1, default=5),)),),
|
||||
constraints=(Constraint("x", ">=", 100),),
|
||||
objective=Objective("x", "max"),
|
||||
solver="analytic",
|
||||
)
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
CrossProcessOptimizer(r).optimize()
|
||||
|
||||
def test_stub_solver_returns_default(self):
|
||||
r = _two_stage_recipe("stub")
|
||||
res = CrossProcessOptimizer(r).optimize()
|
||||
# stub 直接取默认值,改善为 0
|
||||
self.assertEqual(res.improvement, 0.0)
|
||||
self.assertEqual(res.solver, "stub")
|
||||
|
||||
def test_unknown_solver_raises(self):
|
||||
r = Recipe.from_dict({**_two_stage_recipe().to_dict(), "solver": "grid"})
|
||||
# 临时篡改 recipe.solver 为非法值(绕过校验)测主干分支
|
||||
object.__setattr__(r, "solver", "voodoo")
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
CrossProcessOptimizer(r).optimize()
|
||||
|
||||
|
||||
class TestAcceptanceAndSuggestions(unittest.TestCase):
|
||||
"""采纳率口径(PRD 5.3 ≥ 60%)与可解释建议。"""
|
||||
|
||||
def test_grid_improvement_marks_accepted(self):
|
||||
r = _two_stage_recipe("grid")
|
||||
# 默认值非最优,grid 应能找到更优解 → accepted
|
||||
res = CrossProcessOptimizer(r).optimize()
|
||||
if res.improvement > 1e-9:
|
||||
self.assertTrue(res.accepted)
|
||||
self.assertGreaterEqual(res.acceptance, res.acceptance_floor)
|
||||
|
||||
def test_suggestion_direction(self):
|
||||
s_up = StageSuggestion("s", "x", 1.0, 3.0, 2.0)
|
||||
self.assertEqual(s_up.direction, "上调")
|
||||
s_down = StageSuggestion("s", "x", 3.0, 1.0, -2.0)
|
||||
self.assertEqual(s_down.direction, "下调")
|
||||
s_keep = StageSuggestion("s", "x", 2.0, 2.0, 0.0)
|
||||
self.assertEqual(s_keep.direction, "保持")
|
||||
|
||||
def test_result_to_dict_serializable(self):
|
||||
r = _two_stage_recipe("stub")
|
||||
res = CrossProcessOptimizer(r).optimize()
|
||||
d = res.to_dict()
|
||||
# 可 JSON 序列化
|
||||
json.dumps(d)
|
||||
self.assertIn("suggestions", d)
|
||||
self.assertIn("accepted", d)
|
||||
|
||||
|
||||
class TestSampleRecipes(unittest.TestCase):
|
||||
"""样例配方(Ti / 树脂)加载与寻优(验收口径)。"""
|
||||
|
||||
def test_sample_recipes_listed(self):
|
||||
names = list_sample_recipes()
|
||||
self.assertIn("recipe.ti.json", names)
|
||||
self.assertIn("recipe.resin.json", names)
|
||||
|
||||
def test_ti_recipe_loads_and_optimizes(self):
|
||||
opt = build_from_recipe(sample_recipe_path("recipe.ti.json"))
|
||||
res = opt.optimize()
|
||||
self.assertEqual(res.solver, "grid")
|
||||
self.assertGreater(res.feasible_count, 0)
|
||||
self.assertGreaterEqual(res.objective_score, res.baseline_score)
|
||||
# 工序建议覆盖三道工序
|
||||
stages_covered = {s.stage for s in res.suggestions}
|
||||
self.assertEqual(stages_covered, {"氯化", "精制", "还原"})
|
||||
|
||||
def test_resin_recipe_loads_and_optimizes(self):
|
||||
opt = build_from_recipe(sample_recipe_path("recipe.resin.json"))
|
||||
res = opt.optimize()
|
||||
self.assertEqual(res.solver, "random")
|
||||
self.assertGreater(res.feasible_count, 0)
|
||||
stages_covered = {s.stage for s in res.suggestions}
|
||||
self.assertEqual(stages_covered, {"反应", "水洗", "干燥"})
|
||||
|
||||
def test_two_recipes_same_engine_class(self):
|
||||
"""验收口径:同框架加载两套配方,寻优主干类零改动。"""
|
||||
opt_ti = build_from_recipe(sample_recipe_path("recipe.ti.json"))
|
||||
opt_resin = build_from_recipe(sample_recipe_path("recipe.resin.json"))
|
||||
self.assertIs(type(opt_ti), type(opt_resin))
|
||||
# 两套配方的工序拓扑确实不同
|
||||
self.assertNotEqual(opt_ti.recipe_meta["stages"],
|
||||
opt_resin.recipe_meta["stages"])
|
||||
|
||||
def test_load_recipe_from_temp_file(self):
|
||||
r = _two_stage_recipe()
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False, encoding="utf-8") as fh:
|
||||
json.dump(r.to_dict(), fh, ensure_ascii=False)
|
||||
path = fh.name
|
||||
try:
|
||||
r2 = load_recipe(path)
|
||||
self.assertEqual(r, r2)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
class TestRegisterSolver(unittest.TestCase):
|
||||
"""插件式求解器注册。"""
|
||||
|
||||
def test_register_custom_solver(self):
|
||||
called = {"n": 0}
|
||||
|
||||
def my_solver(recipe, **kw):
|
||||
called["n"] += 1
|
||||
return stub_solver(recipe, **kw)
|
||||
|
||||
register_solver("my", my_solver)
|
||||
self.assertIn("my", SOLVERS)
|
||||
# 直接构造主干并替换 recipe.solver 为已注册的自定义求解器
|
||||
r = _two_stage_recipe()
|
||||
object.__setattr__(r, "solver", "my")
|
||||
CrossProcessOptimizer(r).optimize()
|
||||
self.assertEqual(called["n"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,334 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FeatureSpec 声明式特征定义引擎测试(issue #35)。
|
||||
|
||||
覆盖:
|
||||
1. 解析:算子调用、裸点位、数值/窗口字面量、嵌套、中文点位、带符号数值;
|
||||
2. 解析错误:空 spec、非法字符、括号不匹配、多余内容、参数缺失;
|
||||
3. 语义校验:未知算子、arity 不匹配、参数 kind 错误;
|
||||
4. 依赖分析:resolve_inputs 去重与顺序、嵌套算子依赖汇总;
|
||||
5. 执行:EMA/SMA/RollingStd/RateOfChange/Diff/Lag/Log/Scale/Clip/Combine 的
|
||||
数值正确性,缺失点位 fail-fast;
|
||||
6. 插件注册:register_operator 扩展新算子;
|
||||
7. 往返:to_dict/repr 稳定。
|
||||
"""
|
||||
import math
|
||||
import unittest
|
||||
|
||||
import _bootstrap # noqa: F401 挂载包名
|
||||
|
||||
from model_framework.feature_spec import (
|
||||
FeatureAST,
|
||||
Number,
|
||||
OpCall,
|
||||
OPERATORS,
|
||||
ParseError,
|
||||
SpecIssue,
|
||||
TagRef,
|
||||
Window,
|
||||
describe,
|
||||
materialize,
|
||||
parse,
|
||||
register_operator,
|
||||
resolve_inputs,
|
||||
validate,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 解析
|
||||
# ---------------------------------------------------------------------------
|
||||
class ParseTest(unittest.TestCase):
|
||||
def test_simple_op_with_window(self):
|
||||
ast = parse("EMA(CLF-01.TEMP, 5m)")
|
||||
self.assertEqual(
|
||||
ast, OpCall("EMA", (TagRef("CLF-01.TEMP"), Window(5.0, "m")))
|
||||
)
|
||||
|
||||
def test_simple_op_with_number_window(self):
|
||||
ast = parse("RollingStd(CLF-01.CL2, 10)")
|
||||
self.assertEqual(ast, OpCall("RollingStd", (TagRef("CLF-01.CL2"), Number(10))))
|
||||
|
||||
def test_bare_tag(self):
|
||||
self.assertEqual(parse("炉压"), TagRef("炉压"))
|
||||
|
||||
def test_tag_with_dots_and_dash(self):
|
||||
self.assertEqual(parse("A.B-C_01"), TagRef("A.B-C_01"))
|
||||
|
||||
def test_signed_and_scientific_number(self):
|
||||
ast = parse("Scale(A, -0.5)")
|
||||
self.assertEqual(ast, OpCall("Scale", (TagRef("A"), Number(-0.5))))
|
||||
ast2 = parse("Scale(A, 1e-3)")
|
||||
self.assertAlmostEqual(ast2.args[1].value, 0.001)
|
||||
|
||||
def test_nested_op(self):
|
||||
# 嵌套:外层 Scale,内层 EMA 作为第一个参数点位位置(语法合法,语义由算子判定)
|
||||
ast = parse("Combine(EMA(A, 5m), B)")
|
||||
self.assertEqual(ast.name, "Combine")
|
||||
self.assertEqual(len(ast.args), 2)
|
||||
self.assertEqual(ast.args[0].name, "EMA")
|
||||
|
||||
def test_no_arg_op(self):
|
||||
ast = parse("Diff()")
|
||||
self.assertEqual(ast, OpCall("Diff", ()))
|
||||
|
||||
def test_integer_window_vs_number(self):
|
||||
self.assertEqual(parse("Lag(A, 3)").args[1], Number(3))
|
||||
self.assertEqual(parse("Lag(A, 3m)").args[1], Window(3.0, "m"))
|
||||
|
||||
def test_repr_roundtrip(self):
|
||||
for spec in ["EMA(CLF-01.TEMP, 5m)", "RateOfChange(炉压)", "Clip(P, -1, 1)"]:
|
||||
self.assertEqual(repr(parse(spec)).replace(" ", ""), spec.replace(" ", ""))
|
||||
|
||||
# ---- 解析错误 ----
|
||||
def test_empty_raises(self):
|
||||
with self.assertRaises((ValueError, ParseError)):
|
||||
parse("")
|
||||
with self.assertRaises((ValueError, ParseError)):
|
||||
parse(" ")
|
||||
|
||||
def test_non_string_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
parse(123) # type: ignore[arg-type]
|
||||
|
||||
def test_unrecognized_char(self):
|
||||
with self.assertRaises(ParseError) as cm:
|
||||
parse("EMA(A, 5m) @")
|
||||
self.assertIsNotNone(cm.exception.position)
|
||||
|
||||
def test_missing_rparen(self):
|
||||
with self.assertRaises(ParseError):
|
||||
parse("EMA(A, 5m")
|
||||
|
||||
def test_missing_rparen_inner(self):
|
||||
with self.assertRaises(ParseError):
|
||||
parse("EMA(A, (5m)")
|
||||
|
||||
def test_trailing_garbage(self):
|
||||
with self.assertRaises(ParseError):
|
||||
parse("EMA(A, 5m) B")
|
||||
|
||||
def test_missing_arg_after_comma(self):
|
||||
with self.assertRaises(ParseError):
|
||||
parse("EMA(A, )")
|
||||
|
||||
def test_starts_with_paren(self):
|
||||
with self.assertRaises(ParseError):
|
||||
parse("(A)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 语义校验
|
||||
# ---------------------------------------------------------------------------
|
||||
class ValidateTest(unittest.TestCase):
|
||||
def test_known_op_valid(self):
|
||||
self.assertEqual(validate(parse("EMA(A, 5m)")), [])
|
||||
|
||||
def test_unknown_operator(self):
|
||||
issues = validate(parse("FooBar(A, 5m)"))
|
||||
self.assertEqual(len(issues), 1)
|
||||
self.assertEqual(issues[0].code, "unknown_operator")
|
||||
|
||||
def test_arity_too_few(self):
|
||||
issues = validate(parse("EMA(A)"))
|
||||
self.assertTrue(any(i.code == "arity" for i in issues))
|
||||
|
||||
def test_arity_too_many(self):
|
||||
issues = validate(parse("EMA(A, 5m, 7)"))
|
||||
self.assertTrue(any(i.code == "arity" for i in issues))
|
||||
|
||||
def test_bad_arg_kind_number_where_window(self):
|
||||
# EMA 第二参数允许 window/number,故合法
|
||||
self.assertEqual(validate(parse("EMA(A, 7)")), [])
|
||||
# 但 tag 位置传 number 非法
|
||||
issues = validate(parse("EMA(5, 7)"))
|
||||
self.assertTrue(any(i.code == "bad_arg" for i in issues))
|
||||
|
||||
def test_combine_varargs(self):
|
||||
self.assertEqual(validate(parse("Combine(A, B, C)")), [])
|
||||
issues = validate(parse("Combine(A)"))
|
||||
self.assertTrue(any(i.code == "arity" for i in issues))
|
||||
|
||||
def test_nested_unknown(self):
|
||||
issues = validate(parse("Combine(Foo(A), B)"))
|
||||
self.assertTrue(any(i.code == "unknown_operator" for i in issues))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 依赖分析
|
||||
# ---------------------------------------------------------------------------
|
||||
class ResolveInputsTest(unittest.TestCase):
|
||||
def test_single_tag(self):
|
||||
self.assertEqual(resolve_inputs(parse("炉压")), ["炉压"])
|
||||
|
||||
def test_dedup_order(self):
|
||||
# 同一点位重复出现,去重且保持首次出现顺序
|
||||
self.assertEqual(resolve_inputs(parse("Combine(A, A)")), ["A"])
|
||||
|
||||
def test_multiple_tags(self):
|
||||
self.assertEqual(resolve_inputs(parse("Combine(A.tank1, A.tank2)")), ["A.tank1", "A.tank2"])
|
||||
|
||||
def test_op_collects_input(self):
|
||||
self.assertEqual(resolve_inputs(parse("EMA(CLF-01.TEMP, 5m)")), ["CLF-01.TEMP"])
|
||||
|
||||
def test_number_window_no_inputs(self):
|
||||
# 裸数值/窗口虽不是合法特征根,但 resolve_inputs 不报错
|
||||
self.assertEqual(resolve_inputs(Number(3)), [])
|
||||
self.assertEqual(resolve_inputs(Window(5.0, "m")), [])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 执行
|
||||
# ---------------------------------------------------------------------------
|
||||
class MaterializeTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# 一个稳定的伪时序:1..10
|
||||
self.series = {"A": [float(i) for i in range(1, 11)]} # 1..10
|
||||
|
||||
def test_bare_tag(self):
|
||||
self.assertEqual(materialize(parse("A"), self.series), self.series["A"])
|
||||
|
||||
def test_number(self):
|
||||
self.assertEqual(materialize(Number(3), {}), 3)
|
||||
|
||||
def test_sma_window3(self):
|
||||
out = materialize(parse("SMA(A, 3)"), self.series)
|
||||
# 前 2 个 NaN,第 3 个 = (1+2+3)/3 = 2.0
|
||||
self.assertTrue(math.isnan(out[0]) and math.isnan(out[1]))
|
||||
self.assertAlmostEqual(out[2], 2.0)
|
||||
self.assertAlmostEqual(out[9], (8 + 9 + 10) / 3)
|
||||
|
||||
def test_ema_decreasing_weight(self):
|
||||
out = materialize(parse("EMA(A, 5)"), self.series)
|
||||
# EMA 单调(输入单调增),首值 = 首个观测
|
||||
self.assertAlmostEqual(out[0], 1.0)
|
||||
self.assertTrue(all(out[i] <= out[i + 1] for i in range(len(out) - 1)))
|
||||
|
||||
def test_rolling_std(self):
|
||||
out = materialize(parse("RollingStd(A, 2)"), self.series)
|
||||
self.assertTrue(math.isnan(out[0]))
|
||||
# std(1,2) 无偏 = 0.7071...
|
||||
self.assertAlmostEqual(out[1], math.sqrt(0.5))
|
||||
|
||||
def test_rolling_max_min(self):
|
||||
mx = materialize(parse("RollingMax(A, 3)"), self.series)
|
||||
mn = materialize(parse("RollingMin(A, 3)"), self.series)
|
||||
self.assertEqual(mx[2], 3.0)
|
||||
self.assertEqual(mn[2], 1.0)
|
||||
|
||||
def test_diff(self):
|
||||
out = materialize(parse("Diff(A)"), self.series)
|
||||
self.assertTrue(math.isnan(out[0]))
|
||||
self.assertTrue(all(out[i] == 1.0 for i in range(1, len(out))))
|
||||
|
||||
def test_lag(self):
|
||||
out = materialize(parse("Lag(A, 2)"), self.series)
|
||||
self.assertTrue(math.isnan(out[0]) and math.isnan(out[1]))
|
||||
self.assertEqual(out[2], 1.0)
|
||||
|
||||
def test_rate_of_change(self):
|
||||
# 常数序列 → 变化率为 0(非 NaN;NaN 仅出现在前 window 步预热)
|
||||
const = {"C": [5.0] * 6}
|
||||
out = materialize(parse("RateOfChange(C)"), const)
|
||||
self.assertTrue(math.isnan(out[0])) # 预热步 NaN
|
||||
self.assertEqual(out[1], 0.0)
|
||||
# 含 0 的序列 → 分母为 0 → NaN
|
||||
zero_denom = {"Z": [0.0, 1.0, 2.0]}
|
||||
outz = materialize(parse("RateOfChange(Z)"), zero_denom)
|
||||
self.assertTrue(math.isnan(outz[1]))
|
||||
# 线性序列 ROC 步长1 = 1/prev
|
||||
out2 = materialize(parse("RateOfChange(A)"), self.series)
|
||||
self.assertAlmostEqual(out2[1], 1.0 / 1.0)
|
||||
self.assertAlmostEqual(out2[5], 1.0 / 5.0)
|
||||
|
||||
def test_log_negative_nan(self):
|
||||
data = {"P": [1.0, -2.0, math.e]}
|
||||
out = materialize(parse("Log(P)"), data)
|
||||
self.assertAlmostEqual(out[0], 0.0)
|
||||
self.assertTrue(math.isnan(out[1]))
|
||||
self.assertAlmostEqual(out[2], 1.0)
|
||||
|
||||
def test_scale(self):
|
||||
out = materialize(parse("Scale(A, 10)"), self.series)
|
||||
self.assertEqual(out[0], 10.0)
|
||||
self.assertEqual(out[9], 100.0)
|
||||
|
||||
def test_clip(self):
|
||||
out = materialize(parse("Clip(A, 3, 7)"), self.series)
|
||||
self.assertEqual(out, [3.0, 3.0, 3.0, 4.0, 5.0, 6.0, 7.0, 7.0, 7.0, 7.0])
|
||||
|
||||
def test_combine(self):
|
||||
data = {"A": [1.0, 2.0, 3.0], "B": [10.0, 20.0, 30.0]}
|
||||
self.assertEqual(materialize(parse("Combine(A, B)"), data), [11.0, 22.0, 33.0])
|
||||
|
||||
def test_missing_input_fails_fast(self):
|
||||
with self.assertRaises(KeyError):
|
||||
materialize(parse("EMA(Missing, 3)"), {"A": [1.0, 2.0, 3.0]})
|
||||
|
||||
def test_unknown_op_fails_fast(self):
|
||||
with self.assertRaises(ValueError):
|
||||
materialize(OpCall("NoSuchOp", (TagRef("A"),)), self.series)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 插件注册
|
||||
# ---------------------------------------------------------------------------
|
||||
class RegisterOperatorTest(unittest.TestCase):
|
||||
def test_register_then_parse_and_run(self):
|
||||
def _double(series_map, args):
|
||||
tag = args[0]
|
||||
return [x * 2 for x in series_map[tag.name]]
|
||||
|
||||
register_operator(
|
||||
"Double",
|
||||
min_arity=1,
|
||||
max_arity=1,
|
||||
arg_kinds=(("tag",),),
|
||||
func=_double,
|
||||
doc="示例自定义算子:翻倍",
|
||||
)
|
||||
try:
|
||||
self.assertIn("Double", OPERATORS)
|
||||
self.assertEqual(validate(parse("Double(A)")), [])
|
||||
self.assertEqual(
|
||||
materialize(parse("Double(A)"), {"A": [1.0, 2.0]}), [2.0, 4.0]
|
||||
)
|
||||
finally:
|
||||
OPERATORS.pop("Double", None)
|
||||
|
||||
def test_register_overrides(self):
|
||||
register_operator(
|
||||
"Stub", min_arity=0, max_arity=0, arg_kinds=(), func=lambda s, a: 1, doc="v1"
|
||||
)
|
||||
register_operator(
|
||||
"Stub", min_arity=0, max_arity=0, arg_kinds=(), func=lambda s, a: 2, doc="v2"
|
||||
)
|
||||
try:
|
||||
self.assertEqual(OPERATORS["Stub"].doc, "v2")
|
||||
finally:
|
||||
OPERATORS.pop("Stub", None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 描述 / 往返
|
||||
# ---------------------------------------------------------------------------
|
||||
class DescribeAndSerializeTest(unittest.TestCase):
|
||||
def test_describe_contains_inputs(self):
|
||||
d = describe(parse("EMA(CLF-01.TEMP, 5m)"))
|
||||
self.assertIn("CLF-01.TEMP", d)
|
||||
self.assertIn("EMA", d)
|
||||
|
||||
def test_to_dict_roundtrip_shape(self):
|
||||
ast = parse("RateOfChange(炉压)")
|
||||
d = ast.to_dict()
|
||||
self.assertEqual(d["kind"], "op")
|
||||
self.assertEqual(d["name"], "RateOfChange")
|
||||
self.assertEqual(d["args"][0], {"kind": "tag", "name": "炉压"})
|
||||
|
||||
def test_window_seconds(self):
|
||||
w = Window(5.0, "m")
|
||||
self.assertEqual(w.seconds, 300)
|
||||
self.assertEqual(Window(2.0, "h").seconds, 7200)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,303 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""issue #34 Model Recipe 插件接口与样例协议 单元测试。
|
||||
|
||||
覆盖:
|
||||
* 内置四类 Recipe 已注册、字段合法;
|
||||
* build_model 跨主干(gbdt/dnn/lstm/gnn/stub)可构造、fit/predict 契约;
|
||||
* 插件注册(register_recipe / register_backbone)零改码扩展;
|
||||
* ModelRecipe 不可变 + to_dict/from_dict 往返;
|
||||
* 超参包校验(recipe_id / 必需特征 / 主干可构造性);
|
||||
* 样例协议:树脂 + Ti 两套 Recipe 同框架均跑通(EPIC #5 验收口径)。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 引导:挂载 model_framework 包(目录含连字符)
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import _bootstrap # noqa: F401,E402
|
||||
|
||||
import unittest
|
||||
|
||||
from model_framework.model_recipe import ( # noqa: E402
|
||||
BACKBONES,
|
||||
RECIPE_KINDS,
|
||||
ModelRecipe,
|
||||
RecipeError,
|
||||
build_model,
|
||||
get_recipe,
|
||||
list_recipes,
|
||||
load_sample_recipe,
|
||||
register_backbone,
|
||||
register_recipe,
|
||||
validate_hyperparam_pack,
|
||||
)
|
||||
|
||||
|
||||
class TestBuiltinRecipes(unittest.TestCase):
|
||||
"""内置四类 Recipe 注册与字段合法性。"""
|
||||
|
||||
def test_four_builtin_recipes_registered(self):
|
||||
ids = {r["id"] for r in list_recipes()}
|
||||
for rid in (
|
||||
"quality_predict.default",
|
||||
"process_optimize.default",
|
||||
"anomaly_detect.default",
|
||||
"cross_process.default",
|
||||
):
|
||||
self.assertIn(rid, ids, f"缺少内置 Recipe {rid}")
|
||||
|
||||
def test_each_builtin_kind_covered(self):
|
||||
kinds = {get_recipe(rid).kind for rid in (
|
||||
"quality_predict.default",
|
||||
"process_optimize.default",
|
||||
"anomaly_detect.default",
|
||||
"cross_process.default",
|
||||
)}
|
||||
self.assertEqual(kinds, set(RECIPE_KINDS))
|
||||
|
||||
def test_backbone_registered(self):
|
||||
for name in ("gbdt", "dnn", "lstm", "gnn", "stub"):
|
||||
self.assertIn(name, BACKBONES, f"缺少内置主干 {name}")
|
||||
|
||||
|
||||
class TestModelRecipeDataclass(unittest.TestCase):
|
||||
"""ModelRecipe 不可变 + 序列化往返 + 校验。"""
|
||||
|
||||
def test_immutable(self):
|
||||
r = get_recipe("quality_predict.default")
|
||||
with self.assertRaises(Exception):
|
||||
r.id = "x" # type: ignore[misc]
|
||||
|
||||
def test_to_from_dict_roundtrip(self):
|
||||
r = get_recipe("quality_predict.default")
|
||||
d = r.to_dict()
|
||||
r2 = ModelRecipe.from_dict(d)
|
||||
self.assertEqual(r2.to_dict(), d)
|
||||
self.assertEqual(r2.id, r.id)
|
||||
self.assertEqual(r2.backbone, r.backbone)
|
||||
|
||||
def test_invalid_kind_rejected(self):
|
||||
with self.assertRaises(RecipeError):
|
||||
ModelRecipe(id="x.bad", kind="bogus", backbone="gbdt")
|
||||
|
||||
def test_unregistered_backbone_rejected(self):
|
||||
with self.assertRaises(RecipeError):
|
||||
ModelRecipe(id="x.nobackbone", kind="quality_predict", backbone="no-such")
|
||||
|
||||
def test_merged_hyperparams_override_wins(self):
|
||||
r = get_recipe("quality_predict.default")
|
||||
base = r.default_hyperparams
|
||||
merged = r.merged_hyperparams({"max_depth": 99})
|
||||
self.assertEqual(merged["max_depth"], 99)
|
||||
# 默认值未被污染
|
||||
self.assertEqual(base["max_depth"], 6)
|
||||
self.assertIn("eta", merged)
|
||||
|
||||
|
||||
class TestBuildModel(unittest.TestCase):
|
||||
"""build_model 跨主干构造 + fit/predict 契约。"""
|
||||
|
||||
def test_build_each_backbone(self):
|
||||
for rid, bb in (
|
||||
("quality_predict.default", "gbdt"),
|
||||
("process_optimize.default", "gbdt"),
|
||||
("anomaly_detect.default", "dnn"),
|
||||
("cross_process.default", "gnn"),
|
||||
):
|
||||
m = build_model(rid)
|
||||
self.assertEqual(m.backbone, bb)
|
||||
self.assertFalse(m.fitted)
|
||||
|
||||
def test_fit_then_predict_returns_correct_length(self):
|
||||
m = build_model("quality_predict.default")
|
||||
X = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]
|
||||
y = [1.0, 2.0, 3.0]
|
||||
m.fit(X, y)
|
||||
self.assertTrue(m.fitted)
|
||||
pred = m.predict([[2.0, 3.0], [4.0, 5.0]])
|
||||
self.assertEqual(len(pred), 2)
|
||||
for v in pred:
|
||||
self.assertIsInstance(v, float)
|
||||
|
||||
def test_predict_before_fit_fails_closed(self):
|
||||
m = build_model("anomaly_detect.default")
|
||||
with self.assertRaises(RecipeError):
|
||||
m.predict([[1.0, 2.0]])
|
||||
|
||||
def test_unsupervised_fit_without_y(self):
|
||||
# anomaly_detect 主干应允许无 y 拟合
|
||||
m = build_model("anomaly_detect.default")
|
||||
m.fit([[1.0, 2.0], [3.0, 4.0]])
|
||||
self.assertTrue(m.fitted)
|
||||
out = m.predict([[1.0, 2.0]])
|
||||
self.assertEqual(len(out), 1)
|
||||
|
||||
def test_X_width_mismatch_rejected(self):
|
||||
m = build_model("quality_predict.default")
|
||||
with self.assertRaises(ValueError):
|
||||
m.fit([[1.0, 2.0], [3.0]], [1.0, 2.0])
|
||||
|
||||
def test_Xy_length_mismatch_rejected(self):
|
||||
m = build_model("quality_predict.default")
|
||||
with self.assertRaises(ValueError):
|
||||
m.fit([[1.0, 2.0], [3.0, 4.0]], [1.0])
|
||||
|
||||
def test_empty_X_rejected(self):
|
||||
m = build_model("quality_predict.default")
|
||||
with self.assertRaises(ValueError):
|
||||
m.fit([], [])
|
||||
|
||||
def test_handle_to_dict(self):
|
||||
m = build_model("quality_predict.default", {"max_depth": 7})
|
||||
d = m.to_dict()
|
||||
self.assertEqual(d["recipe_id"], "quality_predict.default")
|
||||
self.assertEqual(d["backbone"], "gbdt")
|
||||
self.assertEqual(d["hyperparams"]["max_depth"], 7)
|
||||
self.assertFalse(d["fitted"])
|
||||
|
||||
def test_unknown_recipe_raises(self):
|
||||
with self.assertRaises(RecipeError):
|
||||
build_model("no.such.recipe")
|
||||
|
||||
|
||||
class TestPluginRegistration(unittest.TestCase):
|
||||
"""register_recipe / register_backbone 零改码扩展(PRD「新增结构走插件注册」)。"""
|
||||
|
||||
def test_register_custom_backbone_and_recipe(self):
|
||||
seen = {}
|
||||
|
||||
def my_bb(hp):
|
||||
class _Impl:
|
||||
def iaop_fit(self, rows, y):
|
||||
seen["fit_called"] = True
|
||||
|
||||
def iaop_predict(self, rows):
|
||||
return [42.0 for _ in rows]
|
||||
return _Impl()
|
||||
|
||||
register_backbone("my-gnn", my_bb)
|
||||
self.assertIn("my-gnn", BACKBONES)
|
||||
|
||||
register_recipe(ModelRecipe(
|
||||
id="cross_process.custom_gnn",
|
||||
kind="cross_process",
|
||||
backbone="my-gnn",
|
||||
description="自研 GNN 主干,验证插件扩展",
|
||||
))
|
||||
m = build_model("cross_process.custom_gnn")
|
||||
m.fit([[1.0, 2.0]], [1.0])
|
||||
self.assertTrue(seen.get("fit_called"))
|
||||
self.assertEqual(m.predict([[9.0, 9.0]]), [42.0])
|
||||
|
||||
def test_register_recipe_overwrites(self):
|
||||
# 用独立的临时 recipe 验证"重复注册同 id 覆盖",不污染内置表
|
||||
register_recipe(ModelRecipe(
|
||||
id="quality_predict.temp",
|
||||
kind="quality_predict",
|
||||
backbone="gbdt",
|
||||
description="第一版",
|
||||
))
|
||||
self.assertEqual(get_recipe("quality_predict.temp").description, "第一版")
|
||||
register_recipe(ModelRecipe(
|
||||
id="quality_predict.temp",
|
||||
kind="quality_predict",
|
||||
backbone="stub",
|
||||
description="第二版覆盖",
|
||||
))
|
||||
self.assertEqual(get_recipe("quality_predict.temp").backbone, "stub")
|
||||
self.assertEqual(get_recipe("quality_predict.temp").description, "第二版覆盖")
|
||||
|
||||
def test_register_invalid_backbone_name_rejected(self):
|
||||
with self.assertRaises(RecipeError):
|
||||
register_backbone("bad name!", lambda hp: None)
|
||||
|
||||
def test_register_non_callable_factory_rejected(self):
|
||||
with self.assertRaises(RecipeError):
|
||||
register_backbone("oops", "not callable") # type: ignore[arg-type]
|
||||
|
||||
def test_register_non_recipe_rejected(self):
|
||||
with self.assertRaises(RecipeError):
|
||||
register_recipe("not a recipe") # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestHyperparamPackValidation(unittest.TestCase):
|
||||
"""超参包校验(Recipe 视角)。"""
|
||||
|
||||
def test_valid_pack_no_issues(self):
|
||||
pack = load_sample_recipe("ti")
|
||||
self.assertEqual(validate_hyperparam_pack(pack), [])
|
||||
|
||||
def test_missing_required_field(self):
|
||||
issues = validate_hyperparam_pack({"recipe_id": "quality_predict.default"})
|
||||
msgs = " ".join(issues)
|
||||
self.assertIn("model_id", msgs)
|
||||
self.assertIn("features", msgs)
|
||||
|
||||
def test_unknown_recipe_id(self):
|
||||
issues = validate_hyperparam_pack({
|
||||
"model_id": "x", "recipe_id": "no.such", "features": [],
|
||||
})
|
||||
self.assertTrue(any("未注册" in i for i in issues))
|
||||
|
||||
def test_missing_required_feature(self):
|
||||
# quality_predict.default 要求 'target' 特征
|
||||
issues = validate_hyperparam_pack({
|
||||
"model_id": "x",
|
||||
"recipe_id": "quality_predict.default",
|
||||
"features": [{"name": "only_a"}],
|
||||
})
|
||||
self.assertTrue(any("target" in i for i in issues))
|
||||
|
||||
|
||||
class TestSampleRecipesAcceptance(unittest.TestCase):
|
||||
"""EPIC #5 / PRD 5.3 验收口径:同框架加载树脂与 Ti 两套 Recipe 均跑通。"""
|
||||
|
||||
def test_both_samples_build_fit_predict(self):
|
||||
for name in ("resin", "ti"):
|
||||
pack = load_sample_recipe(name)
|
||||
self.assertEqual(validate_hyperparam_pack(pack), [],
|
||||
f"样例 {name} 校验未通过")
|
||||
m = build_model(pack["recipe_id"], pack.get("hyperparams"))
|
||||
# 构造与目标维度无关的训练样本(2 特征列)
|
||||
X = [[float(i), float(i + 1)] for i in range(6)]
|
||||
y = [float(i) for i in range(6)]
|
||||
m.fit(X, y)
|
||||
self.assertTrue(m.fitted)
|
||||
pred = m.predict([[1.0, 2.0]])
|
||||
self.assertEqual(len(pred), 1)
|
||||
|
||||
def test_samples_share_same_framework(self):
|
||||
# 关键:两套样例用同一个 recipe_id(quality_predict.default),
|
||||
# 仅超参不同——证明「切换模板仅改超参包,模型代码零改动」
|
||||
r1 = load_sample_recipe("resin")
|
||||
r2 = load_sample_recipe("ti")
|
||||
self.assertEqual(r1["recipe_id"], r2["recipe_id"])
|
||||
# 但超参不同(max_depth 4 vs 6)
|
||||
self.assertNotEqual(
|
||||
r1["hyperparams"]["max_depth"],
|
||||
r2["hyperparams"]["max_depth"],
|
||||
)
|
||||
# 各自 build 得到不同超参的句柄
|
||||
m1 = build_model(r1["recipe_id"], r1["hyperparams"])
|
||||
m2 = build_model(r2["recipe_id"], r2["hyperparams"])
|
||||
self.assertEqual(m1.hyperparams["max_depth"], 4)
|
||||
self.assertEqual(m2.hyperparams["max_depth"], 6)
|
||||
|
||||
def test_load_unknown_sample_raises(self):
|
||||
with self.assertRaises(RecipeError):
|
||||
load_sample_recipe("bogus")
|
||||
|
||||
|
||||
class TestBackboneFallback(unittest.TestCase):
|
||||
"""主干在无第三方依赖时退化为 stub,接口契约不变。"""
|
||||
|
||||
def test_lstm_gnn_fallback_to_stub_contract(self):
|
||||
# 无论是否有 torch,lstm/gnn 主干都应能构造并 fit/predict
|
||||
for rid in ("cross_process.default",):
|
||||
m = build_model(rid)
|
||||
m.fit([[1.0, 2.0]], [1.0])
|
||||
self.assertEqual(len(m.predict([[1.0, 2.0]])), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,301 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""训练 / 推理流水线编排单元测试(issue #40)。
|
||||
|
||||
覆盖:
|
||||
- Context 读写与快照;
|
||||
- ModelRegistry 注册 / 版本 / 别名(latest / stable);
|
||||
- 估计器(MeanRegressor / MajorityClassifier)训练与预测;
|
||||
- 各 Step(LoadData / Train / Evaluate / Register / LoadModel / Predict / Custom)
|
||||
的执行与产物传递;
|
||||
- Pipeline 顺序编排、失败短路、dry_run;
|
||||
- PipelineConfig 声明式配置往返与 from_config 构建。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
if HERE not in sys.path:
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
import _bootstrap # noqa: E402 加载 model_framework 包
|
||||
|
||||
from model_framework.pipeline import ( # noqa: E402
|
||||
Context,
|
||||
CustomStep,
|
||||
ESTIMATORS,
|
||||
EvaluateStep,
|
||||
LoadDataStep,
|
||||
LoadModelStep,
|
||||
MajorityClassifier,
|
||||
MeanRegressor,
|
||||
ModelArtifact,
|
||||
ModelRegistry,
|
||||
Pipeline,
|
||||
PipelineConfig,
|
||||
PipelineError,
|
||||
PredictStep,
|
||||
RegisterStep,
|
||||
StepResult,
|
||||
TrainStep,
|
||||
register_estimator,
|
||||
register_step_type,
|
||||
)
|
||||
|
||||
|
||||
class TestContext(unittest.TestCase):
|
||||
def test_get_set(self):
|
||||
ctx = Context(params={"lr": 0.1})
|
||||
ctx.set("x", 1)
|
||||
self.assertEqual(ctx.get("x"), 1)
|
||||
self.assertEqual(ctx.get("missing", "d"), "d")
|
||||
self.assertEqual(ctx.params["lr"], 0.1)
|
||||
|
||||
def test_snapshot(self):
|
||||
ctx = Context()
|
||||
ctx.set("a", 1)
|
||||
ctx.set("b", 2)
|
||||
snap = ctx.snapshot()
|
||||
self.assertEqual(snap["artifacts_keys"], ["a", "b"])
|
||||
|
||||
|
||||
class TestModelRegistry(unittest.TestCase):
|
||||
def test_register_and_latest(self):
|
||||
reg = ModelRegistry()
|
||||
a1 = ModelArtifact("m", "v1", object())
|
||||
a2 = ModelArtifact("m", "v2", object())
|
||||
reg.register(a1)
|
||||
reg.register(a2)
|
||||
self.assertEqual(reg.get("m").version, "v2") # latest
|
||||
self.assertEqual(reg.get("m", "v1").version, "v1")
|
||||
self.assertEqual(reg.list_versions("m"), ["v1", "v2"])
|
||||
|
||||
def test_alias(self):
|
||||
reg = ModelRegistry()
|
||||
reg.register(ModelArtifact("m", "v1", object()))
|
||||
reg.register(ModelArtifact("m", "v2", object()))
|
||||
reg.set_alias("m", "stable", "v1")
|
||||
self.assertEqual(reg.get("m", "stable").version, "v1")
|
||||
self.assertEqual(reg.get("m", "latest").version, "v2")
|
||||
|
||||
def test_missing_raises(self):
|
||||
reg = ModelRegistry()
|
||||
with self.assertRaises(PipelineError):
|
||||
reg.get("nope")
|
||||
reg.register(ModelArtifact("m", "v1", object()))
|
||||
with self.assertRaises(PipelineError):
|
||||
reg.get("m", "v99")
|
||||
|
||||
def test_register_requires_name_version(self):
|
||||
reg = ModelRegistry()
|
||||
with self.assertRaises(PipelineError):
|
||||
reg.register(ModelArtifact("", "v1", object()))
|
||||
|
||||
|
||||
class TestEstimators(unittest.TestCase):
|
||||
def test_mean_regressor(self):
|
||||
est = MeanRegressor()
|
||||
est.fit([[1], [2], [3]], [10, 20, 30])
|
||||
self.assertEqual(est.predict([[99], [100]]), [20.0, 20.0])
|
||||
|
||||
def test_majority_classifier(self):
|
||||
est = MajorityClassifier()
|
||||
est.fit([[1], [2], [3]], [0, 1, 1])
|
||||
self.assertEqual(est.predict([[9], [10]]), [1.0, 1.0])
|
||||
|
||||
def test_empty_fit_raises(self):
|
||||
with self.assertRaises(PipelineError):
|
||||
MeanRegressor().fit([], [])
|
||||
|
||||
def test_register_estimator(self):
|
||||
class MyEst(MeanRegressor):
|
||||
name = "my_est"
|
||||
register_estimator("my_est", MyEst)
|
||||
self.assertIn("my_est", ESTIMATORS)
|
||||
|
||||
|
||||
class TestSteps(unittest.TestCase):
|
||||
def test_load_data_from_list(self):
|
||||
ctx = Context()
|
||||
r = LoadDataStep("load", {"source": [[1, 2], [3, 4]]}).execute(ctx)
|
||||
self.assertTrue(r.success)
|
||||
self.assertEqual(ctx.get("dataset"), [[1.0, 2.0], [3.0, 4.0]])
|
||||
|
||||
def test_load_data_from_csv(self):
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".csv", delete=False, encoding="utf-8") as fh:
|
||||
fh.write("a,b,y\n1,2,3\n4,5,6\n")
|
||||
path = fh.name
|
||||
try:
|
||||
ctx = Context()
|
||||
r = LoadDataStep("load", {"source": path}).execute(ctx)
|
||||
self.assertTrue(r.success)
|
||||
self.assertEqual(ctx.get("dataset"), [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_load_data_missing_source(self):
|
||||
ctx = Context()
|
||||
r = LoadDataStep("load", {}).execute(ctx)
|
||||
self.assertFalse(r.success)
|
||||
self.assertIn("source", r.error or "")
|
||||
|
||||
def test_train_step(self):
|
||||
ctx = Context()
|
||||
ctx.set("dataset", [[1, 10], [2, 20], [3, 30]]) # 最后一列 target
|
||||
r = TrainStep("train", {"estimator": "mean_regressor"}).execute(ctx)
|
||||
self.assertTrue(r.success)
|
||||
est = ctx.get("model")
|
||||
self.assertEqual(est.predict([[9]]), [20.0])
|
||||
|
||||
def test_train_unknown_estimator(self):
|
||||
ctx = Context()
|
||||
ctx.set("dataset", [[1, 10]])
|
||||
r = TrainStep("train", {"estimator": "voodoo"}).execute(ctx)
|
||||
self.assertFalse(r.success)
|
||||
|
||||
def test_evaluate_step_regression(self):
|
||||
ctx = Context()
|
||||
ctx.set("dataset", [[1, 10], [2, 20], [3, 30]])
|
||||
TrainStep("train", {}).execute(ctx)
|
||||
r = EvaluateStep("eval", {}).execute(ctx)
|
||||
self.assertTrue(r.success)
|
||||
m = ctx.get("metrics")
|
||||
# 均值预测:mae 为各 |y-20| 的均值
|
||||
self.assertAlmostEqual(m["mae"], (10 + 0 + 10) / 3)
|
||||
self.assertGreaterEqual(m["rmse"], 0)
|
||||
|
||||
def test_evaluate_step_classification(self):
|
||||
ctx = Context()
|
||||
ctx.set("dataset", [[1, 0], [2, 1], [3, 1]])
|
||||
TrainStep("train", {"estimator": "majority_classifier"}).execute(ctx)
|
||||
EvaluateStep("eval", {}).execute(ctx)
|
||||
m = ctx.get("metrics")
|
||||
self.assertIn("accuracy", m)
|
||||
self.assertGreaterEqual(m["accuracy"], 0.0)
|
||||
|
||||
def test_register_and_load_model(self):
|
||||
ctx = Context()
|
||||
ctx.set("dataset", [[1, 10], [2, 20]])
|
||||
TrainStep("train", {}).execute(ctx)
|
||||
reg_r = RegisterStep("reg", {"model_name": "demo", "version": "v1"}).execute(ctx)
|
||||
self.assertTrue(reg_r.success)
|
||||
registry = ctx.get("registry")
|
||||
self.assertIsInstance(registry, ModelRegistry)
|
||||
self.assertEqual(registry.list_versions("demo"), ["v1"])
|
||||
|
||||
load_r = LoadModelStep("load", {"model_name": "demo", "version": "v1"}).execute(ctx)
|
||||
self.assertTrue(load_r.success)
|
||||
serving = ctx.get("serving_model")
|
||||
self.assertEqual(serving.predict([[9]]), [15.0])
|
||||
|
||||
def test_load_model_missing_registry(self):
|
||||
ctx = Context()
|
||||
r = LoadModelStep("load", {"model_name": "x"}).execute(ctx)
|
||||
self.assertFalse(r.success)
|
||||
|
||||
def test_predict_step(self):
|
||||
ctx = Context()
|
||||
ctx.set("dataset", [[1, 10], [2, 20]])
|
||||
TrainStep("train", {}).execute(ctx)
|
||||
RegisterStep("reg", {"model_name": "demo", "version": "v1"}).execute(ctx)
|
||||
LoadModelStep("load", {"model_name": "demo"}).execute(ctx)
|
||||
ctx.set("input", [[5], [6]])
|
||||
r = PredictStep("predict", {}).execute(ctx)
|
||||
self.assertTrue(r.success)
|
||||
self.assertEqual(ctx.get("predictions"), [15.0, 15.0])
|
||||
|
||||
def test_custom_step(self):
|
||||
ctx = Context()
|
||||
r = CustomStep("c", {"handler": lambda c: {"out": 42}}).execute(ctx)
|
||||
self.assertTrue(r.success)
|
||||
self.assertEqual(ctx.get("out"), 42)
|
||||
|
||||
def test_custom_step_bad_handler(self):
|
||||
ctx = Context()
|
||||
r = CustomStep("c", {"handler": "not_callable"}).execute(ctx)
|
||||
self.assertFalse(r.success)
|
||||
|
||||
def test_step_requires_name(self):
|
||||
with self.assertRaises(PipelineError):
|
||||
TrainStep("", {})
|
||||
|
||||
|
||||
class TestPipeline(unittest.TestCase):
|
||||
def _full_pipeline(self):
|
||||
return Pipeline("demo", [
|
||||
LoadDataStep("load", {"source": [[1, 10], [2, 20], [3, 30]]}),
|
||||
TrainStep("train", {"estimator": "mean_regressor"}),
|
||||
EvaluateStep("eval", {}),
|
||||
RegisterStep("register", {"model_name": "demo", "version": "v1"}),
|
||||
LoadModelStep("load_model", {"model_name": "demo", "version": "v1"}),
|
||||
PredictStep("predict", {"input_key": "dataset"}),
|
||||
])
|
||||
|
||||
def test_full_pipeline_success(self):
|
||||
result = self._full_pipeline().run()
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(len(result.step_results), 6)
|
||||
self.assertIsNone(result.failed_step)
|
||||
|
||||
def test_pipeline_context_shared(self):
|
||||
pipe = Pipeline("p", [
|
||||
CustomStep("a", {"handler": lambda c: {"v": 7}}),
|
||||
CustomStep("b", {"handler": lambda c: {"v2": c.get("v") * 2}}),
|
||||
])
|
||||
r = pipe.run()
|
||||
self.assertTrue(r.success)
|
||||
self.assertEqual(pipe is not None, True)
|
||||
|
||||
def test_pipeline_failure_short_circuits(self):
|
||||
# 第二步失败(无 dataset),应短路不执行后续
|
||||
pipe = Pipeline("p", [
|
||||
CustomStep("a", {"handler": lambda c: {}}),
|
||||
EvaluateStep("bad_eval", {}), # 缺 model → 失败
|
||||
CustomStep("c", {"handler": lambda c: {"never": 1}}),
|
||||
])
|
||||
r = pipe.run()
|
||||
self.assertFalse(r.success)
|
||||
self.assertEqual(r.failed_step, "bad_eval")
|
||||
self.assertEqual(len(r.step_results), 2) # a + bad_eval
|
||||
|
||||
def test_dry_run(self):
|
||||
r = self._full_pipeline().run(dry_run=True)
|
||||
self.assertTrue(r.success)
|
||||
# dry_run 不真正 run,predictions 不存在
|
||||
# (dry_run 不产出 artifacts)
|
||||
|
||||
def test_pipeline_requires_name(self):
|
||||
with self.assertRaises(PipelineError):
|
||||
Pipeline("", [])
|
||||
|
||||
def test_register_step_type_and_from_config(self):
|
||||
register_step_type("double", lambda n, p: CustomStep(
|
||||
n, {"handler": lambda c: {"doubled": c.params.get("x", 0) * 2}}))
|
||||
cfg = PipelineConfig("cfg", steps=[
|
||||
{"type": "double", "name": "d", "params": {}},
|
||||
], params={"x": 21})
|
||||
pipe = Pipeline.from_config(cfg)
|
||||
ctx = Context()
|
||||
r = pipe.run(ctx)
|
||||
self.assertTrue(r.success)
|
||||
self.assertEqual(ctx.get("doubled"), 42)
|
||||
|
||||
def test_from_config_unknown_type(self):
|
||||
cfg = PipelineConfig("cfg", steps=[{"type": "voodoo"}])
|
||||
with self.assertRaises(PipelineError):
|
||||
Pipeline.from_config(cfg)
|
||||
|
||||
def test_config_roundtrip(self):
|
||||
cfg = PipelineConfig("c", steps=[{"type": "train", "name": "t", "params": {}}],
|
||||
params={"k": 1})
|
||||
d = cfg.to_dict()
|
||||
cfg2 = PipelineConfig.from_dict(json.loads(json.dumps(d)))
|
||||
self.assertEqual(cfg2.name, "c")
|
||||
self.assertEqual(cfg2.params, {"k": 1})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,255 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""``quality_forecast`` 单元测试(issue #36)。
|
||||
|
||||
覆盖:
|
||||
- 配方(Recipe)不可变性 / 序列化往返 / 非法主干与越界校验;
|
||||
- 主干工厂注册表 + 自定义主干注册(PRD 5.3「新增结构走插件注册」);
|
||||
- stub / gbdt / dnn 三类主干的 fit/predict/evaluate 契约;
|
||||
- 固定主干 + 配方加载:同框架加载 Ti / 树脂两套配方均跑通(PRD 5.3
|
||||
验收口径);
|
||||
- Accuracy 验收口径(PRD 5.3 / 里程碑:准确率 ≥ 90%);
|
||||
- 零外部强依赖:无 sklearn 时 stub 退化仍可加载与校验。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
import _bootstrap # noqa: E402 注册 model_framework 包
|
||||
|
||||
from model_framework.quality_forecast import ( # noqa: E402
|
||||
Accuracy,
|
||||
BACKBONES,
|
||||
ModelHandle,
|
||||
QualityForecastError,
|
||||
QualityForecastModel,
|
||||
Recipe,
|
||||
build_from_recipe,
|
||||
dnn_backbone,
|
||||
gbdt_backbone,
|
||||
list_sample_recipes,
|
||||
load_recipe,
|
||||
register_backbone,
|
||||
sample_recipe_path,
|
||||
stub_backbone,
|
||||
)
|
||||
|
||||
|
||||
def _linear_dataset(n=40, noise=0.0):
|
||||
"""构造一个 y ≈ 2*x0 + x1 的可学习数据集(带可选噪声)。"""
|
||||
X, y = [], []
|
||||
for i in range(n):
|
||||
x0 = float(i % 7) + 1.0
|
||||
x1 = float(i % 5) * 0.5 + 0.5
|
||||
yv = 2.0 * x0 + x1 + noise * (i % 3 - 1)
|
||||
X.append([x0, x1])
|
||||
y.append(yv)
|
||||
return X, y
|
||||
|
||||
|
||||
class TestRecipe(unittest.TestCase):
|
||||
"""配方数据对象与校验。"""
|
||||
|
||||
def test_defaults_and_immutability(self):
|
||||
r = Recipe(name="t")
|
||||
self.assertEqual(r.backbone, "gbdt")
|
||||
self.assertEqual(r.target_column, "quality_index")
|
||||
self.assertAlmostEqual(r.accuracy_floor, 0.90)
|
||||
with self.assertRaises(Exception):
|
||||
r.name = "other" # frozen
|
||||
|
||||
def test_roundtrip(self):
|
||||
r = Recipe(name="t", backbone="dnn",
|
||||
hyperparams={"max_iter": 50},
|
||||
feature_columns=("a", "b"),
|
||||
target_column="y",
|
||||
accuracy_floor=0.8, industry="树脂", notes="n")
|
||||
d = r.to_dict()
|
||||
r2 = Recipe.from_dict(d)
|
||||
self.assertEqual(r, r2)
|
||||
# JSON 往返
|
||||
r3 = Recipe.from_dict(json.loads(json.dumps(d)))
|
||||
self.assertEqual(r, r3)
|
||||
|
||||
def test_invalid_backbone_raises(self):
|
||||
with self.assertRaises(QualityForecastError):
|
||||
Recipe(name="t", backbone="svm")
|
||||
|
||||
def test_accuracy_floor_out_of_range(self):
|
||||
with self.assertRaises(QualityForecastError):
|
||||
Recipe(name="t", accuracy_floor=1.5)
|
||||
with self.assertRaises(QualityForecastError):
|
||||
Recipe(name="t", accuracy_floor=-0.1)
|
||||
|
||||
def test_missing_name(self):
|
||||
with self.assertRaises(QualityForecastError):
|
||||
Recipe(name="")
|
||||
|
||||
def test_load_recipe_from_file(self, ):
|
||||
path = sample_recipe_path("recipe.ti.json")
|
||||
r = load_recipe(path)
|
||||
self.assertEqual(r.name, "ti-cl4-quality")
|
||||
self.assertEqual(r.backbone, "gbdt")
|
||||
self.assertIn("furnace_temp", r.feature_columns)
|
||||
|
||||
|
||||
class TestBackbones(unittest.TestCase):
|
||||
"""主干工厂与注册表。"""
|
||||
|
||||
def test_builtin_backbones_registered(self):
|
||||
for name in ("gbdt", "dnn", "stub"):
|
||||
self.assertIn(name, BACKBONES)
|
||||
|
||||
def test_register_custom_backbone(self):
|
||||
class _Custom(ModelHandle):
|
||||
def __init__(self, p):
|
||||
super().__init__("custom", p)
|
||||
self._v = 1.0
|
||||
|
||||
def _fit_impl(self, X, y):
|
||||
self._v = sum(y) / len(y)
|
||||
|
||||
def _predict_one(self, row):
|
||||
return self._v
|
||||
|
||||
register_backbone("custom_test", lambda p: _Custom(p))
|
||||
m = QualityForecastModel(backbone="custom_test")
|
||||
X, y = _linear_dataset()
|
||||
m.fit(X, y)
|
||||
self.assertEqual(len(m.predict(X)), len(X))
|
||||
# 清理避免污染其它用例
|
||||
BACKBONES.pop("custom_test", None)
|
||||
|
||||
def test_unknown_backbone_raises(self):
|
||||
with self.assertRaises(QualityForecastError):
|
||||
QualityForecastModel(backbone="not_a_backbone")
|
||||
|
||||
def test_stub_predict_is_deterministic(self):
|
||||
h = stub_backbone({})
|
||||
X, y = _linear_dataset()
|
||||
h.fit(X, y)
|
||||
p1 = h.predict(X)
|
||||
p2 = h.predict(X)
|
||||
self.assertEqual(p1, p2)
|
||||
self.assertTrue(all(isinstance(v, float) for v in p1))
|
||||
|
||||
def test_gbdt_factory_runs_with_or_without_sklearn(self):
|
||||
# 无论 sklearn 是否存在都不应报错
|
||||
h = gbdt_backbone({"n_estimators": 20, "max_depth": 2})
|
||||
X, y = _linear_dataset()
|
||||
h.fit(X, y)
|
||||
preds = h.predict(X)
|
||||
self.assertEqual(len(preds), len(y))
|
||||
|
||||
|
||||
class TestModelContract(unittest.TestCase):
|
||||
"""模型 fit/predict/evaluate 契约。"""
|
||||
|
||||
def test_fit_predict_shapes(self):
|
||||
m = QualityForecastModel(backbone="stub")
|
||||
X, y = _linear_dataset(20)
|
||||
m.fit(X, y)
|
||||
self.assertTrue(m.fitted)
|
||||
preds = m.predict(X)
|
||||
self.assertEqual(len(preds), len(y))
|
||||
|
||||
def test_predict_before_fit_raises(self):
|
||||
m = QualityForecastModel(backbone="stub")
|
||||
with self.assertRaises(QualityForecastError):
|
||||
m.predict([[1.0, 2.0]])
|
||||
|
||||
def test_fit_mismatched_lengths_raises(self):
|
||||
m = QualityForecastModel(backbone="stub")
|
||||
with self.assertRaises(QualityForecastError):
|
||||
m.fit([[1.0], [2.0]], [1.0])
|
||||
|
||||
def test_fit_empty_raises(self):
|
||||
m = QualityForecastModel(backbone="stub")
|
||||
with self.assertRaises(QualityForecastError):
|
||||
m.fit([], [])
|
||||
|
||||
def test_to_dict_roundtrip_meta(self):
|
||||
m = QualityForecastModel(backbone="gbdt",
|
||||
hyperparams={"n_estimators": 5},
|
||||
feature_columns=["a"],
|
||||
target_column="y")
|
||||
d = m.to_dict()
|
||||
self.assertEqual(d["recipe_meta"]["backbone"], "gbdt")
|
||||
self.assertIn("handle", d)
|
||||
|
||||
|
||||
class TestAccuracy(unittest.TestCase):
|
||||
"""验收口径(PRD 5.3:准确率 ≥ 90%)。"""
|
||||
|
||||
def test_perfect_predictions_pass(self):
|
||||
y = [10.0, 20.0, 30.0, 40.0]
|
||||
acc = Accuracy.compute(y, y, accuracy_floor=0.9)
|
||||
self.assertAlmostEqual(acc.accuracy, 1.0)
|
||||
self.assertAlmostEqual(acc.mae, 0.0)
|
||||
self.assertAlmostEqual(acc.rmse, 0.0)
|
||||
self.assertTrue(acc.passed)
|
||||
|
||||
def test_bad_predictions_fail(self):
|
||||
y_true = [10.0, 20.0, 30.0, 40.0]
|
||||
y_pred = [11.0, 50.0, 5.0, 80.0] # 大偏差
|
||||
acc = Accuracy.compute(y_true, y_pred, accuracy_floor=0.9)
|
||||
self.assertLess(acc.accuracy, 0.9)
|
||||
self.assertFalse(acc.passed)
|
||||
self.assertGreater(acc.mae, 0.0)
|
||||
self.assertGreater(acc.rmse, 0.0)
|
||||
|
||||
def test_length_mismatch_raises(self):
|
||||
with self.assertRaises(QualityForecastError):
|
||||
Accuracy.compute([1.0, 2.0], [1.0])
|
||||
|
||||
def test_empty_raises(self):
|
||||
with self.assertRaises(QualityForecastError):
|
||||
Accuracy.compute([], [])
|
||||
|
||||
def test_evaluate_end_to_end(self):
|
||||
# stub 主干在确定性、低噪声线性数据上应能给出确定性的验收结果
|
||||
m = QualityForecastModel(backbone="stub", accuracy_floor=0.0)
|
||||
X, y = _linear_dataset(30)
|
||||
m.fit(X, y)
|
||||
acc = m.evaluate(X, y)
|
||||
self.assertIsInstance(acc, Accuracy)
|
||||
self.assertEqual(acc.to_dict()["accuracy_floor"], 0.0)
|
||||
|
||||
|
||||
class TestSampleRecipes(unittest.TestCase):
|
||||
"""样例协议:同框架加载 Ti / 树脂两套配方均跑通(PRD 5.3 验收口径)。"""
|
||||
|
||||
def test_samples_present(self):
|
||||
names = list_sample_recipes()
|
||||
self.assertIn("recipe.ti.json", names)
|
||||
self.assertIn("recipe.resin.json", names)
|
||||
|
||||
def test_build_from_each_sample_runs(self):
|
||||
for name in ("recipe.ti.json", "recipe.resin.json"):
|
||||
m = build_from_recipe(sample_recipe_path(name))
|
||||
self.assertIn(m.recipe_meta["backbone"], ("gbdt", "dnn", "stub"))
|
||||
# 用配方里声明的特征数构造一份演示数据跑通完整链路
|
||||
feat = m.recipe_meta["feature_columns"]
|
||||
n_feat = len(feat)
|
||||
self.assertGreater(n_feat, 0)
|
||||
X = [[float(i + j) for j in range(n_feat)] for i in range(12)]
|
||||
y = [float(i % 4) + 1.0 for i in range(12)]
|
||||
m.fit(X, y)
|
||||
preds = m.predict(X)
|
||||
self.assertEqual(len(preds), len(y))
|
||||
acc = m.evaluate(X, y)
|
||||
self.assertIsInstance(acc, Accuracy)
|
||||
|
||||
def test_two_recipes_share_same_code(self):
|
||||
"""切换模板仅改配方,模型代码零改动(PRD 5.3)。"""
|
||||
m1 = build_from_recipe(sample_recipe_path("recipe.ti.json"))
|
||||
m2 = build_from_recipe(sample_recipe_path("recipe.resin.json"))
|
||||
self.assertEqual(type(m1), type(m2))
|
||||
self.assertNotEqual(m1.recipe_meta.get("recipe_name"),
|
||||
m2.recipe_meta.get("recipe_name"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,170 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""模型框架模板化 PoC 单元测试(issue #42)。
|
||||
|
||||
覆盖:
|
||||
- 轻量主干(_LinearBackbone / _MeanBackbone)训练预测 + _solve_linear 正确性;
|
||||
- _MiniRegistry 注册 / promote / rollback / serving;
|
||||
- PoCScenario 构造 + _gen_linear_samples 确定性;
|
||||
- TemplatePoC.run 端到端链路 + PoCReport 三条 RISK 验收口径(R1/R2/R3);
|
||||
- 内置 Ti + 树脂场景跑通且 all_passed。
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
if HERE not in sys.path:
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
import _bootstrap # noqa: E402 加载 model_framework 包
|
||||
|
||||
from model_framework.template_poc import ( # noqa: E402
|
||||
PoCError,
|
||||
PoCReport,
|
||||
PoCScenario,
|
||||
TemplatePoC,
|
||||
resin_quality_scenario,
|
||||
run_poc,
|
||||
ti_quality_scenario,
|
||||
)
|
||||
from model_framework.template_poc import ( # noqa: E402
|
||||
BACKBONES,
|
||||
_Artifact,
|
||||
_gen_linear_samples,
|
||||
_LinearBackbone,
|
||||
_MeanBackbone,
|
||||
_MiniRegistry,
|
||||
_solve_linear,
|
||||
)
|
||||
|
||||
|
||||
class TestSolveLinear(unittest.TestCase):
|
||||
def test_simple(self):
|
||||
# 2x + 3y = 8; x - y = 1 => x=2.2, y=1.2
|
||||
w = _solve_linear([[2, 3], [1, -1]], [8, 1])
|
||||
self.assertAlmostEqual(w[0], 2.2, places=6)
|
||||
self.assertAlmostEqual(w[1], 1.2, places=6)
|
||||
|
||||
def test_identity(self):
|
||||
w = _solve_linear([[1, 0], [0, 1]], [3, 5])
|
||||
self.assertEqual(w, [3.0, 5.0])
|
||||
|
||||
|
||||
class TestBackbones(unittest.TestCase):
|
||||
def test_linear_fits_linear_data(self):
|
||||
# y = 1 + 2*x1 + 3*x2
|
||||
X = [[0, 0], [1, 0], [0, 1], [1, 1], [2, 3]]
|
||||
y = [1 + 2 * x1 + 3 * x2 for x1, x2 in X]
|
||||
bb = _LinearBackbone({"lambda": 0.0})
|
||||
bb.fit(X, y)
|
||||
preds = bb.predict([[1, 1], [2, 2]])
|
||||
self.assertAlmostEqual(preds[0], 6.0, places=4)
|
||||
self.assertAlmostEqual(preds[1], 11.0, places=4)
|
||||
|
||||
def test_mean_backbone(self):
|
||||
bb = _MeanBackbone({})
|
||||
bb.fit([[1], [2], [3]], [10, 20, 30])
|
||||
self.assertEqual(bb.predict([[9]]), [20.0])
|
||||
|
||||
def test_backbones_registered(self):
|
||||
self.assertIn("linear", BACKBONES)
|
||||
self.assertIn("mean", BACKBONES)
|
||||
|
||||
|
||||
class TestMiniRegistry(unittest.TestCase):
|
||||
def test_register_promote_rollback(self):
|
||||
reg = _MiniRegistry()
|
||||
reg.register(_Artifact("m", "v1", "linear", {"mae": 1.0}))
|
||||
self.assertEqual(reg.serving("m", "dev"), "v1")
|
||||
reg.promote("m", "v1") # dev->staging
|
||||
reg.promote("m", "v1") # staging->prod
|
||||
self.assertEqual(reg.serving("m", "prod"), "v1")
|
||||
reg.register(_Artifact("m", "v2", "linear", {"mae": 0.8}))
|
||||
reg.promote("m", "v2")
|
||||
reg.promote("m", "v2")
|
||||
reg.rollback("m", "prod", "v1")
|
||||
self.assertEqual(reg.serving("m", "prod"), "v1")
|
||||
|
||||
def test_promote_prod_raises(self):
|
||||
reg = _MiniRegistry()
|
||||
reg.register(_Artifact("m", "v1", "linear", {}))
|
||||
reg.promote("m", "v1")
|
||||
reg.promote("m", "v1")
|
||||
with self.assertRaises(PoCError):
|
||||
reg.promote("m", "v1")
|
||||
|
||||
|
||||
class TestGenSamples(unittest.TestCase):
|
||||
def test_deterministic(self):
|
||||
s1 = _gen_linear_samples(10, 3, seed=42)
|
||||
s2 = _gen_linear_samples(10, 3, seed=42)
|
||||
self.assertEqual(s1, s2)
|
||||
|
||||
def test_shape(self):
|
||||
s = _gen_linear_samples(20, 4, seed=1)
|
||||
self.assertEqual(len(s), 20)
|
||||
self.assertEqual(len(s[0]), 5) # 4 feat + 1 target
|
||||
|
||||
|
||||
class TestTemplatePoC(unittest.TestCase):
|
||||
def test_run_two_scenarios_all_passed(self):
|
||||
report = run_poc()
|
||||
self.assertIsInstance(report, PoCReport)
|
||||
self.assertEqual(len(report.scenario_results), 2)
|
||||
self.assertTrue(report.all_passed, report.summary())
|
||||
self.assertTrue(report.r1_precision_ok)
|
||||
self.assertTrue(report.r2_recipe_switch_ok)
|
||||
self.assertTrue(report.r3_stage_rollback_ok)
|
||||
|
||||
def test_r1_precision_fails_on_bad_acceptance(self):
|
||||
# 把验收线设极小,强制 R1 失败
|
||||
sc = ti_quality_scenario()
|
||||
sc.acceptance_mae = 0.0001 # 不可能达到
|
||||
report = TemplatePoC([sc]).run()
|
||||
self.assertFalse(report.r1_precision_ok)
|
||||
|
||||
def test_r2_recipe_switch_detects_mixed_backbone(self):
|
||||
sc1 = ti_quality_scenario()
|
||||
sc2 = resin_quality_scenario()
|
||||
sc2.backbone = "mean" # 故意用不同主干
|
||||
report = TemplatePoC([sc1, sc2]).run()
|
||||
self.assertFalse(report.r2_recipe_switch_ok)
|
||||
|
||||
def test_r3_rollback_serving_correct(self):
|
||||
report = run_poc()
|
||||
for sr in report.scenario_results:
|
||||
self.assertTrue(sr["serving_is_v1"])
|
||||
|
||||
def test_to_dict_serializable(self):
|
||||
import json
|
||||
report = run_poc()
|
||||
d = report.to_dict()
|
||||
json.dumps(d) # 可序列化
|
||||
self.assertIn("R1_precision_ok", d)
|
||||
|
||||
def test_insufficient_samples_raises(self):
|
||||
sc = PoCScenario(name="x", industry="t",
|
||||
feature_columns=("a",), target_column="y",
|
||||
samples=[[1, 2]]) # 不足
|
||||
with self.assertRaises(PoCError):
|
||||
TemplatePoC([sc]).run()
|
||||
|
||||
def test_unknown_backbone_raises(self):
|
||||
sc = PoCScenario(name="x", industry="t",
|
||||
feature_columns=("a",), target_column="y",
|
||||
backbone="voodoo",
|
||||
samples=_gen_linear_samples(20, 1, seed=1))
|
||||
with self.assertRaises(PoCError):
|
||||
TemplatePoC([sc]).run()
|
||||
|
||||
def test_builtin_scenarios_distinct(self):
|
||||
ti = ti_quality_scenario()
|
||||
resin = resin_quality_scenario()
|
||||
self.assertNotEqual(ti.feature_columns, resin.feature_columns)
|
||||
self.assertEqual(ti.backbone, resin.backbone) # 共用主干(R2)
|
||||
self.assertNotEqual(ti.hyperparams, resin.hyperparams) # 配方不同
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,235 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""模型模板注册 / 加载 / 版本机制单元测试(issue #41)。
|
||||
|
||||
覆盖:
|
||||
- 版本号语义校验 ``is_valid_version``;
|
||||
- Stage 枚举与 ``next_stage`` 阶段提升顺序;
|
||||
- ModelTemplate 构造校验(name/version/backbone/stage)+ 序列化往返;
|
||||
- TemplateRegistry:注册(拒重复 / force 覆盖)、加载(version/stage/默认)、
|
||||
阶段提升 promote、回滚 rollback、set_stage、查询(list_*)、审计日志、
|
||||
JSON 持久化 save/load 往返一致性。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
if HERE not in sys.path:
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
import _bootstrap # noqa: E402 加载 model_framework 包
|
||||
|
||||
from model_framework.template_registry import ( # noqa: E402
|
||||
ModelTemplate,
|
||||
Stage,
|
||||
TemplateRegistry,
|
||||
TemplateRegistryError,
|
||||
is_valid_version,
|
||||
next_stage,
|
||||
)
|
||||
|
||||
|
||||
class TestVersionValidation(unittest.TestCase):
|
||||
def test_valid_versions(self):
|
||||
for v in ["v1", "1.0", "v1.2.3", "1.2.3", "v1.0-rc1", "v2.0.0+build5"]:
|
||||
self.assertTrue(is_valid_version(v), f"应合法:{v}")
|
||||
|
||||
def test_invalid_versions(self):
|
||||
for v in ["", "v", "abc", "v1.x", "1..2", None, "v 1"]:
|
||||
self.assertFalse(is_valid_version(v), f"应非法:{v!r}")
|
||||
|
||||
|
||||
class TestStage(unittest.TestCase):
|
||||
def test_from_str(self):
|
||||
self.assertEqual(Stage.from_str("dev"), Stage.DEV)
|
||||
self.assertEqual(Stage.from_str("PROD"), Stage.PROD)
|
||||
|
||||
def test_from_str_invalid(self):
|
||||
with self.assertRaises(TemplateRegistryError):
|
||||
Stage.from_str("qa")
|
||||
|
||||
def test_next_stage(self):
|
||||
self.assertEqual(next_stage(Stage.DEV), Stage.STAGING)
|
||||
self.assertEqual(next_stage(Stage.STAGING), Stage.PROD)
|
||||
self.assertIsNone(next_stage(Stage.PROD))
|
||||
|
||||
|
||||
class TestModelTemplate(unittest.TestCase):
|
||||
def test_construct_minimal(self):
|
||||
t = ModelTemplate(name="m", version="v1")
|
||||
self.assertEqual(t.backbone, "generic")
|
||||
self.assertEqual(t.stage, Stage.DEV)
|
||||
|
||||
def test_rejects_empty_name(self):
|
||||
with self.assertRaises(TemplateRegistryError):
|
||||
ModelTemplate(name="", version="v1")
|
||||
|
||||
def test_rejects_bad_version(self):
|
||||
with self.assertRaises(TemplateRegistryError):
|
||||
ModelTemplate(name="m", version="abc")
|
||||
|
||||
def test_rejects_bad_backbone(self):
|
||||
with self.assertRaises(TemplateRegistryError):
|
||||
ModelTemplate(name="m", version="v1", backbone="magic")
|
||||
|
||||
def test_accepts_known_backbones(self):
|
||||
for b in ("quality_forecast", "anomaly_detection",
|
||||
"cross_process_opt", "recipe_opt", "generic"):
|
||||
ModelTemplate(name="m", version="v1", backbone=b)
|
||||
|
||||
def test_roundtrip(self):
|
||||
t = ModelTemplate(
|
||||
name="qa-model", version="v1.2.0", backbone="quality_forecast",
|
||||
hyperparams={"lr": 0.1}, feature_columns=("a", "b"),
|
||||
target_column="y", metrics={"accuracy": 0.93},
|
||||
stage="prod", description="d", extra={"k": "v"})
|
||||
t2 = ModelTemplate.from_dict(json.loads(json.dumps(t.to_dict())))
|
||||
self.assertEqual(t, t2)
|
||||
self.assertEqual(t2.stage, Stage.PROD)
|
||||
self.assertEqual(t2.metrics["accuracy"], 0.93)
|
||||
|
||||
|
||||
class TestRegistryRegisterLoad(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.reg = TemplateRegistry()
|
||||
self.t1 = ModelTemplate(name="m", version="v1", backbone="quality_forecast")
|
||||
self.t2 = ModelTemplate(name="m", version="v2", backbone="quality_forecast")
|
||||
|
||||
def test_register_and_get_by_version(self):
|
||||
self.reg.register(self.t1)
|
||||
self.assertEqual(self.reg.get("m", "v1").version, "v1")
|
||||
|
||||
def test_register_duplicate_rejected(self):
|
||||
self.reg.register(self.t1)
|
||||
with self.assertRaises(TemplateRegistryError):
|
||||
self.reg.register(self.t1)
|
||||
|
||||
def test_register_force_overwrites(self):
|
||||
self.reg.register(self.t1)
|
||||
t1_updated = ModelTemplate(
|
||||
name="m", version="v1", description="updated")
|
||||
self.reg.register(t1_updated, force=True)
|
||||
self.assertEqual(self.reg.get("m", "v1").description, "updated")
|
||||
|
||||
def test_get_missing_name(self):
|
||||
with self.assertRaises(TemplateRegistryError):
|
||||
self.reg.get("nope")
|
||||
|
||||
def test_get_missing_version(self):
|
||||
self.reg.register(self.t1)
|
||||
with self.assertRaises(TemplateRegistryError):
|
||||
self.reg.get("m", "v99")
|
||||
|
||||
def test_get_default_latest(self):
|
||||
self.reg.register(self.t1)
|
||||
time.sleep(0.01)
|
||||
self.reg.register(self.t2)
|
||||
self.assertEqual(self.reg.get("m").version, "v2")
|
||||
|
||||
def test_get_by_stage_pointer(self):
|
||||
self.reg.register(self.t1)
|
||||
# 新注册默认进 dev
|
||||
self.assertEqual(self.reg.get("m", stage=Stage.DEV).version, "v1")
|
||||
with self.assertRaises(TemplateRegistryError):
|
||||
self.reg.get("m", stage=Stage.PROD)
|
||||
|
||||
|
||||
class TestPromoteRollback(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.reg = TemplateRegistry()
|
||||
self.reg.register(ModelTemplate(name="m", version="v1"))
|
||||
self.reg.register(ModelTemplate(name="m", version="v2"))
|
||||
|
||||
def test_promote_chain(self):
|
||||
self.reg.promote("m", "v1") # dev -> staging
|
||||
self.assertEqual(self.reg.stage_pointer("m", Stage.STAGING), "v1")
|
||||
self.reg.promote("m", "v1") # staging -> prod
|
||||
self.assertEqual(self.reg.stage_pointer("m", Stage.PROD), "v1")
|
||||
|
||||
def test_promote_prod_raises(self):
|
||||
self.reg.promote("m", "v1")
|
||||
self.reg.promote("m", "v1") # 到 prod
|
||||
with self.assertRaises(TemplateRegistryError):
|
||||
self.reg.promote("m", "v1") # prod 无法继续
|
||||
|
||||
def test_rollback_stage_pointer(self):
|
||||
self.reg.promote("m", "v2") # v2 -> staging
|
||||
self.reg.promote("m", "v2") # v2 -> prod
|
||||
# 回滚 prod 到 v1
|
||||
self.reg.rollback("m", Stage.PROD, "v1")
|
||||
self.assertEqual(self.reg.stage_pointer("m", Stage.PROD), "v1")
|
||||
# v2 版本本身仍在(可审计)
|
||||
self.assertIn("v2", self.reg.list_versions("m"))
|
||||
|
||||
def test_set_stage_direct(self):
|
||||
self.reg.set_stage("m", "v1", Stage.PROD)
|
||||
self.assertEqual(self.reg.get("m", "v1").stage, Stage.PROD)
|
||||
self.assertEqual(self.reg.stage_pointer("m", Stage.PROD), "v1")
|
||||
|
||||
|
||||
class TestQueries(unittest.TestCase):
|
||||
def test_list_names_and_versions(self):
|
||||
reg = TemplateRegistry()
|
||||
reg.register(ModelTemplate(name="a", version="v1"))
|
||||
reg.register(ModelTemplate(name="a", version="v2"))
|
||||
reg.register(ModelTemplate(name="b", version="v1"))
|
||||
self.assertEqual(reg.list_names(), ["a", "b"])
|
||||
self.assertEqual(reg.list_versions("a"), ["v1", "v2"])
|
||||
self.assertIn("a", reg)
|
||||
self.assertNotIn("c", reg)
|
||||
self.assertEqual(len(reg), 3)
|
||||
|
||||
def test_list_by_stage(self):
|
||||
reg = TemplateRegistry()
|
||||
reg.register(ModelTemplate(name="m", version="v1"))
|
||||
reg.register(ModelTemplate(name="m", version="v2"))
|
||||
reg.promote("m", "v2") # v2 -> staging
|
||||
self.assertEqual(reg.list_by_stage("m", Stage.DEV), ["v1"])
|
||||
self.assertEqual(reg.list_by_stage("m", Stage.STAGING), ["v2"])
|
||||
|
||||
|
||||
class TestHistoryAndPersist(unittest.TestCase):
|
||||
def test_history_logged(self):
|
||||
reg = TemplateRegistry()
|
||||
reg.register(ModelTemplate(name="m", version="v1"))
|
||||
reg.promote("m", "v1")
|
||||
h = reg.history("m")
|
||||
actions = [e["action"] for e in h]
|
||||
self.assertIn("register", actions)
|
||||
self.assertIn("promote", actions)
|
||||
|
||||
def test_history_filter_by_name(self):
|
||||
reg = TemplateRegistry()
|
||||
reg.register(ModelTemplate(name="a", version="v1"))
|
||||
reg.register(ModelTemplate(name="b", version="v1"))
|
||||
self.assertEqual(len(reg.history("a")), 1)
|
||||
self.assertEqual(len(reg.history("b")), 1)
|
||||
|
||||
def test_save_load_roundtrip(self):
|
||||
reg = TemplateRegistry()
|
||||
reg.register(ModelTemplate(
|
||||
name="m", version="v1", backbone="quality_forecast",
|
||||
metrics={"accuracy": 0.9}, stage="dev"))
|
||||
reg.promote("m", "v1")
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False, encoding="utf-8") as fh:
|
||||
path = fh.name
|
||||
try:
|
||||
reg.save(path)
|
||||
reg2 = TemplateRegistry.load(path)
|
||||
self.assertEqual(reg2.list_names(), ["m"])
|
||||
self.assertEqual(reg2.get("m", "v1").backbone, "quality_forecast")
|
||||
self.assertEqual(reg2.get("m", "v1").metrics["accuracy"], 0.9)
|
||||
# 阶段指针恢复
|
||||
self.assertEqual(reg2.stage_pointer("m", Stage.STAGING), "v1")
|
||||
# 审计日志恢复
|
||||
self.assertTrue(len(reg2.history("m")) >= 2)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user