| """动力学模型库与自动择优单元测试(任务 12 / 需求 16)。 |
| |
| 覆盖: |
| - 16.1:六种模型(零级、一级、二级、√t、Prout–Tompkins、二次多项式)均可拟合并预测。 |
| - 16.2:合成各阶数据能被 `select_best_model` 正确识别(按校正 R²/AIC 择优)。 |
| - 16.3:参数个数 ≥ 有效数据点数的模型被排除(防过拟合);全不适用时降级零级。 |
| - 16.5:纯 Python 计算,过程进入可审计的 trace(此处验证 trace/summary 结构)。 |
| - 16.6:每个模型配套预测区间,非恒等变换(一级/二级/PT)在实数空间产生非对称区间, |
| 且 t=0(或最小杠杆处)区间宽度 > 0。 |
| |
| 导入路径由 ``tests/conftest.py`` 设置(仓库根加入 sys.path),故可直接导入顶层 skills 包。 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import math |
| import sys |
|
|
| import numpy as np |
| import pytest |
|
|
| from skills.stability.models import ( |
| Interval, |
| FitStats, |
| ModelSelectionResult, |
| ModelNotApplicable, |
| ZeroOrder, |
| FirstOrder, |
| SecondOrder, |
| SqrtTime, |
| ProutTompkins, |
| QuadraticPoly, |
| DEFAULT_MODELS, |
| select_best_model, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| TIMES = np.array([0.0, 3.0, 6.0, 9.0, 12.0, 18.0, 24.0]) |
|
|
|
|
| def zero_order_data(y0=0.20, k=0.012): |
| return TIMES, y0 + k * TIMES |
|
|
|
|
| def first_order_data(y0=0.50, k=0.05): |
| return TIMES, y0 * np.exp(k * TIMES) |
|
|
|
|
| def second_order_data(y0=0.50, k=0.02): |
| |
| return TIMES, 1.0 / (1.0 / y0 + k * TIMES) |
|
|
|
|
| def sqrt_time_data(y0=0.20, k=0.10): |
| return TIMES, y0 + k * np.sqrt(TIMES) |
|
|
|
|
| def prout_tompkins_data(c=-3.0, k=0.20): |
| z = c + k * TIMES |
| return TIMES, 1.0 / (1.0 + np.exp(-z)) |
|
|
|
|
| def quadratic_data(a=0.20, b=0.005, c=0.002): |
| return TIMES, a + b * TIMES + c * TIMES ** 2 |
|
|
|
|
| |
| |
| |
|
|
| def test_zero_order_fit_recovers_parameters(): |
| t, y = zero_order_data(y0=0.2, k=0.012) |
| m = ZeroOrder() |
| stats = m.fit(t, y) |
| assert stats.name == "zero-order" |
| assert stats.n_params == 2 |
| assert stats.params["y0"] == pytest.approx(0.2, abs=1e-6) |
| assert stats.params["k"] == pytest.approx(0.012, abs=1e-6) |
| assert stats.r2 == pytest.approx(1.0, abs=1e-9) |
| |
| assert m.predict(12.0) == pytest.approx(0.2 + 0.012 * 12.0, abs=1e-6) |
|
|
|
|
| def test_first_order_fit_recovers_parameters(): |
| t, y = first_order_data(y0=0.5, k=0.05) |
| m = FirstOrder() |
| stats = m.fit(t, y) |
| assert stats.space == "log" |
| assert stats.params["y0"] == pytest.approx(0.5, rel=1e-6) |
| assert stats.params["k"] == pytest.approx(0.05, rel=1e-6) |
| assert stats.r2 == pytest.approx(1.0, abs=1e-9) |
|
|
|
|
| def test_second_order_fit_recovers_parameters(): |
| t, y = second_order_data(y0=0.5, k=0.02) |
| m = SecondOrder() |
| stats = m.fit(t, y) |
| assert stats.space == "reciprocal" |
| assert stats.params["y0"] == pytest.approx(0.5, rel=1e-6) |
| assert stats.params["k"] == pytest.approx(0.02, rel=1e-6) |
| assert stats.r2 == pytest.approx(1.0, abs=1e-9) |
|
|
|
|
| def test_sqrt_time_fit_recovers_parameters(): |
| t, y = sqrt_time_data(y0=0.2, k=0.10) |
| m = SqrtTime() |
| stats = m.fit(t, y) |
| assert stats.space == "sqrt-t" |
| assert stats.params["y0"] == pytest.approx(0.2, abs=1e-6) |
| assert stats.params["k"] == pytest.approx(0.10, abs=1e-6) |
| assert stats.r2 == pytest.approx(1.0, abs=1e-9) |
|
|
|
|
| def test_prout_tompkins_fit_recovers_parameters(): |
| t, y = prout_tompkins_data(c=-3.0, k=0.20) |
| m = ProutTompkins() |
| stats = m.fit(t, y) |
| assert stats.space == "logit" |
| assert stats.params["k"] == pytest.approx(0.20, rel=1e-6) |
| assert stats.params["c"] == pytest.approx(-3.0, rel=1e-6) |
| assert stats.r2 == pytest.approx(1.0, abs=1e-9) |
|
|
|
|
| def test_quadratic_fit_recovers_parameters(): |
| t, y = quadratic_data(a=0.2, b=0.005, c=0.002) |
| m = QuadraticPoly() |
| stats = m.fit(t, y) |
| assert stats.n_params == 3 |
| assert stats.params["a"] == pytest.approx(0.2, abs=1e-6) |
| assert stats.params["b"] == pytest.approx(0.005, abs=1e-6) |
| assert stats.params["c"] == pytest.approx(0.002, abs=1e-6) |
| assert stats.r2 == pytest.approx(1.0, abs=1e-9) |
|
|
|
|
| |
| |
| |
|
|
| |
| NOISY_TIMES = [0.0, 3.0, 6.0, 9.0, 12.0] |
|
|
|
|
| def test_zero_order_interval_positive_width_and_symmetric(): |
| y = [0.20, 0.27, 0.33, 0.41, 0.46] |
| m = ZeroOrder() |
| m.fit(NOISY_TIMES, y) |
| iv = m.predict_interval(0.0) |
| assert iv.upper - iv.lower > 0.0 |
| |
| mid = m.predict_interval(6.0) |
| upper_gap = mid.upper - mid.point |
| lower_gap = mid.point - mid.lower |
| assert math.isclose(upper_gap, lower_gap, rel_tol=1e-6) |
|
|
|
|
| def test_first_order_interval_is_asymmetric(): |
| |
| y = [0.20, 0.255, 0.33, 0.41, 0.52] |
| m = FirstOrder() |
| m.fit(NOISY_TIMES, y) |
| iv = m.predict_interval(24.0) |
| upper_gap = iv.upper - iv.point |
| lower_gap = iv.point - iv.lower |
| assert iv.upper - iv.lower > 0.0 |
| |
| assert upper_gap > lower_gap |
| assert not math.isclose(upper_gap, lower_gap, rel_tol=1e-3) |
|
|
|
|
| def test_second_order_interval_is_asymmetric(): |
| y = [0.50, 0.46, 0.40, 0.36, 0.30] |
| m = SecondOrder() |
| m.fit(NOISY_TIMES, y) |
| iv = m.predict_interval(6.0) |
| assert iv.upper - iv.lower > 0.0 |
| assert iv.lower <= iv.point <= iv.upper |
|
|
|
|
| def test_interval_lower_bound_floored_at_zero(): |
| """物理地板:预测区间下界不为负。""" |
| y = [0.20, 0.27, 0.33, 0.41, 0.46] |
| m = ZeroOrder() |
| m.fit(NOISY_TIMES, y) |
| iv = m.predict_interval(0.0) |
| assert iv.lower >= 0.0 |
|
|
|
|
| |
| |
| |
|
|
| def test_select_identifies_zero_order(): |
| t, y = zero_order_data() |
| res = select_best_model(t, y) |
| assert res.degraded is False |
| |
| assert res.best_stats.name == "zero-order" |
|
|
|
|
| def test_select_identifies_first_order(): |
| t, y = first_order_data() |
| res = select_best_model(t, y) |
| assert res.degraded is False |
| assert res.best_stats.name == "first-order" |
|
|
|
|
| def test_select_identifies_sqrt_time(): |
| t, y = sqrt_time_data() |
| res = select_best_model(t, y) |
| assert res.degraded is False |
| assert res.best_stats.name == "sqrt-time" |
|
|
|
|
| def test_select_identifies_second_order(): |
| t, y = second_order_data() |
| res = select_best_model(t, y) |
| assert res.degraded is False |
| assert res.best_stats.name == "second-order" |
|
|
|
|
| def test_select_identifies_prout_tompkins(): |
| t, y = prout_tompkins_data() |
| res = select_best_model(t, y) |
| assert res.degraded is False |
| assert res.best_stats.name == "prout-tompkins" |
|
|
|
|
| def test_selected_model_not_worse_than_zero_order_baseline(): |
| """design Property 11:所选模型校正 R² 不劣于零级基线。""" |
| t, y = first_order_data() |
| res = select_best_model(t, y) |
| zo = ZeroOrder() |
| zo_stats = zo.fit(t, y) |
| assert res.best_stats.adj_r2 >= zo_stats.adj_r2 - 1e-9 |
|
|
|
|
| |
| |
| |
|
|
| def test_overfit_model_excluded_when_params_ge_points(): |
| """二次多项式(p=3)在 n=3 时应被排除(p ≥ n)。""" |
| t = [0.0, 6.0, 12.0] |
| y = [0.20, 0.30, 0.45] |
| res = select_best_model(t, y) |
| quad = next(c for c in res.candidates if c.name == "quadratic-poly") |
| assert quad.status == "excluded_overfit" |
| assert "过拟合" in quad.reason |
| |
| assert res.best_stats is not None |
| assert res.best_stats.n_params < 3 |
|
|
|
|
| def test_two_point_excludes_all_two_param_then_degrades_appropriately(): |
| """n=2 时所有 p≥2 模型均被排除。零级(p=2)也满足 p≥n → 触发降级路径。""" |
| t = [0.0, 12.0] |
| y = [0.20, 0.40] |
| res = select_best_model(t, y) |
| |
| |
| assert res.degraded is True |
| assert res.best_stats is not None |
| assert res.best_stats.name == "zero-order" |
|
|
|
|
| def test_degrade_when_no_model_applicable_due_to_insufficient_points(): |
| """点数不足(n=1):连零级都无法回归 → best_model 为 None,由上层 refusal 处理。""" |
| t = [5.0] |
| y = [0.30] |
| res = select_best_model(t, y) |
| assert res.best_model is None |
| assert res.degraded is True |
| assert "不适用" in res.degrade_reason or "局限" in res.degrade_reason |
|
|
|
|
| def test_non_positive_values_exclude_log_and_reciprocal_models(): |
| """含 0 / 负值时,一级与二级(需正数)不适用,被跳过而非报错。""" |
| t = [0.0, 3.0, 6.0, 9.0, 12.0] |
| y = [0.0, 0.10, 0.20, 0.30, 0.40] |
| res = select_best_model(t, y) |
| statuses = {c.name: c.status for c in res.candidates} |
| assert statuses["first-order"] == "not_applicable" |
| assert statuses["second-order"] == "not_applicable" |
| |
| assert res.best_stats is not None |
| assert res.degraded is False |
|
|
|
|
| |
| |
| |
|
|
| def test_selection_result_summary_and_trace_structure(): |
| t, y = first_order_data() |
| res = select_best_model(t, y) |
| summary = res.summary() |
| assert summary["selected_model"] == "first-order" |
| assert summary["degraded"] is False |
| assert summary["params"] is not None |
| assert isinstance(summary["candidates"], list) |
| assert len(summary["candidates"]) == len(DEFAULT_MODELS) |
| |
| assert res.trace and all(isinstance(s, str) for s in res.trace) |
| |
| selected = [c for c in res.candidates if c.status == "selected"] |
| assert len(selected) == 1 |
| assert selected[0].name == "first-order" |
|
|
|
|
| def test_model_not_applicable_raised_on_direct_fit(): |
| """直接对非正数据拟合一级模型应抛 ModelNotApplicable。""" |
| m = FirstOrder() |
| with pytest.raises(ModelNotApplicable): |
| m.fit([0.0, 6.0, 12.0], [0.0, -0.1, 0.2]) |
|
|
|
|
| def test_predict_before_fit_raises(): |
| m = ZeroOrder() |
| with pytest.raises(RuntimeError): |
| m.predict(5.0) |
|
|
|
|
| def test_predict_accepts_array_and_scalar(): |
| t, y = zero_order_data() |
| m = ZeroOrder() |
| m.fit(t, y) |
| scalar = m.predict(6.0) |
| arr = m.predict([0.0, 6.0, 12.0]) |
| assert isinstance(scalar, float) |
| assert isinstance(arr, np.ndarray) |
| assert arr.shape == (3,) |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(pytest.main([__file__, "-v"])) |
|
|