DATA NOTEBOOK阅读地图
9个主要章节
1段代码示例
5组图片与图表

先确认变量、单位与假设,再核对手算、图表和解释范围。

章节速览9 节 · 展开选择

文件可以分块读取,传感器记录也会持续到达。如果每来一条数据就重新扫描历史记录,统计量的更新时间会随数据量增长;如果只保留总和与平方和,又可能在“大基数、小波动”的数据中丢失有效数字。

本篇接续NumPy常用统计,研究一个具体任务:不保存全部历史值,持续维护数量、均值和方差,并允许合并互不重复的数据分块。 例子全部是合成记录。本文是普通等权、实数标量的累计统计,不处理权重、缺失值填补、删点或滑动窗口。

推导目标三个状态替代历史数组

明确n、均值μ、离均差平方和M₂的含义,再证明新观测到来后怎样更新。

实现目标能更新,也能核对合并

完整Python标准库代码保留分母口径、空状态和错误处理;测试使用独立高精度两遍参考。

先定义统计量,不急着换算法

对n条记录x₁,…,xₙ,均值μₙ=(Σxᵢ)/n。中心量M₂,ₙ=Σ(xᵢ−μₙ)²不是方差,而是离均差平方和。总体描述方差为M₂/n;在独立同分布、有限方差的抽样条件下,常用的无偏样本方差使用M₂/(n−1)。分母修正不能替代抽样条件,时间相关或选择偏差不会被“减一”自动消除。

公式1|从中心平方和取得方差
总体描述:v = M₂ / n,n ≥ 1
样本估计:s² = M₂ / (n − 1),n ≥ 2

M₂、v和s²的单位是观测单位的平方。空数据的均值/方差未定义,单个观测的样本方差也未定义。口径对照文献2。

若记录是延迟毫秒,均值单位为ms,方差单位为ms²,标准差才回到ms。本文代码以None表达样本数不足,不把“尚未观察”伪装成“没有波动”。完整数据集的描述量与总体参数的估计是不同使用目的,报告时要写明采用哪个分母。

为什么总平方和相减可能不可靠

代数恒等式M₂=Σxᵢ²−(Σxᵢ)²/n在精确算术下成立。但当记录围绕很大的基数轻微波动,右边两项都很大、差却很小。浮点数先舍入再相减,可能损失小波动的信息。问题不在减法符号本身,而在使用近似的大量抵消来取得小结果。

例如x=10¹²+[1,2,3,4]。真正均值是10¹²+2.5,M₂=5。直接处理约10²⁴量级的平方项,比维护相对当前均值的增量更容易出现抵消。这个例子不是说所有输入都一定失败,也不保证每种语言、求和次序和浮点环境给出相同的错误值。

Welford的1962年论文是这一在线更新方法的原始出处之一,出版信息见文献1;下面给出独立的代数推导,而不是只把公式当作一个名称来记忆。

从均值变化推导中心矩更新

假设已有n≥1个观测,状态为(n,μ,M₂),新值为x。令δ=x−μ,新的均值μ′=μ+δ/(n+1)。对旧观测,把相对旧均值的偏差重新写成相对新均值的偏差:xᵢ−μ′=(xᵢ−μ)−δ/(n+1)。

展开旧记录平方项之和时,交叉项包含Σ(xᵢ−μ)=0,因此只增加n·δ²/(n+1)²。新记录自身相对新均值的偏差是nδ/(n+1),平方后再相加,新增总量为nδ²/(n+1)。于是得到同一更新的两种写法:

公式2|单条观测的中心矩更新
n′ = n + 1,δ = x − μ
μ′ = μ + δ / n′
M₂′ = M₂ + δ(x − μ′) = M₂ + δ²·n / n′

精确算术下成立。实现使用δ(x−μ′),明确第二项必须用新均值。n=0时直接得到(1,x,0),不需要先定义空集合的真实均值。

四条记录的逐步手算

