Чому зміна порядку доходу повертає інший результат?


294

Чому зміна порядку доходу повертає інший результат?

23.53 + 5.88 + 17.64 = 47.05

23.53 + 17.64 + 5.88 = 47.050000000000004

І Java, і JavaScript дають однакові результати.

Я розумію, що через те, як числа з плаваючою комою представлені у двійкових, деякі раціональні числа ( наприклад, 1/3 - 0,333333 ... ) неможливо точно представити.

Чому просто зміна порядку елементів впливає на результат?


28
Сума дійсних чисел асоціативна та комутативна. Плаваючі точки не є реальними числами. Насправді ви просто довели, що їх операції не є комутаційними. Досить легко показати, що вони теж не асоціативні (наприклад (2.0^53 + 1) - 1 == 2.0^53 - 1 != 2^53 == 2^53 + (1 - 1)). Отже, так: будьте обережні, обираючи порядок сум та інших операцій. Деякі мови надають вбудований для виконання "високоточних" сум (наприклад, python math.fsum), тому ви можете розглянути можливість використання цих функцій замість алгоритму наївної суми.
Бакуріу

1
@RBerteig Це можна визначити, вивчивши порядок операцій з мовою для арифметичних виразів, і, якщо їх представлення чисел з плаваючою комою в пам’яті не буде різним, результати будуть однаковими, якщо правила пріоритетності їх оператора однакові. Ще один зауваження: мені цікаво, скільки часу знадобилося розробникам, які розробляють банківські програми, щоб це зрозуміти? Ці додаткові 0000000000004 копійок дійсно складаються!
Кріс Сірефіс

3
@ChrisCirefice: якщо у вас є 0,00000004 копійок , ви робите це неправильно. Ніколи не слід використовувати двійковий тип з плаваючою комою для фінансових розрахунків.
Даніель Приден

2
@DanielPryden Ах, на жаль, це був жарт ... просто закидання думкою про те, що люди, яким справді потрібно вирішити подібний тип проблеми, мали одну з найважливіших робочих місць, яку ви знаєте, має грошовий статус людей і все таке . Я був дуже саркастичний ...
Кріс Сірефіс

Відповіді:


276

Можливо, це питання дурне, але чому просто зміна порядку елементів впливає на результат?

Це змінить точки, в яких значення округляються, виходячи з їх величини. В якості прикладу роду речей , які ми бачать, давайте робити вигляд , що замість бінарної з плаваючою точкою, ми використовували десятковий тип з плаваючою точкою з 4 значущими цифрами, де виконуються кожне додавання в «нескінченної» точності , а потім з округленням до найближчий представницький номер. Ось дві суми:

1/3 + 2/3 + 2/3 = (0.3333 + 0.6667) + 0.6667
                = 1.000 + 0.6667 (no rounding needed!)
                = 1.667 (where 1.6667 is rounded to 1.667)

2/3 + 2/3 + 1/3 = (0.6667 + 0.6667) + 0.3333
                = 1.333 + 0.3333 (where 1.3334 is rounded to 1.333)
                = 1.666 (where 1.6663 is rounded to 1.666)

Нам навіть не потрібні цілі числа, щоб це було проблемою:

10000 + 1 - 10000 = (10000 + 1) - 10000
                  = 10000 - 10000 (where 10001 is rounded to 10000)
                  = 0

10000 - 10000 + 1 = (10000 - 10000) + 1
                  = 0 + 1
                  = 1

Це, можливо, більш чітко демонструє, що важлива частина полягає в тому, що ми маємо обмежену кількість значущих цифр - не обмежену кількість десяткових знаків . Якби ми завжди могли зберігати однакову кількість десяткових знаків, то з додаванням і відніманням принаймні, ми б добре (до тих пір, поки значення не переповнювались). Проблема полягає в тому, що коли ви потрапляєте до більшої кількості, втрачається менша інформація - в цьому випадку 10001 округляється до 10000. (Це приклад проблеми, яку Ерік Ліпперт зазначив у своїй відповіді .)

Важливо зауважити, що значення у першому рядку правого боку є однаковими у всіх випадках - тому хоча важливо розуміти, що ваші десяткові числа (23.53, 5.88, 17.64) не будуть представлені точно як doubleзначення, це лише проблема через проблеми, показані вище.


10
May extend this later - out of time right now!чекаю з нетерпінням @Jon
Prateek

3
коли я скажу, що пізніше я повернусь до відповіді, громада трохи менш добра до мене <введіть сюди якийсь легкий смайлик, щоб показати, що я жартую, а не ривок> ... повернусь до цього пізніше.
Гравець Граді

