审查并精简 ChatGPT 的建库建议后落地的第一版骨架,只定接口不写实现。 - 建议审查:14 个包 → 7 个;约 250 个函数 → 约 40 个;5 个 Result 类 → 1 个 (docs/01-suggestion-review.md) - docs/:架构、路线图(含永久不做清单)、环境、Git 工作流、函数卡片模板 - src/mcm_toolkit/:core / data / stats / models / solve / decision / viz 共 23 个模块,均为签名 + docstring + NotImplementedError - tests/:9 个文件,其中 test_api_surface.py 已通过(20 passed) - examples/ problems/ notebooks/ figures/ tables/ 骨架 - pyproject.toml(核心依赖 4 个 + 6 组 extras)、environment.yml - scripts/:setup-venv.ps1(ASCII + LF)、setup-conda.ps1、doctor.ps1 - 环境:.venv(system-site-packages)与 conda 环境 mcm-toolkit 均已建好, 数值栈版本与 base 完全对齐(numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6,MKL 后端)
215 lines
6.5 KiB
Python
215 lines
6.5 KiB
Python
"""敏感性分析与鲁棒性检验。
|
||
|
||
★ 建议做成招牌模块。原建议这一节方向完全正确,只是函数名偏多,
|
||
已合并同义项、删掉 `grid_sweep`(`parameter_sweep` 的特例)。
|
||
|
||
论文里的"敏感性分析"一节基本就是这四个函数的组合:
|
||
局部扰动 → OAT 单因素扫描 → 多因素参数扫描 → 全局 Sobol/Morris。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Callable
|
||
from typing import Any
|
||
|
||
__all__ = [
|
||
"local_sensitivity",
|
||
"one_at_a_time",
|
||
"parameter_sweep",
|
||
"robustness_test",
|
||
]
|
||
|
||
|
||
def local_sensitivity(
|
||
model: Callable[..., Any],
|
||
params: Any,
|
||
step: float = 1e-4,
|
||
relative: bool = True,
|
||
**kwargs: Any,
|
||
) -> Any:
|
||
"""局部敏感性:各参数的偏导 / 弹性。
|
||
|
||
Parameters
|
||
----------
|
||
relative:
|
||
`True` 时算弹性 `(∂y/∂p)·(p/y)`——**无量纲,可以跨参数比较**,
|
||
这是局部敏感性的正确做法;`False` 时是原始偏导(有量纲,只有同量纲参数之间可比)。
|
||
|
||
Returns
|
||
-------
|
||
`Result`:`data` 是参数 → 弹性 的表,按绝对值降序。
|
||
|
||
Notes
|
||
-----
|
||
有限差分的步长是精度与截断误差的权衡:太大截断误差大,
|
||
太小被浮点误差淹没。`1e-4` 是稳健默认值;参数本身量级差很多时用相对步长。
|
||
|
||
Examples
|
||
--------
|
||
>>> s = local_sensitivity(lambda p: p["a"] ** 2 * p["b"], {"a": 2.0, "b": 3.0}) # doctest: +SKIP
|
||
"""
|
||
# TODO(L1) ★ S 档
|
||
raise NotImplementedError
|
||
|
||
|
||
def one_at_a_time(
|
||
model: Callable[..., Any],
|
||
params: Any,
|
||
parameter: str | None = None,
|
||
values: Any = None,
|
||
n_points: int = 20,
|
||
span: float = 0.5,
|
||
) -> Any:
|
||
"""OAT(一次只动一个参数)。
|
||
|
||
Parameters
|
||
----------
|
||
parameter:
|
||
`None` 时对所有参数依次扫描。
|
||
span:
|
||
相对基准值的扫描范围,`0.5` 表示 ±50%。
|
||
|
||
Returns
|
||
-------
|
||
`Result`:`data` 是长表 `(parameter, value, output)`,配 `viz.plot` 直接出蛛网图/敏感性曲线。
|
||
|
||
Notes
|
||
-----
|
||
OAT 的**已知缺陷**:它看不见参数之间的交互作用。
|
||
如果两个参数是相乘关系(很常见),OAT 会严重低估整体敏感性——
|
||
论文里必须说明这一点,并补一个全局敏感性作为交叉验证。
|
||
"""
|
||
# TODO(L1)
|
||
raise NotImplementedError
|
||
|
||
|
||
def parameter_sweep(
|
||
model: Callable[..., Any],
|
||
ranges: dict[str, Any],
|
||
n_points: int = 20,
|
||
method: str = "grid",
|
||
n_samples: int = 1000,
|
||
random_state: int | None = None,
|
||
) -> Any:
|
||
"""多参数扫描(合并了原建议的 `grid_sweep` 与 `random_parameter_sweep`)。
|
||
|
||
Parameters
|
||
----------
|
||
ranges:
|
||
`{"alpha": (0.1, 0.9), "beta": (0, 5)}`。
|
||
method:
|
||
`"grid"`:笛卡尔积,点数 = `n_points^d`,**维度一高就爆**(d=5, n=20 是 320 万次);
|
||
`"lhs"`:拉丁超立方,维度高时用这个;
|
||
`"random"`:纯随机。
|
||
|
||
Returns
|
||
-------
|
||
`Result`:`data` 是 `(n_points^d, d + 1)` 的结果表,
|
||
`params["best"]` 是让目标最优的参数组合。
|
||
|
||
Notes
|
||
-----
|
||
`d >= 4` 时默认改走 `"lhs"`,否则 `n_points` 会被指数放大到跑不完。
|
||
|
||
Examples
|
||
--------
|
||
>>> r = parameter_sweep(lambda a, b: -(a - 0.3) ** 2 - (b - 2) ** 2,
|
||
... {"a": (0, 1), "b": (0, 4)}, n_points=11) # doctest: +SKIP
|
||
"""
|
||
# TODO(L0)
|
||
raise NotImplementedError
|
||
|
||
|
||
def robustness_test(
|
||
model: Callable[..., Any],
|
||
scenarios: Any = None,
|
||
params: Any = None,
|
||
perturbation: float = 0.1,
|
||
n_samples: int = 1000,
|
||
random_state: int | None = None,
|
||
) -> Any:
|
||
"""鲁棒性检验(合并了原建议的 `perturbation_test`)。
|
||
|
||
在参数上施加 ±`perturbation` 的随机扰动,看结论是否翻盘。
|
||
|
||
Returns
|
||
-------
|
||
`Result`:`metrics` 含 `"rank_stability"`(原结论在前 X% 的比例)、
|
||
`"conclusion_flip_rate"`(结论反转频率)。**这两个数字才是审稿人想看的。**
|
||
|
||
Notes
|
||
-----
|
||
鲁棒性检验和敏感性分析不是一回事:
|
||
敏感性问"参数变了输出变多少",鲁棒性问"参数变了**结论**还成立吗"。
|
||
论文里两节都要有,别混着写。
|
||
|
||
Examples
|
||
--------
|
||
>>> r = robustness_test(model, params={"alpha": 0.5}, random_state=0) # doctest: +SKIP
|
||
"""
|
||
# TODO(L1)
|
||
raise NotImplementedError
|
||
|
||
|
||
def sobol_analyze(
|
||
model: Callable[..., Any],
|
||
bounds: Any,
|
||
n_samples: int = 1024,
|
||
calc_second_order: bool = False,
|
||
random_state: int | None = None,
|
||
) -> Any:
|
||
"""Sobol 全局敏感性(底层 SALib,自己不要重写)。
|
||
|
||
Parameters
|
||
----------
|
||
bounds:
|
||
`{"alpha": (0, 1), "beta": (0, 5)}` 形式的参数上下界。
|
||
n_samples:
|
||
SALib 的基样本数 N;**实际模型调用次数是 `N·(2d + 2)`**
|
||
(二阶时更多),d=5、N=1024 就是 12288 次调用。先估一下模型单次耗时。
|
||
|
||
Returns
|
||
-------
|
||
`Result`:`data` 含 `S1`(一阶)、`ST`(总效应)、`S2`(二阶,可选)三列。
|
||
|
||
Notes
|
||
-----
|
||
* `ST - S1` 的差距就是**交互作用**的大小——这正是 OAT 看不见的东西。
|
||
两者差得多,说明不能只看单因素。
|
||
* 需要 `pip install "mcm-toolkit[sensitivity]"`;缺 SALib 时本函数给出
|
||
可读的安装提示,而不是裸抛 `ModuleNotFoundError`。
|
||
* 费用高:**先做 OAT 缩小参数范围,再对少数关键参数做 Sobol**,
|
||
不要一上来就对全部参数做全局敏感性。
|
||
|
||
Examples
|
||
--------
|
||
>>> r = sobol_analyze(model, bounds={"a": (0, 1), "b": (0, 5)}) # doctest: +SKIP
|
||
"""
|
||
# TODO(L1) ★ S 档:配完整函数卡片
|
||
raise NotImplementedError
|
||
|
||
|
||
def morris_analyze(
|
||
model: Callable[..., Any],
|
||
bounds: Any,
|
||
n_trajectories: int = 10,
|
||
n_levels: int = 4,
|
||
random_state: int | None = None,
|
||
) -> Any:
|
||
"""Morris 筛选法(底层 SALib)。
|
||
|
||
调用次数只与轨迹数成正比(`(d + 1) · n_trajectories`),比 Sobol 便宜一个量级,
|
||
适合**参数多但只需要找出"哪几个重要"**的筛选阶段。
|
||
|
||
Returns
|
||
-------
|
||
`Result`:`data` 含 `mu`、`mu_star`(推荐看的)、`sigma` 三列。
|
||
`sigma` 大表示该参数的效应是非线性的或与其他参数有交互。
|
||
|
||
Examples
|
||
--------
|
||
>>> r = morris_analyze(model, bounds={"a": (0, 1), "b": (0, 5)}) # doctest: +SKIP
|
||
"""
|
||
# TODO(L1)
|
||
raise NotImplementedError
|