应该以哪种顺序添加浮点数以获得最精确的结果?


105

这是我最近的一次采访中问的一个问题,我想知道(我实际上不记得数值分析的理论,所以请帮助我:)

如果我们有一些函数,它会累积浮点数:

std::accumulate(v.begin(), v.end(), 0.0);

v是一个std::vector<float>,例如。

  • 在累加它们之前对这些数字进行排序会更好吗?

  • 哪个命令会给出最准确的答案?

我怀疑排序依次递增的数字实际上使数值误差,但不幸的是我不能证明它自己。

PS我确实意识到这可能与现实世界的编程无关,只是感到好奇。


17
实际上,这与现实编程有关。但是,许多应用程序并不真正关心计算的绝对最佳精度,只要它“相当接近”即可。工程应用?非常重要。医疗应用?非常重要。大规模统计?精度稍差一些是可以接受的。
Zéychin

18
除非您真正知道并且可以指向页面以详细解释您的推理,否则请不要回答。关于浮点数的飞散已经有很多废话,我们不想添加。如果您认为自己知道。停。因为如果您仅认为自己知道,那您可能是错的。
马丁·约克

4
@Zéychin“工程应用程序?非常重要。医疗应用程序?非常重要。” 我想你会如果你知道真相:)惊讶
BЈовић

3
@Zeychin绝对错误无关紧要。重要的是相对误差。如果百分之几的弧度是0.001%,那么谁在乎呢?
2011年

3
我真的建议阅读以下内容:“每个计算机科学家需要了解的浮点知识” perso.ens-lyon.fr/jean-michel.muller/goldberg.pdf
Mohammad Alaggan

Answers:


108

您的直觉基本上是正确的,按升序排列(数量级)通常可以使事情有所改善。考虑以下情况:我们要添加单精度(32位)浮点数,并且有10亿个值等于1 /(10亿个),并且有一个值等于1。如果先出现1,那么总和就到了到1,因为1 +(1 /十亿)由于精度损失而为1。每次添加对总数完全没有影响。

如果较小的值排在第一位,则它们至少会求和,尽管即使如此,我仍有2 ^ 30个值,而在2 ^ 25个左右之后,我又回到了每个值都不影响总数的情况还有。所以我仍然需要更多技巧。

这是一个极端的情况,但是总的来说,相加幅度相似的两个值比相加幅度不同的两个值更准确,因为您以这种较小的值“舍弃”了较少的精度位。通过对数字进行排序,可以将大小相似的值组合在一起,并按升序将它们相加,从而为较小的值提供累积达到较大数字的大小的“机会”。

但是,如果涉及负数,则很容易“胜过”这种方法。考虑三个值的总和{1, -1, 1 billionth}。算术上正确的总和是1 billionth,但是如果我的第一个加法涉及一个微小的值,那么我的最终总和将为0。在6个可能的阶数中,只有2个是“正确的”- {1, -1, 1 billionth}{-1, 1, 1 billionth}。所有6个阶次给出的结果在输入中的最大震级值的尺度上(0.0000001%输出)都是准确的,但是对于其中4个阶次,结果在真实解的尺度上(100%输出)是不准确的。您要解决的特定问题将告诉您前者是否足够好。

实际上,您可以玩的技巧更多,而不仅仅是按顺序添加它们。如果您有很多非常小的值,中等数量的中间值和少量大值,那么首先将所有小值相加,然后分别将中值相加,然后将这两个总数相加,可能是最准确的方法一起添加大的 找到最精确的浮点加法组合并不是一件容易的事,但是要处理非常糟糕的情况,您可以将整个运行总计保持在不同的幅度,将每个新值添加到最匹配其幅度的总和,当运行的总计开始变得太大而无法达到其幅度时,请将其添加到下一个总计中,然后开始一个新的总计。从逻辑上讲,此过程等效于以任意精度类型执行总和(因此,d)。但是考虑到按升序或降序数量级的简单选择,升序是更好的选择。

