Files
iAOP/templates/ti-cl4/quality-forecast/tests/test_model.py
T
bot_dev1 aeee8ef468 feat(#69): [Ti-1] 质量预测模型训练与评估(岭回归+R²/MAE/RMSE+可解释)
新增 templates/ti-cl4/quality-forecast/model.py:
- RidgeRegression:纯标准库最小二乘+L2正则闭式解(高斯消元,不依赖numpy)
- QualityModel:聚合特征+目标+岭回归,fit/predict/evaluate(R²/MAE/RMSE)/explain
- explain():尺度归一化重要性(|权重|×std),跨特征可比较,供#73可解释
- TrainingSet.from_records:自动跳过含 NaN 的行
- ModelRecipe 超参包:target/alpha/feature_names 外置 YAML/JSON
- 序列化 to_dict/from_dict 往返一致
- 17 用例(累计 39 用例)全通过;纯标准库零运行时依赖。
2026-08-05 05:16:26 +08:00

183 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Ti-1 质量预测模型训练与评估测试(Issue #69)。
覆盖:
1. RidgeRegression 拟合线性关系(权重/截距/预测正确);
2. L2 正则缓解共线性(alpha>0 可解奇异阵);
3. 评估指标 R²/MAE/RMSE 数值正确;
4. QualityModel fit/predict/evaluate/explain 全链路;
5. TrainingSet.from_records 自动跳过含 NaN 的行;
6. 超参包 YAML/JSON 加载 + 校验;
7. 序列化 to_dict/from_dict 往返一致;
8. 数据不足报错。
"""
import math
import os
import sys
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import _bootstrap # noqa: F401,E402
from quality_forecast import model as M # noqa: E402
from quality_forecast import features as F # noqa: E402
class TestRidgeRegression(unittest.TestCase):
def test_fit_linear(self):
# y = 2*x0 + 1,纯线性
X = [[0.0], [1.0], [2.0], [3.0]]
y = [1.0, 3.0, 5.0, 7.0]
r = M.RidgeRegression(alpha=0.0).fit(X, y)
self.assertAlmostEqual(r.coef_[0], 2.0, places=4)
self.assertAlmostEqual(r.intercept_, 1.0, places=4)
self.assertAlmostEqual(r.predict([[5.0]])[0], 11.0, places=4)
def test_fit_multivariate(self):
# y = 1*x0 + 2*x1
X = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0], [2.0, 3.0]]
y = [0.0, 1.0, 2.0, 3.0, 8.0]
r = M.RidgeRegression(alpha=0.0).fit(X, y)
self.assertAlmostEqual(r.coef_[0], 1.0, places=3)
self.assertAlmostEqual(r.coef_[1], 2.0, places=3)
def test_regularization_handles_collinearity(self):
# 共线性特征:x0 == x1,alpha=0 会奇异,alpha>0 可解
X = [[1.0, 1.0], [2.0, 2.0], [3.0, 3.0], [4.0, 4.0]]
y = [2.0, 4.0, 6.0, 8.0]
with self.assertRaises(M.ModelError):
M.RidgeRegression(alpha=0.0).fit(X, y)
r = M.RidgeRegression(alpha=1.0).fit(X, y)
pred = r.predict([[5.0, 5.0]])
# 预测应接近 10(y=2*x)
self.assertAlmostEqual(pred[0], 10.0, places=0)
def test_empty_raises(self):
with self.assertRaises(M.ModelError):
M.RidgeRegression().fit([], [])
def test_predict_before_fit_raises(self):
with self.assertRaises(M.ModelError):
M.RidgeRegression().predict([[1.0]])
class TestMetrics(unittest.TestCase):
def test_perfect_prediction(self):
y = [1.0, 2.0, 3.0]
self.assertEqual(M.r2_score(y, y), 1.0)
self.assertEqual(M.mae_score(y, y), 0.0)
self.assertEqual(M.rmse_score(y, y), 0.0)
def test_r2_mean_predictor(self):
# 预测恒为均值 → R²=0
y = [1.0, 2.0, 3.0]
mean = 2.0
self.assertAlmostEqual(M.r2_score(y, [mean, mean, mean]), 0.0, places=6)
def test_mae_rmse(self):
y = [1.0, 3.0]
pred = [2.0, 2.0]
self.assertEqual(M.mae_score(y, pred), 1.0)
self.assertAlmostEqual(M.rmse_score(y, pred), 1.0, places=6)
class TestQualityModel(unittest.TestCase):
def _make_model_and_data(self):
recipe = M.ModelRecipe(
target="y", alpha=0.01, feature_names=["a", "b"])
# y = 3*a + 0*b + 2;b 为与 a 不相关的纯噪声维度(权重应趋近 0)
X = [[0.0, 7.0], [1.0, 3.0], [2.0, 9.0],
[3.0, 1.0], [4.0, 5.0]]
y = [2.0, 5.0, 8.0, 11.0, 14.0]
return recipe, X, y
def test_fit_predict_evaluate(self):
recipe, X, y = self._make_model_and_data()
model = M.QualityModel(recipe).fit(X, y)
ev = model.evaluate(X, y)
self.assertGreaterEqual(ev.r2, 0.99)
self.assertLess(ev.mae, 0.1)
self.assertTrue(ev.passes(min_r2=0.9))
def test_explain(self):
recipe, X, y = self._make_model_and_data()
model = M.QualityModel(recipe).fit(X, y)
exp = model.explain()
self.assertEqual(len(exp), 2)
# a 的贡献应远大于 b
imp = {e["feature"]: e["importance"] for e in exp}
self.assertGreater(imp["a"], imp["b"])
def test_predict_before_fit(self):
model = M.QualityModel(M.ModelRecipe(target="y"))
with self.assertRaises(M.ModelError):
model.predict([[1.0]])
def test_serialization_roundtrip(self):
recipe, X, y = self._make_model_and_data()
model = M.QualityModel(recipe).fit(X, y)
d = model.to_dict()
model2 = M.QualityModel.from_dict(d)
self.assertEqual(model.predict(X), model2.predict(X))
class TestTrainingSet(unittest.TestCase):
def test_skips_nan_rows(self):
records = [
{"a": 1.0, "b": 2.0, "y": 5.0},
{"a": float("nan"), "b": 2.0, "y": 5.0}, # a NaN → 跳过
{"a": 1.0, "b": float("nan"), "y": 5.0}, # b NaN → 跳过
{"a": 3.0, "b": 4.0, "y": 7.0},
{"a": 1.0, "b": 2.0}, # 无 y → 跳过
]
ts = M.TrainingSet.from_records(records, ["a", "b"], "y")
self.assertEqual(len(ts), 2)
class TestRecipeLoading(unittest.TestCase):
def test_load_yaml(self):
text = "target: TiCl4_purity\nalpha: 0.5\nunit: '%'\n"
r = M.load_recipe(text)
self.assertEqual(r.target, "TiCl4_purity")
self.assertEqual(r.alpha, 0.5)
def test_load_json(self):
r = M.load_recipe('{"target": "y", "alpha": 2.0}')
self.assertEqual(r.target, "y")
self.assertEqual(r.alpha, 2.0)
def test_bad_recipe_raises(self):
with self.assertRaises(M.ModelError):
M.QualityModel(M.ModelRecipe(target="", alpha=-1))
class TestEndToEndWithFeatures(unittest.TestCase):
"""端到端:特征抽取 → 训练 → 评估。"""
def test_pipeline(self):
# 用 features 模块抽取,再训练一个能拟合的模型
ext = F.FeatureExtractor([
F.FeatureSpec("t", "CLF-01.TEMP", "raw"),
])
samples = [
F.Sample(ts=i * 10.0, values={"CLF-01.TEMP": 100.0 + 10 * i})
for i in range(8)
]
matrix = ext.extract(samples)
# 目标:纯度 = 0.1*TEMP - 5(线性可分)
records = []
for row, s in zip(matrix.rows, samples):
temp = s.values["CLF-01.TEMP"]
records.append({"t": row[0], "RF-01.PURITY": 0.1 * temp - 5.0})
ts = M.TrainingSet.from_records(records, ["t"], "RF-01.PURITY")
model = M.QualityModel(
M.ModelRecipe(target="RF-01.PURITY", alpha=0.001,
feature_names=["t"])).fit(ts.X, ts.y)
ev = model.evaluate(ts.X, ts.y)
self.assertGreaterEqual(ev.r2, 0.99)
if __name__ == "__main__":
unittest.main(verbosity=2)