2
@ZongZhengLi: Хоча це, безумовно, важливо розуміти, але в цьому випадку це не першопричина. Ви могли б написати подібний приклад зі значеннями , які будуть представлені саме в двійковому коді, і побачити той же ефект. Проблема тут полягає у збереженні великомасштабної інформації та інформації малого масштабу одночасно.
Джон Скіт

1
@Buksy: Округлено до 10000 - оскільки ми маємо справу з типом даних, який може зберігати лише 4 значущі цифри. (так x.xxx * 10 ^ n)
Джон Скіт

3
@meteors: Ні, це не викликає переповнення - і ви використовуєте неправильні числа. Це 10001, округлений до 10000, а не 1001 округлення до 1000. Щоб зробити це більш зрозумілим, 54321 було б округлено до 54320 - тому що це лише чотири значущі цифри. Існує велика різниця між "чотирма значущими цифрами" та "максимальним значенням 9999". Як я вже говорив раніше, ви в основному представляєте x.xxx * 10 ^ n, де для 10000, x.xxx було б 1.000, а n було б 4. Це так само, doubleі floatколи для дуже великої кількості, послідовні представницькі числа є більш ніж 1 один від одного.
Джон Скіт

52

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

За допомогою цієї програми я виводить шістнадцяткові подання кожного числа та результати кожного додавання.

public class Main{
   public static void main(String args[]) {
      double x = 23.53;   // Inexact representation
      double y = 5.88;    // Inexact representation
      double z = 17.64;   // Inexact representation
      double s = 47.05;   // What math tells us the sum should be; still inexact

      printValueAndInHex(x);
      printValueAndInHex(y);
      printValueAndInHex(z);
      printValueAndInHex(s);

      System.out.println("--------");

      double t1 = x + y;
      printValueAndInHex(t1);
      t1 = t1 + z;
      printValueAndInHex(t1);

      System.out.println("--------");

      double t2 = x + z;
      printValueAndInHex(t2);
      t2 = t2 + y;
      printValueAndInHex(t2);
   }

   private static void printValueAndInHex(double d)
   {
      System.out.println(Long.toHexString(Double.doubleToLongBits(d)) + ": " + d);
   }
}

printValueAndInHexМетод просто помічник шестигранного принтера.

Вихід такий:

403787ae147ae148: 23.53
4017851eb851eb85: 5.88
4031a3d70a3d70a4: 17.64
4047866666666666: 47.05
--------
403d68f5c28f5c29: 29.41
4047866666666666: 47.05
--------
404495c28f5c28f6: 41.17
4047866666666667: 47.050000000000004

Перші 4 цифри x, y, z, і s«и шістнадцятиричні уявлення. У поданні IEEE з плаваючою точкою біти 2-12 представляють двійковий показник , тобто масштаб числа. (Перший біт - біт знака, а решта бітів для мантіси .) Представлений показник - це фактично двійкове число мінус 1023.

Експоненти для перших 4 чисел витягуються:

    sign|exponent
403 => 0|100 0000 0011| => 1027 - 1023 = 4
401 => 0|100 0000 0001| => 1025 - 1023 = 2
403 => 0|100 0000 0011| => 1027 - 1023 = 4
404 => 0|100 0000 0100| => 1028 - 1023 = 5

Перший набір доповнень

Друге число ( y) має меншу величину. Додаючи ці два числа для отримання x + y, останні 2 біти другого числа ( 01) зміщуються поза діапазоном і не враховуються в обчисленні.

Друге додавання додає x + yі zдодає два числа однакової шкали.

Другий набір доповнень

Тут, x + zвідбувається спочатку. Вони однакової шкали, але дають число, яке вище за шкалою:

404 => 0|100 0000 0100| => 1028 - 1023 = 5

Друге додавання додає x + zі y, і тепер для додавання чисел ( ) випадають 3 біти . Тут має бути кругле вгору, тому що результат - наступне число з плаваючою комою вгору: для першого набору доповнень проти другого набору доповнень. Ця помилка є достатньо істотною для відображення у роздруківці загальної кількості.y10140478666666666664047866666666667

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


Різниця ваг є важливою частиною. Ви можете записати (у десятковій точці) точні значення, які представлені у двійковій формі, як вхідні дані, і все ще мають ту саму проблему.
Джон Скіт

@rgettman Як програмісту, мені подобається, що ваша відповідь краще =)+1 для вашого помічника з шестигранним принтером ... це дійсно чудово!
ADTC