依次纳入2、4、4、6后,数量为1、2、3、4,均值为2、3、10/3、4,中心平方和为0、2、8/3、8
图1|原创状态卡片;M₂不是已经除过分母的方差。查看原图

取[2,4,4,6],从空状态开始。第三行使用分数更方便核对,代码只打印最终数值。

已纳入的值 n μ M₂ 总体方差M₂/n
2 1 2 0 0
2,4 2 3 2 1
2,4,4 3 10/3 8/3 8/9
2,4,4,6 4 4 8 2

最后一次,δ=6−10/3=8/3,新均值为4,增量δ(6−4)=16/3;8/3+16/3=8。直接围绕最终均值4计算,也得到(2−4)²+0+0+(6−4)²=8。样本方差是8/3,不能把表中总体方差2换个名称就报告成样本方差。

自检1:为什么常量数据的M₂保持零?

第一条数据建立均值后,每个新值与当前均值相同,δ=0,因此中心矩不再增加。样本数达到2以前,代码仍不报告样本方差;“恰好没有波动”和“样本数不足”需要区分。

两个分块怎样合并

设A、B是互不重复的观测分块,分别保存(n_A,μ_A,M₂,A)和(n_B,μ_B,M₂,B)。新均值是数量加权均值;两个块内中心矩之外,还要补上各块均值偏离总均值的贡献。展开平方项,块内偏差和为零,得到:

公式3|两个非空分块的合并
n = n_A + n_B,Δ = μ_B − μ_A
μ = μ_A + Δ·n_B/n
M₂ = M₂,A + M₂,B + Δ²·n_A·n_B/n

任一块为空时直接采用另一块。分块不能重复计入。推广到并行统计的背景见文献3,本式也可由块内中心化展开直接证明。

将A=[2,4]、B=[4,6]分别统计,得到(2,3,2)与(2,5,2)。Δ=2,跨块项为4,合并后(4,4,8)。只把两个M₂相加会得到4,漏掉块均值之间的差异;平均两块方差也不能一般地得到总体方差。

A块数量2均值3中心平方和2,B块数量2均值5中心平方和2,跨块修正4,合并得到数量4均值4中心平方和8
图2|原创合成分块例子;跨块修正不可省略。查看原图
自检2:同一块被合并两次,为什么不是无害的重试?

合并操作没有去重语义。重复合并会重复计数,通常也改变均值与方差。分布式任务需要额外的分块标识和提交记录;不能靠三个统计量判断一批记录是否已经处理过。

代码1:可直接运行的标准库实现

代码1|Python 3 标准库:保存为welford_demo.py运行。只接受有限int/float标量,排除bool;不把字符串、缺失值或无穷大偷偷转换成有效记录。输入先转换为float,因此大整数精度可能在算法开始前就损失。代码中的None表示未定义量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import math


class OnlineStats:
def __init__(self):
self.n = 0
self.mean = 0.0 # 空状态占位值,不代表已观测均值
self.m2 = 0.0

def _commit(self, n, mean, m2):
if not (math.isfinite(mean) and math.isfinite(m2)) or m2 < 0:
raise ArithmeticError("state exceeds supported float range")
self.n, self.mean, self.m2 = n, mean, m2

def add(self, x):
if type(x) not in (int, float):
raise TypeError("expected int or float, excluding bool")
try:
x = float(x)
except OverflowError as exc:
raise ValueError("input is outside float range") from exc
if not math.isfinite(x):
raise ValueError("input must be finite")
if self.n == 0:
self._commit(1, x, 0.0)
return
n = self.n + 1
delta = x - self.mean
mean = self.mean + delta / n
m2 = self.m2 + delta * (x - mean)
self._commit(n, mean, m2)

def merge(self, other):
if not isinstance(other, OnlineStats):
raise TypeError("expected OnlineStats")
# 支持读取自身快照,但这代表重复数据,不是去重。
na, ma, sa = self.n, self.mean, self.m2
nb, mb, sb = other.n, other.mean, other.m2
if nb == 0:
return
if na == 0:
self._commit(nb, mb, sb)
return
n = na + nb
delta = mb - ma
mean = ma + delta * (nb / n)
m2 = sa + sb + delta * delta * (na * (nb / n))
self._commit(n, mean, m2)

