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

105

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

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

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

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

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

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

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

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

Йиппи-Ки-Яй
источник
17
На самом деле это имеет прямое отношение к программированию в реальном мире. Однако многие приложения на самом деле не заботятся об абсолютной максимальной точности вычислений, если она «довольно близка». Инженерные приложения? Крайне важный. Медицинские приложения? Крайне важный. Масштабная статистика? Допускается несколько меньшая точность.
Zéychin
18
Пожалуйста, не отвечайте, если вы действительно не знаете и не можете указать страницу, на которой подробно объясняются ваши рассуждения. О летающих числах с плавающей запятой уже так много чуши, что мы не хотим к этому прибавлять. Если вы думаете, что знаете. СТОП. потому что если вы только думаете, что знаете, то, вероятно, ошибаетесь.
Мартин Йорк
4
@ Zéychin "Инженерные приложения? Чрезвычайно важные. Медицинские? Чрезвычайно важные." ??? Я думаю, вы были бы удивлены, если бы узнали правду :)
BЈoviћ
3
@Zeychin Абсолютная ошибка не имеет значения. Важна относительная погрешность. Если несколько сотых радиана составляют 0,001%, то кого это волнует?
BЈовић
3
Я действительно рекомендую прочитать эту статью: «Что каждый компьютерный ученый должен знать о плавающей запятой» perso.ens-lyon.fr/jean-michel.muller/goldberg.pdf
Мохаммад Алагган

Ответы:

108

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

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

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

Стив Джессоп
источник
8
+1 за объяснение. Это несколько нелогично, поскольку сложение обычно численно стабильно (в отличие от вычитания и деления).
Конрад Рудольф
2
@Konrad, он может быть численно стабильным, но он не точен, учитывая разные значения операндов :)
MSN
3
@ 6502: они отсортированы по порядку величины, поэтому в конце стоит -1. Если истинное значение суммы равно 1, то это нормально. Если вы сложите вместе три значения: 1 / миллиард, 1 и -1, тогда вы получите 0, и тогда вам нужно будет ответить на интересный практический вопрос - нужен ли вам ответ, точный в масштабе истинная сумма, или вам нужен только ответ, который точен в масштабе самых больших значений? Для некоторых практических приложений последнего достаточно, но когда это не так, вам нужен более сложный подход. Квантовая физика использует перенормировку.
Стив Джессоп,
8
Если вы собираетесь придерживаться этой простой схемы, я бы всегда складывал два числа с наименьшей величиной и снова вставлял сумму в набор. (Что ж, вероятно, здесь лучше всего подойдет сортировка слиянием. Вы можете использовать часть массива, содержащую ранее суммированные числа, в качестве рабочей области для частичных сумм.)
Нил
2
@Kevin Panko: Простая версия состоит в том, что число с плавающей запятой одинарной точности состоит из 24 двоичных цифр, наибольшая из которых является наибольшим установленным битом числа. Итак, если вы сложите два числа, которые отличаются по величине более чем на 2 ^ 24, вы полностью потеряете меньшее значение, а если они отличаются по величине на меньшую степень, вы потеряете соответствующее количество бит точности меньшего число.
Стив Джессоп,
88

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

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

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

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

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 прекрасное дополнение к этой теме. Любой компилятор, который «нетерпеливо оптимизирует» эти операторы, должен быть запрещен.
Крис А.
1
Это простой способ почти удвоить точность, с помощью двух переменных суммирования sumи cразличающихся величиной. Его можно тривиально расширить до N переменных.
MSalters
2
@ChrisA. ну, вы можете явно контролировать это на всех подсчитываемых компиляторах (например, через -ffast-mathGCC).
Конрад Рудольф
6
@Konrad Rudolph благодарит за указание, что это возможная оптимизация с -ffast-math. Из этого обсуждения и этой ссылки я узнал , что если вы заботитесь о числовой точности, вам, вероятно, следует избегать использования -ffast-mathэтого во многих приложениях, где вы можете быть привязаны к процессору, но не заботитесь о точных численных вычислениях (например, программирование игр ), -ffast-mathразумно использовать. Таким образом, я хотел бы внести поправки в свой строго сформулированный «запрещенный» комментарий.
Крис А.
Использование переменных двойной точности для sum, c, t, y. Вам также нужно добавить sum -= cдо return sum.
Г. Коэн
34

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

#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 на floats в приведенном выше коде, я получу:

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 (но второй чуть ближе).

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

#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 на floats в приведенном выше коде, я получу:

Kahan sum  = 2.000000000000000    (difference = 0.000000000000000)

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

Эндрю Штайн
источник
Мое «большое» значение равно 1, а не 1e9. Ваш второй ответ, добавленный в порядке возрастания размера, математически верен (1 миллиард плюс миллиардная миллиардная, это 1 миллиард и 1), хотя, к счастью, любая общая надежность метода :-) Обратите внимание, что doubleэто не плохо потеря точности при сложении миллиардных долей, поскольку он имеет 52 значащих бита, тогда как IEEE floatимеет только 24 и будет.
Стив Джессоп,
@ Стив, моя ошибка, извинения. Я обновил пример кода до того, что вы планировали.
Эндрю Штейн
4
Кахан по-прежнему имеет ограниченную точность, но для построения убийственного случая вам нужно, чтобы как основная сумма, так и аккумулятор ошибок cсодержали значения, намного превышающие следующее слагаемое. Это означает, что слагаемое намного, намного меньше, чем основная сумма, поэтому их должно быть очень много, чтобы складывать много. Особенно с doubleарифметикой.
Стив Джессоп
14

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

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

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

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

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

NPE
источник
1
@Inverse: По-прежнему существуют обычные библиотеки. Кроме того, покупка PDF-файла в Интернете стоит 5-15 долларов (в зависимости от того, являетесь ли вы участником ACM). Наконец, DeepDyve, похоже, предлагает одолжить бумагу на 24 часа за 2,99 доллара (если вы новичок в DeepDyve, вы даже можете получить ее бесплатно в рамках их бесплатной пробной версии
NPE
2

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

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

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

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

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

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

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

fredoverflow
источник
2

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

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

Rjmunro
источник
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;
}
rcgldr
источник
Это несколько похоже на метод Малкольма 1971 или, более того, на его вариант, в котором используется показатель степени Деммеля и Хиды («Алгоритм 3»). Есть другой алгоритм, который выполняет цикл на основе переноса, как ваш, но я не могу его найти в данный момент.
ZachB
@ZachB - концепция аналогична сортировке слиянием снизу вверх для связанного списка , который также использует небольшой массив, где array [i] указывает на список с 2 узлами ^ i. Я не знаю, как далеко это зашло. В моем случае это было открытие еще в 1970-х годах.
rcgldr
-1

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

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

скряга729
источник
1
«Это даст вам больше точности, чем любой другой метод». Понимаете ли вы, что ваш ответ приходит более чем через год после более позднего ответа, который описывал, как использовать точное суммирование?
Паскаль Куок
Тип "длинный двойной" ужасен, и вы не должны его использовать.
Джефф
-1

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

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

но

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

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

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

КОАД
источник