В каком порядке следует добавлять поплавки, чтобы получить наиболее точный результат? - PullRequest
104 голосов
/ 14 июля 2011

Это был вопрос, который мне задавали во время моего недавнего интервью, и я хочу знать (я не помню теории численного анализа, поэтому, пожалуйста, помогите мне:)

Если у нас есть какая-то функция, которая накапливает числа с плавающей точкой:

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

v - это, например, std::vector<float>.

  • Было бы лучше отсортировать эти числа перед их накоплением?

  • Какой порядок даст наиболее точный ответ?

Я подозреваю, что сортировка чисел в порядке возрастания фактически приведет к числовой ошибке меньше , но, к сожалению, я не могу доказать это сам.

PS Я понимаю, что это, вероятно, не имеет ничего общего с программированием в реальном мире, просто любопытство.

Ответы [ 11 ]

108 голосов
/ 14 июля 2011

Ваш инстинкт в принципе правильный, сортировка по возрастанию (по величине) обычно несколько улучшает ситуацию. Рассмотрим случай, когда мы добавляем 32-битные числа с одинарной точностью, и 1 миллиард значений равен 1 / (1 миллиард), а одно значение равно 1. Если 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%). Конкретная проблема, которую вы решаете, скажет вам, достаточно ли она хороша или нет.

На самом деле, вы можете играть намного больше трюков, чем просто добавлять их в отсортированном порядке. Если у вас есть много очень маленьких значений, среднее число средних значений и небольшое количество больших значений, то, возможно, будет наиболее точным сначала сложить все маленькие, а затем отдельно сложить средние значения, добавить эти два итога. вместе затем добавьте большие. Найти совсем точную комбинацию сложений с плавающей точкой совсем не тривиально, но чтобы справиться с действительно плохими случаями, вы можете сохранить целый массив промежуточных сумм с разными величинами, добавляя каждое новое значение к сумме, которая лучше всего соответствует его величине, и когда промежуточный итог начинает становиться слишком большим для его величины, добавьте его в следующий итог и начните новый. Взятый до логического предела, этот процесс эквивалентен выполнению суммы в типе с произвольной точностью (так что вы бы это сделали). Но, учитывая упрощенный выбор сложения в порядке возрастания или убывания, лучше сделать ставку по возрастанию.

Это имеет какое-то отношение к программированию в реальном мире, поскольку в некоторых случаях ваши вычисления могут пойти очень плохо, если вы случайно отрубите «тяжелый» хвост, состоящий из большого числа значений, каждое из которых слишком мало индивидуально влиять на сумму, или если вы отбрасываете слишком много точности из множества маленьких значений, которые по отдельности влияют только на последние несколько битов суммы. В тех случаях, когда хвост в любом случае незначителен, вам, вероятно, все равно. Например, если вы сначала складываете небольшое количество значений и используете только несколько значащих цифр суммы.

87 голосов
/ 15 июля 2011

Существует также алгоритм, разработанный для такого рода операций накопления, который называется Суммирование Кахана , о котором вам, вероятно, следует знать.

Согласно Википедии,

Алгоритм суммирования Кахана (также известный как компенсированное суммирование ) значительно уменьшает числовую ошибку в сумме, полученную путем добавления последовательности чисел с плавающей запятой конечной точности, по сравнению с очевидной подход. Это делается путем сохранения отдельной рабочей компенсации (переменная для накопления мелких ошибок).

В псевдокоде алгоритм выглядит так:

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
34 голосов
/ 15 июля 2011

Я опробовал крайний пример в ответе Стива Джессопа.

#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)

Ошибка в первой строке более чем в десять раз больше во второй.

Если я изменю double с на 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 (но второй немного ближе).

Использование суммирования Кахана (с double с), как описано Даниэлем Приденом:

#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)

И даже если я изменим double с на float с в приведенном выше коде, я получу:

Kahan sum  = 2.000000000000000    (difference = 0.000000000000000)

Казалось бы, Кахан - это путь!

14 голосов
/ 15 июля 2011

Существует класс алгоритмов, решающих эту точную проблему, без необходимости сортировки или иного переупорядочения данных .

Другими словами, суммирование может быть выполнено за один проход по данным. Это также делает такие алгоритмы применимыми в ситуациях, когда набор данных заранее неизвестен, например, если данные поступают в режиме реального времени и необходимо сохранить текущую сумму.