def variance(self, sample=False):
if type(sample) is not bool:
raise TypeError("sample must be bool")
denominator = self.n - 1 if sample else self.n
return self.m2 / denominator if denominator > 0 else None


if __name__ == "__main__":
left, right = OnlineStats(), OnlineStats()
for value in [2, 4]:
left.add(value)
for value in [4, 6]:
right.add(value)
left.merge(right)
print(left.n, left.mean, left.m2)
print(left.variance(), left.variance(sample=True))

预期输出第一行为4 4.0 8.0,第二行为2.0 2.6666666666666665。每次更新或合并是常数次浮点运算,累计n条记录为O(n)时间,额外状态O(1);这里不把任意大整数运算的位复杂度算作真正常数。类属性用于查看,不支持手动改写状态后再继续合并。

状态先在局部变量中计算,检查失败时不会提交半个更新。代码检测非有限状态并拒绝,而不是宣称对任意有限输入都不会溢出。生产代码还应定义错误的记录策略与诊断信息,避免上层捕获异常后无声遗漏数据。

并行计算时,各工作单元可以各自维护独立分块,随后由调用方串行合并。这个类没有锁,不保证多个线程同时写入同一实例的原子性;公式可并行组合,不等于这段可变状态代码已经实现了并发保护。

稳定不等于精确,也不等于完整统计分析

浮点加法不满足一般的结合律,分块方式、合并树和记录顺序都可能改变末尾位。因此不能要求并行合并与逐条更新逐位相同。Welford减少一种常见的抵消问题,却没有消除舍入、输入量化和动态范围限制;也不应通过无条件把负方差改成零来隐藏实现错误。

尤其是float(10**16 + 1)可能与float(10**16)相同。两个不同整数已经映射成同一个浮点输入,再好的增量算法也无法恢复丢失的信息。要保留十进制计量值或极大整数的精确关系,应先决定数值表示,再决定更新方法。

自检3:合并结果最后几位不同,能否立即判定代码错误?

不能仅凭末尾位不同判断。先用相同的浮点输入、高精度参考及合理绝对/相对容差核对,再检查分母和分块是否重复。相反,明显漏掉跨块项或数量错误,也不能用“浮点误差”掩盖。

自检4:累计统计能直接表示最近一小时吗?

不能。本文状态累计所有纳入的记录,没有时间窗口或删点操作。最近一小时需要明确过期规则并保存足以撤销旧数据的信息,或使用分桶与重新合并;不能把累计均值改个标签当作窗口均值。

验证时应该比较什么

先核对[2,4,4,6]、空数据、单值和常量;再用高精度两遍计算,独立求均值与离均差平方和,比较随机数据和大基数小波动。合并验证要覆盖空块、不同分块、不同合并次序和重复输入语义,不能只比较两份复用了同一更新式的程序。

需要报告的不只是方差数值,还应包括样本数、单位、分母、输入转换规则和数据范围。缺失与异常值的处理应在统计之前定义;标准差也不是均值置信区间,若要研究抽样不确定性,可衔接配对Bootstrap的采样单位讨论。

参考文献与适用位置

  1. B. P. Welford. Note on a Method for Calculating Corrected Sums of Squares and Products. Technometrics, 4(3), 419–420, 1962. 出版社原始条目与DOI。用于方法来源和出版信息;本篇推导自行展开,不声称取得受限全文。返回公式2。
  2. Python官方文档:statistics。 pvariance与variance说明总体/样本分母与数据数量条件;本文返回None的接口约定与标准库抛异常的约定不同。返回公式1。
  3. Philippe Pébay. Formulas for Robust, One-Pass Parallel Computation of Covariances and Arbitrary-Order Statistical Moments. SAND2008-6212, 2008. Sandia官方报告条目。用于分块与并行统计背景;本篇仅实现标量二阶中心矩,不实现全部高阶公式。返回公式3。