它确实与实际编程有关,因为在某些情况下,如果您不小心砍掉了由大量值组成的“沉重”尾巴,那么每个尾巴都太小而无法单独影响,则计算可能会变得非常错误。总和,或者如果您放弃了很多单独影响总和的最后几位的小值而导致的精度过高。如果尾巴可以忽略不计,那么您可能不在乎。例如,如果您刚开始只将少量值相加,而只使用了一些重要的和。


8
+1以作解释。这一点有点违反直觉,因为加法通常在数值上是稳定的(与减法和除法不同)。
康拉德·鲁道夫

2
@Konrad,它在数值上可能是稳定的,但是鉴于操作数的大小不同,它不是精确的:)
MSN

3
@ 6502:它们按大小顺序排序,因此-1在末尾。如果总计的真实值是1,那么就可以了。如果您将三个值加在一起:1 /十亿,1和-1,那么您将得到0,这时您必须回答有趣的实际问题-您是否需要一个准确的答案?真正的总和,还是您只需要一个在最大值范围内准确的答案?对于某些实际应用,后者足够好,但是如果不是,则需要更复杂的方法。量子物理学使用重归一化。
史蒂夫·杰索普

8
如果您要坚持这种简单的方案,我将始终将幅度最小的两个数字相加,然后将和重新插入集合中。(好吧,合并排序可能在这里效果最好。您可以使用包含先前求和数字的数组部分作为部分求和的工作区域。)
Neil

2
@Kevin Panko:简单的版本是单精度浮点数有24个二进制数字,其中最大的是数字中最大的设置位。因此,如果将两个大小相差超过2 ^ 24的数字相加,则将损失较小值的总和;如果它们在大小上相差较小的值,则将损失相应数量的较小精度的位数数。
史蒂夫·杰索普

88

您可能还应该知道还有一种针对这种累加运算设计的算法,称为Kahan Summation

根据维基百科,

Kahan的求和算法(也称为补偿求和)显著降低通过添加有限精度浮点数的一个序列获得的总的数值误差,相对于明显的方法。这是通过保留单独的运行补偿(用于累积小误差的变量)来完成的。

在伪代码中,算法为:

function kahanSum(input)
 var sum = input[1]
 var c = 0.0          //A running compensation for lost low-order bits.
 for i = 2 to input.length
  y = input[i] - c    //So far, so good: c is zero.
  t = sum + y         //Alas, sum is big, y small, so low-order digits of y are lost.
  c = (t - sum) - y   //(t - sum) recovers the high-order part of y; subtracting y recovers -(low part of y)
  sum = t             //Algebraically, c should always be zero. Beware eagerly optimising compilers!
 next i               //Next time around, the lost low part will be added to y in a fresh attempt.
return sum

3
+1这个线程的可爱成员。任何“急于优化”这些语句的编译器都应被禁止。
克里斯·A

1
这是一种简单的方法,通过使用两个不同大小的求和变量sum,几乎可以使精度提高一倍c。它可以简单地扩展到N个变量。
MSalters 2011年

2
@ChrisA。好了,您可以在所有重要的编译器上显式地控制它(例如,通过-ffast-mathGCC)。
康拉德·鲁道夫

6
@Konrad Rudolph感谢您指出这是使用可能的优化-ffast-math。我从这次讨论和此链接中学到的是,如果您关心数值精度,则应该避免使用,-ffast-math但是在很多应用中,它们可能是CPU密集型的,但并不关心精确的数值计算(例如游戏编程) ),-ffast-math使用合理。因此,我想对我措辞强烈的“被禁止”评论进行修正。
克里斯·A,

使用双精度变量sum, c, t, y会有所帮助。您还需要添加sum -= c在之前return sum
G. Cohen,

34

我在史蒂夫·杰索普(Steve Jessop)提供的答案中尝试了极端的例子。

#include <iostream>
#include <iomanip>
#include <cmath>

