SALib敏感性分析的python库学习笔记

一、认识 SALib,建立敏感性分析的完整思路

模型里有十几个参数,究竟哪个最影响结果?哪些参数必须认真测量,哪些可以适当简化?两个单独看起来不起眼的参数,会不会组合起来产生很大的影响?SALib 就是用来系统回答这些问题的 Python 工具库。

这篇教程沿着一条实用路线展开,从敏感性分析的基本概念,到 Sobol、Morris、FAST、PAWN 的使用,再进入时间序列、多输出、参数分组、非均匀分布、外部模型和计算加速。案例中的代码采用现代函数接口,尽量做到可以直接运行,同时解释结果该怎样读、哪些结论不能轻易下。

版本说明 PyPI 项目页面当前显示 SALib 1.6.0。在线文档的 latest 页面可能与正式发行版存在差异,因此科研项目应记录实际安装版本。下面的代码属于教学示例,未在本文环境中实际执行,示例中的理论结果与数值估计会明确区分。

1.1 SALib 负责什么

SALib 的核心工作有两部分。

  • 生成输入样本,按照某种敏感性分析方法,设计模型应该在哪些参数组合上运行。
  • 计算敏感性指标,把模型输出转换成参数重要性、交互作用等结果。

模型本身由你提供。它可以是一个 Python 函数,也可以是水文模型、有限元软件、能源系统模型,甚至通过命令行调用的可执行程序。

这种分离设计很灵活。你只需要把模型接成一个稳定的输入输出流程。官方入门文档将典型工作流概括为定义参数、生成样本、运行模型、分析输出四个步骤。

这里最容易被低估的是最左端。参数范围和概率分布,本身就是分析假设的一部分。

同一个模型,给参数设定不同的不确定范围,得到不同的重要性排序完全正常。

1.2 敏感性分析与不确定性分析有什么区别

可以把模型理解成一台加工机器。参数是原料,输出是产品。

  • 不确定性分析关心产品可能落在哪个范围内,以及出现各种结果的概率。
  • 敏感性分析关心这种变化主要来自哪些原料,以及它们如何共同发挥作用。

设模型为
Y=f(X1,X2,...,XD) Y=f(X_1,X_2,\ldots,X_D) Y=f(X1,X2,...,XD)

不确定性分析可能研究输出的均值、分位数、方差或超过阈值的概率。

敏感性分析则进一步追问,输出的这些变化中,有多少与某个输入有关。

两者通常使用同一批模型运行结果,但回答的问题不同。

1.3 局部分析与全局分析

局部敏感性分析常在一个基准点附近,轻微改变某个参数,观察输出变化。导数就是典型的局部指标。
∂f ∂xi \frac{\partial f}{\partial x_i} ∂xi∂f

它适合回答某个运行状态附近的问题,例如当前设计点上,厚度增加一点会使应力变化多少。

全局敏感性分析则让参数在指定范围内变化,研究整个输入空间。SALib 的主要方法属于这一类。

全局方法尤其适合以下情况。

  • 模型具有明显非线性。
  • 参数之间存在交互作用。
  • 参数不确定范围较宽。
  • 基准点难以代表整个系统。
  • 希望给出面向测量、校准或模型简化的总体判断。

单因素逐个改变的方法容易错过交互作用。一个参数在基准点附近影响很小,在其他参数取值改变后,却可能变得十分重要。

1.4 如何选择方法

选择方法时,先看问题,再看预算。下面这张表可以作为起点。SALib 官方文档和 API 列出了这些方法及其接口。

方法 主要回答的问题 数据或计算特点 适合的场景
Sobol 各参数贡献多少输出方差,交互有多强 需要专门的结构化采样,成本较高 定量归因、交互分析、论文中的核心结果
Morris 哪些参数可能重要,哪些可以优先排除 相对节省模型运行次数 高维模型、昂贵仿真、前期筛选
FAST 各参数的一阶和总效应有多大 使用专门的频率编码采样 希望获得方差型指标的替代方案
RBD-FAST 参数的一阶方差效应有多大 可用于适当的现有输入输出数据 已有实验设计或仿真数据
PAWN 参数变化会如何改变输出分布 基于条件分布与无条件分布比较 偏态、多峰、阈值型输出
Delta 参数对完整输出分布影响多大 涉及分布估计,计算与样本需求需评估 方差不足以描述输出变化的情况
DGSM 参数的导数效应是否较大 需要导数或有限差分信息 平滑模型、辅助筛选与理论界限
HDMR 如何用低阶作用结构近似模型 涉及响应分解与近似拟合 高维系统的结构分析

实际项目中,一条很稳妥的路线是 Morris 筛选,再对重点参数开展 Sobol 定量分析。

不过,筛选后固定其他参数会改变分析问题。新的 Sobol 指标对应简化后的模型及其输入分布,不能直接当作原模型指标。

1.5 安装、环境检查与最小数据结构

建议在独立环境中安装,避免与其他科研软件的依赖冲突。

bash 复制代码
python -m venv .venv

# Linux 或 macOS
source .venv/bin/activate

# Windows PowerShell
# .venv\Scripts\Activate.ps1

python -m pip install --upgrade pip
python -m pip install SALib numpy pandas matplotlib scipy

检查版本,并保存环境。

python 复制代码
import sys
from importlib.metadata import version

for package in ["SALib", "numpy", "scipy", "pandas", "matplotlib"]:
    print(package, version(package))

print("Python", sys.version)
bash 复制代码
python -m pip freeze > requirements-lock.txt

具体 Python 与依赖版本要求以所安装 SALib 发行版的元数据为准。不要把旧教程中的依赖要求直接搬到新环境。

SALib 最基本的配置对象叫 problem。

python 复制代码
problem = {
    "num_vars": 3,
    "names": ["length", "width", "height"],
    "bounds": [
        [1.0, 2.0],
        [0.5, 1.5],
        [0.2, 0.8],
    ],
}

它表达了三个参数的名称与取值范围。没有指定其他分布时,默认采用均匀分布。

常见数据形状如下。

对象 典型形状 含义
X 模型运行次数 × 参数个数 每一行是一组参数
Y 模型运行次数 每组参数对应一个标量输出
Y_multi 模型运行次数 × 输出个数 每组参数对应多个输出
Y_time 模型运行次数 × 时间点个数 每组参数对应一条时间序列

