Shanraq.org Shanraq.org
Числа в отчёте: форматы, округление и единицы
IT

Python: от данных до своей сводки Урок 36 из 56

Числа в отчёте: форматы, округление и единицы

Тридцать пятый урок курса по Python. `1234567.891` и `1 234 567,89` — одно число и два разных отчёта. Разбираем формат-строки, разделители по-нашему, `round`, который округляет к чётному, `Decimal` для денег и сумму округлённых, которая не равна округлённой сумме.

Зачем это нужно

Отчёт собран, страница открывается, а в ячейке стоит 11.539999999999999. Или 1234567.891 — число, которое читатель будет считать глазами по три цифры, и всё равно ошибётся.

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

Сразу целиком

Файл chisla.py.

"""Урок 35: числа в отчёте.

Одно и то же число, показанное семью способами, и три ловушки округления,
из-за которых отчёт перестаёт сходиться.
"""

from decimal import Decimal, ROUND_HALF_UP

amount = 1234567.891

print("== одно число, семь видов")
print("как есть:        ", amount)
print("два знака:       ", f"{amount:.2f}")
print("с разделителем:  ", f"{amount:,.2f}")
print("по-нашему:       ", f"{amount:,.2f}".replace(",", " ").replace(".", ","))
print("в миллионах:     ", f"{amount / 1_000_000:.1f} млн")
print("со знаком:       ", f"{amount:+,.0f}")
print("в колонке шириной 16:", f"|{amount:>16,.2f}|")

print()
print("== доля: процент — это формат, а не умножение")
share = 0.1274
print("руками:", round(share * 100, 1), "% | форматом:", f"{share:.1%}")

print()
print("== ловушка первая: round округляет к чётному")
for value in (0.5, 1.5, 2.5, 3.5):
    print(f"  round({value}) = {round(value)}")
print("  это не ошибка, а правило: половинки идут к чётному")

print()
print("== ловушка вторая: для денег берут Decimal")
money = Decimal("2.675")
print("  round(2.675, 2) =", round(2.675, 2), "— двоичная дробь не равна 2.675")
print("  Decimal с ROUND_HALF_UP =", money.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))

print()
print("== ловушка третья: сумма округлённых ≠ округлённая сумма")
rows = [10.005, 10.005, 10.005, 10.005]
rounded = [round(value, 2) for value in rows]
print("  строки:", rounded)
print("  сумма округлённых:", round(sum(rounded), 2))
print("  округлённая сумма:", round(sum(rows), 2))
print("  расхождение:", round(sum(rows) - sum(rounded), 2))

Выводит:

== одно число, семь видов
как есть:         1234567.891
два знака:        1234567.89
с разделителем:   1,234,567.89
по-нашему:        1 234 567,89
в миллионах:      1.2 млн
со знаком:        +1,234,568
в колонке шириной 16: |    1,234,567.89|

== доля: процент — это формат, а не умножение
руками: 12.7 % | форматом: 12.7%

== ловушка первая: round округляет к чётному
  round(0.5) = 0
  round(1.5) = 2
  round(2.5) = 2
  round(3.5) = 4
  это не ошибка, а правило: половинки идут к чётному

== ловушка вторая: для денег берут Decimal
  round(2.675, 2) = 2.67 — двоичная дробь не равна 2.675
  Decimal с ROUND_HALF_UP = 2.68

== ловушка третья: сумма округлённых ≠ округлённая сумма
  строки: [10.01, 10.01, 10.01, 10.01]
  сумма округлённых: 40.04
  округлённая сумма: 40.02
  расхождение: -0.02

Разбор

Формат-строка: что стоит после двоеточия

f"{amount:,.2f}" — внутри фигурных скобок после двоеточия описано, как показать число:

Запись Что делает
.2f два знака после точки, всегда
, разделитель тысяч
+ знак даже у положительного
>16 выравнивание вправо по ширине 16
.1% доля как процент: 0.12712.7%
,.0f целое с разделителем

Всё это — часть языка, а не библиотека, и работает одинаково в print, в f-строке и в format().

Разделители: у нас не как по умолчанию

