feat(models): 审查 metrics.py:工程侧收口 + 修 r2/smape 两个真 bug(+18 条测试)
models/metrics.py(用户写、Codex 审):6 个回归指标 + 共享入口 verify_yt_yp 落地, compare_models 仍是 TODO(L1)。 真 bug(决策 59,由用户亲手改定): - r2_score 的 SS_tot 基准用了 np.mean(y_pred),必须是 np.mean(y_true)。 探针实测:修前 0.8803827751196172、修后 0.875 = sklearn.metrics.r2_score 一致; adjusted_r2 复用它,一并修正。完美拟合(r2_score(y, y) == 1)与常量 y 都掩盖了这个错, 所以新测试特意用"非完美拟合"的数据。ss_tol 顺带改名 ss_tot。 - smape 的 np.where 掩码防不住零除(两条分支都会被求值),在 filterwarnings = ["error::RuntimeWarning"] 下任何含 0 的用例直接红 → 改 np.divide(where=), 数值结果不变(决策 60)。 工程侧(决策 47):报错文案统一为"参数名 + 实际值";补 7 处 docstring (Parameters / Returns / Raises,把 NaN 报错、mape 遇 0、r2 常量 y、adjusted_r2 分母保护 写成契约;n_features 注明不含截距);ruff 9 条清零(6 F541 + 3 W293)。 tests/test_models.py:5 条 → 23 条(2 条摘 skip 转正 + 18 条新增)。 三连:pytest 224 passed / 26 skipped(250 收集)、ruff 0、pyright 0。 文档:STATUS 决策 59 / 60 + 第九轮(含 4 条待拍板口径);CHANGELOG 本轮条目; docs/dev/personal-pitfalls.md 追加易错点 31(基准量抄错对象)与 32(np.where 掩码防不住零除)。
This commit is contained in:
1 parent
3bb31f95de
commit
9907289ec8
5 files changed
+463
-26
No files matched your search
@@ -518,3 +518,29 @@
|
||||
把"要一起改的地方"列出来再动手;② 改完立刻跑 `pytest` —— 红的测试就是"过期契约"的清单;
|
||||
③ docstring 的 `Returns` / `Raises` 属于**契约的一部分**,和代码同一次改完,别留到"下次顺手"。
|
||||
- 首次:2026-10-10|复发:0
|
||||
|
||||
### 31. 公式里的"基准量"抄错了对象,而且完美拟合会把错掩盖掉(R² 的 SS_tot)
|
||||
|
||||
- 现象:`models/metrics.py` 写 `ss_tol = np.sum((yt - np.mean(yp)) ** 2)` —— 分母的基准该是 `y_true` 的均值,
|
||||
写成 `y_pred` 的均值了。实测(`y_true=[1,2,3,4,5]`、`y_pred=[1.5,2.5,2.5,4.5,5.5]`):错版 **0.8803827751196172**,
|
||||
正确版 **0.875** = `sklearn.metrics.r2_score`。**而 `r2_score(y, y)` 照样返回 1**,常量 y 那条照样报错 ——
|
||||
两种最常见的测试输入都盖住了这个错。
|
||||
- 为什么会错:R² 的分子用 `y_pred`、分母用 `y_true` 的均值,两个变量名只差一个字母(`yt` / `yp`),
|
||||
是从同一个表达式复制过来的。这种错"看起来像对、跑起来不报错、只有对着数据才露馅"。
|
||||
- 以后怎么防:① 写完公式**逐个念一遍每个量的"是谁的"**(这个均值是 `y_true` 的还是 `y_pred` 的?);
|
||||
② 指标类函数至少测一条**非完美拟合**的数据(完美拟合 / 常量 / 全 0 都是"天然掩盖 bug"的输入);
|
||||
③ 拿成熟库当**独立参照物**(`sklearn.metrics.r2_score`、`scipy.stats`),别自己算自己;
|
||||
④ 变量名别用 `ss_tol`(无意义),写 `ss_tot`(total sum of squares)—— 名字说得清,错法就少一半。
|
||||
- 首次:2026-10-10|复发:0
|
||||
|
||||
### 32. `np.where` 的掩码防不住零除(两条分支都会被求值)
|
||||
|
||||
- 现象:`smape` 写 `np.where(denom == 0, 0.0, 2 * |yt - yp| / denom)`。分母为 0 时**数值结果是对的**(记 0),
|
||||
但 `RuntimeWarning: invalid value encountered in divide` 仍会漏出来;本项目 `pyproject.toml` 开了
|
||||
`filterwarnings = ["error::RuntimeWarning"]`,于是任何"含 0 的 smape"测试**直接判失败**。
|
||||
- 为什么会错:`np.where` 是"**先把两条分支都算好,再按掩码挑**",不是 `if / else` 的短路求值。
|
||||
"结果对" 和 "没有副作用" 是两件事,告警就是那个副作用。
|
||||
- 以后怎么防:① 除法一律写 `np.divide(a, b, out=..., where=掩码)`(或临时 `with np.errstate(invalid="ignore")`),
|
||||
不要拿 `np.where` 当挡箭牌;② 写完拿最脏的输入跑一遍(全 0、单个点、常量列),**盯着 warning 看**;
|
||||
③ 项目开了 warning-as-error 时,"不告警"就是契约的一部分,要有一条测试专门钉住它。
|
||||
- 首次:2026-10-10|复发:0
|
||||
Reference in new issue
Block a user