函数式分析接口通常每次处理一个标量输出向量。多输出和时间序列可以逐列分析;ProblemSpec 还提供了更方便的多输出组织方式。


二、深入 Sobol,从跑通代码到读懂交互作用

Sobol 方法适合做定量解释。它把输出方差拆分到参数及参数组合上,让参数重要性有一个清晰的概率意义。

理解它的关键,是区分一阶效应、总效应与交互作用。

2.1 一阶指数究竟表示什么

经典 Sobol 方差分解通常假设输入变量相互独立,模型输出具有有限且非零的方差。

一阶指数为
Si= Var⁡Xi E⁡(Y∣Xi) Var⁡(Y) S_i= \frac{ \operatorname{Var}_{X_i} \left \\operatorname{E}(Y\\mid X_i) \\right }{ \operatorname{Var}(Y) } Si=Var(Y)VarXiE(Y∣Xi)

这个公式可以这样理解。

固定某个参数的取值,对其他参数造成的变化取平均。然后观察,这条平均响应随着该参数变化,能够产生多少输出方差。

如果一阶指数接近 0.6,可以理解为该参数的独立主效应约解释了输出方差的 60%。

它不表示参数增加 1%,输出就增加 60%。

Sobol 指标描述方差贡献,通常也不提供影响方向。判断参数增加会让输出上升还是下降,需要结合响应曲线、散点图或其他分析。

2.2 总效应与交互作用

总效应指数包含某个参数的一阶贡献,以及所有涉及它的交互贡献。
STi =1− Var⁡ X∼i E⁡(Y∣ X∼i ) Var⁡(Y) S_{T_i}= 1- \frac{ \operatorname{Var}{X{\sim i}} \left \\operatorname{E}(Y\\mid X_{\\sim i}) \\right }{ \operatorname{Var}(Y) } STi=1−Var(Y)VarX∼iE(Y∣X∼i)

其中, X∼i X_{\sim i} X∼i 表示除第一个指定参数以外的其他全部输入,这里的指定参数是下标为 ii i 的参数。

更直观地说,总效应衡量的是所有与这个参数有关的方差贡献。

二阶指数则描述两个参数共同产生、且无法由各自一阶作用解释的贡献。
Sij = Var⁡E⁡(Y∣Xi,Xj)−Vi−Vj Var⁡(Y) S_{ij}= \frac{ \operatorname{Var} \left \\operatorname{E}(Y\\mid X_i,X_j) \\right - V_i - V_j }{ \operatorname{Var}(Y) } Sij=Var(Y)VarE(Y∣Xi,Xj)−Vi−Vj

其中
Vi=Var⁡E⁡(Y∣Xi) V_i= \operatorname{Var} \left \\operatorname{E}(Y\\mid X_i) \\right Vi=VarE(Y∣Xi)

阅读结果时,可以抓住三个判断。

  • 一阶指数较大,说明参数独立作用明显。
  • 总效应较大,说明参数整体重要。
  • 总效应明显高于一阶指数,说明该参数存在较强的交互贡献。

例如模型
Y=X1X2 Y=X_1X_2 Y=X1X2

在两个输入相互独立、都以零为均值的情况下,两个一阶指数都可能为零,但两个总效应都为一。

这很好地解释了一个常见误区。一阶指数很小,不能直接据此删掉参数。

2.3 案例一,Ishigami 基准函数

Ishigami 函数是敏感性分析中的经典测试模型。它兼具非线性和交互作用,很适合检验代码与解释逻辑。SALib 的官方入门教程也使用这一基准。

这里直接写出模型,便于看到每个参数如何进入计算。
Y=sin⁡(X1)+7sin⁡2(X2)+0.1X34sin⁡(X1)Y=\sin(X_1)+7\sin^2(X_2)+0.1X_3^4\sin(X_1) Y=sin(X1)+7sin2(X2)+0.1X34sin(X1)

三个输入相互独立,并均匀分布在同一个区间。
Xi∼U(−π,π) X_i\sim U(-\pi,\pi) Xi∼U(−π,π)

下面是一份完整示例。

python 复制代码
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from SALib.sample import sobol as sobol_sample
from SALib.analyze import sobol as sobol_analyze


def ishigami_model(X):
    X = np.asarray(X, dtype=float)

    x1 = X[:, 0]
    x2 = X[:, 1]
    x3 = X[:, 2]

    return (
        np.sin(x1)
        + 7.0 * np.sin(x2) ** 2
        + 0.1 * x3 ** 4 * np.sin(x1)
    )


problem = {
    "num_vars": 3,
    "names": ["x1", "x2", "x3"],
    "bounds": [[-np.pi, np.pi] for _ in range(3)],
}

base_N = 4096
second_order = True

X = sobol_sample.sample(
    problem,
    base_N,
    calc_second_order=second_order,
    scramble=True,
    seed=2026,
)

Y = ishigami_model(X)

assert X.shape == (base_N * (2 * problem["num_vars"] + 2), 3)
assert Y.shape == (X.shape[0],)
assert np.isfinite(Y).all()
assert np.var(Y) > 0.0

Si = sobol_analyze.analyze(
    problem,
    Y,
    calc_second_order=second_order,
    num_resamples=500,
    conf_level=0.95,
    seed=2027,
    print_to_console=False,
)

result = pd.DataFrame(
    {
        "S1": Si["S1"],
        "S1_conf": Si["S1_conf"],
        "ST": Si["ST"],
        "ST_conf": Si["ST_conf"],
    },
    index=problem["names"],
)

print("X shape:", X.shape)
print(result.round(4))

print("\nSecond-order interactions")
for i in range(problem["num_vars"]):
    for j in range(i + 1, problem["num_vars"]):
        print(
            problem["names"][i],
            problem["names"][j],
            Si["S2"][i, j],
            Si["S2_conf"][i, j],
        )

positions = np.arange(problem["num_vars"])
width = 0.36

fig, ax = plt.subplots(figsize=(7, 4))

ax.bar(
    positions - width / 2,
    result["S1"],
    width,
    yerr=result["S1_conf"],
    capsize=4,
    label="First-order",
)

ax.bar(
    positions + width / 2,
    result["ST"],
    width,
    yerr=result["ST_conf"],
    capsize=4,
    label="Total-order",
)

ax.set_xticks(positions)
ax.set_xticklabels(problem["names"])
ax.set_ylabel("Sobol index")
ax.legend()
fig.tight_layout()
plt.show()

