From 953e98898338ec8913d9b06b28f55e875f84b0e4 Mon Sep 17 00:00:00 2001 From: guzhujushi Date: Fri, 9 Oct 2026 23:52:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(solve):=20=E5=AE=A1=E6=9F=A5=20mm1=5Fmetric?= =?UTF-8?q?s=20+=20=E7=AC=AC=E4=BA=8C=E6=89=B9=E5=85=A5=E5=8F=A3=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=EF=BC=88=E5=B7=A5=E7=A8=8B=E4=BE=A7=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=20+=206=20=E6=9D=A1=E6=B5=8B=E8=AF=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户本轮改了 markov_simulate / markov_steady_state 的入口校验,并写完 mm1_metrics, 三处算法本体都正确(mm1 的 5 个公式经探针与闭式解逐位对拍通过,含 W = Wq + 1/mu)。 Codex 只动工程侧: Fixed - mm1_metrics 的稳定性分界 "<" 应为 "<="(docstring 与既有测试的口径都是 lambda >= mu 不稳定): lambda == mu 原先掉进 try、rho == 1.0 触发 ZeroDivisionError,被 except 吞成 success=False。 两个分支的可观测输出完全相同(success=False / metrics={} / data=None),只有 message 不同, 故按决策 31 直接修并加回归测试,未改动任何口径。 - 三处报错文案的参数名抄错:markov_steady_state 的 max_iter <= 0 写成 "tol 必须是正数", mm1_metrics 的 service_rate 两条报错都写成 "tol";半角逗号统一为全角。 Changed - mm1_metrics 的 docstring 补 Raises 与 data 说明;call_params = {...} 补空格; ruff 6 条(W293 x5 / F541 f-string 无占位符 / W292 文件末尾无换行)。 - markov_steady_state 的 try 前 3 个空行 -> 1;"异常: " -> "异常:"。 - tests/test_solve.py:摘掉 2 条 mm1_metrics 的 skip,新增 4 条(lambda == mu 的回归、 非负校验回归、max_iter=3 未收敛判 success=False)。 - STATUS.md 决策 56 / 57 更新办结项 + 新增决策 58(mm1 的 4 条待拍板);CHANGELOG;易错点 29。 校验:pytest 202 passed / 28 skipped(230 收集);ruff 0;pyright 0 --- CHANGELOG.md | 33 +++++++ STATUS.md | 158 ++++++++++++++++++++---------- docs/dev/personal-pitfalls.md | 19 ++++ src/mcm_toolkit/solve/simulate.py | 101 ++++++++++++++++--- tests/test_solve.py | 72 ++++++++++++-- 5 files changed, 311 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c315ff..bfc0f95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -555,6 +555,39 @@ `tol=np.nan` 穿过校验白跑迭代、`converged=False` 仍 `success=True`、 类型错也抛 `ValueError`(与决策 54 分工不一致)、`params` 没记转移矩阵。 +### Added +- `solve.mm1_metrics` 落地(**L1,S 档**,实现由用户本人写):闭式解 `ρ = λ/μ`、`L = ρ/(1-ρ)`、 + `Lq = ρ²/(1-ρ)`、`W = 1/(μ-λ)`、`Wq = W - 1/μ`;`λ >= μ` 走"系统不稳定"分支返回 `success=False`。 + Codex 审查结论:**公式全对**(探针实测 3 组参数 `(2,3)` / `(1,4)` / `(0.5,1.0)` 与闭式公式逐位一致, + `W = Wq + 1/μ` 恒等式成立)。至此 `solve` 层的 L1 清单**全部落地**。 +- `markov_simulate` / `markov_steady_state` 增加**非负校验**(用户 2026-10-09 改定):含负元素的行随机矩阵 + 现在入口即报错,不再静默给出错结果(前者会"全落 0",后者会溢出成 `nan` 却报成功)。 +- `markov_steady_state` 的 `params` 补 `transition`(`arr.tolist()`);`success=converged` + (未收敛判失败,口径 B);`tol` 拆成 `TypeError` / `ValueError` 且先判 `bool`。 +- `tests/test_solve.py`:摘掉 2 条 `mm1_metrics` 的 skip,新增 4 条(含 `λ == μ` 的回归测试);本轮累计 +6 条。 + +### Fixed +- **`mm1_metrics` 的稳定性分界写错**(真 bug,按决策 31 修):`service_rate < arrival_rate` 与 docstring / 既有测试的 + `λ >= μ` 口径不符 —— `λ == μ` 会掉进 `try`、`rho == 1.0` → `ZeroDivisionError`,被吞成 `success=False` + (结果碰巧对,但 message 成了"计算失败:ZeroDivisionError - float division by zero")。改成 `<=` 并加回归测试; + **两个分支的可观测输出完全相同(`success=False` / `metrics={}` / `data=None`),故未动任何口径**。 +- **三处报错文案的参数名抄错**:`markov_steady_state` 的 `max_iter <= 0` 写成"tol 必须是正数"; + `mm1_metrics` 的 `service_rate` 两条报错都写成 "tol"。顺带把半角逗号统一成全角。 + +### Changed +- `mm1_metrics` 的 docstring 补 `Raises` 与 `data` 说明;`call_params = {{...}}` 补空格; + ruff 6 条(5 处 `W293` + `F541` f-string 无占位符 + `W292` 文件末尾无换行)。 +- `markov_steady_state` 的 `try` 前 3 个空行 → 1;`异常: ` → `异常:`。 +- `STATUS.md`:决策 56 / 57 更新办结项,新增**决策 58**(`mm1_metrics` 的 4 条待拍板);「下一步」推进到第七轮; + 验证计数更新为 **202 passed / 28 skipped(230 收集)**。 +- `docs/dev/personal-pitfalls.md`:新增易错点 29(同一个校验里的两把尺子)。 + +### Notes +- **待用户拍板的口径**(Codex 只报不改):`markov_simulate` 4 条(`initial` 语义 A/B/C、`paths[:, 0]` 赋值位置、 + 状态越界、`std_terminal` 对分类量)、`markov_steady_state` 3+2 条(`tol` 的 NaN、`max_iter` 类型错、 + 非负校验的**容差**、未收敛却带 `data`)、`mm1_metrics` 4 条(`λ = 0`、numpy 标量、`data=metrics`、 + 失败分支丢 `rho`)。详见 `STATUS.md` 决策 56 / 57 / 58。 + ## [0.0.1] - 2026-10-01 ### Added diff --git a/STATUS.md b/STATUS.md index b8f8984..c89e65c 100644 --- a/STATUS.md +++ b/STATUS.md @@ -22,9 +22,11 @@ `detect_outliers`、`normalize`/`standardize`、`describe`、`correlation_matrix`、`bootstrap_ci`、 `monte_carlo`(**用户写**,2026-10-04);另**提前落地**了 L1 的 `confidence_interval` / `t_test` / `monte_carlo_probability`、`monte_carlo_integral`、`random_walk`、`markov_simulate`、 -`markov_steady_state`(**均为用户写**)。剩余 1 项:`save_figure`(Codex 写)。 +`markov_steady_state`、`mm1_metrics`(**均为用户写**)—— `solve` 层的 L1 清单**已全部落地**。 +剩余 1 项:`save_figure`(Codex 写)。 `monte_carlo` 的口径已拍板(决策 48);`monte_carlo_probability` / `random_walk` 的待办**均已办结**; -`markov_simulate`(决策 56,5 条)与 `markov_steady_state`(决策 57,6 条)的**入口口径待你拍板**。 +`markov_simulate`(决策 56)/ `markov_steady_state`(决策 57)/ `mm1_metrics`(决策 58) +**仍有一批入口口径待你拍板**(本轮各办结了一批,详见"下一步")。 另 3 个小口径仍悬着:`ci_low`/`ci_high` 的语义、`monte_carlo_integral` 的第一道 `isinstance` 分支可塌成两行、 `random_state` 注解与 `std` 的 `ddof` 是否全项目对齐。 @@ -333,7 +335,9 @@ 单位矩阵吸收态自锁、可复现(同种子相同、异种子不同)、终点频率收敛到 `[2/3, 1/3]`、 metrics = 终点状态、非法转移矩阵(非方阵 / 行和不为 1)、非法 `steps` / `n_paths`(0 / float / bool)、 `np.int64` 可收、docstring Example 真能跑 - - ⏳ **留给你的 5 条**见"下一步 → markov_simulate 待你拍板"(全是入口口径与少考虑情况,决策 49) + - **第二轮(2026-10-09)**:用户补了 `np.any(arr < 0)` 非负校验 → 待办 ② 办结; + Codex 补一条"行和是 1 但含负元素"的回归测试 + - ⏳ **余下 4 条**见"下一步 → markov_simulate 待你拍板"(全是入口口径与少考虑情况,决策 49) - [x] `solve/simulate.py` 的 `markov_steady_state`(**L1,S 档,算法由用户本人写**,2026-10-09) - 核心:`v` 从均匀分布出发,`v_next = v @ arr` 反复推进(行随机矩阵按**行向量右乘**), @@ -349,7 +353,28 @@ - **测试:+8 条、摘掉 1 条 skip**(此前该函数只有 1 条挂着的 skip)—— 2 状态 `[2/3, 1/3]` + `converged` / `iterations`、 3 状态与特征分解对拍、`data` 是分布(和为 1 / 非负 / `float64`)、慢混合链迭代次数 > 100、 `np.int64` 的 `max_iter`、非法转移矩阵、`max_iter=0` / `tol=0`、docstring Example 真能跑 - - ⏳ **留给你的 6 条口径**见"下一步 → markov_steady_state 待你拍板"(决策 57) + - **第二轮(2026-10-09,用户改定)**:补非负校验(① 办结)、`params` 记 `transition`(⑥ 办结)、 + `success=converged`(选口径 B,④ 办结)、`tol` 拆成 `TypeError` / `ValueError` 且先判 `bool`(② 办结); + Codex 顺手修文案(`max_iter <= 0` 那条写成 "tol 必须是正数")与 `异常: ` → `异常:`, + 并补 2 条测试(负元素矩阵、`max_iter=3` 未收敛走 `success=False`) + - ⏳ **余下 3 条 + 1 个新口子**见"下一步 → markov_steady_state 待你拍板"(决策 57) + +- [x] `solve/simulate.py` 的 `mm1_metrics`(**L1,S 档,算法由用户本人写**,2026-10-09) + - 核心:闭式解 `ρ = λ/μ`、`L = ρ/(1-ρ)`、`Lq = ρ²/(1-ρ)`、`W = 1/(μ-λ)`、`Wq = W - 1/μ`; + `λ >= μ` 走"系统不稳定"分支返回 `success=False` + - **Codex 审查结论:公式全对**(探针实测 3 组参数 `(2,3)` / `(1,4)` / `(0.5,1.0)` 与闭式公式逐位一致, + `W = Wq + 1/μ` 恒等式也成立) + - **Codex 修了一处边界 bug(决策 31,纯 bug)**:稳定性判断写成 `service_rate < arrival_rate`, + 而 docstring 与既有测试的口径都是 `λ >= μ` —— 于是 `λ == μ` 掉进 `try`、`rho == 1.0` → + `ZeroDivisionError` 被 `except` 吞成 `success=False`(结果碰巧对,message 却是"计算失败:ZeroDivisionError")。 + 两支的可观测输出完全相同(`success=False` / `metrics={}` / `data=None`),改 `<=` 只换 message,故直接修并加回归测试 + - **Codex 只碰工程侧**:① ruff 6 条(5 处 `W293` + `F541` f-string 无占位符 + `W292` 文件末尾无换行); + ② 文案:`service_rate` 的报错写成 "tol"、`max_iter` 的也写成 "tol"(同一轮里 3 处参数名抄错)、半角逗号 → 全角; + ③ `call_params={...}` → `call_params = {...}`;④ docstring 补 `Raises` + 说明 `data` 与 `metrics` 同值 + - **测试:+6 条、摘掉 2 条 skip**(此前只有 2 条挂着的 skip)—— 闭式公式逐项对拍(含 `W = Wq + 1/μ`)、 + `λ == μ` 与 `λ > μ` 都判失败且带"不稳定"文案、入口(0 / 负数 / `bool` / 非数值)、 + `λ == μ` 不能靠 `ZeroDivisionError` 落到失败分支、docstring Example + - ⏳ **留给你的 4 条**见"下一步 → mm1_metrics 待你拍板"(决策 58) - [ ] 剩余 1 项:`save_figure`(A 档 = 工程,**Codex 直接写**) @@ -357,7 +382,7 @@ | 检查 | venv | conda `mcm-toolkit` | |---|---|---| -| `pytest` | 见下(单列) | **196 passed, 30 skipped(0 xfail)** | +| `pytest` | 见下(单列) | **202 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` | 跑通 | — | @@ -367,15 +392,15 @@ (修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与 `docs/04-environment.md` 坑 7) - base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致) -- **226 条用例:196 passed / 30 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、 - `test_core.py` 23、`test_solve.py` 58(本轮 +8、摘掉 1 条 skip)、`test_viz.py` 8、`test_decision.py` 7、 +- **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、 `test_stats.py` 53、`test_models.py` 5 - **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正 - 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环 - `examples/01_clean_and_describe.py` 实测跑通(exit 0);`00_quickstart.py` 仍等 `stats` / `viz` -- 30 条 skip 是等实现的算法(`train_test_split`、`mm1_metrics`、`anova`、`fit_distribution`、 - `knapsack_dp`、`linear_program`、`simulated_annealing` 等); - `monte_carlo_probability` / `monte_carlo_integral` / `markov_steady_state` 已落地,分别摘掉 2 / 1 / 1 条 skip;`markov_simulate` 新增的 9 条全部实跑; +- 28 条 skip 是等实现的算法(`train_test_split`、`anova`、`fit_distribution`、`knapsack_dp`、 + `linear_program`、`simulated_annealing` 等); + `monte_carlo_probability` / `monte_carlo_integral` / `markov_steady_state` / `mm1_metrics` 已落地,分别摘掉 2 / 1 / 1 / 2 条 skip;`markov_simulate` 新增的 9 条全部实跑; `TestValidate` 的 4 条仍挂着 —— `check_*` 还是 `NotImplementedError`,但它们实现时应当直接复用 `as_array` - **本轮回归(已修)**:`transform.py` 上一版有 3 处真 bug —— 修前 **7 failed / 75 passed**, 且 pyright 在同一处报 `reportArgumentType`。**不是静态误报**:pyright CLI 复现 + 测试真红。 @@ -541,34 +566,47 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 长度不匹配时**裸抛** `ValueError`(本该 `success=False`,同易错点 26 一族)。另有三个口子: `np.int64(0)` 掉进数组分支、被 `as_array` 拒(与 `steps`/`n_paths` 收 `np.integer` 不一致,易错点 1 复发); `True` 因 `isinstance(True, int)` 为真被当状态 1 **静默放行**;越界状态(如 `initial=5`、`s=2`)只在 -第一轮索引时才炸、被 `except` 吞成 `success=False`。可选口径见下一步的 A / B / C。 +第一轮索引时才炸、被 `except` 吞成 `success=False`。 +**2026-10-09 更新**:② 已办结(用户补了 `np.any(arr < 0)`,含负元素的行随机矩阵现在入口直接报错); +① ③ ④ ⑤ 仍待拍板(A / B / C 三个选项见"下一步")。 -| 57 | `markov_steady_state` 的 6 条口径**待用户拍板**(Codex 只报选项,未代改,决策 49) | -探针实测出的口子:① **含负元素**的"行和为 1"矩阵(`[[1.5, -0.5], [0, 1]]`)会溢出成 `nan`,而 -`data=[nan, nan]` 却 `success=True` —— 违反决策 48 的 NaN 口径(根因:没查 `arr >= 0`);附带的 -`RuntimeWarning: overflow encountered in matmul` 因为 pyproject 设了 `filterwarnings = ["error::RuntimeWarning"]`, -**能让测试直接报错**,所以不能只靠测试兜;② `tol=True` 因 `isinstance(True, int)` 通过校验 → `tol=1` → -1 次就"收敛",给出 `[0.55, 0.45]` 的假答案(`max_iter` 查了 `bool`、`tol` 没查,易错点 1 复发); -③ `tol=np.nan` 因 `nan <= 0` 为假穿过校验 → 白跑 10000 次迭代;④ `converged=False` 仍 `success=True` -且 `data` 是未收敛的迭代值(`max_iter=5` → `[0.6387, 0.3613]`);⑤ 类型错也抛 `ValueError` -(`max_iter=100.0`),与决策 54 的"类型错 `TypeError`"分工不一致;⑥ `params` 只记 `max_iter` / `tol`, -没记转移矩阵(`monte_carlo_integral` 记了 `bounds`),事后复现不了。可选口径见"下一步"。 +| 57 | `markov_steady_state` 的口径:**4 条已办、3 条 + 2 个新口子仍待拍板**(Codex 只报选项,未代改,决策 49) | +**2026-10-09 用户第二轮办结**:① 补 `np.any(arr < 0)`(含负元素的行随机矩阵现在入口即报错,原先会溢出成 `nan` +却报 `success=True`);② `tol` 拆成 `TypeError`(类型 + `bool`)与 `ValueError`(取值); +④ **选口径 B**:`converged=False` → `success=False`;⑥ `params` 补 `transition`(`arr.tolist()`,可 JSON 化)。 +**仍待拍板**:③ `tol=np.nan`(`nan <= 0` 为假,穿过校验白跑 §max_iter 次迭代); +⑤ `max_iter` 的类型错仍抛 `ValueError`(与决策 54 的"类型错 `TypeError`"不一致); +⑦ **两把尺子宽严不一**——非负校验用精确 `arr < 0`,同一个函数里的行和校验却用宽容的 `np.allclose`, +实测 `[[1.0, -1e-16], [0.5, 0.5]]`(行和都是 1)会被判非法,建议改成 `< -1e-12` 这类容差; +⑧ 未收敛时 `success=False` 但 `data` 仍是未收敛向量(文件内唯一"失败却带 `data`"的分支)—— 保持还是 `data=None`? + +| 58 | `mm1_metrics` 的稳定性分界由 Codex 按**决策 31** 直接修(`<` → `<=`),另有 4 条口径待用户拍板 | +docstring 与既有测试写的都是 `λ >= μ` 不稳定,代码却写 `service_rate < arrival_rate` —— 于是 `λ == μ` +掉进 `try`、`rho == 1.0` → `ZeroDivisionError`,被 `except` 吞成 `success=False` +(碰巧对,但 message 成了"计算失败:ZeroDivisionError - float division by zero")。两支的可观测输出完全相同 +(`success=False` / `metrics={}` / `data=None`),改 `<=` 只换 message,故直接修 + 加回归测试。 +**待拍板**:① `λ = 0` 现在被 `ValueError` 拒(数学上是合法退化解:空系统 `L = W = 0`); +② `np.int64` / `np.float32` 全被 `TypeError` 拒(文件内 `random_walk` 的 `start` 收 `np.number`、 +整数参数收 `np.integer`,§4.7 有共识);③ `data=metrics` —— **同一个 dict 对象**(`r.data is r.metrics`), +且成功分支给 dict、失败分支给 `None`,与文件内"`data` 是数组"的习惯不同; +④ 失败分支的 `metrics={}` 会丢掉 `rho` 等已算出的量(可选保留)。 ## 下一步 -### ⭐ 当前进度与待办(2026-10-09 第六轮) +### ⭐ 当前进度与待办(2026-10-09 第七轮) -**本轮:`markov_steady_state`(你写的)已审查通过** —— 算法本体正确,一行没动(3 状态与特征分解逐位一致、 -200×200 只用 6 次迭代 / 0.10s);Codex 只清工程侧(ruff 5 条、docstring 补 `max_iter`/`tol`/`Raises`、 -修正"周期链"表述、全角冒号),并 **+8 条测试、摘掉 1 条 skip**。 -三连:`pytest` **196 passed / 30 skipped**、`ruff` 0、`pyright` 0。 +**本轮:你改了 `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 清单**已全部落地**。 **接下来三件事** -1. **`markov_steady_state` 的 6 条口径待你拍板**(下节,决策 57)。 -2. **`markov_simulate` 的 5 条口径**仍在挂着(决策 56 的 A / B / C),与 `solve` 的收尾一起定。 -3. L0 最后一项 `save_figure`(A 档 = 工程,`viz/export.py`)—— **Codex 直接实现**,你只读。 +1. **三个函数的口径待你拍板**:`markov_simulate` 4 条(决策 56)、`markov_steady_state` 3+2 条(决策 57)、 + `mm1_metrics` 4 条(决策 58)—— 全在下面三节。 +2. L0 最后一项 `save_figure`(A 档 = 工程,`viz/export.py`)—— **Codex 直接实现**,你只读。 +3. 之后就往外层走:L1 只剩 `decision` / `models` / `stats` 的几条。 -### ⏳ markov_simulate 待你拍板(5 条,Codex 只报不改) +### ⏳ markov_simulate 待你拍板(4 条) **① `initial` 的语义(最大的一条,三个选项)** 现在标量分支按**路径数**铺满、数组分支按**状态数**校验长度,两条口径互相矛盾(详见决策 56)。 @@ -576,39 +614,57 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 - **B**:只收长度 `n_paths` 的数组(标量走 `np.full` 铺满),`data` 首列就是给定的初始状态序列(与 `paths[:, 0]` 赋值天然一致) - **C**:沿用现状,但把类型收成 `int | np.integer` + 先判 `bool`,把 `paths[:, 0] = ...` 挪进 `try`,docstring 写清"数组按路径数" -**② 转移矩阵没查非负**:`[[1.5, -0.5], [0, 1]]` 行和恰为 1.0 → 通过校验,但 `cumsum` 非单调 → -落格结果**全是 0**(静默错)。建议入口加一条 `np.any(arr < 0)` → `ValueError`。 +~~**② 转移矩阵没查非负**~~ → **已办(2026-10-09)**:入口加了 `np.any(arr < 0)` → `ValueError`, +测试里钉了"行和是 1 但含负元素"这条。余下的一小问:这条校验是**精确**判负,见决策 57 的 ⑦(容差)。 + **③ `paths[:, 0]` 赋值在 `try` 之外**:长度不匹配时裸抛 `ValueError`,与"出错统一 `success=False`"(决策 48)不一致。 + **④ 状态越界没在入口拦**:`initial=5`(`s=2`)第一轮才 `IndexError`,被吞成 `success=False`; 建议入口加 `0 <= initial < s`。 -**⑤ `std_terminal` 对**分类**状态的意义**:状态编号是标称量,均值和标准差只对"有序状态"(如排队人数)成立 —— + +**⑤ `std_terminal` 对分类状态的意义**:状态编号是标称量,均值和标准差只对"有序状态"(如排队人数)成立 —— 要不要把 `metrics` 换成状态频率(`terminal_freq`)?你定。 -### ⏳ markov_steady_state 待你拍板(6 条,Codex 只报不改) +### ⏳ markov_steady_state 待你拍板(3 条 + 2 个新口子) -**① 转移矩阵没查非负**(与 `markov_simulate` 的待办 ② 是同一块):`[[1.5, -0.5], [0, 1]]` 行和恰为 1.0 → -通过校验 → `v` 指数发散 → `data=[nan, nan]` **但 `success=True`**(违反决策 48 的 NaN 口径)。 -实测还会先抛 `RuntimeWarning: overflow encountered in matmul`,而 pyproject 设了 -`filterwarnings = ["error::RuntimeWarning"]` —— 这条**能让测试直接报错**,所以得在校验层拦,不能只靠测试兜。 +- ~~**① 转移矩阵没查非负**~~ → **已办(2026-10-09)** +- ~~**② `tol` 没查 `bool`**~~ → **已办**:`tol` 现在拆成 `TypeError`(类型 / `bool`)与 `ValueError`(取值) +- ~~**④ `converged=False` 仍 `success=True`**~~ → **已办**:选了**口径 B**,`success=converged` +- ~~**⑥ `params` 没记转移矩阵**~~ → **已办**:补了 `"transition": arr.tolist()` -**② `tol` 没查 `bool`**:`tol=True` → `isinstance(True, int)` 为真 → `tol=1` → -1 次就"收敛",给出 `[0.55, 0.45]` 的**假答案**(`success=True, converged=True, iterations=1`)。 -`max_iter` 查了 `bool`,`tol` 漏了(易错点 1 复发)。 +**③ `tol` 没查 NaN**:`np.nan <= 0` 为假 → 穿过校验 → 白跑满 10000 次迭代才返回(结果本身对,纯浪费)。 +建议与 `max_iter` 一起走"类型 → 有限性 → 取值"三件套(易错点 28)。 -**③ `tol` 没查 NaN**:`np.nan <= 0` 为假 → 穿过校验 → 白跑 10000 次迭代后返回(结果本身是对的,纯浪费)。 +**⑤ `max_iter` 的类型错仍抛 `ValueError`**(`max_iter=100.0` / `max_iter=True`), +与文件内其它函数(决策 54:类型错 `TypeError`、取值错 `ValueError`)不一致 —— 要不要对齐? -**④ `converged=False` 仍 `success=True`**,且 `data` 是未收敛的迭代值(`max_iter=5` → `[0.6387, 0.3613]`)。 -选项:**A** 保持现状(`converged` 自己看);**B** 不收敛判 `success=False`;**C** 不收敛时 `data=None`、只留 `metrics`。 +**⑦【新】非负校验的两把尺子**:`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` 的同名校验也适用。 -**⑤ 类型错也抛 `ValueError`**(`max_iter=100.0`),与文件内其它函数(决策 54:类型错 `TypeError`、取值错 `ValueError`) -不一致 —— 要不要对齐?`tol` 同理。 +**⑧【新】未收敛时 `success=False` 但 `data` 仍是未收敛向量**:这是文件里唯一"失败却带 `data`"的分支 +(其它失败分支一律 `data=None`)。调用方若只看 `data is not None` 就会拿到没收敛的结果 —— 保持(B)还是改 `data=None`(C)? -**⑥ `params` 没记转移矩阵**:`{"max_iter": 10000, "tol": 1e-10}` 复现不了"算的是哪个矩阵" -(`monte_carlo_integral` 记了 `bounds`)—— 要不要补? +### ⏳ mm1_metrics 待你拍板(4 条) -**你下一个要写的(S 档,仍在 `solve/simulate.py`)**:`mm1_metrics`(M/M/1 闭式指标,10 行)。 -docstring 已写明口径:`λ >= μ` 时系统不稳定、队列长度无界 —— 这种情况返回 `success=False` 并说明原因, -而不是返回 `inf`。写完 `solve` 这一层就齐了,L1 只剩 `decision` / `models` 那几条。 +**① `λ = 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 批量收口 —— 两条路都行,你定。 ### ✅ random_walk 的 4 条待办(2026-10-05 已办结) diff --git a/docs/dev/personal-pitfalls.md b/docs/dev/personal-pitfalls.md index 6cf04ed..1a538ff 100644 --- a/docs/dev/personal-pitfalls.md +++ b/docs/dev/personal-pitfalls.md @@ -487,3 +487,22 @@ 有限性(`np.isfinite`,挡 NaN / inf)、取值区间**;② 同一函数里同类参数(`max_iter` / `tol` / `n` / `tol`) 用**同一套模板**写,别一个查 `bool` 一个不查;③ 校验"概率/权重"这类输入时,除了和为 1,还要问一句"有没有可能是负数/NaN"。 - 首次:2026-10-09|复发:0 + +### 29. 同一个校验里的两把尺子:一个宽容、一个精确 + +- 现象:`markov_simulate` / `markov_steady_state` 校验转移矩阵时有两条: + `if not np.allclose(arr.sum(axis=1), 1.0)`(**宽容**,默认 `atol=1e-8`)和 `if np.any(arr < 0)`(**精确**)。 + 实测 `[[1.0, -1e-16], [0.5, 0.5]]`:行和都是 1.0,但左边那行有个 `-1e-16` 的归一化残渣 → + 被判"含负元素"直接报错。**这个矩阵在数学上没问题,是校验太严**。 +- 为什么会错:两条校验是分两次、照着不同念头写的 —— 行和那条想着"浮点比较不能写 `==`", + 非负那条想着"概率怎么能为负"。各自都对,**放在一起就成了一严一宽**。建模时矩阵常常是 + `df / df.sum()` 归一化出来的,这类 `-1e-17` 残渣非常常见,比赛现场会直接卡住。 +- 同族(同一轮里一起犯的):① 边界比较与文档口径不一致 —— `mm1_metrics` 写 `service_rate < arrival_rate`, + 而 docstring 与测试都写 `λ >= μ` 不稳定,于是 `λ == μ` 掉进正常计算分支、`rho = 1` → + `ZeroDivisionError`(结果碰巧对,message 却是"计算失败:ZeroDivisionError"); + ② 一批报错文案是复制粘贴的,**参数名忘了改** —— 同一轮里 `max_iter` 的文案写 "tol"、 + `service_rate` 的两条文案也都写 "tol"。两条都是"看着像对的、跑起来也行、但口径/文案是错的"。 +- 以后怎么防:① 同一个量的多条校验,**先把容差定下来再写**(要么都用 `np.allclose` / 小 `eps`,要么都精确), + 写完回头对一眼"这几条的严格程度一致吗";② 阈值类比较(`<` / `<=`)**对着 docstring 读一遍**, + 特别是 == 落在哪边;③ 复制粘贴报错文案时,逐个把参数名念出来。 +- 首次:2026-10-09|复发:0 diff --git a/src/mcm_toolkit/solve/simulate.py b/src/mcm_toolkit/solve/simulate.py index 37ee2b8..68b7fb5 100644 --- a/src/mcm_toolkit/solve/simulate.py +++ b/src/mcm_toolkit/solve/simulate.py @@ -521,6 +521,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 not np.allclose(arr.sum(axis=1), 1.0): raise ValueError("transition 每一行的和必须是 1.0") s = arr.shape[0] @@ -638,19 +640,30 @@ 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 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__}") + 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: + raise ValueError(f"tol 必须是正数,你传入了 {tol}") + s = arr.shape[0] v = np.full(s, 1.0 / s) iterations = 0 converged = False - - if isinstance(max_iter, bool) or not isinstance(max_iter, (int, np.integer)) or max_iter <= 0: - raise ValueError(f"max_iter 必须是正整数,你传入了 {max_iter}") - if not isinstance(tol, (int, float)) or tol <= 0: - raise ValueError(f"tol 必须是正数,你传入了 {tol}") + call_params = { + "transition": arr.tolist(), + "max_iter": max_iter, + "tol": tol, + } try: for _ in range(max_iter): @@ -670,21 +683,20 @@ def markov_steady_state(transition: Any, max_iter: int | np.integer = 10000, tol return Result( name="markov_steady_state", - params={"max_iter": max_iter, "tol": tol}, + params=call_params, metrics=metrics, data=v, - success=True, - message=f"平稳分布求解{'成功' if converged else '未完全收敛'},迭代 {iterations} 次。" + success=converged, + message=f"平稳分布求解{'成功' if converged else '失败:未在最大迭代次数内收敛'},迭代 {iterations} 次。" ) - except Exception as e: return Result( name="markov_steady_state", - params={"max_iter": max_iter, "tol": tol}, + params=call_params, metrics={}, data=None, success=False, - message=f"平稳分布求解失败:{type(e).__name__} - {str(e)}" + message=f"平稳分布求解异常:{type(e).__name__} - {str(e)}" ) @@ -700,7 +712,15 @@ def mm1_metrics(arrival_rate: float, service_rate: float) -> Any: Returns ------- `Result`:`metrics={"rho", "L", "Lq", "W", "Wq"}` - (系统利用率、系统内平均顾客数、队列长度、平均逗留时间、平均等待时间)。 + (系统利用率、系统内平均顾客数、队列长度、平均逗留时间、平均等待时间); + `data` 与 `metrics` 同值(闭式解没有样本数组)。 + + Raises + ------ + TypeError: + `arrival_rate` / `service_rate` 不是数值(`bool` 也判非法)。 + ValueError: + `arrival_rate` / `service_rate` 不是正数。 Notes ----- @@ -714,5 +734,58 @@ def mm1_metrics(arrival_rate: float, service_rate: float) -> Any: >>> round(r.metrics["rho"], 4) 0.6667 """ - # TODO(L1) - raise NotImplementedError + if not isinstance(arrival_rate, (int, float)) 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): + 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} + + if service_rate <= arrival_rate: + return Result( + name="mm1_metrics", + params=call_params, + metrics={}, + data=None, + success=False, + message="系统不稳定:到达率 λ 大于等于服务率 μ,队列长度无界,无法计算闭式解。" + ) + + try: + rho = arrival_rate / service_rate + L = rho / (1.0 - rho) # 系统内平均顾客数 + Lq = rho ** 2 / (1.0 - rho) # 队列中平均等待顾客数 + W = 1.0 / (service_rate - arrival_rate) # 平均逗留时间 + Wq = W - 1.0 / service_rate # 平均等待时间 + + # 4. 封装返回 + metrics = { + "rho": float(rho), + "L": float(L), + "Lq": float(Lq), + "W": float(W), + "Wq": float(Wq) + } + + return Result( + name="mm1_metrics", + params=call_params, + metrics=metrics, + data=metrics, + success=True, + message="M/M/1 排队系统指标计算成功。" + ) + + except Exception as e: + return Result( + name="mm1_metrics", + params=call_params, + metrics={}, + data=None, + success=False, + message=f"计算失败:{type(e).__name__} - {str(e)}" + ) diff --git a/tests/test_solve.py b/tests/test_solve.py index 84d1df3..f451c53 100644 --- a/tests/test_solve.py +++ b/tests/test_solve.py @@ -469,6 +469,8 @@ def test_markov_simulate_rejects_bad_transition(): markov_simulate(np.array([[0.5, 0.5, 0.0], [0.2, 0.8, 0.0]]), initial=0) with pytest.raises(ValueError): markov_simulate(np.array([[0.5, 0.4], [0.2, 0.8]]), initial=0) + with pytest.raises(ValueError): + markov_simulate(np.array([[1.5, -0.5], [0.0, 1.0]]), initial=0) # 行和是 1,但含负元素 def test_markov_simulate_rejects_bad_arguments(): @@ -570,6 +572,20 @@ def test_markov_steady_state_rejects_bad_transition(): markov_steady_state(np.ones((2, 3)) / 3) with pytest.raises(ValueError): markov_steady_state(np.array([[0.5, 0.4], [0.2, 0.8]])) + with pytest.raises(ValueError): + markov_steady_state(np.array([[1.5, -0.5], [0.0, 1.0]])) # 行和是 1,但含负元素 + + +def test_markov_steady_state_non_convergence_is_success_false(): + """`max_iter` 不够时按"未收敛"处理:`success=False` 且 `converged=False`(口径 B)。""" + from mcm_toolkit.solve import markov_steady_state + + P = np.array([[0.9, 0.1], [0.2, 0.8]]) + r = markov_steady_state(P, max_iter=3) + assert r.success is False + assert r.metrics["converged"] is False + assert r.metrics["iterations"] == 3 + assert "未在最大迭代次数内收敛" in r.message def test_markov_steady_state_rejects_bad_arguments(): @@ -610,21 +626,63 @@ def test_linear_program_basic(): assert np.isclose(r.metrics["objective"], 4.0) -@pytest.mark.skip(reason=_NOT_IMPLEMENTED) def test_mm1_metrics_closed_form(): + """与闭式公式逐项对拍(ρ = λ/μ,L = ρ/(1-ρ),Wq = ρ/(μ-λ),W = Wq + 1/μ)。""" + from mcm_toolkit.solve import mm1_metrics + + arrival_rate, service_rate = 2, 3 + rho = arrival_rate / service_rate + r = mm1_metrics(arrival_rate=arrival_rate, service_rate=service_rate) + + assert r.success + assert np.isclose(r.metrics["rho"], rho, rtol=1e-12) + assert np.isclose(r.metrics["L"], rho / (1 - rho), rtol=1e-12) + assert np.isclose(r.metrics["Lq"], rho**2 / (1 - rho), rtol=1e-12) + assert np.isclose(r.metrics["W"], 1 / (service_rate - arrival_rate), rtol=1e-12) + assert np.isclose(r.metrics["Wq"], rho / (service_rate - arrival_rate), rtol=1e-12) + assert np.isclose(r.metrics["W"], r.metrics["Wq"] + 1 / service_rate, rtol=1e-12) + + +def test_mm1_rejects_unstable_system(): + """λ >= μ 时系统不稳定,应 success=False 而不是返回 inf(λ == μ 也要走这一支)。""" + from mcm_toolkit.solve import mm1_metrics + + equal = mm1_metrics(arrival_rate=3, service_rate=3) + assert equal.success is False + assert "不稳定" in equal.message + assert equal.metrics == {} + + assert mm1_metrics(arrival_rate=3, service_rate=2).success is False + + +def test_mm1_rejects_bad_arguments(): + """λ / μ 必须是正数:0、负数、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(TypeError): + mm1_metrics(arrival_rate=True, service_rate=3) + with pytest.raises(TypeError): + mm1_metrics(arrival_rate=2, service_rate="3") # type: ignore[arg-type] # 就是要传错 + + +def test_mm1_docstring_example_runs(): from mcm_toolkit.solve import mm1_metrics r = mm1_metrics(arrival_rate=2, service_rate=3) - assert np.isclose(r.metrics["rho"], 2 / 3, rtol=1e-6) - assert np.isclose(r.metrics["L"], 2.0, rtol=1e-6) + assert round(r.metrics["rho"], 4) == 0.6667 -@pytest.mark.skip(reason=_NOT_IMPLEMENTED) -def test_mm1_rejects_unstable_system(): - """λ >= μ 时系统不稳定,应 success=False 而不是返回 inf。""" +def test_mm1_stability_boundary_is_success_not_exception(): + """λ == μ 不能靠 ZeroDivisionError 落到失败分支:必须是显式的"不稳定"口径。""" from mcm_toolkit.solve import mm1_metrics - assert mm1_metrics(arrival_rate=3, service_rate=3).success is False + r = mm1_metrics(arrival_rate=5, service_rate=5) + assert r.success is False + assert "ZeroDivisionError" not in r.message @pytest.mark.skip(reason=_NOT_IMPLEMENTED)