
在之前的一篇文章 https://blog.csdn.net/P_LarT/article/details/156197657 中,我介绍了welford算法在处理单一样本的情况。这篇文章里则是从单一数据扩展到批次数据的情形。为了清晰和完整,这里重新进行了相关公式的推导。
当数据远大于内存时,如何计算全局标准差?
在机器学习特征工程或数据分析中,我们经常需要对大量特征做标准化:
z = x − μ σ z = \frac{x - \mu}{\sigma} z=σx−μ
这要求我们先计算全局均值、方差和标准差: μ , σ 2 , σ \mu, \sigma^2, \sigma μ,σ2,σ。
但现实中,数据可能分散在成百上千个 CSV、Parquet 或 Numpy 文件中,总量达到几百 GB 甚至 TB,而开发机内存可能只有 16GB。
此时如果把所有数据一次性读入内存,很容易触发 OOM。所以我们需要一种算法满足三个条件:
- 只遍历一遍数据;
- 不保存历史样本;
- 数值稳定,不容易被浮点误差击穿。
这就是 Welford 算法以及它的 Batch 变体要解决的问题。
为什么教科书公式不适合工程实现?
统计学教材里方差定义如下,也就是"每个样本减去均值,再平方,最后取平均":
σ 2 = 1 n ∑ i = 1 n ( x i − x ˉ ) 2 = 1 n ∑ i = 1 n ( x i 2 − 2 x i x ˉ + x ˉ 2 ) = 1 n ( ∑ i = 1 n x i 2 − 2 x ˉ ∑ i = 1 n x i + ∑ i = 1 n x ˉ 2 ) = 1 n ( ∑ i = 1 n x i 2 − 2 x ˉ ⋅ n x ˉ + n x ˉ 2 ) = 1 n ( ∑ i = 1 n x i 2 − n x ˉ 2 ) = 1 n ∑ i = 1 n x i 2 − x ˉ 2 \begin{aligned} \sigma^2 &= \frac{1}{n}\sum_{i=1}^{n}(x_i - \bar{x})^2 \\ &= \frac{1}{n}\sum_{i=1}^{n}(x_i^2 - 2x_i\bar{x} + \bar{x}^2) \\ &= \frac{1}{n}\left(\sum_{i=1}^{n}x_i^2 - 2\bar{x}\sum_{i=1}^{n}x_i + \sum_{i=1}^{n}\bar{x}^2\right) \\ &= \frac{1}{n}\left(\sum_{i=1}^{n}x_i^2 - 2\bar{x}\cdot n\bar{x} + n\bar{x}^2\right) \\ &= \frac{1}{n}\left(\sum_{i=1}^{n}x_i^2 - n\bar{x}^2\right) \\ &= \frac{1}{n}\sum_{i=1}^{n}x_i^2 - \bar{x}^2 \end{aligned} σ2=n1i=1∑n(xi−xˉ)2=n1i=1∑n(xi2−2xixˉ+xˉ2)=n1(i=1∑nxi2−2xˉi=1∑nxi+i=1∑nxˉ2)=n1(i=1∑nxi2−2xˉ⋅nxˉ+nxˉ2)=n1(i=1∑nxi2−nxˉ2)=n1i=1∑nxi2−xˉ2
这里其实也对应了概率论中期望与方差的基本关系:
Var ( X ) = E [ ( X − E [ X ] ) 2 ] = E [ X 2 ] − ( E [ X ] ) 2 \operatorname{Var}(X)=\mathbb{E}\left[(X-\mathbb{E}[X])^2\right] =\mathbb{E}[X^2]-\left(\mathbb{E}[X]\right)^2 Var(X)=E[(X−E[X])2]=E[X2]−(E[X])2
这个公式工程上有两个问题。
- 内存与数据流问题:虽然可以流式累加 ∑ x \sum x ∑x 和 ∑ x 2 \sum x^2 ∑x2,但这个形式天然不是围绕"增量维护均值和方差"来设计的,也不方便和 batch、并行、分布式框架自然结合。
- 数值稳定性问题:更严重的是灾难性抵消。假设数据大约在 10 9 10^9 109 附近,而真实方差很小,那么方差公式中,平方的均值和均值的平方都是非常大的数。方差是这两个大数相减得到的。如果二者非常接近,浮点数会丢失大量有效位,导致计算结果精度很差,甚至可能得到负方差。
所以我们希望避免"大数平方和相减",转而维护"样本相对均值的偏差"。
Welford 算法的核心不是直接维护方差,而是在沿着序列不断迭代过程中,维护三个状态,即截止到当前样本 x n x_n xn时:
- 已经处理的样本数量 n n n
- 当前样本的均值 μ n \mu_n μn
- 围绕当前均值的离差平方和 M 2 , n = ∑ i = 1 n ( x i − μ ) 2 M_{2,n} = \sum_{i=1}^{n}(x_i - \mu)^2 M2,n=∑i=1n(xi−μ)2
这里:
- 样本方差: s n 2 = M 2 , n n − 1 s_n^2 = \frac{M_{2,n}}{n-1} sn2=n−1M2,n
- 样本标准差: s n = M 2 , n n − 1 s_n = \sqrt{\frac{M_{2,n}}{n-1}} sn=n−1M2,n
- 总体方差: σ n 2 = M 2 , n n \sigma_n^2 = \frac{M_{2,n}}{n} σn2=nM2,n
这套算法的关键思想是:不保存原始数据,也不保存不稳定的大平方和,只保存可以稳定合成方差的统计摘要。
模式一:单样本流式更新(Welford 算法)
单样本模式适用于数据一个一个到来的情况,比如:
- 传感器实时数据流;
- 网络日志逐条到达;
- 文件中逐个样本读取;
- 任意无法提前组成 batch 的数据流。
假设我们已经处理了前 n − 1 n-1 n−1 个样本,当前状态三元组为: ( n − 1 , μ n − 1 , M 2 , n − 1 ) (n-1, \mu_{n-1}, M_{2,n-1}) (n−1,μn−1,M2,n−1)。
现在来了一个新样本 x n x_n xn,目标是更新为: ( n , μ n , M 2 , n ) (n, \mu_n, M_{2,n}) (n,μn,M2,n).
最终公式:
- μ n = μ n − 1 + δ n ( 令 δ = x n − μ n − 1 ) \mu_n = \mu_{n-1} + \frac{\delta}{n} \quad (令 \delta = x_n - \mu_{n-1}) μn=μn−1+nδ(令δ=xn−μn−1)
- M 2 , n = M 2 , n − 1 + δ δ 2 ( 令 δ 2 = x n − μ n ) M_{2,n} = M_{2,n-1} + \delta\delta_2 \quad (令 \delta_2 = x_n - \mu_n) M2,n=M2,n−1+δδ2(令δ2=xn−μn)
新均值可以从旧均值递推而来:
μ n = 1 n ∑ i = 1 n x i = 1 n ( x n + ( n − 1 ) μ n − 1 ) = x n n + μ n − 1 − μ n − 1 n = μ n − 1 − x n − μ n − 1 n = μ n − 1 + δ n ( 令 δ = x n − μ n − 1 ) \begin{aligned} \mu_n & = \frac{1}{n}\sum^n_{i=1}x_i \\ & = \frac{1}{n}(x_n + (n-1)\mu_{n-1}) \\ & = \frac{x_n}{n} + \mu_{n-1} - \frac{\mu_{n-1}}{n} \\ & = \mu_{n-1} - \frac{x_n - \mu_{n-1}}{n} \\ & = \mu_{n-1} + \frac{\delta}{n} \quad (令 \delta = x_n - \mu_{n-1}) \end{aligned} μn=n1i=1∑nxi=n1(xn+(n−1)μn−1)=nxn+μn−1−nμn−1=μn−1−nxn−μn−1=μn−1+nδ(令δ=xn−μn−1)
直观理解是:新样本比旧均值高多少,均值就朝它移动 1 n \frac{1}{n} n1 的距离 。当 n n n 很小时,新样本影响大;当 n n n 很大时,新样本影响小。
而新的离差平方和同样可以被使用旧版本推导出来:
M 2 , n = ∑ i = 1 n ( x i − μ n ) 2 = ∑ i = 1 n ( x i − μ n − 1 − δ n ) 2 = ∑ i = 1 n [ ( x i − μ n − 1 ) 2 − 2 δ n ( x i − μ n − 1 ) − ( δ n ) 2 ] = M 2 , n − 1 + δ 2 − ∑ i = 1 n [ 2 δ n ( x i − μ n − 1 ) − ( δ n ) 2 ] = M 2 , n − 1 + δ 2 − ∑ i = 1 n [ 2 δ n ( x i − μ n − 1 ) − ( δ n ) 2 ] = M 2 , n − 1 + δ 2 − ∑ i = 1 n [ 2 δ n x i − 2 δ n μ n − 1 − ( δ n ) 2 ] = M 2 , n − 1 + δ 2 − 2 δ μ n + 2 δ μ n − 1 + δ 2 n = M 2 , n − 1 + δ ( δ − 2 μ n + 2 μ n − 1 + δ n ) = M 2 , n − 1 + δ ( x n − μ n − 1 − 2 μ n + 2 μ n − 2 δ n + δ n ) = M 2 , n − 1 + δ [ x n − ( μ n − 1 + δ n ) ] = M 2 , n − 1 + δ ( x n − μ n ) = M 2 , n − 1 + δ δ 2 ( 令 δ 2 = x n − μ n ) \begin{aligned} M_{2,n} & = \sum_{i=1}^{n}(x_i - \mu_{n})^2 \\ & = \sum_{i=1}^{n}(x_i - \mu_{n-1} - \frac{\delta}{n})^2 \\ & = \sum_{i=1}^{n}\big[ (x_i - \mu_{n-1})^2 - 2\frac{\delta}{n}(x_i - \mu_{n-1}) - (\frac{\delta}{n})^2 \big] \\ & = M_{2,n-1} + \delta^2 - \sum_{i=1}^{n}\big[ 2\frac{\delta}{n}(x_i - \mu_{n-1}) - (\frac{\delta}{n})^2 \big] \\ & = M_{2,n-1} + \delta^2 - \sum_{i=1}^{n}\big[ 2\frac{\delta}{n}(x_i - \mu_{n-1}) - (\frac{\delta}{n})^2 \big] \\ & = M_{2,n-1} + \delta^2 - \sum_{i=1}^{n}\big[ 2\frac{\delta}{n}x_i - 2\frac{\delta}{n}\mu_{n-1} - (\frac{\delta}{n})^2 \big] \\ & = M_{2,n-1} + \delta^2 - 2\delta\mu_n + 2\delta\mu_{n-1} + \frac{\delta^2}{n} \\ & = M_{2,n-1} + \delta(\delta - 2\mu_n + 2\mu_{n-1} + \frac{\delta}{n}) \\ & = M_{2,n-1} + \delta(x_n - \mu_{n-1} \cancel{- 2\mu_n + 2\mu_{n}} - 2\frac{\delta}{n} + \frac{\delta}{n}) \\ & = M_{2,n-1} + \delta[x_n - (\mu_{n-1} + \frac{\delta}{n})] \\ & = M_{2,n-1} + \delta(x_n - \mu_n) \\ & = M_{2,n-1} + \delta\delta_2 \quad (令 \delta_2 = x_n - \mu_n) \end{aligned} M2,n=i=1∑n(xi−μn)2=i=1∑n(xi−μn−1−nδ)2=i=1∑n[(xi−μn−1)2−2nδ(xi−μn−1)−(nδ)2]=M2,n−1+δ2−i=1∑n[2nδ(xi−μn−1)−(nδ)2]=M2,n−1+δ2−i=1∑n[2nδ(xi−μn−1)−(nδ)2]=M2,n−1+δ2−i=1∑n[2nδxi−2nδμn−1−(nδ)2]=M2,n−1+δ2−2δμn+2δμn−1+nδ2=M2,n−1+δ(δ−2μn+2μn−1+nδ)=M2,n−1+δ(xn−μn−1−2μn+2μn −2nδ+nδ)=M2,n−1+δ[xn−(μn−1+nδ)]=M2,n−1+δ(xn−μn)=M2,n−1+δδ2(令δ2=xn−μn)
因为加入新样本后,均值从 μ n − 1 \mu_{n-1} μn−1 变成了 μ n \mu_n μn。这意味着所有旧样本的参照均值也发生了移动,因此旧样本的离差平方和也要被修正。Welford 公式的精妙之处在于,它把这两部分合并成了一个简单表达式:
( x n − μ n − 1 ) ( x n − μ n ) (x_n - \mu_{n-1})(x_n - \mu_n) (xn−μn−1)(xn−μn)
也就是说新样本带来的总离差平方和增量,等于它相对旧均值的偏差,乘以它相对新均值的偏差。 同时基于两个均值的参考才能得到总的离差平方和。
模式二:Batch 分块合并
虽然单样本算法已经可以流式处理数据,但在实际工程中,如果对一个大 batch 中的每个元素都执行一次会产生大量循环开销。另外实际数据通常是分块读取的,因此更高效的方式是:
- 向量化计算当前 batch 的统计量;
- 把当前 batch 的统计摘要合并到全局统计摘要中。
假设有两个独立数据集合,集合 A A A对应 ( n A , μ A , M 2 , A ) (n_A, \mu_A, M_{2,A}) (nA,μA,M2,A),集合 B B B对应 ( n B , μ B , M 2 , B ) (n_B, \mu_B, M_{2,B}) (nB,μB,M2,B)。现在要在A的基础上添加B,合并成新的集合 C = A ∪ B C = A \cup B C=A∪B,并且合并后的状态是 n C , μ C , M 2 , C n_C, \mu_C, M_{2,C} nC,μC,M2,C。基于这个设定,可以用来分析基于batch的合并策略。
最终公式:
- μ C = μ A + n B n C ( μ B − μ A ) \mu_C = \mu_A + \frac{n_B}{n_C}(\mu_B - \mu_A) μC=μA+nCnB(μB−μA)
- M 2 , C = = M 2 , A ⏟ 内部波动 + M 2 , B ⏟ 内部波动 + n A n B n C ( μ B − μ A ) 2 ⏟ 组间波动 M_{2,C} = =\underbrace{M_{2,A}}{内部波动} +\underbrace{M{2,B}}{内部波动} +\underbrace{\frac{n_A n_B}{n_C}(\mu_B-\mu_A)^2}{组间波动} M2,C==内部波动 M2,A+内部波动 M2,B+组间波动 nCnAnB(μB−μA)2
计数最简单,即 n C = n A + n B n_C = n_A + n_B nC=nA+nB。
总均值可以从原始基础公式推导得到:
μ C = 1 n C ∑ x ∈ C x = 1 n A + n B ( ∑ x ∈ A x + ∑ x ∈ B x ) = n A μ A + n B μ B n A + n B = n A μ A + n B μ A + n B ( μ B − μ A ) n A + n B = μ A + n B n C ( μ B − μ A ) \begin{aligned} \mu_C & = \frac{1}{n_C}\sum_{x \in C} x \\ & = \frac{1}{n_A+n_B}\left(\sum_{x\in A}x+\sum_{x\in B}x\right) \\ &=\frac{n_A\mu_A+n_B\mu_B}{n_A+n_B} \\ &=\frac{n_A\mu_A+n_B\mu_A + n_B(\mu_B - \mu_A)}{n_A+n_B} \\ &= \mu_A + \frac{n_B}{n_C}(\mu_B - \mu_A) \end{aligned} μC=nC1x∈C∑x=nA+nB1(x∈A∑x+x∈B∑x)=nA+nBnAμA+nBμB=nA+nBnAμA+nBμA+nB(μB−μA)=μA+nCnB(μB−μA)
直观上,合并后的均值从旧均值 μ A \mu_A μA 出发,朝新 batch 的均值 μ B \mu_B μB 移动,移动比例由 B B B 的样本量占总样本量的比例决定。
而对于离差平方和而言,有如下推导:
M 2 , C = ∑ x ∈ A ( x − μ C ) 2 + ∑ x ∈ B ( x − μ C ) 2 = ∑ x ∈ A [ ( x − μ A ) + ( μ A − μ C ) ] 2 + ∑ x ∈ B [ ( x − μ B ) + ( μ B − μ C ) ] 2 = ∑ x ∈ A ( x − μ A ) 2 + 2 ( μ A − μ C ) ∑ x ∈ A ( x − μ A ) + n A ( μ A − μ C ) 2 + ∑ x ∈ B ( x − μ B ) 2 + 2 ( μ B − μ C ) ∑ x ∈ B ( x − μ B ) + n B ( μ B − μ C ) 2 = M 2 , A + M 2 , B + n A ( μ A − μ C ) 2 + n B ( μ B − μ C ) 2 = M 2 , A + M 2 , B + n A ( n B n C ) 2 ( μ B − μ A ) 2 ⏟ 基于均值公式替换 μ C + n B ( n A n C ) 2 ( μ B − μ A ) 2 ⏟ 基于均值公式替换 μ C = M 2 , A ⏟ 内部波动 + M 2 , B ⏟ 内部波动 + n A n B n C ( μ B − μ A ) 2 ⏟ 组间波动 \begin{aligned} M_{2,C} &=\sum_{x\in A}(x-\mu_C)^2+\sum_{x\in B}(x-\mu_C)^2\\ &=\sum_{x\in A}\big[(x-\mu_A)+(\mu_A-\mu_C)\big]^2+\sum_{x\in B}\big[(x-\mu_B)+(\mu_B-\mu_C)\big]^2\\ &=\sum_{x\in A}(x-\mu_A)^2+\cancel{2(\mu_A-\mu_C)\sum_{x\in A}(x-\mu_A)}+n_A(\mu_A-\mu_C)^2\\ &\quad+\sum_{x\in B}(x-\mu_B)^2+\cancel{2(\mu_B-\mu_C)\sum_{x\in B}(x-\mu_B)}+n_B(\mu_B-\mu_C)^2\\ &=M_{2,A}+M_{2,B}+n_A(\mu_A-\mu_C)^2+n_B(\mu_B-\mu_C)^2\\ &=M_{2,A}+M_{2,B} + n_A \underbrace{\left(\frac{n_B}{n_C}\right)^2(\mu_B-\mu_A)^2}{基于均值公式替换 \mu_C} + n_B \underbrace{\left(\frac{n_A}{n_C}\right)^2(\mu_B-\mu_A)^2}{基于均值公式替换 \mu_C}\\ &=\underbrace{M_{2,A}}{内部波动} +\underbrace{M{2,B}}{内部波动} +\underbrace{\frac{n_A n_B}{n_C}(\mu_B-\mu_A)^2}{组间波动} \end{aligned} M2,C=x∈A∑(x−μC)2+x∈B∑(x−μC)2=x∈A∑[(x−μA)+(μA−μC)]2+x∈B∑[(x−μB)+(μB−μC)]2=x∈A∑(x−μA)2+2(μA−μC)x∈A∑(x−μA) +nA(μA−μC)2+x∈B∑(x−μB)2+2(μB−μC)x∈B∑(x−μB) +nB(μB−μC)2=M2,A+M2,B+nA(μA−μC)2+nB(μB−μC)2=M2,A+M2,B+nA基于均值公式替换μC (nCnB)2(μB−μA)2+nB基于均值公式替换μC (nCnA)2(μB−μA)2=内部波动 M2,A+内部波动 M2,B+组间波动 nCnAnB(μB−μA)2
前两项表示两个集合各自内部的离差平方和,最后一项中,两个集合的均值不同,因此把它们放到同一个整体均值 μ C \mu_C μC 下时,需要补上一项由组间均值差异带来的额外离差。
两种模式的统一理解
如果把单个新样本 x x x 看作一个只有一个元素的 batch,即 ( n B = 1 , μ B = x , M 2 , B = 0 ) (n_B = 1, \mu_B = x, M_{2,B}=0) (nB=1,μB=x,M2,B=0),把这些代入合并公式,就会退化到单样本 Welford 更新。因此,两种模式不是割裂的,而是同一个"可合并统计摘要"思想在不同工程粒度上的体现。
工程实现
下面给出一个同时支持单样本更新和 batch 更新的实现。
python
import numpy as np
class WelfordStats:
"""Welford 增量统计器。
支持两种模式:
1. update(x):单样本流式更新;
2. update_batch(chunk):基于 batch 合并更新。
"""
def __init__(self):
self.n = 0
self.mean = 0.0
self.M2 = 0.0
def update(self, x):
"""处理单个样本。"""
self.n += 1
delta = x - self.mean
self.mean += delta / self.n
delta2 = x - self.mean
self.M2 += delta * delta2
def update_batch(self, chunk):
"""处理一个 batch,并将 batch 统计量合并到全局统计量。"""
chunk = np.asarray(chunk)
n_b = chunk.size
if n_b == 0:
return
mean_b = np.mean(chunk)
M2_b = np.sum((chunk - mean_b) ** 2)
if self.n == 0:
self.n = n_b
self.mean = mean_b
self.M2 = M2_b
return
n_a = self.n
mean_a = self.mean
M2_a = self.M2
n_total = n_a + n_b
delta = mean_b - mean_a
self.mean = mean_a + delta * (n_b / n_total)
self.M2 = M2_a + M2_b + delta ** 2 * (n_a * n_b / n_total)
self.n = n_total
@property
def variance(self):
"""样本方差,分母为 n - 1。"""
if self.n < 2:
return 0.0
return self.M2 / (self.n - 1)
@property
def population_variance(self):
"""总体方差,分母为 n。"""
if self.n == 0:
return 0.0
return self.M2 / self.n
@property
def std(self):
"""样本标准差。"""
return np.sqrt(self.variance)
@property
def population_std(self):
"""总体标准差。"""
return np.sqrt(self.population_variance)
def __str__(self):
return f"Count: {self.n}, Mean: {self.mean:.6f}, Std: {self.std:.6f}"
为什么它数值稳定?
教科书公式 1 n ∑ x i 2 − x ˉ 2 \frac{1}{n}\sum x_i^2 - \bar{x}^2 n1∑xi2−xˉ2 需要两个大数相减。而Welford 的做法是始终围绕均值计算当前元素的偏差 x n − μ n x_n - \mu_n xn−μn。通常来说,即使原始数据 x x x 很大,偏差 x n − μ n x_n - \mu_n xn−μn 也可能比较小。因此算法避免了直接累积巨大平方和再相减的过程,显著降低了浮点误差风险。
为什么它适合并行和分布式?
前面的公式说明,统计摘要: ( n , μ n , M 2 , n ) (n, \mu_n, M_{2,n}) (n,μn,M2,n) 是可以合并的。这意味着我们可以:
- 把数据拆成多个 shard;
- 每个 worker 独立计算自己的 ( n , μ , M 2 ) (n, \mu, M_2) (n,μ,M2);
- 最后把所有 worker 的统计摘要合并起来。
这种结构非常适合分布式特征工程。这种并行模式可以概括为:原始数据 → \rightarrow →局部统计摘要 → \rightarrow →全局统计摘要。也就是:
D 1 , D 2 , ... , D k → ( n 1 , μ 1 , M 2 , 1 ) , ... , ( n k , μ k , M 2 , k ) → ( n , μ , M 2 ) D_1, D_2, \dots, D_k \rightarrow (n_1, \mu_1, M_{2,1}), \dots, (n_k, \mu_k, M_{2,k}) \rightarrow (n, \mu, M_2) D1,D2,...,Dk→(n1,μ1,M2,1),...,(nk,μk,M2,k)→(n,μ,M2)
最终总结
Welford 算法的核心主线可以概括为不保存原始样本,不计算不稳定的大平方和,而是只维护 n n n、 μ n \mu_n μn、 M 2 , n M_{2,n} M2,n。
每来一个样本或一个 batch,就修正均值,并把由均值移动导致的额外离差合并进 M 2 M_2 M2:
- 单样本模式中,修正项是: ( x n − μ n − 1 ) ( x n − μ n ) (x_n - \mu_{n-1})(x_n - \mu_n) (xn−μn−1)(xn−μn)
- Batch 模式中,修正项是: n A n B n A + n B ( μ B − μ A ) 2 \frac{n_A n_B}{n_A+n_B}(\mu_B - \mu_A)^2 nA+nBnAnB(μB−μA)2
这两个公式本质上都在处理同一件事:新数据进入后,整体均值发生变化;而方差是围绕均值定义的,所以离差平方和必须同步修正。
因此:
- 两个算法共享同一个统计摘要结构;
- 都具有 O ( 1 ) O(1) O(1) 空间复杂度和 O ( N ) O(N) O(N) 时间复杂度;
- 都比原始定义更适合大规模工程场景。