From 8ec08ea9ce70942de71a593b7abc9723f3a4da9b Mon Sep 17 00:00:00 2001 From: guzhujushi Date: Mon, 5 Oct 2026 17:04:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(solve):=20random=5Fwalk=20=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=EF=BC=9B=E4=BF=AE=E9=BB=98=E8=AE=A4=E9=87=87=E6=A0=B7?= =?UTF-8?q?=E5=99=A8=E7=9C=9F=20bug=EF=BC=88integers=20=E4=B8=8D=E6=94=B6?= =?UTF-8?q?=20float=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit random_walk 由用户本人写;Codex 改一处真 bug + 工程侧。 - 真 bug:默认采样器 rng.integers(0, 2, size=size, dtype=float) 被 numpy 拒掉 (Unsupported dtype dtype(float64) for integers),默认路径 100% success=False、 data=None,连 docstring 的 Examples 都跑不通。改成 integers(size=size) * 2 - 1, 由后面的 np.asarray(..., dtype=float) 转 float;实测 (5,101)、步长恒 ±1、同种子可复现 - 工程:ruff W293 x2;docstring 补全(n_steps/n_paths/start/random_state + Returns / Raises / Notes,写清 step_sampler 收二维 size=(n_paths, n_steps)) - tests:random_walk 首次有测试,新增 6 条(形状 + start、步长恒 ±1、可复现、自定义 高斯采样器、3 类非法入参、docstring Example) - monte_carlo_integral:用户第二轮已收 ndarray + 把三行提出 if/else(决策 52) - 验证:205 收集 / 174 passed / 31 skipped / 0 xfail,ruff 0,pyright 0 - docs:STATUS 决策 53 + random_walk 4 条待办;CHANGELOG;易错点 24/25,第 18 条复发 +1 --- CHANGELOG.md | 23 ++++++++ STATUS.md | 76 +++++++++++++++++++------ docs/dev/personal-pitfalls.md | 33 ++++++++++- src/mcm_toolkit/solve/simulate.py | 94 ++++++++++++++++++++++++++----- tests/test_solve.py | 60 ++++++++++++++++++++ 5 files changed, 254 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cbcdaa..065e446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -443,6 +443,29 @@ - `STATUS.md`:integral 的 3 条待办标记办结、概率函数的 6 条待办全部办结;新增**决策 52** 与 2 条新口径 (`bounds` 收不收 ndarray、`if`/`else` 里那三行重复);验证计数更新为 168 / 31。 +### Added +- `solve.random_walk` 落地(**L1,S 档**,实现由用户本人写):`step_sampler(rng, size=(n_paths, n_steps))` 一次抽完整批增量 → + `np.cumsum(..., axis=1)` → 首列填 `start`、其余列 `start + cumsum`;`data` 形状 `(n_paths, n_steps + 1)`。 + 配套 `tests/test_solve.py` **新增 6 条**(此前这个函数一条测试都没有)。 + +### Fixed +- **`random_walk` 的默认采样器整个不可用**(真 bug,按决策 31 修):`rng.integers(0, 2, size=size, dtype=float)` + 被 numpy 拒掉(`TypeError: Unsupported dtype dtype('float64') for integers`)—— 默认路径 100% `success=False`、 + `data=None`,连 docstring 的 Examples 都跑不通。改成 `rng.integers(0, 2, size=size) * 2 - 1` + (由后面的 `np.asarray(..., dtype=float)` 统一转 float);实测形状 `(5, 101)`、步长恒 ±1、同种子可复现。 +- 格式:`simulate.py` 又两行"只有空格的行"(ruff W293 ×2)。 + +### Changed +- `monte_carlo_integral` 按用户第二轮口径收口(**决策 52**):`bounds` 现在也收 `np.ndarray`; + `name` / `params["bounds"]` / `params["dimension"]` 从 `if` / `else` 两支提到 `if` 之前,只让 `message` 分两支。 +- `random_walk` 的 docstring 补全(原文只有 `step_sampler` 一节):补齐 `n_steps` / `n_paths` / `start` / `random_state` + 与 `Returns` / `Raises` / `Notes`,并写清 `step_sampler` 收的是**二维** `size=(n_paths, n_steps)`。 +- `tests/test_solve.py`:新增 6 条 `random_walk` 测试(形状与 `start` 列、默认步长恒 ±1、同种子可复现、 + 自定义高斯采样器、3 类非法入参、docstring Example 真能跑);`solve` 段 32 → **38** 条, + 全量 **205 收集 / 174 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。 +- `STATUS.md`:新增**决策 53** 与 `random_walk` 的 4 条待办;`monte_carlo_integral` 的 2 条新口径办结 1 条 + (剩"第一道 `isinstance` 分支已冗余");「下一步」推进到 `markov_simulate` / `markov_steady_state`。 + ## [0.0.1] - 2026-10-01 ### Added diff --git a/STATUS.md b/STATUS.md index dd5f750..f7441f8 100644 --- a/STATUS.md +++ b/STATUS.md @@ -21,7 +21,7 @@ 当前 **L0 11/12**:`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、 `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`(**用户写**,2026-10-05)。剩余 1 项:`save_figure`(Codex 写)。 +`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 条新口径,见"下一步"。 ## 任务清单 @@ -292,13 +292,30 @@ - ✅ **3 条待办已由用户第二轮办结**(同日):`message` 改成 `if result.success:` / `else:` 两支(失败语义不再被覆盖); 入口补 `isinstance(bounds, (list, tuple))` + `ndim == 2 and shape[1] == 2` 两道(`[0, 1]` 从裸 `IndexError` 变成可读 `ValueError`); `n` 注解放宽为 `int | np.integer`。⏳ 尚有 2 条新口径(`bounds` 收不收 ndarray、`if`/`else` 三行重复)见"下一步" +- [x] `solve/simulate.py` 的 `random_walk`(**L1,S 档,算法由用户本人写**,2026-10-05) + - 核心:`step_sampler(rng, size=(n_paths, n_steps))` 一次抽完整批增量 → `np.cumsum(..., axis=1)` → + 首列填 `start`、其余列 `start + cumsum`。`data` 形状 `(n_paths, n_steps + 1)` + - **Codex 改了一处真 bug(决策 31,纯 bug)**:默认采样器写 `rng.integers(0, 2, size=size, dtype=float)`, + 而 numpy 的 `integers` 不收 `dtype=float`(`TypeError: Unsupported dtype dtype('float64') for integers`) + —— **默认路径 100% 失败**(`success=False`、`data=None`),连 docstring 的 Examples 都跑不通。 + 改成 `rng.integers(0, 2, size=size) * 2 - 1`(后面的 `np.asarray(..., dtype=float)` 负责转 float)。 + 实测:`(5, 101)` 形状、`diff` 恒为 ±1、同种子可复现 + - **Codex 只碰工程侧**:① ruff 两处 W293(只有空格的行);② docstring 补全(原只有 `step_sampler` 一节, + 现补齐 `n_steps` / `n_paths` / `start` / `random_state` + `Returns` / `Raises` / `Notes`, + 并写清 `step_sampler` 收的是**二维** `size=(n_paths, n_steps)`);③ Examples 增加一条"步长恒为 ±1"的断言 + - **测试:新增 6 条**(此前 `random_walk` **一条测试都没有**)—— 形状与 `start` 列、默认步长恒 ±1、 + 同种子可复现、自定义高斯采样器(带漂移)、3 类非法入参、docstring Example 真能跑 + - ⏳ **留给用户 4 条**(见"下一步"):非整数 `n_steps` 该抛 `TypeError` 而现抛 `ValueError`; + `steps` 形状没校验(`n_paths == n_steps` 时转置的采样器会**静默给出转置结果**);NaN 不判失败; + `params` 少了 `random_state`、`metrics` 只是把 `n_steps` / `n_paths` 抄了一遍 + - [ ] 剩余 1 项:`save_figure`(A 档 = 工程,**Codex 直接写**) ### 验证结果 | 检查 | venv | conda `mcm-toolkit` | |---|---|---| -| `pytest` | 见下(单列) | **168 passed, 31 skipped(0 xfail)** | +| `pytest` | 见下(单列) | **174 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` | 跑通 | — | @@ -308,8 +325,8 @@ (修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与 `docs/04-environment.md` 坑 7) - base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致) -- **199 条用例:168 passed / 31 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、 - `test_core.py` 23、`test_solve.py` 32(本轮再 +2:非法 `bounds` 扩到 4 类 + 失败 message 语义 / `np.int64`)、`test_viz.py` 8、 +- **205 条用例:174 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_stats.py` 53、`test_models.py` 5 - **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正 - 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环 @@ -450,21 +467,44 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 ① `isinstance(bounds, (list, tuple))` 把 `np.array([[0, 1]])` 也拒了,与"尽量传 ndarray"的偏好冲突; ② `if` / `else` 两支里 `name` / `params["bounds"]` / `params["dimension"]` 各写了一遍(易错点 20)。 +| 53 | `random_walk` 的默认步长采样器**不能用 `rng.integers(..., dtype=float)`**(numpy 直接抛 `TypeError`);`step_sampler` 收到的 `size` 是**二维** `(n_paths, n_steps)` | +用户 2026-10-05 写完第一版。默认采样器原写 `rng.integers(0, 2, size=size, dtype=float) * 2.0 - 1.0` —— +`Generator.integers` 不收浮点 dtype(`TypeError: Unsupported dtype dtype('float64') for integers`), +于是**整个默认路径 100% 失败**,连 docstring 的 Examples 都跑不通;Codex 按决策 31 改成 `rng.integers(0, 2, size=size) * 2 - 1` +(交给后面的 `np.asarray(..., dtype=float)` 转类型),实测形状 `(5, 101)`、步长恒 ±1、同种子可复现。 +`step_sampler` 的 `size` 是 `(n_paths, n_steps)` 而不是 `monte_carlo` 那样的一个整数 —— 契约已写进 docstring, +**遗留 4 条待办**见"下一步"(`TypeError`/`ValueError` 分工、`steps` 形状校验、NaN 口径、`params` 缺 `random_state`)。 + ## 下一步 -### ⭐ 当前进度与待办(2026-10-05 第二轮) +### ⭐ 当前进度与待办(2026-10-05 第三轮) -**本轮:`monte_carlo_integral` 已由用户写完并审查通过**(L1,S 档)。Codex 只改了一处**纯 bug** -(体积 `lows - highs` → `highs - lows`,见决策 51)—— **算法结构一行未动**。 +**本轮:`random_walk` 已由用户写完第一版并审查通过**(L1,S 档)。Codex 改了一处**真 bug** +(默认采样器 `rng.integers(..., dtype=float)` 是 numpy 不支持的写法,默认路径 100% 失败,见决策 53), +另补 ruff / docstring / **6 条从无到有的测试**。 **接下来三件事** -1. `monte_carlo_integral` 的 3 条待办**已办结**;新增 2 条口径(下面一节)——都是你的活。 +1. `random_walk` 有 **4 条待办**(下面一节)——都是你的活。 2. `ci_low` / `ci_high` 的语义确认一句(也是你的活),确认后 Codex 补 `docs/algorithms/monte_carlo.md` 卡片。 3. L0 最后一项 `save_figure`(A 档 = 工程,`viz/export.py`)—— **Codex 直接实现**,你只读。 -**你下一个要写的(S 档,仍在 `solve/simulate.py`)**:`random_walk`。 -它和刚写完的两个函数是**同一套骨架的第三次复用**(造批 → 一次算 → 汇总),新东西只有两处: -`np.cumsum` 把增量累成轨迹;`data` 的 shape 从 `(n,)` 变成 `(n_paths, n_steps + 1)` 的**二维**。 +**你下一个要写的(S 档,仍在 `solve/simulate.py`)**:`markov_simulate` → `markov_steady_state`。 +两者是同一件事的两面:前者按转移矩阵**逐代抽状态**(`rng.choice` 的 `p=` 版),后者用**迭代**求平稳分布。 +`markov_steady_state` 的 docstring 已写明口径:用迭代而不用特征分解,因为"收敛快不快"本身就是论文里要写的性质。 + +### ⏳ random_walk 的 4 条待办(2026-10-05,你的活) + +1. **非整数 `n_steps` 抛的是 `ValueError`,按 §4.7 该是 `TypeError`**:现在 `n_steps=2.5` / `n_steps=True` 都走 + `ValueError("必须是正整数")`。同模块的 `monte_carlo` / `monte_carlo_probability` 是"类型不对 → `TypeError`、 + 取值不对(`<= 0`)→ `ValueError`",这里要拆成两条。 +2. **`steps` 没有形状校验,会静默出错**:实测 `n_paths == n_steps` 时,一个返回**转置**形状的采样器 + 能拿到 `success=True` 和形状"正确"的 `data` —— 数字却是转置过来的(`n_paths != n_steps` 时才报错)。 + 方向:`steps.shape != (n_paths, n_steps)` 就判失败,和 `monte_carlo` 的 `len(data) != n` 一个套路。 +3. **NaN 不判失败**:`step_sampler` 返回全 NaN 时现在照样 `success=True`(决策 48 里 `monte_carlo` 是判失败的)—— + 两个函数的 NaN 口径要不要拉齐? +4. **`params` 少了 `random_state`**(另两个函数都记了,`Result.params` 的用途就是"能复现"); + 另外 `metrics={"n_steps", "n_paths"}` 只是把 `params` 里的两个数抄了一遍 —— 要么换成真正的指标 + (终点的均值 / 标准差、理论 `√t` 对比),要么直接去掉。 ### ✅ monte_carlo_probability 的 6 条待办(2026-10-05 全部办结) @@ -488,13 +528,15 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 `bounds=[0, 1]` 现在报"bounds 必须形如 [(lo, hi), ...],当前形状为 (2,),请检查是否漏了括号"。 - ~~③ `n: int`~~ → 已改 `int | np.integer`。 -### ⏳ monte_carlo_integral 还有 2 条口径(新增,你的活) +### ✅ monte_carlo_integral 的 2 条新口径(2026-10-05 已办 1 条、剩 1 条) -1. **`bounds` 把 ndarray 也拒了**:`isinstance(bounds, (list, tuple))` 使 `np.array([[0.0, 1.0]])` 直接 `ValueError`。 - 这和你自己定的口径("以后我也尽量不传 list 和 tuple,尽量用 `pd.DataFrame` / `pd.Series` / `np.array`")冲突 —— - 要不要放成"list / tuple / ndarray 都收"?(`np.asarray` 本来就能接住三者) -2. **`if` / `else` 两个分支里那三行写了两遍**:`result.name`、`params["bounds"]`、`params["dimension"]` 各写一次。 - 要改的话很简单:这三行提到 `if` 之前,只让 `message` 分两支 —— 正是易错点 20 说的"同一个信息写两遍"。 +- ~~① `bounds` 把 ndarray 拒了~~ → 已加 `isinstance(bounds, np.ndarray)` 分支,`np.array([[0.0, 1.0]])` 现在能收。 +- ~~② `if` / `else` 里那三行重复~~ → 已把 `name` / `params["bounds"]` / `params["dimension"]` 提到 `if` 之前,只剩 `message` 分两支。 + +**还剩 1 条(你的活)** +1. **第一道 `isinstance` 分支现在是冗余的**:`np.asarray` 本来就同时收 list / tuple / ndarray, + 而 `[]`、`"abc"`、标量这些都会被下面那句形状校验抓住(报"当前形状为 (0,)"等)。 + 整段可以塌成两行:`bounds_arr = np.asarray(bounds)` + 形状校验 —— 顺带 `bounds.copy()` 也是多余的一次拷贝。 ### ⏳ monte_carlo 只剩 1 个口径要你确认 diff --git a/docs/dev/personal-pitfalls.md b/docs/dev/personal-pitfalls.md index 75da37c..30d578e 100644 --- a/docs/dev/personal-pitfalls.md +++ b/docs/dev/personal-pitfalls.md @@ -45,6 +45,10 @@ | 21 | 公式里的 `^` 不是乘方,而且被 `except` 吞成 success=False | 2026-10-05 | | 22 | 失败结果的 `message` 被包装层无条件覆盖 —— 对外永远报"成功" | 2026-10-05 | | 23 | 体积/宽度这种"看着总为正"的量也会减反,维度奇偶还会把符号错误藏住 | 2026-10-05 | +| 24 | 默认参数路径从没被执行过 —— 默认采样器用了库不接受的参数 | 2026-10-05 | +| 25 | 两个维度相等时,转置的形状检查会静默通过 | 2026-10-05 | +| 22 | 失败结果的 `message` 被包装层无条件覆盖 —— 对外永远报"成功" | 2026-10-05 | +| 23 | 体积/宽度这种"看着总为正"的量也会减反,维度奇偶还会把符号错误藏住 | 2026-10-05 | --- @@ -335,7 +339,9 @@ (int + np.integer / bool 排在 int 前 / float + np.floating / 数组先 `np.asarray`), 再用三个探针各打一枪:`f(x=np.int64(1))`、`f(x=True)`、`f(x=1.0)`。 **这一步是本人的活**(决策 49):要不要放宽、报什么错、算不算合法,都是取舍,不是机械活。 -- 首次:2026-10-04|复发:0 +- 复发(2026-10-05):`random_walk` 的 `n_steps = 2.5` / `n_steps = True` 又都走 `ValueError("必须是正整数")` —— + 同模块的 `monte_carlo` 早就是"类型不对 `TypeError`、取值不对 `ValueError`"。表就在那儿,还是没翻。 +- 首次:2026-10-04|复发:**1(2026-10-05)** ### 19. 只想着主路径上的那种值,忘了同一位置还会出现别的类型 / 值 @@ -404,3 +410,28 @@ 拿到 `mean` 之后先跟解析值比**符号**,再看数值大小。 - 首次:2026-10-05|复发:0 +### 24. 默认参数路径从没被执行过 —— 默认采样器用了库不接受的参数 + +- 现象:`random_walk` 的默认步长采样器写 `rng.integers(0, 2, size=size, dtype=float)`。numpy 的 `Generator.integers` + **不收浮点 dtype**(`TypeError: Unsupported dtype dtype('float64') for integers`),于是 `random_walk(100, 5)` + 这种最普通的调用 100% 返回 `success=False`、`data=None`;连 docstring 里那条 `>>> r.data.shape (5, 101)` 都跑不通。 +- 为什么会错:写的时候验的是**自定义采样器**那条分支,默认分支(`step_sampler is None`)一次都没执行过; + 更巧的是 `try/except Exception` 把它压成了一句 message,看起来像"输入不合法"而不是"代码写错了"。 + 与第 21 条同源:**错误现场被 `except` 藏起来**,静态检查(pyright)也看不见。 +- 以后怎么防:新函数写完,**第一个跑的就是"什么参数都不填的默认调用"**,`success` 必须是 `True`; + 再把 docstring 的 Examples 真执行一遍(它们是最便宜的一组冒烟测试)。 + 凡是用到"库函数 + 一串可选参数"的地方,别凭记忆写,跑一次看它收不收。 +- 首次:2026-10-05|复发:0 + +### 25. 两个维度恰好相等时,转置的形状检查会静默通过 + +- 现象:`random_walk` 把 `size=(n_paths, n_steps)` 交给 `step_sampler`,但没校验它返回的形状。 + 实测一个返回**转置**形状 `(n_steps, n_paths)` 的采样器:`n_paths=6, n_steps=4` 时报错(好事), + 而 `n_paths=n_steps=5` 时 `success=True`、`data` 形状 `(5, 6)` 也"对"—— 里面的路径却是转置过来的,**静默错**。 +- 为什么会错:形状检查依赖"两个维度不相等"这个前提才生效;`cumsum(axis=1)` + 赋值广播在方阵上完全合法。 + 验算时只挑了一组 `n_paths == n_steps` 的参数,正好落在唯一发现不了的那个点上。 + 与第 23 条同一个思路的不同侧面:**对称/相等会把错误藏起来**。 +- 以后怎么防:验算参数**把两个维度取成不同值**(`n_paths=3, n_steps=100` 这种),别用正方形; + 对外部传进来的批量数组显式校验 `shape == (n, d)`,别指望后面的运算替你把关。 +- 首次:2026-10-05|复发:0 + diff --git a/src/mcm_toolkit/solve/simulate.py b/src/mcm_toolkit/solve/simulate.py index 9cdd59c..55636fe 100644 --- a/src/mcm_toolkit/solve/simulate.py +++ b/src/mcm_toolkit/solve/simulate.py @@ -286,10 +286,13 @@ def monte_carlo_integral( >>> abs(r.metrics["mean"] - 1 / 3) < 0.01 # ∫₀¹ x² dx = 1/3 True """ - if not isinstance(bounds, (list, tuple)) or len(bounds) == 0: - raise ValueError("bounds 必须是非空的列表或元组") + if not isinstance(bounds, np.ndarray): + if not isinstance(bounds, (list, tuple)) or len(bounds) == 0: + raise ValueError("bounds 必须是非空的列表或元组") - bounds_arr = np.asarray(bounds) + bounds_arr = np.asarray(bounds) + else: + bounds_arr = bounds.copy() if bounds_arr.ndim != 2 or bounds_arr.shape[1] != 2: raise ValueError(f"bounds 必须形如 [(lo, hi), ...],当前形状为 {bounds_arr.shape},请检查是否漏了括号") @@ -320,15 +323,12 @@ def monte_carlo_integral( random_state=random_state, ) + result.name = "monte_carlo_integral" + result.params["bounds"] = bounds + result.params["dimension"] = d if result.success: - result.name = "monte_carlo_integral" - result.params["bounds"] = bounds - result.params["dimension"] = d result.message = f"蒙特卡洛多重积分成功:维度 {d},体积 {volume:.4f},模拟 {n} 次。" else: - result.name = "monte_carlo_integral" - result.params["bounds"] = bounds - result.params["dimension"] = d result.message = f"蒙特卡洛多重积分失败:维度 {d},体积 {volume:.4f},模拟 {n} 次。{result.message}" return result @@ -341,25 +341,91 @@ def random_walk( start: float = 0.0, random_state: int | None = None, ) -> Any: - """随机游走轨迹。 + """随机游走轨迹:一次抽完整批增量,再 `cumsum` 成路径。 Parameters ---------- + n_steps: + 每条路径的步数,正整数。`data` 的列数是 `n_steps + 1`(多出的那一列是起点)。 + n_paths: + 路径条数,正整数,默认 1 条。 step_sampler: - `f(rng, size) -> 增量`,默认为 ±1 等概率(对称随机游走)。 + `f(rng, size) -> 一批增量`。批量大小由本函数以 `size=(n_paths, n_steps)` 传入, + 必须返回形状 `(n_paths, n_steps)` 的数组(契约同 `monte_carlo` 的 `sampler`,只是这里是二维)。 + 默认 ±1 等概率(对称随机游走)。 + start: + 起点,写进每条路径的第 0 列。 + random_state: + 随机种子。`None` 时跟随 `core.set_seed` 的全局流,见 `core.random.get_rng`。 Returns ------- - `Result`:`data` 形状 `(n_paths, n_steps + 1)`。 + `Result`:`data` 形状 `(n_paths, n_steps + 1)`,第 0 列恒为 `start`,之后是逐列累加出来的位置; + `metrics={"n_steps", "n_paths"}`。 + + Raises + ------ + ValueError: + `n_steps` / `n_paths` 不是正整数。 + TypeError: + `start` 不是数值。 + + Notes + ----- + 随机游走是"平方根扩散"的原型:位置的标准差随步数按 `√t` 增长,所以典型图形是 `±√t` 的喇叭口。 + 本函数只负责 `cumsum`;**建模差异全在 `step_sampler` 里** —— 换成 `rng.normal(0, 1, size)` 就是高斯游走, + 换成带正漂移的增量就是有趋势的游走。 Examples -------- + >>> import numpy as np >>> r = random_walk(n_steps=100, n_paths=5, random_state=0) >>> r.data.shape (5, 101) + >>> bool(np.all(np.abs(np.diff(r.data, axis=1)) == 1)) # 默认步长恒为 1 + True """ - # TODO(L1) - raise NotImplementedError + if isinstance(n_steps, bool) or not isinstance(n_steps, (int, np.integer)) or n_steps <= 0: + raise ValueError(f"n_steps 必须是正整数,你传入了 {n_steps}") + if isinstance(n_paths, bool) or not isinstance(n_paths, (int, np.integer)) or n_paths <= 0: + 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) + + if step_sampler is None: + def _default_step_sampler(rng, size): + return rng.integers(0, 2, size=size) * 2 - 1 + + step_sampler = _default_step_sampler + + try: + steps = np.asarray(step_sampler(rng, size=(n_paths, n_steps)), dtype=float) + + cumsum = np.cumsum(steps, axis=1) + + paths = np.zeros((n_paths, n_steps + 1), dtype=float) + paths[:, 0] = start + paths[:, 1:] = start + cumsum + + return Result( + name="random_walk", + params={"n_steps": n_steps, "n_paths": n_paths, "start": start}, + metrics={"n_steps": n_steps, "n_paths": n_paths}, + data=paths, + success=True, + message=f"随机游走模拟成功:生成了 {n_paths} 条路径,每条路径 {n_steps} 步。" + ) + except Exception as e: + return Result( + name="random_walk", + params={"n_steps": n_steps, "n_paths": n_paths, "start": start}, + metrics={}, + data=None, + success=False, + message=f"随机游走模拟失败:{type(e).__name__} - {str(e)}" + ) def markov_simulate( diff --git a/tests/test_solve.py b/tests/test_solve.py index b67ea68..84d05d1 100644 --- a/tests/test_solve.py +++ b/tests/test_solve.py @@ -276,6 +276,66 @@ def test_monte_carlo_probability_rejects_bad_confidence(): monte_carlo_probability(lambda z: z > 0, _normal_batch, n=100, confidence=bad) +def test_random_walk_shape_and_start(): + """`data` 形状 (n_paths, n_steps + 1),第 0 列是起点。""" + from mcm_toolkit.solve import random_walk + + r = random_walk(n_steps=100, n_paths=5, start=2.5, random_state=0) + assert r.success + assert r.data.shape == (5, 101) + assert np.all(r.data[:, 0] == 2.5) + + +def test_random_walk_default_steps_are_plus_minus_one(): + """默认是对称随机游走:每一步都恰好是 ±1。""" + from mcm_toolkit.solve import random_walk + + r = random_walk(n_steps=200, n_paths=3, random_state=0) + assert set(np.unique(np.diff(r.data, axis=1))) == {-1.0, 1.0} + + +def test_random_walk_is_reproducible(): + from mcm_toolkit.solve import random_walk + + a = random_walk(n_steps=50, n_paths=4, random_state=7) + b = random_walk(n_steps=50, n_paths=4, random_state=7) + assert np.array_equal(a.data, b.data) + + +def test_random_walk_accepts_custom_step_sampler(): + """换成高斯增量就是带漂移的高斯游走 —— 建模差异全在 step_sampler 里。""" + from mcm_toolkit.solve import random_walk + + r = random_walk( + n_steps=100, + n_paths=3, + step_sampler=lambda rng, size: rng.normal(0.5, 1.0, size), + random_state=0, + ) + assert r.success + assert r.data.shape == (3, 101) + assert abs(r.data[:, -1].mean() - 50.0) < 20.0 # 100 步 × 漂移 0.5 + + +def test_random_walk_rejects_bad_arguments(): + from mcm_toolkit.solve import random_walk + + with pytest.raises(ValueError): + random_walk(n_steps=0) + with pytest.raises(ValueError): + random_walk(n_steps=10, n_paths=0) + with pytest.raises(TypeError): + random_walk(n_steps=10, start="a") # type: ignore[arg-type] # 就是要传错 + + +def test_random_walk_docstring_example_runs(): + from mcm_toolkit.solve import random_walk + + r = random_walk(n_steps=100, n_paths=5, random_state=0) + assert r.data.shape == (5, 101) + assert np.all(np.abs(np.diff(r.data, axis=1)) == 1) + + @pytest.mark.skip(reason=_NOT_IMPLEMENTED) def test_markov_steady_state_two_state_chain(): """P=[[0.9,0.1],[0.2,0.8]] 的平稳分布是 [2/3, 1/3]。"""