44

Відповідь Джона, звичайно, правильна. У вашому випадку помилка не більша за помилку, яку ви накопичили, виконуючи будь-яку просту операцію з плаваючою комою. У вас є сценарій, коли в одному випадку ви отримуєте нульову помилку, а в іншому - крихітну помилку; це насправді не такий цікавий сценарій. Хороше питання: чи існують сценарії, коли зміна порядку розрахунків переходить від крихітної помилки до (відносно) величезної помилки? Відповідь однозначно - так.

Розглянемо для прикладу:

x1 = (a - b) + (c - d) + (e - f) + (g - h);

проти

x2 = (a + c + e + g) - (b + d + f + h);

проти

x3 = a - b + c - d + e - f + g - h;

Очевидно в точній арифметиці вони були б однаковими. Приємно намагатися знайти значення для a, b, c, d, e, f, g, h, такі, що значення x1 і x2 та x3 відрізняються на велику кількість. Подивіться, чи можете ви це зробити!


Як ви визначаєте велику кількість? Ми говоримо на порядку 1000-х? 100-ті? 1
Cruncher

3
@Cruncher: Обчисліть точний математичний результат та значення x1 та x2. Назвіть точну математичну різницю між істинними та обчисленими результатами e1 та e2. Зараз існує кілька способів думати про розмір помилок. Перше: чи можна знайти сценарій, в якому чи | e1 / e2 | або | e2 / e1 | великі? Мовляв, чи можете ви зробити помилку в один десять разів більше помилки іншого? Тим цікавішим є те, якщо ви можете зробити помилку однієї значної частки від розміру правильної відповіді.
Ерік Ліпперт

1
Я розумію, що він говорить про час виконання, але мені цікаво: якщо вираз був виразом часу компіляції (скажімо, constexpr), чи є компілятори досить розумні, щоб мінімізувати помилку?
Кевін Хсу

@kevinhsu взагалі ні, компілятор не такий розумний. Звичайно, компілятор міг би зробити операцію в точній арифметиці, якщо так вирішив, але зазвичай це не так.
Ерік Ліпперт

8
@frozenkoi: Так, помилка може бути нескінченною дуже легко. Наприклад, розглянемо C #: double d = double.MaxValue; Console.WriteLine(d + d - d - d); Console.WriteLine(d - d + d - d);- вихід Infinity тоді 0.
Jon Skeet

10

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

У пам'яті плаваючі точки використовують спеціальний формат по лініях IEEE 754 (перетворювач забезпечує набагато краще пояснення, ніж я можу).

У всякому разі, ось перетворювач поплавця.

http://www.h-schmidt.net/FloatConverter/

Справа в порядку операцій - це «тонкість» операції.

Ваш перший рядок отримує 29,41 від перших двох значень, що дає нам 2 ^ 4 як показник.

Ваш другий рядок дає 41,17, що дає нам 2 ^ 5 як показник.

Ми втрачаємо значну цифру, збільшуючи показник, який, ймовірно, змінить результат.

Спробуйте позначити останній біт праворуч увімкнення та вимкнення 41,17, і ви можете побачити, що чогось такого «незначного», як 1/2 ^ 23 експонента, буде достатньо, щоб викликати різницю з плаваючою точкою.

Редагувати: Для тих, хто пам’ятає значні цифри, це підпадає під цю категорію. 10 ^ 4 + 4999 зі значущою цифрою 1 буде дорівнює 10 ^ 4. У цьому випадку значна цифра значно менша, але ми можемо побачити результати із доданим до неї .00000000004.


9

Номери з плаваючою комою представлені у форматі IEEE 754, який забезпечує певний розмір бітів для мантіси (significand). На жаль, це дає вам певну кількість «дробових будівельних блоків», з якими можна грати, і певні дробові значення не можуть бути представлені точно.

У вашому випадку відбувається те, що у другому випадку додавання, ймовірно, стикається з якоюсь точністю через порядок оцінювання доповнень. Я не обчислював значення, але може бути, наприклад, що 23,53 + 17,64 неможливо точно представити, тоді як 23,53 + 5,88 може.

На жаль, це відома проблема, з якою вам просто доводиться стикатися.


6

Я вважаю, що це стосується порядку евакуляції. Хоча сума, природно, однакова в математичному світі, у двійковому світі замість A + B + C = D, це

A + B = E
E + C = D(1)

Отже, є той вторинний крок, на якому числа з плаваючою комою можуть зійти.

Змінивши замовлення,

A + C = F
F + B = D(2)

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