feat(solve): monte_carlo 系列两个函数落地(L0 11/12、L1)+ Wilson 区间;修 z_score^2 异或 bug

用户实现:
- solve.monte_carlo(L0 第 11 项,S 档):向量化采样→仿真→汇总,8 个指标;
  批量大小由实现传 sampler(rng, size=n);结果含 NaN 判失败;std 用 ddof=0。
- solve.monte_carlo_probability(L1,S 档):event(batch)->bool,Wilson score 区间夹 [0,1],
  confidence 记进 params,小概率改成 message 警告。

Codex 只碰工程侧(算法未动,唯一例外见下):
- 真 bug:z_score^2 是异或不是乘方(np.float64 ^ 2 抛 TypeError,被 except 吞成 success=False,
  函数实际不可用)-> 改 z_score**2。新增经验第 21 条。
- docstring:补齐 Parameters / Raises,修掉两处跑不通的 Examples,Wald -> Wilson 同步。
- 入口校验按 §4.7 收严 n(bool / np.integer / TypeError 与 ValueError 分工)—— 决策 49 后此类归用户,已记档。
- ruff 九条(I001 / F541 x3 / SIM300 / W291 x2 / W293 x2 等)、格式与文案。
- 测试:摘掉 2 条已实现却仍挂着的 skip(易错点 9),新增 14 条(含 vectorization、
  Wilson 对 statsmodels 的对照、NaN / 布尔 / 长度等边界),solve 段共 25 条。

校验:193 收集 / 161 passed / 32 skipped / 0 xfail;ruff 0;pyright 0。
文档:STATUS.md 决策 47-50;docs/dev/personal-pitfalls.md 第 18-21 条;CHANGELOG.md。
This commit is contained in:
guzhujushi committed 2026-10-05 11:26:54 +08:00
1 parent 58f2236703
commit b0c4b4a413
5 files changed
+619 -61

No files matched your search

