try ai
科普
编辑
分享
反馈
  • 高精度求和:精确计算的艺术

高精度求和:精确计算的艺术

SciencePedia玻尔百科
核心要点
  • 在对数量级差异巨大的数字求和时,标准浮点运算可能导致如淹没和灾难性抵消等重大误差。
  • Kahan 求和算法通过使用一个补偿变量来追踪并纠正求和过程中每一步的舍入误差,从而显著提高准确性。
  • 激进的编译器优化可能会错误地破坏 Kahan 算法,这要求程序员采取特定措施来保留预期的操作序列。
  • 高精度求和对于确保统计分析、物理模拟、金融和人工智能模型训练等不同领域结果的完整性至关重要。

引言

我们对计算机作为完美计算的机器抱有极大的信任,然而这种信念忽略了数字算术核心中的一个根本性妥协。计算机表示数字的方式——使用一个有限的浮点值系统——在纯粹数学的无缝世界与计算的现实之间制造了一道鸿沟。这种差异意味着,像对一列数求和这样看似简单的操作,也可能充满舍入误差,并累积成灾难性的不准确。因此,我们面临的挑战不仅是计算,而是在面对这些固有局限性时如何可靠地计算。

本文将阐明实现可靠计算精度的路径。首先,在“原理与机制”一章中,我们将剖析简单的计算机加法为何会失败,探讨如淹没和灾难性抵消等现象。然后,我们将揭示 Kahan 补偿求和算法背后的精妙逻辑,这是一种巧妙恢复丢失精度的方法。随后,“应用与跨学科联系”一章将带领读者穿越一系列学科——从统计学和物理学到金融和人工智能——展示这项技术的深刻而必要的影响,证明精确求和如何构成了现代科学技术的基石。

原理与机制

如果你问别人计算机是做什么的,他们可能会说“计算”。毕竟,这个词就在它的名字里。我们相信计算机是数学精度的典范,能以坚定不移的准确性执行计算。在大多数情况下,我们这样做是对的。然而,在微处理器沉默而嗡鸣的世界里,一出微妙的戏剧正在上演——一个关于近似、信息丢失以及为重获信息而设计的巧妙方案的故事。对一列数求和这个看似微不足道的行为,一个我们在小学就学会的操作,在计算精度的极限下,变成了一个引人入胜的挑战。

无穷小的幻觉

当我们意识到计算机如何存储数字时,我们迎来了第一个意外。我们认为数轴是一条平滑、连续的带子。你能想象的任何数字,无论它有多少位小数,都有其独特的落点。然而,计算机版本的数轴更像一串串珠项链。珠子的数量是有限的,尽管非常庞大,如果一个数字没有正好落在一颗珠子上,它就必须被移到最近的一颗上。这些珠子就是我们所说的​​浮点数​​。

这个被标准化为 IEEE 754 的系统是一项工程奇迹,类似于科学记数法。它可以表示从微观到天文级别的惊人范围内的数值。但它有一个根本的局限性:其精度是有限的。就像你无法写下 π\piπ 的所有数字一样,计算机也无法全部存储它们。更令人惊讶的是,即使是像 0.10.10.1 这样简单的数字,也无法在计算机原生的二进制(以 2 为底)系统中完美表示。它的二进制表示是一个循环小数,0.0001100110011…20.0001100110011\dots_20.0001100110011…2​,必须被截断。因此,从一开始,我们相加的数字通常就不是我们想要的精确数字。这个最初的微小差异是我们发现计算机算术并非纯粹数学的完美世界的第一个线索。这是一个“足够接近”的世界,而挑战在于防止“足够接近”变成“错得离谱”。

当微小之物被遗忘

真正的麻烦始于我们开始做加法。让我们想象一下,我们的浮点数有固定的,比如说 8 位数字的精度。如果我们想将 10,000,00010,000,00010,000,000 和 111 相加,我们会将第一个数写为 1.0000000×1071.0000000 \times 10^71.0000000×107。为了加上第二个数,我们必须对齐指数:111 变成 0.0000001×1070.0000001 \times 10^70.0000001×107。和是 1.0000001×1071.0000001 \times 10^71.0000001×107,即 10,000,00110,000,00110,000,001。一切正常。

但如果我们加上 10,000,00010,000,00010,000,000 和 0.10.10.1 呢?我们再次对齐指数。第一个数是 1.0000000×1071.0000000 \times 10^71.0000000×107。第二个数 0.10.10.1 变成 0.00000001×1070.00000001 \times 10^70.00000001×107。我们的和应该是 1.00000001×1071.00000001 \times 10^71.00000001×107。但是等等——我们只有 8 位数字的精度!最后的那个‘1’掉出去了。计算机被迫进行舍入,结果被存储为 1.0000000×1071.0000000 \times 10^71.0000000×107。那个 0.10.10.1 就这样消失得无影无踪。

