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 用例)全通过;纯标准库零运行时依赖。
This commit is contained in:
2026-08-05 05:16:26 +08:00
parent c6dc7d2344
commit aeee8ef468
3 changed files with 731 additions and 0 deletions
@@ -0,0 +1,182 @@
# -*- 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)