Как стать автором
Обновить

Комментарии 62

в целом округлять таким образом не стоит, потому-что во многих языках нет гарантии, что 6.5 — это на самом деле 6.5, а не, например 6.4999999999999 за счет погрешности округления. Ну вернее в этом случае гарантия есть, но в других может и не быть, так-что старое доброе round(x+0.0000001) работает во всех (почти) случаях

6,5 — это всегда 6,5. И десятичном виде, и в двоичном.

Видимо имелось ввиду поведение JS в некоторых ситуациях после проведения мат операций.
Есть `BigInt`, который решает эту проблему. Если точность операции критична — нужно использовать сторонние библиотеки или делать свое решение, если в языке его нет.
Вы явно не сталкивались с особенностями операций над числами с плавающей запятой. Это касается любых языков, потому как выполняется все на одних и тех же процессорах. Порой, после ряда операций, из-за ограниченной точности, вы можете получить как раз описанные 6.4999999999999, вместо 6.5. Усугубляется все тем, что функции преобразования числа в строку (в delphi, например) могут показать вам 6,5. Поэтому же просто сравнивать числа с плавающей запятой не рекомендуется. Всегда стоит учитывать некую погрешность.

Как вот это вот всё что вы сказали, включая ad hominem к предыдущему комментатору, опровергает утверждение что 6.5 — это ровно 6.5 и в десятичном виде и в двоичном?

>>> type(x)
<type 'float'>
>>> print x
6.5
>>> print x==6.5
False

Вы всё ещё уверены, что 6.5 — это всегда 6.5?

[redacted]

В вашем примере не показано чему равен x.

Есть IEEE 754 вполне недвусмысленно описывающая работу чисел с плавающей точкой. Процессоры ей следуют. В рамках IEEE 754
13/2=6.5
5+1.5=6.5

Also, утверждение было про десятичную и двоичную системы счисления, а не про «как print в питоне неизвестно какой версии, скорее всего 2.х, печатает float'ы».

Исходное утверждение было «6,5 — это всегда 6,5». У него есть два возможных уточнения:
1) что там «под капотом» — ну да, IEEE754;
2) что можно увидеть в логе, наступив на эти грабли.


Я помнил эти грабли из питона, но, оказывается, их в третьем действительно убрали. Ну, есть же в вебе два других популярных языка, в которых старые грабли многочисленны и бережно сохраняемы — и один из них превзошёл мои ожидания:


php > echo PHP_VERSION;
7.3.8
php > $x = ((6 + 0.1 * 3 + 0.2) - 6.2 + 0.2) * 13;
php > echo '$x='.$x.', $x<6.5: '.($x<6.5 ? 'yes': 'no').', round($x)='.round($x);
$x=6.5, $x<6.5: yes, round($x)=7
чтобы восстановить это, можете сделать нечто в духе 6,5 — 0,0000000001 + 0,0000000001 или что-то такое. Идея в том, что вы никогда не знаете, как числа будут округляться в памяти, с учетом того, что помимо ограничений мантисы, в языках есть внутренние оптимизации.

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

отлично. Но вам не кажется, что в высокоуровневом коде на языке c# не стоит опираться на специальные флаги транслятора и правила стандарта, который знаете только вы?

Я не понял, в чём именно вопрос.
а) Если дошло до такой задачи, где важны тонкости округления, то, может, стоит брать не C#, а Фортран?
или
б) С чего бы "высокоуровневым" языкам давать программисту возможность выбора, каким образом математические выражения в них транслируются в машинный код и какие оптимизации при этом могут применяться?


Моё мнение как раз в том, что в (б) — с того, чтобы вопрос (а) не возникал. Не особо убудет от разработчиков компилятора, если будет флаг, при включении которого x + 1e-10 - 3e-10 не будет автоматически оптимизироваться до x - 2e-10, а float + int + float + int не будет преобразовываться в (float + float) + (int + int) для параллелизации сложений. А кому-то контроль на этом уровне может внезапно оказаться нужен.

мне кажется, что вместо того, чтобы вдаваться в такие не очевидные подробности, можно просто писать round(x+0.0000001) и не париться