这种现象被称为​​淹没​​或​​吸收​​。较小的数被较大的数完全吸收,其贡献永远丢失了。这就像试图测量一座摩天大楼的高度,在顶部加上一张纸,然后期望你的卷尺能注意到这个差异。你的工具精度不够高,无法记录如此微小的变化。

这不仅仅是一个理论上的好奇心;它可能导致灾难性的失败。考虑对三个数的序列求和:[1016,1,−1016][10^{16}, 1, -10^{16}][1016,1,−1016]。当然,确切答案是 111。但一台从左到右执行简单、朴素求和的计算机会执行以下操作:

  1. 首先,它计算 1016+110^{16} + 11016+1。在标准的双精度算术中,数字 111 比 101610^{16}1016 小十六个数量级以上。它远小于那种数量级的数字可能有的最小增量。这个 111 被吸收了,结果就是 101610^{16}1016。信息已经被销毁。
  2. 接下来,它计算 1016−101610^{16} - 10^{16}1016−1016,结果恰好是 000。

最终答案是 000。不是接近 111,而是精确的 000。误差并不小,而是 100%100\%100%。这就是​​灾难性抵消​​的一个例子,其中中间步骤中一个小值的丢失导致了完全错误的最终答案。想象一下,朴素地将数百万个微小的增量加到一个大的初始值上,最后再减去这个大值。所有微小的增量都会丢失,导致灾难性的结果。

保留零钱的天才之举

我们如何应对这个问题?我们需要一种方法来记住那些被舍入掉的微小部分。这就是​​补偿求和​​背后的绝妙洞见,这项技术由数学家 William Kahan 完善。其思想非常简单:如果你在一次交易中损失了一些零钱,你应该把它记下来,并用它来调整下一次交易。

Kahan 算法在朴素的累加和(我们称之为 sss)的基础上,增加了一个第二个变量 ccc,即​​补偿​​。可以把 ccc 想象成一个“误差罐”,用来存放所有被扫到地毯下的数值尘埃。以下是把列表中的下一个数 xxx 加进来的过程:

  1. y = x - c:首先,我们通过减去之前所有步骤累积的误差来校正我们的数字 xxx。
  2. t = s + y:然后,我们将这个校正后的数字 yyy 加到我们的主和 sss 上。这会得到一个临时的和 ttt。这一步可能会发生淹没,并引入新的舍入误差。
  3. c = (t - s) - y:这就是魔法所在。在完美的数学世界里,因为 ttt 应该是 s+ys+ys+y,所以这个表达式会是零。但在浮点运算中,这个精确的操作序列巧妙地分离出了 yyy 在加到 sss 上时刚刚丢失的部分。其结果是步骤 2 中舍入误差的负值。我们将这个新丢失的部分放入我们的误差罐中,供下一轮使用。
  4. s = t:最后,我们更新主和。

让我们用 Kahan 算法重新审视一下我们那个灾难性的例子:[1016,1,−1016][10^{16}, 1, -10^{16}][1016,1,−1016]。

  • ​​初始化:​​ s=0s = 0s=0,c=0c = 0c=0。
  • ​​加上 101610^{16}1016:​​ y=1016−0=1016y = 10^{16} - 0 = 10^{16}y=1016−0=1016。然后 t=0+1016=1016t = 0 + 10^{16} = 10^{16}t=0+1016=1016。误差计算得出 c=(1016−0)−1016=0c = (10^{16} - 0) - 10^{16} = 0c=(1016−0)−1016=0。新状态是 s=1016s = 10^{16}s=1016,c=0c = 0c=0。
  • ​​加上 111:​​ y=1−0=1y = 1 - 0 = 1y=1−0=1。现在,t=s+y=1016+1t = s + y = 10^{16} + 1t=s+y=1016+1。正如我们所见,这会舍入为 101610^{16}1016。魔法来了:c=(t−s)−y=(1016−1016)−1=−1c = (t - s) - y = (10^{16} - 10^{16}) - 1 = -1c=(t−s)−y=(1016−1016)−1=−1。算法通过在误差罐中存储其负值,“记住”了那个 111 已经丢失了!新状态是 s=1016s = 10^{16}s=1016,c=−1c = -1c=−1。
  • ​​加上 −1016-10^{16}−1016:​​ y=−1016−c=−1016−(−1)=−1016+1y = -10^{16} - c = -10^{16} - (-1) = -10^{16} + 1y=−1016−c=−1016−(−1)=−1016+1。接下来,t=s+y=1016+(−1016+1)=1t = s + y = 10^{16} + (-10^{16} + 1) = 1t=s+y=1016+(−1016+1)=1。计算得出了正确的最终和!

