# -*- coding: utf-8 -*- """Ti-2 跨工序关联寻优模型训练 单元测试(Issue #80)。 覆盖: - RidgeRegression(拟合/预测/正则/权重/序列化、奇异矩阵处理); - 线性求解器; - CrossProcessModelConfig(合法性校验); - CrossProcessModel(fit/predict/evaluate R²/特征权重可解释/序列化往返); - 数据门槛(min_samples 拒绝、缺失值过滤)。 """ import math import os import sys import tempfile import unittest sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import _bootstrap # noqa: E402 from recipe_optim.cross_process import ( # noqa: E402 CrossProcessError, CrossProcessModel, CrossProcessModelConfig, CrossProcessSample, RidgeRegression, _solve_linear, ) class TestSolveLinear(unittest.TestCase): def test_basic(self): # x + y = 3, 2x - y = 0 → x=1, y=2 w = _solve_linear([[1, 1], [2, -1]], [3, 0]) self.assertAlmostEqual(w[0], 1.0) self.assertAlmostEqual(w[1], 2.0) def test_singular_raises(self): with self.assertRaises(CrossProcessError): _solve_linear([[1, 1], [1, 1]], [1, 1]) class TestRidgeRegression(unittest.TestCase): def test_fit_predict_linear(self): # y = 2x + 1(精确线性) reg = RidgeRegression(alpha=0.0) reg.fit([[0], [1], [2], [3]], [1, 3, 5, 7], ["x"]) self.assertAlmostEqual(reg.predict_one([4]), 9.0) d = reg.weights_dict() self.assertAlmostEqual(d["x"], 2.0, places=6) self.assertAlmostEqual(d["__intercept__"], 1.0, places=6) def test_multivariate(self): # y = x0 + 2*x1 reg = RidgeRegression(alpha=0.0) reg.fit([[0, 0], [1, 0], [0, 1], [1, 1], [2, 3]], [0, 1, 2, 3, 8], ["x0", "x1"]) self.assertAlmostEqual(reg.predict_one([1, 1]), 3.0, places=5) def test_regularization_smooths(self): # 强正则下权重被压缩向 0 X = [[0], [1], [2], [3]] y = [1, 3, 5, 7] r0 = RidgeRegression(alpha=0.0); r0.fit(X, y, ["x"]) rbig = RidgeRegression(alpha=1000.0); rbig.fit(X, y, ["x"]) self.assertLess(abs(rbig.weights_dict()["x"]), abs(r0.weights_dict()["x"])) def test_negative_alpha_rejected(self): with self.assertRaises(CrossProcessError): RidgeRegression(alpha=-1) def test_mismatched_columns_rejected(self): reg = RidgeRegression() with self.assertRaises(CrossProcessError): reg.fit([[1, 2]], [3], ["x"]) def test_predict_before_fit(self): with self.assertRaises(CrossProcessError): RidgeRegression().predict_one([1]) def test_roundtrip(self): reg = RidgeRegression(alpha=0.5) reg.fit([[0], [1], [2]], [1, 3, 5], ["x"]) reg2 = RidgeRegression.from_dict(reg.to_dict()) self.assertAlmostEqual(reg2.predict_one([3]), reg.predict_one([3])) class TestConfig(unittest.TestCase): def test_empty_features_rejected(self): with self.assertRaises(CrossProcessError): CrossProcessModelConfig(upstream_features=[], downstream_targets=["t"]) def test_empty_targets_rejected(self): with self.assertRaises(CrossProcessError): CrossProcessModelConfig(upstream_features=["f"], downstream_targets=[]) def test_bad_min_samples(self): with self.assertRaises(CrossProcessError): CrossProcessModelConfig(upstream_features=["f"], downstream_targets=["t"], min_samples=1) def _gen_samples(n=20, seed=42): """生成 y = 2*x + 3 的合成样本(上游 x,下游 y),用于训练/评估。""" import random rng = random.Random(seed) samples = [] for i in range(n): x = rng.uniform(0, 10) samples.append(CrossProcessSample( upstream={"TiCl4_purity": x}, downstream={"sponge_titanium_grade": 2.0 * x + 3.0}, batch=f"B{i}", timestamp=float(i), )) return samples class TestCrossProcessModel(unittest.TestCase): def test_fit_predict_evaluate(self): cfg = CrossProcessModelConfig( upstream_features=["TiCl4_purity"], downstream_targets=["sponge_titanium_grade"], alpha=0.0, min_samples=10, ) m = CrossProcessModel(cfg) train = _gen_samples(15, seed=1) m.fit(train) # 预测接近真实 pred = m.predict({"TiCl4_purity": 5.0}) self.assertAlmostEqual(pred["sponge_titanium_grade"], 2 * 5 + 3, places=3) # R² 接近 1(线性可精确拟合) report = m.evaluate(_gen_samples(20, seed=2)) self.assertGreater(report["r2_sponge_titanium_grade"], 0.99) self.assertIn("mse_overall", report) def test_min_samples_enforced(self): cfg = CrossProcessModelConfig( upstream_features=["f"], downstream_targets=["t"], min_samples=10) m = CrossProcessModel(cfg) with self.assertRaises(CrossProcessError): m.fit([CrossProcessSample(upstream={"f": 1}, downstream={"t": 2})] * 3) def test_missing_values_filtered(self): cfg = CrossProcessModelConfig( upstream_features=["f1", "f2"], downstream_targets=["t"], min_samples=5) samples = [] for i in range(10): s = CrossProcessSample( upstream={"f1": float(i), "f2": float(i)}, downstream={"t": float(i) + float(i)}, batch=f"B{i}") samples.append(s) # 给部分样本注入缺失值(应被过滤,但剩余 ≥ min_samples 仍可训练) samples[0].upstream["f1"] = float("nan") m = CrossProcessModel(cfg) m.fit(samples) self.assertTrue(m.fitted) def test_predict_missing_feature(self): cfg = CrossProcessModelConfig( upstream_features=["f"], downstream_targets=["t"], min_samples=5) m = CrossProcessModel(cfg) m.fit([CrossProcessSample(upstream={"f": float(i)}, downstream={"t": float(i)}) for i in range(6)]) with self.assertRaises(CrossProcessError): m.predict({}) # 缺 f def test_feature_weights_explainable(self): cfg = CrossProcessModelConfig( upstream_features=["f"], downstream_targets=["t"], alpha=0.0, min_samples=5) m = CrossProcessModel(cfg) m.fit([CrossProcessSample(upstream={"f": float(i)}, downstream={"t": 2 * float(i) + 1}) for i in range(6)]) w = m.feature_weights("t") self.assertAlmostEqual(w["f"], 2.0, places=4) self.assertIn("__intercept__", w) with self.assertRaises(CrossProcessError): m.feature_weights("ghost") def test_evaluate_before_fit(self): cfg = CrossProcessModelConfig( upstream_features=["f"], downstream_targets=["t"], min_samples=5) with self.assertRaises(CrossProcessError): CrossProcessModel(cfg).evaluate([]) def test_save_load_roundtrip(self): cfg = CrossProcessModelConfig( upstream_features=["TiCl4_purity"], downstream_targets=["sponge_titanium_grade"], alpha=0.1, min_samples=5) m = CrossProcessModel(cfg) m.fit(_gen_samples(10, seed=3)) with tempfile.TemporaryDirectory() as d: path = os.path.join(d, "model.json") m.save(path) m2 = CrossProcessModel.load(path) self.assertTrue(m2.fitted) p1 = m.predict({"TiCl4_purity": 4.0}) p2 = m2.predict({"TiCl4_purity": 4.0}) self.assertAlmostEqual(p1["sponge_titanium_grade"], p2["sponge_titanium_grade"], places=6) if __name__ == "__main__": unittest.main()