From 9368dd91cba350de2fad584c1ea62f20be1149f0 Mon Sep 17 00:00:00 2001 From: guzhujushi Date: Mon, 5 Oct 2026 18:06:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(solve):=20random=5Fwalk=20=E7=9A=84=20step?= =?UTF-8?q?=5Fsampler=20=E5=A5=91=E7=BA=A6=E6=94=B9=E6=88=90"=E6=95=B4?= =?UTF-8?q?=E6=95=B0=20size=20+=20=E4=B8=80=E7=BB=B4=E8=BF=94=E5=9B=9E"?= =?UTF-8?q?=EF=BC=88=E6=96=B9=E6=A1=88=20B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户拍板方案 B(决策 55),Codex 代改。 - size 由 (n_paths, n_steps) 改成整数 n_paths * n_steps,必须返回一维; 内部 reshape(n_paths, n_steps)(行主序);二维返回一律判失败 - 动因:方阵时转置 (n_steps, n_paths) 与目标形状相同,形状校验天生分不出来(易错点 25) - 关键:不用 .ravel() 救场(会把转置摊平,歧义原样回来) - 红利:与 monte_carlo 的 sampler 契约(决策 48 的 size=n)统一,采样器可直接复用(实测) - docstring:step_sampler 契约与 Notes 同步(一维 / 行主序 / 二维必失败) - tests:转置那条改成 rejects_2d_step_sampler(含方阵),新增"整数 size + 行主序"契约测试;solve 段 43 条 - 验证:210 收集 / 179 passed / 31 skipped / 0 xfail,ruff 0,pyright 0 - docs:STATUS 决策 55 + random_walk 待办全部办结;CHANGELOG --- CHANGELOG.md | 13 +++++++++ STATUS.md | 48 +++++++++++++++---------------- src/mcm_toolkit/solve/simulate.py | 18 ++++++++---- tests/test_solve.py | 32 ++++++++++++++++----- 4 files changed, 73 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 947ac8c..8705822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -495,6 +495,19 @@ - `STATUS.md`:新增**决策 55**(`n_paths == n_steps` 的"方阵歧义"留作已知边界,A 接受 / B 换契约两条路); 3 条口径办结 2 条,验证计数更新为 178 / 31。 +### Changed +- `random_walk` 的 `step_sampler` 契约按**方案 B** 重定(用户拍板,**决策 55**):`size` 由 `(n_paths, n_steps)` 改为**整数** + `n_paths * n_steps`,必须返回**一维**数组,内部 `reshape(n_paths, n_steps)`(行主序)。 + **动因**:转置 `(n_steps, n_paths)` 与目标 `(n_paths, n_steps)` 在 `n_paths == n_steps` 时形状相同,形状校验天生分不出来(易错点 25)。 + 探针实测:默认采样器 `(5, 101)` 正常;转置二维 / 方阵二维 / 长度 +1 / `(N, 1)` 四种返回一律判失败。 + **关键点:不要用 `.ravel()` 救场**,那会把转置的二维摊平、歧义原样回来。红利:与 `monte_carlo` 的 `sampler`(决策 48 的 `size=n`) + 契约统一,给 `monte_carlo` 写的采样器可直接复用(实测通过)。 +- `random_walk` 的 docstring 同步:`step_sampler` 说明改为"整数 `size` + 一维返回 + 行主序",`Notes` 记明二维返回一律判失败。 +- `tests/test_solve.py`:把"转置采样器"那条改成 `test_random_walk_rejects_2d_step_sampler`(含方阵情形), + 新增 `test_random_walk_step_sampler_gets_flat_size_and_is_row_major`(验 `size == 6` 与 `cumsum` 的行主序结果); + `solve` 段 42 → **43** 条,全量 **210 收集 / 179 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。 +- `STATUS.md`:决策 55 改为"已选 B 并落地";`random_walk` 的 7 条待办全部办结;验证计数更新为 179 / 31。 + ## [0.0.1] - 2026-10-01 ### Added diff --git a/STATUS.md b/STATUS.md index 9f248d5..8c24448 100644 --- a/STATUS.md +++ b/STATUS.md @@ -22,8 +22,9 @@ `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`(**用户写**,2026-10-05)。剩余 1 项:`save_figure`(Codex 写)。 -`monte_carlo` 的口径已拍板(决策 48);概率 / 积分两个函数的待办**均已办结**, -剩下 `ci_low`/`ci_high` 的语义与 `monte_carlo_integral` 的 2 条新口径,见"下一步"。 +`monte_carlo` 的口径已拍板(决策 48);`monte_carlo_probability` / `random_walk` 的待办**均已办结**, +剩下 `ci_low`/`ci_high` 的语义、`monte_carlo_integral` 的 1 条口径(第一道 `isinstance` 分支已冗余),见"下一步"。 + ## 任务清单 ### ✅ 已完成 @@ -313,8 +314,8 @@ 按原意把 `call_params` 移到 `try` 之前(不动任何口径),实测失败路径全部恢复正常;新增一条"必然失败"的回归测试(易错点 26) - **测试:本轮再 +3 → 共 9 条**(新增"失败也必须返回 Result 且带 params"、NaN 判失败、`√t` 扩散); `start="a"` 那条恢复成 `pytest.raises(TypeError)`(用户恢复了入口校验,见下) - - ⏳ **留给用户 3 条**(见"下一步"):`start` 的类型校验要不要恢复;`n_steps` / `n_paths` 签名注解仍是 `int` 却收 `np.int64`; - `n_paths == n_steps` 时转置采样器仍无法被形状校验发现 + - **第三轮(2026-10-05)**:`start` 校验恢复、注解放宽为 `int | np.integer`;最后一条"方阵歧义"用户选**方案 B**, + `step_sampler` 契约改为"整数 `size=n_paths * n_steps` + 必须返回一维 + 内部行主序 `reshape`"(决策 55,Codex 代改) - [ ] 剩余 1 项:`save_figure`(A 档 = 工程,**Codex 直接写**) @@ -322,7 +323,7 @@ | 检查 | venv | conda `mcm-toolkit` | |---|---|---| -| `pytest` | 见下(单列) | **178 passed, 31 skipped(0 xfail)** | +| `pytest` | 见下(单列) | **179 passed, 31 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` | 跑通 | — | @@ -332,8 +333,8 @@ (修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与 `docs/04-environment.md` 坑 7) - base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致) -- **209 条用例:178 passed / 31 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、 - `test_core.py` 23、`test_solve.py` 42(本轮 +1:`np.int64` 的 `n_steps` / `n_paths`)、`test_viz.py` 8、`test_decision.py` 7、 +- **210 条用例:179 passed / 31 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、 + `test_core.py` 23、`test_solve.py` 43(本轮 +1:采样器契约"整数 `size` + 行主序")、`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)跑完过一个完整闭环 @@ -489,12 +490,14 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 ② 签名 `n_steps: int` / `n_paths: int` 与运行期收 `np.int64` 不一致(§4.7;`monte_carlo_integral` 的 `n` 已放宽); ③ `n_paths == n_steps` 时,返回转置形状的采样器仍会静默通过(形状校验天生发现不了,见易错点 25)。 -| 55 | `step_sampler` 的"方阵歧义"留作**已知边界**:`n_paths == n_steps` 时,返回转置形状的采样器无法被形状校验发现 | +| 55 | `step_sampler` 的"方阵歧义"按**方案 B** 解决:`size` 改成整数 `n_paths * n_steps`、必须返回**一维**数组,内部 `reshape(n_paths, n_steps)` | 形状校验(决策 54)对"方阵"天生失效 —— 转置 `(n_steps, n_paths)` 正好等于目标 `(n_paths, n_steps)`。 -实测确认:`n_paths != n_steps` 能拦住,相等时静默通过(易错点 25)。两条路(**用户拍板**): -**A 接受**:docstring 写明"方阵时转置的采样器察觉不到";**B 换契约**:`size` 传**整数** `n_paths * n_steps`、要求返回**一维**数组, -内部 `reshape(n_paths, n_steps)` —— 二维返回一律判失败(探针实测:转置二维与方阵二维都被拦下), -而且与 `monte_carlo` 的 `sampler` 契约(决策 48 的 `size=n`)统一,"monte_carlo 风格的采样器可直接复用"。 +**用户 2026-10-05 拍板选 B**(Codex 代改,用户明确要求)。转置 `(n_steps, n_paths)` 与目标 `(n_paths, n_steps)` 在方阵时形状相同, +校验从形状上无法区分 —— 根源是**契约本身有歧义**(一个 tuple 尺寸给了两种朝向)。改成整数尺寸 + **要求一维**后, +二维返回一律判失败,转置问题从根上消失。顺带与 `monte_carlo` 的 `sampler` 契约(决策 48 的 `size=n`)统一: +实测给 `monte_carlo` 写的采样器可以直接丢给 `random_walk`。**关键点:不要用 `.ravel()` 救场** —— 那会把转置的二维摊平, +歧义原样回来。**代价(已写进 docstring)**:`step_sampler` 不能再返回二维,步与路径的对应关系是**行主序**。 +探针实测:默认采样器 `(5, 101)` 正常;后四种(转置二维 / 方阵二维 / 长度 +1 / `(N, 1)`)一律判失败。 ## 下一步 @@ -505,7 +508,7 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 另补 docstring、+3 条测试(共 9 条)。 **接下来三件事** -1. `random_walk` 的 4 条待办已办结,第二轮新增的 3 条又办结 2 条(下面一节)——只剩 1 条。 +1. `random_walk` 的 7 条待办**全部办结**(4 条 + 本轮 3 条),下面一节留档。 2. `ci_low` / `ci_high` 的语义确认一句(也是你的活),确认后 Codex 补 `docs/algorithms/monte_carlo.md` 卡片。 3. L0 最后一项 `save_figure`(A 档 = 工程,`viz/export.py`)—— **Codex 直接实现**,你只读。 @@ -521,19 +524,14 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 - ~~④ `params` 少 `random_state` / `metrics` 抄了一遍~~ → 已补 `random_state`;`metrics` 换成 `mean_terminal` / `std_terminal` (终点位置的均值与标准差,`ddof=0`)—— 这才是"游走"该看的指标,测试里还顺手验了 `std ≈ √t`。 -### ✅ random_walk 新增的 3 条口径(2026-10-05 办结 2 条、剩 1 条) +### ✅ random_walk 的口径(2026-10-05 全部办结) -- ~~① `start` 的入口类型校验被删~~ → 已恢复(`isinstance(start, (int, float, np.number))`)。 - 我顺手把文案里的"必须是整数"改成"必须是数值"(这条校验本来就收 float,原文案自相矛盾), - 并把 `{type(start)}` 统一成 `{type(start).__name__}`(全模块的口径)。测试改回 `pytest.raises(TypeError)`。 -- ~~② 签名注解比行为窄~~ → 已改成 `n_steps: int | np.integer` / `n_paths: int | np.integer`, - 与 §4.7 及 `monte_carlo_integral` 的 `n` 对齐;`np.int64` 的测试也补回来了。 - -**还剩 1 条(你的活)** -1. **`n_paths == n_steps` 时转置形状仍会静默通过**:形状校验对"方阵"天生失效(转置正好等于目标形状)。 - 两个方向,任选(见"决策 55"):**A** 接受这个边界,在 docstring 写明"返回转置形状的采样器在方阵时无法察觉"; - **B** 把 `step_sampler` 的契约改成"`size` 是**整数** `n_paths * n_steps`、必须返回**一维**数组", - 内部再 `reshape(n_paths, n_steps)` —— 二维返回一律判失败,转置问题从根上消失,且与 `monte_carlo` 的 `sampler` 契约统一。 +- ~~① `start` 的入口类型校验被删~~ → 已恢复;文案的"必须是整数"改成"必须是数值"(这条校验本来就收 float), + 类型名统一 `{type(start).__name__}`。 +- ~~② 签名注解比行为窄~~ → 已改成 `int | np.integer`,`np.int64` 的测试补回来。 +- ~~③ `n_paths == n_steps` 的方阵歧义~~ → 用户选**方案 B**(决策 55):`size=n_paths * n_steps`(整数)、 + 要求返回一维、内部 `reshape(n_paths, n_steps)`;二维返回一律判失败。实测转置二维 / 方阵二维 / 长度不符全部拦下, + 且 `monte_carlo` 风格的采样器可直接复用。 ### ✅ monte_carlo_probability 的 6 条待办(2026-10-05 全部办结) diff --git a/src/mcm_toolkit/solve/simulate.py b/src/mcm_toolkit/solve/simulate.py index d482363..1d98feb 100644 --- a/src/mcm_toolkit/solve/simulate.py +++ b/src/mcm_toolkit/solve/simulate.py @@ -350,8 +350,9 @@ def random_walk( n_paths: 路径条数,正整数,默认 1 条。 step_sampler: - `f(rng, size) -> 一批增量`。批量大小由本函数以 `size=(n_paths, n_steps)` 传入, - 必须返回形状 `(n_paths, n_steps)` 的数组(契约同 `monte_carlo` 的 `sampler`,只是这里是二维)。 + `f(rng, size) -> 一批增量`。**`size` 是整数** `n_paths * n_steps`(与 `monte_carlo` 的 `sampler` 同一契约), + 必须返回**一维**长度 `n_paths * n_steps` 的数组;本函数负责 `reshape(n_paths, n_steps)`(行主序)。 + 返回二维一律判失败 —— 否则"转置的采样器"在 `n_paths == n_steps` 时会静默给出转置结果。 默认 ±1 等概率(对称随机游走)。 start: 起点,写进每条路径的第 0 列。 @@ -376,8 +377,9 @@ def random_walk( 随机游走是"平方根扩散"的原型:位置的标准差随步数按 `√t` 增长,所以典型图形是 `±√t` 的喇叭口。 本函数只负责 `cumsum`;**建模差异全在 `step_sampler` 里** —— 换成 `rng.normal(0, 1, size)` 就是高斯游走, 换成带正漂移的增量就是有趋势的游走。 - `step_sampler` 返回的形状不等于 `(n_paths, n_steps)`、或增量里出现 NaN,都判 `success=False` + `step_sampler` 返回的不是**一维**、长度不等于 `n_paths * n_steps`,或增量里出现 NaN,都判 `success=False` (NaN 口径与 `monte_carlo` 一致);其余异常同样统一走失败分支。 + 步与路径的对应关系是**行主序**:一维数组的前 `n_steps` 个元素属于第 1 条路径。 Examples -------- @@ -417,12 +419,16 @@ def random_walk( } try: - steps = np.asarray(step_sampler(rng, size=(n_paths, n_steps)), dtype=float) - if steps.shape != (n_paths, n_steps): - raise ValueError(f"step_sampler 返回形状必须为 ({n_paths}, {n_steps}),实际得到了 {steps.shape}") + steps = np.asarray(step_sampler(rng, size=n_paths * n_steps), dtype=float) + if steps.shape != (n_paths * n_steps,): + raise ValueError( + f"step_sampler 返回长度必须为 {n_paths * n_steps},实际得到了 {steps.shape}" + ) if np.isnan(steps).any(): raise ValueError("step_sampler 生成的增量数据中包含 NaN,请检查采样逻辑") + steps = steps.reshape(n_paths, n_steps) # 行主序:前 n_steps 个属于第 1 条路径 + cumsum = np.cumsum(steps, axis=1) paths = np.zeros((n_paths, n_steps + 1), dtype=float) diff --git a/tests/test_solve.py b/tests/test_solve.py index d254eb6..7e68f82 100644 --- a/tests/test_solve.py +++ b/tests/test_solve.py @@ -335,19 +335,37 @@ def test_random_walk_rejects_bad_arguments(): random_walk(n_steps=10, start="a") # type: ignore[arg-type] # 就是要传错 -def test_random_walk_failure_returns_result_not_exception(): - """失败也要返回带 params 的 Result,不能把异常抛给调用方 —— 包装层引用未赋值变量那次就靠它拦住。""" +def test_random_walk_rejects_2d_step_sampler(): + """`step_sampler` 必须返回**一维** —— 二维返回(含方阵)一律判失败(易错点 25 的根治)。""" from mcm_toolkit.solve import random_walk def transposed(rng, size): - return rng.normal(0, 1, (size[1], size[0])) + return rng.normal(0, 1, (4, 3)) # 无论要多少,一律给二维 - r = random_walk(n_steps=4, n_paths=6, step_sampler=transposed, random_state=0) - assert r.success is False - assert "形状" in r.message - assert r.params["n_paths"] == 6 # 失败也要留下追溯信息 + r = random_walk(n_steps=4, n_paths=3, step_sampler=transposed, random_state=0) + assert r.success is False # 失败也要返回 Result,不能抛给调用方 + assert "长度" in r.message + assert r.params["n_paths"] == 3 # 失败也要留下追溯信息 assert r.data is None + # 旧方案在 n_paths == n_steps 时对"转置"完全无感;现在同样拦住 + assert random_walk(n_steps=3, n_paths=3, step_sampler=transposed, random_state=0).success is False + + +def test_random_walk_step_sampler_gets_flat_size_and_is_row_major(): + """契约:`size` 是整数 `n_paths * n_steps`,一维增量按**行主序**切成 n_paths 行。""" + from mcm_toolkit.solve import random_walk + + seen = {} + + def sampler(rng, size): + seen["size"] = size + return np.arange(1, size + 1, dtype=float) # 1, 2, 3, 4, 5, 6 + + r = random_walk(n_steps=3, n_paths=2, step_sampler=sampler, random_state=0) + assert seen["size"] == 6 # 整数,不是 (n_paths, n_steps) + assert r.data.tolist() == [[0, 1, 3, 6], [0, 4, 9, 15]] # cumsum(1,2,3) / cumsum(4,5,6) + def test_random_walk_rejects_nan_steps(): """增量含 NaN 判失败 —— 与 monte_carlo 的 NaN 口径(决策 48)一致。"""