Files
MCM-ICM_Toolkit/tests/test_solve.py
T
guzhujushi b0c4b4a413 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。
2026-10-05 11:26:54 +08:00

275 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""solve 包测试。"""
from __future__ import annotations
import numpy as np
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。"""
from mcm_toolkit.solve import monte_carlo_integral
r = monte_carlo_integral(lambda X: X[:, 0] ** 2, bounds=[(0, 1)], n=200_000, random_state=0)
assert abs(r.data - 1 / 3) < 0.01
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, _normal_batch, n=5000, random_state=0)
assert r.success
assert abs(r.metrics["probability"] - 0.5) < 0.05
def test_monte_carlo_is_reproducible():
from mcm_toolkit.solve import monte_carlo_probability
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]。"""
from mcm_toolkit.solve import markov_steady_state
r = markov_steady_state(np.array([[0.9, 0.1], [0.2, 0.8]]))
assert np.allclose(r.data, [2 / 3, 1 / 3], atol=1e-6)
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_knapsack_dp_known_optimum():
from mcm_toolkit.solve import knapsack_dp
r = knapsack_dp(weights=[1, 3, 4, 5], values=[1, 4, 5, 7], capacity=7)
assert np.isclose(r.metrics["total_value"], 9.0)
assert np.isclose(r.metrics["total_weight"], 7.0)
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_linear_program_basic():
from mcm_toolkit.solve import linear_program
r = linear_program(c=[1, 2], A_ub=[[1, 1]], b_ub=[4], bounds=[(0, None)] * 2)
assert r.success
assert np.isclose(r.metrics["objective"], 4.0)
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_mm1_metrics_closed_form():
from mcm_toolkit.solve import mm1_metrics
r = mm1_metrics(arrival_rate=2, service_rate=3)
assert np.isclose(r.metrics["rho"], 2 / 3, rtol=1e-6)
assert np.isclose(r.metrics["L"], 2.0, rtol=1e-6)
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_mm1_rejects_unstable_system():
"""λ >= μ 时系统不稳定,应 success=False 而不是返回 inf。"""
from mcm_toolkit.solve import mm1_metrics
assert mm1_metrics(arrival_rate=3, service_rate=3).success is False
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_simulated_annealing_finds_quadratic_minimum():
from mcm_toolkit.solve import simulated_annealing
f = lambda v: (v[0] - 3.0) ** 2 + (v[1] + 1.0) ** 2 # noqa: E731
r = simulated_annealing(f, x0=[0.0, 0.0], n_iter=5000, random_state=0)
assert np.allclose(r.params["x"], [3.0, -1.0], atol=0.1)