这个优雅的过程是一种​​迭代求精​​。它使用一系列标准的低精度操作来模拟单个高精度计算,恢复丢失的残差并将其反馈回过程中。

乱中求序

这种方法的实际影响是惊人的。对于 NNN 个数的朴素求和,误差可能与 NNN 成比例增长。求和越长,结果越差。但 Kahan 算法的误差非常稳定。总误差被一个小的常数乘以这些数绝对值之和所限制,几乎完全独立于 NNN。这意味着你可以用几乎与求和一百项相同的相对精度来求和一百万项。在比较两种方法的测试中,Kahan 算法的准确度可以高出数百万甚至数万亿倍,将误差从灾难性水平降低到接近于零。

小心过度热心的助手

然而,故事还有一个转折。当 Kahan 算法的精妙之处遇到一个同样聪明但辨别力较差的伙伴——优化编译器时,这种精妙反而可能成为它的败笔。

编译器的任务是让你的代码运行得更快。为此,它经常应用代数简化。当一个带有激进“快速数学”优化(-ffast-math)的编译器看到 c = (t - s) - y 这行魔法代码时,它可能会这样推理:“啊哈!程序员刚刚设置了 t = s + y。因此,(t - s) - y 等同于 (s + y - s) - y,也就是 y - y,结果永远是 000!” 编译器出于热心,可能会用 c = 0 替换整个复杂的计算。这种“优化”会完全破坏算法,使补偿失效,并将这个复杂的方法退化回简单的朴素求和。为了防止这种情况,程序员有时必须使用特殊指令(比如 C 语言中的 volatile 关键字)来告诉编译器:“别动!这个操作序列是特意安排的,决不能更改。”

即使实现得完美无缺,该算法也有其阿喀琉斯之踵。它的魔法依赖于累加和 sss 大于校正项 yyy。如果你试图对像 [1,10100,−10100][1, 10^{100}, -10^{100}][1,10100,−10100] 这样的序列求和,算法就会失败。巨大的项 1010010^{100}10100 不仅淹没了累加和 111,也淹没了误差恢复机制本身。在其他更微妙的情况下,计算出的误差 ccc 相对于下一个数 xxx 可能会变得非常小,以至于在计算 x - c 时被吸收,从而有效地打破了补偿链。没有哪个算法是万能的银弹;真正的精通在于理解其局限性。

超越 Kahan:精度的阶梯

Kahan 算法是相比朴素求和的巨大进步,但它并非终点。如果误差项 c 本身的计算也存在微小误差呢?我们能为补偿本身再做补偿吗?

答案是肯定的。通过应用相同的逻辑,我们可以创建​​双重补偿求和​​算法,比如 Douglas Priest 开发的那种。这些方法使用第二个误差罐 cc,来捕捉在更新第一个误差罐 c 时丢失的微小误差。这建立在一个更基础的概念之上:​​无误差变换​​。这些是精心设计的操作序列(如 TwoSum 算法),可以接收两个浮点数 aaa 和 bbb,不仅返回它们的舍入和 s=fl⁡(a+b)s = \operatorname{fl}(a+b)s=fl(a+b),还返回精确、可完美表示的舍入误差 eee,使得在纯粹数学中 a+b=s+ea+b = s+ea+b=s+e 成立。

这些先进技术构成了一个精度阶梯。最底层是快速但危险的朴素求和。往上一大步是 Kahan 算法,对大多数需求而言既稳健又准确。更高层是双重和多重补偿方法,为最苛刻的科学和金融计算提供非凡的精度。阶梯的每一级都代表了对实数与其有限浮点表示之间微妙舞蹈的更深理解——这是一个美丽的证明,证明了让强大但不完美的计算机以越来越高的保真度说出数学语言所需的智慧。

应用与跨学科联系

