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
@@ -613,6 +613,37 @@
|
|||||||
`markov_steady_state` 未收敛时是否带 `data`、非方阵的 `TypeError` / `ValueError` 不一致、
|
`markov_steady_state` 未收敛时是否带 `data`、非方阵的 `TypeError` / `ValueError` 不一致、
|
||||||
`mm1_metrics` 的 `data` 该不该是 dict。
|
`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
|
## [0.0.1] - 2026-10-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# STATUS — 当前进度
|
# STATUS — 当前进度
|
||||||
|
|
||||||
> 更新日期:2026-10-05
|
> 更新日期:2026-10-10
|
||||||
|
|
||||||
## 本次任务
|
## 本次任务
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@
|
|||||||
`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`)
|
||||||
|
+ 共享入口 `verify_yt_yp` **由用户写**、Codex 审(第九轮);同文件只剩 `compare_models` 未实现。
|
||||||
`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 条细枝末节(详见"下一步"第八轮)。
|
||||||
@@ -393,7 +395,7 @@
|
|||||||
|
|
||||||
| 检查 | venv | conda `mcm-toolkit` |
|
| 检查 | 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 项 |
|
| `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` | 跑通 | — |
|
||||||
@@ -403,9 +405,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(与两套环境一致)
|
||||||
- **232 条用例:204 passed / 28 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、
|
- **250 条用例:224 passed / 26 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_core.py` 23、`test_solve.py` 64、`test_viz.py` 8、`test_decision.py` 7、
|
||||||
`test_stats.py` 53、`test_models.py` 5
|
`test_stats.py` 53、`test_models.py` 23(第九轮 5 → 23,指标测试转正 + 18 条新增)
|
||||||
- **`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`
|
||||||
@@ -609,8 +611,58 @@ docstring 与既有测试写的都是 `λ >= μ` 不稳定,代码却写 `servi
|
|||||||
**2026-10-10 更新**:① ② ④ 办结(`λ = 0` 放开为合法空系统、两个速率收 `np.number`、失败分支报 `rho`);
|
**2026-10-10 更新**:① ② ④ 办结(`λ = 0` 放开为合法空系统、两个速率收 `np.number`、失败分支报 `rho`);
|
||||||
③ 只办了一半(`data=metrics.copy()` 去掉了同一个 dict 对象的别名,但 `data` 仍是 dict、失败时仍是 `None`)—— 只剩这一条。
|
③ 只办了一半(`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 第八轮)
|
### ⭐ 当前进度与待办(2026-10-10 第八轮)
|
||||||
|
|
||||||
**本轮:你按第七轮的清单改了 5 处口径** —— `markov_simulate`(非负校验带容差、收 `np.integer`、赋值挪进 `try`、
|
**本轮:你按第七轮的清单改了 5 处口径** —— `markov_simulate`(非负校验带容差、收 `np.integer`、赋值挪进 `try`、
|
||||||
|
|||||||
@@ -518,3 +518,29 @@
|
|||||||
把"要一起改的地方"列出来再动手;② 改完立刻跑 `pytest` —— 红的测试就是"过期契约"的清单;
|
把"要一起改的地方"列出来再动手;② 改完立刻跑 `pytest` —— 红的测试就是"过期契约"的清单;
|
||||||
③ docstring 的 `Returns` / `Raises` 属于**契约的一部分**,和代码同一次改完,别留到"下次顺手"。
|
③ docstring 的 `Returns` / `Raises` 属于**契约的一部分**,和代码同一次改完,别留到"下次顺手"。
|
||||||
- 首次:2026-10-10|复发:0
|
- 首次: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
|
||||||
@@ -5,12 +5,19 @@
|
|||||||
|
|
||||||
注意:本模块被 `models` 与 `solve` 共用,属于"公共数学",但为了少建一个包,
|
注意:本模块被 `models` 与 `solve` 共用,属于"公共数学",但为了少建一个包,
|
||||||
放在 `models` 下;`solve` 单向依赖 `models` 是允许的。
|
放在 `models` 下;`solve` 单向依赖 `models` 是允许的。
|
||||||
|
|
||||||
|
本模块的函数**只返回 `float`,不返回 `Result`**(架构 §4.1 只覆盖拟合/求解/
|
||||||
|
模拟/评价);入口非法一律抛异常,见各函数的 `Raises`。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from mcm_toolkit.core.validate import as_array
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"adjusted_r2",
|
"adjusted_r2",
|
||||||
"compare_models",
|
"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:
|
def rmse(y_true: Any, y_pred: Any) -> float:
|
||||||
"""均方根误差。与 y 同量纲,最常用。"""
|
"""均方根误差。与 y 同量纲、对大误差敏感,回归里最常用的第一指标。
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
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:
|
def mae(y_true: Any, y_pred: Any) -> float:
|
||||||
"""平均绝对误差。对离群值比 RMSE 稳健。"""
|
"""平均绝对误差。对离群值比 RMSE 稳健,和 RMSE 一起看能判断有无离群点。
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
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:
|
def mape(y_true: Any, y_pred: Any) -> float:
|
||||||
"""平均绝对百分比误差。无量纲,但 y 含 0 时会炸(用 smape 替代)。"""
|
"""平均绝对百分比误差。无量纲,可跨量纲比较;`y_true` 含 0 时无定义。
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
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:
|
def smape(y_true: Any, y_pred: Any) -> float:
|
||||||
"""对称 MAPE,y 含 0 也安全。"""
|
"""对称 MAPE。`y_true` / `y_pred` 含 0 或为 0 都安全,是 MAPE 的替代品。
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
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:
|
def r2_score(y_true: Any, y_pred: Any) -> float:
|
||||||
"""决定系数 R²。注意:加变量一定会升高,跨模型比较要看 adjusted_r2。"""
|
"""决定系数 R²。加变量一定会升高,跨模型比较要看 :func:`adjusted_r2`。
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
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:
|
def adjusted_r2(y_true: Any, y_pred: Any, n_features: int | np.integer) -> float:
|
||||||
"""调整 R²,惩罚变量个数。"""
|
"""调整 R²,按自由度惩罚变量个数;同一样本量下比 R² 公平。
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
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(
|
def compare_models(
|
||||||
models: dict[str, Any],
|
models: dict[str, Any],
|
||||||
@@ -92,3 +270,4 @@ def compare_models(
|
|||||||
"""
|
"""
|
||||||
# TODO(L1)
|
# TODO(L1)
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
+151
-2
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
_NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L1"
|
_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)
|
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():
|
def test_metrics_known_values():
|
||||||
from mcm_toolkit.models import mae, r2_score, rmse
|
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
|
assert r2_score(y_true, y_pred) < 1.0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
|
||||||
def test_r2_is_one_for_perfect_fit():
|
def test_r2_is_one_for_perfect_fit():
|
||||||
from mcm_toolkit.models import r2_score
|
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 set(tbl.index) >= {"linear", "poly2"}
|
||||||
assert "rmse" in tbl.columns
|
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))
|
||||||
Reference in new issue
Block a user