在数据分析这个行当里,“正态分布”大概是出现频率最高的统计名词。我早先接手过一个交易数据清洗的项目,需求很简单:把异常订单挑出来。当时第一反应就是用均值加减三倍标准差做过滤,结果筛出来的全是正常订单,真正的问题订单反而被漏掉。回过头检查,那批交易金额根本不是正态分布,而是典型的长尾分布。用错了分布假设,后面所有基于正态的统计算法全都跑偏,这个教训我记得特别清楚。
所以这篇内容,我想围绕 Normal Distribution with Python 这个主题,把实际工作中会反复用到的知识点串一遍:从正态分布的本质、用 numpy/scipy 生成和计算正态分布数据,到 matplotlib/seaborn 可视化、Q-Q 图判断,再到正态性检验和置信区间推断。适合刚接触 Python 数据分析的新手,也适合老是在统计检验上翻车的同学。内容尽量说人话,同时把可以直接复制的代码给你。
1. 正态分布不是公式,而是一种体感
1.1 概率密度函数到底在说什么
对于连续型随机变量,正态分布的概率密度函数可以写成:
f(x) = (1 / (σ * sqrt(2π))) * exp(-(x - μ)^2 / (2σ^2))
这个公式看着复杂,实际上核心就两个参数:μ 决定曲线的对称中心在哪里,σ 决定曲线是“胖”还是“瘦”。σ 越小,数据越聚拢在均值附近,曲线越高、越尖;σ 越大,数据越分散,曲线越矮、越宽。
理解概率密度有一个特别关键的直觉:曲线上的高度本身不是概率,概率是曲线和 x 轴围出来的面积。比如“身高在 170 到 175 厘米之间的人数占比”,就是 x=170 和 x=175 之间那一小段面积。用 Python 的 scipy.stats.norm,可以直接用累积分布函数 cdf 来计算这段面积,后面我会给完整代码。
另外,正态分布是一条对称曲线,均值和中位数、众数重合在同一个位置。这也意味着当一组数据出现明显的右偏或左偏时,用“均值加减标准差”来刻画区间分布,往往不是聪明的做法。很多异常检测的翻车,源头就在这里。
1.2 为什么到处都冒出正态分布
一个分布能这么流行,当然不是因为教材偏爱它,而是真实世界里有大量独立随机因素叠加之后,总和会趋近正态。比如一批零件要经过几十道工序,每道工序都会产生一点随机误差,这些误差累加起来的整体误差往往就接近正态分布。再比如测量同一个物体的长度,仪器读数受温度、读数习惯、操作力度等因素干扰,多次测量的误差也常被近似为正态。
这里必须提前埋下一个重要区分:中心极限定理说的是“均值”或“求和”的分布趋近正态,不等于“原始数据本身”是正态。绝大多数业务数据如收入、点击时长、交易金额都是右偏长尾,根本不是正态。为什么很多人踩坑?就是把这句话忘了。我一直觉得,理解正态分布最好的方式,不是背公式,而是分清“哪个对象”在正态:是原始数据,还是统计量。
1.3 Z-score:把任意正态分布变成同一把尺子
既然 μ 和 σ 不同会让图形有无数种样子,那不同分布之间怎么比较?很简单,把原始值减去均值再除以标准差,得到的值就是标准正态分布 N(0, 1) 里的取值。它表达的是“这个值离均值有几个标准差”。
用 Python 写:
import numpy as np x = 178 mu = 170 std = 6 z = (x - mu) / std print(z) # 1.333...z=1.333 表示这个人比平均值高出 1.333 个标准差。后面构造置信区间时会用到 95% 对应的 z 值,也就是 1.96。为什么正好是 1.96?因为在标准正态分布里,[-1.96, 1.96] 这个区间覆盖了 95% 的面积,这是通过正态分布的累积分布函数反推出来的。所有统计软件都在用这个常数,你也可以自己算:
from scipy import stats # 标准正态分布 0.975 分位点 print(stats.norm.ppf(0.975)) # 大约 1.959964因为左右各留 2.5%,所以取 0.975 分位点。理解了这一步,你就能真正理解为什么 1.96 在假设检验里无处不在,而不是死记硬背。
2. 用 Python 造一份正态分布数据:不只是 np.random.normal
2.1 参数别搞反:loc、scale 和 size
要做统计模拟,第一步通常是生成符合正态分布的数据。numpy 里最常用的是np.random.normal,但新版 numpy 更推荐用Generator接口,也就是default_rng。它可以传入种子,保证你的实验可复现。很多数据分析项目无法复现,往往就是随机种子没有固定。
rng = np.random.default_rng(20240501) data = rng.normal(loc=170, scale=6, size=1000)这里的loc就是均值 μ,scale是标准差 σ,size是生成多少个样本。三个参数一点都不复杂,但很多人会把scale当成方差传进去,生成出来的数据分布形状完全不对。要注意,numpy 里用的是标准差,不是方差。如果你手上只有方差,记得先用np.sqrt开根号。
用default_rng而不是直接np.random.normal的另一个好处是,它创建了一个独立的随机数生成器,不会污染全局状态。比如你的代码里其他库也在调用 numpy 随机函数,使用独立 rng 能让你的数据生成过程更干净、可控。
2.2 数据生成后马上做的三项体检
生成数据之后不要急着画图,先看一组描述统计量。用 pandas 的Series.describe()能看到均值、标准差、最小值、四分位数等,再用skew()和kurtosis()看一下偏度和峰度。
import pandas as pd s = pd.Series(data) print(s.describe()) print('偏度:', s.skew()) print('峰度:', s.kurtosis())理论上正态分布的偏度为 0,峰度为 0。pandas 里的峰度默认是超额峰度,也就是说正态分布的基准是 0,不是 3。如果你用别的库,可能会得到 3 左右的数值,那是和原始峰度定义有关。遇到这种情况别慌,先确认文档再说。
这三个指标只看绝对大小。如果偏度绝对值大于 1,或者峰度明显偏离 0,就要怀疑数据是否真的服从正态分布。它们不构成正式检验,但作为快速筛查非常方便,尤其是面对成百上千个指标批量检查的时候,一个循环就能把所有字段的偏度峰度都打出来,肉眼很快发现异常。
2.3 用 scipy.stats.norm 做精确概率计算
numpy 负责生成样本,scipy 负责理论概率,两者配合才是完整流程。scipy.stats.norm 提供了几个高频方法:
cdf(x):计算从负无穷到 x 的累积概率,也就是 P(X ≤ x)sf(x):生存函数,等于 1 - cdf(x),也就是 P(X > x)ppf(q):分位点函数,输入概率,返回对应的 xpdf(x):概率密度函数,用于画图
下面的例子假设身高服从均值为 170、标准差为 6 的正态分布:
from scipy import stats mu, std = 170, 6 # 小于 180 的概率 print(stats.norm.cdf(180, mu, std)) # 大于 180 的概率 print(stats.norm.sf(180, mu, std)) # 累计概率为 0.975 的临界身高 print(stats.norm.ppf(0.975, mu, std)) # 身高落在 170 到 175 之间的概率 print(stats.norm.cdf(175, mu, std) - stats.norm.cdf(170, mu, std))cdf 和 ppf 是互逆的操作,类似乘法与除法。你要知道“某个临界值以上的概率”可以用 sf,避免写 1 - cdf 把自己绕晕。日常计算置信区间,几乎离不开 ppf。记住这些方法,比每次临时上网搜要高效得多。
3. 画图这件事,90% 的人第一步就错了
3.1 直方图 + 密度曲线:先归一化再叠加
很多同学画直方图的时候,纵轴默认是“频数”,然后想和理论密度曲线叠在一张图里,结果曲线被压在 X 轴附近,怎么调都不对。问题不在于曲线,而在于直方图的纵轴没有归一化。正确做法是把直方图的纵轴设为“概率密度”:
import matplotlib.pyplot as plt import numpy as np from scipy import stats rng = np.random.default_rng(42) data = rng.normal(loc=170, scale=6, size=500) plt.hist(data, bins=30, density=True, alpha=0.6, label='Histogram') xs = np.linspace(data.min(), data.max(), 200) plt.plot(xs, stats.norm.pdf(xs, 170, 6), 'r-', label='Theoretical PDF') plt.xlabel('Height (cm)') plt.ylabel('Density') plt.legend() plt.show()关键点在于density=True,它会把直方图归一化,让所有矩形的面积加起来约等于 1。这样理论概率密度曲线才能跟直方图在同一种坐标系下比较。另一个细节是,画理论曲线时横坐标要取一组平滑的点,也就是np.linspace(data.min(), data.max(), 200),不要直接在原始数据点上连线,那会画出乱七八糟的锯齿图。
如果你用 seaborn,一句话也能搞定:
import seaborn as sns sns.histplot(data, kde=True, stat='density', bins=30) sns.kdeplot(data, color='red', linewidth=2)不过我还是建议先理解 matplotlib 的原理,再去用高层封装,否则出问题都不知道在哪一层。
3.2 Q-Q 图:判断“像不像正态”比直方图靠谱
直方图看起来直观,但它的形状高度依赖分箱数量。同样的数据,分 20 箱和分 60 箱,视觉感受可能完全不同,甚至有可能从“单峰”变成“双峰”。所以更稳定的判断工具是 Q-Q 图,它把样本分位数和理论正态分位数放在一起比较。如果数据来自正态分布,点会围绕着一条直线。
from scipy import stats stats.probplot(data, dist="norm", plot=plt) plt.show()在 Q-Q 图里,最常见的形态是“中间贴线、两端翘起”。这说明数据中间部分和正态分布一致,但尾部比正态分布更厚或更薄。金融资产收益率、传感器异常数据经常是这种形态。直方图只能给你“看起来像”,Q-Q 图能告诉你“哪里不像”。所以我的习惯是两者都画,先直方图建立整体印象,再 Q-Q 图确认偏差位置。
3.3 样本量大小会让图形形象完全不同
我专门做过一个对比实验:从同一个正态分布母体里分别抽 30 个和 5000 个样本,再画直方图和 Q-Q 图。30 个样本的直方图有时候看起来明显偏斜,甚至中间凹陷;5000 个样本则恢复成漂亮的钟形。Q-Q 图在小样本上也经常出现两端偏离,但这不代表数据不是正态的,只是随机性在作怪。
所以看可视化的时候,一定要把样本量带进判断。小样本图形不像正态,不一定是坏事;大样本图形仍然强烈偏离正态,才更值得警惕。不要看到一张不好看的直方图就急着推翻正态假设。
4. 那些年我们碰到的“伪正态”:为什么数据老是不听话
4.1 中心极限定理的正确打开方式
中心极限定理(CLT)是很多人最早学到的统计定理,也是最容易被误用的。它说的是:从任意分布中抽取样本,样本均值在样本量足够大时近似服从正态分布,不管原始分布是什么。这个定理支撑了无数参数检验在“非正态数据”上仍然可以使用的合理性。
但问题是,不少同学把这个结论延伸成了“原始数据本身会变成正态”。比如直接对单笔收入做均值加减三倍标准差来检测异常,结果几乎必然失败,因为单笔收入是长尾分布,根本没有中心化的“钟形”。正确做法是:如果你的分析对象是群体均值或总量,CLT 能帮你;如果分析对象是单样本的分布形态,CLT 帮不了你。想清楚你再对谁做推断。
4.2 对数变换:把长尾数据掰回来
交易金额、城市人口、网页停留时长,这类数据有一个共同特征:大多数值很小,少数值巨大,绘制直方图时右边拖出一条长长的尾巴。对这种数据直接用正态假设,均值会被极端值拉跑,方差也会变得巨大。
一个非常实用的手段是对数变换。Python 里可以直接用np.log1p(x),它在 x=0 时也能安全计算,避免log(0)报错。变换之后再看直方图和 Q-Q 图,如果接近正态,就可以在变换后的尺度上做统计推断。
original = rng.lognormal(mean=3, sigma=0.7, size=1000) log_data = np.log1p(original) fig, axes = plt.subplots(1, 2, figsize=(12, 4)) axes[0].hist(original, bins=50, density=True) axes[0].set_title('Original') axes[1].hist(log_data, bins=50, density=True) axes[1].set_title('Log-transformed') plt.show()但要注意,对数变换不是万灵药。它适用于乘性效应和倍数关系的数据,而对负数或有实际业务意义的零值并不友好。而且做了变换之后,业务同事很难直观理解“对数尺度上差异 0.05 意味着什么”。如果目标是给业务报表,优先用中位数和分位数描述;如果目标是建统计模型,对数变换是合理选项。
4.3 正态性检验:别把 p 值当圣旨
说起“验证正态性”,很多人第一反应是跑一个 Shapiro-Wilk 检验或者stats.normaltest。代码确实很简单:
from scipy import stats data = rng.normal(0, 1, 100) print(stats.shapiro(data)) print(stats.normaltest(data))但真正的经验是:p 值只是一个辅助信号,别把它当成最终裁决。原因有两个。第一,样本量小的时候,检验功效低,明明数据很不正态,它也可能给出很大的 p 值,让你误以为安全。第二,样本量大的时候,检验功效又太高,只要数据有微小偏离,p 值就会很小,于是你会得到一个“数据严格不是正态”的结论,但这个偏离在实际业务中可能完全无关紧要。
所以我的建议是三层判断:先画 Q-Q 图看偏差位置,再算偏度和峰度看偏离程度,最后再看检验 p 值。如果三者方向一致,再下结论也不迟。尤其是当你只关心均值差异是否显著时,CLT 往往已经足够支撑你继续使用参数方法,不必在正态性检验上钻牛角尖。
5. 从正态到置信区间:一次完整的推断流程
5.1 场景设定与数据构造
假设某业务团队想比较两种下单页面的转化率。A 组看旧版,B 组看新版。我们需要判断新版是否显著提高了转化率。这里要特别指出,转化率本质上是 0/1 比例的均值,单次订单是否成功显然不是正态分布,但我们真正要推断的是“转化率这个均值”的差异,所以可以借助 CLT 和 t 检验。
我按 A 组转化率 10%、B 组转化率 11.7% 构造两组模拟数据,每组 10000 次重复实验:
rng = np.random.default_rng(88) a = rng.binomial(n=500, p=0.10, size=10000) / 500 b = rng.binomial(n=500, p=0.117, size=10000) / 500 diff = b.mean() - a.mean() print(diff)每次实验得到的是基于 500 个样本计算出的转化率,这 10000 个转化率就构成了均值抽样分布。你会发现这个抽样分布无论原始 0/1 数据是否正态,都会越看越接近钟形。这就是为什么线上大量 A/B 测试敢直接用 t 检验。
5.2 用 t 检验判断差异是否显著
标准的独立样本 t 检验可以直接用 scipy:
from scipy import stats t_stat, p_value = stats.ttest_ind(a, b, equal_var=False) print(t_stat, p_value)equal_var=False对应 Welch's t-test,不假设两组方差相等。很多系统在实践时不会先做方差齐性检验,直接用 Welch 更稳妥。因为 A 组和 B 组的转化率虽然样本量相同,但总体方差可能不同,Welch 会调整自由度,降低误判风险。
如果你还需要给出置信区间,可以手写一个正态近似的区间估计:
se = np.sqrt(a.var(ddof=1) / len(a) + b.var(ddof=1) / len(b)) ci = (diff - 1.96 * se, diff + 1.96 * se) print(ci)如果置信区间不包含 0,说明在 95% 置信水平下差异显著。但实际做业务决策时,不要只看 p 值或区间是否包含 0,还要结合效应量。比如转化率提升了 1.7 个百分点,绝对值不小,但如果这个页面每天只有 100 个访客,置信区间可能非常宽,你要权衡收益和风险。
5.3 正态假设不成立时的 Plan B
如果样本量很小、数据极端、或者业务上无法接受近似正态的假设,就需要备选方案。第一个常用方案是 Mann-Whitney U 检验,它比较的是两组数据的分布位置,属于非参数检验,不要求正态性。注意它检验的是“随机占优”或中位数差异,而不是均值差异。
另一个更稳健的方案是 bootstrap 置信区间,基本逻辑是从原始样本中反复有放回抽样,构造经验分布,再取分位数:
boots = [] for _ in range(5000): sample_diff = (rng.choice(b, len(b), replace=True).mean() - rng.choice(a, len(a), replace=True).mean()) boots.append(sample_diff) lower, upper = np.percentile(boots, [2.5, 97.5]) print(lower, upper)bootstrap 的优势是几乎不依赖分布假设,只要抽样过程能代表总体。缺点是计算量大,不过现在机器上跑几千次通常几秒钟就能完成。我习惯在 t 检验结果之外顺手跑一个 bootstrap,相当于给结论上了双保险。两者方向一致时,心里会踏实很多。
6. 我踩过的坑和给新手的三个建议
6.1 连续数字不等于连续正态
有一段时间我负责监控一个线上指标是否波动异常,把每天的小时级指标直接做 Z-score,然后看有没有超过 3。结果连续几天触发告警,排查之后发现根本不是异常,而是这个指标本身混入了两种完全不同的业务流量高峰,整体分布接近双峰。把不同时段分开之后,每个时段的数据才勉强接近正态,Z-score 也恢复正常。
有些同事看到连续数字就默认可以做正态假设,这个习惯要改。连续只是尺度概念,正态是分布形态概念,两者没有必然关系。做任何统计推断之前,先想清楚这批数据由什么过程产生,有没有多组机制混合。混合分布最擅长制造“看起来连续,但怎么也不正态”的数据。
6.2 描述统计里的“量纲”也会骗人
还有一次处理传感器数据,绝大多数读数聚集在 10 附近,但设备偶尔断线,会记录成 0。把 0 混进数据后,直方图在 0 的位置出现了一个突兀的尖峰,整体偏度和峰度都严重异常。只跑正态性检验,你只会得到一个“拒绝正态”的结论,却完全不知道问题出在哪里。
所以可视化永远是排查的第一步。直方图和 Q-Q 图能告诉你“哪里不像”。如果发现密度曲线在某个特定值上有尖峰,十有八九是质量问题或离散值混入,而不是真实的分布形态。这时候应该做数据清洗,而不是直接抛弃正态假设。
6.3 学统计是为了服务业务,不必追求“纯粹正态”
最后说一点更抽象但很重要的体会:很多业务数据不符合正态分布,这本身就是一个信号。比如收入分布是长尾的,说明少数高价值用户贡献了大部分收入;网页停留时长是偏态的,说明多数用户快速离开,少数用户深度浏览。这些信息远比“它是不是正态”更有业务含义。
如果数据不符合正态,不要下意识地认为分析就做不下去了。你可以选择用对数变换、用非参数方法、用 bootstrap、用广义线性模型,甚至用贝叶斯方法。重点是让统计工具去匹配真实场景,而不是反过来要求真实世界配合正态分布。我在实际项目里会追问自己:我现在需要的是均值推断,还是分位数描述?需要的是 p 值,还是业务风险?想清楚这些,正态分布反而会变成一个好用的起点,而不是精神枷锁。
最后再分享一个常用的检查习惯:拿到数据后,我一般会在十分钟内完成三件事——画直方图、画 Q-Q 图、跑一次偏度峰度汇总。这三个动作做完,对数据形态的判断就有了八成把握。再往后选 t 检验、Mann-Whitney 还是 bootstrap,就会顺畅很多。正态分布是工具,不是目标。把生成、可视化、检验和推断这套流程跑熟,你对正态分布的理解就能超过大部分只是把“正态”挂在嘴边的人。