diff --git a/CHANGELOG.md b/CHANGELOG.md index bfc0f95..b6aa593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -588,6 +588,31 @@ 非负校验的**容差**、未收敛却带 `data`)、`mm1_metrics` 4 条(`λ = 0`、numpy 标量、`data=metrics`、 失败分支丢 `rho`)。详见 `STATUS.md` 决策 56 / 57 / 58。 +### Added +- `tests/test_solve.py`:本轮再 +2(`markov_simulate` 的 `terminal_distribution` 口径、 + `mm1_metrics` 的 `λ = 0` 空系统),并把受口径变更影响的 5 条测试同步到新行为。 + +### Changed +- `markov_simulate`:非负校验带容差 `-1e-12`(浮点归一化的 `-1e-16` 残渣不再被误判); + `initial` 收 `np.integer`;`paths` 构造与首列赋值挪进 `try`(长度不匹配 / 状态越界不再裸抛); + `params` 键 `n_steps` → `steps`、`initial` 存成可 JSON 化的标量 / 列表; + `metrics` 的 `std_terminal` → `terminal_distribution`(`{状态: 频率}`,只列出现过的状态)。 +- `markov_steady_state`:非负校验带容差;`max_iter` 类型错改抛 `TypeError`;`tol` 增加 `np.isnan` 拦截; + 非方阵改抛 `TypeError`。 +- `mm1_metrics`:`arrival_rate` 放开为**非负**(`λ = 0` = 空系统,`L = Lq = Wq = 0`、`W = 1/μ` 取极限值); + 两个速率收 `np.number`;不稳定分支保留 `rho` 并写进 message;`data=metrics.copy()`(去掉同一个 dict 对象的别名)。 +- docstring 同步:`markov_simulate` 的 `Returns` 与"遗留"段、`markov_steady_state` 的 `Raises`(TypeError / ValueError 分工)、 + `mm1_metrics` 的 `Parameters` / `Returns` / `Raises`。 +- ruff:`B905`(`zip` 缺 `strict`)+ 2 条空白 → 0;`markov_simulate` 里 `rng` 之后的双空行 → 1。 +- `STATUS.md`:决策 56 / 57 / 58 更新到"只剩 5 条小口径",「下一步」推进到第八轮, + 并写入**第八轮备忘:`models` 与 `decision` 各是干什么的**(含两包的 19 / 8 个函数清单、使用顺序、建议开工顺序)。 +- `docs/dev/personal-pitfalls.md`:新增易错点 30(口径一改,docstring / 测试 / STATUS 三处会同时过期)。 + +### Notes +- **只剩 5 条口径待拍板**(Codex 只报不改):`markov_simulate` 的 `bool` 漏网与 `initial` 的 A/B/C、 + `markov_steady_state` 未收敛时是否带 `data`、非方阵的 `TypeError` / `ValueError` 不一致、 + `mm1_metrics` 的 `data` 该不该是 dict。 + ## [0.0.1] - 2026-10-01 ### Added diff --git a/STATUS.md b/STATUS.md index c89e65c..3a153b0 100644 --- a/STATUS.md +++ b/STATUS.md @@ -26,7 +26,7 @@ 剩余 1 项:`save_figure`(Codex 写)。 `monte_carlo` 的口径已拍板(决策 48);`monte_carlo_probability` / `random_walk` 的待办**均已办结**; `markov_simulate`(决策 56)/ `markov_steady_state`(决策 57)/ `mm1_metrics`(决策 58) -**仍有一批入口口径待你拍板**(本轮各办结了一批,详见"下一步")。 +的入口口径**已办掉大半**,各剩 1~2 条细枝末节(详见"下一步"第八轮)。 另 3 个小口径仍悬着:`ci_low`/`ci_high` 的语义、`monte_carlo_integral` 的第一道 `isinstance` 分支可塌成两行、 `random_state` 注解与 `std` 的 `ddof` 是否全项目对齐。 @@ -337,6 +337,11 @@ `np.int64` 可收、docstring Example 真能跑 - **第二轮(2026-10-09)**:用户补了 `np.any(arr < 0)` 非负校验 → 待办 ② 办结; Codex 补一条"行和是 1 但含负元素"的回归测试 + - **第三轮(2026-10-10,用户改定)**:非负校验改带容差 `-1e-12`(⑦ 办结); + `initial` 注解放宽到 `(int, np.integer)`(`np.int64` 已收);`paths` 的构造与首列赋值**挪进 `try`**(③ 办结, + 长度不匹配/越界不再裸抛);`params` 的键 `n_steps` → `steps`(与参数名对齐)、`initial` 存成可 JSON 化的标量/列表; + `metrics` 去掉 `std_terminal`、换成 `terminal_distribution`(`{状态: 频率}` dict,⑤ 办结) + —— Codex 同步 docstring 与 3 条测试(含"只列出现过的状态"这条口径) - ⏳ **余下 4 条**见"下一步 → markov_simulate 待你拍板"(全是入口口径与少考虑情况,决策 49) - [x] `solve/simulate.py` 的 `markov_steady_state`(**L1,S 档,算法由用户本人写**,2026-10-09) @@ -357,7 +362,9 @@ `success=converged`(选口径 B,④ 办结)、`tol` 拆成 `TypeError` / `ValueError` 且先判 `bool`(② 办结); Codex 顺手修文案(`max_iter <= 0` 那条写成 "tol 必须是正数")与 `异常: ` → `异常:`, 并补 2 条测试(负元素矩阵、`max_iter=3` 未收敛走 `success=False`) - - ⏳ **余下 3 条 + 1 个新口子**见"下一步 → markov_steady_state 待你拍板"(决策 57) + - **第三轮(2026-10-10,用户改定)**:非负校验带容差 `-1e-12`(⑦ 办结);`max_iter` 类型错改抛 `TypeError`(⑤ 办结); + `tol` 加 `np.isnan` 拦截(③ 办结);非方阵改抛 `TypeError` + - ⏳ **只剩 2 条**(⑧ 未收敛带 `data`、非方阵的 `TypeError`/`ValueError` 与 `markov_simulate` 不一致)见"下一步"(决策 57) - [x] `solve/simulate.py` 的 `mm1_metrics`(**L1,S 档,算法由用户本人写**,2026-10-09) - 核心:闭式解 `ρ = λ/μ`、`L = ρ/(1-ρ)`、`Lq = ρ²/(1-ρ)`、`W = 1/(μ-λ)`、`Wq = W - 1/μ`; @@ -374,7 +381,11 @@ - **测试:+6 条、摘掉 2 条 skip**(此前只有 2 条挂着的 skip)—— 闭式公式逐项对拍(含 `W = Wq + 1/μ`)、 `λ == μ` 与 `λ > μ` 都判失败且带"不稳定"文案、入口(0 / 负数 / `bool` / 非数值)、 `λ == μ` 不能靠 `ZeroDivisionError` 落到失败分支、docstring Example - - ⏳ **留给你的 4 条**见"下一步 → mm1_metrics 待你拍板"(决策 58) + - **第三轮(2026-10-10,用户改定)**:`arrival_rate` 放开到"非负"(`λ = 0` 空系统合法,① 办结)、 + 两个速率都收 numpy 标量(`np.number`,② 办结)、不稳定分支**保留 `rho`** 并写进 message(④ 办结)、 + `data=metrics.copy()` 去掉同一个 dict 对象的别名(③ 部分办结:仍是 dict,只是不再别名) + —— Codex 同步 docstring 与 4 条测试 + - ⏳ **只剩 1 条**(`data` 该不该是 dict)见"下一步 → mm1_metrics 待你拍板"(决策 58) - [ ] 剩余 1 项:`save_figure`(A 档 = 工程,**Codex 直接写**) @@ -382,7 +393,7 @@ | 检查 | venv | conda `mcm-toolkit` | |---|---|---| -| `pytest` | 见下(单列) | **202 passed, 28 skipped(0 xfail)** | +| `pytest` | 见下(单列) | **204 passed, 28 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` | 跑通 | — | @@ -392,8 +403,8 @@ (修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与 `docs/04-environment.md` 坑 7) - base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致) -- **230 条用例:202 passed / 28 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、 - `test_core.py` 23、`test_solve.py` 62(本轮 +4、再摘掉 2 条 skip)、`test_viz.py` 8、`test_decision.py` 7、 +- **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 - **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正 - 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环 @@ -567,8 +578,11 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 `np.int64(0)` 掉进数组分支、被 `as_array` 拒(与 `steps`/`n_paths` 收 `np.integer` 不一致,易错点 1 复发); `True` 因 `isinstance(True, int)` 为真被当状态 1 **静默放行**;越界状态(如 `initial=5`、`s=2`)只在 第一轮索引时才炸、被 `except` 吞成 `success=False`。 -**2026-10-09 更新**:② 已办结(用户补了 `np.any(arr < 0)`,含负元素的行随机矩阵现在入口直接报错); -① ③ ④ ⑤ 仍待拍板(A / B / C 三个选项见"下一步")。 +**2026-10-09 更新**:② 已办结(用户补了 `np.any(arr < 0)`,含负元素的行随机矩阵现在入口直接报错)。 +**2026-10-10 更新**:③ 办结(`paths` 的构造与首列赋值挪进 `try`,长度不匹配/越界不再裸抛); +④ 办结(越界现在被吞成 `success=False`,但仍没在入口拦);⑤ 办结(`std_terminal` → `terminal_distribution`); +② 升级为带容差 `-1e-12`;`params` 的 `n_steps` → `steps`、`initial` 存成可 JSON 化的值。 +**只剩 ①**:`bool` 仍被当状态 1(`np.integer` 已收),以及"数组按状态数 vs 按路径数"两条口径的矛盾(A / B / C)。 | 57 | `markov_steady_state` 的口径:**4 条已办、3 条 + 2 个新口子仍待拍板**(Codex 只报选项,未代改,决策 49) | **2026-10-09 用户第二轮办结**:① 补 `np.any(arr < 0)`(含负元素的行随机矩阵现在入口即报错,原先会溢出成 `nan` @@ -579,6 +593,8 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 ⑦ **两把尺子宽严不一**——非负校验用精确 `arr < 0`,同一个函数里的行和校验却用宽容的 `np.allclose`, 实测 `[[1.0, -1e-16], [0.5, 0.5]]`(行和都是 1)会被判非法,建议改成 `< -1e-12` 这类容差; ⑧ 未收敛时 `success=False` 但 `data` 仍是未收敛向量(文件内唯一"失败却带 `data`"的分支)—— 保持还是 `data=None`? +**2026-10-10 更新**:③(`tol` 拦 NaN)、⑤(`max_iter` 类型错 → `TypeError`)、⑦(非负校验带容差)办结。 +**只剩 2 条**:⑧(上面那条),以及新出现的"非方阵抛 `TypeError`,而 `markov_simulate` 的同一条校验抛 `ValueError`"不一致。 | 58 | `mm1_metrics` 的稳定性分界由 Codex 按**决策 31** 直接修(`<` → `<=`),另有 4 条口径待用户拍板 | docstring 与既有测试写的都是 `λ >= μ` 不稳定,代码却写 `service_rate < arrival_rate` —— 于是 `λ == μ` @@ -590,81 +606,74 @@ docstring 与既有测试写的都是 `λ >= μ` 不稳定,代码却写 `servi 整数参数收 `np.integer`,§4.7 有共识);③ `data=metrics` —— **同一个 dict 对象**(`r.data is r.metrics`), 且成功分支给 dict、失败分支给 `None`,与文件内"`data` 是数组"的习惯不同; ④ 失败分支的 `metrics={}` 会丢掉 `rho` 等已算出的量(可选保留)。 +**2026-10-10 更新**:① ② ④ 办结(`λ = 0` 放开为合法空系统、两个速率收 `np.number`、失败分支报 `rho`); +③ 只办了一半(`data=metrics.copy()` 去掉了同一个 dict 对象的别名,但 `data` 仍是 dict、失败时仍是 `None`)—— 只剩这一条。 ## 下一步 -### ⭐ 当前进度与待办(2026-10-09 第七轮) +### ⭐ 当前进度与待办(2026-10-10 第八轮) -**本轮:你改了 `markov_simulate` / `markov_steady_state` 的入口,并写完 `mm1_metrics`** —— 三处算法本体都正确。 -Codex 只做工程侧:`mm1_metrics` 修了一处**边界 bug**(`<` → `<=`,见决策 58)、ruff 6 条、4 处文案参数名抄错、 -docstring 补 `Raises`、**+6 条测试 + 摘掉 2 条 skip**。 -三连:`pytest` **202 passed / 28 skipped**、`ruff` 0、`pyright` 0。`solve` 层 L1 清单**已全部落地**。 +**本轮:你按第七轮的清单改了 5 处口径** —— `markov_simulate`(非负校验带容差、收 `np.integer`、赋值挪进 `try`、 +`metrics` 换成 `terminal_distribution`)、`markov_steady_state`(容差、`max_iter` 的 `TypeError`、 +`tol` 拦 NaN)、`mm1_metrics`(`λ = 0` 放开、收 numpy 标量、失败分支报 `rho`、`data` 去别名)。 +**三处算法本体仍然正确**(探针复测:终点分布 `{0: 0.6644, 1: 0.3356}` 对照 `[2/3, 1/3]`; +`λ = 0` 给 `L = Lq = Wq = 0`、`W = 1/μ`;`r.data is r.metrics` 已为 `False`)。 +Codex 只同步 docstring + 6 条测试、清 ruff(`B905` 的 `zip` 缺 `strict` + 2 条空白)。 +三连:`pytest` **204 passed / 28 skipped**、`ruff` 0、`pyright` 0。 **接下来三件事** -1. **三个函数的口径待你拍板**:`markov_simulate` 4 条(决策 56)、`markov_steady_state` 3+2 条(决策 57)、 - `mm1_metrics` 4 条(决策 58)—— 全在下面三节。 +1. 下面只剩 **5 条小口径**,基本都是"一个值 / 一个类型"级别的决定。 2. L0 最后一项 `save_figure`(A 档 = 工程,`viz/export.py`)—— **Codex 直接实现**,你只读。 -3. 之后就往外层走:L1 只剩 `decision` / `models` / `stats` 的几条。 +3. 之后往 `models` / `decision` 走(见下方第八轮备忘)。 -### ⏳ markov_simulate 待你拍板(4 条) +### ⏳ 只剩这 5 条口径待你拍板(Codex 只报不改) -**① `initial` 的语义(最大的一条,三个选项)** -现在标量分支按**路径数**铺满、数组分支按**状态数**校验长度,两条口径互相矛盾(详见决策 56)。 -- **A**:只收标量状态,另加 `initial_dist` 参数显式传"初始分布"(分布再抽一次,语义最干净,签名变长) -- **B**:只收长度 `n_paths` 的数组(标量走 `np.full` 铺满),`data` 首列就是给定的初始状态序列(与 `paths[:, 0]` 赋值天然一致) -- **C**:沿用现状,但把类型收成 `int | np.integer` + 先判 `bool`,把 `paths[:, 0] = ...` 挪进 `try`,docstring 写清"数组按路径数" +**① `markov_simulate` 的 `bool` 漏网**:`isinstance(True, (int, np.integer))` 为真 → `initial=True` 被当成状态 1。 +实测现在**不再静默给错答案**(`np.full(n_paths, True)` 让后面 `arr[current_states]` 变成布尔掩码、崩掉后吞成 `success=False`), +但 message 是个看不懂的广播错误 —— 建议入口先判 `bool` → `TypeError`,把易错点 1 的最后一格补上。 -~~**② 转移矩阵没查非负**~~ → **已办(2026-10-09)**:入口加了 `np.any(arr < 0)` → `ValueError`, -测试里钉了"行和是 1 但含负元素"这条。余下的一小问:这条校验是**精确**判负,见决策 57 的 ⑦(容差)。 +**② `markov_simulate` 数组分支的长度口径**(决策 56 的 ①,仍是最大的那条):标量按**路径数**铺满、数组按**状态数**校验, +两条对不上(只有 `n_paths == s` 才通)。A / B / C 三个选项见决策 56。 -**③ `paths[:, 0]` 赋值在 `try` 之外**:长度不匹配时裸抛 `ValueError`,与"出错统一 `success=False`"(决策 48)不一致。 +**③ `markov_steady_state` 的 `data`**:`converged=False` → `success=False`,但 `data` 仍是未收敛向量 +(文件内唯一"失败却带 `data`"的分支)。保持(B)还是 `data=None`(C)? -**④ 状态越界没在入口拦**:`initial=5`(`s=2`)第一轮才 `IndexError`,被吞成 `success=False`; -建议入口加 `0 <= initial < s`。 +**④ 非方阵的异常类型两处不一致**:`markov_steady_state` 抛 `TypeError`、`markov_simulate` 抛 `ValueError` +(同一条"不是二维方阵"的校验)。numpy 自己对形状问题抛的是 `ValueError`;要统一成哪个? -**⑤ `std_terminal` 对分类状态的意义**:状态编号是标称量,均值和标准差只对"有序状态"(如排队人数)成立 —— -要不要把 `metrics` 换成状态频率(`terminal_freq`)?你定。 +**⑤ `mm1_metrics` 的 `data`**:现在是 `metrics.copy()`(不再是同一个对象),但仍是 **dict**,失败分支是 `None`。 +选项:**A** 保持;**B** `data=None`;**C** `data` 给一个 5 元素数组。 -### ⏳ markov_steady_state 待你拍板(3 条 + 2 个新口子) +### 📦 第八轮备忘:`models` 与 `decision` 是干什么的 -- ~~**① 转移矩阵没查非负**~~ → **已办(2026-10-09)** -- ~~**② `tol` 没查 `bool`**~~ → **已办**:`tol` 现在拆成 `TypeError`(类型 / `bool`)与 `ValueError`(取值) -- ~~**④ `converged=False` 仍 `success=True`**~~ → **已办**:选了**口径 B**,`success=converged` -- ~~**⑥ `params` 没记转移矩阵**~~ → **已办**:补了 `"transition": arr.tolist()` +这两个包**目前一行都没实现**(全部 `TODO` + `NotImplementedError`),是 `solve` 之后的两大块。分工完全不同: -**③ `tol` 没查 NaN**:`np.nan <= 0` 为假 → 穿过校验 → 白跑满 10000 次迭代才返回(结果本身对,纯浪费)。 -建议与 `max_iter` 一起走"类型 → 有限性 → 取值"三件套(易错点 28)。 +| | `models` —— 建模 | `decision` —— 评价决策 | +|---|---|---| +| 回答的问题 | "这条规律是什么?能不能预测?" | "这几个方案哪个好?" | +| 输入 | 特征 `X` + 目标 `y`(有监督)或只有 `X`(聚类/降维) | **决策矩阵**(行 = 方案、列 = 指标),通常无标签 | +| 输出 | 模型对象 + 预测值 + 指标;`compare_models` 出对比表 | 权重向量 + 排序表(`rank`) | +| 主要依赖 | sklearn / statsmodels(**薄封装**) | numpy 手写为主(S 档:熵权、AHP、GM(1,1)、TOPSIS) | +| 美赛场景 | 预测题(人口、销量、气温)、"要不要上复杂模型" | 中文赛 / ICM 的评价题(AHP、TOPSIS、灰色关联、熵权) | +| roadmap 时间盒 | 第 2–3 周 | 第 4 周 | -**⑤ `max_iter` 的类型错仍抛 `ValueError`**(`max_iter=100.0` / `max_iter=True`), -与文件内其它函数(决策 54:类型错 `TypeError`、取值错 `ValueError`)不一致 —— 要不要对齐? +`models`(19 个函数,5 个文件):`base.py` 已写好 `Model` 协议(`fit / predict / score` 三件套,已实现); +`metrics.py` 是 8 个指标 + `compare_models`(返回 DataFrame,可直接 `save_table(fmt="latex")`); +`regression.py` / `timeseries.py` / `ml.py` 全是"一行转发 + 参数校验 + `Result` 包装"。 +注意两处:① `rmse` / `mae` / `r2_score` 的注释写的是 `TODO(L0)`,但 roadmap 的 L0 12 项里没有它们, +它们属于 L1 的 `models` 清单 —— 注释待统一;② `tests/test_models.py` 用的是**模块级** `pytestmark`, +违反决策 26(逐条 skip),开写时要拆开。 -**⑦【新】非负校验的两把尺子**:`arr < 0` 是**精确**判断,而同一个函数里的行和校验用的是宽容的 `np.allclose`。 -实测 `[[1.0, -1e-16], [0.5, 0.5]]`(行和都是 1.0,只是浮点归一化的残渣)会被判"含负元素"而报错。 -建模时转移矩阵常常是从数据归一化出来的,这种 `-1e-17` 级残渣很常见 —— 建议改成 `np.any(arr < -1e-12)` -(或 `arr.min() < -1e-12`)这样的容差写法。`markov_simulate` 的同名校验也适用。 +`decision`(8 个函数,4 个文件):`weighting.py`(等权 / 熵权 / CRITIC)、`ahp.py`(判断矩阵 + 一致性比率 `CR`)、 +`topsis.py`、`grey.py`(灰色关联 + GM(1,1) 预测)。 +**使用顺序是硬规矩**(`decision/__init__.py` 里写着):先 `normalize` 无量纲化 → 再声明方向 `benefit_columns` +→ 再 `entropy_weights` / `ahp_weights` → 最后 `topsis`。跳过第一步直接算权重是评价类题最常见的翻车方式。 +S 档(要配函数卡片)三个:`entropy_weights` / `ahp_weights` / `gm11_fit_predict`,另有 `topsis` 也标了卡片。 +永久不做:`dea_efficiency`(要解一串 LP、低频)、`fuzzy_comprehensive_evaluation`(隶属度函数没有通用形式)。 -**⑧【新】未收敛时 `success=False` 但 `data` 仍是未收敛向量**:这是文件里唯一"失败却带 `data`"的分支 -(其它失败分支一律 `data=None`)。调用方若只看 `data is not None` 就会拿到没收敛的结果 —— 保持(B)还是改 `data=None`(C)? - -### ⏳ mm1_metrics 待你拍板(4 条) - -**① `λ = 0` 被拒**:现在 `arrival_rate <= 0` → `ValueError`。数学上 `λ = 0` 是合法退化解 -(没有顾客到达,`ρ = L = Lq = W = Wq = 0`)—— 要收还是继续拒? - -**② numpy 标量全被拒**:`np.int64(2)` / `np.float32(2.0)` → `TypeError`。 -文件内其它函数已按 §4.7 放宽(`random_walk` 的 `start` 收 `np.number`、计数参数收 `np.integer`), -这里还是 `(int, float)` —— 要不要对齐?(`np.float64` 本来就是 `float` 的子类,不受影响) - -**③ `data=metrics`**:`data` 和 `metrics` 是**同一个 dict 对象**(实测 `r.data is r.metrics` 为 `True`), -而且成功分支给 dict、失败分支给 `None`,与文件内"`data` 是样本数组"的习惯不同。选项:**A** 保持; -**B** `data=None`(闭式解没有样本数组);**C** 只留 `metrics`、`data` 给一个 5 元素数组。 - -**④ 失败分支丢掉了已算出的量**:`λ >= μ` 时 `metrics={}`,但 `ρ = λ/μ` 其实算得出来 -(`ρ >= 1` 本身就是"不稳定"的量化说明,论文里常要写)—— 要不要在失败分支里保留 `rho`? - -**你下一个要写的**:`solve` 这层已经齐了(`mm1_metrics` 落地后 L1 清单走完)。 -建议下一步挑 **`decision` / `models` 里你感兴趣的 L1 函数**开刀(E 节有清单), -或者先把上面三节的口径一次性拍完、Codex 批量收口 —— 两条路都行,你定。 +**建议的开工顺序**:先 `models/metrics.py`(纯函数、最容易、`compare_models` 是"要不要上复杂模型"的裁判, +也是 roadmap §四里 `models` 的完成标志)→ 再 `fit_linear` / `fit_polynomial`(把 `Model` 协议跑通) +→ 然后 `decision` 的 `entropy_weights`(S 档、20 行、三个坑:方向 / 量纲 / 零值)→ `topsis` → `ahp` → `gm11`。 ### ✅ random_walk 的 4 条待办(2026-10-05 已办结) diff --git a/docs/dev/personal-pitfalls.md b/docs/dev/personal-pitfalls.md index 1a538ff..d9bea6e 100644 --- a/docs/dev/personal-pitfalls.md +++ b/docs/dev/personal-pitfalls.md @@ -506,3 +506,15 @@ 写完回头对一眼"这几条的严格程度一致吗";② 阈值类比较(`<` / `<=`)**对着 docstring 读一遍**, 特别是 == 落在哪边;③ 复制粘贴报错文案时,逐个把参数名念出来。 - 首次:2026-10-09|复发:0 + +### 30. 改了口径(返回值 / 键名 / 分支),下游三处会同时过期 + +- 现象:`markov_simulate` 这一轮把 `metrics` 从 `std_terminal` 换成 `terminal_distribution`、 + `mm1_metrics` 的不稳定分支从 `metrics={}` 变成 `metrics={"rho"}`、`arrival_rate=0` 从"报错"变成"合法"。 + 代码本身没问题,但**同一轮的测试 5 条全红**(都在断言旧键名 / 旧分支),docstring 的 `Returns` 也还在写 `std_terminal`。 +- 为什么会错:改的时候注意力全在"新行为对不对",**看不见的契约**(测试断言、docstring、STATUS 的口径表)没人提醒你。 + 这三处过期不会报错,只会"文档说 A、代码做 B",过几天自己都分不清哪个是准的。 +- 以后怎么防:① 动返回值 / 键名 / 分支前,先 `rg` 一下这个名字在全仓的出现位置(`rg "std_terminal" `), + 把"要一起改的地方"列出来再动手;② 改完立刻跑 `pytest` —— 红的测试就是"过期契约"的清单; + ③ docstring 的 `Returns` / `Raises` 属于**契约的一部分**,和代码同一次改完,别留到"下次顺手"。 +- 首次:2026-10-10|复发:0 diff --git a/src/mcm_toolkit/solve/simulate.py b/src/mcm_toolkit/solve/simulate.py index 68b7fb5..810b76d 100644 --- a/src/mcm_toolkit/solve/simulate.py +++ b/src/mcm_toolkit/solve/simulate.py @@ -487,7 +487,8 @@ def markov_simulate( Returns ------- `Result`:`data` 是形状 `(n_paths, steps + 1)` 的整数状态序列,第 0 列是初始状态; - `metrics={"mean_terminal", "std_terminal"}`(终点状态的均值与标准差,`std` 用 `ddof=0`)。 + `metrics={"mean_terminal", "terminal_distribution"}` —— 前者是终点状态的均值, + 后者是 `{状态: 出现频率}` 的 dict(按终点统计,只含**实际出现过**的状态,没出现的状态没有 key)。 Raises ------ @@ -504,9 +505,10 @@ def markov_simulate( `cum_probs[:, -1] = 1.0` 是为了压掉浮点累加误差,避免随机数落在 `[1 - 1e-16, 1)` 时索引越界。 终点分布随步数收敛到平稳分布:这既是本函数的自检手段,也是论文里值得写一句的性质。 - 遗留(决策 56 待定):`initial` 现在靠 `isinstance(initial, int)` 区分"标量状态 / 状态数组", - `np.int64`、`bool` 会掉进数组分支,"按状态数校验长度"与"按路径数写入"也互相矛盾。 - 口径定下来后,上面 `initial` 这一节与本段随之改写。 + 遗留(决策 56):`initial` 靠 `isinstance(initial, (int, np.integer))` 区分"标量状态 / 状态数组", + `np.int64` 已收,但 `bool` 仍会被当成状态 1(`isinstance(True, int)` 为真); + 且数组分支"按**状态数**校验长度"与真正写进去时"要按**路径数**"仍互相矛盾 —— 只有 `n_paths == s` 才跑得通。 + 状态越界(如 `initial=5`、`s=2`)也要等第一轮索引才炸。口径定了这段随之改写。 Examples -------- @@ -521,8 +523,8 @@ def markov_simulate( arr, _ = as_array(transition) if arr.ndim != 2 or arr.shape[0] != arr.shape[1]: raise ValueError(f"transition 必须是二维方阵,你传入的形状是 {arr.shape}") - if np.any(arr < 0): - raise ValueError("transition 矩阵的元素必须是非负的(概率不能为负)") + if np.any(arr < -1e-12): + raise ValueError("transition 矩阵的元素必须是非负的") if not np.allclose(arr.sum(axis=1), 1.0): raise ValueError("transition 每一行的和必须是 1.0") s = arr.shape[0] @@ -538,24 +540,25 @@ def markov_simulate( rng = get_rng(random_state) - if isinstance(initial, int): + if isinstance(initial, (int,np.integer)): current_states = np.full(n_paths, initial) + initial_param = int(initial) else: current_states, _ = as_array(initial, ndim=1) if len(current_states) != s: raise TypeError(f"initial 的长度必须是 {s},而你传入的是 {len(current_states)}") - - paths = np.zeros((n_paths, steps + 1), dtype=int) - paths[:, 0] = current_states + initial_param = current_states.tolist() call_params = { - "n_steps": steps, + "steps": steps, "n_paths": n_paths, - "initial": initial, - "random_state": random_state, + "initial": initial_param, + "random_state": random_state } try: + paths = np.zeros((n_paths, steps + 1), dtype=int) + paths[:, 0] = current_states for t in range(steps): probs = arr[current_states] @@ -569,9 +572,12 @@ def markov_simulate( current_states = next_states terminal_positions = paths[:, -1] + unique, counts = np.unique(terminal_positions, return_counts=True) + terminal_dist = {int(state): float(count) / n_paths for state, count in zip(unique, counts, strict=True)} + metrics = { "mean_terminal": float(np.mean(terminal_positions)), - "std_terminal": float(np.std(terminal_positions)), + "terminal_distribution": terminal_dist } return Result( name="markov_simulate", @@ -612,9 +618,11 @@ def markov_steady_state(transition: Any, max_iter: int | np.integer = 10000, tol Raises ------ + TypeError: + `transition` 不是二维方阵;`max_iter` 不是整数;`tol` 不是数值。 ValueError: - `transition` 不是二维方阵、或某一行和不等于 1.0; - `max_iter` 不是正整数;`tol` 不是正数。 + `transition` 含负元素、或某一行和不等于 1.0; + `max_iter` 不是正整数;`tol` 不是正数(NaN 也算非法)。 Notes ----- @@ -639,19 +647,19 @@ def markov_steady_state(transition: Any, max_iter: int | np.integer = 10000, tol """ arr, _ = as_array(transition) if arr.ndim != 2 or arr.shape[0] != arr.shape[1]: - raise ValueError(f"transition 必须是二维方阵,你传入的形状是 {arr.shape}") - if np.any(arr < 0): + raise TypeError(f"transition 必须是二维方阵,你传入的形状是 {arr.shape}") + if np.any(arr < -1e-12): raise ValueError("transition 矩阵的元素必须是非负的(概率不能为负)") if not np.allclose(arr.sum(axis=1), 1.0): raise ValueError("transition 每一行的和必须是 1.0") if isinstance(max_iter, bool) or not isinstance(max_iter, (int, np.integer)): - raise ValueError(f"max_iter 必须是正整数,你传入了 {type(max_iter).__name__}") + raise TypeError(f"max_iter 必须是正整数,你传入了 {type(max_iter).__name__}") if max_iter <= 0: raise ValueError(f"max_iter 必须是正整数,你传入了 {max_iter}") if not isinstance(tol, (int, float)) or isinstance(tol, bool): raise TypeError(f"tol 必须是浮点数,你传入了 {type(tol).__name__}") - if tol <= 0: + if tol <= 0 or np.isnan(tol): raise ValueError(f"tol 必须是正数,你传入了 {tol}") s = arr.shape[0] @@ -706,21 +714,23 @@ def mm1_metrics(arrival_rate: float, service_rate: float) -> Any: Parameters ---------- arrival_rate, service_rate: - 到达率 λ 与服务率 μ。`λ >= μ` 时系统不稳定,队列长度无界—— - 这种情况下函数返回 `success=False` 并说明原因,而不是返回 `inf`。 + 到达率 λ 与服务率 μ,都收 `int` / `float` / numpy 标量。 + `λ` 允许为 0(空系统:`L = Lq = Wq = 0`,`W` 取极限值 `1/μ`); + `λ >= μ` 时系统不稳定、队列长度无界 —— 这种情况返回 `success=False` 并说明原因,而不是返回 `inf`。 Returns ------- `Result`:`metrics={"rho", "L", "Lq", "W", "Wq"}` (系统利用率、系统内平均顾客数、队列长度、平均逗留时间、平均等待时间); - `data` 与 `metrics` 同值(闭式解没有样本数组)。 + `data` 与 `metrics` 同值(闭式解没有样本数组),但**不是同一个对象**。 + 不稳定时 `success=False`、`metrics={{"rho"}}`(只报利用率)、`data=None`。 Raises ------ TypeError: `arrival_rate` / `service_rate` 不是数值(`bool` 也判非法)。 ValueError: - `arrival_rate` / `service_rate` 不是正数。 + `arrival_rate` 是负数;`service_rate` 不是正数。 Notes ----- @@ -734,27 +744,27 @@ def mm1_metrics(arrival_rate: float, service_rate: float) -> Any: >>> round(r.metrics["rho"], 4) 0.6667 """ - if not isinstance(arrival_rate, (int, float)) or isinstance(arrival_rate, bool): + if not isinstance(arrival_rate, (int, float, np.number)) or isinstance(arrival_rate, bool): raise TypeError(f"arrival_rate 必须是浮点数,你传入了 {type(arrival_rate).__name__}") - if arrival_rate <= 0: - raise ValueError(f"arrival_rate 必须是正数,你传入了 {arrival_rate}") - if not isinstance(service_rate, (int, float)) or isinstance(service_rate, bool): + if arrival_rate < 0: + raise ValueError(f"arrival_rate 必须是非负数,你传入了 {arrival_rate}") + if not isinstance(service_rate, (int, float, np.number)) or isinstance(service_rate, bool): raise TypeError(f"service_rate 必须是浮点数,你传入了 {type(service_rate).__name__}") if service_rate <= 0: raise ValueError(f"service_rate 必须是正数,你传入了 {service_rate}") - call_params = {"arrival_rate": arrival_rate, "service_rate": service_rate} + call_params = {"arrival_rate": float(arrival_rate), "service_rate": float(service_rate)} - if service_rate <= arrival_rate: + if arrival_rate >= service_rate: + rho = arrival_rate / service_rate return Result( name="mm1_metrics", params=call_params, - metrics={}, + metrics={"rho": float(rho)}, data=None, success=False, - message="系统不稳定:到达率 λ 大于等于服务率 μ,队列长度无界,无法计算闭式解。" + message=f"系统不稳定:λ >= μ,队列无界。当前利用率 rho={rho:.4f}" ) - try: rho = arrival_rate / service_rate L = rho / (1.0 - rho) # 系统内平均顾客数 @@ -775,7 +785,7 @@ def mm1_metrics(arrival_rate: float, service_rate: float) -> Any: name="mm1_metrics", params=call_params, metrics=metrics, - data=metrics, + data=metrics.copy(), success=True, message="M/M/1 排队系统指标计算成功。" ) diff --git a/tests/test_solve.py b/tests/test_solve.py index f451c53..4017fbb 100644 --- a/tests/test_solve.py +++ b/tests/test_solve.py @@ -428,7 +428,7 @@ def test_markov_simulate_absorbing_states_never_move(): assert r.success assert np.all(r.data == 1) assert r.metrics["mean_terminal"] == 1.0 - assert r.metrics["std_terminal"] == 0.0 + assert r.metrics["terminal_distribution"] == {1: 1.0} def test_markov_simulate_is_reproducible(): @@ -453,12 +453,25 @@ def test_markov_simulate_terminal_frequency_matches_stationary(): def test_markov_simulate_metrics_are_terminal_states(): + """`metrics` 只看终点:均值 + `{状态: 频率}`(频率之和为 1、与逐条统计一致)。""" from mcm_toolkit.solve import markov_simulate P = np.array([[0.9, 0.1], [0.2, 0.8]]) r = markov_simulate(P, initial=0, steps=50, n_paths=500, random_state=3) - assert r.metrics["mean_terminal"] == float(np.mean(r.data[:, -1])) - assert r.metrics["std_terminal"] == float(np.std(r.data[:, -1])) + terminal = r.data[:, -1] + dist = r.metrics["terminal_distribution"] + assert r.metrics["mean_terminal"] == float(np.mean(terminal)) + assert np.isclose(sum(dist.values()), 1.0) + for state, freq in dist.items(): + assert np.isclose(freq, float(np.mean(terminal == state))) + + +def test_markov_simulate_terminal_distribution_only_lists_seen_states(): + """只含实际出现过的状态(没出现的状态没有 key)—— docstring 已写明这条口径。""" + from mcm_toolkit.solve import markov_simulate + + r = markov_simulate(np.eye(3), initial=2, steps=5, n_paths=4, random_state=0) + assert r.metrics["terminal_distribution"] == {2: 1.0} def test_markov_simulate_rejects_bad_transition(): @@ -568,8 +581,8 @@ def test_markov_steady_state_accepts_numpy_integer(): def test_markov_steady_state_rejects_bad_transition(): from mcm_toolkit.solve import markov_steady_state - with pytest.raises(ValueError): - markov_steady_state(np.ones((2, 3)) / 3) + with pytest.raises(TypeError): + markov_steady_state(np.ones((2, 3)) / 3) # 形状不对 → TypeError(口径见决策 57) with pytest.raises(ValueError): markov_steady_state(np.array([[0.5, 0.4], [0.2, 0.8]])) with pytest.raises(ValueError): @@ -650,19 +663,33 @@ def test_mm1_rejects_unstable_system(): equal = mm1_metrics(arrival_rate=3, service_rate=3) assert equal.success is False assert "不稳定" in equal.message - assert equal.metrics == {} + assert equal.metrics == {"rho": 1.0} # 失败时也把 rho 报出来(决策 58 的 ④) + assert equal.data is None assert mm1_metrics(arrival_rate=3, service_rate=2).success is False +def test_mm1_allows_zero_arrival_rate_as_empty_system(): + """λ = 0 是合法退化解:空系统(L = Lq = Wq = 0,W 取极限值 1/μ)。""" + from mcm_toolkit.solve import mm1_metrics + + r = mm1_metrics(arrival_rate=0, service_rate=3) + assert r.success + assert r.metrics["rho"] == 0.0 + assert r.metrics["L"] == 0.0 + assert r.metrics["Lq"] == 0.0 + assert r.metrics["Wq"] == 0.0 + assert np.isclose(r.metrics["W"], 1 / 3) + + def test_mm1_rejects_bad_arguments(): - """λ / μ 必须是正数:0、负数、bool、非数值都要在入口拦下。""" + """λ 非负、μ 必须为正;bool 与非数值都要在入口拦下。""" from mcm_toolkit.solve import mm1_metrics - with pytest.raises(ValueError): - mm1_metrics(arrival_rate=0, service_rate=3) with pytest.raises(ValueError): mm1_metrics(arrival_rate=-1, service_rate=3) + with pytest.raises(ValueError): + mm1_metrics(arrival_rate=2, service_rate=0) with pytest.raises(TypeError): mm1_metrics(arrival_rate=True, service_rate=3) with pytest.raises(TypeError):