Python по умолчанию пишет 1,234,567.89 — запятая в тысячах, точка в дробях. У нас наоборот: пробел в тысячах, запятая в дробях. Отсюда приём из примера:

f"{amount:,.2f}".replace(",", " ").replace(".", ",")

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

Пробел лучше брать неразрывный (\u00a0): обычный пробел браузер переносит на другую строку, и 1 234 может разъехаться по разным строчкам таблицы.

Есть модуль locale, который умеет это сам, но он настраивается на весь процесс сразу и зависит от того, какие локали стоят в системе, — в программе, которая работает на сервере, это чаще мешает, чем помогает. Три replace честнее.

Главное правило: единица измерения живёт в заголовке столбца, а не в каждой ячейке. сумма, ₸ сверху и чистые числа под ним читаются, а 1 240 500,00 ₸ в каждой строке — нет.

round округляет к чётному

Первая ловушка, и она не ошибка Python: round(0.5) даёт 0, round(2.5) даёт 2, а round(1.5) и round(3.5)2 и 4. Половинки уходят к чётному, и это стандарт: при большом количестве округлений ошибка не накапливается в одну сторону.

Если нужно школьное «половина вверх», это Decimal с ROUND_HALF_UP.

Деньги — Decimal, а не float

round(2.675, 2) даёт 2.67, хотя ждали 2.68. Виноват не round, а то, что 2.675 в двоичной дроби — это чуть меньше, чем 2.675 (четвёртый урок).

Для денег берут Decimal("2.675") — от строки, не от числа, — и quantize с явным правилом округления. Правило простое: деньги считают в Decimal или в целых тиынах, а float оставляют физике и статистике.

Сумма округлённых ≠ округлённая сумма

Третья ловушка — та, из-за которой в отчёте «не сходится копейка». Четыре строки по 10.005: каждая округляется до 10.01, сумма округлённых — 40.04, а округлённая сумма — 40.02.

Что с этим делают:

  1. Считают по неокруглённым, а округляют только при выводе — тогда итог верный, но столбец в него не складывается на глаз;
  2. либо подгоняют последнюю строку: разницу дописывают в самую большую строку, чтобы столбец складывался;
  3. и в любом случае пишут в отчёте, что именно вы сделали, если расхождение может увидеть читатель.

Чего делать нельзя — считать итог по округлённым строкам и называть это суммой.

Масштаб: когда 1.2 млн лучше, чем 1 234 567

Крупные числа в отчёте для человека обычно показывают в тысячах или миллионах: 1.2 млн ₸ читается с одного взгляда, 1 234 567 ₸ — нет. Но в таблице, по которой будут сверять или складывать, оставляют полное число: округление до миллионов прячет как раз ту разницу, ради которой таблицу открыли.

Простое правило: в тексте и заголовках — крупно, в таблице — точно.

Карта урока

Карта урока: формат, округление и единицы

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Почему round(2.5) даёт 2, и ошибка ли это?
  2. Почему сумма округлённых строк может не совпасть с округлённой суммой?
  3. Где должна стоять единица измерения — в ячейке или в заголовке столбца?

Разминка

Три коротких шага перед заданием: предсказать, дописать, починить. Ответы — в конце урока, но сначала ответьте сами.

1. Предскажите. Что напечатает эта программа?

amount = 45678.4
print(f"{amount:.0f}")
print(f"{amount:,.2f}")
print(f"{amount:+.1f}")
print(f"|{amount:>12,.1f}|")
print(f"{amount / 1000:.1f} тыс.")

2. Заполните пропуск. Вместо ... приведите число к местному виду: пробел в тысячах, запятая в дробях.

# по умолчанию Python пишет 1,234,567.89
amount = 1234567.891
print(f"{amount:,.2f}"...)

3. Почините. Доля напечаталась как 1274.0%.

# процент — это формат, и он умножает сам
share = 0.1274
print(f"доля: {share * 100:.1%}")

Задание