Вот реферат недавней статьи:

Представляем новый онлайн-алгоритм точного суммирования потока чисел с плавающей точкой. Под «онлайн» мы подразумеваем, что алгоритм нужно видеть только один вход за раз, и может принять произвольный длина входного потока таких входов, при этом требуется только постоянная объем памяти. Под «точным» мы подразумеваем, что сумма внутреннего массива нашего Алгоритм в точности равен сумме всех входов, а возвращаемый результат - правильно округленная сумма. Доказательство правильности действительно для всех входов (включая ненормализованные числа, но по модулю промежуточное переполнение) и не зависит от количества слагаемых или номер условия суммы. Алгоритм асимптотически нуждается только 5 FLOP на слагаемое и из-за параллелизма на уровне команд работает всего в 2-3 раза медленнее, чем очевидный, быстрый, но тупой Цикл «обычное рекурсивное суммирование», когда число слагаемых больше 10000 Таким образом, насколько нам известно, это самый быстрый, самый точный и наиболее эффективный по памяти среди известных алгоритмов. Действительно, это Трудно понять, насколько быстрее алгоритм или тот, который требует значительно меньше FLOP может существовать без аппаратных улучшений. Предоставляется приложение для большого числа слагаемых.

Источник: Алгоритм 908: точное точное суммирование потоков с плавающей запятой .

2 голосов
/ 16 июля 2011

Это не совсем отвечает на ваш вопрос, но разумно сделать это дважды: один раз с режимом округления «с округлением вверх» и один раз с «округлением вниз». Сравните два ответа, и вы знаете / как / неточны ваши результаты, и если вам необходимо использовать более умную стратегию суммирования. К сожалению, в большинстве языков изменение режима округления с плавающей запятой не так просто, как следовало бы, потому что люди не знают, что это действительно полезно в повседневных вычислениях.

Взгляните на Интервальная арифметика , где вы выполняете все подобные вычисления, сохраняя при этом самые высокие и самые низкие значения. Это приводит к некоторым интересным результатам и оптимизации.

2 голосов
/ 15 июля 2011

Я думаю, что вы можете сделать лучше, чем сортировать числа, прежде чем их накапливать, потому что в процессе накопления аккумулятор становится все больше и больше. Если у вас большое количество похожих чисел, вы быстро начнете терять точность. Вот что я бы предложил вместо этого:

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 голосов
/ 15 июля 2011

Основываясь на ответе Стива о первой сортировке чисел в порядке возрастания, я бы представил еще две идеи:

  1. Определите разницу в показателе двух чисел, над которым вы можете решитьчто вы потеряете слишком много точности.

  2. Затем сложите числа по порядку, пока показатель накопителя не станет слишком большим для следующего числа, затем поместите аккумулятор во временную очередь изапустить аккумулятор со следующим номером.Продолжайте, пока не исчерпаете исходный список.

Вы повторяете процесс с временной очередью (отсортировав ее) и, возможно, с большей разницей в показателе степени.

Я думаюэто будет довольно медленно, если вам придется вычислять показатели все время.

Я быстро пошел по программе, и результат составил 1.99903

0 голосов
/ 29 июня 2017

Что касается сортировки, мне кажется, что если вы ожидаете отмены, то числа должны быть добавлены в порядке по убыванию , а не по возрастанию.Например:

((- 1 + 1) + 1e-20) даст 1e-20

, но

((1e-20 + 1) - 1)будет давать 0

В первом уравнении два больших числа отбрасываются, тогда как во втором член 1e-20 теряется при добавлении к 1, поскольку для его сохранения недостаточно точности.

Кроме того, парное суммирование вполне подходит для суммирования большого числа чисел.

0 голосов
/ 07 сентября 2015

Для 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;
}
0 голосов
/ 14 августа 2014

Ваши поплавки должны быть добавлены с двойной точностью.Это даст вам больше точности, чем любая другая техника.Для большей точности и значительно большей скорости вы можете создать, скажем, четыре суммы и сложить их в конце.

Если вы добавляете числа двойной точности, используйте long double для суммы - однако это будет иметь только положительный эффект в реализациях, где long double на самом деле имеет большую точность, чем double (обычно x86, PowerPC в зависимости от настроек компилятора).

...