fix(solve): random_walk 失败路径的 UnboundLocalError;第二轮口径收口

口径由用户第二轮改定;Codex 修一处真 bug + 工程侧。

- 真 bug:call_params 写在 try 里、却在 except 被引用,5 种失败输入 5 个全炸 UnboundLocalError
  (本该返回 success=False 的 Result,变成把异常抛给调用方)。按原意前移到 try 之前,未改口径;
  失败时照样带上 n_steps / n_paths / start / random_state
- 用户口径:n_steps/n_paths 拆 TypeError/ValueError;加 steps 形状与 NaN 两条校验;
  默认步长改 rng.choice([-1.0, 1.0]);params 补 random_state;metrics 改 mean_terminal/std_terminal
- 工程:docstring 同步(Returns 的 metrics、Raises 的分工、去掉已删的 start 校验);+3 条测试
  (失败也必须返回带 params 的 Result、NaN 判失败、终点 std ~= sqrt(t)),solve 段 41 条
- 验证:208 收集 / 177 passed / 31 skipped / 0 xfail,ruff 0,pyright 0
- docs:STATUS 决策 54 + 3 条新口径;CHANGELOG;易错点 26
This commit is contained in:
guzhujushi committed 2026-10-05 17:24:23 +08:00
1 parent 8ec08ea9ce
commit 6fca82e3fc
5 files changed
+160 -37

No files matched your search