int main()
{
    long billion = 1000000000;
    double big = 1.0;
    double small = 1e-9;
    double expected = 2.0;

    double sum = big;
    for (long i = 0; i < billion; ++i)
        sum += small;
    std::cout << std::scientific << std::setprecision(1) << big << " + " << billion << " * " << small << " = " <<
        std::fixed << std::setprecision(15) << sum <<
        "    (difference = " << std::fabs(expected - sum) << ")" << std::endl;

    sum = 0;
    for (long i = 0; i < billion; ++i)
        sum += small;
    sum += big;
    std::cout  << std::scientific << std::setprecision(1) << billion << " * " << small << " + " << big << " = " <<
        std::fixed << std::setprecision(15) << sum <<
        "    (difference = " << std::fabs(expected - sum) << ")" << std::endl;

    return 0;
}

我得到以下结果:

1.0e+00 + 1000000000 * 1.0e-09 = 2.000000082740371    (difference = 0.000000082740371)
1000000000 * 1.0e-09 + 1.0e+00 = 1.999999992539933    (difference = 0.000000007460067)

第一行中的错误比第二行中的错误大十倍以上。

如果在上面的代码中将doubles 更改为s float,则会得到:

1.0e+00 + 1000000000 * 1.0e-09 = 1.000000000000000    (difference = 1.000000000000000)
1000000000 * 1.0e-09 + 1.0e+00 = 1.031250000000000    (difference = 0.968750000000000)

这两个答案都无法接近2.0(但第二个答案稍微接近)。

使用doubleDaniel Pryden所述的Kahan求和(带有s):

#include <iostream>
#include <iomanip>
#include <cmath>

int main()
{
    long billion = 1000000000;
    double big = 1.0;
    double small = 1e-9;
    double expected = 2.0;

    double sum = big;
    double c = 0.0;
    for (long i = 0; i < billion; ++i) {
        double y = small - c;
        double t = sum + y;
        c = (t - sum) - y;
        sum = t;
    }

    std::cout << "Kahan sum  = " << std::fixed << std::setprecision(15) << sum <<
        "    (difference = " << std::fabs(expected - sum) << ")" << std::endl;

    return 0;
}

我得到的正是2.0:

Kahan sum  = 2.000000000000000    (difference = 0.000000000000000)

即使在上面的代码中将doubles 更改为s,我也会float得到:

Kahan sum  = 2.000000000000000    (difference = 0.000000000000000)

看来Kahan是要走的路!


我的“大”值等于1,而不是1e9。您的第二个答案,按大小顺序递增,在数学上是正确的(10亿,再加上十亿分之十,分别是10亿和1),尽管幸运的是,该方法的任何一般性double都还不错:-)请注意,这并不算不好十亿分之十的精度相加会损失精度,因为它有52个有效位,而IEEE float只有24个,并且会。
史蒂夫·杰索普

@Steve,我的错,很抱歉。我已经将示例代码更新为您想要的。
Andrew Stein

4
Kahan的精度仍然有限​​,但是要构造一个杀手级案例,您既需要主和,还需要误差累加器c以包含比下一个被求和大得多的值。这意味着总和要比主要总和小得多,因此必须有很多这样的总和。尤其是double算术运算。
史蒂夫·杰索普

14

有一类算法可以解决这个确切的问题,而无需对数据进行排序或重新排序

换句话说,求和可以一次通过数据。这也使得这样的算法适用于事先不知道数据集的情况,例如,如果数据是实时到达的并且需要保持运行总和。

这是最近一篇论文的摘要:

我们提出一种新颖的在线算法,用于精确求和浮点数流。“在线”是指该算法一次只需要看一个输入,并且可以采用这种输入的任意长度的输入流,而只需要恒定的内存。“精确”是指算法内部数组的总和与所有输入的总和完全相等,并且返回的结果是正确舍入的总和。正确性证明对所有输入均有效(包括非归一化的数字,但取模中间溢出),并且与求和数或总和的条件数无关。该算法渐近每次请求只需要5个FLOP,并且由于指令级并行性的运行速度比明显的速度慢2--3倍,当求和数大于10,000时,快速但笨拙的“常规递归求和”循环。因此,据我们所知,它是已知算法中最快,最准确和最有效的内存。确实,很难看到如果不对硬件进行改进就可以存在一种更快的算法或需要更少FLOP的算法。提供了大量求证的申请。

来源:算法908:浮点流的在线精确求和


1
@Inverse:周围仍然有实体库。另外,在线购买PDF的费用为5到15美元(取决于您是否是ACM成员)。最后,DeepDyve似乎愿意以2.99美元的价格提供24小时的借阅服务(如果您是DeepDyve的新手,您甚至可以免费获得其免费试用版的内容):deepdyve.com/lp/acm /…
NPE

2

在史蒂夫(Steve)首先对数字进行升序排序的答案的基础上,我将介绍另外两个想法:

  1. 确定两个数字在指数上的差异,如果超过两个数字,您可能会决定失去太多的精度。

  2. 然后按顺序累加数字,直到累加器的指数对于下一个数字太大为止,然后将累加器置于临时队列中,并使用下一个数字启动累加器。继续,直到用尽原始列表。

使用临时队列(已对其进行排序)和指数可能更大的差异重复该过程。

如果您必须一直计算指数,我认为这会很慢。

我快速浏览了一个程序,结果是1.99903


2

我认为您可以比对数字进行累加更好,因为在累加过程中,累加器会越来越大。如果您有大量相似的数字,您将很快失去精度。这是我的建议:

while the list has multiple elements
    remove the two smallest elements from the list
    add them and put the result back in
the single element in the list is the result

当然,使用优先级队列而不是列表,该算法将是最有效的。C ++代码:

template <typename Queue>
void reduce(Queue& queue)
{
    typedef typename Queue::value_type vt;
    while (queue.size() > 1)
    {
        vt x = queue.top();
        queue.pop();
        vt y = queue.top();
        queue.pop();
        queue.push(x + y);
    }
}

司机:

#include <iterator>
#include <queue>

template <typename Iterator>
typename std::iterator_traits<Iterator>::value_type
reduce(Iterator begin, Iterator end)
{
    typedef typename std::iterator_traits<Iterator>::value_type vt;
    std::priority_queue<vt> positive_queue;
    positive_queue.push(0);
    std::priority_queue<vt> negative_queue;
    negative_queue.push(0);
    for (; begin != end; ++begin)
    {
        vt x = *begin;
        if (x < 0)
        {
            negative_queue.push(x);
        }
        else
        {
            positive_queue.push(-x);
        }
    }
    reduce(positive_queue);
    reduce(negative_queue);
    return negative_queue.top() - positive_queue.top();
}

队列中的数字为负,因为top产生最大的数字,但我们希望最小的。我可以向队列提供更多模板参数,但是这种方法似乎更简单。


2

这并不能完全回答您的问题,但是明智的做法是将求和运算两次,一次使用“ 舍入舍入模式,一次使用“ 舍入舍入模式。比较两个答案,您会知道/如何/不正确的结果,以及是否因此需要使用更聪明的求和策略。不幸的是,大多数语言都没有像应该那样容易地更改浮点舍入模式,因为人们不知道它在日常计算中实际上是有用的。

看一下Interval算术,您可以在其中进行所有这样的数学运算,并随时随地保持最高和最低值。它导致一些有趣的结果和优化。


0

提高准确性的最简单排序是按递增的绝对值排序。这样一来,最小的震级值就有机会在与较大的震级值进行交互之前累积或抵消,而这些较大的震级值会触发精度损失。

就是说,通过跟踪多个不重叠的部分和,可以做得更好。这是一篇描述该技术并提出准确性证明的论文:www-2.cs.cmu.edu/afs/cs/project/quake/public/papers/robust-arithmetic.ps