А что делать, если x больше 109, и добавление к нему 10-7 его не меняет, т.к. это за гранью двойной точности?


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

Кнокретно 6.5 может и всегда 6.5, но вообще, это правило не всегда работает тыц

Какое "это" правило?

Кнокретно 6.5 может и всегда 6.5

Если это именно 6.5, а не почти оно:
>>> ((6 + 0.1 * 3 + 0.2) - 6.2 + 0.2) * 13
6.499999999999998
>>> ((6 + 0.1 * 3 + 0.2) - 6) * 13
6.5
Если число не является 6.5, то оно не является 6.5. Логично, капитан.

Обобщим: есть числа, представимые с конечной точностью в некоей системе счисления, а есть непредставимые. Компьютеры обычно используют двоичную систему счисления. Память компьютеров штука не бесконечная. Поэтому не все числа компьютер может хранить без потери точности.
Дело в том, что «банковское» округление работает чуть хитрее — оно округляет число до ближайшего четного целого числа, а не до ближайшего целого по модулю

Это же только когда дробная часть в точности равна 1/2, и ближайших целых два равноудалённых.
Вполне логично, кстати, особенно когда цифры сначала округляются до десятых, а потом решаешь, что точности до целых уже достаточно. "Школьный" способ — при дробной части 1/2 округлять с повышением модуля — даёт, что какое-нибудь 3.48 округляется сначала до 3.5, а потом до 4. "Банковский", конечно, тоже, но также есть вероятность, что рядом 6.54 округлилось сначала до 6.5, а затем до 6. То есть "в среднем", действительно, можно ожидать более "несдвинутое" (unbiased) округление.
Математически, кстати, тоже логично: если двоичная дробная часть равна 0.12, то округляем не до 20, где есть неоднозначность, а до 21.

Мне тут больше понравилось «случайное округление». Чисто интуитивно почему-то кажется, что оно будет давать более нормальное распределение, чем банковское… Хотя математики наверняка уже всё просчитали :)

Не нормальное, а равномерное :)


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

Да, описался слегка :) А какого рода претензии могут возникнуть с функции, которая делает что-либо «якобы» случайно. Я к тому, что её случайность и есть желаемый результат работы, причем тут её «чистота» (независимости и т.д.)?

При том, что при использовании ГСЧ результат функции перестаёт быть воспроизводимым, нарушается условие ∀x f(x) == f(x)

Я скорее про то, как это может выкатиться боком на практике? Ибо получение не воспроизводимого рандомного результата «направления» округления и есть ожидаемый результат. Нарушение ∀x f(x) == f(x) в данном случае умышленное.
Я скорее про то, как это может выкатиться боком на практике?

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

Никаких, при условии, что функция round() из стандартной библиотеки работает детерминированно.
Иначе, как уже заметили, будет много радости при отладке и объяснении пользователям, почему одни и те же входные данные им дают разные ответы.

За 5 лет программирования на проде (признаю, немного, да, но опыт какой-то есть), ни разу не пришлось округлять числа. Гораздо полезнее были предсказуемые операции вроде floor/ceil. Товарищи, а кто вообще хоть раз округлением занимался, расскажите, зачем! Правда, интересно :)
Ну а как же погрешности? floor/ceil дадут большую совокупную при округлении к целым
Если мы боимся совокупных погрешностей, то проще вести вычисления в числах с плавающей точкой, и округление производить один раз при выдаче результата.
floor/ceil чаше применяются для метрических расчетов чем для бухгалтерских и теоретических. В частности схема
i = floor(x);
f = x - i;

используется чаще в программировании.
На сумму начислить % и округлить до копеек.

Банки тут ни при чем, округление до чётного используется потому что оно более стабильно и не даёт "дрейфа" при сложении и умножении. Его ещё Кнут рекомендовал.


Обычное округление:


5 + 0.5 = 5.5 ≅ 6
6 - 0.5 = 5.5 ≅ 6
6 + 0.5 = 6.5 ≅ 7

Округление до чётного:


5 + 0.5 = 5.5 ≅ 6
6 - 0.5 = 5.5 ≅ 6
6 + 0.5 = 6.5 ≅ 6
Правильно. Банковское Округление — это Округление до ближайшего четного.
Если бы ещё кто пояснил, что такое «дрейф» и какой от него вред.
А, продолжая ваш пример с «окрулением до чётного»,
5 + 0.5 = 5.5 ≅ 6
5 - 0.5 = 4.5 ≅ 4

Никакой стабильности…

Дрейф — это когда выражение "x + y — y + y — y + y — y + ..." уходит довольно далеко от изначального числа x.

В этом выражении нет округлений. Если предположить округление после каждого действия, то вроде понятно, о чём речь.
Гораздо интересней решения проблем, появляющихся при округлении.
Например, классика:
есть некоторые ежедневные значения.
каждый месяц их складываем и округляем (например отбрасываем копейки любым методом — банковским /к четному целому).
в конце года округляем сумму ежедневных значений за год.
А теперь сравниваем с суммой за 12 месяцев — с большой вероятностью они будут разные.
А в чем, собственно, проблема? Вы же сами округлили числа. Округление это уменьшение точности и точность, на которую вы уменьшили число, зависит от числа. Вы уменьшили точность разных чисел и хотите, чтобы их сумма совпала?
Я это понимаю, но с другой стороны, бухгалтерам не нужна точность в тысячные доли копеек, однако эти доли нужно учитывать в итоговых суммах по разным периодам.
Так это же классика. Погрешность обязательно становится проблемой, если она накапливается. Накопление погрешности — это то, чего следует избегать в первую очередь, когда дело касается каких-то расчётов.
В описанном вами случае, очевидно, что округлять следует только конечный результат. Если бухгалтерам не нужна точность до тысячных долей, то их можно отбросить в каком-то отчёте, или документе. Но отчёт за более долгий период не должен быть суммой других отчётов, которые были округлены. Он должен считаться по исходным данным.
Так это же классика.

Так в стартовом сообщении я так и написал про классику.

Он должен считаться по исходным данным.

Да, так и делается. Но проблема в другом — у бухгалтеров есть уже распечатанные ежемесячные отчеты с некоторыми округленными суммами, так они хотят, чтобы они также сходились с годовым, как в программе (которая округляет исходные данные), так и при подсчете на калькуляторе округленных заранее ежемесячных сумм.

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

«бухгалтерия хочет», а очень даже «налоговая хочет»

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

И где такой взять бухгалтеру? Выше уже ответили, что с понижением точности, будет расти разность — это математика. Можно нагородить костылей, но на то они и костыли, что где-то можно не учесть/забыть и опять все развалится.
При проверке банков у нас, я слышал, что ЦБ дает поле для маневра в несколько тысяч на дельту. И самый простой способ — это договориться, что несколько копеек/рублей вполне допустимая разница.
И где такой взять бухгалтеру?

Его должны были ему научить...


Выше уже ответили, что с понижением точности, будет расти разность — это математика.

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

Прелестно
Бывают случаи когда MidpointRounding.AwayFromZero не помогает :)
Например, 150.515 внезапно округляется до 150.51, даже если указать MidpointRounding.AwayFromZero.
Выход тут только преобразование в decimal, так как double точности не хватает и в памяти оно выглядит не просто как 150.515.
С float ещё хуже.
Зря в первый коммент минус кинули. Бывают сюрпризы.

Ну да, если говорить об округлении не до целых, то всё немного сложнее

При округлении до целых это возникает про бОльших числах: попробуйте в Javascript получить число 9007199254740993 ¯\_(ツ)_/¯

Почему бы не использовать старый добрый способ с (int)(x + 0.5 + 1e-6)? В реальных задачах почти не бывает кейсов, когда нужно округлять числа типа 6.4999999

И вроде как незнание этой мелочи сильно не сказывается на вашей работе, но как только вам приходится работать со статистикой и расчетами показателей, базирующихся на куче всяких записей и циферок эта штука вылазит боком

Я вот тут совсем не понял аргументацию автора. Как банковское округление может «вылезти боком» при работе со статистикой и расчетами показателей, базирующихся на куче всяких записей, если это как раз тот случай, где его необходимо использовать. Математическое округление при работе с крупными массивами данных приводит к некорректным итоговым суммам, и как раз для этого и было придумано банковское округление, которое дает статистически верный результат на массивах данных :)
decimal — не есть тип с плавающей точкой. float и double — да, decimal — нет
decimal — не есть тип с плавающей точкой

