В каком порядке следует добавлять поплавки, чтобы получить наиболее точный результат?

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

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

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

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

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

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

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

P.S. Я действительно понимаю, что это, вероятно, не имеет никакого отношения к программированию на реальном мире, просто любопытно.

Ответ 1

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

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

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

Ответ 2

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

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

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

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

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

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

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

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

Kahan sum  = 2.000000000000000    (difference = 0.000000000000000)

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

Ответ 4

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

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

Вот тезис недавней работы:

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

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

Ответ 5

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

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

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

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

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

У меня был быстрый переход с программой, и результат был 1.99903

Ответ 6

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

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

Конечно, этот алгоритм будет наиболее эффективным с приоритетной очередью, а не с списком. Код С++:

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 дает наибольшее число, но мы хотим наименьшее. Я мог бы предоставить больше аргументов шаблона в очередь, но этот подход кажется более простым.

Ответ 7

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

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

Ответ 8

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

Тем не менее, вы можете сделать это лучше, отслеживая несколько неперекрывающихся частичных сумм. Вот документ, описывающий технику и представляющий доказательство точности: www-2.cs.cmu.edu/afs/cs/project/quake/public/papers/robust-arithmetic.ps

Этот алгоритм и другие подходы к точному суммированию с плавающей запятой реализуются в простом Python по адресу: http://code.activestate.com/recipes/393090/ Как минимум два из них могут быть тривиальными преобразован в С++.

Ответ 9

Для IEEE 754 одиночной или двойной точности или известных номеров формата другой альтернативой является использование массива чисел (переданных вызывающим или в классе для С++), индексированных экспонентом. При добавлении чисел в массив добавляются только числа с одинаковым показателем (пока не будет найден пустой слот и сохраненный номер). Когда вызывается сумма, массив суммируется от минимального до самого большого, чтобы минимизировать усечение. Пример с одной точностью:

/* 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;
}

Ответ 10

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

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

Ответ 11

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

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

но

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

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

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