代码里把采样模块和分析模块分别起了别名。两者都叫 sobol,使用别名可以避免导入后相互覆盖。

现代教程建议优先使用 SALib.sample.sobol.sample。一些旧示例仍使用 SALib.sample.saltelli.sample,后者在较新的版本中已被弃用。Sobol 序列、Saltelli 型样本构造和 Sobol 指标是相关但不同的概念。

2.4 应该得到什么结果

在上述模型及输入分布下,理论指标约为下表。实际运行得到的是有限样本估计,不会逐位相同。

参数 一阶指数 总效应指数 解释
x1 0.3139 0.5576 独立作用与交互作用都明显
x2 0.4424 0.4424 主要通过自身产生影响
x3 0 0.2437 主要通过与 x1 的交互产生影响

主要二阶交互来自 x1 与 x3,理论值约为 0.2437。

这组结果可以直接从模型结构中找到解释。

  • x2 单独进入平方正弦项。
  • x3 只出现在乘积项中。
  • x1 既单独出现,也与 x3 联合作用。

还有两个很有用的检查关系。
∑iSi≤1 \sum_i S_i\leq 1 i∑Si≤1
STi ≥Si S_{T_i}\geq S_i STi≥Si

这些关系针对经典独立输入条件下的真实指标。有限样本估计可能轻微违反它们。

**总效应之和可以大于一。**同一项交互作用会计入多个相关参数的总效应,因此不能把总效应简单归一化成总和为一的贡献饼图。

2.5 样本量与计算成本

Sobol 采样中的 N 是基础样本量,通常不等于实际模型运行次数。

无分组时,不计算二阶指数需要
M=N(D+2)M=N(D+2) M=N(D+2)

计算二阶指数需要
M=N(2D+2)M=N(2D+2) M=N(2D+2)

这里, DD D 是参数数量, MM M 是模型运行次数。采样器的实际输出形状是最可靠的成本依据。

以十个参数为例。

基础样本量 是否计算二阶 模型运行次数
1024 否 12288
1024 是 22528
4096 否 49152
4096 是 90112

如果模型运行一次要一分钟,成本会迅速增长。只需要参数排名和整体交互判断时,可以先关闭二阶计算。

python 复制代码
X = sobol_sample.sample(
    problem,
    2048,
    calc_second_order=False,
    scramble=True,
    seed=42,
)

Y = ishigami_model(X)

Si = sobol_analyze.analyze(
    problem,
    Y,
    calc_second_order=False,
    seed=43,
)

采样与分析阶段的 calc_second_order 必须一致。

基础样本量一般优先选择二的整数次幂,有利于保持 Sobol 低差异序列的平衡性质。不要随意删掉采样行,也不要把普通随机采样或 Latin 超立方采样生成的输出直接交给 Sobol 分析器。

2.6 置信区间与收敛检查

S1_conf 和 ST_conf 是置信区间的半宽度,并非区间上界。默认的置信水平通常为 0.95,可通过 conf_level 调整。相关分析接口也允许设置重采样次数与随机种子。

例如一阶估计为 0.31,半宽度为 0.04,近似区间就是
0.31−0.04,  0.31+0.040.31-0.04,\\;0.31+0.04 0.31−0.04,0.31+0.04

它描述的是敏感性指标估计的统计不确定性,不能替代输入分布假设、模型结构误差等更广泛的不确定性评估。

建议从多个样本量与多个随机扰乱种子检查结果。

下面的代码接续前面的 problem 与 ishigami_model。

python 复制代码
records = []

for base_N in [256, 512, 1024, 2048, 4096]:
    for sampling_seed in [11, 22, 33]:
        X_check = sobol_sample.sample(
            problem,
            base_N,
            calc_second_order=False,
            scramble=True,
            seed=sampling_seed,
        )

        Y_check = ishigami_model(X_check)

        Si_check = sobol_analyze.analyze(
            problem,
            Y_check,
            calc_second_order=False,
            num_resamples=300,
            seed=1000 + sampling_seed,
        )

        for i, name in enumerate(problem["names"]):
            records.append(
                {
                    "N": base_N,
                    "seed": sampling_seed,
                    "parameter": name,
                    "S1": Si_check["S1"][i],
                    "ST": Si_check["ST"][i],
                    "ST_conf": Si_check["ST_conf"][i],
                }
            )

convergence = pd.DataFrame(records)

summary = (
    convergence
    .groupby(["N", "parameter"])[["S1", "ST", "ST_conf"]]
    .mean()
)

print(summary)

判断收敛时,关注三个方面。

  • 参数排序是否稳定。
  • 指标数值是否趋于稳定。
  • 不同种子之间的差异和置信区间是否足够小。

把 num_resamples 从 100 提高到 1000,主要是在改进置信区间估计,不会替代新增模型样本。


三、典型场景实战,从工程模型到现有数据

掌握 Sobol 后,可以把同一套思路迁移到真实任务。本节用几个完整或可衔接的案例,展示方法选择、代码组织与结果解释。

3.1 案例二,工程成本模型与 Morris 筛选

假设要估计一套小型系统的年度总成本,考虑以下因素。

  • 能源需求。
  • 设备效率。
  • 能源单价。
  • 安装费用。
  • 年维护费用。
  • 与能源成本相关的附加费率。

为方便演示,定义一个简化成本模型。
C= Epη (1+r)+ Cinstall 10 + Cmaintenance C= \frac{Ep}{\eta}(1+r) + \frac{C_{\mathrm{install}}}{10} + C_{\mathrm{maintenance}} C=ηEp(1+r)+10Cinstall+Cmaintenance

这里的十年分摊属于教学假设,没有加入贴现率、残值等完整财务因素。

python 复制代码
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from SALib.sample import morris as morris_sample
from SALib.analyze import morris as morris_analyze


def annual_cost_model(X):
    X = np.asarray(X, dtype=float)

    demand = X[:, 0]
    efficiency = X[:, 1]
    price = X[:, 2]
    installation = X[:, 3]
    maintenance = X[:, 4]
    surcharge = X[:, 5]

    energy_cost = demand * price / efficiency

    return (
        energy_cost * (1.0 + surcharge)
        + installation / 10.0
        + maintenance
    )