Обязательное. Напечатайте таблицу расходов: статья, сумма, доля и изменение. Суммы — в местном виде с двумя знаками, доли — процентом с той же запятой, изменения — со знаком, а ноль изменения — прочерком. Итог посчитайте по неокруглённым значениям. В конце напечатайте проверку: сходится ли итог, чему равна сумма округлённых строк и на сколько она расходится с настоящей.

Ожидаемый вывод:

статья            сумма, ₸    доля    изменение, ₸
аренда        1 240 500,00   67,4%       62 500,00
еда             348 210,34   18,9%      -12 400,00
топливо         190 880,67   10,4%        4 300,00
связь            59 941,00    3,3%               —
итого         1 839 532,00  100,0%

проверка:
  итог посчитан до округления: True
  сумма округлённых строк: 1 839 532,01
  округлённый итог:        1 839 532,00
  расхождение: 0.01
  доли складываются в: 100,0%

Готово, когда: вывод совпадает построчно; форматирование вынесено в функции, а не повторяется в каждой строке; итог считается из исходных чисел; расхождение посчитано и напечатано, а не спрятано.

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

По желанию.

  • Замените обычный пробел на неразрывный (\u00a0) и посмотрите в браузере, что изменилось при узком окне.
  • Посчитайте те же суммы в Decimal и сравните итог с float.
  • Добавьте столбцу font-variant-numeric: tabular-nums в CSS и посмотрите, как выровнялись цифры.

Куда это встанет в проекте

Пятнадцатый шаг: у сводки появляется sholu/pishim.py — единственное место, где число превращается в текст. Три функции: number — пробел и запятая по-нашему, signed — изменение со знаком (и округлённый ноль без плюса), percent — доля с той же запятой. Пропуск во всех трёх становится прочерком.

Страница берёт форматирование оттуда по имени столбца, и в ячейках вместо 8.690822 стоит 8,69. Заодно в стилях появилась строка font-variant-numeric: tabular-nums: без неё цифры разной ширины, и столбец не выравнивается, как его ни форматируй.

Долги. Единицы у сводки живут в заголовках столбцов — орташа, ең_жоғары, — и то, что это проценты, читатель узнаёт только из заголовка страницы. Когда в отчёт придут деньги, заголовки придётся переписать так, чтобы единица стояла у каждого столбца своя.

Ответы

Показать ответы

На вопросы

  1. Потому что Python округляет половинки к чётному: 0.50, 1.52, 2.52, 3.54. Это не ошибка, а стандарт: при множестве округлений ошибка не накапливается в одну сторону. «Половина вверх» делается через Decimal с ROUND_HALF_UP.
  2. Потому что каждая строка при округлении сдвигается в свою сторону, и эти сдвиги складываются. Четыре строки по 10.005 дают 40.04 округлёнными и 40.02 — округлённой суммой. Итог считают по неокруглённым, а расхождение либо показывают, либо дописывают в самую большую строку.
  3. В заголовке столбца. Тогда числа в ячейках читаются как числа и выравниваются по разряду; повторённая в каждой строке единица мешает и глазу, и сравнению.

К разминке

  1. Каждый формат делает ровно одно: .0f убирает дробную часть, , расставляет разделители, + показывает знак, >12 выравнивает по ширине, а деление на тысячу — это уже не формат, а другая единица.
45678
45,678.40
+45678.4
|    45,678.4|
45.7 тыс.
  1. .replace(",", " ").replace(".", ",") — именно в таком порядке.
amount = 1234567.891
print(f"{amount:,.2f}".replace(",", " ").replace(".", ","))
1 234 567,89
  1. :.1% умножает на сто сам. Умножать до него — значит сделать это дважды.
share = 0.1274
print(f"доля: {share:.1%}")
print(f"или так: {share * 100:.1f} %")
доля: 12.7%
или так: 12.7 %

К заданию

Форматирование вынесено в money и percent не ради красоты: одинаковая запятая в суммах и процентах — это то, что держит таблицу единой. Стоит поменять её в одном месте — и таблица начинает выглядеть как две.

Итог считается из исходных чисел, а расхождение с суммой округлённых печатается рядом. Это и есть честный ответ на «не сходится копейка»: она не сходится по устройству арифметики, и отчёт должен об этом говорить, а не прятать.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.