Files
MCM-ICM_Toolkit/src/mcm_toolkit/solve/sensitivity.py
T
guzhujushi 5319589dfd chore: 初始化 MCM/ICM Toolkit 仓库骨架
审查并精简 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 后端)
2026-10-01 17:43:48 +08:00

215 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""敏感性分析与鲁棒性检验。
★ 建议做成招牌模块。原建议这一节方向完全正确,只是函数名偏多,
已合并同义项、删掉 `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