伽利略有句名言:“自然这本伟大的书是用数学语言写成的。” 在我们这个现代,我们已将这本书翻译成了计算机的语言。但这种翻译并不完美。正如我们所见,数字算术的根基——浮点数——存在一个微妙的缺陷:它无法以完美的保真度表示每一个数字。一个简单的求和并非总是那么简单。我们已经探讨了 Kahan 求和算法这个巧妙的机制,它像一个一丝不苟的记账员,追踪着计算机通常会丢弃的微小舍入误差。现在,让我们踏上一段旅程,去看看这个聪明的想法在哪些领域发挥了作用。我们会发现,这个单一而优雅的原则回响在科学的广阔殿堂中,从我们数据的核心到人工智能的前沿,揭示了计算思维深刻且常令人惊讶的内在联系。

计算的基石:数学与统计学

在我们建造模拟和人工智能的摩天大楼之前,我们必须确保基础是稳固的。这个基础通常是由线性代数和统计学的工具构建的,即使在这些基础学科中,精度也至关重要。

思考一下几何学和数据分析中最基本的操作之一:点积。想象空间中的两个向量如同箭头。它们的点积告诉我们它们“一致”的程度——即它们在多大程度上指向同一方向。当两个向量几乎垂直(正交)时,它们的点积应该接近于零。计算这个过程涉及对一系列乘积求和。然而,如果这些乘积是符号相反的大数,它们的和可能是一个由大数抵消而产生的小值。这是灾难性抵消的经典场景。朴素求和可能会得出一个完全不正确的结果,甚至可能暗示两个几乎正交的向量指向相似或相反的方向。使用一个位宽受限的数字系统进行的简单思维实验可以清楚地揭示这种失败。通过使用补偿求和来计算点积,我们确保了我们的几何直觉在数字领域中仍然成立。

这一原则直接延伸到统计学世界。统计学家的一个主要目标是总结数据——将庞大的数字集合提炼成几个有意义的指标。其中最重要的之一是方差,它衡量数据的“离散程度”。用于计算方差的中心矩公式涉及对每个数据点与均值之差的平方求和:M2=∑(xi−μ)2M_2 = \sum (x_i - \mu)^2M2​=∑(xi​−μ)2。现在,想象一下你的数据点非常紧密地聚集在一起,但它们都围绕着一个非常大的值(例如,测量汽车的精确重量,每辆都在 200020002000 公斤左右)。均值 μ\muμ 将是一个大数,而差值 xi−μx_i - \muxi​−μ 将是两个几乎相等的大数相减的结果。对这些差值进行朴素求和可能会导致灾难性的不准确,可能报告一个数量级错误的方差,甚至是负值!对于任何试图在生产线上进行质量控制或分析科学实验结果的人来说,这样的错误是不可接受的。补偿求和提供了一种稳健的方法来计算这些关键的统计矩,确保我们的数据讲述的是真实的故事。

模拟宇宙,从行星到蛋白质

有了更坚实的数学基础,我们可以将注意力转向科学最宏伟的追求之一:模拟自然世界。无论我们是在描绘宇宙中行星的壮丽舞蹈、机翼上空气的湍流,还是化学反应的复杂网络,我们通常都在求解常微分方程(ODE)。这个过程的本质是随着时间向前迈出微小的步子。我们系统在下一时刻的状态是当前状态加上一个由物理定律计算出的非常小的变化:yn+1=yn+Δy_{n+1} = y_n + \Deltayn+1​=yn​+Δ。

在这里,一个熟悉的问题出现了。如果状态 yny_nyn​ 是一个大数(比如远离太阳的行星的位置),而更新量 Δ\DeltaΔ 非常微小,那么朴素的浮点加法 yn+Δy_n + \Deltayn​+Δ 可能会舍入回 yny_nyn​,实际上忽略了这次更新。在数百万个模拟步骤中,这些被忽略的更新会累积起来,导致我们模拟的行星偏离其真实轨道。Kahan 算法可以直接编织到 ODE 求解器的核心中,作为防止模拟被缓慢、渐进地腐蚀的守护者。它确保每一步,无论多么微小,都对最终的轨迹有所贡献。

这不仅仅是一个抽象的顾虑,它可能带来生死攸关的后果。让我们从宇宙尺度放大到生命本身的微观芭蕾:一个蛋白质折叠成其复杂、有功能的形状。一个蛋白质构象的总能量是其原子间无数静电吸引和排斥的宏大总和。一些贡献很大,一些很小,一些是正的,一些是负的——这是数值问题的完美配方。使用朴素求和的分子动力学模拟可能会错误地计算总能量。这不仅仅是一个小的定量误差;它可能导致一个完全错误的定性结论。例如,生物学家可能会使用一个能量阈值来判断一个蛋白质是“折叠的”还是“未折叠的”。总和中的一个微小数值误差可能会使能量刚好跨过这个阈值,导致程序报告错误的状态。这里的一个错误预测,源于一个看似无害的舍入误差,可能会使一个药物发现项目脱轨,或导致对生物机制的根本性误解。在这个世界里,求和算法的选择确实可以改变科学答案。