cost_problem = {
    "num_vars": 6,
    "names": [
        "demand",
        "efficiency",
        "price",
        "installation",
        "maintenance",
        "surcharge",
    ],
    "bounds": [
        [8000.0, 16000.0],
        [0.70, 0.95],
        [0.40, 1.00],
        [10000.0, 30000.0],
        [300.0, 1500.0],
        [0.00, 0.20],
    ],
}

levels = 6

X_morris = morris_sample.sample(
    cost_problem,
    N=80,
    num_levels=levels,
    optimal_trajectories=20,
    local_optimization=True,
    seed=2026,
)

Y_morris = annual_cost_model(X_morris)

Si_morris = morris_analyze.analyze(
    cost_problem,
    X_morris,
    Y_morris,
    num_levels=levels,
    num_resamples=500,
    conf_level=0.95,
    seed=2027,
    print_to_console=False,
)

morris_table = pd.DataFrame(
    {
        "mu": Si_morris["mu"],
        "mu_star": Si_morris["mu_star"],
        "sigma": Si_morris["sigma"],
        "mu_star_conf": Si_morris["mu_star_conf"],
    },
    index=cost_problem["names"],
).sort_values("mu_star", ascending=False)

print("Model evaluations:", X_morris.shape[0])
print(morris_table)

fig, ax = plt.subplots(figsize=(7, 5))

ax.scatter(
    morris_table["mu_star"],
    morris_table["sigma"],
)

for name, row in morris_table.iterrows():
    ax.annotate(
        name,
        (row["mu_star"], row["sigma"]),
        xytext=(5, 5),
        textcoords="offset points",
    )

ax.set_xlabel("mu_star")
ax.set_ylabel("sigma")
fig.tight_layout()
plt.show()

Morris 通过多条轨迹,在不同位置依次扰动参数,计算基本效应。优化轨迹采样可以从候选轨迹中挑选覆盖更好的子集。上例生成 80 条候选轨迹,最终保留 20 条。无分组时,实际模型运行次数为
20(6+1)=14020(6+1)=140 20(6+1)=140

候选轨迹生成与选择也会消耗计算资源,只是没有要求对全部候选点运行模型。

如何解释 Morris 结果

指标或模式 含义 解释边界
mu 基本效应的平均值 正负效应可能相互抵消
mu_star 基本效应绝对值的平均值 常用来做重要性筛选
sigma 基本效应的标准差 较大可能来自非线性、交互或噪声
mu_star 大、sigma 小 效应较强,变化相对稳定 仍需结合模型结构理解
mu_star 大、sigma 大 重要且响应随位置明显变化 不能只凭这一点区分非线性与交互
两者都较小 在给定范围内影响可能较弱 需要结合轨迹数量与不确定性判断

**Morris 指标不能解释成方差贡献百分比。**它的定位是筛选与结构探索。

scaled=True 可以使用按输入输出标准差缩放的基本效应,有利于某些比较任务,但会改变指标含义。分析时应使用模型实际评价的输入,并在报告中说明是否进行了缩放。

3.2 对工程模型进一步做 Sobol 分析

下面的代码使用上一案例中的 cost_problem 与 annual_cost_model,保持全部六个参数,开展定量归因。

python 复制代码
from SALib.sample import sobol as sobol_sample
from SALib.analyze import sobol as sobol_analyze

X_cost = sobol_sample.sample(
    cost_problem,
    4096,
    calc_second_order=False,
    scramble=True,
    seed=123,
)

Y_cost = annual_cost_model(X_cost)

Si_cost = sobol_analyze.analyze(
    cost_problem,
    Y_cost,
    calc_second_order=False,
    num_resamples=500,
    seed=124,
)

cost_sobol = pd.DataFrame(
    {
        "S1": Si_cost["S1"],
        "ST": Si_cost["ST"],
        "ST_conf": Si_cost["ST_conf"],
        "interaction_gap": Si_cost["ST"] - Si_cost["S1"],
    },
    index=cost_problem["names"],
).sort_values("ST", ascending=False)

print(cost_sobol)

这里新增的 interaction_gap 是总效应减去一阶效应,用来观察涉及该参数的交互贡献。

它与参数相关的全部高阶交互有关,不能直接当作某一对参数的二阶指数;不同参数的差值也不能简单相加,以免重复计算交互。

结果如何转化成行动,可以沿着这条路线理解。

  • 能源单价重要,说明价格情景设定对成本结论影响大。
  • 需求重要,说明需求预测质量很关键。
  • 效率重要,说明性能测量或技术选型值得投入资源。
  • 安装费用重要,说明采购报价的不确定性需要认真控制。
  • 总效应明显高于一阶效应,说明该参数的作用依赖其他因素。

这些都是对当前成本模型和当前参数分布的判断。它们不能直接替代投资优化,也不能告诉你哪个参数最容易控制。

3.3 案例三,时间序列输出的逐时分析

许多模型的输出是一条曲线,例如温度、径流量、浓度或感染人数。此时敏感性通常也随时间变化。

考虑一个简化冷却模型。
T(t)= Tambient + (T0− Tambient ) e−ktT(t)=T_{\mathrm{ambient}} + \left(T_0-T_{\mathrm{ambient}}\right)e^{-kt} T(t)=Tambient+(T0−Tambient)e−kt

参数包括初始温度、环境温度和冷却系数。

python 复制代码
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from SALib.sample import sobol as sobol_sample
from SALib.analyze import sobol as sobol_analyze


def cooling_model(X, times):
    initial = X[:, 0][:, None]
    ambient = X[:, 1][:, None]
    rate = X[:, 2][:, None]

    return ambient + (initial - ambient) * np.exp(
        -rate * times[None, :]
    )


cooling_problem = {
    "num_vars": 3,
    "names": ["initial_temperature", "ambient_temperature", "cooling_rate"],
    "bounds": [
        [70.0, 90.0],
        [15.0, 25.0],
        [0.05, 0.20],
    ],
}

times = np.linspace(0.0, 60.0, 121)

X_cooling = sobol_sample.sample(
    cooling_problem,
    2048,
    calc_second_order=False,
    scramble=True,
    seed=2026,
)

Y_time = cooling_model(X_cooling, times)

assert Y_time.shape == (X_cooling.shape[0], len(times))

S1_time = np.empty((len(times), cooling_problem["num_vars"]))
ST_time = np.empty_like(S1_time)

