From 748affb7f056a61b8f3e32e99ef2bf50a6f03a1a Mon Sep 17 00:00:00 2001 From: guzhujushi Date: Mon, 12 Oct 2026 16:53:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(models):=20=E8=90=BD=E5=9C=B0=20compare=5F?= =?UTF-8?q?models=EF=BC=88=E6=8C=87=E6=A0=87=E5=90=8D=E5=88=AB=E5=90=8D?= =?UTF-8?q?=E6=98=A0=E5=B0=84=EF=BC=89+=20=E5=90=8C=E6=AD=A5=E4=BD=A0?= =?UTF-8?q?=E7=9A=84=E4=B8=89=E6=9D=A1=E5=85=A5=E5=8F=A3=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 你的三条改动(决策 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 多参数)。 --- CHANGELOG.md | 31 +++++ STATUS.md | 77 ++++++++++--- docs/dev/personal-pitfalls.md | 16 +++ src/mcm_toolkit/models/metrics.py | 181 ++++++++++++++++++++++++++---- tests/test_models.py | 159 ++++++++++++++++++++++++-- 5 files changed, 414 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aad78ef..e4c38d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -644,6 +644,37 @@ `rmse(1.0, 1.0)` 这类标量被当成 1 个样本、`compare_models` 的键名 `"r2"` 与函数名 `r2_score` 不一致。 详见 `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 ### Added diff --git a/STATUS.md b/STATUS.md index e0e6ce5..3592e8d 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,6 +1,6 @@ # STATUS — 当前进度 -> 更新日期:2026-10-10 +> 更新日期:2026-10-12 ## 本次任务 @@ -24,8 +24,9 @@ `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` 未实现。 +**`models/metrics.py` 已完成(第九~十轮)**:6 个指标(`rmse` / `mae` / `mape` / `smape` / `r2_score` / +`adjusted_r2`)+ 共享入口 `verify_yt_yp` **由用户写**、Codex 审;`compare_models` 由 Codex 按 A 档落地 +(含指标名**别名映射**)。`models` 下一步是 `regression.py`。 `monte_carlo` 的口径已拍板(决策 48);`monte_carlo_probability` / `random_walk` 的待办**均已办结**; `markov_simulate`(决策 56)/ `markov_steady_state`(决策 57)/ `mm1_metrics`(决策 58) 的入口口径**已办掉大半**,各剩 1~2 条细枝末节(详见"下一步"第八轮)。 @@ -395,7 +396,7 @@ | 检查 | 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 项 | | `ruff check` | All checks passed | **All checks passed**(0 error) | | `scripts/doctor.ps1` | 跑通 | — | @@ -405,9 +406,9 @@ (修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与 `docs/04-environment.md` 坑 7) - 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_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),那条用例转正 - 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环 - `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)` 后告警消失、数值与口径完全不变, 并补了一条测试把"不许告警"钉住(`test_smape_is_silent_when_denominator_is_zero`)。 **判据**:只是防告警、不改语义 → 工程侧;如果是"改公式"(如决策 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 第九轮) **本轮:`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` 协议三件套跑通)。 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`)。 - 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"`(与函数名一致)?也可以两个都收(别名映射,最省事)。 +1. ~~`mape` 遇负 `y_true` 返回负数~~ → **用户选"拦"**:改成 `np.any(yt <= 0)` 直接报错并点名改用 `smape` + (另一条路是用 `|y_true|` 做分母,没选)。测试文案随之改成 `match="非正数"`。 +2. ~~`inf` 不拦~~ → **用户选"跟 `NaN` 一样拦"**:`verify_yt_yp` 增加 `np.isinf(...).any()`(`rmse` 不再安静地返回 `inf`)。 +3. ~~标量被当成 1 个样本~~ → **用户选"要求至少 2 个点"**,于是 `rmse(1.0, 1.0)` 现在报 `ValueError`。 + 注意:这与 `as_array` 白名单里"标量要不要摘掉"是**两个问题**(那条仍未拍板,见下方"其余待办"第 6 条)。 +4. ~~`compare_models` 的键名 `"r2"` vs `"r2_score"`~~ → **用户选别名映射**(两个都收),由 Codex 实现,见决策 62。 ### ⭐ 当前进度与待办(2026-10-10 第八轮) diff --git a/docs/dev/personal-pitfalls.md b/docs/dev/personal-pitfalls.md index 3accf46..00ffe12 100644 --- a/docs/dev/personal-pitfalls.md +++ b/docs/dev/personal-pitfalls.md @@ -544,3 +544,19 @@ 不要拿 `np.where` 当挡箭牌;② 写完拿最脏的输入跑一遍(全 0、单个点、常量列),**盯着 warning 看**; ③ 项目开了 warning-as-error 时,"不告警"就是契约的一部分,要有一条测试专门钉住它。 - 首次: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 diff --git a/src/mcm_toolkit/models/metrics.py b/src/mcm_toolkit/models/metrics.py index 1c9ae38..533bf59 100644 --- a/src/mcm_toolkit/models/metrics.py +++ b/src/mcm_toolkit/models/metrics.py @@ -6,17 +6,20 @@ 注意:本模块被 `models` 与 `solve` 共用,属于"公共数学",但为了少建一个包, 放在 `models` 下;`solve` 单向依赖 `models` 是允许的。 -本模块的函数**只返回 `float`,不返回 `Result`**(架构 §4.1 只覆盖拟合/求解/ -模拟/评价);入口非法一律抛异常,见各函数的 `Raises`。 +本模块的指标函数**只返回 `float`,不返回 `Result`**(架构 §4.1 只覆盖拟合/求解/ +模拟/评价);入口非法一律抛异常,见各函数的 `Raises`。唯一的例外是 +`compare_models`:它按架构 §4.3 返回一张 `DataFrame` 对比表。 """ from __future__ import annotations +from collections.abc import Callable from typing import Any 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__ = [ "adjusted_r2", @@ -30,11 +33,12 @@ __all__ = [ def verify_yt_yp(y_true: Any, y_pred: Any) -> tuple[np.ndarray, np.ndarray]: - """收成同形的一维 `ndarray`,并拦掉空 / 形状不一致 / 含 NaN 三类非法输入。 + """收成同形的一维 `ndarray`,并拦掉形状 / 空 / NaN / Inf / 样本不足五类非法输入。 指标函数的第一道关:先在这里把非法输入拦掉,函数体里就只剩公式。 刻意**不删缺失值**(与 `as_array` 同一口径):删了会悄悄改变样本量, - 指标就不可比了,所以含 `NaN` 直接报错(决策 48)。 + 指标就不可比了,所以含 `NaN` / `Inf` 直接报错(决策 48)。 + **至少 2 个样本**:1 个点的误差指标没有统计意义(`r2_score` 更是直接无定义)。 Parameters ---------- @@ -50,7 +54,7 @@ def verify_yt_yp(y_true: Any, y_pred: Any) -> tuple[np.ndarray, np.ndarray]: Raises ------ ValueError - 形状不一致、长度为 0,或含 `NaN`。 + 形状不一致;长度为 0 或 1(指标至少要 2 个样本);含 `NaN` 或 `Inf`。 """ yt, _ = as_array(y_true, 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: raise ValueError("y_true 与 y_pred 都不能为空") 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 @@ -81,7 +89,7 @@ def rmse(y_true: Any, y_pred: Any) -> float: Raises ------ ValueError - 形状不一致、为空或含 `NaN`。 + 未通过 :func:`verify_yt_yp`:形状不一致、样本少于 2 个、含 `NaN` 或 `Inf`。 """ 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 ------ ValueError - 形状不一致、为空或含 `NaN`。 + 未通过 :func:`verify_yt_yp`:形状不一致、样本少于 2 个、含 `NaN` 或 `Inf`。 """ 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: - """平均绝对百分比误差。无量纲,可跨量纲比较;`y_true` 含 0 时无定义。 + """平均绝对百分比误差。无量纲,可跨量纲比较;`y_true` 必须**全为正数**。 Parameters ---------- @@ -127,13 +135,14 @@ def mape(y_true: Any, y_pred: Any) -> float: Raises ------ ValueError - 形状不一致、为空、含 `NaN`,或 `y_true` 中有 0(分母为 0,结果无意义; + 未通过 :func:`verify_yt_yp`(形状 / 样本数 / `NaN` / `Inf`), + 或 `y_true` 里有非正数(0 → 分母为 0;负数 → 百分比误差无意义; 这种数据请改用 :func:`smape`)。 """ yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred) - if np.any(yt == 0): - raise ValueError("y_true 中不能含 0,否则 MAPE 无定义;含 0 的数据请改用 smape") + if np.any(yt <= 0): + raise ValueError("y_true 包含非正数(0或负数),MAPE 在数学上无定义,请清洗数据或改用 SMAPE。") return float(np.mean(np.abs(yt - yp) / yt)) @@ -154,7 +163,7 @@ def smape(y_true: Any, y_pred: Any) -> float: Raises ------ ValueError - 形状不一致、为空或含 `NaN`。 + 未通过 :func:`verify_yt_yp`:形状不一致、样本少于 2 个、含 `NaN` 或 `Inf`。 """ 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 ------ 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) @@ -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`)。 ValueError `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) @@ -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))) + +# 指标名 → 函数。**别名表**就在这里:`"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( models: dict[str, Any], X: Any, @@ -243,7 +294,7 @@ def compare_models( metrics: tuple[str, ...] = ("rmse", "mae", "r2"), cv: int = 5, random_state: int | None = None, -) -> Any: +) -> pd.DataFrame: """把若干模型放在一起打分,返回对比表。 这是赛题里最省时间的函数之一。队友问"要不要上复杂模型"时, @@ -253,21 +304,103 @@ def compare_models( ---------- models: `{"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=0` 表示在全部数据上拟合后打分(只看拟合能力时用)。 + `cv >= 2` 时报的是**折间均值**,每折至少 2 个样本(指标本身的入口要求)。 + **时序数据不要用它** —— 随机划分会让未来信息漏进训练集(要用滚动窗口)。 + random_state: + 随机种子(`cv >= 2` 时决定折怎么分)。传整数可复现;`None` 时跟随 + `core.set_seed` 的全局流。 Returns ------- - DataFrame:行 = 模型,列 = 指标;按第一列指标升序(越小越好)或降序排列。 - 可直接 `save_table(result, "compare.tex", fmt="latex")` 贴进论文。 + pandas.DataFrame:行 = 模型(`models` 的键),列 = 指标(`metrics` 里的名字)。 + 按第一个指标排好序,可直接 `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 -------- - >>> tbl = compare_models({"linear": m1, "poly": m2}, X, y) # doctest: +SKIP - >>> tbl.index[0] # doctest: +SKIP + >>> tbl = compare_models({"linear": m1, "poly": m2}, X, y, cv=0) # doctest: +SKIP + >>> tbl.index[0] # doctest: +SKIP 'poly' """ - # TODO(L1) - raise NotImplementedError + if not isinstance(models, dict): + 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) diff --git a/tests/test_models.py b/tests/test_models.py index 9822921..259cd54 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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)