Спецификация C# с Вами не согласна.
docs.microsoft.com/ru-ru/dotnet/api/system.decimal
Represents a decimal floating-point number.
Для меня самая насущная проблема в округлении — это получение корректных бухгалтерских отчетов в «тысячах» рублей.
Для примера: есть три операции и итог: 3 333.33 + 3 333.34 + 3 333.33 = 10 000.00
Делим на тысячу, округляем, получаем: 3.3 + 3.3 + 3.3 = 10.0. Как-то глупо.
Пробуем еще: 3.3 + 3.3 + 3.3 = 9.9. Тоже глупо, потому что отчет проверяется через сальдо счета 10 000.00, а отчет показывает 9.9.

Значит надо добиться 3.4 + 3.3 + 3.3 = 10.0.
Таких функций округления уже нет.
Такое огругление пока делаю только руками в каких-то простых случаях. Считаю разницу, и добавляю её в первую/последнюю строку.

Или даже в этом простом подходе есть свои дырки, например если очень много мелких сумм, то разница целого отчета может дорасти до рублей и добавлять её в случайную строку — тоже глупо, потому что сумма отдельной строки может измениться до не узнаваемости. Значит надо размазывать её равномерно среди всех строк.

А если случаи сложные, например иерархия по вертикали с промежуточными итогами + месяцы по горизонтали с итогом по году, плюс суперитог в правом нижнем углу, то я не берусь такое «округлять», отвечаю дайте методику или делайте отчет без тысяч.

Есть какие-то универсальные подходы?
ну например хранить и выполнять все вычисления в копейках, то есть целых числах. А уже предьявлять пользователю в рублях с копейками.

В 1998ом году когда была деноминация необходимо было конвертировать базу данных бухгалтерских операций в новый формат. При этом чтобы итоговые суммы сошлись до копейки. Ни один из способов округления не позволял этого сделать, т.к. операций было очень много на мелкие суммы, и в итоге набегало несколько копеек разницы со старыми итогами (при округлении терялся один десятичный разряд). Пришлось писать свое округление, считать разницу в итоговой сумме и случайным образом добавлять по одной копейке до тех пор, пока разница с итогом не исчезнет…
Тоже столкнулся с этой особенностью .NET. Делал отчеты и данные брал из SQL Server, а он использует арифметичное округление, а .NET банковское. Часть данных округлялась в базе, другая часть округлялась в коде C#. В итоге результаты отчетов различались в 100 000 евро. Пришлось объяснять финансистам откуда берутся эти 100 000 евро, они естественно даже не слышали что есть другой метод округления. Было неприятно.
Отлично, спасибо большое за информацию, новичкам пригодится
И напоследок пару слов о приведении типов:

var number = 6.9;
var intNumber = (int)number;

В этом примере я привожу тип с плавающей запятой (double в данном случае) к целочисленному int. Так вот, при приведении типов к целочисленному вся не целая часть просто отсекается.


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

public static int ToInt32(float inputValue)
{ return (int)(inputValue * 100); }

Откопал чей-то старый код перевода одной валюты в другую, а нём вот такое округление:
        Public Function MyRound(val As Double) As Double
            Dim temp As Double
            ' Get fractional part
            temp = (val - Math.Truncate(val)) * 100                                 
            temp = Math.Truncate(temp - Math.Truncate(temp)) * 1000
            ' Check if number >= x.xx499
            If temp >= 499 Then                                                     
                ' Do a little rounding hack
                Return Math.Round(val + 0.0011, 2, MidpointRounding.AwayFromZero)   
            Else
                ' Use standard rounding routine
                Return Math.Round(val, 2, MidpointRounding.AwayFromZero)            
            End If
        End Function

Сейчас не нашёл на msdn той самой статьи, где прямым текстом указана .NET особенность (баг/фича), при которой что-то вроде 0.2354 окугляется не туда, куда показано в основной статье. А вот такой «школьный» костыль вполне справлялся и чётко обрабатывал числа вроде 6.499999999999998 рукописные и вычисленные.
Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории