用户实现: - 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。
275 lines
9.4 KiB
Python
275 lines
9.4 KiB
Python
"""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)
|