新增 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 用例)全通过;纯标准库零运行时依赖。
183 lines
6.6 KiB
Python
183 lines
6.6 KiB
Python
# -*- 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)
|