+61
View File
@@ -338,6 +338,67 @@
(`monte_carlo` 的契约 / 向量化要求 / 三条测试建议 / 卡片路径 / 一个待拍板口径);
验证结果里的 skip 说明由 37 条订正为 **34 条**。
### Added
- `solve.monte_carlo` 落地(**L0 第 11 项,S 档**,实现由用户本人写):向量化"采样 → 仿真 → 汇总",
返回 `Result`(8 个 metrics:mean / std / median / p05 / p95 / ci_low / ci_high),随机数无条件走 `get_rng`。
配套 `tests/test_solve.py` **10 条**测试(含"`simulator` 只被调用一次"这条可执行的向量化断言)。
### Fixed
- `monte_carlo` 的**布尔结果**此前让 `np.std` 在 numpy 2 里抛 `numpy boolean subtract ... is not supported`,
整个函数判失败 —— 而"事件是否发生"是 MC 最常见的用法。入口改为 `np.asarray(..., dtype=float)`(布尔按 0/1 统计);
字符串结果也从"ufunc isnan 不支持"变成可读的 `could not convert string to float`。由行为探针挖出。
- `monte_carlo` 的 docstring `Examples` 跑不通:原采样器返回标量,实测 `success=False`
("simulator 返回的结果数量(1)必须与模拟次数 n(1000)一致");改成向量化写法。
- `monte_carlo` 的 ruff 三条(I001 合并同源 import、F541 空 f-string ×2)、`success= True` 的空格、
失败文案"重采样" → "模拟"(从 `bootstrap_ci` 抄过来忘改)。
### Changed
- `monte_carlo` 按 §4.7 收严 `n`:`np.int64` 不再被误拒(注解放宽为 `int | np.integer`);
`n=True` / 浮点 `n` 抛 `TypeError`;`n<=0` 抛 `ValueError`。
- 用户第二轮改的三处口径(决策 48):批量大小改由实现传 `sampler(rng, size=n)`;结果含 NaN 判 `success=False`;
`np.std` 显式 `ddof=0`。docstring 同步改写(`sampler` 契约、`Raises`、`Notes` 的 NaN 口径)。
- `STATUS.md`:L0 10/12 → **11/12**,新增决策 48;验证计数更新为 **187 收集 / 153 passed / 34 skipped**。
### Changed
- 工作约定收窄(**决策 49**):**入口没收紧 / 少考虑情况 / 不符合简单原则**这三类改动交回用户本人写,
Codex 只报告 + 列选项(判据:这处改动要不要"想一下才对")。决策 47 里"入口 / 校验重构归 Codex"的部分作废。
- `docs/dev/personal-pitfalls.md`:新增 **第 18 / 19 / 20 条**(入口校验没照 §4.7 清单逐条过 / 只想着主路径那种值 /
"不简单"的信号是同一个信息要写两遍),第 1 条(类型判断只写 `int`)**复发 +1**,索引补到 20。
- `STATUS.md`:新增决策 49 与两个待拍板小口径(`random_state` 注解是否全项目对齐、`std` 的 `ddof` 是否统一)。
### Added
- `solve.monte_carlo_probability` 落地(**L1,S 档**,实现由用户本人写):向量化 `event(batch) -> bool 数组`,
返回 `Result`(`probability` / `ci_low` / `ci_high` / `std_error`),区间是 Wald 区间并夹在 `[0, 1]`(沿用决策 42 口径)。
`tests/test_solve.py` 新增 5 条 + **摘掉 2 条 skip**(实现已写完却还挂着,易错点 9),`solve` 段共 24 条。
### Fixed
- `monte_carlo_probability` 的 docstring `Examples` 跑不通:原采样器返回标量,实测 `success=False`
("event 返回的结果数量(1)必须与模拟次数 n(2000)一致");改成批量采样写法并实测通过。
- `monte_carlo_probability` 的 ruff 六条:SIM300(`0.0 >= confidence` 写成 Yoda 条件)、W291 ×2、W293 ×2、F541。
- 该函数 NaN 提示里的 "simulator" 改成 "event"(从 `monte_carlo` 抄来没改);`metrics` 缩进与 `{"n":n}` 空格。
### Changed
- `monte_carlo_probability` 的 docstring 补齐 `Parameters` / `Raises`(原文只有 Returns / Notes / Examples),
并把"Wald 区间 + 夹到 [0,1]"与"`p <= 1e-3` 判失败"两条现状如实写进 Returns / Notes。
- `STATUS.md`:L1 清单加入 `monte_carlo_probability`;验证计数更新为 **192 收集 / 160 passed / 32 skipped**;
「下一步」新增该函数的 **6 条待办口径**(全部留给用户,Codex 未动)。
### Fixed
- **`monte_carlo_probability` 的 `z_score^2` 是异或不是乘方**(Python `^` 语义):`np.float64 ^ 2` 直接抛
`TypeError: ufunc 'bitwise_xor' not supported`,被 `except Exception` 吞成 `success=False` —— 函数实际上**整个不可用**,
6 条测试全红才暴露。改成 `z_score**2`。新增经验第 21 条。
- `monte_carlo_probability` 的 message 在没有警告时留了个尾逗号;`{"confidence":confidence}` 补空格。
### Changed
- 用户第二轮改定(**决策 50**):区间由 **Wald 换成 Wilson score**(实测 p=1、n=1000 从退化的 `(1.0, 1.0)`
变成 `(0.99617, 1.0)`;p=0 从"判失败"变成 `(0.0, 0.00383)`);小概率不再判失败,改为 message 里的警告;
`confidence` 记进 `Result.params`;`callable` 入口校验(现在抛 `TypeError`);`float()` 统一 `ci_*` 类型;
`n` 注解放宽为 `int | np.integer`。
- `tests/test_solve.py`:新增 1 条对照测试(区间必须等于 `statsmodels.proportion_confint(method="wilson")`),
共 25 条;全量 **193 收集 / 161 passed / 32 skipped / 0 xfail**。
- `docs/dev/personal-pitfalls.md`:新增第 **21** 条(公式里的 `^` 不是乘方,且被 `except` 吞成 `success=False`),索引补到 21。
- docstring 的 "Wald 区间" 同步改为 "Wilson score 区间"。
## [0.0.1] - 2026-10-01
### Added
+125 -32
View File
@@ -18,10 +18,11 @@
- A 档**薄封装**(`rmse`/`mae`/`fit_linear`/`train_test_split`…)算工程,Codex 可直接实现。
- 分地盘按**文件**划,不按函数划(同一文件两人各写一半曾导致用户编辑器保存失败)。
当前 **L0 10/12**:`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、
`detect_outliers`、`normalize`/`standardize`、`describe`、`correlation_matrix`、`bootstrap_ci`;
另**提前落地**了 L1 的 `confidence_interval` / `t_test`。剩余 2 项:`monte_carlo`(用户写)、
`save_figure`(Codex 写)。
当前 **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`(**用户写**,2026-10-05)。剩余 1 项:`save_figure`(Codex 写)。
`monte_carlo` 的口径已拍板(决策 48),`ci_low`/`ci_high` 的语义与 `monte_carlo_probability` 的 6 条待办见"下一步"。
## 任务清单
@@ -234,13 +235,52 @@
- [x] **工程收尾(本轮)**:`tests/test_solve.py` 的模块级 `pytestmark = pytest.mark.skip(...)`
拆成 **9 条逐条** `@pytest.mark.skip(reason=_NOT_IMPLEMENTED)` —— 模块级 skip 会把整个文件吞掉,
正是易错点 9 的根源(决策 26)。收集数不变(177)、跳过数不变(34),只是粒度变细。
- [ ] 剩余 2 项:`monte_carlo`(**用户写**)、`save_figure`(Codex 写)
- [x] `solve/simulate.py` 的 `monte_carlo`(**L0 第 11 项,S 档,算法由用户本人写**,2026-10-04)
- 核心逻辑:向量化采样 → 一次仿真 → 8 个指标;`get_rng` 无条件调用(决策 46 已内化)
- 用户第二轮改了三处(见决策 48):批量大小改由实现传 `sampler(rng, size=n)`;
结果含 NaN 判失败;`np.std(data, ddof=0)` 写明
- **Codex 只碰工程侧,算法一行未动**:
① ruff 三条(I001 合并同源 import、F541 空 f-string ×2)
② docstring:补 `Parameters`(原文漏 `random_state` / `**kwargs`)与 `Raises`;按新契约改写
`sampler` 说明(必须能接住 `size`,且 `**kwargs` 里别再传 `size`);`Notes` 记 NaN 口径;
**修掉跑不通的 Examples**(原版 `lambda rng, **k: rng.normal(0, 1)` 返回标量,实测 `success=False`)
③ 按 §4.7 收严 `n` 校验:`np.int64` 不再被拒、`n=True` 改抛 `TypeError`、浮点 `n` 归 `TypeError`、
`n<=0` 归 `ValueError`
④ 格式与文案:`success= True` → `success=True`;失败文案"重采样" → "模拟"(从 `bootstrap_ci` 抄来没改)
⑤ **入口统一 `dtype=float`** —— 修掉一个真 bug:布尔结果("事件是否发生",MC 最常见的用法)
会让 `np.std` 在 numpy 2 里抛 `boolean subtract ... is not supported`,整个函数判失败;
字符串结果也从"ufunc isnan 不支持"变成可读的 `could not convert string to float`
- **测试:10 条**(`tests/test_solve.py`)—— 返回长度与 8 个 key、`simulator` 只被调用一次(向量化的可执行版本)、
同种子可复现、省略种子时跟随 `set_seed`、E[X²]=1(n=2e5)、docstring Example 真能跑、`np.int64` 的 n、
布尔结果按 0/1 统计、NaN 结果判失败、5 个非法入参
- 行为探针实测:`simulator` 调用次数 = **1**(真向量化);20/20 次区间覆盖 0
- [x] `solve/simulate.py` 的 `monte_carlo_probability`(**L1,S 档,算法由用户本人写**,2026-10-05)
- 核心:向量化 `event(batch) -> bool 数组` → `p = mean(data)` → **Wilson score 区间**并夹到 `[0, 1]`
(比例量纲,沿用决策 42 的口径——同类问题没重犯)
- 用户第二轮(2026-10-05)按 6 条待办改掉 5 条:Wilson 替 Wald、`callable` 入口校验、
`confidence` 记进 `params`、`float()` 统一 ci 类型、小概率不再判失败(改成 message 里的警告)——见决策 50
- **Codex 第三轮只碰工程侧**:① `z_score^2` → `z_score**2` —— **真 bug**:`^` 是异或,
numpy 浮点直接抛 `TypeError`,被 `except` 吞成 `success=False`,6 条测试全红才暴露(易错点 21);
② message 无警告时的尾逗号;③ `{"confidence":confidence}` 的空格;④ docstring 的 "Wald" → "Wilson";
⑤ 新增 1 条对照测试:区间必须等于 `statsmodels.proportion_confint(method="wilson")`
- 行为探针实测:p≈0.5 正确(0.49991 @ n=2e5)、`confidence` 变大区间变宽、
同种子可复现、省略种子跟随 `set_seed`、`np.int64` 的 n 可收、`confidence=1.5` 抛 `ValueError`
- **Codex 只碰工程侧,算法一行未动**:
① ruff 六条(SIM300 Yoda、W291 ×2、W293 ×2、F541)
② docstring:补 `Parameters`(原文只有 Returns/Notes/Examples)与 `Raises`,
**修掉跑不通的 Examples**(原 `lambda rng, **k: rng.normal(0, 1)` 返回标量,实测 `success=False`),
并把"Wald 区间 + 夹到 [0,1]"与"`p <= 1e-3` 判失败"两条**现状**如实写进 Returns / Notes
③ 格式:`metrics` 字典缩进、`{"n":n, ...}` 空格、行尾空白
④ 文案:NaN 提示里的 "simulator" 改成 "event"(从 `monte_carlo` 抄来没改)
- **测试:摘掉 2 条 skip**(`test_monte_carlo_probability_of_standard_normal`、`test_monte_carlo_is_reproducible`
—— 实现写完还挂着 skip,正是易错点 9)并新增 5 条,`solve` 段共 24 条
- [ ] 剩余 1 项:`save_figure`(A 档 = 工程,**Codex 直接写**)
### 验证结果
| 检查 | venv | conda `mcm-toolkit` |
|---|---|---|
| `pytest` | 见下(单列) | **143 passed, 34 skipped(0 xfail)** |
| `pytest` | 见下(单列) | **161 passed, 32 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` | 跑通 | — |
@@ -250,13 +290,14 @@
(修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与
`docs/04-environment.md` 坑 7)
- base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致)
- **177 条用例:143 passed / 34 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、
`test_core.py` 23、`test_solve.py` 9、`test_viz.py` 8、`test_decision.py` 7、`test_stats.py` 53、
- **193 条用例:161 passed / 32 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、
`test_core.py` 23、`test_solve.py` 25(+16,含摘掉 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`
- 34 条 skip 是等实现的算法(`train_test_split`、`monte_carlo`、`anova`、`fit_distribution` 等);
- 32 条 skip 是等实现的算法(`train_test_split`、`monte_carlo_integral`、`anova`、`fit_distribution` 等);
`monte_carlo_probability` 已落地,摘掉 2 条 skip(本轮净 +3 条用例);
`bootstrap_ci` 已落地摘掉 3 条 skip(本轮新增 6 条用例,净 +3);`test_solve.py` 9 条按决策 26 改为逐条 skip;
`TestValidate` 的 4 条仍挂着 —— `check_*` 还是 `NotImplementedError`,但它们实现时应当直接复用 `as_array`
- **本轮回归(已修)**:`transform.py` 上一版有 3 处真 bug —— 修前 **7 failed / 75 passed**,
@@ -271,7 +312,7 @@
只差 Python 补丁号(3.13.9 vs 3.13.15)。详见 `docs/04-environment.md` §5。
- [x] ~~Gitea 远程尚未配置~~ → **已配置并推送完成**,见上面 D 节与决策 19/20。
note:仓库当前是 **Public**(匿名 `git ls-remote` 可读),要改私有去 Gitea 仓库设置。
- [ ] L0 完成 **10/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、
- [ ] L0 完成 **11/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、
`detect_outliers`、`normalize`、`standardize`、`describe`、`correlation_matrix`、`bootstrap_ci`),
另 L1 的 `confidence_interval` / `t_test` 已提前落地;
**只剩 `monte_carlo`(用户写)与 `save_figure`(Codex 写)**,详情见 E 节与"下一步"
@@ -353,37 +394,89 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向
工程侧(入口/校验、`tests/`、lint、文档、卡片、`.gitignore`)反过来由 Codex 直接落盘,用户只读;
分地盘按**文件**划,不按函数划。决策 39 的"语义由用户拍板"继续有效。
| 48 | `monte_carlo` 的四个口径由**用户拍板**:实现传 `size=n`;结果含 NaN 判失败;`std` 用 `ddof=0`;出错仍统一返回 `success=False` |
用户 2026-10-04 直接改在代码里。① 批量大小由实现传(`sampler(rng, size=n, **kwargs)`):调用方少写一次,
代价是**每个 sampler 都必须能接住 `size`**,且 `**kwargs` 里不能再传 `size`(会重复传参)—— 已写进 docstring。
② 结果里只要出现 NaN 就 `success=False`:MC 的 NaN 多来自除零 / 模型发散,静默统计只会得到一列 nan 指标。
③ `np.std` 默认 `ddof=0`,现在显式写明(与 `describe` 那条线的 `ddof=1` 不一致,属已知差异)。
④ 计算期异常仍全部包成 `success=False`(保留 `try/except Exception`);代价是自己写错代码时没有 traceback。
配套 Codex 代改:入口 `np.asarray(..., dtype=float)` —— 布尔结果此前会让 `np.std` 抛错(真 bug,已修)。
**唯一未定**:`ci_low`/`ci_high` 保持"结果的分位数"(现状)还是改成"均值的置信区间"。
| 49 | **这类改动归用户本人**:入口没收紧、少考虑情况、不符合简单原则 —— 用户 2026-10-04 明确"交给我改,不要帮我改" |
用户原话:"以后像这种入口没收紧、少考虑情况、不符合简单原则的情况交给我改,不要帮我改,把这几条经验写入经验文档"。
这是决策 47 的**收窄**:47 把"入口 / 校验重构"划给了 Codex,49 把它收回 —— 凡是**带判断、取舍、设计成分**的,
一律由用户写,Codex 只报告 + 列选项(与决策 39 同一逻辑)。判据一句话:**这处改动要不要"想一下才对"** ——
要,就留给用户;不要(ruff、格式化、docstring 缺节、`tests/`、`STATUS` / `CHANGELOG`、函数卡片)才由 Codex 顺手补。
本轮实例(本该留给用户,Codex 已代改、用户可回退重写):`monte_carlo` 的 `n` 按 §4.7 收严;入口 `np.asarray(..., dtype=float)`。
经验已写入 `docs/dev/personal-pitfalls.md` **第 18 / 19 / 20 条**,并把第 1 条(类型判断只写 `int`)**复发 +1**。
| 50 | `monte_carlo_probability` 的区间由 **Wald 改 Wilson score**;小概率不再判失败(改成 message 警告);`confidence` 记进 `params` |
用户 2026-10-05 按"6 条待办"自行改定。Wilson 在 p 贴近 0 / 1 时不会退化成零宽:实测 p=1、n=1000 从 `(1.0, 1.0)`
变成 `(0.99617, 1.0)`;p=0 从"判失败"变成 `(0.0, 0.00383)`(原 Wald 是 0)。区间口径与
`statsmodels.proportion_confint(method="wilson")` 对齐,已加成对照测试。
**遗留两条**:`event: Callable[..., bool]` 注解与"吃整批、返回数组"不符;警告文案把 p≈1 也说成"小概率事件"。
另:`z_score^2` 那处是 Codex 修的真 bug(`^` 是异或,见易错点 21)——按决策 31 属纯 bug,未改任何语义。
## 下一步
### ⭐ 明天从这里开始(第一个要写的文件)
### ⭐ 当前进度与待办(2026-10-05)
**文件:`src/mcm_toolkit/solve/simulate.py` → 写 `monte_carlo`(L0 第 11/12 项,S 档)。**
同一文件里 `monte_carlo_probability` / `monte_carlo_integral` / `random_walk` 先别动 —— 一次一个函数。
**本轮:`monte_carlo_probability` 已由用户写完并改到第二版**(L1,S 档)。Codex 两轮都只做工程侧,
**算法逻辑一行未动**(唯一例外是一个纯 typo:`z_score^2` → `z_score**2`,见易错点 21 / 决策 50)。
**① 参考契约(docstring 里已定,签名别改)**
- `monte_carlo(sampler, simulator, n=10000, random_state=None, **kwargs) -> Result`
- `sampler(rng, **kwargs) -> 参数`:一次抽**一整批**参数(可以是标量/数组/元组,随便)
- `simulator(params) -> float`:单次仿真结果
- 返回 `Result`:`data` = `n` 个结果组成的 `np.ndarray`;
`metrics = {"mean", "std", "median", "p05", "p95", "ci_low", "ci_high"}`
**接下来三件事**
1. `monte_carlo_probability` 还剩 **2 条待办**(下面一节)——都是你的活。
2. `ci_low` / `ci_high` 的语义确认一句(也是你的活),确认后 Codex 补 `docs/algorithms/monte_carlo.md` 卡片。
3. L0 最后一项 `save_figure`(A 档 = 工程,`viz/export.py`)—— **Codex 直接实现**,你只读。
**② 必须向量化**:`sampler` 一次抽 `n` 份(如 `rng.normal(0, 1, size=n)`),`simulator` 对整个参数数组算一次,
**绝不在 `simulator` 里写 Python 循环**。10^4 次循环还能忍,10^6 次直接放弃 —— 这是"能用"和"不能用"的分界线。
**你下一个要写的(S 档,仍在 `solve/simulate.py`)**:`monte_carlo_integral` → `random_walk`。
`monte_carlo_integral` 其实就是 `monte_carlo` 的特例:自己造 `n×d` 的均匀样本 → 逐点求值 →
`mean(f) × 体积`,正好把那套"造批 → 一次算 → 汇总"的写法复用第二次。
**③ 随机数无条件走 `core.get_rng`**(决策 46 的教训,别重演 bootstrap 那个坑):
`rng = get_rng(random_state)`,不要自己另开 `np.random.default_rng()`,否则 `set_seed` 管不到它、区间不可复现。
### ✅ monte_carlo_probability 的 6 条待办(2026-10-05 已办 5 条)
**④ 先写一条会失败的测试**(roadmap §3.2 的纪律)。`tests/test_solve.py` 现在**没有**直接测 `monte_carlo` 的用例
(`test_monte_carlo_is_reproducible` 名字像,实际调的是 `monte_carlo_probability`)。建议三条:
- 固定种子两次结果完全相同(可复现)
- `sampler` 恒返回常数 → `data` 全等于该常数、`std == 0`(常量边界)
- `n` 增大时 `mean` 的波动按 `O(1/√n)` 下降(可留成大样本慢测或标 `slow`)
- ~~① `p <= 1e-3` 判 `success=False`~~ → 改成 `warning_message`,仍返回区间(实测 p=0 → `(0.0, 0.00383)`)。
- ~~② Wald 在 p 靠近 1 时退化~~ → 换 **Wilson**(p=1、n=1000:`(1.0, 1.0)` → `(0.99617, 1.0)`)。
- ~~③ `call_params` 没记 `confidence`~~ → 已加。
- ~~④ `event` / `sampler` 不可调用被吞成 `success=False`~~ → 已加 `callable` 校验,现在抛 `TypeError`。
- ~~⑤ `metrics` 混 `np.float64`~~ → `float()` 统一(实测 4 个 key 全是 `float`)。
- ~~⑥ `n: int`~~ → 已改 `int | np.integer`。
**⑤ 还欠一张 S 档卡片**:`docs/algorithms/monte_carlo.md`(模板 `docs/dev/function-card-template.md`,
照抄 `docs/algorithms/bootstrap_ci.md` 的骨架),写完把 `docs/algorithms/README.md` 的进度表打勾。
**还剩 2 条(你的活)**
1. `event: Callable[..., bool]` 的注解与行为不符:实际必须吃**整批**参数、返回长度 `n` 的数组。
2. 警告文案把 **p ≈ 1** 也说成"小概率事件 P < 1e-3 不建议使用"——那一条建议对 p→1 是错的
(临界情形该说的是"改用 1−p 或换方法")。条件是你写的 `p_value <= 1e-3 or p_value >= 1 - 1e-3`,文案只覆盖了前一半。
**⑥ 等你拍板的一个口径(Codex 不代选,决策 39/47)**:`ci_low` / `ci_high` 用**分位数法**
(`np.percentile(data, [2.5, 97.5])`)还是**正态近似**(`mean ± 1.96·std/√n`)?两个都能用,选一个我记进卡片。
### ⏳ monte_carlo 只剩 1 个口径要你确认
**`ci_low` / `ci_high` 是不是就按"结果的分位数"定?**
现状 `np.percentile(data, [2.5, 97.5])`。实测 n=2e5、E[X²]=1 给出 `[0.001, 5.0455]` ——
这是**模拟结果的分散度**(预测区间),与 `p05` / `p95` 同类,只是分位点不同。
而"均值估计的置信区间"(MC 的统计误差)是 `mean ± 1.96·std/√n` = `[0.9962, 1.0086]`,
两者差两个数量级,论文话术也完全不同:
- A(现状):写"95% 的模拟结果落在 [a, b]"(描述风险分布)
- B:改成均值 CI → 写"估计值 1.0024 ± 0.0043(95% CI)"(证明 n 够大)
- C:两者都要 → 指标要改名(如 `p025`/`p975` + `mean_ci_low`/`mean_ci_high`)
注意:`monte_carlo_probability` 里的 `ci_*` 已经是**估计量的 CI**(Wald 区间),所以这两个函数的 `ci_*`
目前**含义不同**——这正是要给它们定口径的原因之一。
### ✅ 已拍板(决策 48)
- 批量大小由实现传 `size=n`;`sampler` 必须能接住 `size`,`**kwargs` 里不要再传 `size`。
- 结果含 NaN → `success=False`(不落 metrics)。
- `std` 用 `ddof=0`。
- 出错仍统一返回 `success=False`(`try/except Exception` 保留)。
### ⏳ 另外两个小口径(你说了算)
- **`random_state` 的注解**:经验文档第 3 条写的标准是 `int | np.integer | np.random.Generator | None`,
但 `stats/bootstrap.py`、`solve/simulate.py`、`stats/inference.py` 现在都写 `int | None`(只有 `core/random.py` 是全的)——
要不要全项目对齐?(运行期 `get_rng` 收 `np.int64`,所以是注解与行为不一致,不是功能 bug)
- **`std` 的 `ddof`**:`monte_carlo` 用 `ddof=0`,`describe` 那条线用 `ddof=1`。要不要统一?
### 其余待办
+71 -1
View File
@@ -39,6 +39,10 @@
| 15 | 区间被无条件夹到 `[0, 1]`,且 `min` / `max` 参数写反 | 2026-10-03 |
| 16 | 同一段入口转换抄了 N 遍,还当它是"本项目的写法" | 2026-10-03 |
| 17 | `hasattr(f, "__name__")` 挡不住 lambda,`else` 分支是死代码 | 2026-10-03 |
| 18 | 入口校验"想起什么写什么",没照 §4.7 清单逐条过 | 2026-10-04 |
| 19 | 只想着主路径那种值,忘了布尔 / NaN / 长度不对 | 2026-10-04 |
| 20 | "不简单"的信号:同一个信息要在调用方写两遍 | 2026-10-04 |
| 21 | 公式里的 `^` 不是乘方,而且被 `except` 吞成 success=False | 2026-10-05 |
---
@@ -50,7 +54,10 @@
- 以后怎么防:写类型分支时默认假设"这个值可能来自 numpy"。判断用 `isinstance(x, (int, np.integer))`,
取值统一 `int(x)` 归一化;并且给所有非预期类型加 `raise TypeError` 兜底——
**宁可报错,也不要静默走默认分支**。
- 首次:2026-10-01|复发:0
- 复发(2026-10-04):`monte_carlo` 的 `n` 只写了 `isinstance(n, int)` —— `np.int64(1000)` 被 `ValueError` **误拒**,
而 `n=True` 反而放行(`bool` 是 `int` 子类,判断顺序又反了)。
同一张 §4.7 表的第一、三行,症状从"静默兜底"变成"误拒 + 误收":**规则没换,只是没去查**(见第 18 条)。
- 首次:2026-10-01|复发:0 → **1(2026-10-04)**
### 2. 以为 `# TODO(L0)` 只是普通注释
@@ -309,3 +316,66 @@
把"没有名字"和"名字是占位符"两种情况都覆盖;③ 每个 `else`/兜底分支都问一句
"**这条到底测得到吗**"——走不到的分支就是 bug 伪装成的防御性代码。
- 首次:2026-10-03|复发:0
### 18. 入口校验"想起什么写什么",没有照着清单逐条过
- 现象:`monte_carlo` 的入口只写了"n 是整数、n 为正",没写"那 `n=True` / `n=np.int64` / `n=10.0` 各走哪条"。
结果 `np.int64(1000)` 被误拒、`n=True` 被放行、浮点 `n` 报的是 `ValueError` 而不是 `TypeError`。
§4.7 那张判断顺序表早就写死了,写代码时一次都没翻。
- 为什么会错:校验是"防御性代码",写的时候满脑子是主路径("次数当然是整数"),
**不会自然想到 `True`、`np.int64`、`None`、0 这些"合法类型里的例外值"**。
清单存在的意义正是替代这种直觉——但清单要主动去查才有用;不查就等于没有。
同类:第 1 条(只写 `int`)、第 7 条(守卫子句的位置)、`random_state=None` 绕开 `get_rng`(决策 46)。
- 以后怎么防:每个公开函数写完,把 §4.7 的表当 checklist **逐行过一遍**
(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
### 19. 只想着主路径上的那种值,忘了同一位置还会出现别的类型 / 值
- 现象:`monte_carlo` 的 `simulator` 返回**布尔**数组("事件是否发生",MC 最常见的用法)时,
`np.asarray(...)` 原样保留 bool,`np.std` 在 numpy 2 上直接抛
`numpy boolean subtract ... is not supported`,整个函数判失败;字符串结果则给出
"ufunc isnan not supported"这种看不懂的话。同一轮还漏了:结果里含一个 NaN 时 8 个指标全 NaN 却报 `success=True`。
- 为什么会错:写的时候脑子里只有"年收益 = 浮点数"这一种结果,
"0/1 事件"、"NaN"、"长度对不上"都不在主路径上。而**这个函数恰恰是这几类输入的高发区**
(事件概率、模型发散、批量大小写错),所以它们不是边角料,是常规用法。
- 以后怎么防:写完一个"吃调用方数组"的函数,除了主路径,按五类各跑一次:
**布尔 / 整数 / 含 NaN / 全相同(常量)/ 长度不对**。
类型归一化放在**入口一处**做(`np.asarray(x, dtype=float)`),后面所有指标就都安全了——
这正是 §4.7 表最后一行与 §4.8 存在的理由。
- 首次:2026-10-04|复发:0
### 20. "不简单"的最早信号:同一个信息要在调用方写两遍
- 现象:第一版 `monte_carlo` 的契约是 `sampler(rng, **kwargs)`,但采样器**拿不到 n**,
于是调用方必须写 `n=1000, size=1000` —— 同一个数写两遍,还要靠 `len(data) != n` 事后兜底。
改成实现自己传 `sampler(rng, size=n, **kwargs)` 之后,调用方只写 `n=1000`。
同一处的后遗症:`**kwargs` 里再传 `size` 会撞成 `got multiple values for keyword argument`。
- 为什么会错:设计签名时只想着"参数全靠 `**kwargs` 透传"这一条好处(灵活),
没数"调用方为了让它跑起来还得额外写什么"。
**重复的那一个数是症状,不是问题**:真正的问题是"批量大小"这个信息在接口里**没有归属**。
- 以后怎么防:写完签名,立刻写出**最小调用示例**,然后数两件事:
① 有没有哪个信息写了两遍;② 有没有哪一步"只有我知道"。有 → 把它吸收进实现,而不是写进文档。
简单 ≠ 参数少,而是**调用方不用重复表达**。
- 首次:2026-10-04|复发:0
### 21. 把数学公式抄进代码时,`^` 当成乘方 —— 还被 `except` 吞成 `success=False`
- 现象:`monte_carlo_probability` 的 Wilson 区间里写 `z_score^2 / n`。Python 的 `^` 是**按位异或**,
对 `np.float64` 直接抛 `TypeError: ufunc 'bitwise_xor' not supported`;因为整段计算在
`try/except Exception` 里,对外只表现为 `success=False` + 一句 message ——
最后是 6 条测试全红(`KeyError: 'ci_high'`)才把它挖出来。
- 为什么会错:纸上的 `z²`、`x²` 抄进代码时手会自动敲 `^`(数学记号、Excel、LaTeX 都是这个符号),
而 Python 的乘方是 `**`。更险的是 `^` 在**整数**上是合法异或:`3^2` 得到 1、不报错,只算错。
第二层原因是 `try/except Exception` 把"代码写错"和"数据不合法"混成一个出口,
错误现场(哪一行、哪个异常)被压成一句字符串,静态检查也看不见(pyright 当时 0 error)。
- 以后怎么防:① 公式里的幂一律写 `**`(或 `np.power`),见到 `^` 就当 bug 处理;
② 新函数写完先**手动跑一次正常输入**,`success` 必须是 `True` —— 这条一跑就现形;
③ 给算法配一条**独立实现的对照测试**(Wilson 对 `statsmodels.proportion_confint(method="wilson")`),
这类"公式抄错"会被直接钉死;④ 记住 `except Exception` 越宽,错误越难看见。
- 首次:2026-10-05|复发:0
+171 -17
View File
@@ -10,6 +10,11 @@ from __future__ import annotations
from collections.abc import Callable
from typing import Any
import numpy as np
from scipy.stats import norm
from mcm_toolkit.core import Result, get_rng
__all__ = [
"markov_simulate",
"markov_steady_state",
@@ -24,7 +29,7 @@ __all__ = [
def monte_carlo(
sampler: Callable[..., Any],
simulator: Callable[..., Any],
n: int = 10000,
n: int | np.integer = 10000,
random_state: int | None = None,
**kwargs: Any,
) -> Any:
@@ -33,60 +38,209 @@ def monte_carlo(
Parameters
----------
sampler:
`f(rng, **kwargs) -> 一组参数`,例如从先验分布里抽参数。
`f(rng, size, **kwargs) -> 一批参数`。**一次就要抽够 `n` 组**,
例如 `lambda rng, size: rng.normal(0, 1, size)`。
批量大小由本函数以 `size=n` 传进去:`sampler` 必须能接住 `size`
(显式声明,或带 `**kwargs`)。**别再往 `**kwargs` 里塞 `size`**,否则重复传参。
simulator:
`f(params) -> float`,例如"给定参数下的年收益"。
`f(params) -> 一批结果`。收到 `sampler` 的那一整批参数,
返回长度同为 `n` 的数组(标量 / 长度对不上都算契约错误)。
**内部不要写 Python 循环**。
n:
模拟次数。**必须向量化**:`simulator` 内部不要写 Python 循环,
否则 10^4 次能跑几分钟,10^6 次直接放弃。
模拟次数,正整数。与 `simulator` 返回的长度必须一致。
random_state:
随机种子。`None` 时跟随 `core.set_seed` 的全局流,见 `core.random.get_rng`。
**kwargs:
原样转交给 `sampler`,例如分布参数 `mu` / `sigma`(`size` 由本函数自己传,见上)。
会一并记进 `Result.params`,便于复现。
Returns
-------
`Result`:`data` 是 `n` 个结果组成的数组,
`Result`:`data` 是 `n` 个结果组成的数组(一律转成 float,布尔结果按 0/1 统计),
`metrics={"mean", "std", "median", "p05", "p95", "ci_low", "ci_high"}`。
Raises
------
TypeError:
`sampler` / `simulator` 不可调用,`n` 不是整数,或 `random_state` 类型非法。
ValueError:
`n` 不是正整数。
Notes
-----
比赛里 Monte Carlo 的收敛速度是 `O(1/√n)`——
想少一半误差要 4 倍样本。所以论文里要给"区间随 n 收敛"的图,
证明 `n` 已经够大(这也是 `viz.plot_convergence` 的典型用法)。
比赛里 Monte Carlo 的收敛速度是 `O(1/√n)` —— 想少一半误差就要 4 倍样本。
所以论文里要给"区间随 n 收敛"的图,证明 `n` 已经够大
(这也是 `viz.plot_convergence` 的典型用法)。
结果里只要出现 **NaN** 就判失败(`success=False`):MC 的 NaN 通常来自除零、
取负数的根号或模型发散,静默统计只会得到一列 nan 指标。
Examples
--------
>>> import numpy as np
>>> r = monte_carlo(lambda rng, **k: rng.normal(0, 1), np.mean, n=1000, random_state=0)
>>> draw = lambda rng, size, **k: rng.normal(0, 1, size)
>>> r = monte_carlo(draw, lambda x: x ** 2, n=10000, random_state=0)
>>> bool(abs(r.metrics["mean"] - 1.0) < 0.1) # E[X²] = 1
True
"""
# TODO(L1) ★ S 档
raise NotImplementedError
if not callable(sampler) or not callable(simulator):
raise TypeError("sampler 和 simulator 必须是可调用函数")
if isinstance(n, bool) or not isinstance(n, (int, np.integer)):
raise TypeError(f"n 必须是整数,你传入了 {type(n).__name__}")
if int(n) <= 0:
raise ValueError(f"n 必须是正整数,你传入了 {n}")
n = int(n)
rng = get_rng(random_state)
call_params = {"n": n, "random_state": random_state, **kwargs}
try:
params = sampler(rng, size=n, **kwargs)
data = np.asarray(simulator(params), dtype=float).ravel()
if np.isnan(data).any():
raise ValueError("模拟结果中包含 NaN,请检查 sampler 或 simulator 的传入参数")
if len(data) != n:
raise ValueError(f"simulator 返回的结果数量({len(data)})必须与模拟次数 n({n})一致")
metrics = {
"mean": float(np.mean(data)),
"std": float(np.std(data, ddof=0)),
"median": float(np.median(data)),
"p05": float(np.percentile(data, 5)),
"p95": float(np.percentile(data, 95)),
"ci_low": float(np.percentile(data, 2.5)),
"ci_high": float(np.percentile(data, 97.5)),
}
return Result(
name="monte_carlo",
params=call_params,
metrics=metrics,
data=data,
success=True,
message=f"蒙特卡洛模拟成功:已完成 {n} 次模拟",
)
except Exception as e:
return Result(
name="monte_carlo",
params=call_params,
metrics={},
data=None,
success=False,
message=f"蒙特卡洛模拟失败:{type(e).__name__} - {e}",
)
def monte_carlo_probability(
event: Callable[..., bool],
sampler: Callable[..., Any],
n: int = 10000,
n: int | np.integer = 10000,
confidence: float = 0.95,
random_state: int | None = None,
) -> Any:
"""估计事件发生概率,并给出区间。
Parameters
----------
event:
`f(params) -> 一批 bool`。吃到 `sampler` 的**整批**参数,
返回长度为 `n` 的布尔数组(写成 `z > 0` 这种逐元素判断即可,不要写 Python 循环)。
sampler:
`f(rng, size, **kwargs) -> 一批参数`,契约同 `monte_carlo`:
批量大小由本函数以 `size=n` 传入,`sampler` 必须能接住 `size`。
n:
模拟次数,正整数。与 `event` 返回的长度必须一致。
confidence:
区间置信水平,必须落在开区间 `(0, 1)` 内。
random_state:
随机种子。`None` 时跟随 `core.set_seed` 的全局流,见 `core.random.get_rng`。
Returns
-------
`Result`:`metrics={"probability", "ci_low", "ci_high", "std_error"}`。
`Result`:`data` 是 `n` 个 0/1 结果组成的数组,
`metrics={"probability", "ci_low", "ci_high", "std_error"}`。
区间是 Wilson score 区间(`p` 贴近 0 / 1 时比 Wald 稳),并夹在 `[0, 1]` 内(比例量纲,见决策 42)。
Raises
------
TypeError:
`n` 不是整数,或 `random_state` 类型非法。
ValueError:
`n` 不是正整数,或 `confidence` 不在 `(0, 1)` 内。
Notes
-----
概率估计的相对误差 ≈ `1/√(n·p)`。**小概率事件(p < 1e-3)用朴素 MC 是不可行的**,
需要重要性采样或方差缩减——遇到这类题要提前意识到,别硬跑。
本函数当前的口径:`probability <= 1e-3` 时**直接判 `success=False`**(只给提示,不给区间)。
Examples
--------
>>> import numpy as np
>>> r = monte_carlo_probability(lambda v: v > 0, lambda rng, **k: rng.normal(0, 1), n=2000, random_state=0)
>>> draw = lambda rng, size, **k: rng.normal(0, 1, size)
>>> r = monte_carlo_probability(lambda z: z > 0, draw, n=2000, random_state=0)
>>> abs(r.metrics["probability"] - 0.5) < 0.1
True
"""
# TODO(L1) ★ S 档
raise NotImplementedError
if not callable(sampler) or not callable(event):
raise TypeError("sampler 和 event 必须是可调用函数")
if isinstance(n, bool) or not isinstance(n, (int, np.integer)):
raise TypeError(f"n 必须是整数,你传入了 {type(n).__name__}")
if int(n) <= 0:
raise ValueError(f"n 必须是正整数,你传入了 {n}")
n = int(n)
if confidence <= 0.0 or confidence >= 1:
raise ValueError(f"confidence必须在0和1之间,而你输入了{confidence}")
rng = get_rng(random_state)
call_params = {"n": n, "random_state": random_state, "confidence": confidence}
try:
params = sampler(rng, size=n)
data = np.asarray(event(params), dtype=float).ravel()
if np.isnan(data).any():
raise ValueError("模拟结果中包含 NaN,请检查 event 或 sampler 的传入参数")
if len(data) != n:
raise ValueError(f"event 返回的结果数量({len(data)})必须与模拟次数 n({n})一致")
p_value = float(np.mean(data))
std_error = float(np.sqrt(p_value * (1.0 - p_value) / n))
z_score = norm.ppf(1 - (1 - confidence) / 2)
center = (p_value + z_score**2 / (2 * n)) / (1 + z_score ** 2 / n)
half_width = (z_score / (1 + z_score ** 2 / n)) * np.sqrt(p_value * (1 - p_value) / n + z_score ** 2 / (4 * n ** 2))
ci_low = max(0.0, center - half_width)
ci_high = min(1.0, center + half_width)
warning_message = ""
if p_value <= 1e-3 or p_value >= 1 - 1e-3:
warning_message = "小概率事件 P < 1e-3 不建议使用monte_carlo_probability"
metrics = {
"probability": p_value,
"ci_low": float(ci_low),
"ci_high": float(ci_high),
"std_error": std_error,
}
return Result(
name="monte_carlo_probability",
params=call_params,
metrics=metrics,
data=data,
success=True,
message=(
f"蒙特卡洛模拟成功:已完成 {n} 次模拟"
+ (f";{warning_message}" if warning_message else "")
),
)
except Exception as e:
return Result(
name="monte_carlo_probability",
params=call_params,
metrics={},
data=None,
success=False,
message=f"蒙特卡洛模拟失败:{type(e).__name__} - {e}",
)
def monte_carlo_integral(
+191 -11
View File
@@ -8,6 +8,11 @@ import pytest
_NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L0/L1"
def _normal_batch(rng, size, **kwargs):
"""`monte_carlo` 的 sampler 契约:收下实现传来的 `size`,一次抽够。"""
return rng.normal(0, 1, size)
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_monte_carlo_integral_converges_to_analytic_value():
"""∫₀¹ x² dx = 1/3。n=2e5 时误差应 < 0.01。"""
@@ -17,30 +22,205 @@ def test_monte_carlo_integral_converges_to_analytic_value():
assert abs(r.data - 1 / 3) < 0.01
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_monte_carlo_probability_of_standard_normal():
"""P(Z > 0) = 0.5,n=5000 时误差应 < 0.05。"""
from mcm_toolkit.solve import monte_carlo_probability
r = monte_carlo_probability(
lambda z: z > 0,
lambda rng, **kw: rng.standard_normal(),
n=5000,
random_state=0,
)
r = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=5000, random_state=0)
assert r.success
assert abs(r.metrics["probability"] - 0.5) < 0.05
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_monte_carlo_is_reproducible():
from mcm_toolkit.solve import monte_carlo_probability
args = (lambda z: z > 0, lambda rng, **kw: rng.standard_normal())
a = monte_carlo_probability(*args, n=1000, random_state=9)
b = monte_carlo_probability(*args, n=1000, random_state=9)
a = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=1000, random_state=9)
b = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=1000, random_state=9)
assert a.metrics["probability"] == b.metrics["probability"]
def test_monte_carlo_returns_n_results_and_documented_metrics():
from mcm_toolkit.solve import monte_carlo
r = monte_carlo(_normal_batch, lambda x: x**2, n=2000, random_state=0)
assert r.success
assert r.data.shape == (2000,)
assert set(r.metrics) == {"mean", "std", "median", "p05", "p95", "ci_low", "ci_high"}
def test_monte_carlo_calls_simulator_only_once():
"""`simulator` 只被调用一次 —— 这是"必须向量化"的可执行版本。"""
from mcm_toolkit.solve import monte_carlo
counter = {"calls": 0}
def simulator(params):
counter["calls"] += 1
return params
r = monte_carlo(_normal_batch, simulator, n=1000, random_state=0)
assert r.success
assert counter["calls"] == 1
def test_monte_carlo_data_is_reproducible_with_same_seed():
from mcm_toolkit.solve import monte_carlo
a = monte_carlo(_normal_batch, lambda x: x, n=2000, random_state=11)
b = monte_carlo(_normal_batch, lambda x: x, n=2000, random_state=11)
assert np.array_equal(a.data, b.data)
def test_monte_carlo_follows_set_seed_when_random_state_omitted():
from mcm_toolkit.core import set_seed
from mcm_toolkit.solve import monte_carlo
set_seed(5)
a = monte_carlo(_normal_batch, lambda x: x, n=500)
set_seed(5)
b = monte_carlo(_normal_batch, lambda x: x, n=500)
assert np.array_equal(a.data, b.data)
def test_monte_carlo_estimates_known_expectation():
"""E[X²] = 1(X ~ N(0, 1));n = 2e5 时误差应 < 0.02。"""
from mcm_toolkit.solve import monte_carlo
r = monte_carlo(_normal_batch, lambda x: x**2, n=200_000, random_state=0)
assert abs(r.metrics["mean"] - 1.0) < 0.02
def test_monte_carlo_docstring_example_runs():
"""docstring 的 Examples 必须真的能跑通(此前那一版是坏的)。"""
from mcm_toolkit.solve import monte_carlo
draw = lambda rng, size, **k: rng.normal(0, 1, size) # noqa: E731
r = monte_carlo(draw, lambda x: x**2, n=10000, random_state=0)
assert r.success
assert abs(r.metrics["mean"] - 1.0) < 0.1
def test_monte_carlo_accepts_numpy_integer_n():
"""§4.7:`np.int64` 不是 `int` 的子类,不能漏。"""
from mcm_toolkit.solve import monte_carlo
r = monte_carlo(_normal_batch, lambda x: x, n=np.int64(100), random_state=0)
assert r.success
assert r.data.shape == (100,)
def test_monte_carlo_counts_boolean_outcomes():
"""布尔结果按 0/1 统计 —— 事件概率是 MC 最常见的用法。"""
from mcm_toolkit.solve import monte_carlo
r = monte_carlo(
lambda rng, size, **k: rng.normal(0, 1, size) > 0,
lambda x: x,
n=5000,
random_state=0,
)
assert r.success
assert r.data.dtype == float
assert abs(r.metrics["mean"] - 0.5) < 0.05
def test_monte_carlo_rejects_nan_results():
"""结果里出现 NaN 就判失败,不静默给出一列 nan 指标。"""
from mcm_toolkit.solve import monte_carlo
r = monte_carlo(
lambda rng, size, **k: np.r_[np.zeros(size - 1), np.nan],
lambda x: x,
n=10,
random_state=0,
)
assert not r.success
assert "NaN" in r.message
def test_monte_carlo_rejects_bad_arguments():
from mcm_toolkit.solve import monte_carlo
with pytest.raises(TypeError):
monte_carlo(123, lambda x: x) # type: ignore[arg-type] # 就是要传错
with pytest.raises(TypeError):
monte_carlo(_normal_batch, 123) # type: ignore[arg-type] # 就是要传错
with pytest.raises(TypeError):
monte_carlo(_normal_batch, lambda x: x, n=True)
with pytest.raises(TypeError):
monte_carlo(_normal_batch, lambda x: x, n=10.0) # type: ignore[arg-type] # 就是要传错
with pytest.raises(ValueError):
monte_carlo(_normal_batch, lambda x: x, n=0)
def test_monte_carlo_probability_returns_documented_metrics():
from mcm_toolkit.solve import monte_carlo_probability
r = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=20000, random_state=0)
assert r.success
assert set(r.metrics) == {"probability", "ci_low", "ci_high", "std_error"}
assert r.data.shape == (20000,)
assert 0.0 <= r.metrics["ci_low"] <= r.metrics["probability"]
assert r.metrics["probability"] <= r.metrics["ci_high"] <= 1.0
def test_monte_carlo_probability_std_error_matches_binomial_formula():
"""`std_error` 就是比例的标准误 √(p(1-p)/n)。"""
from mcm_toolkit.solve import monte_carlo_probability
n = 20000
r = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=n, random_state=0)
p = r.metrics["probability"]
assert np.isclose(r.metrics["std_error"], np.sqrt(p * (1 - p) / n))
def test_monte_carlo_probability_ci_narrows_with_n():
"""区间宽度随 n 增大而收窄(O(1/√n))。"""
from mcm_toolkit.solve import monte_carlo_probability
def width(n):
r = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=n, random_state=0)
return r.metrics["ci_high"] - r.metrics["ci_low"]
assert width(100_000) < width(1_000)
def test_monte_carlo_probability_higher_confidence_is_wider():
from mcm_toolkit.solve import monte_carlo_probability
def width(confidence):
r = monte_carlo_probability(
lambda z: z > 0, _normal_batch, n=5000, random_state=0, confidence=confidence
)
return r.metrics["ci_high"] - r.metrics["ci_low"]
assert width(0.99) > width(0.90)
def test_monte_carlo_probability_ci_matches_statsmodels_wilson():
"""区间必须对得上独立实现(statsmodels 的 wilson)—— `^` 写成异或那次就靠它拦住。"""
from statsmodels.stats.proportion import proportion_confint
from mcm_toolkit.solve import monte_carlo_probability
n = 5000
r = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=n, random_state=0)
count = int(r.data.sum())
lo, hi = proportion_confint(count, n, alpha=0.05, method="wilson")
assert np.isclose(r.metrics["ci_low"], lo)
assert np.isclose(r.metrics["ci_high"], hi)
def test_monte_carlo_probability_rejects_bad_confidence():
from mcm_toolkit.solve import monte_carlo_probability
for bad in (0.0, 1.0, -0.5, 1.5):
with pytest.raises(ValueError):
monte_carlo_probability(lambda z: z > 0, _normal_batch, n=100, confidence=bad)
@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]。"""