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:
guzhujushi committed 2026-10-10 17:07:46 +08:00
1 parent 3bb31f95de
commit 9907289ec8
5 files changed
+463 -26

No files matched your search

+57 -5
View File
@@ -1,6 +1,6 @@
# STATUS — 当前进度
> 更新日期:2026-10-05
> 更新日期:2026-10-10
## 本次任务
@@ -24,6 +24,8 @@
`monte_carlo_probability`、`monte_carlo_integral`、`random_walk`、`markov_simulate`、
`markov_steady_state`、`mm1_metrics`(**均为用户写**)—— `solve` 层的 L1 清单**已全部落地**。
剩余 1 项:`save_figure`(Codex 写)。
**`models/metrics.py` 已开工**:6 个指标(`rmse` / `mae` / `mape` / `smape` / `r2_score` / `adjusted_r2`)
+ 共享入口 `verify_yt_yp` **由用户写**、Codex 审(第九轮);同文件只剩 `compare_models` 未实现。
`monte_carlo` 的口径已拍板(决策 48);`monte_carlo_probability` / `random_walk` 的待办**均已办结**;
`markov_simulate`(决策 56)/ `markov_steady_state`(决策 57)/ `mm1_metrics`(决策 58)
的入口口径**已办掉大半**,各剩 1~2 条细枝末节(详见"下一步"第八轮)。
@@ -393,7 +395,7 @@
| 检查 | venv | conda `mcm-toolkit` |
|---|---|---|
| `pytest` | 见下(单列) | **204 passed, 28 skipped(0 xfail)** |
| `pytest` | 见下(单列) | **224 passed, 26 skipped(0 xfail)** |
| `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 |
| `ruff check` | All checks passed | **All checks passed**(0 error) |
| `scripts/doctor.ps1` | 跑通 | — |
@@ -403,9 +405,9 @@
(修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与
`docs/04-environment.md` 坑 7)
- base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致)
- **232 条用例:204 passed / 28 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、
`test_core.py` 23、`test_solve.py` 64(本轮再 +2)、`test_viz.py` 8、`test_decision.py` 7、
`test_stats.py` 53、`test_models.py` 5
- **250 条用例:224 passed / 26 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、
`test_core.py` 23、`test_solve.py` 64、`test_viz.py` 8、`test_decision.py` 7、
`test_stats.py` 53、`test_models.py` 23(第九轮 5 → 23,指标测试转正 + 18 条新增)
- **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正
- 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环
- `examples/01_clean_and_describe.py` 实测跑通(exit 0);`00_quickstart.py` 仍等 `stats` / `viz`
@@ -609,8 +611,58 @@ docstring 与既有测试写的都是 `λ >= μ` 不稳定,代码却写 `servi
**2026-10-10 更新**:① ② ④ 办结(`λ = 0` 放开为合法空系统、两个速率收 `np.number`、失败分支报 `rho`);
③ 只办了一半(`data=metrics.copy()` 去掉了同一个 dict 对象的别名,但 `data` 仍是 dict、失败时仍是 `None`)—— 只剩这一条。
| 59 | `models/metrics.py` 开工:6 个指标 + 共享入口 `verify_yt_yp` **由用户写**,Codex 只做工程侧审查(决策 47 的延续) |
本轮把 `metrics.py` 的 L1 `TODO` 转成实现(`compare_models` 仍 `NotImplementedError`)。六个指标共用一个入口
`verify_yt_yp`:`as_array(ndim=1)` → 形状不一致 / 长度 0 / 含 `NaN` 一律 `ValueError`(与决策 48 的"NaN 判失败"同口径)。
Codex 侧(决策 47 授权范围):补 7 处 docstring(`Parameters` / `Returns` / `Raises`)、统一报错文案为"参数名 + 实际值"、
清 6 条 `F541` + 3 条 `W293`、补空行;`tests/test_models.py` 两条指标测试转正 + 新增 18 条。
**用户侧拍板并亲手改**:`r2_score` 的 `ss_tol` 基准从 `np.mean(yp)` 改成 `np.mean(yt)`(真 bug,见决策 60 与易错点 31),
顺带把无意义的 `ss_tol` 改名 `ss_tot`。Codex 探针实测:修前 0.8803827751196172、修后 0.875 = `sklearn.metrics.r2_score` 一致。
**另有 4 条口径仍待拍板**:`mape` 遇负 `y_true`(现在返回无意义的负数)、只拦 `NaN` 不拦 `inf`、
`rmse(1.0, 1.0)` 标量被当 1 个样本、`compare_models` 的键名 `"r2"` 与函数名 `r2_score` 对不上。
| 60 | `smape` 的零除告警按**决策 31** 由 Codex 直接修(`np.where` → `np.divide(..., where=)`),数值结果不变 |
原式 `np.where(denom == 0, 0.0, 2 * |yt - yp| / denom)` 的**结果是对的**(分母 0 记 0),但 `np.where` 两条分支都会被求值,
分母为 0 时仍触发 `RuntimeWarning: invalid value encountered in divide`;而 `pyproject.toml` 开了
`filterwarnings = ["error::RuntimeWarning"]` —— 于是任何"含 0 的 smape"测试都会直接红(探针复现)。
改成 `np.divide(..., out=np.zeros_like(denom), where=denom != 0.0)` 后告警消失、数值与口径完全不变,
并补了一条测试把"不许告警"钉住(`test_smape_is_silent_when_denominator_is_zero`)。
**判据**:只是防告警、不改语义 → 工程侧;如果是"改公式"(如决策 59 的 r2),一律留给用户(决策 49)。
## 下一步
### ⭐ 当前进度与待办(2026-10-10 第九轮)
**本轮:`models` 开工,你亲手写了 `models/metrics.py`** —— 6 个回归指标(`rmse` / `mae` / `mape` / `smape` /
`r2_score` / `adjusted_r2`)加一个共享入口 `verify_yt_yp`;`compare_models` 仍是 `NotImplementedError`。
Codex 只碰工程侧:7 处 docstring(补 `Parameters` / `Returns` / `Raises`,把口径写成契约)、报错文案统一成
"参数名 + 实际值"、清 6 条 `F541` + 3 条 `W293` + 空行、`tests/test_models.py` 5 条 → 23 条(两条转正 + 18 条新增)。
**Codex 找到 1 个真 bug(已由你亲手改定)**:`r2_score` 的 `ss_tot` 用了 `np.mean(yp)`,基准必须是 `np.mean(yt)`。
探针实测(`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` 完全一致。它会连带污染 `adjusted_r2`。**注意 `r2_score(y, y) == 1` 仍成立** ——
完美拟合与常量 y 都会掩盖这类错,所以新增的测试特意用"非完美拟合"的数据(见个人易错点 31)。
**另修一处会真炸的工程问题(决策 60)**:`smape` 的 `np.where` 掩码防不住零除,`filterwarnings=error::RuntimeWarning`
下任何含 0 的测试都会红 → 改 `np.divide(..., where=)`,数值结果不变,并补测试钉住"不许告警"。
三连:`pytest` **224 passed / 26 skipped(250 收集)**、`ruff` **0**、`pyright` **0**。
**接下来(按 roadmap §四,`models` 的完成标志就是 `compare_models`)**
1. `compare_models`(同文件最后一块;`cv=0` 走"全量拟合后打分"分支,返回按第一列指标排序的 `DataFrame`,
依赖 `pd.DataFrame`,顺带定 `metrics` 的键名口径)。
2. `regression.py` 的 `fit_linear` / `fit_polynomial`(把 `models/base.py` 的 `Model` 协议三件套跑通)。
3. 然后转 `decision/entropy_weights`(**S 档,你写**,三个坑:方向 / 量纲 / 零值)。
### ⏳ 第九轮新增:4 条口径待你拍板(Codex 只报不改,决策 49)
1. **`mape` 遇负 `y_true` 不拦**:`mean(|yt - yp| / yt)` 在 `yt < 0` 时给出**负数**(实测 `mape([-1, 2], [1, 2])` → `-1.0`)。
MAPE 的定义域是 `y_true > 0`;两个方向:拦 `y_true <= 0`,或把分母改成 `|y_true|`(标准写法 `|(y-ŷ)/y|`)。
2. **`inf` 不拦**:`verify_yt_yp` 只查 `np.isnan`,实测 `rmse([1.0, inf], [1.0, 2.0])` → `inf`。要不要跟 `NaN` 一样直接报错?
3. **标量能进来**:`as_array` 的白名单含 `int` / `float`,`rmse(1.0, 1.0)` → `0.0`(被当成 1 个样本)。接受还是要求 ≥ 2 个点?
(与 `as_array` 那条"标量是否摘掉"的旧口径是同一个问题,见下面"其余待办"第 6 条。)
4. **`compare_models` 的键名**:签名里写 `metrics=("rmse", "mae", "r2")`,但函数名是 `r2_score`。
选 `"r2"`(列名短)还是 `"r2_score"`(与函数名一致)?也可以两个都收(别名映射,最省事)。
### ⭐ 当前进度与待办(2026-10-10 第八轮)
**本轮:你按第七轮的清单改了 5 处口径** —— `markov_simulate`(非负校验带容差、收 `np.integer`、赋值挪进 `try`、