diff --git a/CHANGELOG.md b/CHANGELOG.md index b6aa593..aad78ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -613,6 +613,37 @@ `markov_steady_state` 未收敛时是否带 `data`、非方阵的 `TypeError` / `ValueError` 不一致、 `mm1_metrics` 的 `data` 该不该是 dict。 +### Added +- `models/metrics.py`:6 个回归指标落地(**由用户本人实现**)—— `rmse` / `mae` / `mape` / `smape` / + `r2_score` / `adjusted_r2`,共用一个入口守卫 `verify_yt_yp`(`as_array` → 形状 / 空 / NaN 三道校验)。 + `compare_models` 仍是 `TODO(L1)`。 +- `tests/test_models.py`:5 条 → **23 条**。`test_metrics_known_values` / `test_r2_is_one_for_perfect_fit` + 两条摘掉 `skip` 转正,另新增 18 条 —— 形状不一致 / 空 / NaN / `str` 被 `TypeError` 挡、四种入参 + (`DataFrame` / `Series` / `ndarray` / `list`)结果一致 ×5、`mape` 遇 0、`smape` 全 0 且**不许漏告警**、 + `adjusted_r2` 的 `bool` / `float` / `0` / 分母保护 / `np.int64` / 惩罚单调性,以及 `r2_score` 的 + **非完美拟合**解析值与 `sklearn.metrics.r2_score` 对照。 + +### Changed +- `models/metrics.py`:`r2_score` 的 `SS_tot` 基准由 `np.mean(y_pred)` 改为 `np.mean(y_true)`(**真 bug**, + 由用户亲手改定,见 STATUS 决策 59 / 个人易错点 31),`ss_tol` 随之改名 `ss_tot`;修前 0.8803827751196172、 + 修后 0.875(与 sklearn 一致),`adjusted_r2` 同步修正。 +- `models/metrics.py`:`smape` 的零除改用 `np.divide(..., out=..., where=denom != 0.0)` —— + `np.where` 的两条分支都会被求值,原写法在 `filterwarnings = ["error::RuntimeWarning"]` 下会让 + "含 0 的 smape"测试直接失败(决策 60)。数值结果与口径不变。 +- `models/metrics.py`:补 7 处 docstring(`Parameters` / `Returns` / `Raises`),把"NaN 报错 / `mape` 遇 0 报错 / + `r2_score` 常量 y 报错 / `adjusted_r2` 的分母保护"写成契约;`n_features` 的口径写明**不含截距**。 +- `models/metrics.py`:报错文案统一为"参数名 + 实际值"(形状不一致会带上两个 `shape`); + ruff 9 条清零(6 条 `F541` 无占位符 f-string + 3 条 `W293`)+ 补函数间空行。 +- `STATUS.md`:新增**决策 59 / 60**,「下一步」推进到第九轮(含 4 条待拍板口径); + 验证计数更新为 **224 passed / 26 skipped(250 收集)**。 +- `docs/dev/personal-pitfalls.md`:新增易错点 31(公式的"基准量"抄错对象,且完美拟合会掩盖它)、 + 32(`np.where` 掩码防不住零除)。 + +### Notes +- **4 条口径待用户拍板**(Codex 只报不改):`mape` 遇负 `y_true` 现在返回负数、只拦 `NaN` 不拦 `inf`、 + `rmse(1.0, 1.0)` 这类标量被当成 1 个样本、`compare_models` 的键名 `"r2"` 与函数名 `r2_score` 不一致。 + 详见 `STATUS.md`「第九轮新增:4 条口径待你拍板」。 + ## [0.0.1] - 2026-10-01 ### Added diff --git a/STATUS.md b/STATUS.md index 3a153b0..e0e6ce5 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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`、 diff --git a/docs/dev/personal-pitfalls.md b/docs/dev/personal-pitfalls.md index d9bea6e..3accf46 100644 --- a/docs/dev/personal-pitfalls.md +++ b/docs/dev/personal-pitfalls.md @@ -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 diff --git a/src/mcm_toolkit/models/metrics.py b/src/mcm_toolkit/models/metrics.py index 334a26a..1c9ae38 100644 --- a/src/mcm_toolkit/models/metrics.py +++ b/src/mcm_toolkit/models/metrics.py @@ -5,12 +5,19 @@ 注意:本模块被 `models` 与 `solve` 共用,属于"公共数学",但为了少建一个包, 放在 `models` 下;`solve` 单向依赖 `models` 是允许的。 + +本模块的函数**只返回 `float`,不返回 `Result`**(架构 §4.1 只覆盖拟合/求解/ +模拟/评价);入口非法一律抛异常,见各函数的 `Raises`。 """ from __future__ import annotations from typing import Any +import numpy as np + +from mcm_toolkit.core.validate import as_array + __all__ = [ "adjusted_r2", "compare_models", @@ -22,41 +29,212 @@ __all__ = [ ] +def verify_yt_yp(y_true: Any, y_pred: Any) -> tuple[np.ndarray, np.ndarray]: + """收成同形的一维 `ndarray`,并拦掉空 / 形状不一致 / 含 NaN 三类非法输入。 + + 指标函数的第一道关:先在这里把非法输入拦掉,函数体里就只剩公式。 + 刻意**不删缺失值**(与 `as_array` 同一口径):删了会悄悄改变样本量, + 指标就不可比了,所以含 `NaN` 直接报错(决策 48)。 + + Parameters + ---------- + y_true, y_pred: + `DataFrame` / `Series` / `ndarray` / `list` / `tuple`,按 `as_array` 的规则 + 收成一维浮点数组(`str` 会被白名单挡住并 `TypeError`)。 + + Returns + ------- + (numpy.ndarray, numpy.ndarray) + 校验通过后的 `(y_true, y_pred)`,两者形状相同。 + + Raises + ------ + ValueError + 形状不一致、长度为 0,或含 `NaN`。 + """ + yt, _ = as_array(y_true, ndim=1) + yp, _ = as_array(y_pred, ndim=1) + + if yt.shape != yp.shape: + raise ValueError(f"y_true 与 y_pred 的形状必须一致,你传入的是 {yt.shape} 和 {yp.shape}") + 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,请先清洗或补全") + + return yt, yp + + def rmse(y_true: Any, y_pred: Any) -> float: - """均方根误差。与 y 同量纲,最常用。""" - # TODO(L1) - raise NotImplementedError + """均方根误差。与 y 同量纲、对大误差敏感,回归里最常用的第一指标。 + + Parameters + ---------- + y_true, y_pred: + 同形的一维数据,校验规则见 :func:`verify_yt_yp`。 + + Returns + ------- + float + `sqrt(mean((y_true - y_pred) ** 2))`,越小越好,0 表示完美拟合。 + + Raises + ------ + ValueError + 形状不一致、为空或含 `NaN`。 + """ + yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred) + + return float(np.sqrt(np.mean((yt - yp) ** 2))) def mae(y_true: Any, y_pred: Any) -> float: - """平均绝对误差。对离群值比 RMSE 稳健。""" - # TODO(L1) - raise NotImplementedError + """平均绝对误差。对离群值比 RMSE 稳健,和 RMSE 一起看能判断有无离群点。 + + Parameters + ---------- + y_true, y_pred: + 同形的一维数据,校验规则见 :func:`verify_yt_yp`。 + + Returns + ------- + float + `mean(|y_true - y_pred|)`,越小越好。 + + Raises + ------ + ValueError + 形状不一致、为空或含 `NaN`。 + """ + yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred) + + return float(np.mean(np.abs(yt - yp))) def mape(y_true: Any, y_pred: Any) -> float: - """平均绝对百分比误差。无量纲,但 y 含 0 时会炸(用 smape 替代)。""" - # TODO(L1) - raise NotImplementedError + """平均绝对百分比误差。无量纲,可跨量纲比较;`y_true` 含 0 时无定义。 + + Parameters + ---------- + y_true, y_pred: + 同形的一维数据,校验规则见 :func:`verify_yt_yp`。 + + Returns + ------- + float + `mean(|y_true - y_pred| / y_true)`,`0.1` 表示平均偏差 10%。 + + Raises + ------ + ValueError + 形状不一致、为空、含 `NaN`,或 `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") + return float(np.mean(np.abs(yt - yp) / yt)) def smape(y_true: Any, y_pred: Any) -> float: - """对称 MAPE,y 含 0 也安全。""" - # TODO(L1) - raise NotImplementedError + """对称 MAPE。`y_true` / `y_pred` 含 0 或为 0 都安全,是 MAPE 的替代品。 + + Parameters + ---------- + y_true, y_pred: + 同形的一维数据,校验规则见 :func:`verify_yt_yp`。 + + Returns + ------- + float + `mean(2 * |y_true - y_pred| / (|y_true| + |y_pred|))`。 + 分子分母同时为 0 的点**记 0**(该点无偏差可言),不计入分母。 + + Raises + ------ + ValueError + 形状不一致、为空或含 `NaN`。 + """ + yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred) + + denom = np.abs(yt) + np.abs(yp) + # 分母为 0 的点记 0。注意 np.where 的两条分支**都会被求值**,直接写除式会触发 + # "invalid value encountered in divide" 的 RuntimeWarning,而本项目 pytest 开了 + # filterwarnings=error::RuntimeWarning,那样测试会直接算失败;所以用 where= 掩码。 + ratio = np.divide(2 * np.abs(yt - yp), denom, out=np.zeros_like(denom), where=denom != 0.0) + return float(np.mean(ratio)) def r2_score(y_true: Any, y_pred: Any) -> float: - """决定系数 R²。注意:加变量一定会升高,跨模型比较要看 adjusted_r2。""" - # TODO(L1) - raise NotImplementedError + """决定系数 R²。加变量一定会升高,跨模型比较要看 :func:`adjusted_r2`。 + + Parameters + ---------- + y_true, y_pred: + 同形的一维数据,校验规则见 :func:`verify_yt_yp`。 + + Returns + ------- + float + `1 - SS_res / SS_tot`,其中 `SS_res = sum((y_true - y_pred) ** 2)`、 + `SS_tot = sum((y_true - mean(y_true)) ** 2)`,基准是 `y_true` 的均值。 + 完美拟合约等于 1,比"直接取均值"还差时为负。 + + Raises + ------ + ValueError + 形状不一致、为空、含 `NaN`,或 `y_true` 是常量(`SS_tot = 0`,分母为 0)。 + """ + yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred) + + ss_res = np.sum((yt - yp) ** 2) + ss_tot = np.sum((yt - np.mean(yt)) ** 2) + + if ss_tot == 0: + raise ValueError("y_true 是常量,SS_tot = 0,无法计算 R²") + return float(1 - (ss_res / ss_tot)) -def adjusted_r2(y_true: Any, y_pred: Any, n_features: int) -> float: - """调整 R²,惩罚变量个数。""" - # TODO(L1) - raise NotImplementedError +def adjusted_r2(y_true: Any, y_pred: Any, n_features: int | np.integer) -> float: + """调整 R²,按自由度惩罚变量个数;同一样本量下比 R² 公平。 + Parameters + ---------- + y_true, y_pred: + 同形的一维数据,校验规则见 :func:`verify_yt_yp`。 + n_features: + **自变量个数,不含截距**(`y = 2x + 1` 只有 1 个自变量)。 + 必须为正整数;`bool` 会被挡掉(`True` 看着像 1,但不是个数)。 + + Returns + ------- + float + `1 - (1 - R²) * (n - 1) / (n - n_features - 1)`,`n = len(y_true)`。 + 比 R² 更保守:`n_features` 越大惩罚越重,所以它才是跨模型比较该看的那个。 + + Raises + ------ + TypeError + `n_features` 不是整数(含 `bool`、`float`、`np.float64`)。 + ValueError + `n_features <= 0`;`n <= n_features + 1`(自由度为 0,分母为 0); + 或 `y_true` / `y_pred` 未通过 :func:`verify_yt_yp`。 + """ + yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred) + + if isinstance(n_features, bool) or not isinstance(n_features, (int, np.integer)): + raise TypeError(f"n_features 必须是正整数,你传入了 {type(n_features).__name__}") + if n_features <= 0: + raise ValueError(f"n_features 必须是正整数,你传入了 {n_features}") + + r2 = r2_score(yt, yp) + n = len(yt) + + if n - n_features - 1 <= 0: + raise ValueError(f"样本量太小(n={n},自变量 {n_features} 个),分母会变成 0 或负数") + + return float(1 - ((1 - r2) * (n - 1) / (n - 1 - n_features))) def compare_models( models: dict[str, Any], @@ -92,3 +270,4 @@ def compare_models( """ # TODO(L1) raise NotImplementedError + diff --git a/tests/test_models.py b/tests/test_models.py index 2bd5814..9822921 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -3,6 +3,7 @@ from __future__ import annotations import numpy as np +import pandas as pd import pytest _NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L1" @@ -28,7 +29,6 @@ def test_fit_polynomial_matches_numpy(linear_data): assert np.allclose(r.params["coef"], np.polyfit(x, y, 2), rtol=1e-6) -@pytest.mark.skip(reason=_NOT_IMPLEMENTED) def test_metrics_known_values(): from mcm_toolkit.models import mae, r2_score, rmse @@ -39,7 +39,6 @@ def test_metrics_known_values(): assert r2_score(y_true, y_pred) < 1.0 -@pytest.mark.skip(reason=_NOT_IMPLEMENTED) def test_r2_is_one_for_perfect_fit(): from mcm_toolkit.models import r2_score @@ -61,3 +60,153 @@ def test_compare_models_returns_table_sorted_by_first_metric(linear_data): ) assert set(tbl.index) >= {"linear", "poly2"} assert "rmse" in tbl.columns + + +def test_metrics_reject_mismatched_shapes(): + from mcm_toolkit.models import rmse + + with pytest.raises(ValueError, match="形状必须一致"): + rmse([1.0, 2.0], [1.0, 2.0, 3.0]) + + +def test_metrics_reject_empty_input(): + from mcm_toolkit.models import rmse + + with pytest.raises(ValueError, match="不能为空"): + rmse(np.array([]), np.array([])) + + +def test_metrics_reject_nan_instead_of_dropping_it(): + """含 NaN 一律报错,**绝不静默删点**:删了样本量就变了,指标不可比。""" + from mcm_toolkit.models import rmse + + with pytest.raises(ValueError, match="NaN"): + rmse([1.0, np.nan, 3.0], [1.0, 2.0, 3.0]) + with pytest.raises(ValueError, match="NaN"): + rmse([1.0, 2.0, 3.0], [1.0, np.nan, 3.0]) + + +def test_metrics_reject_non_whitelisted_type(): + """`str` 也是可迭代对象,必须被入口白名单挡住,不能被当成三个字符算下去。""" + from mcm_toolkit.models import rmse + + with pytest.raises(TypeError): + rmse("abc", "abc") # type: ignore[arg-type] + + +@pytest.mark.parametrize("name", ["rmse", "mae", "mape", "smape", "r2_score"]) +def test_metrics_agree_across_input_types(name): + """`DataFrame` / `Series` / `ndarray` / `list` 四种入参必须给出同一个数。""" + import mcm_toolkit.models as models + + yt = [1.0, 2.0, 3.0, 4.0] + yp = [1.1, 1.9, 3.2, 3.8] + fn = getattr(models, name) + base = fn(yt, yp) + + assert np.isclose(fn(np.array(yt), np.array(yp)), base) + assert np.isclose(fn(pd.Series(yt), pd.Series(yp)), base) + 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。""" + from mcm_toolkit.models import mape + + with pytest.raises(ValueError, match="不能含 0"): + mape([0.0, 2.0], [1.0, 2.0]) + + +def test_smape_is_silent_when_denominator_is_zero(): + """分子分母同时为 0 的点记 0,且**不能漏出 RuntimeWarning**。 + + 本仓库 `pyproject.toml` 里开了 `filterwarnings = ["error::RuntimeWarning"]`, + 所以这条测试同时守着"零除"这个坑:`np.where` 的两条分支都会被求值, + 直接写除式就会把告警漏出来,测试会红。 + """ + from mcm_toolkit.models import smape + + assert smape([0.0, 0.0], [0.0, 0.0]) == 0.0 + assert smape([0.0, 2.0], [0.0, 2.0]) == 0.0 + assert np.isclose(smape([1.0, 1.0], [0.0, 1.0]), 1.0) + + +def test_adjusted_r2_rejects_invalid_n_features(): + from mcm_toolkit.models import adjusted_r2 + + yt = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + yp = np.array([1.5, 2.5, 2.5, 4.5, 5.5]) + + for bad in (True, 1.0, np.float64(1.0)): + with pytest.raises(TypeError): + adjusted_r2(yt, yp, bad) # type: ignore[arg-type] + with pytest.raises(ValueError, match="正整数"): + adjusted_r2(yt, yp, 0) + with pytest.raises(ValueError, match="样本量太小"): + adjusted_r2(yt, yp, 4) # n = 5,分母 n - k - 1 = 0 + + +def test_adjusted_r2_accepts_numpy_integer(): + from mcm_toolkit.models import adjusted_r2 + + yt = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + yp = np.array([1.5, 2.5, 2.5, 4.5, 5.5]) + + assert adjusted_r2(yt, yp, np.int64(1)) == adjusted_r2(yt, yp, 1) + + +def test_adjusted_r2_penalises_extra_features(): + """同一份数据、同一个 R²,自变量越多,调整 R² 越小(没用的变量只吃惩罚)。""" + from mcm_toolkit.models import adjusted_r2 + + yt = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]) + yp = np.array([1.2, 2.1, 3.3, 3.8, 5.1, 6.2]) + + assert adjusted_r2(yt, yp, 3) < adjusted_r2(yt, yp, 2) < adjusted_r2(yt, yp, 1) + + +def test_r2_matches_analytic_formula(): + """`R² = 1 - SS_res / SS_tot`,且 `SS_tot` 的基准必须是 **y_true 的均值**。 + + 这里刻意用**非完美拟合**的数据:完美拟合(R² = 1)和常量 y(R² = 0)都能把 + "基准量抄错对象"这类 bug 掩盖掉(个人易错点 31)。 + """ + from mcm_toolkit.models import r2_score + + yt = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + yp = np.array([1.5, 2.5, 2.5, 4.5, 5.5]) + ss_res = np.sum((yt - yp) ** 2) + ss_tot = np.sum((yt - np.mean(yt)) ** 2) + + assert np.isclose(r2_score(yt, yp), 1 - ss_res / ss_tot) + assert np.isclose(r2_score(yt, yp), 0.875) + + +def test_r2_matches_sklearn(): + """拿成熟库当**独立的参照物**:自实现指标最容易"自己算自己"。""" + sk = pytest.importorskip("sklearn.metrics") + + from mcm_toolkit.models import r2_score + + rng = np.random.default_rng(0) + yt = rng.normal(size=50) + yp = yt + rng.normal(scale=0.5, size=50) + + assert np.isclose(r2_score(yt, yp), sk.r2_score(yt, yp)) + + +def test_r2_is_negative_when_worse_than_predicting_the_mean(): + from mcm_toolkit.models import r2_score + + assert np.isclose(r2_score([1.0, 2.0, 3.0], [3.0, 2.0, 1.0]), -3.0) + + +def test_adjusted_r2_matches_analytic_formula(): + from mcm_toolkit.models import adjusted_r2, r2_score + + yt = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]) + yp = np.array([1.2, 2.1, 3.3, 3.8, 5.1, 6.2]) + n, k = len(yt), 2 + r2 = r2_score(yt, yp) + + assert np.isclose(adjusted_r2(yt, yp, k), 1 - (1 - r2) * (n - 1) / (n - k - 1))