← 返回模块
3.2.3.2beta 可读 · 未来付费内容校验中内容版本 2026-05-27

用 SciPy 做假设检验与置信区间

3.2.3 · SciPy 与统计工具 · 编程

周五下午两点半,浦东陆家嘴一家中型私募的风控会上,PM 把昨晚跑出来的 tear-sheet 推过来:「食品饮料这只 600519.SH 的 63 日滚动 ​夏普比率​​(Sharpe ratio)样本期均值是 0.86,银行那两只 000001.SZ600036.SH 是 0.42。0.44 的差,可信吗?」你脑子里第一反应是 3.2.2 L5 那条管道吐出的两条 sharpe_63d 序列——但「均值差 0.44」和「均值差显著不为 0」是两件事。L1 已经教会你把 scipy.stats 的分布对象、.fit() 与描述性统计当一套统一接口来用;本课接着上来,把同一个 scipy.stats 当成第二件工具:一组假设检验(hypothesis test)函数加一个 bootstrap 置信区间,给出可写进会议纪要、能在风控签字栏边上守得住的判定句。

一、最小起点:单样本 t 检验

把问题拍简单:手头一条 252 个交易日的日收益数组 returns(沪深300 ETF 510300.SH 在 2024 年的样本),问「这只 ETF 的总体日均收益是否显著不为 0?」零假设(null hypothesis, H₀)写作 H0:μ=0H_0: \mu = 0,备择(alternative hypothesis, H₁)写 H1:μ0H_1: \mu \neq 0,双侧。SciPy 的反射动作只有四行:

import scipy.stats as st
result = st.ttest_1samp(returns, popmean=0.0)
t_stat, p_value = result.statistic, result.pvalue
reject_null = p_value < 0.05

返回值是命名元组(named tuple)。.statistic 是 t 统计量 t=(rˉμ0)/(s/N)t = (\bar r - \mu_0) / (s / \sqrt N).pvalue 是在 N1N - 1 个自由度的 t 分布上、双侧累积尾部面积。决策规则:在显著性水平 α=0.05\alpha = 0.05 下,若 p_value < 0.05 就拒绝 H₀。承重假设是 i.i.d. 样本——日收益弱相关近似成立,但厚尾会把方差估计推高,使检验偏保守;这正是 ​中心极限定理​​(central limit theorem)给出的「近似有效」的保护伞,也是后面用 Mann-Whitney U 与 bootstrap 反复交叉印证的根本原因。

二、双样本 t 检验与 Mann-Whitney

回到开场那道题。把 sharpe_63d 在两个行业上的子序列分别叫 s_a(食品饮料:600519.SH)与 s_b(银行:000001.SZ600036.SH 拼接):

result = st.ttest_ind(s_a, s_b, equal_var=False)
mw_result = st.mannwhitneyu(s_a, s_b, alternative='two-sided')

equal_var=False 把检验切换成 Welch's t-test(韦尔奇 t 检验):不假设两组方差相等。这是 finance 数据的安全默认——同一只票不同时期的波动率都未必相等,更不必说两个行业。零假设 H0:μa=μbH_0: \mu_a = \mu_bmannwhitneyu 是它的非参数表兄:比较的是秩(rank)而非均值,因此不依赖正态性,检验「两个分布是否相等」——在金融语境下与「两个均值是否相等」近似可换用。经验法则:两个 p 值同时报告,​​若两者结论相反,相信非参数那个​​——说明 Gaussian 假设正在做主要工作。alternative='two-sided' 显式声明双侧;不要在量化数据上沿用 equal_var=True 的默认。

注意一道承重的告警,第五节会兑现:「Rolling-Sharpe series with a 63-day window are heavily autocorrelated (consecutive days share 62 of 63 observations); the t-test's effective sample size is far below the nominal N, the test will overstate significance, and the bootstrap CI on the difference of point-estimate Sharpes is the safer cross-check.」翻译过来:63 日窗口的滚动 Sharpe 在相邻两天共享 62 of 63 个观测,t 检验的有效样本量远低于名义 N,检验会高估显著性,bootstrap CI 落在点估计 Sharpe 之差上才是更稳的交叉验证。

