"""动力学模型库与自动择优单元测试(任务 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): # 1/y = 1/y0 + k t 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 # --------------------------------------------------------------------------- # 16.1:每个模型可拟合 / 预测 / 区间 # --------------------------------------------------------------------------- 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) # --------------------------------------------------------------------------- # 16.6:预测区间正性与非对称性 # --------------------------------------------------------------------------- # 含轻微噪声的数据,使残差标准差非零,区间宽度有意义。 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 # log 空间对称 → 实数空间上间隙 > 下间隙 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 # --------------------------------------------------------------------------- # 16.2:合成各阶数据被正确识别 # --------------------------------------------------------------------------- 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 # --------------------------------------------------------------------------- # 16.3:过拟合排除与降级 # --------------------------------------------------------------------------- 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 # 二参数模型(p=2)在 n=3 时仍可参与 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) # 所有候选都因 p>=n 被排除 → 进入降级;但零级在降级路径里 p>=n 亦不可拟合带自由度, # 仍可线性拟合(lstsq 不要求自由度),故降级成功且 degraded=True。 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] # 含 0 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 # --------------------------------------------------------------------------- # 16.5:结果结构 / 追踪可审计 # --------------------------------------------------------------------------- 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) # trace 为非空字符串列表 assert res.trace and all(isinstance(s, str) for s in res.trace) # 恰有一个候选标记为 selected 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__": # pragma: no cover sys.exit(pytest.main([__file__, "-v"]))