很多人在做数据体检的时候,第一页永远只写均值、标准差、最大最小值,然后就直接下结论说"这批数据没问题"。我见过不止一次这样的评审现场:两组数摆在一起,均值和方差几乎一模一样,可把直方图画出来,一个是规规矩矩的钟形,另一个是拖着长长右尾巴、中间尖得像根针的形状。方差相同、均值相同,为什么会连"长得像不像"都对不上?原因很简单,均值和方差分别是一阶、二阶的信息,它们只描述了数据的"位置"和"散布",对"形状"这件事一无所知。偏度和峰度就是专门用来补这个缺口的两个指标,前者告诉你分布往哪边甩尾巴,后者告诉你尾巴有多厚。这篇内容面向已经会用均值方差、但还没系统建立分布形状判断习惯的数据分析从业者,也适合做风控、做实验分析、做数据质量的同学当一份速查手册。我会从公式原理一路讲到代码实现、显著性判断和几个容易翻车的地方,全部是可复现的操作。
1. 均值方差看不住的东西,偏度峰度怎么补位
1.1 两批数同均值同方差,却完全不是一类数据
先做一个思想实验。A 组数据是 100 个来自标准正态的采样,B 组数据是 95 个来自标准正态的采样,再混进 5 个绝对值很大的极端值。这两组数据的均值都接近 0,但 B 组因为那几个极端值,方差会被抬高,所以严格来说方差并不相同。真正让人困惑的场景是调参之后的对比:把 B 组的数据做一次线性缩放,让它的方差和 A 组对齐,此时两组的均值和方差就完全一样了,但它们的直方图形态天差地别。
这种"同均值同方差、不同形态"的情况在真实业务里一点都不罕见。比如某电商平台统计用户月消费金额,绝大多数人集中在几百到几千,少数几个大客户能到几十万;再比如某在线教育产品统计用户单次学习时长,大部分人停留几分钟,极少数人挂机一整天。这些数据的分布都严重右偏,而一旦右偏,用均值去描述"典型用户"就会失真——均值会被右侧那几条长尾拉得远远高于中位数。
所以判断一批数据能不能用均值加减标准差来概括,本质上是在问:这批数据的分布形态是否接近对称、尾部是否足够薄。这两个问题正好对应偏度和峰度。我个人的习惯是,任何一次描述性统计汇报,均值、标准差、中位数、偏度、峰度这五个数必须同时出现,缺一个都说明这次体检没做完整。
1.2 从矩的角度理解:三阶矩管方向,四阶矩管尾巴
要理解偏度和峰度的本质,绕不开"矩"这个概念。在统计学里,围绕均值定义的 k 阶中心矩是:
$$m_k = \frac{1}{n}\sum_{i=1}^{n}(x_i - \bar{x})^k$$
- 一阶中心矩恒为 0,没信息量;
- 二阶中心矩就是方差,衡量数据离均值有多散;
- 三阶中心矩衡量的是"偏离的方向性"——正偏差的三次方是正的,负偏差的三次方是负的,两边不会互相抵消,所以它能捕捉到哪一侧的偏差更"重";
- 四阶中心矩因为四次方抹掉了符号,衡量的是"偏差的量级有多极端",少数超大偏差会在四次方下被极度放大,因此它对尾部极其敏感。
把三阶、四阶中心矩除以标准差对应的幂次做标准化,就得到了无量纲的偏度和峰度。这个标准化的动作很关键:如果不除以 $\sigma^3$ 和 $\sigma^4$,偏度和峰度就会带着量纲,收入数据算出来的偏度和温度数据算出来的偏度根本没法比。标准化之后,它们变成纯数字,可以跨数据集比较。
生活化的类比是这样的:把均值想成一群人的"重心位置",方差想成"大家散得多开",偏度想成"人群往哪一侧拖了一条长长的队伍",峰度想成"有没有几个跑得特别远的人"。重心和散布告诉你队伍大概在哪、有多宽,但只有加上后面两个,你才知道这队伍是圆的还是拖着尾巴的。
1.3 一个常被忽略的事实:形状指标不是线性可加的
这里要提前埋一个坑,后面第 6 节会展开。偏度和峰度都是标准化矩,分母里带着标准差,它们不是线性可加的统计量。这意味着你把 10 个城市的数据各自算出偏度,再取平均,得到的数字和"把 10 个城市数据合在一起算一个偏度"完全是两回事。很多人做分组汇总表时习惯性地对偏度列取平均,这在数学上是没有意义的。
同样地,也不能说"整体偏度等于各部分偏度之和"。方差好歹还有方差分解公式,偏度和峰度没有类似的简单可加性。这一点在做多层级报表时特别容易出错,记住一句话就行:偏度峰度只能对原始数据算,不能对汇总值再算。
2. 偏度:把"往哪边甩尾巴"翻译成一个可比的数字
2.1 手算一遍公式,看清分子分母各自在干什么
样本偏度最常用的定义是 Fisher-Pearson 标准化矩系数:
$$g_1 = \frac{m_3}{m_2^{3/2}} = \frac{\frac{1}{n}\sum (x_i-\bar{x})^3}{\left[\frac{1}{n}\sum (x_i-\bar{x})^2\right]^{3/2}}$$
分子是三阶中心矩,负责判断方向;分母是方差的 1.5 次方,负责把量纲消掉。举个小例子,数据是[1, 2, 3, 4, 100]。均值是 22,五个偏差分别是 -21、-20、-19、-18、78。把这些偏差立方后,前四个加起来大约是 -9261-8000-6859-5832 = -29952,最后一个是 78³ = 474552,总和约 444600,除以 5 得 88920。方差这边,偏差平方和约 441+400+361+324+6084 = 7610,除以 5 得 1522,取 1.5 次方约 59370。所以 $g_1 \approx 88920/59370 \approx 1.50$。
这个 1.50 的含义就是:右边那条尾巴比左边重得多。你可以顺手算一下中位数是 3,均值是 22,均值远大于中位数,这正是右偏分布的典型特征。这个"均值大于中位数就是右偏"的经验判断,在单峰分布上非常好用,但遇到双峰分布会失效,不能当作定理用。
需要特别注意的是,当 $n$ 较小的时候,上面这个 $g_1$ 是有偏的。统计上常用的修正版本是:
$$G_1 = \frac{\sqrt{n(n-1)}}{n-2} \cdot g_1$$
这个修正因子在 $n$ 很大时趋近于 1,所以大样本下两者几乎没有区别。但如果你在做小样本分析(比如每组只有 20 个观测的 A/B 实验),用哪个版本就会带来可见的差异。
2.2 正偏负偏在业务里的真实含义,以及最常见的误读
偏度为正叫右偏,尾巴拖在右边;偏度为负叫左偏,尾巴拖在左边。听起来简单,但误读率极高。我见过最常见的错误是:看到偏度为正,就说"数据整体偏大"。这是完全相反的。
右偏的真实含义是:大部分观测值集中在较小的区间,少数极大的值把均值往上拽。收入、消费金额、页面停留时长、订单金额、城市人口,几乎全是右偏。右偏时均值 > 中位数,用均值描述"典型值"会系统性高估。这时候正确的做法是报中位数,或者报截尾均值(比如去掉最高的 1% 再算平均)。
左偏相对少见,典型场景是"天花板效应":一场很简单的考试,大部分人考 90 分以上,少数人不及格,尾巴拖在左边。这时候均值 < 中位数,均值会低估典型水平。
把判断规则整理一下,方便随时对照:
| 偏度符号 | 分布形态 | 均值与中位数关系 | 业务上的典型信号 |
|---|---|---|---|
| 接近 0(绝对值小于 0.5) | 近似对称 | 均值约等于中位数 | 数据可用均值描述 |
| 正(大于 0.5) | 右偏,长尾在右 | 均值明显大于中位数 | 存在少量超大值,考虑用中位数或对数变换 |
| 负(小于 -0.5) | 左偏,长尾在左 | 均值明显小于中位数 | 存在少量极小值,警惕数据缺失被填成了 0 |
表中的 0.5 这个阈值是经验值,不同领域的教材给的数字不一样,有的用 0.5,有的用 1。我的建议是别把它当硬性标准,把它当"提示灯":超过 0.5 就该去画图看看了,至于要不要做变换,还得结合业务判断。
顺带说一个极端情况:如果偏度绝对值超过 2,通常意味着数据里有几个非常突出的点,这时候第一反应不应该是"数据偏斜严重",而应该是"我是不是有脏数据没清掉"。
2.3 三种常见算法与一条样本量红线
实际动手时你会遇到至少三种偏度值,它们在小样本下差别明显:
- 原始矩估计(
scipy.stats.skew(x, bias=True)):直接用 $g_1$,有偏但计算简单; - 样本修正估计(
scipy.stats.skew(x, bias=False)或pandas.Series.skew()):用 $G_1$,是无偏估计,日常分析推荐用这个; - 分位数偏度(Bowley skewness):$\frac{Q_3 + Q_1 - 2Q_2}{Q_3 - Q_1}$,只用三个分位数,取值范围固定在 -1 到 1,对离群值完全不敏感。
分位数偏度这个工具值得单独说一下。它的最大价值是抗干扰:如果你怀疑数据里有未被清理的异常值,用分位数偏度得到的结论会比矩偏度稳得多。代价是它对分布中段的变化不敏感,只能看大方向。
样本量这条红线必须强调。偏度的标准误近似为:
$$SE(g_1) \approx \sqrt{\frac{6n(n-1)}{(n-2)(n+1)(n+3)}}$$
当 $n=30$ 时,这个标准误大约是 0.43;当 $n=1000$ 时,降到 0.077。也就是说,30 个样本算出来的偏度如果是 0.4,它连一个标准误都不到,完全可能是随机波动,不能下"数据右偏"的结论。经验做法是:$n$ 小于 100 时,偏度只当参考;$n$ 小于 30 时,基本别用偏度下结论,直接画图更靠谱。
3. 峰度:判断厚尾风险的那把尺子,以及它最大的定义误会
3.1 超额峰度与原始峰度的换算,以及那个致命的默认参数
峰度的定义是四阶标准化矩:
$$g_2 = \frac{m_4}{m_2^2} = \frac{\frac{1}{n}\sum(x_i-\bar{x})^4}{\left[\frac{1}{n}\sum(x_i-\bar{x})^2\right]^2}$$
对正态分布,这个值恒等于 3。为了让"正态"对应到 0,方便判断,业界普遍使用超额峰度(excess kurtosis):
$$\text{excess} = g_2 - 3$$
这里就是翻车事故的高发区。scipy.stats.kurtosis()的默认参数是fisher=True,返回的是超额峰度,也就是正态分布会返回 0。而很多人想当然地以为它返回的是原始峰度,看到结果是 0 就以为"数据没有峰",或者看到结果是 3 就以为"峰度很高"。我见过一份分析报告把正态数据的峰度写成 0 并标注"分布完全平坦",这就是纯纯的参数误读。
正确的写法永远显式指定参数:
from scipy import stats import numpy as np x = np.random.default_rng(42).normal(size=5000) print(stats.kurtosis(x, fisher=True, bias=False)) # 超额峰度,正态约 0 print(stats.kurtosis(x, fisher=False, bias=False)) # 原始峰度,正态约 3pandas.Series.kurt()同样返回超额峰度,且默认无偏修正。记住一条:pandas 和 scipy 的默认返回值都是超额峰度,看结果先看它是不是在 0 附近。
几个常见分布的超额峰度参考值,用来建立直觉:
| 分布 | 超额峰度 | 形态特征 |
|---|---|---|
| 正态分布 | 0 | 基准,尾部衰减速度参照 |
| 均匀分布 | -1.2 | 平顶,无尾,极端值几乎不出现 |
| 拉普拉斯分布 | 3 | 尖顶厚尾,比正态更容易出极端值 |
| t 分布(自由度 5) | 6 | 厚尾明显,金融收益常用 |
| 混合分布(95% 标准正态 + 5% 放大 5 倍正态) | 约 17 | 极端厚尾 |
负的超额峰度叫"低峰态",正叫"尖峰态"。但这两个词很容易误导人,下一节就来讲为什么。
3.2 峰度高不等于"更尖",真正该看的是尾部概率
这是峰度理解上最顽固的误区。教科书上经常画三张图,把高蜂度的曲线画得又尖又瘦,低峰度的画得又扁又宽,久而久之大家就形成了"峰度 = 尖不尖"的条件反射。但数学上,峰度是四阶矩,它对离均值远的点的权重是距离的四次方,对均值附近的点几乎没有区分能力。峰度本质上衡量的是尾部的重量,不是顶端的尖锐度。
我可以构造一个反例把这件事说死。取一个混合分布:95% 的质量来自标准正态,5% 的质量来自标准差为 5 的正态。算一下它的超额峰度:
- 方差 $= 0.95 \times 1 + 0.05 \times 25 = 2.2$
- 四阶矩 $= 0.95 \times 3 \times 1^2 + 0.05 \times 3 \times 25^2 = 2.85 + 93.75 = 96.6$
- 标准化四阶矩 $= 96.6 / 2.2^2 \approx 19.96$,超额峰度约 17
而它标准化之后,在均值 0 处的密度大约是 $0.95 \times 0.3989/1.483 + 0.05 \times 0.3989/5 \approx 0.2595$,标准正态在 0 处的密度是 0.3989。也就是说,这个分布的峰顶比正态低了一大截,但峰度是正态的 17 倍。如果峰度真的是"尖锐度",这个结果就自相矛盾了。
反过来,均匀分布的超额峰度是 -1.2,是典型的低峰态,但标准化之后它在整个区间上的高度是 $1/(2\sqrt{3}) \approx 0.2887$,并不比正态矮多少,只是"平顶"而已。
还有一个更有说服力的例子:双峰分布。两个分离的峰,看起来"尖"得很明显,但它的峰度往往接近 0 甚至为负,因为质量集中在两个峰附近,远离整体均值的点反而不多。
所以真正正确的解读方式是:超额峰度为正且数值大,说明出现远离均值的极端值的频率显著高于正态分布。它是一把衡量尾部风险的尺子。这解释了为什么金融领域对峰度如此执着——收益率的分布超额峰度动辄 5 以上,意味着"罕见的暴跌"出现的频率远高于正态假设给出的预测,而所有基于正态假设的风险模型都会在这个地方低估风险。
3.3 用分位数和 QQ 图给峰度结论做交叉验证
单看一个峰度数字容易误判,我习惯用两个工具交叉验证:尾部概率对比和 QQ 图。
尾部概率对比的做法很直接:对数据做标准化(减均值除标准差),然后统计落在 $|z| > 3$ 之外的比例。正态分布下这个比例的理论值是 0.27%,也就是 1000 个点里大约 2.7 个。如果你的数据有 1000 个点,落在 3 倍标准差外的有 30 个,那即使峰度算出来只是 2 或 3,你也应该认定这是一个厚尾分布。这个方法的优点是直观、业务方一听就懂,"我们的数据里极端情况出现的频率是正态预期的 11 倍"这种话比"超额峰度是 8.3"有说服力得多。
import numpy as np def tail_ratio(x, k=3): z = (x - x.mean()) / x.std(ddof=1) empirical = np.mean(np.abs(z) > k) from scipy.stats import norm theoretical = 2 * (1 - norm.cdf(k)) return empirical, theoretical, empirical / theoretical rng = np.random.default_rng(7) x = rng.standard_t(df=4, size=20000) print(tail_ratio(x, 3))用 t 分布自由度 4 跑一下,你会看到尾部比例大约是理论正态值的七八倍,这个倍数比峰度数字更容易让非技术同事理解风险。
QQ 图则是看整体形态。把数据的分位数和正态分位数画在一起,如果尾部明显偏离直线往上翘(右尾)或往下坠(左尾),就是厚尾的直接证据。峰度只给你一个数字,QQ 图给你形状,两者一起看才能确定到底是"整体偏移"还是"只有尾部异常"。我的习惯是:峰度超过 1 就画 QQ 图,图上的尾部偏离如果肉眼可见,就把这个结论写进报告;如果看不出偏离,那就说明峰度是被个别极端点撑起来的,应该先去查数据质量。
4. 代码实现:把偏度峰度算准并做显著性判断
4.1 numpy、scipy、pandas 三条路的差异
三条路都能算出偏度和峰度,但结果在小样本下会有肉眼可见的差别,必须搞清楚用的是哪一套。
import numpy as np import pandas as pd from scipy import stats x = np.array([12, 15, 18, 21, 24, 27, 30, 95]) # 路线一:numpy 手写,完全可控 n = len(x) dev = x - x.mean() m2 = np.mean(dev**2) m3 = np.mean(dev**3) m4 = np.mean(dev**4) g1 = m3 / m2**1.5 g2 = m4 / m2**2 - 3 print(f"手写 偏度={g1:.4f} 超额峰度={g2:.4f}") # 路线二:scipy,显式指定 bias print(f"scipy 偏度(有偏)={stats.skew(x, bias=True):.4f}") print(f"scipy 偏度(无偏)={stats.skew(x, bias=False):.4f}") print(f"scipy 超额峰度(有偏)={stats.kurtosis(x, bias=True):.4f}") print(f"scipy 超额峰度(无偏)={stats.kurtosis(x, bias=False):.4f}") # 路线三:pandas s = pd.Series(x) print(f"pandas 偏度={s.skew():.4f} 超额峰度={s.kurt():.4f}")用上面这组只有 8 个数的数据跑一遍,你会发现手写版本和 pandas 版本差了大概 10% 到 20%。原因是 scipy 的bias=False和 pandas 用的是同一套样本修正公式($G_1$ 和 $G_2$),而手写版本用的是原始矩。样本越小差异越大,$n=1000$ 以上时基本可以忽略。
我的选择原则是:做探索性分析、画图、跟同事沟通,用 pandas 的默认方法(无偏);做算法内部的特征工程、需要和训练时完全一致,就把公式手写死,别依赖库的默认值。后者听起来啰嗦,但吃过亏的都懂——换一个库版本,默认参数变了,特征分布悄悄漂移,模型效果掉点却找不到原因。
4.2 偏度峰度的显著性检验与 JB 正态性检验
算出偏度是 0.6,这个数到底算不算"显著偏斜"?光看数值大小是不够的,得看它相对于标准误的倍数。
import numpy as np from scipy import stats def shape_test(x): x = np.asarray(x, dtype=float) n = len(x) s = stats.skew(x, bias=False) k = stats.kurtosis(x, bias=False) se_s = np.sqrt(6 * n * (n - 1) / ((n - 2) * (n + 1) * (n + 3))) se_k = np.sqrt(24 * n * (n - 1) ** 2 / ((n - 3) * (n - 2) * (n + 3))) return { "n": n, "skew": round(s, 4), "skew_se": round(se_s, 4), "skew_z": round(s / se_s, 3), "kurt": round(k, 4), "kurt_se": round(se_k, 4), "kurt_z": round(k / se_k, 3), } rng = np.random.default_rng(11) print(shape_test(rng.standard_t(df=5, size=800)))判断规则很简单:$|z| > 1.96$ 大致对应 5% 显著性水平,$|z| > 2.58$ 对应 1%。但要注意,这两个公式本身依赖大样本近似,$n$ 小于 50 时结论不太可靠,别太当真。
更系统的做法是直接上 Jarque-Bera 检验,它同时把偏度和峰度纳入统计量:
$$JB = \frac{n}{6}\left(S^2 + \frac{(K-3)^2}{4}\right)$$
其中 $S$ 是偏度、$K$ 是原始峰度,$JB$ 在原假设(数据来自正态分布)下服从自由度为 2 的卡方分布。
from scipy.stats import jarque_bera stat, p = jarque_bera(x) print(f"JB={stat:.2f}, p={p:.6f}")用的时候有一个重要前提:JB 检验的原假设是"数据服从正态分布",所以 $p$ 值小才代表"拒绝正态"。我见过有人把 $p > 0.05$ 解读成"数据不正态",完全反了。另外,JB 检验在大样本下极其敏感,哪怕偏离正态的程度微乎其微,$n$ 上到几万也会给出显著结果。所以大样本时不要只看 $p$ 值,要结合偏度峰度的绝对大小一起判断——统计显著不等于业务上重要。
4.3 一份可以直接抄走的分布体检脚本
把上面这些串起来,形成一份每次拿到新数据都能跑的体检脚本:
import numpy as np import pandas as pd from scipy import stats def profile_distribution(x, name="col"): x = np.asarray(x, dtype=float) x = x[~np.isnan(x)] n = len(x) if n < 8: return {"name": name, "note": "样本过少,跳过形状分析"} s = stats.skew(x, bias=False) k = stats.kurtosis(x, bias=False) se_s = np.sqrt(6 * n * (n - 1) / ((n - 2) * (n + 1) * (n + 3))) se_k = np.sqrt(24 * n * (n - 1) ** 2 / ((n - 3) * (n - 2) * (n + 3))) z = (x - x.mean()) / x.std(ddof=1) tail3 = float(np.mean(np.abs(z) > 3)) jb_stat, jb_p = stats.jarque_bera(x) flags = [] if abs(s) > 2 * se_s: flags.append("偏斜显著") if abs(k) > 2 * se_k: flags.append("峰度显著") if tail3 > 0.01: flags.append("尾部偏厚,3sigma外占比超1%") if jb_p < 0.01: flags.append("拒绝正态") return { "name": name, "n": n, "mean": round(float(x.mean()), 4), "median": round(float(np.median(x)), 4), "std": round(float(x.std(ddof=1)), 4), "skew": round(float(s), 4), "skew_z": round(float(s / se_s), 2), "kurt": round(float(k), 4), "kurt_z": round(float(k / se_k), 2), "tail3_ratio": round(tail3, 5), "jb_p": round(float(jb_p), 6), "flags": " | ".join(flags) if flags else "无明显异常", } rng = np.random.default_rng(2024) df = pd.DataFrame({ "normal_like": rng.normal(100, 15, 3000), "income_like": rng.lognormal(3, 1.2, 3000), "heavy_tail": rng.standard_t(df=4, size=3000), }) for col in df.columns: print(profile_distribution(df[col], col))跑完之后你会得到一张三行的对照表:第一行各项指标接近 0、无明显异常;第二行偏度显著为正、均值远大于中位数;第三行偏度接近对称但峰度和尾部比例都亮红灯。三种情况对应的处理方式完全不同,这正是偏度峰度配合使用才能给出的判断。
提示:这份脚本里我把
bias=False全部显式写出来了,不建议省略。代码是给未来的自己和同事看的,默认值依赖是最难排查的问题来源之一。
5. 落到业务里:偏度峰度的几个实际用法
5.1 收益序列的厚尾与阈值设定
在某金融数据团队做过一段时间收益分析,最深的一个体会是:用正态假设去算风险阈值,几乎必然出事。日收益率的超额峰度经常在 3 到 8 之间波动,这意味着 4 倍标准差的事件在历史上出现的频率,可能是正态理论预测的十几倍。
具体做法上,我的思路是先看峰度决定用哪套方法。超额峰度在 1 以内,说明尾部还不算离谱,正态假设误差可控;超过 2,就明确放弃正态假设,改用历史分位数法或者 t 分布拟合;超过 5,则要额外检查是不是混入了不同时期的数据(比如把极端行情和常态行情混在一起算),因为混合分布本身就会制造高蜂度。
举个具体的数字对比:一批日收益率数据,超额峰度 6.2,标准差 1.8%。按正态假设,99% 分位对应 2.33 倍标准差,也就是 4.19%。但如果用实际分位数去取,99% 分位可能是 5.8%。差了 1.6 个百分点,在杠杆场景下这个差距足以决定是稳健还是爆仓。
还有一点值得说:峰度是会随市场状态变化的。平静期的峰度可能只有 1,动荡期能冲到 10 以上。所以做滚动计算比看整体值更有意义,我在第 6 节会讲滚动窗口的实现细节。
5.2 数据清洗环节用偏度峰度做异常筛查
这个用法可能没那么广为人知,但实测非常好用。做数据质量检查时,箱线图的 IQR 规则只能抓"单点异常",对整体形态的异常不敏感。而偏度和峰度可以当作"形态指纹":一批本来形态稳定的数据,突然某一期的偏度从 0.8 跳到 4.5,基本可以断定录入环节出了问题。
一个具体的场景是某电商平台的每日 GMV 数据。正常情况下日 GMV 的偏度稳定在 1 左右(右偏,符合交易金额分布)。某天偏度突然变成 8,一查发现是后台把一笔大额企业采购也计入了零售口径。这种问题用均值方差根本看不出来——那笔采购只占当天总体的千分之几,均值只涨了 2%,完全在正常波动范围内,但偏度会瞬间爆炸,因为它是三次方的。
落地时建议给每个关键指标建立一个"形态基线":连续采集 30 天的偏度和峰度,算出均值和标准差,之后每天的值偏离基线超过 3 个标准差就告警。这个方法比单纯监控均值的好处是,它能抓住"数量没变但结构变了"的问题,这类问题往往影响更大也更隐蔽。
要注意的是,这个基线必须按业务周期分别建立。周末和工作日的分布形态本来就不一样,混在一起做基线会产生大量误报。我的经验是至少按星期几分组,节假日再单独维护。
5.3 A/B 实验里指标分布异常的早期预警
做实验分析的同学应该都遇到过这种情况:实验组和对照组的均值差了 1.2%,做了 t 检验 p 值 0.03,看起来显著,但业务方质疑"是不是有几个大户带偏了"。这个质疑往往是对的。
t 检验的前提是大样本下样本均值近似正态,这个近似在分布严重偏斜或者厚尾时会打折扣。判断能不能用 t 检验,偏度峰度就是一个快速的守门员。我的检查顺序是:
- 两组各自的偏度绝对值是否都在 1 以内,若超出,先做对数变换或缩尾处理;
- 两组的超额峰度是否都在 2 以内,若超出,说明有厚尾,t 检验的置信区间可能偏窄;
- 两组的形态指标差异是否过大,若一组偏度 0.3、另一组偏度 2.1,说明实验本身改变了分布结构,这时候只看均值差异是不够的,得看分位数差异。
如果形态检查不过关,我会改用 bootstrap 方法估算差异的置信区间。方法不复杂:
import numpy as np def bootstrap_diff(a, b, n_boot=5000, seed=0): rng = np.random.default_rng(seed) diffs = np.empty(n_boot) for i in range(n_boot): sa = rng.choice(a, size=len(a), replace=True) sb = rng.choice(b, size=len(b), replace=True) diffs[i] = sb.mean() - sa.mean() return np.percentile(diffs, [2.5, 97.5]) rng = np.random.default_rng(1) a = rng.lognormal(1, 1, 2000) b = rng.lognormal(1.02, 1.3, 2000) print(bootstrap_diff(a, b))bootstrap 不依赖分布假设,代价是计算量大一些,5000 次重采样在普通机器上是秒级的事,完全可以接受。更重要的是,bootstrap 能看出置信区间是否对称——如果上下界严重不对称,那说明分布形态确实在影响推断,这时候报"均值差 1.2%"就不如报"中位数差 0.8%,且分布右尾变厚"更准确。
6. 几个最容易翻车的细节
6.1 小样本下这两个数字的剧烈波动
这件事必须反复强调,因为我见过太多人在每组只有二十几个样本的实验里,把偏度 0.8 当成"数据严重右偏"的证据。实际上 $n=25$ 时偏度的标准误约 0.47,0.8 还不到两个标准误,完全在随机波动范围内。
做过一个简单的模拟:从标准正态里反复抽 20 个样本,算偏度,10000 次下来偏度的范围大概在 -1.2 到 1.2 之间,峰度的范围更夸张,能从 -1.5 到 5。也就是说,哪怕数据完全来自正态,小样本的峰度也可能看起来像重度厚尾。
所以我的处理原则是:$n < 50$ 时,不看偏度峰度,直接画直方图或者用分位数描述;$50 \le n < 200$ 时,偏度峰度只作为辅助,必须结合图形;$n \ge 200$ 时,才把数值结论写进正式报告。这个门槛不是死规定,但能挡掉大部分误判。
6.2 分组汇总再取平均的陷阱
前面提过偏度峰度不可加,但实际工作里这个坑的变种更多。典型场景是这样:一张全国销售表,需要按大区汇总,很多人算完 8 个大区各自的偏度,然后在报表底部加一行"平均偏度"。这个数字没有任何统计意义。
正确做法只有一条:拿到原始明细数据,对合并后的数据重新计算。如果确实拿不到明细(比如只拿到了各区汇总表),那就别报偏度峰度,改用分位数或者直接说明"形态指标无法从汇总数据推算"。
还有一个更隐蔽的变种:分段算完再拼。比如按时间分成 12 个月,每月算一次偏度,然后想把 12 个月拼成全年的偏度。这也是不行的。全年偏度必须用全年的原始数据算。想要"月度偏度"和"年度偏度"同时存在,那就分别用对应粒度的原始数据各算一遍。
6.3 滚动窗口计算与边界处理
做时间序列监控时,滚动偏度峰度是必备工具,但有几个细节不注意会出错:
import pandas as pd import numpy as np rng = np.random.default_rng(3) s = pd.Series(rng.standard_t(df=5, size=500)) roll_skew = s.rolling(window=60, min_periods=60).skew() roll_kurt = s.rolling(window=60, min_periods=60).kurt() print(roll_skew.isna().sum()) # 前 59 个是 NaN,这是正常的 print(roll_kurt.iloc[60:65])几个要注意的点:
第一,前 window-1 个值是 NaN,这是设计如此,不是 bug。做图的时候要么直接跳过,要么在报告里说明"前 59 个点无值",别让人误以为数据缺失。
第二,窗口长度的选择直接影响敏感性。窗口 30 对短期异常敏感但噪声大,窗口 250(约一年交易日)平稳但滞后明显。我的经验是用两个窗口并行:短窗口(20 到 30)做异常预警,长窗口(120 以上)做趋势判断,两个都亮了才动手排查。
第三,窗口内的样本量必须足够。min_periods一定要显式设置,默认值可能小于 window,导致前几个点用极少的样本算出一个噪声极大的值。这个坑我在生产环境里就踩过,前面十几个点的滚动峰度冲到 20 多,告警响了一整天。
第四,pandas的rolling().skew()内部用的是无偏修正版本,如果你要和另一套系统(比如某个用原始矩实现的计算引擎)对齐数字,必须显式验证。我做过一次跨系统比对,同一份数据两边算出来的峰度差了 0.3,最后定位就是修正版本不同。这种差异在大样本下无害,但在做精确复现时会导致结论不一致。
再补一个小技巧:做滚动计算前,先确认序列里没有缺失值。pandas的滚动窗口遇到 NaN 会按min_periods规则处理,如果缺失较多,算出来的偏度可能是基于不足一半的样本,结果完全不可信。我习惯在滚动之前加一句s = s.dropna()或者先做插值,明确自己的处理逻辑,别让它悄悄发生。