该算法和其他用于精确浮点求和的方法是在以下位置的简单Python中实现的:http//code.activestate.com/recipes/393090/ 其中至少有两个可以轻松转换为C ++。


0

对于IEEE 754单精度或双精度或已知格式的数字,另一种选择是使用指数索引的数字数组(由调用者传递,或在C ++类中)。将数字添加到数组时,仅添加具有相同指数的数字(直到找到一个空插槽并存储该数字)。当需要求和时,将数组从最小到最大求和以最小化截断。单精度示例:

/* clear array */
void clearsum(float asum[256])
{
size_t i;
    for(i = 0; i < 256; i++)
        asum[i] = 0.f;
}

/* add a number into array */
void addtosum(float f, float asum[256])
{
size_t i;
    while(1){
        /* i = exponent of f */
        i = ((size_t)((*(unsigned int *)&f)>>23))&0xff;
        if(i == 0xff){          /* max exponent, could be overflow */
            asum[i] += f;
            return;
        }
        if(asum[i] == 0.f){     /* if empty slot store f */
            asum[i] = f;
            return;
        }
        f += asum[i];           /* else add slot to f, clear slot */
        asum[i] = 0.f;          /* and continue until empty slot */
    }
}

/* return sum from array */
float returnsum(float asum[256])
{
float sum = 0.f;
size_t i;
    for(i = 0; i < 256; i++)
        sum += asum[i];
    return sum;
}

双精度示例:

/* clear array */
void clearsum(double asum[2048])
{
size_t i;
    for(i = 0; i < 2048; i++)
        asum[i] = 0.;
}

/* add a number into array */
void addtosum(double d, double asum[2048])
{
size_t i;
    while(1){
        /* i = exponent of d */
        i = ((size_t)((*(unsigned long long *)&d)>>52))&0x7ff;
        if(i == 0x7ff){         /* max exponent, could be overflow */
            asum[i] += d;
            return;
        }
        if(asum[i] == 0.){      /* if empty slot store d */
            asum[i] = d;
            return;
        }
        d += asum[i];           /* else add slot to d, clear slot */
        asum[i] = 0.;           /* and continue until empty slot */
    }
}

/* return sum from array */
double returnsum(double asum[2048])
{
double sum = 0.;
size_t i;
    for(i = 0; i < 2048; i++)
        sum += asum[i];
    return sum;
}

这听起来有点像Malcolm 1971的方法,或者更像是它的变体,它使用了Demmel和Hida的指数(“算法3”)。还有另一种算法可以像您一样执行基于进位的循环,但是目前无法找到它。
ZachB

@ZachB-这个概念类似于链表的底向上合并排序,它也使用一个小的数组,其中array [i]指向具有2 ^ i个节点的列表。我不知道这有多远。就我而言,这是在1970年代的自我发现。
rcgldr

-1

您的浮标应以双精度添加。这将为您提供比其他任何技术都更高的精度。为了获得更高的精度和更快的速度,您可以创建四个和,然后将它们相加。

如果要添加双精度数,请使用长双精度数作为总和-但是,这只会在长双精度实际上具有比双精度更高的精度的实现中起作用(通常为x86,PowerPC取决于编译器设置)。


1
“这将为您提供比其他任何技术都更高的精度”。您是否意识到答案早于描述了如何使用精确求和的较早答案之后一年多?
Pascal Cuoq 2014年

“ long double”类型太可怕了,您不应该使用它。
杰夫

-1

关于排序,在我看来,如果您希望取消,则应该以降序而不是升序来添加数字。例如:

((-1 + 1)+ 1e-20)将得到1e-20

((1e-20 +1)-1)将给出0

在第一个方程中,两个大数被抵消,而在第二个方程中,将1e-20项加到1时会丢失,因为没有足够的精度来保留它。

同样,成对求和对于求和大量数字非常合适。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.