配对 t 检验 st.ttest_rel(before, after) 用于同一组前后两次测量——例如同一组 ticker 在风控模型变更前后的组合 Sharpe;这里仅指名,不作为本课主线。

三、正态性检验:Jarque-Bera 与 KS

在套用 t 检验或正态 VaR 之前,应当先问一句:这条收益序列本身像不像正态?SciPy 里两件趁手工具:

jb = st.jarque_bera(returns)
ks = st.kstest(returns, 'norm', args=(mu_n, sigma_n))

jarque_bera(雅克-贝拉检验)基于偏度(skewness)与超额峰度(excess kurtosis),对厚尾敏感,是量化金融最常用的正态性检验——它恰好刺中 VaR 模型里最容易漏算的那一段。kstest(柯尔莫哥洛夫-斯米尔诺夫检验)是更通用的拟合优度检验(goodness-of-fit):可对任何完整指定的分布做检验,这里 (mu_n, sigma_n) 来自 L1 的 norm.fit(returns)。两者的零假设都是「数据来自所设分布」。但要小心解读:N = 252 时正态性检验​​功效低​​,难以拒绝小偏离;N = 10000 时反过来——任何微小偏离都被拒绝,纵使经济意义可忽略。配一张 Q-Q 图做眼睛级体检往往比 p 值更诚实。Shapiro-Wilk(夏皮罗-威尔克检验,st.shapiro)对小样本最强,N ≤ 5000;超过该规模时改回 jarque_bera

四、多重比较陷阱

量化研究里最常踩的坑:在因子库里跑 50 个独立 t 检验、每个 α=0.05\alpha = 0.05。即便 50 条零假设全为真,期望误报数也已经是 2.5。这条规则要原文记牢——Running k independent tests at α=0.05 gives expected false positives ≈ k × 0.05; Bonferroni rejects only when p_i < α / k; Benjamini-Hochberg controls the false discovery rate via from statsmodels.stats.multitest import multipletests; rejected, pvals_corr, _, _ = multipletests(pvals, alpha=0.05, method="fdr_bh")。两条校正路线:邦费罗尼(Bonferroni)控制家族错误率(family-wise error rate),简单但保守;Benjamini-Hochberg 控制假发现率(false discovery rate, FDR),是因子研究里的行业默认,因为它在数十至数百条因子的同时检验下保留更多功效。生产 API 是 statsmodels.stats.multitest.multipletests,本课点名 "fdr_bh" 是行业默认;真正在因子库里跑起来,是 4.2.3 信号评估与组合那一节的事。

五、Bootstrap 置信区间

上面的 t 检验是「在零假设成立时数据有多极端」的尺子;bootstrap 给出的是另一类答案——「点估计本身的不确定区间」。scipy.stats.bootstrap 是非参数置信区间(confidence interval, CI)的主力:

import numpy as np
rng = np.random.default_rng(seed=42)
sharpe_fn = lambda r: r.mean() / r.std(ddof=1) * np.sqrt(252)
result = st.bootstrap((returns,), statistic=sharpe_fn, n_resamples=10000, confidence_level=0.95, method='BCa', random_state=rng)
ci_low, ci_high = result.confidence_interval

几件事说清。数据参数是​​元组​ (returns,)——这让同一 API 同时支持单样本、配对、多样本;statistic= 接收任何返回标量的函数;n_resamples=10000 是社区默认起步规模;method='BCa'(bias-corrected accelerated, 偏差校正-加速)是 canonical 选择——它对统计量的偏差与加速做了二阶校正,比 'percentile' 准、比 'basic' 现代。random_state=rng 沿用 3.2.1 L3 的 seeded-generator 模式,写回归测试时这套结果可复现。

为何把 bootstrap CI 当成 Sharpe 显著性的判定工具?因为 Sharpe 估计量在有限样本下并不服从高斯分布(收益有偏度时它本身就偏),sharpe_hat ± 1.96 * SE 那一行高斯 CI 只在大样本无偏度时才正确。Bootstrap 不对采样分布作任何参数假设,95% CI 若不包含 0,就在 5% 水平上拒绝「真 Sharpe 为 0」的零假设。同样的报告方式适用于 ​最大回撤​​(max drawdown)这类样本均值之外的统计量——它的采样分布更斜,bootstrap CI 是几乎唯一能放心上报的写法。

