# -*- 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)