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:
1 parent
58f2236703
commit
b0c4b4a413
5 files changed
+619
-61
No files matched your search
+191
-11
@@ -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]。"""
|
||||
|
||||
Reference in new issue
Block a user