六、回到开场:双 Sharpe 差异判定

把开场那道题真正答完。把同一篮子分成 returns_a(食品饮料:600519.SH 日收益)与 returns_b(银行:000001.SZ600036.SH 日收益拼接),跑差值的 BCa bootstrap:

result_diff = st.bootstrap((returns_a, returns_b), statistic=lambda a, b: sharpe_fn(a) - sharpe_fn(b), n_resamples=10000, confidence_level=0.95, method='BCa', random_state=rng)

这一句把第二节末尾「t 检验高估显著性」的告警一次性化解:sharpe_fn(a) - sharpe_fn(b) 是两段非重叠收益样本上​​点估计 Sharpe 的差​​——没有 63 日窗口的人造自相关,bootstrap 重采样从原始 i.i.d. 假设出发;得到的 95% CI 若不包含 0,则两个行业 Sharpe 在 5% 水平上显著不同。把这条 CI 与第二节里 t 检验的 (t_stat, p_value)、Mann-Whitney 的 (U, p_mw) 一起写进会议纪要——风控对 0.44 的差距才有真正可签字的判断。需要提醒:本例中食品饮料只有一只 ticker,是教学化样本,不构成研究级结论;同一套代码套到 30 只票的篮子上才是研究台真用的形式,标准的「在你自己的票池上跑一遍」是首选迭代方式。

还需点出一处工程上的边界:本课所有检验都假设 i.i.d. 样本。A 股有春节、国庆两段长假,把样本期切断成不连续段;SSE 的 T+1 结算与涨跌停板并不改变检验的统计性质,但会改变可成交收益序列——returns 该用收盘价对数收益还是可成交价收益,是数据合约层面的决定,不属于本课范畴。时间序列特有的平稳性与协整检验(ADF / KPSS / Engle-Granger)见 2.3.1,本课不涉。Bayesian 视角的可信区间(credible interval)以及排列检验(permutation test,scipy.stats.permutation_test)作为替代路线被点名;同样不在本课练习内。

下一课接力

到这里你能在两条收益序列上交付完整的判定:t 检验给出快速的「数值是否在噪音里」、Mann-Whitney 给出无分布假设的交叉验证、bootstrap CI 给出可写进风控报告的不确定区间。L3 面对的是一道更精细的问题:把 600519.SH 的日收益回归到 510300.SH(市场代理)的日收益上,​​回归斜率​​(即 beta)是否显著不等于一个假设值(如 1.0)?这需要的不是「均值差检验」而是「带标准误的 OLS」——scipy.stats.linregress 把斜率、截距、标准误、p 值一次返回,是 L3 的主线。

练习

Exercise

给定两条 1-D NumPy 数组 returns_areturns_b,分别是两个行业的日简单收益。请完成四步:(1) 跑 Welch 双样本 t 检验 st.ttest_ind(returns_a, returns_b, equal_var=False),提取 (t_stat, p_value);(2) 跑 Mann-Whitney U 检验 st.mannwhitneyu(returns_a, returns_b, alternative="two-sided"),提取 (U, p_mw);(3) 计算年化 Sharpe 差 sharpe_fn(returns_a) - sharpe_fn(returns_b) 的 95% BCa bootstrap CI——st.bootstrap((returns_a, returns_b), statistic=lambda a, b: sharpe_fn(a) - sharpe_fn(b), n_resamples=10000, confidence_level=0.95, method="BCa", random_state=np.random.default_rng(seed=42)),从 result.confidence_interval(ci_low, ci_high);(4) 返回元组 (t_stat, p_value, U, p_mw, ci_low, ci_high)。其中 sharpe_fn = lambda r: r.mean() / r.std(ddof=1) * np.sqrt(252)

提示
提示一:st.ttest_ind(..., equal_var=False) 即 Welch 检验;在返回对象上访问 .statistic.pvalue 取出 t 值与 p 值。
提示
提示二:st.bootstrap((a, b), statistic=fn, ...) 把多样本以元组形式一次性传入;result.confidence_interval 直接解包为 (low, high)