做数据分析的时候,拿到一批数值型样本,我最常被问的问题就是“这批数据大概是什么分布”。直接画直方图吧,组距稍微改一下,曲线形状就面目全非;硬套正态分布吧,碰上双峰、偏态数据又完全说不通。后来接触到核密度估计,发现它能把分布形状拟合得又平滑又自然,紧接着就冒出另一个问题:核密度估计的平滑程度完全由带宽决定,带宽怎么选才算“最优”?我在实际项目里试过用KS检验来给不同的带宽打分,跑下来发现这套思路挺靠谱的,这篇文章就把完整的探索过程、原理和Python实现都整理出来,希望能帮到同样被分布拟合问题卡住的人。
1. 从一个画图问题说起:直方图的局限和核密度估计的基本思想
1.1 直方图为什么让人头疼
直方图是大多数人探索数据分布的第一步,它把数据按区间切分,然后数每个区间里的样本个数。问题在于,这个“区间”怎么切,直接决定了图长什么样。我拿一组实际数据做过实验:把组距设为0.2、0.5、1.0,画出来的三张直方图,一眼看上去就像完全不同的三批数据。组距太小,每个柱子参差不齐,全是锯齿状的噪声;组距太大,又把所有细节都抹平了,双峰结构根本看不出来。
更深层的问题是,直方图本质上是一个分段常数的估计,它假设数据在同一个区间内分布是均匀的,这显然不符合绝大多数真实数据的形态。而且直方图对区间边界的位置也很敏感,稍微平移一下边界,柱子高度就会跳变。做探索性数据分析的时候还能忍受,但如果要基于这个分布去做采样、做预测、算概率密度值,直方图带来的误差就不可忽视了。
这正是核密度估计(Kernel Density Estimation,KDE)想解决的问题。KDE不假设数据服从任何固定形状,而是让数据自己去“说话”,输出一条连续可导的密度曲线,还可以直接计算任意点处的概率密度值。在很多算法里,它甚至可以作为非参数概率模型来使用,比直方图实用得多。
1.2 KDE的直觉:每个样本点“铺”一个鼓包
核密度估计的核心思想可以用一句话概括:每个样本点都会在它的位置上“垒”起一个鼓包,把所有鼓包叠加起来再做归一化,就得到了整体概率密度曲线。
想象你在一张桌子上撒了一把豆子,每个豆子落地后不是呆在原地,而是像一滴墨水滴在宣纸上那样,朝四周晕开一小片。每个样本点贡献一个以它为中心的小山包,山包的形状由核函数决定,山包的宽度由带宽决定。把所有这些小山包的高度加起来,在每个位置上求和,就得到了一条连续的密度轮廓。
数学形式上,KDE的估计式为:
[
\hat{f}h(x) = \frac{1}{nh}\sum^n K\left(\frac{x-x_i}{h}\right)
]
其中 (x_i) 是第 (i) 个样本点,(n) 是样本量,(K) 是核函数,(h) 就是带宽。核函数 (K) 可以理解成一个“权重分配模板”,它决定了距离 (x_i) 多远的点能分到多少权重;带宽 (h) 则决定了这个权重在多大范围内衰减。
当你站的位置 (x) 离某个样本点 (x_i) 很近时,这个样本点对你所在位置的密度贡献就大;离得越远,贡献越小。遍历所有样本点,就得到了当前位置的总密度。整个过程相当于把离散的样本点“涂抹”成了连续的概率密度,非常直观。
1.3 核函数的选择:高斯核为什么是默认项
核函数的选择有很多种:均匀核、三角核、Epanechnikov核、高斯核等。从统计效率上讲,Epanechnikov核在均方误差意义下是最优的,但实际工程里,高斯核才是绝对的默认选择,原因有几个。
第一,高斯核处处可导、光滑性好,得到密度曲线不会出现棱角,视觉上也更符合人们对“分布”的直觉。第二,高斯核没有支撑区间限制,理论上每个样本点对所有位置都有非零影响,但影响会按指数速度衰减,计算上天然带了一个平滑截断。第三,高斯核与正态分布天然对应,在很多理论推导和后续计算中会有数学上的便利。
在Python的sklearn.neighbors.KernelDensity中,内核参数kernel='gaussian'就是默认值。如果你想试Epanechnikov等其它核函数,只需改这个参数,代码几乎不用动。就我自己的经验来说,除非你对核函数的统计性质有特殊需求,否则直接使用高斯核就足够了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 带宽左右着KDE的生死:不同带宽下的可视化对比
2.1 带宽过小造成过拟合,带宽过大造成过度平滑
用KDE做分布拟合,最重要的超参数就是带宽 (h)。它相当于直方图的“组距”,但影响比组距更微妙。带宽决定每个样本点“铺开”的范围:带宽越小,每个样本点的影响力越小,密度曲线就越尖锐,甚至会在每个样本点附近出现独立的尖峰;带宽越大,每个样本点的影响力越广,曲线就越平滑,最终趋近于一条扁平的均匀分布。
我用一组双峰数据(400个点来自均值-2、标准差0.8的正态分布,600个点来自均值2、标准差1.2的正态分布)做过带宽敏感性实验。当带宽取0.05时,密度曲线有几百个毛刺,每个样本点都成了一个独立的“山峰”,完全看不出双峰结构,这是典型的过拟合;带宽取0.3时,双峰结构清晰可见,曲线光滑又不失细节;带宽取3.0时,两个峰被完全抹平,数据看起来就像一个宽平的分布,丢失了关键的聚类信息。
从数学角度来看,带宽本质上是一个偏差-方差权衡(bias-variance tradeoff)的旋钮。带宽太小,方差大,估计值在不同样本集之间剧烈抖动;带宽太大,偏差大,真实分布的关键结构被平滑掉了。最优带宽就是让这两个误差取得平衡的点,而这一点用KS检验来找,恰恰比肉眼观察要客观得多。
2.2 常用的自动带宽估计方法
在实际操作中,很多人并不会手动一个个试带宽,而是依赖一些启发式规则。最著名的两个是Scott规则和Silverman规则。在sklearn的KernelDensity中,带宽参数bandwidth是必填的,但statsmodels和R等工具中提供了'normal_reference'等自动带宽选项,底层用的就是这些规则。
Scott规则给出的带宽是:
[
h = n^{-1/(d+4)} \cdot \hat{\sigma}
]
Silverman规则在Scott规则基础上做了一个系数修正:
[
h = \left(\frac{4}{d+2}\right)^{1/(d+4)} \cdot n^{-1/(d+4)} \cdot \hat{\sigma}
]
其中 (d) 是数据维度,(n) 是样本量,(\hat{\sigma}) 是样本标准差。这两个规则都假设数据接近正态分布,所以在单峰、大致对称的数据上表现还可以,遇到双峰、偏态、多峰数据就明显不够用了。
更稳健的方法是交叉验证。思路很简单:留出一部分数据不参与拟合,只用来评估当前带宽下的KDE表现,评估指标通常是留出数据在KDE下的对数似然值。把训练集和验证集的划分轮转多次,取平均对数似然最高的带宽。这个方法的计算量比启发式规则大,但对数据形态没有任何假设,适应性更强。在实际项目中,我经常用交叉验证先圈定一个带宽候选范围,再结合KS检验做最终确认。
2.3 直观感受一下带宽变化曲线
如果只用文字描述,带宽的影响还是太抽象。建议你自己跑一遍下面的代码,把不同带宽下的密度曲线都画在同一个坐标系里,一眼就能看出差别:
python复制import numpy as np
import matplotlib.pyplot as plt
from sklearn.neighbors import KernelDensity
np.random.seed(42)
data = np.concatenate([
np.random.normal(-2, 0.8, 400),
np.random.normal(2, 1.2, 600)
])
x_grid = np.linspace(-6, 6, 500).reshape(-1, 1)
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
for ax, bw in zip(axes, [0.05, 0.3, 3.0]):
kde = KernelDensity(kernel='gaussian', bandwidth=bw).fit(data.reshape(-1, 1))
log_dens = kde.score_samples(x_grid)
ax.fill_between(x_grid.flatten(), np.exp(log_dens), alpha=0.4)
ax.set_title(f"bandwidth={bw}")
plt.tight_layout()
plt.show()
三张图并排一放,你会非常直观地理解什么叫过拟合、什么叫欠拟合。颜色填充部分面积都归一化为1,因为我们画的是概率密度函数。
3. KS检验原理:如何判断拟合分布“像不像”原数据
3.1 经验分布函数:把样本转化为阶梯形状的分布
要判断一个拟合出的分布函数和原始数据是否一致,必须先在同一个参照系里比较。KS检验所依据的参照系,是经验分布函数(Empirical Cumulative Distribution Function,ECDF)。
经验分布函数的定义非常朴素:对任意实数 (x),定义
[
F_n(x) = \frac{1}{n}\sum_{i=1}^n \mathbb{I}(X_i \le x)
]
就是一个样本中小于等于 (x) 的比例。比如你有一组身高数据,(F_n(170)) 就表示身高不超过170厘米的人数占比。把所有 (x) 都算一遍,就得到一条从0单调上升到1的阶梯函数,每个样本点处往上跳一档。当样本量足够大时,经验分布函数会无限逼近真实的总体分布函数,这是格利文科-坎泰利定理保证的。
在Python里计算经验分布函数非常方便。np.searchsorted或简单地对数据排序后求cumulative count都可以:
python复制def ecdf(x, data):
# 输入x和原始数据,返回经验分布函数在x处的值
x = np.asarray(x)
sorted_data = np.sort(data)
return np.searchsorted(sorted_data, x, side='right') / len(sorted_data)
3.2 D统计量与p值:最大偏差怎么算和怎么解读
KS检验的全称是Kolmogorov-Smirnov检验,它的核心指标是 (D) 统计量,定义为经验分布函数与理论分布函数之间的最大绝对偏差:
[
D = \sup_x \left| F_n(x) - F(x) \right|
]
这里的 (F(x)) 就是你要检验的分布函数。如果是一个核密度估计,那 (F(x)) 就是KDE的累积分布函数;如果数据量很大,(D) 统计量还要乘以一个与样本量相关的系数来调整。
(D) 值越大,说明拟合分布与样本数据的偏差越大;(D) 值越小,说明拟合越精确。但仅凭 (D) 值还不够,因为同样的 (D) 值在不同样本量下显著性完全不同。因此KS检验会计算对应的p值,原假设是“样本来自该分布”。当p值小于显著性水平(比如0.05)时,我们拒绝原假设,认为数据不是来自这个分布;当p值大于0.05时,不能拒绝原假设,可以认为拟合的分布在统计意义上是可接受的。
用scipy做单样本KS检验只需要一行代码:
python复制from scipy.stats import kstest
stat, p = kstest(data, 'norm')
这里的'norm'表示标准正态分布。不过要注意,如果要用KDE的分布做检验,就不能用这种字符串简写了,需要传入一个自定义的累积分布函数。
3.3 KS检验的边界条件:不是所有情况都能直接套
KS检验用起来很快,但它有几个前提条件和坑点,我想特别提醒一下。
首先,KS检验要求理论分布是完全指定的,也就是说分布函数中不能有从数据中估计出来的参数。如果你先用数据估了一套均值和方差,然后构造一个正态分布去做KS检验,标准KS检验的p值会偏大,也就是更容易“接受”这个分布。这种情况需要做Lilliefors修正,或者用蒙特卡洛重采样来计算准确的p值。
其次,KS检验对分布的中心位置敏感,但对尾部差异不够敏感。两个分布在均值附近几乎重合,但一个尾巴厚、一个尾巴薄,KS检验可能给出相似的D值。因此KS检验适合作为“总体一致性”的衡量标准,不适合用来专门检验尾部风险。
第三,如果数据中有大量重复值,或者数据是离散的,标准KS检验的渐近分布就不适用了。对于核密度估计的场景,我们通常处理连续型数据,这一点问题不大。
最后要说的是,KS检验的p值对样本量很敏感。样本量很大的时候,即使拟合分布与真实分布只有微不足道的偏差,p值也会变得很小,导致我们误判为“不拟合”。所以在实际项目里,我不看p值判断“拟合还是不拟合”,而是把p值或者D值当作一个相对分数,用来比较不同带宽设置的拟合优劣,这样结果稳健得多。
4. 在Python中用KS检验寻找最优带宽的完整流程
4.1 数据准备与候选带宽
我先准备了一份实验数据,就是本文一直在用的双峰混合正态数据。下面的代码生成了1000个样本点,并把前500个作为训练集,后500个作为验证集:
python复制import numpy as np
from sklearn.neighbors import KernelDensity
from scipy.stats import ks_2samp, kstest
from scipy.integrate import quad
import matplotlib.pyplot as plt
np.random.seed(42)
data = np.concatenate([
np.random.normal(-2, 0.8, 400),
np.random.normal(2, 1.2, 600)
])
np.random.shuffle(data)
train, val = data[:500], data[500:]
候选带宽的范围我在0.05到1.0之间等距取了20个值。从第2节的实验可以看到,0.05处过拟合明显,1.0处平滑程度已经很大,这个区间覆盖了从过拟合到欠拟合的完整过渡:
python复制bandwidths = np.linspace(0.05, 1.0, 20)
有人可能觉得20个值太少,怕错过最优。实际上核密度估计对带宽的响应往往是平滑变化的,如果在20个网格上已经能看到明显的U形趋势,最优值就在谷底附近,不需要用更密的网格。如果网格上出现了剧烈抖动,那说明数据本身有问题,或者样本量太少了。
4.2 思路一:训练/验证集划分与双样本KS检验
这个思路借鉴了机器学习里的交叉验证思想:在训练集上拟合KDE,然后从拟合好的KDE中采样一批新数据,再用双样本KS检验比较这批采样数据和验证集的分布是否一致。p值最大的带宽,就是“外部验证”角度下拟合得最好的带宽。
这里有一个值得琢磨的细节:为什么要从KDE中重新采样,而不是直接把训练数据的原样本和KDE的分布函数做KS检验?原因在于标准KS检验要求检验一个样本是否来自某个完全指定的连续分布,而KDE的分布函数本身完全由训练集决定,如果直接在训练集上做,带宽越小、KDE越贴合训练集,KS统计量就越小,哪怕它的泛化能力很差。这是典型的“用训练数据自评”,结果必然偏乐观。通过train/val划分,让KDE在训练集上拟合、在验证集上评估,才能避免过拟合带来的假自信。
核心代码如下:
python复制results = []
for bw in bandwidths:
kde = KernelDensity(kernel='gaussian', bandwidth=bw)
kde.fit(train.reshape(-1, 1))
# 从拟合好的KDE中采样,采样量与验证集一致
samples = kde.sample(n_samples=len(val), random_state=42).flatten()
stat, p_value = ks_2samp(val, samples)
results.append((bw, stat, p_value))
results = np.array(results)
best_idx = np.argmax(results[:, 2])
best_bw = results[best_idx, 0]
print(f"最优带宽: {best_bw:.3f}, D统计量: {results[best_idx, 1]:.4f}, p值: {results[best_idx, 2]:.4f}")
实际跑下来,p值最高的带宽大多落在0.2~0.35之间,和人工观察密度曲线得出的“看着最顺眼”的带宽很接近。这说明KS检验确实能把视觉感受数值化,给出一个客观的选优标准。
需要提醒的是,kde.sample带有随机性,如果随机种子不固定,每次采样的数据不一样,KS检验结果会有些波动。所以在比较不同带宽时,务必固定随机种子,保证比较的公平性。如果样本量较小,这种波动会被放大,可以考虑多次采样取平均p值。
4.3 思路二:直接比较KDE的CDF与经验CDF的最大偏差
另一个思路是构造KDE对应的累积分布函数,再计算它和经验分布函数的最大绝对偏差。前面提到过,直接在训练集上这么做会偏向小带宽,但它非常直观,数学上对应KS统计量的定义,所以值得实现一遍,用来理解原理。
这里的关键点是如何计算KDE的CDF。sklearn的KernelDensity只能输出对数密度,不能直接输出CDF,我们需要对密度做数值积分。高斯核KDE的CDF其实有解析形式:因为每个核都是高斯分布,所以整体CDF就是每个高斯核的CDF的平均值。但直接用scipy.integrate.quad对密度函数做数值积分也很快,不足1000个样本的情况下,积分开销可忽略:
python复制def kde_cdf_from_pdf(x, kde, x_range=(-10, 10)):
# 对KDE的pdf做数值积分,得到x点的CDF
val, _ = quad(lambda t: np.exp(kde.score_samples([[t]])[0]), x_range[0], x)
return val
然后遍历每个候选带宽,在统一的x网格上计算经验CDF和KDE的CDF之差:
python复制x_grid = np.linspace(-6, 6, 300)
ecdf_vals = ecdf(x_grid, train)
max_devs = []
for bw in bandwidths:
kde = KernelDensity(kernel='gaussian', bandwidth=bw)
kde.fit(train.reshape(-1, 1))
kde_cdf_vals = [kde_cdf_from_pdf(x, kde) for x in x_grid]
max_devs.append(np.max(np.abs(ecdf_vals - np.array(kde_cdf_vals))))
跑完后你会发现,带宽越大,KDE的CDF越平滑,与经验CDF的最大偏差越大;带宽趋近于0,KDE的CDF几乎紧贴经验CDF,最大偏差趋近于0。这说明直接自评确实给出“越小越好”的错误结论。这个思路更有教学意义,它逼着你理解为什么需要验证集、为什么不能只看训练集上的拟合精度。
4.4 结果可视化与分析
把这套流程跑完,我通常会画一张“带宽-p值”的趋势图,观察p值随带宽的变化。绝大多数情况下能看到一个明显的山峰:带宽太小,p值低,因为KDE把噪声也拟合进去了,验证集上的分布对不上;带宽太大,p值也低,因为真实的双峰结构被抹平;只有中间某个带宽区间,p值达到峰值。
python复制plt.figure(figsize=(8, 4))
plt.plot(results[:, 0], results[:, 2], marker='o')
plt.xlabel("bandwidth")
plt.ylabel("KS test p-value")
plt.axvline(best_bw, color='red', linestyle='--', label=f"best: {best_bw:.2f}")
plt.legend()
plt.show()
如果这个山峰出现在带宽区间的两端,说明你设置的搜索范围不合理,需要扩大搜索区间。如果p值整体都非常小(比如全部小于0.001),说明单峰或简单核密度估计本身就不适合描述这批数据,可能需要考虑混合模型、变换后的KDE,或者数据本身有严重的离群点。
用这套交叉验证加KS检验的组合,我在实际项目里帮其他同事解决过不少“分布拟合得莫名其妙”的问题。它在双峰、三峰、偏态数据上都比直接用正态QQ图靠谱得多,因为KS检验不预设数据的分布形状,自由度比正态性检验高很多。
5. 我的实操经验和容易踩的坑
5.1 为什么直接拿原始数据和KDE做KS检验会误导你
这是新手最容易掉进去的坑。我见过不少人写这样的代码:先fit一个KDE,然后直接用kstest(data, kde_cdf),得到p值很大,就认为这个KDE拟合得很好。实际上,如果这个KDE是在同一份数据上拟合出来的,那么当带宽很小时,KDE的CDF几乎就是经验CDF的光滑版本,KS统计量必然小、p值必然大。这种自评结果不能说明泛化能力。
更严重的是,标准KS检验的理论分布建立在“被检验的分布是预先固定、不依赖数据”这个前提上。当分布函数中的参数(包括带宽)是从同一批样本中估计出来的,p值就会失真。所以在探索最优带宽这个问题上,我的建议是:要么使用训练/验证集划分,要么对KDE采样后与另一批独立数据做双样本KS检验。这两条路都绕开了“自评”的陷阱。
5.2 KS检验对样本量和重复值的敏感性
KS检验的p值对样本量极其敏感。样本量到几千上万时,任何一点微小的分布偏差都会被放大成显著的p值,即使这个偏差肉眼根本看不出来。这在选择“最优带宽”这个问题上反而可以利用:如果验证集和采样数据的样本量保持一致,那么在不同带宽之间比较p值是公平的,p值的绝对值大不大反而不那么重要。
另一个需要注意的点是重复值。如果原始数据经过取整、去重或者有大量重复记录,经验分布函数会出现平台和跳跃,KS检验的渐近分布会失效。解决办法是给数据加一点微小的噪声(jitter),或者改用针对离散数据设计的检验统计量。如果数据是从连续测量得来的,这个问题一般不严重。
5.3 带宽搜索范围怎么确定
带宽搜索范围设定得不好,就算跑完整个流程,找到的也不见得是真正的最优值。我一般用一个经验法则来定范围:把数据标准差记为 (\sigma),样本量为 (n),那么Silverman规则下的带宽大约在 (1.06 \sigma n^{-1/5}) 左右。我以此为参考中点,向前后各扩展一个数量级,作为搜索区间。
比如一组数据标准差大约是2.0,样本量1000,那么Silverman参考带宽大约是 (1.06 \times 2.0 / 1000^{0.2} \approx 0.53),搜索范围可以从0.05到2.0。如果数据明显是多峰的,可以适当缩小范围,让搜索更细;如果数据噪声很大,适当放大范围,看看更平滑的曲线能不能提升验证集上的表现。
需要提醒的是,带宽搜索结果和评估指标是绑定的。用KS检验的p值选出来的“最优”,是让验证分布和采样数据最一致的带宽;用留一法对数似然选出来的“最优”,是预测能力最强的带宽。两者经常接近但不完全一致。如果你的下游任务是概率密度估计和采样,我会偏向用留一对数似然;如果只是想找一个“分布看起来最像原始数据”的拟合,KS检验更符合直觉。
5.4 一组“仅供参考”的完整代码
最后给出一段可以直接修改运行的完整代码,把前面的流程整合到一起。你只需要替换data这一行,换成自己的数据,就可以开始探索。
python复制import numpy as np
from sklearn.neighbors import KernelDensity
from scipy.stats import ks_2samp
import matplotlib.pyplot as plt
# 准备数据,这里用双峰混合正态作为示例
np.random.seed(42)
data = np.concatenate([
np.random.normal(-2, 0.8, 400),
np.random.normal(2, 1.2, 600)
])
# 划分训练集与验证集
np.random.shuffle(data)
train, val = data[:500], data[500:]
# 候选带宽
bandwidths = np.linspace(0.05, 1.0, 20)
def find_best_bandwidth(train, val, bandwidths, sample_seed=42):
best_bw = None
best_p = -1
best_stat = None
history = []
for bw in bandwidths:
kde = KernelDensity(kernel='gaussian', bandwidth=bw)
kde.fit(train.reshape(-1, 1))
samples = kde.sample(n_samples=len(val), random_state=sample_seed).flatten()
stat, p_value = ks_2samp(val, samples)
history.append((bw, stat, p_value))
if p_value > best_p:
best_p = p_value
best_stat = stat
best_bw = bw
return best_bw, best_stat, best_p, np.array(history)
best_bw, best_stat, best_p, history = find_best_bandwidth(train, val, bandwidths)
print(f"最优带宽: {best_bw:.3f}, D={best_stat:.4f}, p={best_p:.4f}")
# 画p值随带宽变化曲线
plt.figure(figsize=(8, 4))
plt.plot(history[:, 0], history[:, 2], marker='o')
plt.axvline(best_bw, color='red', linestyle='--')
plt.xlabel("bandwidth")
plt.ylabel("KS p-value")
plt.title("Bandwidth selection via KS test")
plt.show()
脚本跑完后,你会得到一个明确的“最优带宽”。接下来可以用这个带宽重新在全部数据上fit一个KDE,用于最终的可视化或后续计算。
在我的项目经验里,拿KS检验选带宽最大的价值不是替代交叉验证,而是给“哪个带宽最合适”提供一个参考系。它把通常模糊的“看形状差不多”变成了可比较的数值,而且代码写起来也不复杂。如果你现在正被KDE的带宽问题困扰,建议直接把上面这段代码粘进Jupyter里跑一遍,换上你自己的数据,看看p值的山峰落在哪里,大概率会有“原来如此”的感觉。
