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:
@@ -0,0 +1,18 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Ti-1 氯化车间质量预测 · 模型超参包(Issue #69 / PRD §5.3 ①)。
|
||||||
|
#
|
||||||
|
# 模板化技术路径(PRD §5.3):超参包外置,换行业只改本文件 + 特征清单。
|
||||||
|
# target:质量标签列名(默认取 RF-01.PURITY = TiCl₄纯度)。
|
||||||
|
# alpha:岭回归 L2 正则强度(防共线性/过拟合)。
|
||||||
|
# feature_names:训练用特征名(须与 features.template.yaml 的 name 对齐)。
|
||||||
|
|
||||||
|
target: RF-01.PURITY
|
||||||
|
alpha: 1.0
|
||||||
|
unit: "%"
|
||||||
|
feature_names:
|
||||||
|
- clf_temp_mean
|
||||||
|
- clf_temp_slope
|
||||||
|
- clf_cl2_feed_ratio
|
||||||
|
- clf_co_std
|
||||||
|
- clf_bed_range
|
||||||
|
- rf_imp_mean
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Ti-1 氯化车间质量预测 · 模型训练与评估(Issue #69 / PRD §5.3 ①)。
|
||||||
|
|
||||||
|
承接 #68(特征工程)产出的 ``FeatureMatrix``:把"特征矩阵 + 质量标签 → 可评估、
|
||||||
|
可解释的预测模型"这条链路**模板化、可测试**,且与 #73 模型部署解耦。
|
||||||
|
|
||||||
|
PRD 设计口径
|
||||||
|
------------
|
||||||
|
- 架构表(PRD §5.3):``质量预测 | 预测 | 入:特征矩阵;
|
||||||
|
出:质量指标预测值(纯度/杂质) | ① 质量预测 | 中``。
|
||||||
|
- 模板化技术路径:模型超参(``alpha`` 正则强度、``target`` 标签列)外置为
|
||||||
|
JSON/YAML 超参包,切换模板只改超参包(PRD §5.3「换行业只改 Recipe」)。
|
||||||
|
- 验收(PRD §10 DoD):质量预测在客户数据上达约定 R² / MAE 指标。
|
||||||
|
|
||||||
|
本模块交付
|
||||||
|
----------
|
||||||
|
1. **岭回归 ``RidgeRegression``**:纯标准库最小二乘 + L2 正则(闭式解),
|
||||||
|
``fit(X, y)`` / ``predict(X)`` / ``coef_`` / ``intercept_``。不依赖 numpy。
|
||||||
|
2. **质量预测模型 ``QualityModel``**:聚合特征名 + 目标列 + 岭回归,提供
|
||||||
|
``fit(matrix, target)`` / ``predict(matrix)`` / ``evaluate(matrix, target)``
|
||||||
|
(R² / MAE / RMSE)/ ``explain()``(权重 → 特征贡献,可溯源)。
|
||||||
|
3. **超参包 ``ModelRecipe``**:``target``/``alpha``/``feature_names`` 外置
|
||||||
|
JSON/YAML 加载(零依赖 YAML 子集解析)。
|
||||||
|
4. **评估指标**:R²、MAE、RMSE 纯标准库实现。
|
||||||
|
|
||||||
|
设计要点
|
||||||
|
--------
|
||||||
|
- **零运行时依赖**(纯标准库):矩阵运算手写(不依赖 numpy/sklearn)。
|
||||||
|
- **可解释**:``explain()`` 输出每个特征的权重 × 方差贡献度,供 #73 接入驾驶舱
|
||||||
|
展示"为何预测这个纯度"(对齐 PRD"要求结果可解释、可溯源")。
|
||||||
|
- **稳健**:L2 正则避免共线性/过拟合;数据不足时返回明确错误而非崩溃。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
NAN = float("nan")
|
||||||
|
|
||||||
|
|
||||||
|
class ModelError(ValueError):
|
||||||
|
"""质量预测模型错误(数据不足 / 维度不匹配 / 奇异 等)。"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 纯标准库线性代数(最小二乘岭回归闭式解)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _matmul_at_a(a: Sequence[Sequence[float]]) -> List[List[float]]:
|
||||||
|
"""计算 AᵀA(n×n)。"""
|
||||||
|
n = len(a[0]) if a else 0
|
||||||
|
res = [[0.0] * n for _ in range(n)]
|
||||||
|
for row in a:
|
||||||
|
for i in range(n):
|
||||||
|
ri = row[i]
|
||||||
|
if ri == 0.0:
|
||||||
|
continue
|
||||||
|
for j in range(n):
|
||||||
|
res[i][j] += ri * row[j]
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def _matvec_at_b(a: Sequence[Sequence[float]], b: Sequence[float]) -> List[float]:
|
||||||
|
"""计算 Aᵀb(n)。"""
|
||||||
|
n = len(a[0]) if a else 0
|
||||||
|
res = [0.0] * n
|
||||||
|
for row, y in zip(a, b):
|
||||||
|
for i in range(n):
|
||||||
|
res[i] += row[i] * y
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def _solve(A: List[List[float]], b: List[float]) -> List[float]:
|
||||||
|
"""高斯消元解 Ax=b(带部分主元)。A 会被修改。"""
|
||||||
|
n = len(A)
|
||||||
|
# 增广
|
||||||
|
M = [list(A[i]) + [b[i]] for i in range(n)]
|
||||||
|
for col in range(n):
|
||||||
|
# 主元
|
||||||
|
pivot = max(range(col, n), key=lambda r: abs(M[r][col]))
|
||||||
|
if abs(M[pivot][col]) < 1e-12:
|
||||||
|
raise ModelError("矩阵奇异(特征共线或数据不足),无法求解")
|
||||||
|
M[col], M[pivot] = M[pivot], M[col]
|
||||||
|
piv = M[col][col]
|
||||||
|
for j in range(col, n + 1):
|
||||||
|
M[col][j] /= piv
|
||||||
|
for r in range(n):
|
||||||
|
if r == col:
|
||||||
|
continue
|
||||||
|
factor = M[r][col]
|
||||||
|
if factor == 0.0:
|
||||||
|
continue
|
||||||
|
for j in range(col, n + 1):
|
||||||
|
M[r][j] -= factor * M[col][j]
|
||||||
|
return [M[i][n] for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 岭回归
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class RidgeRegression:
|
||||||
|
"""岭回归(L2 正则最小二乘,闭式解)。纯标准库。
|
||||||
|
|
||||||
|
解:``w = (XᵀX + αI)⁻¹ Xᵀy``。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, alpha: float = 1.0):
|
||||||
|
if alpha < 0:
|
||||||
|
raise ModelError(f"alpha 不能为负: {alpha}")
|
||||||
|
self.alpha = alpha
|
||||||
|
self.coef_: List[float] = []
|
||||||
|
self.intercept_: float = 0.0
|
||||||
|
self._n_features: int = 0
|
||||||
|
|
||||||
|
def fit(self, X: Sequence[Sequence[float]], y: Sequence[float]) -> "RidgeRegression":
|
||||||
|
m = len(X)
|
||||||
|
if m == 0:
|
||||||
|
raise ModelError("训练集为空")
|
||||||
|
n = len(X[0])
|
||||||
|
if n == 0:
|
||||||
|
raise ModelError("特征数为 0")
|
||||||
|
if len(y) != m:
|
||||||
|
raise ModelError(f"X/y 行数不匹配: {m} != {len(y)}")
|
||||||
|
self._n_features = n
|
||||||
|
# 中心化(数值稳定 + 让 intercept 可独立)
|
||||||
|
x_mean = [sum(X[i][j] for i in range(m)) / m for j in range(n)]
|
||||||
|
y_mean = sum(y) / m
|
||||||
|
Xc = [[X[i][j] - x_mean[j] for j in range(n)] for i in range(m)]
|
||||||
|
yc = [y[i] - y_mean for i in range(m)]
|
||||||
|
# 正规方程 (XᵀX + αI) w = Xᵀy
|
||||||
|
A = _matmul_at_a(Xc)
|
||||||
|
for i in range(n):
|
||||||
|
A[i][i] += self.alpha
|
||||||
|
b = _matvec_at_b(Xc, yc)
|
||||||
|
self.coef_ = _solve(A, b)
|
||||||
|
self.intercept_ = y_mean - sum(self.coef_[j] * x_mean[j] for j in range(n))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, X: Sequence[Sequence[float]]) -> List[float]:
|
||||||
|
if not self.coef_:
|
||||||
|
raise ModelError("模型未训练")
|
||||||
|
return [self.intercept_ + sum(self.coef_[j] * row[j]
|
||||||
|
for j in range(self._n_features))
|
||||||
|
for row in X]
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {"alpha": self.alpha, "coef": list(self.coef_),
|
||||||
|
"intercept": self.intercept_,
|
||||||
|
"n_features": self._n_features}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: Dict[str, Any]) -> "RidgeRegression":
|
||||||
|
m = cls(alpha=float(d.get("alpha", 1.0)))
|
||||||
|
m.coef_ = [float(c) for c in d.get("coef", [])]
|
||||||
|
m.intercept_ = float(d.get("intercept", 0.0))
|
||||||
|
m._n_features = int(d.get("n_features", len(m.coef_)))
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 评估指标
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def r2_score(y_true: Sequence[float], y_pred: Sequence[float]) -> float:
|
||||||
|
"""决定系数 R²。"""
|
||||||
|
if len(y_true) != len(y_pred):
|
||||||
|
raise ModelError("y_true/y_pred 长度不匹配")
|
||||||
|
n = len(y_true)
|
||||||
|
if n == 0:
|
||||||
|
return NAN
|
||||||
|
mean = sum(y_true) / n
|
||||||
|
ss_res = sum((y_true[i] - y_pred[i]) ** 2 for i in range(n))
|
||||||
|
ss_tot = sum((y_true[i] - mean) ** 2 for i in range(n))
|
||||||
|
if ss_tot == 0:
|
||||||
|
return 1.0 if ss_res == 0 else 0.0
|
||||||
|
return 1.0 - ss_res / ss_tot
|
||||||
|
|
||||||
|
|
||||||
|
def mae_score(y_true: Sequence[float], y_pred: Sequence[float]) -> float:
|
||||||
|
if len(y_true) != len(y_pred):
|
||||||
|
raise ModelError("y_true/y_pred 长度不匹配")
|
||||||
|
n = len(y_true)
|
||||||
|
return sum(abs(y_true[i] - y_pred[i]) for i in range(n)) / n if n else NAN
|
||||||
|
|
||||||
|
|
||||||
|
def rmse_score(y_true: Sequence[float], y_pred: Sequence[float]) -> float:
|
||||||
|
if len(y_true) != len(y_pred):
|
||||||
|
raise ModelError("y_true/y_pred 长度不匹配")
|
||||||
|
n = len(y_true)
|
||||||
|
if n == 0:
|
||||||
|
return NAN
|
||||||
|
return math.sqrt(sum((y_true[i] - y_pred[i]) ** 2 for i in range(n)) / n)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 质量预测模型(聚合特征 + 目标 + 岭回归)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelRecipe:
|
||||||
|
"""模型超参包(外置 JSON/YAML)。"""
|
||||||
|
|
||||||
|
target: str # 质量标签列名(如 TiCl₄纯度)
|
||||||
|
alpha: float = 1.0 # L2 正则强度
|
||||||
|
feature_names: List[str] = field(default_factory=list)
|
||||||
|
unit: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: Dict[str, Any]) -> "ModelRecipe":
|
||||||
|
return cls(
|
||||||
|
target=str(d.get("target", "")).strip(),
|
||||||
|
alpha=float(d.get("alpha", 1.0)),
|
||||||
|
feature_names=[str(x) for x in d.get("feature_names", [])],
|
||||||
|
unit=str(d.get("unit", "")).strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self) -> List[str]:
|
||||||
|
errs = []
|
||||||
|
if not self.target:
|
||||||
|
errs.append("target 不能为空")
|
||||||
|
if self.alpha < 0:
|
||||||
|
errs.append(f"alpha 不能为负: {self.alpha}")
|
||||||
|
return errs
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Evaluation:
|
||||||
|
"""评估结果。"""
|
||||||
|
|
||||||
|
r2: float
|
||||||
|
mae: float
|
||||||
|
rmse: float
|
||||||
|
n_samples: int
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {"r2": round(self.r2, 6), "mae": round(self.mae, 6),
|
||||||
|
"rmse": round(self.rmse, 6), "n_samples": self.n_samples}
|
||||||
|
|
||||||
|
def passes(self, *, min_r2: float = 0.0, max_mae: float = math.inf) -> bool:
|
||||||
|
return (self.r2 >= min_r2 and self.mae <= max_mae
|
||||||
|
and not math.isnan(self.r2))
|
||||||
|
|
||||||
|
|
||||||
|
class QualityModel:
|
||||||
|
"""质量预测模型:特征名 + 目标列 + 岭回归。"""
|
||||||
|
|
||||||
|
def __init__(self, recipe: ModelRecipe):
|
||||||
|
errs = recipe.validate()
|
||||||
|
if errs:
|
||||||
|
raise ModelError("超参包校验失败: " + "; ".join(errs))
|
||||||
|
self.recipe = recipe
|
||||||
|
self.regression: Optional[RidgeRegression] = None
|
||||||
|
self._feature_std: List[float] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fitted(self) -> bool:
|
||||||
|
return self.regression is not None
|
||||||
|
|
||||||
|
def fit(self, X: Sequence[Sequence[float]], y: Sequence[float]) -> "QualityModel":
|
||||||
|
"""X 行=样本,列=特征(顺序与 recipe.feature_names 对齐)。"""
|
||||||
|
self.regression = RidgeRegression(alpha=self.recipe.alpha).fit(X, y)
|
||||||
|
# 记录训练集每列标准差,供 explain() 计算尺度归一化重要性
|
||||||
|
m = len(X)
|
||||||
|
n = len(X[0]) if X else 0
|
||||||
|
if m > 1 and n:
|
||||||
|
means = [sum(X[i][j] for i in range(m)) / m for j in range(n)]
|
||||||
|
self._feature_std = [
|
||||||
|
math.sqrt(sum((X[i][j] - means[j]) ** 2 for i in range(m)) / (m - 1))
|
||||||
|
for j in range(n)]
|
||||||
|
else:
|
||||||
|
self._feature_std = [1.0] * n
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, X: Sequence[Sequence[float]]) -> List[float]:
|
||||||
|
if self.regression is None:
|
||||||
|
raise ModelError("模型未训练,先 fit()")
|
||||||
|
return self.regression.predict(X)
|
||||||
|
|
||||||
|
def evaluate(self, X: Sequence[Sequence[float]],
|
||||||
|
y: Sequence[float]) -> Evaluation:
|
||||||
|
pred = self.predict(X)
|
||||||
|
return Evaluation(
|
||||||
|
r2=r2_score(y, pred), mae=mae_score(y, pred),
|
||||||
|
rmse=rmse_score(y, pred), n_samples=len(y))
|
||||||
|
|
||||||
|
def explain(self) -> List[Dict[str, Any]]:
|
||||||
|
"""特征贡献度(尺度归一化:|权重| × 特征标准差),供可解释性。
|
||||||
|
|
||||||
|
归一化动机:原始权重受特征量纲影响(温度 850℃ vs 配比 30),
|
||||||
|
``|权重|×std`` 才反映特征对预测的实际扰动幅度(与 sklearn
|
||||||
|
permutation importance / 标准化系数同思路),可跨特征横向比较。
|
||||||
|
"""
|
||||||
|
if self.regression is None:
|
||||||
|
raise ModelError("模型未训练")
|
||||||
|
names = self.recipe.feature_names or [
|
||||||
|
f"x{i}" for i in range(len(self.regression.coef_))]
|
||||||
|
weights = list(self.regression.coef_)
|
||||||
|
stds = getattr(self, "_feature_std", None) or [1.0] * len(weights)
|
||||||
|
contribs = [abs(weights[i]) * stds[i] for i in range(len(weights))]
|
||||||
|
total = sum(contribs) or 1.0
|
||||||
|
return [{"feature": names[i], "weight": round(weights[i], 6),
|
||||||
|
"importance": round(contribs[i] / total, 4)}
|
||||||
|
for i in range(len(weights))]
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"recipe": {"target": self.recipe.target, "alpha": self.recipe.alpha,
|
||||||
|
"feature_names": list(self.recipe.feature_names),
|
||||||
|
"unit": self.recipe.unit},
|
||||||
|
"regression": (self.regression.to_dict()
|
||||||
|
if self.regression else None),
|
||||||
|
"feature_std": list(self._feature_std),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: Dict[str, Any]) -> "QualityModel":
|
||||||
|
model = cls(ModelRecipe.from_dict(d.get("recipe", {})))
|
||||||
|
reg = d.get("regression")
|
||||||
|
if reg:
|
||||||
|
model.regression = RidgeRegression.from_dict(reg)
|
||||||
|
model._feature_std = [float(x) for x in d.get("feature_std", [])]
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 训练集数据集(特征矩阵 + 标签)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrainingSet:
|
||||||
|
"""训练集:特征名 + X + y。"""
|
||||||
|
|
||||||
|
feature_names: List[str]
|
||||||
|
X: List[List[float]]
|
||||||
|
y: List[float]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_records(cls, records: Sequence[Dict[str, float]],
|
||||||
|
feature_names: Sequence[str],
|
||||||
|
target: str) -> "TrainingSet":
|
||||||
|
X, y = [], []
|
||||||
|
for r in records:
|
||||||
|
if target not in r or math.isnan(r[target]):
|
||||||
|
continue
|
||||||
|
row = [r.get(fn, NAN) for fn in feature_names]
|
||||||
|
if any(math.isnan(v) for v in row):
|
||||||
|
continue
|
||||||
|
X.append(row)
|
||||||
|
y.append(r[target])
|
||||||
|
return cls(feature_names=list(feature_names), X=X, y=y)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.X)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 超参包加载(零依赖 YAML 子集 / JSON)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def load_recipe(text: str) -> ModelRecipe:
|
||||||
|
text = text.strip()
|
||||||
|
if text.startswith("{"):
|
||||||
|
data = json.loads(text)
|
||||||
|
else:
|
||||||
|
data = _parse_yaml_subset(text)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ModelError("超参包顶层应为映射")
|
||||||
|
return ModelRecipe.from_dict(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_yaml_subset(text: str) -> Any:
|
||||||
|
"""极简 YAML 子集解析(与 features.py 同款实现,避免跨模块依赖)。"""
|
||||||
|
lines: List[str] = []
|
||||||
|
for raw in text.splitlines():
|
||||||
|
stripped = raw.rstrip()
|
||||||
|
if not stripped.strip() or stripped.lstrip().startswith("#"):
|
||||||
|
continue
|
||||||
|
hi = _find_inline_comment(stripped)
|
||||||
|
if hi is not None:
|
||||||
|
stripped = stripped[:hi].rstrip()
|
||||||
|
if stripped:
|
||||||
|
lines.append(stripped)
|
||||||
|
parser = _YamlParser(lines)
|
||||||
|
return parser.parse_block(0)[0] if lines else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _find_inline_comment(line: str) -> Optional[int]:
|
||||||
|
depth = 0
|
||||||
|
in_str = False
|
||||||
|
for i, ch in enumerate(line):
|
||||||
|
if ch == '"':
|
||||||
|
in_str = not in_str
|
||||||
|
elif not in_str:
|
||||||
|
if ch in "[{":
|
||||||
|
depth += 1
|
||||||
|
elif ch in "]}":
|
||||||
|
depth = max(0, depth - 1)
|
||||||
|
elif ch == "#" and depth == 0:
|
||||||
|
if i == 0 or line[i - 1] in (" ", "\t"):
|
||||||
|
return i
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_scalar(raw: str) -> Any:
|
||||||
|
raw = raw.strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
if raw.startswith('"') and raw.endswith('"'):
|
||||||
|
return raw[1:-1]
|
||||||
|
if raw.startswith("[") or raw.startswith("{"):
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return raw
|
||||||
|
low = raw.lower()
|
||||||
|
if low == "true":
|
||||||
|
return True
|
||||||
|
if low == "false":
|
||||||
|
return False
|
||||||
|
if low in ("null", "~", "none"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
class _YamlParser:
|
||||||
|
def __init__(self, lines: List[str]) -> None:
|
||||||
|
self.lines = lines
|
||||||
|
self.i = 0
|
||||||
|
|
||||||
|
def _indent(self, line: str) -> int:
|
||||||
|
return len(line) - len(line.lstrip(" "))
|
||||||
|
|
||||||
|
def parse_block(self, indent: int) -> Tuple[Any, bool]:
|
||||||
|
if self.i >= len(self.lines):
|
||||||
|
return {}, False
|
||||||
|
line = self.lines[self.i]
|
||||||
|
cur = self._indent(line)
|
||||||
|
if cur < indent:
|
||||||
|
return {}, False
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("- ") or stripped == "-":
|
||||||
|
return self._parse_list(cur), True
|
||||||
|
return self._parse_mapping(cur), False
|
||||||
|
|
||||||
|
def _parse_mapping(self, indent: int) -> Dict[str, Any]:
|
||||||
|
result: Dict[str, Any] = {}
|
||||||
|
effective = indent
|
||||||
|
if self.i < len(self.lines):
|
||||||
|
first = self._indent(self.lines[self.i])
|
||||||
|
if first > indent:
|
||||||
|
effective = first
|
||||||
|
while self.i < len(self.lines):
|
||||||
|
line = self.lines[self.i]
|
||||||
|
cur = self._indent(line)
|
||||||
|
if cur < effective:
|
||||||
|
break
|
||||||
|
if cur > effective:
|
||||||
|
self.i += 1
|
||||||
|
continue
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("- "):
|
||||||
|
break
|
||||||
|
key, sep, rest = stripped.partition(":")
|
||||||
|
if not sep:
|
||||||
|
self.i += 1
|
||||||
|
continue
|
||||||
|
key = key.strip()
|
||||||
|
rest = rest.strip()
|
||||||
|
self.i += 1
|
||||||
|
if rest:
|
||||||
|
result[key] = _parse_scalar(rest)
|
||||||
|
else:
|
||||||
|
if self.i < len(self.lines):
|
||||||
|
nxt = self._indent(self.lines[self.i])
|
||||||
|
if nxt > effective:
|
||||||
|
val, _ = self.parse_block(nxt)
|
||||||
|
result[key] = val
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _parse_list(self, indent: int) -> List[Any]:
|
||||||
|
items: List[Any] = []
|
||||||
|
while self.i < len(self.lines):
|
||||||
|
line = self.lines[self.i]
|
||||||
|
cur = self._indent(line)
|
||||||
|
if cur < indent:
|
||||||
|
break
|
||||||
|
if cur > indent:
|
||||||
|
self.i += 1
|
||||||
|
continue
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped.startswith("-"):
|
||||||
|
break
|
||||||
|
item_text = stripped[1:].strip()
|
||||||
|
if not item_text:
|
||||||
|
self.i += 1
|
||||||
|
items.append(None)
|
||||||
|
continue
|
||||||
|
if ":" in item_text and not item_text.startswith('"'):
|
||||||
|
k, sep, v = item_text.partition(":")
|
||||||
|
if sep:
|
||||||
|
item: Dict[str, Any] = {k.strip(): _parse_scalar(v.strip())}
|
||||||
|
self.i += 1
|
||||||
|
if self.i < len(self.lines):
|
||||||
|
child_indent = self._indent(self.lines[self.i])
|
||||||
|
if child_indent > cur:
|
||||||
|
sub, _ = self.parse_block(child_indent)
|
||||||
|
if isinstance(sub, dict):
|
||||||
|
item.update(sub)
|
||||||
|
items.append(item)
|
||||||
|
continue
|
||||||
|
items.append(_parse_scalar(item_text))
|
||||||
|
self.i += 1
|
||||||
|
return items
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user