feat(solve): random_walk 的 step_sampler 契约改成"整数 size + 一维返回"(方案 B)

用户拍板方案 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
This commit is contained in:
guzhujushi committed 2026-10-05 18:06:55 +08:00
1 parent cd6f613568
commit 9368dd91cb
4 files changed
+73 -38

No files matched your search

+13
View File
@@ -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
+23 -25
View File
@@ -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 全部办结)
+12 -6
View File
@@ -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)
+25 -7
View File
@@ -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)一致。"""