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:
guzhujushi committed 2026-10-12 16:53:01 +08:00
1 parent 9907289ec8
commit 748affb7f0
5 files changed
+412 -48

No files matched your search

+31
View File
@@ -644,6 +644,37 @@
`rmse(1.0, 1.0)` 这类标量被当成 1 个样本、`compare_models` 的键名 `"r2"` 与函数名 `r2_score` 不一致。 `rmse(1.0, 1.0)` 这类标量被当成 1 个样本、`compare_models` 的键名 `"r2"` 与函数名 `r2_score` 不一致。
详见 `STATUS.md`「第九轮新增:4 条口径待你拍板」。 详见 `STATUS.md`「第九轮新增:4 条口径待你拍板」。
### Added
- `models.compare_models`:落地(**Codex 按 A 档实现**,决策 62)。返回 `pd.DataFrame`(行 = 模型、列 = 指标),
按第一个指标排序(误差类升序、R² 降序)。`cv=0` 全量拟合后打分;`cv >= 2` 用**手写 K 折**
(`get_rng(random_state).permutation` + `np.array_split`,**不引入 sklearn 依赖**)报折间均值。
- `models/metrics.py` 的**指标名别名映射** `_METRIC_FUNCS`:`"r2"` 与 `"r2_score"` 指向同一个函数,
传哪个名字列名就叫哪个;两个一起传报 `ValueError`。`adjusted_r2` 不进表(需要 `n_features`)。
- `tests/test_models.py`:23 → **31 条**。首列排序(升序 / 降序)、别名映射、坏指标名 / 坏 `models` / 坏 `cv`、
每折样本数下限、`random_state` 可复现;另新增 `Inf` 与"至少 2 个样本"两条入口测试。
原 `test_compare_models_returns_table_sorted_by_first_metric` 的**用法是错的**(把拟合结果当模型传),
已改写成用测试桩 `_MeanModel` / `_LinearModel` 并摘掉 `skip`。
### Changed
- `models/metrics.py`:用户的第三轮入口改动入库(决策 61)—— `verify_yt_yp` 增加 `Inf` 拦截、
增加"至少 2 个样本"(标量不再被当成 1 个样本);`mape` 由"只拦 0"改成拦 `y_true <= 0`
(原式对负数返回**负数**,实测 `-1.0`)。
- `models/metrics.py`:三条新口径同步进 7 处 `Raises`,`mape` 的 `Returns` 与摘要改为"`y_true` 必须全为正数"
(**易错点 30 复发 1 次**:口径一改、docstring 立刻过期)。
- `models/metrics.py`:`compare_models` 的入口收口 —— `X` 必须二维(`as_array(ndim=2)` 不替你猜)、
`cv` 白名单(`bool` 拒收 / `cv=1` 无意义 / `cv <= n // 2`)、`models` 必须是"有 `fit` / `predict` 的对象"、
`metrics` 必须是 `tuple` / `list`(单个字符串给专门的报错文案)。
- `models/metrics.py`:模块 docstring 改为"指标函数只返回 `float`,唯一例外是 `compare_models`(返回 `DataFrame`)"。
- `STATUS.md`:新增**决策 61 / 62**,第九轮那 4 条口径标记办结,「下一步」推进到第十轮;
验证计数更新为 **233 passed / 25 skipped(258 收集)**。
- `docs/dev/personal-pitfalls.md`:易错点 30 记为**复发 1 次**;新增易错点 33
(多行报错文案少引号 / `raise ValueError("a", "b")` 多参数 —— ruff 一秒能抓,别等运行时)。
### Notes
- **1 条小口径待用户拍板**:`verify_yt_yp` 里 `yt.ndim == 0` 是死代码(`as_array(ndim=1)` 必然摊平),
可塌成 `if yt.shape[0] <= 1:`。详见 `STATUS.md`「第十轮新出现的 1 条小口径」。
- **等 `fit_linear` 落地时补一条集成测试**:`compare_models` + 真实模型对象。
## [0.0.1] - 2026-10-01 ## [0.0.1] - 2026-10-01
### Added ### Added
+63 -14
View File
@@ -1,6 +1,6 @@
# STATUS — 当前进度 # STATUS — 当前进度
> 更新日期:2026-10-10 > 更新日期:2026-10-12
## 本次任务 ## 本次任务
@@ -24,8 +24,9 @@
`monte_carlo_probability`、`monte_carlo_integral`、`random_walk`、`markov_simulate`、 `monte_carlo_probability`、`monte_carlo_integral`、`random_walk`、`markov_simulate`、
`markov_steady_state`、`mm1_metrics`(**均为用户写**)—— `solve` 层的 L1 清单**已全部落地**。 `markov_steady_state`、`mm1_metrics`(**均为用户写**)—— `solve` 层的 L1 清单**已全部落地**。
剩余 1 项:`save_figure`(Codex 写)。 剩余 1 项:`save_figure`(Codex 写)。
**`models/metrics.py` 已开工**:6 个指标(`rmse` / `mae` / `mape` / `smape` / `r2_score` / `adjusted_r2`) **`models/metrics.py` 已完成(第九~十轮)**:6 个指标(`rmse` / `mae` / `mape` / `smape` / `r2_score` /
+ 共享入口 `verify_yt_yp` **由用户写**、Codex 审(第九轮);同文件只剩 `compare_models` 未实现。 `adjusted_r2`)+ 共享入口 `verify_yt_yp` **由用户写**、Codex 审;`compare_models` 由 Codex 按 A 档落地
(含指标名**别名映射**)。`models` 下一步是 `regression.py`。
`monte_carlo` 的口径已拍板(决策 48);`monte_carlo_probability` / `random_walk` 的待办**均已办结**; `monte_carlo` 的口径已拍板(决策 48);`monte_carlo_probability` / `random_walk` 的待办**均已办结**;
`markov_simulate`(决策 56)/ `markov_steady_state`(决策 57)/ `mm1_metrics`(决策 58) `markov_simulate`(决策 56)/ `markov_steady_state`(决策 57)/ `mm1_metrics`(决策 58)
的入口口径**已办掉大半**,各剩 1~2 条细枝末节(详见"下一步"第八轮)。 的入口口径**已办掉大半**,各剩 1~2 条细枝末节(详见"下一步"第八轮)。
@@ -395,7 +396,7 @@
| 检查 | venv | conda `mcm-toolkit` | | 检查 | venv | conda `mcm-toolkit` |
|---|---|---| |---|---|---|
| `pytest` | 见下(单列) | **224 passed, 26 skipped(0 xfail)** | | `pytest` | 见下(单列) | **233 passed, 25 skipped(0 xfail)** |
| `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 | | `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 |
| `ruff check` | All checks passed | **All checks passed**(0 error) | | `ruff check` | All checks passed | **All checks passed**(0 error) |
| `scripts/doctor.ps1` | 跑通 | — | | `scripts/doctor.ps1` | 跑通 | — |
@@ -405,9 +406,9 @@
(修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与 (修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与
`docs/04-environment.md` 坑 7) `docs/04-environment.md` 坑 7)
- base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致) - base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致)
- **250 条用例:224 passed / 26 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、 - **258 条用例:233 passed / 25 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_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 条新增) `test_stats.py` 53、`test_models.py` 31(第九轮 5 → 23、第十轮 23 → 31)
- **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正 - **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正
- 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环 - 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环
- `examples/01_clean_and_describe.py` 实测跑通(exit 0);`00_quickstart.py` 仍等 `stats` / `viz` - `examples/01_clean_and_describe.py` 实测跑通(exit 0);`00_quickstart.py` 仍等 `stats` / `viz`
@@ -628,8 +629,57 @@ Codex 侧(决策 47 授权范围):补 7 处 docstring(`Parameters` / `Re
改成 `np.divide(..., out=np.zeros_like(denom), where=denom != 0.0)` 后告警消失、数值与口径完全不变, 改成 `np.divide(..., out=np.zeros_like(denom), where=denom != 0.0)` 后告警消失、数值与口径完全不变,
并补了一条测试把"不许告警"钉住(`test_smape_is_silent_when_denominator_is_zero`)。 并补了一条测试把"不许告警"钉住(`test_smape_is_silent_when_denominator_is_zero`)。
**判据**:只是防告警、不改语义 → 工程侧;如果是"改公式"(如决策 59 的 r2),一律留给用户(决策 49)。 **判据**:只是防告警、不改语义 → 工程侧;如果是"改公式"(如决策 59 的 r2),一律留给用户(决策 49)。
| 61 | `models/metrics.py` 的三条入口口径由**用户第三轮改定**(`Inf` / 至少 2 个样本 / `mape` 拦非正数),Codex 只同步 docstring + 测试 |
前一轮报的 4 条待拍板里,用户改了前三条:① `verify_yt_yp` 增加 `np.isinf(yt).any() or np.isinf(yp).any()`
(只拦 `NaN` 时 `rmse([1.0, inf], ...)` 会安静地返回 `inf`);② 增加"至少 2 个样本"(1 个点的误差指标没有统计意义,
`r2_score` 更是直接无定义),**标量也因此被拦住**(不再被当成 1 个样本悄悄算);③ `mape` 从"只拦 0"改成
`np.any(yt <= 0)`(原来的 `mean(|yt - yp| / yt)` 在 `yt < 0` 时返回**负数**,实测 `-1.0`)。
Codex 侧:把这三条口径写进 7 处 `Raises`(**又是易错点 30**:口径一改、六处 docstring 立刻过期,本轮`复发 1`);
`test_mape_rejects_zero_in_y_true` 的 `match` 文案随新消息改为"非正数",并新增 `Inf` / 至少 2 个样本两条测试。
**遗留小问题(Codex 没动)**:`verify_yt_yp` 里 `yt.ndim == 0` 那一支是**死代码** ——
`as_array(..., ndim=1)` 必然 `reshape(-1)`,`ndim` 恒为 1,可整段塌成 `if yt.shape[0] <= 1:`(真正的判定条件)。
| 62 | `compare_models` 由 Codex 按 **A 档**落地,指标名口径选**别名映射**(用户 2026-10-12 拍板) |
前一轮问的是"`metrics=("rmse","mae","r2")` 里的 `"r2"` 与函数名 `r2_score` 对不上,用哪个"。用户选**两个都收**,
所以别名表落在模块级的 `_METRIC_FUNCS`(`"r2"` 与 `"r2_score"` 指同一个函数):**传哪个名字,输出表的列就叫哪个名字**,
两个一起传报 `ValueError`(否则会出两列一模一样的数字)。别名只解决"名字认不认",不改变数值。
实现要点:`models` 必须传**模型对象**(`fit` / `predict`),不是拟合结果;`X` 必须二维(`as_array(ndim=2)` 不替你猜);
分折是**手写 K 折**(`get_rng(random_state).permutation` + `np.array_split`),**不依赖 sklearn** —— 只装了 4 个核心依赖也能跑;
`cv=0` 全量拟合后打分(`cv=1` 报错:要么 0 要么 ≥2);`cv >= 2` 报**折间均值**;每折至少 2 个样本 ⇒ `cv <= n // 2`;
排序方向跟着**第一个指标**走(误差类升序、R² 降序)。`adjusted_r2` **不进表**(还要 `n_features`,`compare_models` 不知道每个模型用了几列)。
测试 23 → 31 条:首列排序(升序 / 降序两条)、别名、坏指标名、坏 `models` / `cv`、每折样本数下限、`random_state` 可复现。
## 下一步 ## 下一步
### ⭐ 当前进度与待办(2026-10-12 第十轮)
**本轮:你办掉第九轮那 4 条口径里的 3 条,Codex 落地 `compare_models`(别名映射)。**
你改的三条(决策 61):`verify_yt_yp` 拦 `Inf`、拦"只有 1 个样本"、`mape` 从"只拦 0"改成 `yt <= 0`。
三条我都复测过:`rmse([1.0, inf], [1.0, 2.0])` 现在报 `ValueError`;`rmse(1.0, 1.0)` 也报错(标量不再被当成 1 个样本);
`mape([-1.0, 2.0], [1.0, 2.0])` 由返回 `-1.0` 变成报错。
Codex 这轮:① 把三条新口径写进 7 处 `Raises`(口径一改、docstring 就过期 —— **易错点 30 复发 1 次**);
② `compare_models` 按 A 档实现(决策 62),指标名走**别名映射**:`_METRIC_FUNCS` 里 `"r2"` 与 `"r2_score"` 指向同一个函数,
传哪个列名就叫哪个、两个一起传报错;③ 顺手补了 `X` 必须二维、`cv` 白名单(`bool` 不收、`cv=1` 无意义、`cv <= n // 2`)、
`models` 必须是"有 `fit`/`predict` 的对象"等入口;④ 测试 23 → 31 条。
三连:`pytest` **233 passed / 25 skipped(258 收集)**、`ruff` **0**、`pyright` **0**。
**接下来(`models` 的完成标志 `compare_models` 已达成)**
1. `regression.py` 的 `fit_linear` / `fit_polynomial`(A 档薄封装;把 `base.Model` 三件套跑通,
顺带把 `tests/test_models.py` 里那两条**还挂着的 `skip`** 转正)。**注意**:`fit_linear` 落地后要补一条
"`compare_models` + 真实模型"的集成测试 —— 原来那条跳过用例写的是 `compare_models({"linear": fit_linear(X, y)})`,
**把拟合结果当模型传是错的**,本轮已把它改写成用测试桩(`_MeanModel` / `_LinearModel`)。
2. `models` 剩下的 `fit_nonlinear` / `timeseries` / `ml` 按 roadmap 走。
3. 之后转 `decision/entropy_weights`(**S 档,你写**,三个坑:方向 / 量纲 / 零值)。
### ⏳ 第十轮新出现的 1 条小口径(Codex 只报不改,决策 49)
**`verify_yt_yp` 里 `yt.ndim == 0` 那一支是死代码**:`as_array(..., ndim=1)` 内部一定 `ravel()`,`ndim` 恒为 1,
所以 `if yt.ndim == 0 or yt.shape[0] <= 1:` 里前半段永远为假,报错文案里的 `else 1` 也永远走不到。
整段可以塌成 `if yt.shape[0] <= 1:`(行为完全不变,只是少一段"看着像在防什么、其实没防"的代码)。
你决定改不改 —— 不改也能跑,只是以后回头看会多花几秒钟。
### ⭐ 当前进度与待办(2026-10-10 第九轮) ### ⭐ 当前进度与待办(2026-10-10 第九轮)
**本轮:`models` 开工,你亲手写了 `models/metrics.py`** —— 6 个回归指标(`rmse` / `mae` / `mape` / `smape` / **本轮:`models` 开工,你亲手写了 `models/metrics.py`** —— 6 个回归指标(`rmse` / `mae` / `mape` / `smape` /
@@ -653,15 +703,14 @@ Codex 只碰工程侧:7 处 docstring(补 `Parameters` / `Returns` / `Raises
2. `regression.py` 的 `fit_linear` / `fit_polynomial`(把 `models/base.py` 的 `Model` 协议三件套跑通)。 2. `regression.py` 的 `fit_linear` / `fit_polynomial`(把 `models/base.py` 的 `Model` 协议三件套跑通)。
3. 然后转 `decision/entropy_weights`(**S 档,你写**,三个坑:方向 / 量纲 / 零值)。 3. 然后转 `decision/entropy_weights`(**S 档,你写**,三个坑:方向 / 量纲 / 零值)。
### ⏳ 第九轮新增:4 条口径待你拍板(Codex 只报不改,决策 49) ### ✅ 第九轮那 4 条口径(2026-10-12 全部办结)
1. **`mape` 遇负 `y_true` 不拦**:`mean(|yt - yp| / yt)` 在 `yt < 0` 时给出**负数**(实测 `mape([-1, 2], [1, 2])` → `-1.0`)。 1. ~~`mape` 遇负 `y_true` 返回负数~~ → **用户选"拦"**:改成 `np.any(yt <= 0)` 直接报错并点名改用 `smape`
MAPE 的定义域是 `y_true > 0`;两个方向:拦 `y_true <= 0`,或把分母改成 `|y_true|`(标准写法 `|(y-ŷ)/y|`)。 (另一条路是用 `|y_true|` 做分母,没选)。测试文案随之改成 `match="非正数"`。
2. **`inf` 不拦**:`verify_yt_yp` 只查 `np.isnan`,实测 `rmse([1.0, inf], [1.0, 2.0])` → `inf`。要不要跟 `NaN` 一样直接报错? 2. ~~`inf` 不拦~~ → **用户选"跟 `NaN` 一样拦"**:`verify_yt_yp` 增加 `np.isinf(...).any()`(`rmse` 不再安静地返回 `inf`)。
3. **标量能进来**:`as_array` 的白名单含 `int` / `float`,`rmse(1.0, 1.0)` → `0.0`(被当成 1 个样本)。接受还是要求 ≥ 2 个点? 3. ~~标量被当成 1 个样本~~ → **用户选"要求至少 2 个点"**,于是 `rmse(1.0, 1.0)` 现在报 `ValueError`。
(与 `as_array` 那条"标量是否摘掉"的旧口径是同一个问题,见下面"其余待办"第 6 条。) 注意:这与 `as_array` 白名单里"标量要不要摘掉"是**两个问题**(那条仍未拍板,见下方"其余待办"第 6 条)。
4. **`compare_models` 的键名**:签名里写 `metrics=("rmse", "mae", "r2")`,但函数名是 `r2_score`。 4. ~~`compare_models` 的键名 `"r2"` vs `"r2_score"`~~ → **用户选别名映射**(两个都收),由 Codex 实现,见决策 62。
选 `"r2"`(列名短)还是 `"r2_score"`(与函数名一致)?也可以两个都收(别名映射,最省事)。
### ⭐ 当前进度与待办(2026-10-10 第八轮) ### ⭐ 当前进度与待办(2026-10-10 第八轮)
+16
View File
@@ -544,3 +544,19 @@
不要拿 `np.where` 当挡箭牌;② 写完拿最脏的输入跑一遍(全 0、单个点、常量列),**盯着 warning 看**; 不要拿 `np.where` 当挡箭牌;② 写完拿最脏的输入跑一遍(全 0、单个点、常量列),**盯着 warning 看**;
③ 项目开了 warning-as-error 时,"不告警"就是契约的一部分,要有一条测试专门钉住它。 ③ 项目开了 warning-as-error 时,"不告警"就是契约的一部分,要有一条测试专门钉住它。
- 首次:2026-10-10|复发:0 - 首次:2026-10-10|复发:0
### 33. 长报错文案拆行时少一个引号 / 多一个逗号(`ruff` 一秒能抓,别等运行时)
- 现象:两条都在同一轮里写出来 —— ① `raise TypeError(` 下面的长中文 f-string 拆成两行,第一行**忘写结尾引号**,
Python 直接 `SyntaxError: unterminated f-string literal`(`ruff` 报 `invalid-syntax`,`import` 也跑不了);
② `raise ValueError(
f"...",
"..."
)` —— 想拼接两段文案却写成**两个参数**,
message 就成了元组的字符串 `('...', '...')`,看着像"报错文案有点怪",很难一眼看出是参数个数的问题。
- 为什么会错:中文文案长,按习惯拆行;`+` / 隐式拼接 / 逗号在编辑器里长得差不多。
这类错**不是逻辑错**,是"手滑",但会在最不该花时间的地方浪费两分钟。
- 以后怎么防:① 写完多行字符串,**立刻**跑一次 `ruff check`(或 `python -c "import ..."`)—— 语法错它必抓;
② 相邻字符串字面量的隐式拼接**不要夹逗号**,要拼接就老老实实写 `+` 或一次写完;
③ `Raises` 里放的不是长句而是"参数名 + 实际值",本身就不用拆行。
- 首次:2026-10-12|复发:0
+155 -22
View File
@@ -6,17 +6,20 @@
注意:本模块被 `models` 与 `solve` 共用,属于"公共数学",但为了少建一个包, 注意:本模块被 `models` 与 `solve` 共用,属于"公共数学",但为了少建一个包,
放在 `models` 下;`solve` 单向依赖 `models` 是允许的。 放在 `models` 下;`solve` 单向依赖 `models` 是允许的。
本模块的函数**只返回 `float`,不返回 `Result`**(架构 §4.1 只覆盖拟合/求解/ 本模块的指标函数**只返回 `float`,不返回 `Result`**(架构 §4.1 只覆盖拟合/求解/
模拟/评价);入口非法一律抛异常,见各函数的 `Raises`。 模拟/评价);入口非法一律抛异常,见各函数的 `Raises`。唯一的例外是
`compare_models`:它按架构 §4.3 返回一张 `DataFrame` 对比表。
""" """
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from typing import Any from typing import Any
import numpy as np import numpy as np
import pandas as pd
from mcm_toolkit.core.validate import as_array from mcm_toolkit.core import as_array, get_rng
__all__ = [ __all__ = [
"adjusted_r2", "adjusted_r2",
@@ -30,11 +33,12 @@ __all__ = [
def verify_yt_yp(y_true: Any, y_pred: Any) -> tuple[np.ndarray, np.ndarray]: def verify_yt_yp(y_true: Any, y_pred: Any) -> tuple[np.ndarray, np.ndarray]:
"""收成同形的一维 `ndarray`,并拦掉空 / 形状不一致 / 含 NaN 三类非法输入。 """收成同形的一维 `ndarray`,并拦掉形状 / 空 / NaN / Inf / 样本不足五类非法输入。
指标函数的第一道关:先在这里把非法输入拦掉,函数体里就只剩公式。 指标函数的第一道关:先在这里把非法输入拦掉,函数体里就只剩公式。
刻意**不删缺失值**(与 `as_array` 同一口径):删了会悄悄改变样本量, 刻意**不删缺失值**(与 `as_array` 同一口径):删了会悄悄改变样本量,
指标就不可比了,所以含 `NaN` 直接报错(决策 48)。 指标就不可比了,所以含 `NaN` / `Inf` 直接报错(决策 48)。
**至少 2 个样本**:1 个点的误差指标没有统计意义(`r2_score` 更是直接无定义)。
Parameters Parameters
---------- ----------
@@ -50,7 +54,7 @@ def verify_yt_yp(y_true: Any, y_pred: Any) -> tuple[np.ndarray, np.ndarray]:
Raises Raises
------ ------
ValueError ValueError
形状不一致、长度为 0,或含 `NaN`。 形状不一致;长度为 0 或 1(指标至少要 2 个样本);含 `NaN` 或 `Inf`。
""" """
yt, _ = as_array(y_true, ndim=1) yt, _ = as_array(y_true, ndim=1)
yp, _ = as_array(y_pred, ndim=1) yp, _ = as_array(y_pred, ndim=1)
@@ -60,7 +64,11 @@ def verify_yt_yp(y_true: Any, y_pred: Any) -> tuple[np.ndarray, np.ndarray]:
if len(yt) == 0: if len(yt) == 0:
raise ValueError("y_true 与 y_pred 都不能为空") raise ValueError("y_true 与 y_pred 都不能为空")
if np.isnan(yt).any() or np.isnan(yp).any(): if np.isnan(yt).any() or np.isnan(yp).any():
raise ValueError("y_true / y_pred 中不能含 NaN,请先清洗或补全") raise ValueError("有空值(NaN),请清洗或补充后再计算")
if np.isinf(yt).any() or np.isinf(yp).any():
raise ValueError("有无限值(Inf),请检查数据来源,修复除以0或溢出问题")
if yt.ndim == 0 or yt.shape[0] <= 1:
raise ValueError(f"输入至少需要包含 2 个样本,当前长度为 {yt.shape[0] if yt.ndim > 0 else 1}")
return yt, yp return yt, yp
@@ -81,7 +89,7 @@ def rmse(y_true: Any, y_pred: Any) -> float:
Raises Raises
------ ------
ValueError ValueError
形状不一致、为空或含 `NaN`。 未通过 :func:`verify_yt_yp`:形状不一致、样本少于 2 个、含 `NaN` 或 `Inf`。
""" """
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred) yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
@@ -104,7 +112,7 @@ def mae(y_true: Any, y_pred: Any) -> float:
Raises Raises
------ ------
ValueError ValueError
形状不一致、为空或含 `NaN`。 未通过 :func:`verify_yt_yp`:形状不一致、样本少于 2 个、含 `NaN` 或 `Inf`。
""" """
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred) yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
@@ -112,7 +120,7 @@ def mae(y_true: Any, y_pred: Any) -> float:
def mape(y_true: Any, y_pred: Any) -> float: def mape(y_true: Any, y_pred: Any) -> float:
"""平均绝对百分比误差。无量纲,可跨量纲比较;`y_true` 含 0 时无定义。 """平均绝对百分比误差。无量纲,可跨量纲比较;`y_true` 必须**全为正数**。
Parameters Parameters
---------- ----------
@@ -127,13 +135,14 @@ def mape(y_true: Any, y_pred: Any) -> float:
Raises Raises
------ ------
ValueError ValueError
形状不一致、为空、含 `NaN`,或 `y_true` 中有 0(分母为 0,结果无意义; 未通过 :func:`verify_yt_yp`(形状 / 样本数 / `NaN` / `Inf`),
或 `y_true` 里有非正数(0 → 分母为 0;负数 → 百分比误差无意义;
这种数据请改用 :func:`smape`)。 这种数据请改用 :func:`smape`)。
""" """
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred) yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
if np.any(yt == 0): if np.any(yt <= 0):
raise ValueError("y_true 中不能含 0,否则 MAPE 无定义;含 0 的数据请改用 smape") raise ValueError("y_true 包含非正数(0或负数),MAPE 在数学上无定义,请清洗数据或改用 SMAPE。")
return float(np.mean(np.abs(yt - yp) / yt)) return float(np.mean(np.abs(yt - yp) / yt))
@@ -154,7 +163,7 @@ def smape(y_true: Any, y_pred: Any) -> float:
Raises Raises
------ ------
ValueError ValueError
形状不一致、为空或含 `NaN`。 未通过 :func:`verify_yt_yp`:形状不一致、样本少于 2 个、含 `NaN` 或 `Inf`。
""" """
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred) yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
@@ -184,7 +193,8 @@ def r2_score(y_true: Any, y_pred: Any) -> float:
Raises Raises
------ ------
ValueError ValueError
形状不一致、为空、含 `NaN`,或 `y_true` 是常量(`SS_tot = 0`,分母为 0)。 未通过 :func:`verify_yt_yp`(形状 / 样本数 / `NaN` / `Inf`),
或 `y_true` 是常量(`SS_tot = 0`,分母为 0)。
""" """
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred) yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
@@ -219,7 +229,7 @@ def adjusted_r2(y_true: Any, y_pred: Any, n_features: int | np.integer) -> float
`n_features` 不是整数(含 `bool`、`float`、`np.float64`)。 `n_features` 不是整数(含 `bool`、`float`、`np.float64`)。
ValueError ValueError
`n_features <= 0`;`n <= n_features + 1`(自由度为 0,分母为 0); `n_features <= 0`;`n <= n_features + 1`(自由度为 0,分母为 0);
或 `y_true` / `y_pred` 未通过 :func:`verify_yt_yp`。 或 `y_true` / `y_pred` 未通过 :func:`verify_yt_yp`(形状 / 样本数 / `NaN` / `Inf`)。
""" """
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred) yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
@@ -236,6 +246,47 @@ def adjusted_r2(y_true: Any, y_pred: Any, n_features: int | np.integer) -> float
return float(1 - ((1 - r2) * (n - 1) / (n - 1 - n_features))) return float(1 - ((1 - r2) * (n - 1) / (n - 1 - n_features)))
# 指标名 → 函数。**别名表**就在这里:`"r2"` 与 `"r2_score"` 是同一个函数的两个名字,
# 传哪个名字,输出表里的列就叫哪个名字(模型的 `score()` 习惯用 `"r2"`,函数本名是
# `r2_score`,两个都收,省得记)。要加指标就往这个 dict 里添一行。
#
# `adjusted_r2` **不在这里**:它还要 `n_features`(自变量个数),`compare_models` 不知道
# 每个模型实际用了几个自变量,硬塞进来只会算错。单个模型要调整 R² 就自己调它。
_METRIC_FUNCS: dict[str, Callable[[Any, Any], float]] = {
"rmse": rmse,
"mae": mae,
"mape": mape,
"smape": smape,
"r2": r2_score,
"r2_score": r2_score,
}
# "越大越好"的指标:排序方向要反过来。其余(误差类)一律越小越好。
_HIGHER_IS_BETTER = frozenset({"r2", "r2_score"})
def _resolve_metrics(metrics: Any) -> dict[str, Callable[[Any, Any], float]]:
"""把指标名列表翻成 `{列名: 函数}`,顺手拦掉不认识的名字和重复的别名。"""
if isinstance(metrics, str):
raise TypeError("metrics 是一**组**指标名,写成 metrics=('rmse', 'r2'),不要传单个字符串")
if not isinstance(metrics, (list, tuple)):
raise TypeError(f"metrics 必须是 tuple / list,你传入了 {type(metrics).__name__}")
resolved: dict[str, Callable[[Any, Any], float]] = {}
for name in metrics:
fn = _METRIC_FUNCS.get(name)
if fn is None:
raise ValueError(f"不认识的指标名 {name!r};可选:{sorted(_METRIC_FUNCS)}")
if fn in resolved.values():
raise ValueError(f"{name!r} 与前面某个指标是同一个函数(别名),只挑一个传")
resolved[name] = fn
if not resolved:
raise ValueError("metrics 至少要有一个指标")
return resolved
def compare_models( def compare_models(
models: dict[str, Any], models: dict[str, Any],
X: Any, X: Any,
@@ -243,7 +294,7 @@ def compare_models(
metrics: tuple[str, ...] = ("rmse", "mae", "r2"), metrics: tuple[str, ...] = ("rmse", "mae", "r2"),
cv: int = 5, cv: int = 5,
random_state: int | None = None, random_state: int | None = None,
) -> Any: ) -> pd.DataFrame:
"""把若干模型放在一起打分,返回对比表。 """把若干模型放在一起打分,返回对比表。
这是赛题里最省时间的函数之一。队友问"要不要上复杂模型"时, 这是赛题里最省时间的函数之一。队友问"要不要上复杂模型"时,
@@ -254,20 +305,102 @@ def compare_models(
models: models:
`{"linear": m1, "poly3": m2, "random_forest": m3}`。 `{"linear": m1, "poly3": m2, "random_forest": m3}`。
每个模型需满足 :class:`mcm_toolkit.models.base.Model` 协议(有 `fit` / `predict`)。 每个模型需满足 :class:`mcm_toolkit.models.base.Model` 协议(有 `fit` / `predict`)。
**传模型对象本身,不是拟合结果** —— 拟合由本函数自己做。
X:
二维设计矩阵 `(n_samples, n_features)`;一列的 `x` 要自己 `x.reshape(-1, 1)`。
y:
一维真值,与 `X` 行数相同。
metrics:
要打分的指标名。可用 `"rmse"` / `"mae"` / `"mape"` / `"smape"` / `"r2"` / `"r2_score"`;
**`"r2"` 与 `"r2_score"` 是同一个函数的别名**(传哪个、列名就叫哪个),两个一起传会报错。
表按**第一个指标**排序:误差类升序(越小越好),R² 降序。
cv: cv:
交叉验证折数;`cv=0` 表示在全部数据上拟合后打分(只看拟合能力时用)。 交叉验证折数;`cv=0` 表示在全部数据上拟合后打分(只看拟合能力时用)。
`cv >= 2` 时报的是**折间均值**,每折至少 2 个样本(指标本身的入口要求)。
**时序数据不要用它** —— 随机划分会让未来信息漏进训练集(要用滚动窗口)。
random_state:
随机种子(`cv >= 2` 时决定折怎么分)。传整数可复现;`None` 时跟随
`core.set_seed` 的全局流。
Returns Returns
------- -------
DataFrame:行 = 模型,列 = 指标;按第一列指标升序(越小越好)或降序排列。 pandas.DataFrame:行 = 模型(`models` 的键),列 = 指标(`metrics` 里的名字)。
可直接 `save_table(result, "compare.tex", fmt="latex")` 贴进论文。 按第一个指标排好序,可直接 `save_table(tbl, "compare.tex", fmt="latex")` 贴进论文。
Raises
------
TypeError
`models` 不是 dict、`cv` 不是整数、`metrics` 传了单个字符串,
或某个模型没有 `fit` / `predict`。
ValueError
`models` 为空、指标名不认识、两个指标是同一个别名、
`X` / `y` 未通过 `as_array`、样本数不一致、`cv` 取值不可能分折。
Notes
-----
分折是**用 numpy 手写的 K 折交叉验证**(`get_rng(random_state).permutation` +
`np.array_split`),所以不依赖 sklearn —— 比赛现场没装 `ml` 那组 extras 也能跑。
指标自己的入口要求照样生效:`mape` 要求 `y_true` 全为正数,含 0 或负数的数据改用 `smape`。
Examples Examples
-------- --------
>>> tbl = compare_models({"linear": m1, "poly": m2}, X, y) # doctest: +SKIP >>> tbl = compare_models({"linear": m1, "poly": m2}, X, y, cv=0) # doctest: +SKIP
>>> tbl.index[0] # doctest: +SKIP >>> tbl.index[0] # doctest: +SKIP
'poly' 'poly'
""" """
# TODO(L1) if not isinstance(models, dict):
raise NotImplementedError raise TypeError(f"models 必须是 {{名字: 模型}} 的字典,你传入了 {type(models).__name__}")
if not models:
raise ValueError("models 不能为空,至少给一个模型")
for name, model in models.items():
if not (hasattr(model, "fit") and hasattr(model, "predict")):
raise TypeError(
f"模型 {name!r} 不满足 Model 协议:需要 fit / predict 两个方法;"
f"你传入了 {type(model).__name__}(注意:传模型对象,别传拟合结果)"
)
resolved = _resolve_metrics(metrics)
X_arr, _ = as_array(X, ndim=2)
y_arr, _ = as_array(y, ndim=1)
n = y_arr.shape[0]
if X_arr.shape[0] != n:
raise ValueError(f"X 与 y 的样本数必须一致,你传入的是 {X_arr.shape[0]} 和 {n}")
if n < 2:
raise ValueError(f"样本太少:至少要 2 个,当前 n={n}")
if isinstance(cv, bool) or not isinstance(cv, (int, np.integer)):
raise TypeError(f"cv 必须是整数,你传入了 {type(cv).__name__}")
if cv < 0:
raise ValueError(f"cv 不能为负数:0 表示全量打分,你传入了 {cv}")
if cv == 1:
raise ValueError("cv=1 没有意义:要么 cv=0(全量拟合后打分),要么 cv>=2(分折)")
if cv > 0 and cv > n // 2:
raise ValueError(
f"cv={cv} 太大:n={n}、每折至少要 2 个样本,最多只能分 {n // 2} 折;"
"样本太少的话改用 cv=0(全量拟合后打分)"
)
if cv == 0:
folds = [(np.arange(n), np.arange(n))]
else:
index = get_rng(random_state).permutation(n)
blocks = np.array_split(index, cv)
folds = [
(np.concatenate([blocks[j] for j in range(cv) if j != k]), blocks[k])
for k in range(cv)
]
scores: dict[str, dict[str, float]] = {}
for name, model in models.items():
per_fold: dict[str, list[float]] = {column: [] for column in resolved}
for train_idx, test_idx in folds:
model.fit(X_arr[train_idx], y_arr[train_idx])
y_pred = np.asarray(model.predict(X_arr[test_idx]))
for column, fn in resolved.items():
per_fold[column].append(fn(y_arr[test_idx], y_pred))
scores[name] = {column: float(np.mean(vals)) for column, vals in per_fold.items()}
table = pd.DataFrame.from_dict(scores, orient="index")
first = next(iter(resolved))
return table.sort_values(first, ascending=first not in _HIGHER_IS_BETTER)
+147 -12
View File
@@ -46,20 +46,39 @@ def test_r2_is_one_for_perfect_fit():
assert np.isclose(r2_score(y, y), 1.0) 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): 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, y = linear_data
X = x.reshape(-1, 1)
tbl = compare_models( tbl = compare_models(
{"linear": fit_linear(X, y), "poly2": fit_polynomial(X, y, degree=2)}, {"mean": _MeanModel(), "linear": _LinearModel()}, x.reshape(-1, 1), y, cv=0
X,
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(): 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) assert np.isclose(fn(pd.DataFrame({"y": yt}), pd.DataFrame({"y": yp})), base)
def test_mape_rejects_zero_in_y_true(): def test_mape_rejects_non_positive_y_true():
"""`y_true` 含 0 时 MAPE 无定义——拦住并点名改用 smape,而不是返回 inf。""" """`y_true` 非正时 MAPE 无定义 —— 拦住并点名改用 smape,而不是返回负数或 inf。"""
from mcm_toolkit.models import mape 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]) 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(): 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) r2 = r2_score(yt, yp)
assert np.isclose(adjusted_r2(yt, yp, k), 1 - (1 - r2) * (n - 1) / (n - k - 1)) 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)