K-S 检验详解:背景、计算与代码实现
一、背景与用途
1. 什么是 K-S 检验?
Kolmogorov--Smirnov(K--S)检验是一种非参数假设检验方法,用于判断:
- 单样本 K--S 检验 :一组观测数据是否来自某个特定的理论分布(如正态分布、均匀分布)。
- 双样本 K--S 检验 :两组独立样本是否来自同一未知连续分布。
✅ 核心优势:不依赖分布形态假设,适用于任意连续分布,对位置和形状变化敏感。
2. 在本文中的应用
在《思维链与幻觉检测》论文中,作者使用双样本 K--S 检验来评估:
CoT 提示是否显著改变了 LLM 内部状态的分布(如 Hidden Score、Attention Score)。
- 零假设 H0H_0H0:有/无 CoT 条件下,检测分数来自同一分布。
- 若拒绝 H0H_0H0(p < 0.01) → 说明 CoT 显著扰动了内部信号,原有检测阈值失效。
二、计算原理与完整示例-单样本
2.1 场景设定
我们用一个单样本检验 案例,检验以下5个数据点是否服从标准均匀分布 U(0,1):
- 样本数据 (X) :
[0.38, 0.12, 0.95, 0.70, 0.55] - 样本量 (N):5
- 原假设 H0H_0H0:数据服从 U(0,1)
2.2 计算步骤详解
步骤 1:排序数据
K--S 检验基于累积分布,必须先排序:
排序后: [0.12, 0.38, 0.55, 0.70, 0.95]
步骤 2:理解经验累积分布函数 (ECDF)
ECDF 是阶梯函数。对于第 i 个数据点,其"跳跃前"概率为 (i-1)/N,"跳跃后"概率为 i/N。
步骤 3:定义理论 CDF
对于标准均匀分布 U(0,1),理论 CDF 为:
Ftheory(x)=x F_{\text{theory}}(x) = x Ftheory(x)=x
步骤 4:逐点计算最大偏差 D
在每个数据点 x_i 处,计算两个距离:
- 跳跃前距离 :
|(i-1)/N - F_theory(x_i)| - 跳跃后距离 :
|i/N - F_theory(x_i)|
取所有距离中的最大值作为统计量 D。
| 序号 i | 数据点 x_i | 理论值 F_theory(x_i) | 观测值 (跳前) (i-1)/N | 观测值 (跳后) i/N | 距离1 (跳前差) | 距离2 (跳后差) |
|---|---|---|---|---|---|---|
| 1 | 0.12 | 0.12 | 0.0 | 0.2 | 0.12 | 0.08 |
| 2 | 0.38 | 0.38 | 0.2 | 0.4 | 0.18 ← 最大 | 0.02 |
| 3 | 0.55 | 0.55 | 0.4 | 0.6 | 0.15 | 0.05 |
| 4 | 0.70 | 0.70 | 0.6 | 0.8 | 0.10 | 0.10 |
| 5 | 0.95 | 0.95 | 0.8 | 1.0 | 0.15 | 0.05 |
结论 :最大距离出现在第2个点(x=0.38),
D = 0.18
步骤 5:查表判决
对于 N=5,α=0.05 的临界值约为 0.563。
- 因为
D = 0.18 < 0.563,不能拒绝 H₀ → 数据符合均匀分布。
2.3 判断过程
1. 样本较少时(如 n < 30)
- 问题 :KS 检验在小样本下功效较低(不容易拒绝错误的原假设)。
- 判断方式 :
- 直接使用 KS 检验的 p 值。
- 若 p 值 > 显著性水平(如 0.05),不能拒绝原假设(注意:不是"证明成立")。
- 但即使 p 值较大,也不能强结论"分布匹配",因为可能只是样本太少看不出差异。
- 建议:结合可视化(如 Q-Q 图或 ECDF vs CDF 图)辅助判断。
2. 样本正常、较多时
- 问题 :样本正常(如 30 ≤ n < 100)KS 检验表现较稳健,p 值较可靠;KS 检验在大样本下过于敏感,微小偏差也可能导致显著结果(p 值很小)。
- 判断方式 :
- 即使 p 值 < 0.05,也要看 KS 统计量 D(最大偏差)的实际大小。
- 如果 D 很小(如 < 0.05),即使统计显著,实际意义可能不大,可认为"近似成立"。
- 结合业务或应用场景判断:是否容许微小偏差?
- 可考虑使用效应量(如 D 值本身)而非仅依赖 p 值。
三、计算原理与完整示例-双样本
3.1 双样本 K-S 检验计算示例
场景背景 :
假设你在做一个药物实验。
- A组(对照组) :3只老鼠,药物起效时间为:
[10, 15, 20]分钟。 - B组(实验组) :3只老鼠,药物起效时间为:
[12, 18, 25]分钟。 - 目的:我们想知道这两组数据的分布是否有显著差异?
零假设 (H₀):两组数据来自同一个分布(药物没效果,时间分布一样)。
手动计算全过程
步骤 1:混合排序
双样本检验不关心数据是 A 的还是 B 的,我们先要把所有观测到的时间点放在一起,从小到大排列,作为我们的"坐标轴"。
混合坐标点:10, 12, 15, 18, 20, 25
步骤 2:计算各自的累积比例
对于每一个时间点,我们要分别问:
- A组里有多少小于等于这个时间的?(除以A的总数3)
- B组里有多少小于等于这个时间的?(除以B的总数3)
让我们逐行扫描(Nₐ = 3, Nᵦ = 3):
markdown
| 时间点 ($x$) | 事件来源 | A组累积计数 ($N_a=3$) | A的 CDF ($F_a$) | B组累积计数 ($N_b=3$) | B的 CDF ($F_b$) | 差距 $|F_a - F_b|$ |
|:-------------:|:--------:|:---------------------:|:---------------:|:---------------------:|:---------------:|:------------------:|
| 10 | A组数据 | 1 | $1/3$ | 0 | $0$ | $1/3 \approx 0.333$ |
| 12 | B组数据 | 1 | $1/3$ | 1 | $1/3$ | $0$ |
| 15 | A组数据 | 2 | $2/3$ | 1 | $1/3$ | $1/3 \approx 0.333$ |
| 18 | B组数据 | 2 | $2/3$ | 2 | $2/3$ | $0$ |
| 20 | A组数据 | 3 | $1$ | 2 | $2/3$ | $1/3 \approx 0.333$ |
| 25 | B组数据 | 3 | $1$ | 3 | $1$ | $0$ |
步骤 3:寻找最大距离 D
查看表格最后一列,最大的差距是 0.33(出现在多个点,最大值即为 0.33)。
结论:
- 双样本 K-S 统计量 D = 0.33。
- 因为样本量太小且 D 值较小,通常无法拒绝零假设。
3.2 当样本量足够大时,如何处理?
当样本量 m,n≥10m, n \geq 10m,n≥10 时,K--S 统计量 DDD 的分布近似服从 渐近分布 (asymptotic distribution),此时不再依赖查表,而是通过p值来判断是否拒绝零假设。
处理步骤:
1. 计算 K--S 统计量 D
与小样本相同:
- 合并所有观测值,排序去重;
- 在每个点计算两个 ECDF 的差值 ∣FX(t)−FY(t)∣|F_X(t) - F_Y(t)|∣FX(t)−FY(t)∣;
- 取最大值作为 DDD。
2. 计算 p 值(关键步骤)
使用以下公式进行近似(由 Smirnov 提出):
λ=(mnm+n+0.12+0.11mn/(m+n))⋅D \lambda = \left( \sqrt{ \frac{mn}{m+n} } + 0.12 + \frac{0.11}{\sqrt{mn/(m+n)}} \right) \cdot D λ=(m+nmn +0.12+mn/(m+n) 0.11)⋅D
然后 p 值为:
p=2∑k=1∞(−1)k−1e−2k2λ2 p = 2 \sum_{k=1}^{\infty} (-1)^{k-1} e^{-2k^2 \lambda^2} p=2k=1∑∞(−1)k−1e−2k2λ2
实际工程中,无需手动计算此级数。推荐直接调用统计库(如
scipy.stats.ks_2samp),它会自动选择精确或渐近方法。
3. 判断显著性
- 若 p < α (通常 α=0.05),则拒绝零假设 → 两组分布显著不同;
- 若 p ≥ α ,则不能拒绝零假设 → 无足够证据表明分布不同。
四、Python 代码实现
这里我们将代码整合,分别展示 Scipy 库的标准用法和为了理解原理的手写实现。
1. 库调用版本 (Scipy)
- 推荐生产环境使用 Scipy 提供了
kstest(单样本) 和ks_2samp(双样本)。
python
import numpy as np
from scipy import stats
def standard_ks_demo():
print("========== 1. Scipy 库调用演示 ==========")
# --- A. 单样本检验 (One-Sample) ---
# 场景:检验一组数据是否服从正态分布
data_1 = np.random.normal(loc=0, scale=1, size=100) # 生成正态分布数据
# kstest(数据, '理论分布名')
d1, p1 = stats.kstest(data_1, 'norm')
print(f"[单样本] 统计量 D: {d1:.4f}, P值: {p1:.4f}")
if p1 > 0.05:
print(" -> 结论:数据服从正态分布")
else:
print(" -> 结论:数据不服从正态分布")
print("-" * 30)
# --- B. 双样本检验 (Two-Sample) ---
# 场景:比较两组独立数据是否同分布
# 为了演示差异,我们生成两个均值不同的分布
group_A = np.random.normal(0, 1, 100)
group_B = np.random.normal(0.5, 1, 100) # 均值偏移了0.5
d2, p2 = stats.ks_2samp(group_A, group_B)
print(f"[双样本] 统计量 D: {d2:.4f}, P值: {p2:.4f}")
if p2 > 0.05:
print(" -> 结论:两组数据分布相同")
else:
print(" -> 结论:两组数据分布不同 (拒绝H0)")
print("\n")
standard_ks_demo()
2. 手写实现版本 (Manual) -
用于理解原理这里我将手写实现两个函数,逻辑完全对应上面的讲解。
python
import numpy as np
# --- 手写 1: 单样本 K-S 检验 ---
def manual_ks_one_sample(data, cdf_function):
"""
data: 观测数据列表
cdf_function: 理论CDF函数,输入x返回概率
"""
n = len(data)
data_sorted = np.sort(data)
max_d = 0
for i, x in enumerate(data_sorted):
# 经验分布(ECDF)在 x 处的两个值:跳跃前,跳跃后
cdf_obs_pre = i / n
cdf_obs_post = (i + 1) / n
# 理论分布值
cdf_theo = cdf_function(x)
# 找最大差值
diff = max(abs(cdf_obs_pre - cdf_theo), abs(cdf_obs_post - cdf_theo))
if diff > max_d:
max_d = diff
return max_d
# --- 手写 2: 双样本 K-S 检验 ---
def manual_ks_two_sample(data1, data2):
"""
data1: 第一组样本
data2: 第二组样本
"""
n1 = len(data1)
n2 = len(data2)
# 1. 混合所有点并排序,作为 x 轴的扫描点
# (去重是为了提高效率,不去重结果也一样)
all_points = np.unique(np.concatenate([data1, data2]))
max_d = 0
# 2. 遍历每一个混合后的时间点
for x in all_points:
# 计算 data1 中 <= x 的比例
# np.sum(data1 <= x) 算出个数,除以 n1 得到比例
cdf_1 = np.sum(data1 <= x) / n1
# 计算 data2 中 <= x 的比例
cdf_2 = np.sum(data2 <= x) / n2
# 3. 计算差距
diff = abs(cdf_1 - cdf_2)
if diff > max_d:
max_d = diff
return max_d
# --- 测试手写代码 ---
def manual_test_demo():
print("========== 2. 手写代码验证 ==========")
# 测试单样本 (用简单的均匀分布数据)
# 假设理论是 U(0,1),即 F(x) = x
sample_data = np.array([0.1, 0.4, 0.35, 0.8])
d_one = manual_ks_one_sample(sample_data, lambda x: x)
print(f"[手写单样本] 统计量 D: {d_one:.4f}")
# 测试双样本 (使用我们在第二部分讲解的例子)
group_A = np.array([10, 15, 20])
group_B = np.array([12, 18, 25])
d_two = manual_ks_two_sample(group_A, group_B)
print(f"[手写双样本] 统计量 D: {d_two:.4f} (预期应为 0.3333)")
manual_test_demo()
总结
- CDF 是"小于等于某值的比例",是 K-S 检验的标尺。
- 双样本检验 通过混合两组数据,逐点比较它们积累速度的差异。
- 代码实现 中,核心逻辑始终是
abs(CDF_1 - CDF_2)的最大值。