当我们试图从实验数据中建立模型时,同样的挑战也会出现,这个过程通常依赖于线性最小二乘法。标准的教科书方法涉及构建并求解“正规方程”,ATAx=ATyA^T A x = A^T yATAx=ATy。第一步,构建矩阵 G=ATAG = A^T AG=ATA,就是一项庞大的求和任务。如果你的数据矩阵 AAA 的列几乎是线性相关的,那么这个矩阵 GGG 会变得极其敏感,难以精确计算。在此阶段引入的误差不仅仅是给最终答案增加一点噪声;它们可以产生一个完全扭曲的模型,一个用以审视你数据的破碎透镜。使用补偿求和来构建 GGG 为这个科学数据分析的基石提供了更坚固的基础。

经济的引擎:计算金融

从宇宙的宏大尺度,让我们转向人造的金融世界。在这里,数值误差的后果不是用物理漂移来衡量,而是用冰冷的现金。一个投资组合的价值,其核心是一长串过去回报的总和。每日甚至每小时的回报通常是微小的百分比,代表着像 10−410^{-4}10−4 或 10−510^{-5}10−5 这样的数字。当你将如此小的回报加到一个价值数百万美元的投资组合中时,朴素求和很容易遭受淹没,微小的回报被舍入为零。在一年数百个交易日里,这些丢失的小数可以累积成真实、有形的金钱,消失在数字以太中。对于高频交易公司、对冲基金和银行来说,算法管理着数万亿美元,这并非一个理论问题。补偿求和确保每一分钱都被妥善核算,为全球金融系统提供了所需的数值完整性。

新前沿:人工智能

对人工智能的追求是一个优化的故事,即通过对其参数进行数百万次微小调整来训练庞大的人工神经网络。这些调整由梯度决定,而梯度通常是在一个训练样本的“小批量”上计算和累积的。为了加速这项计算量巨大的任务,研究人员和工程师们正积极推动使用较低精度的数字(例如,16 位半精度浮点数而非 64 位双精度浮点数),尤其是在 GPU 和 TPU 等专用硬件上。

然而,这一举措重新唤醒了数值精度的旧恶魔。当以低精度格式累积数千个小梯度时,舍入误差可能会失控。最终累积的梯度可能变得充满噪声,以至于训练过程停滞不前,或者参数更新甚至可能“爆炸”,导致网络完全无法学习。而在这里,我们六十岁的老朋友——Kahan 求和算法——找到了新的、令人兴奋的生命。通过将补偿求和纳入梯度累积步骤,我们可以大大减少舍入误差,稳定训练过程。这是一个美丽的例子,展示了一个古老的基本原理如何为解锁更快、更高效、更可靠的人工智能提供了关键。

加法的隐藏艺术

经过这一切,简单的加法行为似乎变成了一个雷区。但它也是隐藏的美丽和对计算本质深刻洞见的源泉。思考一下交错调和级数的求和:1−12+13−14+⋯1 - \frac{1}{2} + \frac{1}{3} - \frac{1}{4} + \cdots1−21​+31​−41​+⋯。正如我们所见,Kahan 求和可以优雅地处理它。但即使使用朴素求和,操作的顺序也至关重要。如果你按相反的顺序,从最小量级到最大量级对各项求和,朴素求和的准确性会显著提高。然而,如果你先把所有正项分组求和,再把所有负项分组求和,最后将两个巨大的结果相加,你就会制造一个灾难性抵消的教科书案例,得出一个极其不准确的答案。即使是在一个简单多项式的根附近求值,也会遇到同样的挑战:将许多符号交替的大项相加得到一个微小的结果,这是对数值灾难的公然邀请。

这给了我们一个深刻的教训:为计算机编程不仅仅是写下正确的数学公式。它需要对机器本质的欣赏——理解它如何表示数字和执行算术。

我们的旅程从点积的抽象世界,到蛋白质的物理世界,从金融的建构世界,到人工智能的虚拟世界。贯穿其中的共同主线是卑微的求和以及由有限精度产生的持续误差威胁。一个单一而优雅的想法——追踪丢失的东西——提供了一个强大而统一的解决方案。发现这样的原则及其深远的影响是科学的一大乐趣。它向我们展示了,即使在计算最实际的角落,也能找到一种深刻而统一的美,这是严谨思考力量的证明。