for j in range(len(times)):
    output_at_time = Y_time[:, j]

    if np.var(output_at_time) == 0.0:
        S1_time[j] = np.nan
        ST_time[j] = np.nan
        continue

    Si_t = sobol_analyze.analyze(
        cooling_problem,
        output_at_time,
        calc_second_order=False,
        num_resamples=200,
        seed=5000 + j,
    )

    S1_time[j] = Si_t["S1"]
    ST_time[j] = Si_t["ST"]

fig, ax = plt.subplots(figsize=(8, 4))

for i, name in enumerate(cooling_problem["names"]):
    ax.plot(times, ST_time[:, i], label=name)

ax.set_xlabel("Time")
ax.set_ylabel("Total-order Sobol index")
ax.legend()
fig.tight_layout()
plt.show()

time_result = pd.DataFrame(
    ST_time,
    columns=cooling_problem["names"],
)

time_result.insert(0, "time", times)
time_result.to_csv("cooling_total_effects.csv", index=False)

每一列都是一个时间点的标量输出,因此可以使用同一套 Sobol 样本逐列分析。增加分析时间点通常不需要新增参数采样,也不需要额外运行模型,前提是模型已经保存了这些时间点的结果。

这个案例有几个清晰的理论检查点。

  • 在初始时刻,输出等于初始温度,因此初始温度决定全部输出方差。
  • 随着时间增加,冷却系数会影响变化速度。
  • 时间足够长时,温度趋近环境温度,环境温度逐渐主导结果。

**不要把整个时间序列矩阵直接展平后分析。**展平会破坏样本与输出之间的对应关系。

3.4 多输出与时间序列摘要指标

逐时分析很详细,但决策往往只关注几个指标。

  • 最终温度。
  • 最大值。
  • 累积热暴露。
  • 超过危险阈值的持续时间。

以下代码接续冷却案例,将曲线转成几个标量输出。

python 复制代码
outputs = {
    "final_temperature": Y_time[:, -1],
    "maximum_temperature": Y_time.max(axis=1),
    "temperature_integral": np.trapezoid(
        Y_time,
        x=times,
        axis=1,
    ),
}

analysis_by_output = {}

for output_name, values in outputs.items():
    Si_output = sobol_analyze.analyze(
        cooling_problem,
        values,
        calc_second_order=False,
        num_resamples=300,
        seed=2028,
    )

    analysis_by_output[output_name] = pd.DataFrame(
        {
            "S1": Si_output["S1"],
            "ST": Si_output["ST"],
        },
        index=cooling_problem["names"],
    )

for output_name, table in analysis_by_output.items():
    print("\n", output_name)
    print(table)

在这个特定模型和参数范围下,温度单调下降,所以最大温度就是初始温度。这是一个有用的代码检查,不代表其他动态模型也有相同性质。

不同输出指标对应不同问题,参数排序当然可能不同。论文中应明确写出分析的是最终值、峰值、积分,还是整条曲线。

3.5 使用 ProblemSpec 组织多输出工作流

SALib 还提供 ProblemSpec 对象,可以串联采样、模型评价与分析,并组织多个输出。官方文档说明,这个接口会提供额外检查,也会带来一定运行开销。

下面是一份独立示例。

python 复制代码
import numpy as np
from SALib import ProblemSpec


def response_model(X):
    x1 = X[:, 0]
    x2 = X[:, 1]
    x3 = X[:, 2]

    response_1 = x1 + x2 ** 2
    response_2 = x1 * x3

    return np.column_stack([response_1, response_2])


sp = ProblemSpec(
    {
        "names": ["x1", "x2", "x3"],
        "bounds": [[0.0, 1.0] for _ in range(3)],
        "outputs": ["response_1", "response_2"],
    }
)

(
    sp.sample_sobol(
        2048,
        calc_second_order=False,
        scramble=True,
        seed=101,
    )
    .evaluate(response_model)
    .analyze_sobol(
        calc_second_order=False,
        num_resamples=300,
        seed=102,
    )
)

print(sp.samples.shape)
print(sp.results.shape)

for output_name in ["response_1", "response_2"]:
    result = sp.analysis[output_name]
    print(output_name)
    print("S1:", result["S1"])
    print("ST:", result["ST"])

学习初期,函数式接口更容易看清 X、Y 和分析结果的关系。流程成熟以后,ProblemSpec 往往更便于管理。

3.6 案例四,偏态输出与 PAWN

Sobol 主要研究方差。当输出呈现明显偏态、多峰,或者任务关注阈值变化时,观察完整分布也很有价值。

PAWN 的基本思路是比较两种分布。

  • 所有输入共同变化时的输出分布。
  • 某个输入限制在一个局部区间时的输出分布。

如果限制某个输入后,输出分布变化明显,就说明这个输入有较强影响。SALib 的 PAWN 实现使用 Kolmogorov--Smirnov 距离比较分布。

考虑一个非线性的损失模型。

python 复制代码
import numpy as np
import pandas as pd

from SALib.sample import latin
from SALib.analyze import pawn


def loss_model(X):
    loading = X[:, 0]
    resistance = X[:, 1]
    interaction = X[:, 2]

    margin = loading - resistance + 0.4 * loading * interaction

    return np.exp(3.0 * margin)


loss_problem = {
    "num_vars": 3,
    "names": ["loading", "resistance", "interaction"],
    "bounds": [
        [0.0, 1.0],
        [0.0, 1.0],
        [0.0, 1.0],
    ],
}

X_loss = latin.sample(
    loss_problem,
    8000,
    seed=2026,
)

Y_loss = loss_model(X_loss)

Si_pawn = pawn.analyze(
    loss_problem,
    X_loss,
    Y_loss,
    S=10,
    print_to_console=False,
    seed=2027,
)

pawn_table = pd.DataFrame(
    {
        "minimum": Si_pawn["minimum"],
        "mean": Si_pawn["mean"],
        "median": Si_pawn["median"],
        "maximum": Si_pawn["maximum"],
        "CV": Si_pawn["CV"],
    },
    index=loss_problem["names"],
).sort_values("median", ascending=False)

print(pawn_table)

这里,S=10 表示将每个输入划分为十个条件区间。各区间得到一个分布距离,再计算最小值、均值、中位数等摘要。

中位数较大,说明限制该输入时,输出分布通常会出现更明显的变化。

