熵池化压力测试:把先验观点与历史分布融合成压力情景
传统压力测试要么拍脑袋设情景、要么只能重演历史。熵池化(Entropy Pooling, Meucci 2008)用最小相对熵(KL),在「满足一个压力观点」的约束下,找离历史先验最近的那条后验分布——于是「2008 股灾概率抬到 22%」不再是凭空编的情景,而是对历史分布的一次可量化「最小手术」。本文用 3 资产、4000 情景从零复现,并点明离散约束数值不稳定、过拟合观点、灾难定义主观性三类真实陷阱(进阶)。
风控问你:「如果 2008 再来一遍,我们组合会亏多少?」
传统做法有两种,各有硬伤:
- 拍脑袋设情景:直接假设「股票 −40%、债券 +5%」。问题——这组数字从哪来?凭什么不是 −30%?情景之间的相关性呢?你凭空编了一个联合分布。
- 直接重演历史:把 2008 那个月的收益原样搬过来。问题——历史只有一条路径,你永远在测「已知的那次」,测不出「下一次以不同方式崩」。
熵池化(Entropy Pooling, Meucci 2008)走第三条路:你有一整套历史模拟出的情景(先验),你只想说一句观点——「我觉得下季度股票崩 −12% 以上的概率,要从历史的 1.3% 抬到 22%」。然后让算法在「满足这个观点」的所有分布里,挑离先验最近的那个。2008 再来不是一个凭空编的数字,而是对历史分布的一次最小手术。
本文用 3 资产(股/债/金)、4000 个历史模拟情景,把整套逻辑从对偶推导跑到可读的压力 VaR/CVaR。
一、问题:观点怎么「织」进概率分布#
你有 K 个历史模拟情景 x_k(每只情景是「股/债/金」三维月度收益),先验等权 p_k = 1/K。这是你的先验——它天然带着历史里所有的相关性、肥尾、联动。
现在你要加一个观点,写成对情景的线性期望约束:
E_q[f_i(X)] = μ_i 即 Σ_k q_k · f_i(x_k) = μ_iplaintext对压力测试,最自然的 f 是指示函数——「情景落在某灾难区域 A 里」:
f(x_k) = 1{ x_k ∈ A } # 该情景是否算「灾难」
目标: Σ_k q_k · 1{x_k ∈ A} = 0.22 # 灾难概率 = 22%plaintext这比「拍脑袋设 −40%」强在哪?强在 q_k 是定义在所有情景上的完整概率分布,它自动继承了历史里「股票崩的时候债券往往涨、黄金往往涨」的相关性结构。你没破坏联合分布,只是把它的质量重新分配。
二、熵池化:最小相对熵的「最小手术」#
在所有满足约束的 q 里,我们挑相对熵(KL)最小的:
min_q Σ_k q_k · ln(q_k / p_k)
s.t. Σ_k q_k = 1
Σ_k q_k · f_i(x_k) = μ_iplaintext直觉:相对熵衡量「后验 q 相对先验 p 扭了多远」。最小化它 = 只做必要的最小扭曲去满足观点,其余一切尽量保持历史原样。这是「不轻易推翻历史经验」的数学表达。
对偶:把约束藏进指数#
引入拉格朗日乘子 λ,对偶目标:
g(λ) = λ·μ − ln Σ_k p_k · exp(−λ·f(x_k))plaintext最优后验有闭式:
q_k ∝ p_k · exp(−λ·f(x_k))plaintextλ 就是那把「手术刀」的力度:λ 越大,为了压到目标概率,你对先验的扭曲越狠。求 λ 只需让梯度 ∇g = μ − E_q[f] 归零——一个一维求根(单约束时)或阻尼牛顿(多约束时)就能解。
import numpy as np
from scipy.optimize import brentq
def entropy_pooling(f_mat, targets, p):
"""min KL(q||p) s.t. E_q[f] = targets。
f_mat: (K, m) 约束函数;targets: (m,)。单约束用 brentq。"""
def q_of(lam):
lq = np.log(p) - f_mat @ lam
m_ = lq.max()
w = np.exp(lq - m_) # log 空间,防溢出
return w / w.sum()
def grad(lam):
q = q_of(lam)
return targets - f_mat.T @ q # g(λ) = targets - E_q[f]
if f_mat.shape[1] == 1: # 单约束:一维求根
g0 = grad(np.array([0.0]))[0]
lo, hi = -80.0, 80.0
if g0 * grad(np.array([lo]))[0] <= 0:
lam = np.array([brentq(lambda v: grad(np.array([v]))[0], lo, 0.0)])
elif g0 * grad(np.array([hi]))[0] <= 0:
lam = np.array([brentq(lambda v: grad(np.array([v]))[0], 0.0, hi)])
else:
lam = np.array([0.0])
return q_of(lam), lam
# 多约束:阻尼牛顿(略,见仓库完整脚本)
return q_of(np.zeros(f_mat.shape[1])), np.zeros(f_mat.shape[1])
# ---- 构造先验:3 资产学生-t(肥尾)多元情景 ----
rng = np.random.default_rng(20260718)
K = 4000
mu0 = np.array([0.0070, 0.0030, 0.0045]) # 股/债/金 月度均值
sd = np.array([0.0400, 0.0150, 0.0350])
corr = np.array([[1.0,-0.20, 0.10],
[-0.20,1.0, 0.05],
[0.10, 0.05, 1.0]])
L = np.linalg.cholesky(corr)
z = rng.standard_t(5.0, size=(K, 3)) # 自由度 5 -> 肥尾
x = mu0 + (z @ L.T) * sd # 情景 (K,3)
p = np.full(K, 1.0 / K)
W = np.array([0.60, 0.30, 0.10]) # 组合权重:股60/债30/金10
rp = x @ W # 组合月度收益 (K,)python三、三种历史压力情景,量化「抬了多少」#
我们不对「凭空编」的情景测,而是对历史上真发生过的三类压力施加观点,看熵池化如何把尾部重新分配:
def w_quantile(vals, weights, alpha):
order = np.argsort(vals)
vs, ws = vals[order], weights[order]
cdf = np.cumsum(ws) / ws.sum()
return float(np.interp(alpha, cdf, vs))
def w_cvar(vals, weights, alpha):
q = w_quantile(vals, weights, alpha)
m = vals <= q
return float(np.sum(weights[m]*vals[m]) / np.sum(weights[m]))
scenarios = {
"2008 GFC(股灾)": (x[:,0] < -0.12, 0.22), # 股票月跌超12% -> 概率22%
"2020 新冠(急跌)": (x[:,0] < -0.13, 0.10), # 概率10%
"2022 利率冲击(债跌)": (x[:,1] < -0.04, 0.18), # 债券月跌超4% -> 概率18%
}
results = {}
prior_var, prior_cvar = w_quantile(rp, p, 0.05), w_cvar(rp, p, 0.05)
print(f"先验 VaR95={prior_var:.4f} CVaR={prior_cvar:.4f}")
# 先验 VaR95=-0.0442 CVaR=-0.0654
for name, (mask, target) in scenarios.items():
f = mask.astype(float).reshape(-1, 1)
q, lam = entropy_pooling(f, np.array([target]), p)
var_post = w_quantile(x @ W, q, 0.05)
cvar_post = w_cvar(x @ W, q, 0.05)
results[name] = dict(q=q, lam=lam, var=var_post, cvar=cvar_post,
p_crash_prior=mask.mean(), p_crash_post=mask @ q)python跑出来的关键数字:
2008 GFC(股灾): λ=-3.104 P(灾难) 1.3%->22.0% | VaR95 -4.4%->-9.6% | CVaR -6.5%->-16.2%
2020 新冠(急跌): λ=-2.450 P(灾难) 0.9%->10.0% | VaR95 -4.4%->-8.9% | CVaR -6.5%->-13.5%
2022 利率冲击(债跌): λ=-2.483 P(灾难) 1.8%->18.0% | VaR95 -4.4%->-4.9% | CVaR -6.5%->-7.3%plaintext一句话解读:一个「股灾概率抬到 22%」的观点,把组合的 95% VaR 从 −4.4% 推到 −9.6%、CVaR(平均尾部损失)从 −6.5% 推到 −16.2%——尾部风险翻了一倍多。而「债券跌」的观点对组合冲击小(组合只配 30% 债),这正说明熵池化如实继承了历史相关性:股票崩时债券往往对冲,所以纯股灾对组合更狠。

