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:
1 parent
9907289ec8
commit
748affb7f0
5 files changed
+413
-49
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 第八轮)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 = 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)
|
||||
+147
-12
@@ -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)
|
||||
Reference in new issue
Block a user