不过需要守住几个边界。

  • PAWN 数值不能直接解释成方差贡献比例。
  • PAWN 与 Sobol 指标不能混在同一条百分比尺度上比较。
  • 条件区间过多会让每个区间中的样本变少。
  • 条件区间过少会掩盖局部变化。
  • PAWN 比较的是整体分布,不能自动等同于专门的尾部风险分析。

如果真正关心失败概率,可以把输出改为阈值事件。
I(Y> ycritical ) I(Y>y_{\mathrm{critical}}) I(Y>ycritical)

再对这个事件输出开展相应分析。此时需要足够多的失败和未失败样本;如果所有样本都落在同一侧,输出方差为零,就无法正常进行方差型分析。

3.7 已有仿真或观测数据怎么处理

如果手头已有 X 和 Y,却不是按 Sobol 结构生成的,不能仅凭样本数量符合公式就使用 Sobol 分析器。

官方 FAQ 将 DMIM、RBD-FAST、PAWN 和 HDMR 列为可用于现有数据的分析途径。

可接续上面的 X_loss 和 Y_loss 使用 RBD-FAST。

python 复制代码
from SALib.analyze import rbd_fast

Si_rbd = rbd_fast.analyze(
    loss_problem,
    X_loss,
    Y_loss,
    M=10,
    num_resamples=300,
    seed=2028,
)

print(
    pd.DataFrame(
        {
            "S1": Si_rbd["S1"],
            "S1_conf": Si_rbd["S1_conf"],
        },
        index=loss_problem["names"],
    )
)

但能够接受现有数据,不代表任何现有数据都适合。

需要检查输入空间覆盖、样本量、变量之间的依赖,以及采样机制是否与研究问题一致。尤其是观测数据中的相关关系,不能自动解释成干预某个变量产生的因果效应。

3.8 案例五,FAST 的完整调用

FAST 通过不同频率编码输入变化,再从输出的频率结构中估计敏感性。

下面沿用成本模型,展示完整调用。

python 复制代码
from SALib.sample import fast_sampler
from SALib.analyze import fast

harmonics = 4
N_fast = 1024

X_fast = fast_sampler.sample(
    cost_problem,
    N_fast,
    M=harmonics,
    seed=2026,
)

Y_fast = annual_cost_model(X_fast)

Si_fast = fast.analyze(
    cost_problem,
    Y_fast,
    M=harmonics,
    num_resamples=300,
    seed=2027,
)

fast_table = pd.DataFrame(
    {
        "S1": Si_fast["S1"],
        "ST": Si_fast["ST"],
    },
    index=cost_problem["names"],
)

print("Model evaluations:", X_fast.shape[0])
print(fast_table)

FAST 采样的模型运行次数通常为
Mruns =ND M_{\mathrm{runs}}=ND Mruns=ND

采样器参数 M 表示谐波设置,与上式中的模型运行次数是不同概念。代码采用 harmonics 变量名,避免混淆。

采样器要求基础样本量满足相应频率条件,通常为
N>4M2N>4M^2 N>4M2

采样与分析中的谐波参数应保持一致。FAST 适合获取一阶与总效应,但这个接口不会像 Sobol 二阶分析那样,直接给出参数对之间的二阶交互矩阵。

3.9 非均匀分布,准确表达参数不确定性

现实中的参数未必均匀分布。

测量误差可能接近正态分布,始终为正的乘性参数可能接近对数正态分布,专家判断则可能使用三角分布。

SALib 通过 dists 指定边际分布。不过,一旦改变分布,bounds 的含义也可能改变。

分布标识 bounds 的含义 容易误解的地方
unif 下界、上界 区间内密度相同
norm 均值、标准差 两个值不是上下界
lognorm 对数空间的均值、标准差 不是原变量的均值与标准差
truncnorm 下界、上界、均值、标准差 顺序不能与 SciPy 的标准化截断参数混用

下面的示例分别使用均匀、正态和对数正态分布。

python 复制代码
import numpy as np

from SALib.sample import sobol as sobol_sample
from SALib.analyze import sobol as sobol_analyze

distribution_problem = {
    "num_vars": 3,
    "names": ["uniform_factor", "normal_factor", "positive_factor"],
    "bounds": [
        [0.0, 1.0],
        [10.0, 2.0],
        [0.0, 0.25],
    ],
    "dists": ["unif", "norm", "lognorm"],
}

X_dist = sobol_sample.sample(
    distribution_problem,
    2048,
    calc_second_order=False,
    scramble=True,
    seed=2026,
)

Y_dist = (
    X_dist[:, 0]
    + 0.2 * X_dist[:, 1]
    + X_dist[:, 2] ** 2
)

Si_dist = sobol_analyze.analyze(
    distribution_problem,
    Y_dist,
    calc_second_order=False,
    seed=2027,
)

print("S1:", Si_dist["S1"])
print("ST:", Si_dist["ST"])

如果原变量服从对数正态分布,已知其均值与标准差分别为 mm m 和 ss s,对数空间参数可转换为
σlog⁡= ln⁡ (1+s2m2) \sigma_{\log} = \sqrt{ \ln\left(1+\frac{s^2}{m^2}\right) } σlog=ln(1+m2s2)
μlog⁡=ln⁡(m)− σlog⁡22 \mu_{\log} = \ln(m)-\frac{\sigma_{\log}^2}{2} μlog=ln(m)−2σlog2

不要直接把原空间均值和标准差填进 lognorm 的参数配置。

另外,正态分布可能生成负值。如果参数具有严格物理边界,应考虑截断分布或更合适的正值分布。随意把负值裁剪为零,会改变你原先定义的概率模型。

3.10 参数分组,研究系统模块的整体影响

有时更关心整个模块,而不是单个参数,例如能源模块、投资模块和运维模块。

SALib 的 Sobol 与 Morris 方法支持参数分组。

下面的代码接续成本模型。

python 复制代码
group_problem = {
    **cost_problem,
    "groups": [
        "energy",
        "energy",
        "energy",
        "investment",
        "operation",
        "energy",
    ],
}

X_group = sobol_sample.sample(
    group_problem,
    2048,
    calc_second_order=False,
    scramble=True,
    seed=2026,
)

Y_group = annual_cost_model(X_group)

Si_group = sobol_analyze.analyze(
    group_problem,
    Y_group,
    calc_second_order=False,
    seed=2027,
)

total_df, first_df = Si_group.to_df()

print(total_df)
print(first_df)

