У якому порядку слід додати поплавці, щоб отримати найбільш точний результат?


105

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

Якщо у нас є якась функція, яка акумулює числа з плаваючою комою:

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

vє std::vector<float>, наприклад.

  • Чи було б краще сортувати ці числа, перш ніж накопичувати їх?

  • Який наказ дав би найбільш точну відповідь?

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

PS Я розумію, це, мабуть, не має нічого спільного з програмуванням у реальному світі, просто цікавим.


17
Це насправді має все спільне з програмою в реальному світі. Однак багато застосунків насправді НЕ ДУМАЮТЬ про абсолютну найкращу точність розрахунку до тих пір, поки це "досить близько". Інженерні програми? Надзвичайно важливий. Медичне застосування? Надзвичайно важливий. Масштабна статистика? Дещо менша точність прийнятна.
Zéychin

18
Будь ласка, не відповідайте, якщо ви насправді не знаєте і можете вказати на сторінку, де детально пояснюються ваші міркування. Існує вже стільки лайна щодо чисел з плаваючою комою, що летять навколо, ми не хочемо додати його. Якщо ти думаєш, що знаєш. СТОП. тому що якщо ви тільки думаєте, що знаєте, то, ймовірно, помиляєтесь.
Martin York

4
@ Zéychin "Інженерні програми? Надзвичайно важливі. Медичні програми? Надзвичайно важливі." ??? Думаю, ви були б здивовані, якби знали правду :)
BЈовић

3
@ Зейчин Абсолютна помилка не має значення. Важливо - відносна помилка. Якщо кілька сотих радіану становить 0,001%, то кого це хвилює?
BЈоviћ

3
Я дуже рекомендую це прочитання: "що повинен знати кожен вчений-інформатик з плаваючою точкою" perso.ens-lyon.fr/jean-michel.muller/goldberg.pdf
Mohammad Alaggan

Відповіді:


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

Існує також алгоритм, розроблений для такого типу операцій накопичення, який називається підсумком 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 прекрасне доповнення до цієї теми. Будь-який компілятор, який "охоче оптимізує" ці заяви, повинен бути заборонений.
Кріс А.

1
Це простий метод майже подвоїти точність, використовуючи дві змінні підсумовування sumі cрізної величини. Його можна тривіально поширити на N змінних.
MSalters

2
@ChrisA. добре, ви можете явно контролювати це на всіх компіляторах, які рахуються (наприклад, через -ffast-mathна GCC).
Конрад Рудольф

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 (але друга трохи ближче).

Використання підсумків ( 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)

І навіть якщо я зміню doubles на floats в наведеному вище коді, я отримую:

Kahan sum  = 2.000000000000000    (difference = 0.000000000000000)

Здавалося б, Кахан - це шлях!


Моє "велике" значення дорівнює 1, а не 1e9. Ваша друга відповідь, додана в порядку збільшення розміру, математично правильна (1 мільярд плюс мільярд мільярдів, це 1 мільярд і 1), хоча більше, на щастя, будь-яка загальна надійність методу :-) Зауважте, що doubleце не страждає погано втрата точності при збиранні мільярда мільярдів, оскільки вона має 52 значущі біти, тоді як IEEE floatмає лише 24 і буде.
Стів Джессоп

@Steve, моя помилка, вибачте. Я оновив приклад коду до того, що ви задумали.
Ендрю Штейн

4
Kahan все ще має обмежену точність, але для побудови випадку вбивці вам потрібна як основна сума, так і акумулятор помилок, cщоб містити значення, набагато більші, ніж наступна сума. Це означає, що підсумок набагато, набагато менший, ніж основна сума, тому для їх додавання потрібно буде дуже багато. Особливо з doubleарифметикою.
Стів Джессоп

14

Існує клас алгоритмів, які вирішують цю точну проблему, не потребуючи сортування або іншим чином переставляти дані .

Іншими словами, підсумовування можна зробити за один прохід над даними. Це також робить такі алгоритми застосовними у ситуаціях, коли набір даних не відомий заздалегідь, наприклад, якщо дані надходять у режимі реального часу та поточну суму потрібно підтримувати.

Ось реферат недавнього документу:

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

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


1
@ Inverse: Навколо є ще бібліотеки з цегли та розчину. Крім того, придбання PDF в Інтернеті коштує $ 5- 15 (залежно від того, чи є ви членом ОСБ). Нарешті, DeepDyve, здається, пропонують позичити папір на 24 години за $ 2,99 (якщо ви новачок у DeepDyve, ви, можливо, зможете отримати його безкоштовно як частину їхньої безкоштовної пробної версії): deepdyve.com/lp/acm /…
NPE

2

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

  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

Це не зовсім відповідає вашому запитанню, але розумна річ - це запустити суму двічі, один раз в режимі округлення «закруглити» і один раз - «вниз». Порівняйте дві відповіді, і ви знаєте / як / неточні ваші результати, і якщо вам потрібно використовувати розумнішу стратегію підбиття підсумків. На жаль, більшість мов не дозволяють змінити режим округлення плаваючої точки так просто, як це має бути, тому що люди не знають, що це насправді корисно в повсякденних обчисленнях.

Погляньте на арифметику Інтервалу, де ви займаєтесь такою математикою, зберігаючи найвищі та найнижчі значення під час руху. Це призводить до деяких цікавих результатів та оптимізацій.


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

Це звучить дещо як метод Малкольма 1971 року, або, тим більше, його варіант, який використовує показник Деммеля та Хіда ("Алгоритм 3"). Існує ще один алгоритм, який робить цикл на основі несучої, як ваш, але наразі не можу його знайти.
ZachB

@ZachB - концепція схожа на сортування об'єднаного списку знизу вгору для зв’язаного списку , який також використовує невеликий масив, де масив [i] вказує на список із 2 ^ i вузлами. Я не знаю, як далеко це йде. У моєму випадку це було самовідкриття ще в 1970-х.
rcgldr

-1

Ваші поплавці повинні бути додані в подвійній точності. Це дасть вам більшу точність, ніж будь-яка інша техніка. Для трохи більшої точності та значно більшої швидкості ви можете створити, наприклад, чотири суми, і додати їх наприкінці.

Якщо ви додаєте подвійні цифри точності, використовуйте довгі подвійні для суми - однак, це матиме лише позитивний ефект у реалізаціях, коли довгий подвійний насправді має більшу точність, ніж подвійний (зазвичай x86, PowerPC залежно від налаштувань компілятора).


1
"Це дасть вам більшу точність, ніж будь-яка інша методика". Чи усвідомлюєте ви, що ваша відповідь наступає через рік після більш пізньої відповіді, в якій описано, як використовувати точне підсумовування?
Паскаль Куок

Тип "довгий подвійний" жахливий, і вам не слід його використовувати.
Джефф

-1

Що стосується сортування, то мені здається , що якщо ви очікуєте скасування , то цифри повинні бути додані в низхідному порядок, не піднімається. Наприклад:

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

але

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

У першому рівнянні дві великі числа скасовуються, тоді як у другому термін 1e-20 втрачається при додаванні до 1, оскільки недостатньо точності для його збереження.

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

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.