feat(#36): 质量预测模型模板化(固定主干+配方加载,PRD 5.3 ①质量预测)
对应 issue #36(父 EPIC #5「③ AI 模型框架 配置化重构」、PRD 5.3 「网络结构策略 / 模板化技术路径」)。 落地 PRD 5.3「固定主干网络 + 可配置超参」默认模式: - core/model-framework/quality_forecast.py:QualityForecastModel 固定主干 (默认 gbdt 梯度提升回归,PRD 5.3 监督回归默认结构)+ Recipe 配方加载器 (load_recipe/build_from_recipe 声明式 JSON 超参包)。切换行业/工况只改 配方,模型代码零改动——对齐 PRD 验收口径「切换模板仅改超参包」。 - 主干注册表 BACKBONES + register_backbone:gbdt/dnn/stub 三类内置主干, 有 sklearn 升级真实 GBDT/MLP,无依赖退化确定性 stub(零外部强依赖, CI 可加载校验);新增结构走插件注册而非改内核(PRD 5.3 理念,风格对齐 #34)。 - Accuracy 验收口径:PRD 5.3/第6章里程碑「质量预测准确率≥90%」, evaluate 直接给出 accuracy/MAE/RMSE 与是否达标。 - 样例协议 samples/quality-forecast/:Ti(海绵钛氯化车间)+ 树脂 两套超参包, 验证「同框架加载两套配方均跑通」。 - 接口风格对齐 #34 ModelHandle/ModelRecipe,自包含不依赖未合并的 model_recipe; 待 PR #102(#34) 合入后主干可平滑注册为具名 backbone、配方映射为 ModelRecipe。 - 24 个单元测试全通过 + sanity check 通过。
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""测试引导:把连字符目录 ``core/model-framework`` 加载为可导入包
|
||||
``model_framework``,使测试可 ``from model_framework import ...``。
|
||||
|
||||
与仓库内各 core 模块的测试引导同款模式(importlib 完整加载包,执行
|
||||
``__init__.py``,保持顶层导出可用)。
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
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,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 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)
|
||||
Reference in New Issue
Block a user