分组后,结果对应组的作用。组内交互被包含在组自身效应中,因此组的一阶指数通常不能直接等于组内各参数一阶指数之和。

组数小于参数数时,分组设计通常能减少模型运行次数,但代价是丢失组内细节。X 仍然保留全部参数列,只是分析单位变成了组。

分组不会自动建立参数相关性,它与相关输入是两个不同的问题。


四、走向可靠应用,处理假设、性能与科研报告

代码能运行,只是入门。可靠的敏感性分析,还需要正确的输入假设、稳定的模型评价、合理的样本预算,以及清晰的解释边界。

4.1 输入相关与物理约束

经典 Sobol 分解建立在独立输入假设上。

现实系统中常见以下关系。

  • 土壤参数具有统计相关性。
  • 几种组分的比例之和必须为一。
  • 长度与质量受到共同设计规则约束。
  • 效率与能耗之间存在结构关系。

给 problem 中每个参数分别指定一个分布,并不会表达这些依赖关系。

如果直接采样相关输入,再套用独立输入的经典解释,指标可能混合统计依赖与模型作用,失去原有的清晰含义。

可行的处理思路包括以下几类。

  • 用独立潜变量生成相关的物理参数,再分析潜变量的敏感性。
  • 使用明确针对相关输入定义的方法,例如适当的 Shapley effects 方法。
  • 针对设计约束重新参数化,例如用较少的自由变量构造满足比例和约束的参数。

使用潜变量后,得到的是潜变量的重要性。除非变换关系非常明确,否则不能直接将其重新命名为原物理参数的 Sobol 指标。

如果存在物理约束,也不要简单删掉不满足约束的 Sobol 样本行。删除会破坏结构化设计,还会改变输入分布。

4.2 外部模型与并行计算

SALib 的分离式工作流很适合外部仿真。

建议先构造一个单次模型评价函数,确保一组参数得到一个标量输出;然后再并行执行。

下面的示例使用独立 Python 脚本演示多进程。它计算一个简单确定性模型,实际项目中可以替换 evaluate_one 的内部逻辑。

python 复制代码
import numpy as np
from concurrent.futures import ProcessPoolExecutor

from SALib.sample import sobol as sobol_sample
from SALib.analyze import sobol as sobol_analyze


def evaluate_one(row):
    a, b, c = row
    return float(a + b ** 2 + a * c)


def main():
    problem = {
        "num_vars": 3,
        "names": ["a", "b", "c"],
        "bounds": [[0.0, 1.0] for _ in range(3)],
    }

    X = sobol_sample.sample(
        problem,
        1024,
        calc_second_order=False,
        scramble=True,
        seed=2026,
    )

    with ProcessPoolExecutor(max_workers=4) as pool:
        Y = np.asarray(
            list(pool.map(evaluate_one, X, chunksize=64)),
            dtype=float,
        )

    assert Y.shape == (X.shape[0],)
    assert np.isfinite(Y).all()

    Si = sobol_analyze.analyze(
        problem,
        Y,
        calc_second_order=False,
        seed=2027,
    )

    print("ST:", Si["ST"])


if __name__ == "__main__":
    main()

pool.map 会按输入顺序返回结果,这对 Sobol 特别重要。如果使用 as_completed,必须通过样本编号把输出放回原始位置。

外部软件还应考虑以下工程细节。

  • 每次运行使用独立工作目录,避免并行写同一个文件。
  • 保存样本编号、参数、日志和运行状态。
  • 设置超时机制。
  • 对暂时性失败进行有限重试。
  • 保存最终输出,支持中断后继续计算。
  • 区分数值求解失败与模型定义中的真实失败事件。

如果使用进程池,Windows 和部分交互式环境对函数可导入性有要求。优先把并行程序放在独立脚本中。

对于可向量化的 NumPy 模型,批量计算往往比逐样本多进程更快。模型很便宜时,并行调度开销可能比计算本身还大。

4.3 模型失败、NaN 与零方差

敏感性分析前,应进行最基本的数据检查。

python 复制代码
def validate_scalar_output(X, Y):
    Y = np.asarray(Y, dtype=float)

    if Y.ndim != 1:
        raise ValueError("Expected a one-dimensional output vector.")

    if len(Y) != len(X):
        raise ValueError("Input and output sample counts differ.")

    if not np.isfinite(Y).all():
        raise ValueError("Output contains NaN or infinite values.")

    if np.var(Y) == 0.0:
        raise ValueError("Output variance is zero.")

    return Y

输出方差为零时,方差型指标的分母为零,结果没有正常的定义。

常见原因包括以下几种。

  • 模型没有读取变化的参数。
  • 输出文件被缓存,返回了旧结果。
  • 选择的输出在当前范围内本来就恒定。
  • 阈值事件没有被任何样本触发。
  • 参数单位或取值范围配置错误。

出现失败样本时,不要静默删除。可以重试、修复数值求解器、重新定义有效参数空间,或者把失败作为单独的研究输出。

直接把所有失败样本填成一个极大值,会人为构造新的响应模型。只有明确的物理或决策依据,才适合这样处理。

4.4 随机模型怎样分析

有些模型在相同参数下也会产生不同输出,例如随机个体模拟、排队系统或蒙特卡洛模型。

此时可写为
Y=f(X,ω)Y=f(X,\omega) Y=f(X,ω)

其中, ω\omega ω 表示内部随机性。

如果每组参数只运行一次,敏感性结果会同时受到参数变化和内部随机波动的影响。先明确研究目标很重要。

研究目标 常见处理方式 需要说明的内容
参数如何影响平均输出 每组参数重复运行,估计条件均值 重复次数与均值估计误差
参数如何影响输出波动 每组参数估计方差或分位数,再分析这些摘要 摘要估计是否稳定
参数与内部随机性共同贡献多少 显式建模随机输入或采用相应方法 随机性与参数作用如何分配
参数如何影响失败概率 每组参数重复运行,估计事件概率 稀有事件的样本需求

对于条件均值分析,可以在每组参数下运行多次,得到平均输出后再分析。但有限重复次数仍会残留估计噪声。

复用随机数流有时能降低参数比较中的噪声,不过需要有意识地设计,不能让并行调度顺序决定随机数使用方式。仅仅在全部运行中使用同一个固定随机种子,也不能保证回答的是平均输出问题。

4.5 昂贵模型与代理模型

如果直接运行几十万次有限元计算不可行,可以先训练代理模型,再让 SALib 分析代理模型。

