feat(models): 落地 compare_models(指标名别名映射)+ 同步你的三条入口口径
你的三条改动(决策 61,已复测入库): - verify_yt_yp 拦 Inf:rmse([1.0, inf], ...) 从安静返回 inf 变成 ValueError。 - 至少 2 个样本:rmse(1.0, 1.0) 不再被当成 1 个样本算出 0.0。 - mape 从"只拦 0"改成拦 y_true <= 0:原来对负数返回 -1.0(无意义)。 Codex 侧把这三条写进 7 处 Raises(易错点 30 复发 1 次), test_mape_rejects_zero_in_y_true 的 match 文案改为"非正数",另补 Inf / 样本数两条测试。 compare_models(决策 62,A 档,Codex 实现): - 别名映射 _METRIC_FUNCS:r2 与 r2_score 指向同一个函数,传哪个列名就叫哪个, 两个一起传报 ValueError;adjusted_r2 不进表(需要 n_features)。 - cv=0 全量拟合后打分;cv>=2 用手写 K 折(get_rng(random_state).permutation + np.array_split),不引入 sklearn 依赖;报折间均值;每折至少 2 个样本 => cv <= n // 2。 - 排序方向跟第一个指标走(误差类升序、R² 降序);models 必须传模型对象(fit/predict), X 必须二维,metrics 必须是 tuple/list。 - 原 test_compare_models_... 的用法是错的(传拟合结果当模型),改成测试桩后摘 skip。 tests/test_models.py:23 -> 31 条(排序两条、别名、坏指标名 / 坏 models / 坏 cv、 每折下限、random_state 可复现)。 三连:pytest 233 passed / 25 skipped(258 收集)、ruff 0、pyright 0。 文档:STATUS 决策 61 / 62 + 第十轮(第九轮那 4 条标办结);CHANGELOG 本轮条目; personal-pitfalls 易错点 30 记复发 1 次 + 新增 33(多行文案少引号 / ValueError 多参数)。
This commit is contained in:
1 parent
9907289ec8
commit
748affb7f0
5 files changed
+414
-50
No files matched your search
+147
-12
@@ -46,20 +46,39 @@ def test_r2_is_one_for_perfect_fit():
|
||||
assert np.isclose(r2_score(y, y), 1.0)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
||||
class _MeanModel:
|
||||
"""最笨的模型:永远预测训练集的均值(`compare_models` 的对照组)。"""
|
||||
|
||||
def fit(self, X, y):
|
||||
self.mu_ = float(np.mean(y))
|
||||
return self
|
||||
|
||||
def predict(self, X):
|
||||
return np.full(X.shape[0], self.mu_)
|
||||
|
||||
|
||||
class _LinearModel:
|
||||
"""一列特征的普通最小二乘(`compare_models` 的"好模型")。"""
|
||||
|
||||
def fit(self, X, y):
|
||||
self.coef_ = np.polyfit(X[:, 0], y, 1)
|
||||
return self
|
||||
|
||||
def predict(self, X):
|
||||
return np.polyval(self.coef_, X[:, 0])
|
||||
|
||||
|
||||
def test_compare_models_returns_table_sorted_by_first_metric(linear_data):
|
||||
from mcm_toolkit.models import compare_models, fit_linear, fit_polynomial
|
||||
from mcm_toolkit.models import compare_models
|
||||
|
||||
x, y = linear_data
|
||||
X = x.reshape(-1, 1)
|
||||
tbl = compare_models(
|
||||
{"linear": fit_linear(X, y), "poly2": fit_polynomial(X, y, degree=2)},
|
||||
X,
|
||||
y,
|
||||
cv=0,
|
||||
{"mean": _MeanModel(), "linear": _LinearModel()}, x.reshape(-1, 1), y, cv=0
|
||||
)
|
||||
assert set(tbl.index) >= {"linear", "poly2"}
|
||||
assert "rmse" in tbl.columns
|
||||
|
||||
assert list(tbl.columns) == ["rmse", "mae", "r2"]
|
||||
assert list(tbl.index) == ["linear", "mean"] # 首列 rmse 升序 → 好的模型在前
|
||||
assert tbl.loc["linear", "rmse"] < tbl.loc["mean", "rmse"]
|
||||
|
||||
|
||||
def test_metrics_reject_mismatched_shapes():
|
||||
@@ -109,12 +128,34 @@ def test_metrics_agree_across_input_types(name):
|
||||
assert np.isclose(fn(pd.DataFrame({"y": yt}), pd.DataFrame({"y": yp})), base)
|
||||
|
||||
|
||||
def test_mape_rejects_zero_in_y_true():
|
||||
"""`y_true` 含 0 时 MAPE 无定义——拦住并点名改用 smape,而不是返回 inf。"""
|
||||
def test_mape_rejects_non_positive_y_true():
|
||||
"""`y_true` 非正时 MAPE 无定义 —— 拦住并点名改用 smape,而不是返回负数或 inf。"""
|
||||
from mcm_toolkit.models import mape
|
||||
|
||||
with pytest.raises(ValueError, match="不能含 0"):
|
||||
with pytest.raises(ValueError, match="非正数"):
|
||||
mape([0.0, 2.0], [1.0, 2.0])
|
||||
with pytest.raises(ValueError, match="非正数"):
|
||||
mape([-1.0, 2.0], [1.0, 2.0])
|
||||
|
||||
|
||||
def test_metrics_reject_infinite_input():
|
||||
"""`Inf` 与 `NaN` 一样直接拦:算出来是 `inf` 的指标没有任何意义。"""
|
||||
from mcm_toolkit.models import rmse
|
||||
|
||||
with pytest.raises(ValueError, match="Inf"):
|
||||
rmse([1.0, np.inf], [1.0, 2.0])
|
||||
with pytest.raises(ValueError, match="Inf"):
|
||||
rmse([1.0, 2.0], [1.0, -np.inf])
|
||||
|
||||
|
||||
def test_metrics_require_at_least_two_samples():
|
||||
"""1 个点的误差指标没有统计意义;标量也会被这条拦住(不是被当成 1 个样本)。"""
|
||||
from mcm_toolkit.models import rmse
|
||||
|
||||
with pytest.raises(ValueError, match="至少需要包含 2 个样本"):
|
||||
rmse([1.0], [1.0])
|
||||
with pytest.raises(ValueError, match="至少需要包含 2 个样本"):
|
||||
rmse(1.0, 1.0)
|
||||
|
||||
|
||||
def test_smape_is_silent_when_denominator_is_zero():
|
||||
@@ -210,3 +251,97 @@ def test_adjusted_r2_matches_analytic_formula():
|
||||
r2 = r2_score(yt, yp)
|
||||
|
||||
assert np.isclose(adjusted_r2(yt, yp, k), 1 - (1 - r2) * (n - 1) / (n - k - 1))
|
||||
|
||||
|
||||
def test_compare_models_treats_r2_names_as_aliases(linear_data):
|
||||
"""`"r2"` 与 `"r2_score"` 是同一个指标的别名:传哪个、列名就叫哪个,数值相同。"""
|
||||
from mcm_toolkit.models import compare_models
|
||||
|
||||
x, y = linear_data
|
||||
X = x.reshape(-1, 1)
|
||||
short = compare_models({"m": _LinearModel()}, X, y, metrics=("rmse", "r2"), cv=0)
|
||||
full = compare_models({"m": _LinearModel()}, X, y, metrics=("rmse", "r2_score"), cv=0)
|
||||
|
||||
assert list(short.columns) == ["rmse", "r2"]
|
||||
assert list(full.columns) == ["rmse", "r2_score"]
|
||||
assert np.isclose(short.loc["m", "r2"], full.loc["m", "r2_score"])
|
||||
|
||||
|
||||
def test_compare_models_sorts_descending_when_first_metric_is_r2(linear_data):
|
||||
"""误差类是**越小越好**、R² 是**越大越好**,排序方向必须跟着首列指标走。"""
|
||||
from mcm_toolkit.models import compare_models
|
||||
|
||||
x, y = linear_data
|
||||
tbl = compare_models(
|
||||
{"mean": _MeanModel(), "linear": _LinearModel()},
|
||||
x.reshape(-1, 1),
|
||||
y,
|
||||
metrics=("r2",),
|
||||
cv=0,
|
||||
)
|
||||
|
||||
assert list(tbl.index) == ["linear", "mean"] # r2 降序 → 好的模型仍在前
|
||||
|
||||
|
||||
def test_compare_models_rejects_bad_metric_names(linear_data):
|
||||
from mcm_toolkit.models import compare_models
|
||||
|
||||
x, y = linear_data
|
||||
X = x.reshape(-1, 1)
|
||||
|
||||
with pytest.raises(ValueError, match="不认识的指标名"):
|
||||
compare_models({"m": _MeanModel()}, X, y, metrics=("mse",), cv=0)
|
||||
with pytest.raises(ValueError, match="别名"):
|
||||
compare_models({"m": _MeanModel()}, X, y, metrics=("r2", "r2_score"), cv=0)
|
||||
with pytest.raises(TypeError, match="不要传单个字符串"):
|
||||
compare_models({"m": _MeanModel()}, X, y, metrics="rmse", cv=0) # type: ignore[arg-type]
|
||||
with pytest.raises(TypeError, match="tuple / list"):
|
||||
compare_models({"m": _MeanModel()}, X, y, metrics=None, cv=0) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_compare_models_rejects_bad_models_and_cv(linear_data):
|
||||
from mcm_toolkit.models import compare_models
|
||||
|
||||
x, y = linear_data
|
||||
X = x.reshape(-1, 1)
|
||||
|
||||
with pytest.raises(TypeError, match="字典"):
|
||||
compare_models([_MeanModel()], X, y) # type: ignore[arg-type]
|
||||
with pytest.raises(ValueError, match="不能为空"):
|
||||
compare_models({}, X, y)
|
||||
with pytest.raises(TypeError, match="Model 协议"):
|
||||
compare_models({"bad": object()}, X, y, cv=0)
|
||||
with pytest.raises(ValueError, match="cv=1"):
|
||||
compare_models({"m": _MeanModel()}, X, y, cv=1)
|
||||
with pytest.raises(ValueError, match="太大"):
|
||||
compare_models({"m": _MeanModel()}, X, y, cv=1000)
|
||||
with pytest.raises(ValueError, match="样本数必须一致"):
|
||||
compare_models({"m": _MeanModel()}, X, y[:-1], cv=0)
|
||||
|
||||
|
||||
def test_compare_models_needs_two_samples_per_fold():
|
||||
"""每折至少 2 个样本(指标自己的入口要求),所以 cv 最多 n // 2。"""
|
||||
from mcm_toolkit.models import compare_models
|
||||
|
||||
X = np.arange(4, dtype=float).reshape(-1, 1)
|
||||
y = np.arange(4, dtype=float)
|
||||
|
||||
assert compare_models({"m": _MeanModel()}, X, y, cv=2, random_state=0).shape == (1, 3)
|
||||
with pytest.raises(ValueError, match="太大"):
|
||||
compare_models({"m": _MeanModel()}, X, y, cv=3, random_state=0)
|
||||
with pytest.raises(ValueError, match="样本太少"):
|
||||
compare_models({"m": _MeanModel()}, X[:1], y[:1], cv=0)
|
||||
|
||||
|
||||
def test_compare_models_is_reproducible_with_random_state(linear_data):
|
||||
"""分折用了随机数,所以必须服从 `random_state`(架构 §4.2)。"""
|
||||
from mcm_toolkit.models import compare_models
|
||||
|
||||
x, y = linear_data
|
||||
X = x.reshape(-1, 1)
|
||||
models = {"mean": _MeanModel(), "linear": _LinearModel()}
|
||||
|
||||
first = compare_models(models, X, y, cv=5, random_state=42)
|
||||
second = compare_models(models, X, y, cv=5, random_state=42)
|
||||
|
||||
assert first.equals(second)
|
||||
Reference in new issue
Block a user