+18
View File
@@ -466,6 +466,24 @@
- `STATUS.md`:新增**决策 53** 与 `random_walk` 的 4 条待办;`monte_carlo_integral` 的 2 条新口径办结 1 条 - `STATUS.md`:新增**决策 53** 与 `random_walk` 的 4 条待办;`monte_carlo_integral` 的 2 条新口径办结 1 条
(剩"第一道 `isinstance` 分支已冗余");「下一步」推进到 `markov_simulate` / `markov_steady_state`。 (剩"第一道 `isinstance` 分支已冗余");「下一步」推进到 `markov_simulate` / `markov_steady_state`。
### Fixed
- **`random_walk` 的失败路径全部炸成 `UnboundLocalError`**(真 bug,按决策 31 修):`call_params = {...}` 写在 `try:` 里,
而 `except` 分支要引用它 —— 实测 5 种失败输入(形状不对 / 增量含 NaN / 采样器抛异常 / `start` 非数值 / 返回 1-D 数组)
**5 个全中**,本该返回 `success=False` 的 `Result`,变成把异常抛给调用方。按原意把 `call_params` 前移到 `try` 之前,
未改任何口径;失败时照样带上 `n_steps` / `n_paths` / `start` / `random_state`。
### Changed
- `random_walk` 按用户第二轮口径改定(**决策 54**):`n_steps` / `n_paths` 拆成 `TypeError`(类型)/ `ValueError`(取值);
新增 `steps.shape != (n_paths, n_steps)` 与 `np.isnan(steps).any()` 两条校验;默认步长改 `rng.choice([-1.0, 1.0], size=size)`;
`params` 补 `random_state`;`metrics` 由"抄 params"改成 `mean_terminal` / `std_terminal`(终点位置,`ddof=0`)。
- `random_walk` 的 docstring 同步:`Returns` 写实际 metrics、`Raises` 改成"类型错 `TypeError` / 取值错 `ValueError`"、
去掉已不存在的 `start` 校验、`Notes` 记明形状与 NaN 都判失败。
- `tests/test_solve.py`:+3 条("失败也必须返回带 params 的 Result"、NaN 判失败、终点 `std ≈ √t`),
`start="a"` 改为断言 `success is False`(口径待定);`solve` 段 38 → **41** 条,
全量 **208 收集 / 177 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。
- `STATUS.md`:新增**决策 54** 与 `random_walk` 的 3 条新口径;4 条待办标记办结;验证计数更新为 177 / 31。
- `docs/dev/personal-pitfalls.md`:新增第 **26** 条(`except` 用到的变量在 `try` 里才赋值 —— 每条失败路径都炸)。
## [0.0.1] - 2026-10-01 ## [0.0.1] - 2026-10-01
### Added ### Added
+43 -23
View File
@@ -305,9 +305,16 @@
并写清 `step_sampler` 收的是**二维** `size=(n_paths, n_steps)`);③ Examples 增加一条"步长恒为 ±1"的断言 并写清 `step_sampler` 收的是**二维** `size=(n_paths, n_steps)`);③ Examples 增加一条"步长恒为 ±1"的断言
- **测试:新增 6 条**(此前 `random_walk` **一条测试都没有**)—— 形状与 `start` 列、默认步长恒 ±1、 - **测试:新增 6 条**(此前 `random_walk` **一条测试都没有**)—— 形状与 `start` 列、默认步长恒 ±1、
同种子可复现、自定义高斯采样器(带漂移)、3 类非法入参、docstring Example 真能跑 同种子可复现、自定义高斯采样器(带漂移)、3 类非法入参、docstring Example 真能跑
- ⏳ **留给用户 4 条**(见"下一步"):非整数 `n_steps` 该抛 `TypeError` 而现抛 `ValueError`; - **用户第二轮(2026-10-05)按 4 条待办改定**:`n_steps` / `n_paths` 拆成 `TypeError`(类型)/ `ValueError`(取值);
`steps` 形状没校验(`n_paths == n_steps` 时转置的采样器会**静默给出转置结果**);NaN 不判失败; 加 `steps.shape != (n_paths, n_steps)` 与 NaN 两条校验;默认采样器换成 `rng.choice([-1.0, 1.0], size=size)`;
`params` 少了 `random_state`、`metrics` 只是把 `n_steps` / `n_paths` 抄了一遍 `params` 补 `random_state`;`metrics` 由"抄 params"改成 `mean_terminal` / `std_terminal`(终点位置,`ddof=0`)
- **Codex 第二轮又抓到一处真 bug(决策 31)**:用户把 `call_params = {...}` 写进了 `try` 里,而 `except` 分支要引用它 ——
**5 种失败输入 5 个全炸** `UnboundLocalError`(本该返回 `success=False` 的 `Result`,变成把异常抛给调用方)。
按原意把 `call_params` 移到 `try` 之前(不动任何口径),实测失败路径全部恢复正常;新增一条"必然失败"的回归测试(易错点 26)
- **测试:本轮再 +3 → 共 9 条**(新增"失败也必须返回 Result 且带 params"、NaN 判失败、`√t` 扩散);
`start="a"` 那条改为断言 `success is False`(因为用户删掉了 `start` 的入口类型校验,口径待定)
- ⏳ **留给用户 3 条**(见"下一步"):`start` 的类型校验要不要恢复;`n_steps` / `n_paths` 签名注解仍是 `int` 却收 `np.int64`;
`n_paths == n_steps` 时转置采样器仍无法被形状校验发现
- [ ] 剩余 1 项:`save_figure`(A 档 = 工程,**Codex 直接写**) - [ ] 剩余 1 项:`save_figure`(A 档 = 工程,**Codex 直接写**)
@@ -315,7 +322,7 @@
| 检查 | venv | conda `mcm-toolkit` | | 检查 | venv | conda `mcm-toolkit` |
|---|---|---| |---|---|---|
| `pytest` | 见下(单列) | **174 passed, 31 skipped(0 xfail)** | | `pytest` | 见下(单列) | **177 passed, 31 skipped(0 xfail)** |
| `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 | | `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 |
| `ruff check` | All checks passed | **All checks passed**(0 error) | | `ruff check` | All checks passed | **All checks passed**(0 error) |
| `scripts/doctor.ps1` | 跑通 | — | | `scripts/doctor.ps1` | 跑通 | — |
@@ -325,8 +332,8 @@
(修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与 (修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与
`docs/04-environment.md` 坑 7) `docs/04-environment.md` 坑 7)
- base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致) - base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致)
- **205 条用例:174 passed / 31 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、 - **208 条用例:177 passed / 31 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、
`test_core.py` 23、`test_solve.py` 38(本轮 +6:`random_walk` 首次有测试)、`test_viz.py` 8、`test_decision.py` 7、 `test_core.py` 23、`test_solve.py` 41(本轮 +3:失败路径 / NaN / `√t` 扩散)、`test_viz.py` 8、`test_decision.py` 7、
`test_stats.py` 53、`test_models.py` 5 `test_stats.py` 53、`test_models.py` 5
- **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正 - **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正
- 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环 - 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环
@@ -475,16 +482,23 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向
`step_sampler` 的 `size` 是 `(n_paths, n_steps)` 而不是 `monte_carlo` 那样的一个整数 —— 契约已写进 docstring, `step_sampler` 的 `size` 是 `(n_paths, n_steps)` 而不是 `monte_carlo` 那样的一个整数 —— 契约已写进 docstring,
**遗留 4 条待办**见"下一步"(`TypeError`/`ValueError` 分工、`steps` 形状校验、NaN 口径、`params` 缺 `random_state`)。 **遗留 4 条待办**见"下一步"(`TypeError`/`ValueError` 分工、`steps` 形状校验、NaN 口径、`params` 缺 `random_state`)。
| 54 | `random_walk` 第二轮口径由**用户**改定:`n_steps`/`n_paths` 类型错抛 `TypeError`、取值错抛 `ValueError`;加形状与 NaN 两条校验;默认步长改 `rng.choice([-1.0, 1.0])`;`params` 补 `random_state`;`metrics` 改 `mean_terminal`/`std_terminal` |
用户 2026-10-05 自行改定,Codex 只修了一处**纯 bug**:`call_params` 定义在 `try` 内、却被 `except` 引用,
导致**所有失败路径**抛 `UnboundLocalError`(实测 5/5 失败输入全中)。按原意前移到 `try` 之前,未改任何口径。
**遗留 3 条**:① `start` 的入口类型校验被删(`start="a"` 从 `TypeError` 变成 `success=False`),是有意还是顺手删的?
② 签名 `n_steps: int` / `n_paths: int` 与运行期收 `np.int64` 不一致(§4.7;`monte_carlo_integral` 的 `n` 已放宽);
③ `n_paths == n_steps` 时,返回转置形状的采样器仍会静默通过(形状校验天生发现不了,见易错点 25)。
## 下一步 ## 下一步
### ⭐ 当前进度与待办(2026-10-05 第三轮) ### ⭐ 当前进度与待办(2026-10-05 第四轮)
**本轮:`random_walk` 已由用户写完第一版并审查通过**(L1,S 档)。Codex 改了一处**真 bug** **本轮:`random_walk` 第二版(用户按 4 条待办改定)已审查通过**。Codex 又抓到一处**真 bug**:
(默认采样器 `rng.integers(..., dtype=float)` 是 numpy 不支持的写法,默认路径 100% 失败,见决策 53), `call_params` 定义在 `try` 内、却在 `except` 里被引用,**所有失败路径**都抛 `UnboundLocalError`(易错点 26,见决策 54);
另补 ruff / docstring / **6 条从无到有的测试**。 另补 docstring、+3 条测试(共 9 条)。
**接下来三件事** **接下来三件事**
1. `random_walk` 有 **4 条待办**(下面一节)——都是你的活。 1. `random_walk` 的 4 条待办已办结,新增 **3 条**(下面一节)——都是你的活。
2. `ci_low` / `ci_high` 的语义确认一句(也是你的活),确认后 Codex 补 `docs/algorithms/monte_carlo.md` 卡片。 2. `ci_low` / `ci_high` 的语义确认一句(也是你的活),确认后 Codex 补 `docs/algorithms/monte_carlo.md` 卡片。
3. L0 最后一项 `save_figure`(A 档 = 工程,`viz/export.py`)—— **Codex 直接实现**,你只读。 3. L0 最后一项 `save_figure`(A 档 = 工程,`viz/export.py`)—— **Codex 直接实现**,你只读。
@@ -492,19 +506,25 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向
两者是同一件事的两面:前者按转移矩阵**逐代抽状态**(`rng.choice` 的 `p=` 版),后者用**迭代**求平稳分布。 两者是同一件事的两面:前者按转移矩阵**逐代抽状态**(`rng.choice` 的 `p=` 版),后者用**迭代**求平稳分布。
`markov_steady_state` 的 docstring 已写明口径:用迭代而不用特征分解,因为"收敛快不快"本身就是论文里要写的性质。 `markov_steady_state` 的 docstring 已写明口径:用迭代而不用特征分解,因为"收敛快不快"本身就是论文里要写的性质。
### ⏳ random_walk 的 4 条待办(2026-10-05,你的活) ### ✅ random_walk 的 4 条待办(2026-10-05 已办结)
1. **非整数 `n_steps` 抛的是 `ValueError`,按 §4.7 该是 `TypeError`**:现在 `n_steps=2.5` / `n_steps=True` 都走 - ~~① 非整数 `n_steps` 抛 `ValueError`~~ → 已拆成两条:类型错 `TypeError`、取值错(`<= 0`)`ValueError`,`n_paths` 同样处理。
`ValueError("必须是正整数")`。同模块的 `monte_carlo` / `monte_carlo_probability` 是"类型不对 → `TypeError`、 - ~~② `steps` 没有形状校验~~ → 已加 `steps.shape != (n_paths, n_steps)` 判失败(实测转置采样器现在能拦住)。
取值不对(`<= 0`)→ `ValueError`",这里要拆成两条。 - ~~③ NaN 不判失败~~ → 已加 `np.isnan(steps).any()`,与 `monte_carlo` 的 NaN 口径(决策 48)拉齐。
2. **`steps` 没有形状校验,会静默出错**:实测 `n_paths == n_steps` 时,一个返回**转置**形状的采样器 - ~~④ `params` 少 `random_state` / `metrics` 抄了一遍~~ → 已补 `random_state`;`metrics` 换成 `mean_terminal` / `std_terminal`
能拿到 `success=True` 和形状"正确"的 `data` —— 数字却是转置过来的(`n_paths != n_steps` 时才报错)。 (终点位置的均值与标准差,`ddof=0`)—— 这才是"游走"该看的指标,测试里还顺手验了 `std ≈ √t`。
方向:`steps.shape != (n_paths, n_steps)` 就判失败,和 `monte_carlo` 的 `len(data) != n` 一个套路。
3. **NaN 不判失败**:`step_sampler` 返回全 NaN 时现在照样 `success=True`(决策 48 里 `monte_carlo` 是判失败的)—— ### ⏳ random_walk 还有 3 条口径(新增,你的活)
两个函数的 NaN 口径要不要拉齐?
4. **`params` 少了 `random_state`**(另两个函数都记了,`Result.params` 的用途就是"能复现"); 1. **`start` 的入口类型校验被删了**:`start="a"` 现在走 `success=False`("could not convert string to float"),
另外 `metrics={"n_steps", "n_paths"}` 只是把 `params` 里的两个数抄了一遍 —— 要么换成真正的指标 不再是入口的 `TypeError`。是有意改成"交给失败分支"(那 docstring 里我已如实写明"入口不校验类型"),还是顺手删的?
(终点的均值 / 标准差、理论 `√t` 对比),要么直接去掉。 要恢复的话,原来那行是 `if not isinstance(start, (int, float, np.number)): raise TypeError(...)`。
2. **签名注解比行为窄**:`n_steps: int` / `n_paths: int`,但入口校验明明收 `np.integer`(实测 `np.int64` 能过)。
按 §4.7 应与 `monte_carlo_integral` 的 `n` 一样写成 `int | np.integer` —— 现在 pyright 会对老老实实按注解传参的
调用方报 `reportArgumentType`(我写测试时就被拦了一次,所以那条测试暂时撤了)。
3. **`n_paths == n_steps` 时形状校验天生失效**:转置的采样器返回 `(n_steps, n_paths)`,正好等于 `(n_paths, n_steps)`,
校验放行、结果静默转置(易错点 25)。要么接受这个边界,要么让 `step_sampler` 契约改成"返回 `(n_paths, n_steps)` 之外的形状即失败"
之外的更强约束(比如显式要求 `size` 是 tuple 并校验每个维度)—— 你定。
### ✅ monte_carlo_probability 的 6 条待办(2026-10-05 全部办结) ### ✅ monte_carlo_probability 的 6 条待办(2026-10-05 全部办结)
+18
View File
@@ -47,6 +47,7 @@
| 23 | 体积/宽度这种"看着总为正"的量也会减反,维度奇偶还会把符号错误藏住 | 2026-10-05 | | 23 | 体积/宽度这种"看着总为正"的量也会减反,维度奇偶还会把符号错误藏住 | 2026-10-05 |
| 24 | 默认参数路径从没被执行过 —— 默认采样器用了库不接受的参数 | 2026-10-05 | | 24 | 默认参数路径从没被执行过 —— 默认采样器用了库不接受的参数 | 2026-10-05 |
| 25 | 两个维度相等时,转置的形状检查会静默通过 | 2026-10-05 | | 25 | 两个维度相等时,转置的形状检查会静默通过 | 2026-10-05 |
| 26 | `except` 用到的变量在 `try` 里才赋值 —— 每条失败路径都炸 | 2026-10-05 |
| 22 | 失败结果的 `message` 被包装层无条件覆盖 —— 对外永远报"成功" | 2026-10-05 | | 22 | 失败结果的 `message` 被包装层无条件覆盖 —— 对外永远报"成功" | 2026-10-05 |
| 23 | 体积/宽度这种"看着总为正"的量也会减反,维度奇偶还会把符号错误藏住 | 2026-10-05 | | 23 | 体积/宽度这种"看着总为正"的量也会减反,维度奇偶还会把符号错误藏住 | 2026-10-05 |
@@ -435,3 +436,20 @@
对外部传进来的批量数组显式校验 `shape == (n, d)`,别指望后面的运算替你把关。 对外部传进来的批量数组显式校验 `shape == (n, d)`,别指望后面的运算替你把关。
- 首次:2026-10-05|复发:0 - 首次:2026-10-05|复发:0
### 26. `except` 用到的变量在 `try` 里才赋值 —— 每条失败路径都炸
- 现象:`random_walk` 把 `call_params = {...}` 写在 `try:` 里面,但 `except` 分支要 `params=call_params`。
于是**每一个失败路径**都变成 `UnboundLocalError: cannot access local variable 'call_params' where it is not associated with a value`
—— 实测 5 种失败输入(形状不对 / 增量含 NaN / 采样器抛异常 / `start` 非数值 / 返回 1-D 数组)**5 个全中**,
本该返回 `success=False` 的 `Result`,变成了把异常直接抛给调用方。
- 为什么会错:写的时候脑子里只过"成功路径",`except` 是照上一版的结构留下来的;
**异常分支只有真抛异常才会执行**,正常跑一遍、ruff、pyright 全都看不见(当时两个都 0 error)。
与第 24 条(默认路径没跑过)是同一类:**分支覆盖靠"我以为",不靠执行**。
还有一层:`params` 里那几个值(`n_steps` / `n_paths` / `start` / `random_state`)在 `try` 之前就已经定了,
它们本来就**不属于**"可能失败的计算",放进去纯属位置错误。
- 以后怎么防:① 写 `except` 时,逐个变量问"它是在 `try` 里才被赋值的吗"——**失败的返回要在 `try` 之前准备好**;
② 每写一个 `try`,必须配一个**注定失败**的用例(`assert r.success is False`),专门喂 `except` 分支;
③ 包装层的失败结果也要带 `params`,出问题才追得回去。
- 首次:2026-10-05|复发:0
+34 -12
View File
@@ -354,27 +354,29 @@ def random_walk(
必须返回形状 `(n_paths, n_steps)` 的数组(契约同 `monte_carlo` 的 `sampler`,只是这里是二维)。 必须返回形状 `(n_paths, n_steps)` 的数组(契约同 `monte_carlo` 的 `sampler`,只是这里是二维)。
默认 ±1 等概率(对称随机游走)。 默认 ±1 等概率(对称随机游走)。
start: start:
起点,写进每条路径的第 0 列。 起点,写进每条路径的第 0 列(**入口不校验类型**,传非数值会落到失败分支)。
random_state: random_state:
随机种子。`None` 时跟随 `core.set_seed` 的全局流,见 `core.random.get_rng`。 随机种子。`None` 时跟随 `core.set_seed` 的全局流,见 `core.random.get_rng`。
Returns Returns
------- -------
`Result`:`data` 形状 `(n_paths, n_steps + 1)`,第 0 列恒为 `start`,之后是逐列累加出来的位置; `Result`:`data` 形状 `(n_paths, n_steps + 1)`,第 0 列恒为 `start`,之后是逐列累加出来的位置;
`metrics={"n_steps", "n_paths"}`。 `metrics={"mean_terminal", "std_terminal"}`(终点的均值与标准差,`std` 用 `ddof=0`)。
Raises Raises
------ ------
TypeError:
`n_steps` / `n_paths` 不是整数。
ValueError: ValueError:
`n_steps` / `n_paths` 不是正整数。 `n_steps` / `n_paths` 不是正整数。
TypeError:
`start` 不是数值。
Notes Notes
----- -----
随机游走是"平方根扩散"的原型:位置的标准差随步数按 `√t` 增长,所以典型图形是 `±√t` 的喇叭口。 随机游走是"平方根扩散"的原型:位置的标准差随步数按 `√t` 增长,所以典型图形是 `±√t` 的喇叭口。
本函数只负责 `cumsum`;**建模差异全在 `step_sampler` 里** —— 换成 `rng.normal(0, 1, size)` 就是高斯游走, 本函数只负责 `cumsum`;**建模差异全在 `step_sampler` 里** —— 换成 `rng.normal(0, 1, size)` 就是高斯游走,
换成带正漂移的增量就是有趋势的游走。 换成带正漂移的增量就是有趋势的游走。
`step_sampler` 返回的形状不等于 `(n_paths, n_steps)`、或增量里出现 NaN,都判 `success=False`
(NaN 口径与 `monte_carlo` 一致);其余异常同样统一走失败分支。
Examples Examples
-------- --------
@@ -385,23 +387,37 @@ def random_walk(
>>> bool(np.all(np.abs(np.diff(r.data, axis=1)) == 1)) # 默认步长恒为 1 >>> bool(np.all(np.abs(np.diff(r.data, axis=1)) == 1)) # 默认步长恒为 1
True True
""" """
if isinstance(n_steps, bool) or not isinstance(n_steps, (int, np.integer)) or n_steps <= 0: if isinstance(n_steps, bool) or not isinstance(n_steps, (int, np.integer)):
raise TypeError(f"n_steps 必须是整数,你传入了 {type(n_steps).__name__}")
if n_steps <= 0:
raise ValueError(f"n_steps 必须是正整数,你传入了 {n_steps}") raise ValueError(f"n_steps 必须是正整数,你传入了 {n_steps}")
if isinstance(n_paths, bool) or not isinstance(n_paths, (int, np.integer)) or n_paths <= 0:
if isinstance(n_paths, bool) or not isinstance(n_paths, (int, np.integer)):
raise TypeError(f"n_paths 必须是整数,你传入了 {type(n_paths).__name__}")
if n_paths <= 0:
raise ValueError(f"n_paths 必须是正整数,你传入了 {n_paths}") raise ValueError(f"n_paths 必须是正整数,你传入了 {n_paths}")
if not isinstance(start, (int, float, np.number)):
raise TypeError(f"start 必须是数值,你传入了 {type(start).__name__}")
rng = get_rng(random_state) rng = get_rng(random_state)
if step_sampler is None: if step_sampler is None:
def _default_step_sampler(rng, size): def _default_step_sampler(rng, size):
return rng.integers(0, 2, size=size) * 2 - 1 return rng.choice([-1.0, 1.0], size=size)
step_sampler = _default_step_sampler step_sampler = _default_step_sampler
call_params = {
"n_steps": n_steps,
"n_paths": n_paths,
"start": start,
"random_state": random_state,
}
try: try:
steps = np.asarray(step_sampler(rng, size=(n_paths, n_steps)), dtype=float) 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,请检查采样逻辑")
cumsum = np.cumsum(steps, axis=1) cumsum = np.cumsum(steps, axis=1)
@@ -409,10 +425,16 @@ def random_walk(
paths[:, 0] = start paths[:, 0] = start
paths[:, 1:] = start + cumsum paths[:, 1:] = start + cumsum
terminal_positions = paths[:, -1] # 取最后一列,得到 n_paths 个终点位置
metrics = {
"mean_terminal": float(np.mean(terminal_positions)),
"std_terminal": float(np.std(terminal_positions))
}
return Result( return Result(
name="random_walk", name="random_walk",
params={"n_steps": n_steps, "n_paths": n_paths, "start": start}, params=call_params,
metrics={"n_steps": n_steps, "n_paths": n_paths}, metrics=metrics,
data=paths, data=paths,
success=True, success=True,
message=f"随机游走模拟成功:生成了 {n_paths} 条路径,每条路径 {n_steps} 步。" message=f"随机游走模拟成功:生成了 {n_paths} 条路径,每条路径 {n_steps} 步。"
@@ -420,7 +442,7 @@ def random_walk(
except Exception as e: except Exception as e:
return Result( return Result(
name="random_walk", name="random_walk",
params={"n_steps": n_steps, "n_paths": n_paths, "start": start}, params=call_params,
metrics={}, metrics={},
data=None, data=None,
success=False, success=False,
+47 -2
View File
@@ -318,14 +318,59 @@ def test_random_walk_accepts_custom_step_sampler():
def test_random_walk_rejects_bad_arguments(): def test_random_walk_rejects_bad_arguments():
"""§4.7 的分工:类型不对抛 TypeError,取值不对(<= 0)抛 ValueError。"""
from mcm_toolkit.solve import random_walk from mcm_toolkit.solve import random_walk
with pytest.raises(TypeError):
random_walk(n_steps=2.5) # type: ignore[arg-type] # 就是要传错
with pytest.raises(TypeError):
random_walk(n_steps=True)
with pytest.raises(TypeError):
random_walk(n_steps=10, n_paths=1.5) # type: ignore[arg-type] # 就是要传错
with pytest.raises(ValueError): with pytest.raises(ValueError):
random_walk(n_steps=0) random_walk(n_steps=0)
with pytest.raises(ValueError): with pytest.raises(ValueError):
random_walk(n_steps=10, n_paths=0) random_walk(n_steps=10, n_paths=0)
with pytest.raises(TypeError): r = random_walk(n_steps=10, start="a") # type: ignore[arg-type] # 就是要传错
random_walk(n_steps=10, start="a") # type: ignore[arg-type] # 就是要传错 assert r.success is False # 口径待定:入口抛 TypeError 也可以,见 STATUS"下一步"
def test_random_walk_failure_returns_result_not_exception():
"""失败也要返回带 params 的 Result,不能把异常抛给调用方 —— 包装层引用未赋值变量那次就靠它拦住。"""
from mcm_toolkit.solve import random_walk
def transposed(rng, size):
return rng.normal(0, 1, (size[1], size[0]))
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 # 失败也要留下追溯信息
assert r.data is None
def test_random_walk_rejects_nan_steps():
"""增量含 NaN 判失败 —— 与 monte_carlo 的 NaN 口径(决策 48)一致。"""
from mcm_toolkit.solve import random_walk
r = random_walk(
n_steps=4,
n_paths=2,
step_sampler=lambda rng, size: np.full(size, np.nan),
random_state=0,
)
assert r.success is False
assert "NaN" in r.message
def test_random_walk_metrics_are_terminal_positions():
"""终点位置的标准差按 √t 扩散(t = n_steps)—— 随机游走的核心性质。"""
from mcm_toolkit.solve import random_walk
n_steps = 100
r = random_walk(n_steps=n_steps, n_paths=2000, start=5.0, random_state=0)
assert abs(r.metrics["mean_terminal"] - 5.0) < 5.0 # 对称游走,期望还在起点附近
assert abs(r.metrics["std_terminal"] - np.sqrt(n_steps)) < 5.0 # √t 扩散
def test_random_walk_docstring_example_runs(): def test_random_walk_docstring_example_runs():