可选代理包括高斯过程、随机森林、梯度提升模型、多项式近似和神经网络。

这里最重要的是,对代理模型得到的敏感性指标,直接对应代理模型。

代理在整体预测误差上表现不错,也可能漏掉局部交互、边界行为或稀有事件。因此应当检查。

  • 训练数据是否覆盖分析分布。
  • 独立验证集误差是否足够小。
  • 关键区域与阈值附近是否准确。
  • 不同训练样本或代理模型下,重要性排序是否稳定。
  • 原模型能否复核最重要的结论。

论文中应同时报告代理误差和敏感性估计不确定性。

4.6 结果解释中的常见陷阱

下面这些问题在实际项目里很常见,可以作为提交报告前的检查表。

问题 可能原因 建议处理
Sobol 分析报样本数量错误 采样与分析的二阶设置不一致 检查两个阶段的参数
结果没有报错但明显异常 输出顺序改变,或使用了不匹配的采样 核对样本编号与设计结构
出现很小的负指数 有限样本估计误差 增加样本并查看置信区间
总效应略小于一阶效应 估计尚未充分收敛 检查种子稳定性与样本量
某个参数完全不重要 范围太窄、未进入模型或被其他因素掩盖 检查配置与模型实现
参数排名随范围变化 输入不确定性假设发生变化 将范围情景作为研究结果报告
PAWN 与 Sobol 排名不同 指标关注的输出特征不同 分别解释,避免强行统一
Morris 的 sigma 很大 非线性、交互或内部噪声 结合响应图与进一步分析判断
结果全是 NaN 输出无方差或存在非法值 检查模型输出
多输出结论被一个排名替代 没有区分评价指标 按输出分别报告

SALib 的 Sobol 分析器会对输出进行中心化和按标准差标准化,以减轻非中心化输出带来的估计偏差。一般无需提前对输出做同样的线性标准化。

但如果对输出取对数、平方或改成阈值事件,就改变了分析对象。例如,对成本和对数成本做敏感性分析,排序可能不同,两者都需要明确说明。

4.7 科研报告应该包含哪些信息

一份可复现的敏感性分析报告,至少应该交代以下内容。

模型与输出

  • 模型方程、程序版本或仿真平台。
  • 输出指标的定义、单位和时间范围。
  • 模型是否包含内部随机性。

输入假设

  • 参数名称、单位、范围和概率分布。
  • 参数来源,例如测量、文献、专家判断或情景设定。
  • 输入是否独立,是否有物理约束。
  • 分析范围是否对应现实不确定性,还是探索性范围。

计算设计

  • 敏感性分析方法。
  • 基础样本量与实际模型运行次数。
  • 是否计算二阶交互。
  • 随机种子、重采样次数和置信水平。
  • SALib、Python 及关键依赖版本。
  • 失败样本、异常值和缺失值的处理。

结果与验证

  • 指标表、置信区间与必要的交互结果。
  • 样本量收敛和种子稳定性。
  • 多输出之间的差异。
  • 输入分布情景变化下的稳健性。
  • 对参数测量、模型简化或后续研究的具体建议。

可使用这样的报告表述框架。

本研究在所设定的参数范围与独立输入分布下,采用 Sobol 方法分析目标输出的方差来源。参数重要性以总效应指数为主要依据,同时报告一阶指数与估计置信区间。通过增加基础样本量和改变采样随机种子,检查指标与排序的稳定性。相关结论适用于本文定义的模型、输出指标和输入不确定性情景。

这里没有填入未经实际运行验证的排名或数值,正式报告应补上真实结果。

4.8 从入门到熟练的学习路线

如果要把本教程转化成练习,可以按四个阶段推进。

  1. 跑通基准模型

    运行 Ishigami 案例,确认数据形状,理解为什么 x3 一阶效应为零却仍然重要。

  2. 掌握方法选择

    对成本模型分别运行 Morris、Sobol 和 FAST,观察各方法结果及计算预算的差异。

  3. 处理实际输出与输入

    完成冷却曲线逐时分析、摘要指标分析、PAWN 分布分析,以及非均匀分布和分组配置。

  4. 建立可信工作流

    加入版本记录、失败检查、收敛验证、并行运行和结果保存,再接入自己的研究模型。

真正熟练使用 SALib,体现为能够把一个研究问题转换成明确的输入假设、合理的评价指标与可验证的计算设计。

**最重要的三句话是:参数重要性依赖你设定的不确定性范围;一阶效应小不等于参数可以删除;一次成功运行不等于结果已经可靠。**抓住这三点,SALib 就能从一组函数,变成帮助你理解模型、安排实验和组织科研论证的实用工具。

参考资料

SALib Basics

SALib GitHub Repository

SALib Documentation

SALib Concise API Reference

SALib PyPI Project

SALib Sampling API

SALib Analysis API

SALib Advanced Examples

SALib ProblemSpec Interface Basics

SALib Frequently Asked Questions

SALib Distribution Scaling Implementation

相关推荐
ServBay14 分钟前
从 Val Town 停服看,代码能下载不代表项目能搬家
后端·sqlite
duansamve1 小时前
Python3+Flask+PostgreSQL18+Vue3项目生产环境用docker如何部署?
python·docker·flask
我不会起名字3222 小时前
seq 空洞排查:Mongo 的 51 只是预留上界,Redis CURR 才是已消耗边界
redis·后端·mongodb·go·即时通讯·im·故障排查
特立独行的猫A2 小时前
少 3 个环节、少 1 次 ABI、少 3500 行代码:cj-tauri 与 Ionic/Capacitor 的鸿蒙桥接机制对比
java·后端·python
学长毕设2 小时前
基于SpringBoot的高校后勤报修管理系统-附源码
java·spring boot·后端
特立独行的猫A2 小时前
cj-tauri vs Rust/Tauri 同款播放器实测:编译 21 秒 vs 11 分钟,磁盘 12 MB vs 1.2 GB
java·后端·python
乐橙开放平台2 小时前
海康和大华摄像头能统一管理吗?可以通过 GB28181 或视频融合平台接入
网络·后端·物联网·音视频
duansamve2 小时前
详解Flask中Session和Cookie
python·flask
威哥爱编程3 小时前
模拟面试智能体开发实战 04:面试聊到哪算哪?用状态机把整场面试编排起来
python·agent·harmonyos