Почему изменение порядка сумм возвращает другой результат?


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 центов действительно складываются!
Крис Cirefice

3
@ChrisCirefice: если у вас 0,00000004 цента , вы делаете это неправильно. Вы никогда не должны использовать двоичный тип с плавающей запятой для финансовых расчетов.
Даниэль Приден

2
@DanielPryden Ах, увы, это была шутка ... просто рассуждать о том, что люди, которым действительно нужно решить этот тип проблемы, имели одну из самых важных рабочих мест, которую вы знаете, - это денежный статус людей и все такое , Я был очень саркастичен ...
Крис Cirefice

Ответы:


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

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

1
@Buksy: округлено до 10000 - потому что мы имеем дело с типом данных, который может хранить только 4 значащих цифры. (ххххх * 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 бита отбрасываются yдля добавления чисел ( 101). Здесь должен быть округлен вверх, потому что результатом является следующее число с плавающей запятой вверх: 4047866666666666для первого набора дополнений по сравнению 4047866666666667со вторым набором дополнений. Эта ошибка достаточно значительна, чтобы показать ее в распечатке.

В заключение, будьте осторожны при выполнении математических операций над числами 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 отличались в большом количестве. Посмотри, сможешь ли ты сделать это!


Как вы определяете большое количество? Мы говорим о порядке тысячных? 100ths? 1 - х ???
Cruncher

3
@Cruncher: вычислить точный математический результат и значения x1 и x2. Назовите точное математическое различие между истинными и вычисленными результатами e1 и e2. Теперь есть несколько способов думать о размере ошибки. Первый: вы можете найти сценарий, в котором либо | e1 / e2 | или | е2 / е1 | большие? Мол, можете ли вы сделать ошибку одного в десять раз больше ошибки другого? Тем не менее, более интересным является то, что вы можете сделать ошибку в одной значительной доле от размера правильного ответа.
Эрик Липперт

1
Я понимаю, что он говорит о времени выполнения, но мне интересно: если выражение было выражением времени компиляции (скажем, constexpr), достаточно ли умны компиляторы, чтобы минимизировать ошибку?
Кевин Сюй

@kevinhsu вообще нет, компилятор не такой умный. Конечно, компилятор может выбрать выполнение операции в точной арифметике, если он того пожелает, но обычно этого не происходит.
Эрик Липперт

8
@frozenkoi: Да, ошибка может быть бесконечной очень легко. Например, рассмотрим C #: double d = double.MaxValue; Console.WriteLine(d + d - d - d); Console.WriteLine(d - d + d - d);- результат равен бесконечности, а затем 0.
Джон Скит,

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

В вашем случае происходит то, что во втором случае сложение, вероятно, приводит к некоторой проблеме точности из-за порядка, в котором оцениваются дополнения. Я не вычислял значения, но это может быть, например, то, что 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
Я думаю, что этот ответ избегает реальной причины. msgstr "есть второй шаг, где числа с плавающей запятой могут сбиться". Понятно, что это правда, но мы хотим объяснить, почему .
Zong
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.