上图:灰色是先验(历史分布),红色是施加「股灾概率 22%」后的后验。你能直接看到红色把 −12% 以左的尾巴整体抬高——它不是只在 −15% 一个点加质量,而是把整个灾难区域的相对概率都重新配重。

上图:CDF 曲线越往右,同样收益分位越高。三条彩色曲线(三种压力后验)在左尾都明显比灰色先验更靠左——意味着「5% 最坏情形」的收益更低,即 VaR 恶化。

上图:同一 60/30/10 组合,灰柱是先验 VaR(−4.4%),彩柱是三种压力后验 VaR。股灾情景把 VaR 推得最狠(−9.6%),债跌最温和(−4.9%),和组合的资产配置结构一致。
四、为什么它比「拍脑袋」强#
| 方法 | 联合分布哪来 | 相关性 | 可解释性 |
|---|---|---|---|
| 拍脑袋情景 | 凭空编 −40%/+5% | 易漏、需手动设 | 数字无来源 |
| 重演历史 | 只有一条老路径 | 真实但单一 | 只测已知危机 |
| 熵池化 | 从历史先验派生 | 自动继承历史相关 | λ/KL 量化「扭曲多大」 |
最妙的是最后一项:熵池化自带「代价函数」。KL(q||p) 度量你对先验扭了多远。观点越极端,λ 越大、KL 越大——你可以在报告里写「这个压力情景相对历史扭了 KL=0.3,属于中度调整」,而不是「我觉得应该测一下这个」。
五、三类真实陷阱(不写这些就是美化风险)#
陷阱 1:离散约束的数值不稳定#
本文的压力观点 f = 1{灾难区域} 是阶跃函数——它关于 λ 的梯度在 λ→∞ 时行为很差,朴素牛顿法会一头扎到 λ=-80 的极端解(我们第一版就翻车了:P(灾难) 直接从 1.3% 跳到 100%)。
对策:单约束用 brentq 一维求根(稳),多约束用阻尼牛顿;并对 λ 设边界。本文最终用 brentq 才拿到合理的 λ≈-3。
陷阱 2:过拟合观点(Garbage In, Garbage Out)#
熵池化绝不生产新信息——它只是把你塞进去的观点,按最小扭曲铺开。如果你的观点本身错(「我觉得股灾概率 80%」只是恐慌),后验就是一场精确的自欺。 对策:观点要来自可辩护的来源——宏观团队的衰退概率模型、期权隐含的尾部概率、历史分位数。并且永远并列展示「先验 VaR vs 后验 VaR」,让决策者看到「扭了多大」。
陷阱 3:灾难区域是主观的#
「股灾」到底定义为 股票月跌 > 12% 还是 > 15%?边界一挪,先验里「算灾难」的情景比例就从 1.3% 变 0.5%,同样的目标概率 22% 对应的 λ 和尾部形状完全不同。
对策:对灾难阈值做敏感性扫描(12%/15%/20% 各跑一遍),报告 VaR 对阈值的定义域,而不是只报一个点。
六、落地路径(从演示到风控流程)#
- 建先验:用更长窗口(5~10 年日/月收益)做历史模拟,生成
K=10000+情景;或结合 GARCH 把波动聚集也编进去。 - 定观点:从期权隐含分布 / 宏观模型 / 专家判断抽 1~3 个可辩护的矩约束(灾难概率、某资产期望、相关性偏移)。
- 解后验:单约束 brentq、多约束阻尼牛顿,全程 log 空间防溢出。
- 读风险:用后验
q重算组合收益分布,输出 VaR/CVaR、回撤、甚至优化后对冲权重。 - 控陷阱:报
KL(q||p)量化扭曲;对灾难阈值、观点强度做敏感性扫描。 - 进流程:把「先验 vs 各压力后验」做成风控委员会的标准材料一页纸。
小结:熵池化把「压力测试」从「编故事」升级成「对历史分布做可量化的最小手术」。它的诚实在于——既告诉你观点把尾部推到了哪(VaR/CVaR 抬升),也告诉你你为此扭了先验多远(KL)。但记住:它只忠实铺开你给的观点,观点错了,再优雅的分布也是自欺。
最后更新:2026-07-18