Files
iAOP/templates/ti-cl4/recipe-optim/tests/test_cross_process.py
T

207 lines
7.7 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-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()