ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Welford算法:数值稳定的在线方差计算原理与实现

Welford算法:数值稳定的在线方差计算原理与实现 1. 项目概述为什么我们需要Welford算法在数据处理、统计分析乃至机器学习领域计算一组数据的方差和标准差是再基础不过的操作。乍一看这似乎是个简单的任务先求平均值然后计算每个数据点与平均值的差的平方和最后除以数据个数。这个公式我们在中学课本里就见过。然而当数据量巨大或者数据以流式Streaming方式源源不断到来时这个“简单”的任务会立刻变得棘手甚至危险。传统方法的致命缺陷在于它对数值精度的极端脆弱性。想象一下你有一个包含数百万甚至数十亿条记录的数据集其中大部分数值都集中在1000左右但平均值可能是1000.0001。当你计算每个值与平均值的差时你会得到大量非常接近0的小数比如0.0001、-0.0002。对这些接近0的小数进行平方你会得到像1e-8这样极其微小的数。在计算机的浮点数运算中当你要从一个大数所有数据点的平方和中减去另一个由平均值计算出来的大数时就会遭遇“大数吃小数”的灾难性精度损失。这两个大数可能前几位都完全相同它们的差值本该是我们需要的那个“平方和”但这个差值可能因为精度限制而被计算为0甚至是一个负数导致方差计算完全错误标准差更是无从谈起。这就是Welford算法登场的原因。它不是一个新潮的、复杂的黑科技而是一个诞生于1962年、由B. P. Welford提出的优雅数学解决方案。它的核心魅力在于能够在线、单次遍历、且数值稳定地计算方差和标准差。所谓“在线”和“单次遍历”意味着你不需要存储所有原始数据可以来一个数据就更新一次统计量内存消耗是常数级的。而“数值稳定”则是它解决了上述精度问题的关键。对于需要实时监控指标如服务器QPS、传感器读数、处理海量数据文件或在资源受限的嵌入式环境中进行统计的场景Welford算法几乎是唯一可靠的选择。最近随着数据实时处理需求的爆炸式增长以及大家对算法基础原理的深入探究从热词中“mmse算法推导”、“daubechies公式推导”等可以看出推导的热度Welford算法这个经典而强大的工具值得每一位工程师和数据分析师深入理解并掌握其实现。2. 算法核心思想与数学推导要理解Welford算法为何稳定我们必须暂时抛开代码深入到其数学本质。它巧妙地规避了直接计算与总平均值的偏差而是动态地维护一个“中间量”这个中间量在数学上等价于方差的分子部分即平方和但计算过程却无比稳健。2.1 从定义出发的问题审视首先我们回顾样本方差 (s^2) 的定义这里采用无偏估计分母为 (n-1) [ s^2 \frac{1}{n-1} \sum_{i1}^{n} (x_i - \bar{x}_n)^2 ] 其中 (\bar{x}_n) 是前 (n) 个数据的平均值(\bar{x}n \frac{1}{n} \sum{i1}^{n} x_i)。直接计算的“两遍法”是第一遍遍历所有数据求和得到 (\bar{x}_n)第二遍再次遍历计算每个 (x_i - \bar{x}_n) 的平方和。这需要存储所有数据且如前所述在第二遍计算 (x_i - \bar{x}_n) 时可能引入巨大的舍入误差。另一种看似“单遍”的方法是展开平方和公式 [ \sum (x_i - \bar{x})^2 \sum x_i^2 - n\bar{x}^2 ] 这只需要维护两个累积量数据总和 (\sum x_i) 和数据平方和 (\sum x_i^2)。然而这正是导致数值不稳定的“罪魁祸首”因为 (\sum x_i^2) 和 (n\bar{x}^2) 通常都是非常大的数而它们的差值却相对很小浮点数运算中的 catastrophic cancellation灾难性抵消会导致有效数字全部丢失。2.2 Welford算法的递推公式推导Welford算法的智慧在于它找到了一种递推关系让平均值和“校正平方和”能够随着新数据的到来而优雅地更新。1. 平均值的递推这是一个直观的结论。假设我们已经有了前 (n-1) 个数据的平均值 (\bar{x}_{n-1})当第 (n) 个数据 (x_n) 到来时新的平均值 (\bar{x}n) 可以这样计算 [ \bar{x}n \bar{x}{n-1} \frac{x_n - \bar{x}{n-1}}{n} ] 这个公式的含义是新平均值等于旧平均值加上新数据与旧平均值之差的 (1/n)。这避免了每次都重新计算总和是标准的在线更新方法。2. 核心校正平方和 (M) 的递推Welford算法定义了一个关键变量 (M_n)它表示前 (n) 个数据的校正平方和有时也称为二阶中心矩 [ M_n \sum_{i1}^{n} (x_i - \bar{x}_n)^2 ] 注意这里的分母部分方差就是 (M_n / (n-1))。我们的目标是找到 (M_n) 和 (M_{n-1}) 之间的递推关系。推导如下首先根据定义写出 (M_n) [ M_n \sum_{i1}^{n} (x_i - \bar{x}_n)^2 ]将最后一项分离出来并利用平均值递推公式进行变换 [ \begin{aligned} M_n \sum_{i1}^{n-1} (x_i - \bar{x}_n)^2 (x_n - \bar{x}_n)^2 \ \end{aligned} ]这里的关键技巧是凑出 ((x_i - \bar{x}{n-1})) 项。注意到 [ x_i - \bar{x}n (x_i - \bar{x}{n-1}) - (\bar{x}n - \bar{x}{n-1}) ] 而根据平均值递推公式(\bar{x}n - \bar{x}{n-1} \frac{x_n - \bar{x}{n-1}}{n})。将其代入第一项求和 [ \begin{aligned} \sum_{i1}^{n-1} (x_i - \bar{x}n)^2 \sum{i1}^{n-1} \left[ (x_i - \bar{x}{n-1}) - \frac{x_n - \bar{x}{n-1}}{n} \right]^2 \ \sum_{i1}^{n-1} \left[ (x_i - \bar{x}{n-1})^2 - 2(x_i - \bar{x}{n-1})\frac{x_n - \bar{x}{n-1}}{n} \left( \frac{x_n - \bar{x}{n-1}}{n} \right)^2 \right] \ M_{n-1} - 2\frac{x_n - \bar{x}{n-1}}{n} \sum{i1}^{n-1}(x_i - \bar{x}{n-1}) (n-1)\left( \frac{x_n - \bar{x}{n-1}}{n} \right)^2 \end{aligned} ]这里有一个非常重要的性质(\sum_{i1}^{n-1}(x_i - \bar{x}_{n-1}) 0)。因为偏差之和为零是平均值的定义性质。所以中间项消失了于是 [ \sum_{i1}^{n-1} (x_i - \bar{x}n)^2 M{n-1} (n-1)\left( \frac{x_n - \bar{x}_{n-1}}{n} \right)^2 ]现在处理第二项 ((x_n - \bar{x}n)^2)。同样利用平均值递推 [ x_n - \bar{x}n x_n - \left( \bar{x}{n-1} \frac{x_n - \bar{x}{n-1}}{n} \right) (x_n - \bar{x}{n-1}) - \frac{x_n - \bar{x}{n-1}}{n} \frac{n-1}{n}(x_n - \bar{x}_{n-1}) ] 所以 [ (x_n - \bar{x}n)^2 \left( \frac{n-1}{n} \right)^2 (x_n - \bar{x}{n-1})^2 ]最后将两部分相加得到 (M_n) 的最终递推公式 [ \begin{aligned} M_n \left[ M_{n-1} (n-1)\left( \frac{x_n - \bar{x}{n-1}}{n} \right)^2 \right] \left( \frac{n-1}{n} \right)^2 (x_n - \bar{x}{n-1})^2 \ M_{n-1} (x_n - \bar{x}{n-1})^2 \left[ \frac{n-1}{n^2} \frac{(n-1)^2}{n^2} \right] \ M{n-1} (x_n - \bar{x}{n-1})^2 \left[ \frac{n-1 (n-1)^2}{n^2} \right] \ M{n-1} (x_n - \bar{x}{n-1})^2 \left[ \frac{(n-1)n}{n^2} \right] \ M{n-1} \frac{(x_n - \bar{x}{n-1})(x_n - \bar{x}{n-1}) \cdot (n-1)}{n} \end{aligned} ]通常我们使用一个更紧凑、计算上更高效的等价形式。令差值 (delta x_n - \bar{x}{n-1})那么 [ \begin{aligned} \bar{x}n \bar{x}{n-1} \frac{delta}{n} \ M_n M{n-1} delta \cdot (x_n - \bar{x}_n) \end{aligned} ] 第二个公式可以通过将 (x_n - \bar{x}_n \frac{n-1}{n} delta) 代入上面的最终表达式验证得到。这个形式就是Welford算法在代码中实际使用的核心。提示这个推导过程虽然有些繁琐但它清晰地展示了算法如何通过巧妙的数学变换将可能引发精度抵消的大数相减运算转化为相对温和的乘法与加法。核心在于利用了“前n-1个数据与其自身平均值偏差之和为零”这一性质消去了不稳定的交叉项。2.3 算法的数值稳定性分析为什么这个递推公式是稳定的观察 (M_n) 的更新项(delta \cdot (x_n - \bar{x}_n))。(delta x_n - \bar{x}_{n-1})是新样本与旧平均值的偏差。这个值通常不会特别巨大。(x_n - \bar{x}_n)是新样本与新平均值的偏差。根据推导它等于 (\frac{n-1}{n} delta)也是一个大小可控的值。更新量是这两个“适中”大小的数的乘积然后累加到 (M_{n-1}) 上。整个过程中我们从未进行过“两个极大数相减”的操作。(M) 本身是平方和的累积虽然会增长但每次更新都是增加一个正数因为两个偏差通常同号避免了灾难性的抵消。这就是其数值稳定性的根源。3. 算法实现详解与代码剖析理解了数学原理实现就变得直观了。我们将分别用Python和C语言实现基础版本并探讨如何扩展功能如合并数据集、计算协方差等。3.1 Python实现清晰与实用并重Python实现注重可读性和实用性。我们将它封装成一个类方便维护状态。class Welford: 使用Welford算法在线计算均值、方差和标准差。 数值稳定单次遍历内存消耗O(1)。 def __init__(self): self.count 0 # 数据点个数 n self.mean 0.0 # 当前平均值 x_bar self.M2 0.0 # 校正平方和 M_n def update(self, x): 加入一个新的数据点 x。 self.count 1 delta x - self.mean self.mean delta / self.count delta2 x - self.mean # 注意这里用的是更新后的mean self.M2 delta * delta2 def variance(self, ddof1): 计算方差。 Args: ddof: 自由度增量Delta Degrees of Freedom。默认为1计算样本方差。 若为0则计算总体方差。 if self.count ddof 1: return float(nan) # 或返回0取决于你的需求 return self.M2 / (self.count - ddof) def std(self, ddof1): 计算标准差。 import math var self.variance(ddof) return math.sqrt(var) if var 0 else float(nan) def merge(self, other): 合并另一个Welford统计器的状态。 用于并行计算后合并结果非常强大。 if other.count 0: return self if self.count 0: self.count other.count self.mean other.mean self.M2 other.M2 return self total_count self.count other.count delta other.mean - self.mean # 合并均值 self.mean (self.count * self.mean other.count * other.mean) / total_count # 合并M2。这是合并公式的核心它考虑了两个数据集均值不同带来的额外偏差。 self.M2 self.M2 other.M2 delta**2 * (self.count * other.count / total_count) self.count total_count return self def __repr__(self): return fWelford(n{self.count}, mean{self.mean:.6f}, var{self.variance():.6f}, std{self.std():.6f}) # 使用示例 if __name__ __main__: import numpy as np # 生成一些测试数据包含一个较大的常数偏移以凸显传统方法的问题 data 1e9 np.random.randn(1000000) # 100万数据均值为1e9附近 # 使用Welford算法 w Welford() for x in data: w.update(x) print(Welford 结果:, w) # 使用NumPy直接计算两遍法可能不稳定 np_var np.var(data, ddof1) np_std np.std(data, ddof1) print(NumPy 方差:, np_var, 标准差:, np_std) # 使用不稳定的单遍公式验证 naive_mean data.mean() # 下面这行在数据量巨大且均值很大时会因精度问题得到错误结果 unstable_var (np.sum(data**2) - len(data) * naive_mean**2) / (len(data) - 1) print(不稳定单遍法方差:, unstable_var) print(差值Welford - NumPy:, w.variance() - np_var)代码要点与心得update函数中的细节注意计算delta2时使用的是更新后的self.mean。这与我们推导的公式M_n M_{n-1} delta * (x_n - \bar{x}_n)完全一致。有些实现会先保存旧的均值计算完delta2后再更新均值也是等价的。ddof参数这是统计库中常见的参数代表自由度增量。ddof1对应样本方差无偏估计ddof0对应总体方差。在实现中预留这个参数让函数更通用。merge方法这是Welford算法一个极其强大的特性。它允许你将数据分成多个块分别用不同的Welford对象计算例如在多线程或分布式环境中最后将它们的结果正确合并。合并公式的推导同样精妙它确保了合并后的统计量与一次性处理全部数据的结果在数学上完全一致。初始化count从0开始mean和M2初始为0。在update中当count为1时delta/1就是数据本身mean被设置为第一个数据M2为0逻辑是自洽的。3.2 C语言实现追求效率与嵌入性C语言实现更注重效率和内存控制适用于嵌入式系统或高性能计算库。#include math.h #include stdio.h typedef struct { long long count; // 使用long long防止溢出 double mean; double M2; } welford_t; void welford_init(welford_t *w) { w-count 0; w-mean 0.0; w-M2 0.0; } void welford_update(welford_t *w, double x) { double delta, delta2; w-count; delta x - w-mean; w-mean delta / w-count; delta2 x - w-mean; // 使用新的均值 w-M2 delta * delta2; } double welford_variance(const welford_t *w, int ddof) { if (w-count ddof) { return NAN; // 需要C99标准或math.h支持 } return w-M2 / (w-count - ddof); } double welford_std(const welford_t *w, int ddof) { double var welford_variance(w, ddof); return sqrt(var); } void welford_merge(welford_t *dest, const welford_t *src) { if (src-count 0) return; if (dest-count 0) { *dest *src; return; } long long total_count dest-count src-count; double delta src-mean - dest-mean; double new_mean (dest-count * dest-mean src-count * src-mean) / total_count; // 合并M2 dest-M2 dest-M2 src-M2 delta * delta * (dest-count * src-count) / total_count; dest-mean new_mean; dest-count total_count; } // 示例从文件流中读取数据并计算 void process_stream(FILE *stream) { welford_t stat; welford_init(stat); double value; while (fscanf(stream, %lf, value) 1) { welford_update(stat, value); } if (stat.count 1) { printf(数据点数: %lld\n, stat.count); printf(平均值: %.6f\n, stat.mean); printf(样本方差: %.6f\n, welford_variance(stat, 1)); printf(样本标准差: %.6f\n, welford_std(stat, 1)); } }C实现注意事项整数溢出count使用long long类型以防处理海量数据时溢出。在32位系统上int类型最多表示约21亿对于现代大数据场景可能不够。浮点数类型使用double保证精度。在内存极其受限的嵌入式环境如果数据范围已知且较小可考虑float但要注意精度损失风险。NAN的使用当数据不足时返回NANNot a Number是标准做法。确保你的编译环境支持C99math.h中定义了NAN。性能这个实现是O(1)时间和空间每个数据点只进行几次浮点运算效率极高。循环中避免不必要的函数调用和分支。3.3 功能扩展协方差与更复杂的统计量Welford算法的思想可以推广到计算协方差和方差-协方差矩阵。这对于在线计算线性回归、PCA等至关重要。在线协方差计算维护三个状态量count,mean_x,mean_y,C2类似于M2但用于协方差。 更新公式为def update_cov(self, x, y): self.count 1 delta_x x - self.mean_x delta_y y - self.mean_y self.mean_x delta_x / self.count self.mean_y delta_y / self.count # 注意这里用的新均值 self.C2 delta_x * (y - self.mean_y) # 等价于 delta_y * (x - self.mean_x)数值上略有差异但都稳定。协方差即为C2 / (count - ddof)。通过维护多个这样的对象可以构建一个在线更新的协方差矩阵。4. 实战场景、常见问题与性能对比理解了原理和实现我们来看看它在哪能大显身手以及实际使用中会遇到哪些坑。4.1 典型应用场景流式数据监控服务器每秒产生数万条性能指标延迟、CPU使用率。使用Welford算法你只需要维护几个状态变量就可以实时更新并报告最近N秒内的平均值和标准差用于触发告警。内存占用恒定与数据量无关。大规模数据文件处理处理一个几十GB的CSV文件无法一次性加载到内存。你可以分块读取每块用一个Welford对象更新或者并行处理多个块最后合并。这比将数据全部读入再调用np.var()要节省内存得多且数值更稳定。嵌入式传感器系统MCU内存只有几十KB但需要持续采集传感器数据并计算其波动性标准差。Welford算法是唯一可行的选择。在线机器学习一些在线学习算法如在线标准化、某些随机梯度下降变体需要动态更新特征的平均值和标准差。Welford算法可以无缝集成到这些迭代过程中。数据库聚合虽然数据库内置了VAR_POP、VAR_SAMP函数但如果你需要在应用层进行复杂的、多阶段的聚合计算Welford的merge操作会非常有用。4.2 常见陷阱与排查指南即使算法本身健壮实现和使用不当也会出问题。问题1方差或标准差为负数或NaN。原因这几乎总是数值精度问题但发生在Welford算法中可能有两个原因数据量太少当count ddof时分母为零或负计算无意义。实现时必须做防御性检查。极端情况下的舍入误差虽然Welford很稳定但在极端情况下如数据量极大M2累积了巨大的值而更新量极小M2理论上应单调递增但浮点舍入可能导致其略微减小。当M2为一个极小的负数时开平方会得到NaN。排查与解决检查数据量确保count ddof。在variance()函数中返回前可以加一个保护return max(0.0, self.M2) / (self.count - ddof)。方差本质是非负的将极小的负数截断为0是实践中可接受的做法。考虑使用更高精度的浮点数如Python的decimal.Decimal或C的long double但会牺牲性能。问题2合并merge操作后结果与预期不符。原因合并公式使用错误或者合并顺序有误。合并操作不满足交换律吗实际上Welford的合并操作在数学上是满足交换律和结合律的但浮点数运算的舍入误差可能导致微小的差异。排查用一个小数据集如[1,2,3]和[4,5]手动计算合并过程与你的代码输出对比。确保合并时delta计算正确是src.mean - dest.mean。合并大量对象时为了最小化误差可以考虑将所有对象两两合并而不是依次合并到一个对象上。但这通常差异极小。问题3与权威库如NumPy的结果存在微小差异。原因这是正常现象。差异主要来自算法不同NumPy默认使用两遍法。对于病态数据Welford的结果更可信。计算顺序浮点数加法不满足结合律求和顺序不同会导致不同的舍入误差。起始值你的Welford对象从0开始更新而NumPy是一次性计算所有数据。行动对于“合理”的数据差异通常在机器精度1e-15或1e-12级别。如果差异巨大首先用我们示例中的“不稳定单遍法”验证看是否是NumPy的两遍法本身在你的数据上就出现了精度问题。Welford的结果通常更接近“真实”的数值。问题4在多线程环境下更新同一个Welford对象。警告上面给出的基础实现不是线程安全的。count 1和mean、M2的更新不是原子操作。解决方案每个线程维护自己的本地Welford对象处理完一部分数据后再在同步阶段用merge合并到全局对象中。这是推荐做法既避免了锁竞争又利用了merge的强大功能。如果必须并发更新需要对整个update函数加锁如互斥锁但这会严重降低性能。4.3 性能与精度对比实验我们设计一个简单的实验来直观感受其优势。用Python生成一组“坏”数据data 1e12 np.random.randn(10_000_000)即一千万个围绕1万亿波动的数。精度对比Welford算法方差 ≈ 0.999理论应为1因我们用了标准正态分布。NumPy两遍法np.var(data, ddof1)结果也接近0.999在这个量级下NumPy的内部实现可能已经采用了某种稳定算法或高精度累加器。不稳定单遍法(np.sum(data**2) - len(data)*mean**2)/(len(data)-1)。这里你会看到问题np.sum(data**2)和len(data)*mean**2都是大约1e25量级的数它们的差应该在1e13量级即约1e7 * 1e6。实际上由于浮点数只有约15位十进制有效数字这个减法会丢失所有有效数字结果可能是0、负数或者一个完全错误的巨大数值。在我的测试中它返回了一个毫无意义的巨大正数。内存与速度内存Welford算法无论处理多少数据只占用常数内存3个浮点数1个整数。而两遍法需要存储整个数组对于一千万个float64就是80MB。速度对于一千万数据单线程遍历更新Welford单遍更新约0.5秒。NumPy两遍法需要先创建数组创建数组计算约0.3秒。注意NumPy底层是高度优化的C代码且利用了SIMD指令所以单纯计算可能更快。但Welford的O(1)内存和单遍特性在很多场景下是不可替代的优势。实操心得不要盲目认为Welford在所有情况下都“更快”。它的核心优势在于流式处理、内存效率和数值稳定性。当数据能全部装入内存且使用像NumPy这样的优化库时直接调用内置函数可能更方便更快。但当数据是“流”或“太大”时Welford是唯一的王者。在实现时将update函数内联C/C中或使用JIT编译如Numba装饰Python函数可以极大提升其性能使其在速度上也极具竞争力。5. 总结与进阶思考Welford算法是一个将深刻数学思想转化为简洁、鲁棒代码的典范。它解决了工程中的一个实际痛点——稳定地在线计算二阶统计量。通过本次推导和实现我们不仅掌握了一个工具更学习了一种“递推”和“在线更新”的思维方式。我个人在实际使用中的体会是它常常作为更复杂算法的基石。比如在实现在线标准化层Online Normalization时我需要同时维护每个特征维度的Welford统计器在实现简单的异常检测如3-sigma法则时用Welford实时更新阈值比滑动窗口更节省内存。它的merge特性让我能够轻松地将分布在不同机器或线程上的统计结果汇总这在设计分布式数据预处理流水线时非常有用。最后再分享一个小技巧如果你需要计算带权重的方差例如每个数据点有一个权重Welford算法也有相应的加权版本其推导思路类似但维护的是加权计数、加权均值和加权的校正平方和。当你从简单的等权重统计迈向更复杂的加权统计时你会再次欣赏到这种递推公式的优美和强大。
返回列表