diff --git a/CHANGELOG.md b/CHANGELOG.md index 7831ada..6cbcdaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -425,6 +425,24 @@ - `tmp/type_demo.py`(gitignored):用 pyright 证明 `Callable[..., bool]` 会**拒绝**一个注解良好的向量化 `event` (`ndarray` 与 `bool` 不兼容),`Callable[..., Any]` 才收 —— 这是决策 50 那条遗留的实际代价。 +### Fixed +- `monte_carlo_integral` 的 `bounds` 形状校验缺失(用户第二轮补齐):`bounds=[0, 1]` 从裸 `IndexError: too many indices` + 变成可读 `ValueError`(带当前形状与"请检查是否漏了括号");`[]` / 非 `list`-`tuple` / 参差不齐的输入也一并收口。 +- `monte_carlo_integral` 的失败 `message` 被包装层覆盖(用户第二轮改成 `if result.success:` / `else:` 两支): + `func` 返回标量时不再是 `success=False` + "成功"文案,而是"蒙特卡洛多重积分失败:维度 1,体积 1.0000,模拟 100 次。 + 蒙特卡洛模拟失败:TypeError - object of type 'float' has no len()"。 +- 格式:`simulate.py` 两行"只有空格的行"(ruff W293 ×2,Codex 顺手补)。 + +### Changed +- `monte_carlo_probability` 的 `event` 注解 `Callable[..., bool]` → `Callable[..., Any]`(用户改,**决策 50 的遗留清零**): + pyright 实测原注解会**拒绝**一个注解良好的向量化事件函数(`ndarray` 与 `bool` 不兼容),运行期却一直收得下。 +- `monte_carlo_integral` 的 `n` 注解放宽为 `int | np.integer`(与全模块对齐,易错点 1 的老口径)。 +- `monte_carlo_integral` 的 docstring:`bounds` 入参说明与 `Raises` 同步到新入口校验(补"非空 `list`/`tuple`、形状 `(d, 2)`")。 +- `tests/test_solve.py`:非法 `bounds` 从 1 类扩到 4 类(`[(1, 0)]` / `[0, 1]` / `[]` / 参差不齐),新增"失败 message 不许出现 + '成功'"与 `np.int64` 两条;`solve` 段 30 → **32** 条,全量 **199 收集 / 168 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。 +- `STATUS.md`:integral 的 3 条待办标记办结、概率函数的 6 条待办全部办结;新增**决策 52** 与 2 条新口径 + (`bounds` 收不收 ndarray、`if`/`else` 里那三行重复);验证计数更新为 168 / 31。 + ## [0.0.1] - 2026-10-01 ### Added diff --git a/STATUS.md b/STATUS.md index baa18a1..dd5f750 100644 --- a/STATUS.md +++ b/STATUS.md @@ -22,9 +22,8 @@ `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` 的口径已拍板(决策 48);`ci_low`/`ci_high` 的语义、`monte_carlo_probability` 剩下的 -1 条待办、`monte_carlo_integral` 的 3 条待办,都见"下一步"。 - +`monte_carlo` 的口径已拍板(决策 48);概率 / 积分两个函数的待办**均已办结**, +剩下 `ci_low`/`ci_high` 的语义与 `monte_carlo_integral` 的 2 条新口径,见"下一步"。 ## 任务清单 ### ✅ 已完成 @@ -290,15 +289,16 @@ - **测试:新增 4 条 + 摘掉 1 条 skip**(`solve` 段共 30 条)—— 1D 收敛到 1/3(n=2e5)、 **3D 抓体积符号**(奇数维)、**`[0,2]` 抓宽度**(偶数维藏不住)、2D + `name`/`dimension`/`bounds` 三个 params、 `(1, 0)` 抛 `ValueError`。三条"抓符号"用例就是照着上面那个"2D 掩盖效应"设计的 - - ⏳ **留给用户 3 条**(见"下一步"):`result.message` 无条件覆盖(失败被写成"成功")、 - `bounds` 形状没校验(`[0, 1]` 抛裸 `IndexError`)、`n: int` 与全模块 `int | np.integer` 不一致 + - ✅ **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` 三行重复)见"下一步" - [ ] 剩余 1 项:`save_figure`(A 档 = 工程,**Codex 直接写**) ### 验证结果 | 检查 | venv | conda `mcm-toolkit` | |---|---|---| -| `pytest` | 见下(单列) | **166 passed, 31 skipped(0 xfail)** | +| `pytest` | 见下(单列) | **168 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 +308,8 @@ (修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与 `docs/04-environment.md` 坑 7) - base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致) -- **197 条用例:166 passed / 31 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、 - `test_core.py` 23、`test_solve.py` 30(上一轮 25,本轮摘 1 条 skip + 新增 4 条)、`test_viz.py` 8、`test_decision.py` 7、 +- **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、 `test_stats.py` 53、`test_models.py` 5 - **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正 - 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环 @@ -433,7 +433,7 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 用户 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")` 对齐,已加成对照测试。 -**遗留**(2026-10-05 第二轮复核):`event: Callable[..., bool]` 注解与"吃整批、返回数组"不符(**待改**); +**遗留**(2026-10-05 第二轮复核,同日**已清**):~~`event: Callable[..., bool]` 注解与"吃整批、返回数组"不符~~ → 已改 `Callable[..., Any]`; 警告文案把 p≈1 也说成"小概率事件" —— **用户已于同日改掉**(现在两半都覆盖,见 CHANGELOG 本轮)。 | 51 | `monte_carlo_integral` 的 `data` = **n 个样本各自的贡献**(`func(X_i) × 体积`);积分估计值看 `metrics["mean"]` | @@ -443,6 +443,13 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 **留给用户 3 条**:① `result.message` 无条件覆盖(失败被写成"成功");② `bounds` 形状没校验(`[0, 1]` 抛裸 `IndexError`); ③ `n: int` 与全模块的 `int | np.integer` 不一致。 +| 52 | `monte_carlo_integral` 的入口口径由**用户第二轮**改定:`message` 只在成功分支改写;`bounds` 必须是非空 `list`/`tuple` 且形状 `(d, 2)`;`n` 收 `int \| np.integer` | +用户 2026-10-05 按 Codex 报的 3 条自行改定。`message` 走 `if result.success:` / `else:`(失败分支保留失败语义并追加追溯信息: +维度 / 体积 / 模拟次数)—— 修掉"`success=False` + 一句成功文案"的自相矛盾。`bounds` 入口两道校验把 +`[0, 1]` 从 `IndexError: too many indices` 变成带当前形状的可读 `ValueError`。遗留 2 条新口径: +① `isinstance(bounds, (list, tuple))` 把 `np.array([[0, 1]])` 也拒了,与"尽量传 ndarray"的偏好冲突; +② `if` / `else` 两支里 `name` / `params["bounds"]` / `params["dimension"]` 各写了一遍(易错点 20)。 + ## 下一步 ### ⭐ 当前进度与待办(2026-10-05 第二轮) @@ -451,7 +458,7 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 (体积 `lows - highs` → `highs - lows`,见决策 51)—— **算法结构一行未动**。 **接下来三件事** -1. `monte_carlo_integral` 有 **3 条待办**(下面一节)——都是你的活。 +1. `monte_carlo_integral` 的 3 条待办**已办结**;新增 2 条口径(下面一节)——都是你的活。 2. `ci_low` / `ci_high` 的语义确认一句(也是你的活),确认后 Codex 补 `docs/algorithms/monte_carlo.md` 卡片。 3. L0 最后一项 `save_figure`(A 档 = 工程,`viz/export.py`)—— **Codex 直接实现**,你只读。 @@ -459,7 +466,7 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 它和刚写完的两个函数是**同一套骨架的第三次复用**(造批 → 一次算 → 汇总),新东西只有两处: `np.cumsum` 把增量累成轨迹;`data` 的 shape 从 `(n,)` 变成 `(n_paths, n_steps + 1)` 的**二维**。 -### ✅ monte_carlo_probability 的 6 条待办(2026-10-05 已办 5 条、剩 1 条) +### ✅ monte_carlo_probability 的 6 条待办(2026-10-05 全部办结) - ~~① `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)`)。 @@ -468,22 +475,26 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 - ~~⑤ `metrics` 混 `np.float64`~~ → `float()` 统一(实测 4 个 key 全是 `float`)。 - ~~⑥ `n: int`~~ → 已改 `int | np.integer`。 -**还剩 1 条(你的活)** -1. `event: Callable[..., bool]` 的注解与行为不符:实际必须吃**整批**参数、返回长度 `n` 的数组。 - 改法(一行):`Callable[..., bool]` → `Callable[..., Any]`,与同模块的 `sampler` / `simulator` 对齐。 - pyright 实测:`Callable[..., bool]` 会**拒绝**一个注解良好的向量化 `event`(返回 `ndarray` 与 `bool` 不兼容), - `Callable[..., Any]` 才收得下 —— 这正是"注解与行为不符"的实际代价。 +**~~还剩 1 条~~ → 已办(本轮)** +1. ~~`event: Callable[..., bool]` 的注解与行为不符~~ → 已改 `Callable[..., Any]`,与 `sampler` / `simulator` 对齐。 + pyright 复测:向量化事件函数不再被拒(原注解会报 `reportArgumentType`)。**决策 50 的遗留清零。** -### ⏳ monte_carlo_integral 的 3 条待办(2026-10-05,你的活) +### ✅ monte_carlo_integral 的 3 条待办(2026-10-05 已办结) -1. **`result.message` 被无条件覆盖**:拿到 `result` 后直接 `result.message = "…成功…"`,内层 `success=False` 时也照写。 - 实测 `func` 返回标量时,对外是 `success=False` + **"蒙特卡洛多重积分成功"** —— 两个字段自相矛盾。 - 方向(你定):用 `if result.success:` 包住,或 message 只**追加**、不覆盖。 -2. **`bounds` 形状没校验**:`bounds=[0, 1]`(1-D 扁平)在 `bounds_arr[:, 0]` 抛 `IndexError: too many indices` - —— 是裸异常,没包进 `Result`。方向:要求"每个元素都是二元组",入口给可读的 `ValueError`; - 顺手也能和 `(1, 0)` 那条校验合并到一处写。 -3. **`n: int = 10000` 与全模块不一致**:别的函数都写 `int | np.integer`(§4.7)。 - 运行期其实收 `np.int64`(`monte_carlo` 里放行了),只是注解更窄 —— 调用方按注解老实写,pyright 会对 `np.int64` 报错。 +- ~~① `result.message` 被无条件覆盖~~ → 已改成 `if result.success:` / `else:` 两个分支; + 失败时是"蒙特卡洛多重积分失败:维度 1,体积 1.0000,模拟 100 次。蒙特卡洛模拟失败:TypeError - ...", + `success` 与 `message` 不再打架。 +- ~~② `bounds` 形状没校验~~ → 入口加了 `isinstance(bounds, (list, tuple))` 与 `ndim == 2 and shape[1] == 2` 两道; + `bounds=[0, 1]` 现在报"bounds 必须形如 [(lo, hi), ...],当前形状为 (2,),请检查是否漏了括号"。 +- ~~③ `n: int`~~ → 已改 `int | np.integer`。 + +### ⏳ monte_carlo_integral 还有 2 条口径(新增,你的活) + +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 说的"同一个信息写两遍"。 ### ⏳ monte_carlo 只剩 1 个口径要你确认 diff --git a/src/mcm_toolkit/solve/simulate.py b/src/mcm_toolkit/solve/simulate.py index 60ae2ca..9cdd59c 100644 --- a/src/mcm_toolkit/solve/simulate.py +++ b/src/mcm_toolkit/solve/simulate.py @@ -132,7 +132,7 @@ def monte_carlo( def monte_carlo_probability( - event: Callable[..., bool], + event: Callable[..., Any], sampler: Callable[..., Any], n: int | np.integer = 10000, confidence: float = 0.95, @@ -246,7 +246,7 @@ def monte_carlo_probability( def monte_carlo_integral( func: Callable[..., Any], bounds: Any, - n: int = 10000, + n: int | np.integer = 10000, random_state: int | None = None, ) -> Any: """用蒙特卡洛求多重积分。 @@ -257,7 +257,7 @@ def monte_carlo_integral( `f(X) -> 一批函数值`。`X` 是形状 `(n, d)` 的样本矩阵(一行一个样本), 返回长度 `n` 的数组(写成 `X[:, 0] ** 2` 这种逐行写法,不要写 Python 循环)。 bounds: - `[(lo, hi), ...]`,维度即积分重数,要求每个 `lo < hi`。 + `[(lo, hi), ...]`(非空 list / tuple),维度即积分重数;要求每项都是二元组且 `lo < hi`。 n: 模拟次数,正整数。 random_state: @@ -272,7 +272,7 @@ def monte_carlo_integral( Raises ------ ValueError: - `bounds` 里出现 `lo >= hi`。 + `bounds` 不是非空 list / tuple、形状不是 `(d, 2)`(例如漏括号写成 `[0, 1]`),或某个 `lo >= hi`。 Notes ----- @@ -286,8 +286,14 @@ 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 必须是非空的列表或元组") + bounds_arr = np.asarray(bounds) + if bounds_arr.ndim != 2 or bounds_arr.shape[1] != 2: + raise ValueError(f"bounds 必须形如 [(lo, hi), ...],当前形状为 {bounds_arr.shape},请检查是否漏了括号") + lows = bounds_arr[:, 0] highs = bounds_arr[:, 1] d = len(lows) @@ -313,10 +319,17 @@ def monte_carlo_integral( n=n, random_state=random_state, ) - result.name = "monte_carlo_integral" - result.params["bounds"] = bounds - result.params["dimension"] = d - result.message = f"蒙特卡洛多重积分成功:维度 {d},体积 {volume:.4f},模拟 {n} 次。" + + 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 diff --git a/tests/test_solve.py b/tests/test_solve.py index 6716782..b67ea68 100644 --- a/tests/test_solve.py +++ b/tests/test_solve.py @@ -51,10 +51,32 @@ def test_monte_carlo_integral_2d_and_params(): def test_monte_carlo_integral_rejects_bad_bounds(): + """四类非法 bounds 都要在入口抛 ValueError:区间反了 / 漏括号 / 空 / 参差不齐。""" from mcm_toolkit.solve import monte_carlo_integral - with pytest.raises(ValueError): - monte_carlo_integral(lambda X: X[:, 0], bounds=[(1, 0)], n=100, random_state=0) + for bad in ([(1, 0)], [0, 1], [], [(0, 1), (0, 2, 3)]): + with pytest.raises(ValueError): + monte_carlo_integral(lambda X: X[:, 0], bounds=bad, n=100, random_state=0) + + +def test_monte_carlo_integral_failure_message_stays_failure(): + """失败时 message 不能被包装层改写成"成功"(易错点 22)。""" + from mcm_toolkit.solve import monte_carlo_integral + + r = monte_carlo_integral(lambda X: 1.0, bounds=[(0, 1)], n=100, random_state=0) + assert r.success is False + assert "成功" not in r.message + assert "失败" in r.message + assert r.params["dimension"] == 1 # 失败也要留下追溯信息 + + +def test_monte_carlo_integral_accepts_numpy_integer_n(): + """§4.7:`np.int64` 不是 `int` 的子类,注解与运行期都要收(易错点 1)。""" + from mcm_toolkit.solve import monte_carlo_integral + + r = monte_carlo_integral(lambda X: X[:, 0], bounds=[(0, 1)], n=np.int64(1000), random_state=0) + assert r.success + assert len(r.data) == 1000 def test_monte_carlo_probability_of_standard_normal():