Перейти к содержимому

Переполнение точности в Python

Переполнение точности в Python

Введение

Каждый программист рано или поздно сталкивается с загадочным поведением чисел в компьютере. Почему 0.1 + 0.2 не равно 0.3? Почему финансовые расчёты иногда дают странные результаты? Ответ кроется в понимании того, как компьютеры хранят и обрабатывают числа.

Классическая проблема: числа с плавающей точкой

1.1. Неожиданный результат

# Попробуйте выполнить это в интерпретаторе Python
print(0.1 + 0.2)
# Ожидаем: 0.3
# Получаем: 0.30000000000000004

print(0.1 + 0.2 == 0.3)
# Ожидаем: True
# Получаем: False

Это не ошибка Python — это фундаментальная особенность представления чисел в компьютере.

1.2. Почему так происходит?

Компьютеры используют двоичную систему для хранения чисел. Число 0.1 в десятичной системе превращается в бесконечную периодическую дробь в двоичной:

0.1₁₀ = 0.0001100110011001100110011...₂

Так как память ограничена (64 бита для float по стандарту IEEE 754), дробь обрезается, что приводит к потере точности.

1.3. Структура числа с плавающей точкой

64-битное число float (double precision) состоит из:

┌─────────┬─────────────┬──────────────────────────────────────────────┐
│  Знак   │  Экспонента │                  Мантисса                    │
│  1 бит  │   11 бит    │                  52 бита                     │
└─────────┴─────────────┴──────────────────────────────────────────────┘

Это обеспечивает примерно 15-17 значащих десятичных цифр точности.

Типичные ловушки и как их избежать

2.1. Сравнение чисел с плавающей точкой

Неправильно:

result = 0.1 + 0.2
if result == 0.3:
    print("Равны")  # Никогда не выполнится

Правильно — используйте допуск (epsilon):

import math

result = 0.1 + 0.2

# Способ 1: math.isclose()
if math.isclose(result, 0.3):
    print("Равны")  # Работает!

# Способ 2: явный допуск
epsilon = 1e-9
if abs(result - 0.3) ‹ epsilon:
    print("Равны")  # Работает!

# Параметры math.isclose
math.isclose(a, b, rel_tol=1e-9, abs_tol=0.0)
# rel_tol — относительный допуск
# abs_tol — абсолютный допуск

2.2. Накопление ошибок

При многократных операциях ошибки накапливаются:

# Накопление ошибок в цикле
total = 0.0
for _ in range(1000):
    total += 0.1

print(total)          # 99.9999999999986
print(total == 100)   # False

# Решение: суммирование Кэхэна или math.fsum
import math

values = [0.1] * 1000
print(math.fsum(values))  # 100.0 — точный результат!

2.3. Потеря значимости при вычитании

# Катастрофическая потеря значимости
a = 1.0000000000000001
b = 1.0000000000000000

print(a - b)  # 0.0 — потеряли всю точность!

# Другой пример
x = 1e16
y = 1e16 + 1

print(y - x)  # 0.0 — единица "потерялась"

Уникальность Python: целые числа произвольной точности

В отличие от большинства языков, целые числа в Python не переполняются:

# В C/C++ это вызвало бы переполнение
huge_number = 10 ** 1000
print(len(str(huge_number)))  # 1001 цифра — никаких проблем!

# Факториал больших чисел
import math
factorial_1000 = math.factorial(1000)
print(len(str(factorial_1000)))  # 2568 цифр

# Точная арифметика с большими числами
a = 2 ** 1000
b = 2 ** 1000 + 1
print(b - a)  # 1 — точный результат

Python автоматически выделяет память для хранения сколь угодно больших целых чисел. Однако это имеет свою цену:

import sys

# Размер целых чисел в памяти
print(sys.getsizeof(0))           # 24 байта
print(sys.getsizeof(1))           # 28 байт
print(sys.getsizeof(10**100))     # 72 байта
print(sys.getsizeof(10**1000))    # 464 байта

Модуль decimal: точные десятичные вычисления

4.1. Базовое использование

from decimal import Decimal, getcontext

# Создание из строки — точное представление
a = Decimal('0.1')
b = Decimal('0.2')
c = Decimal('0.3')

print(a + b)        # 0.3
print(a + b == c)   # True — наконец-то!

# ВНИМАНИЕ: создание из float сохраняет ошибку!
bad = Decimal(0.1)
print(bad)  # 0.1000000000000000055511151231257827...

good = Decimal('0.1')
print(good)  # 0.1

4.2. Управление точностью

from decimal import Decimal, getcontext, ROUND_HALF_UP

# Установка глобальной точности
getcontext().prec = 50  # 50 значащих цифр

# Вычисление с высокой точностью
result = Decimal(1) / Decimal(7)
print(result)
# 0.14285714285714285714285714285714285714285714285714

# Округление
price = Decimal('19.995')
rounded = price.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
print(rounded)  # 20.00

4.3. Финансовые расчёты

from decimal import Decimal, ROUND_HALF_UP

def calculate_total(prices: list[str], tax_rate: str) -› Decimal:
    """Расчёт суммы с налогом."""
    subtotal = sum(Decimal(p) for p in prices)
    tax = subtotal * Decimal(tax_rate)
    tax = tax.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
    return subtotal + tax

prices = ['19.99', '5.50', '3.25']
total = calculate_total(prices, '0.08')  # 8% налог
print(f"Итого: ${total}")  # Итого: $31.04

4.4. Контексты для изоляции настроек

from decimal import Decimal, localcontext

# Глобальная точность остаётся неизменной
with localcontext() as ctx:
    ctx.prec = 5
    result = Decimal('1') / Decimal('3')
    print(result)  # 0.33333

# За пределами контекста — стандартная точность
result = Decimal('1') / Decimal('3')
print(result)  # 0.3333333333333333333333333333

Модуль fractions: рациональные числа

Для точной работы с дробями:

from fractions import Fraction

# Создание дробей
a = Fraction(1, 3)
b = Fraction(1, 6)

# Точные вычисления
print(a + b)        # 1/2
print(a * 3)        # 1
print(a - b)        # 1/6

# Из строки
f = Fraction('3.14159')
print(f)            # 314159/100000

# Из float (сохраняет ошибку представления!)
f_bad = Fraction(0.1)
print(f_bad)        # 3602879701896397/36028797018963968

# Ограничение знаменателя
f = Fraction(0.1).limit_denominator(100)
print(f)            # 1/10 — то, что ожидали

NumPy и научные вычисления

6.1. Типы данных NumPy

import numpy as np

# Фиксированные типы — могут переполняться!
a = np.array([127], dtype=np.int8)
print(a + 1)  # [-128] — переполнение!

b = np.array([255], dtype=np.uint8)
print(b + 1)  # [0] — переполнение!

# Безопасный способ
c = np.array([127], dtype=np.int64)
print(c + 1)  # [128] — нормально

6.2. Специальные значения

import numpy as np

# Бесконечность
print(np.float64(1e308) * 10)  # inf

# "Не число"
print(np.float64(0) / 0)       # nan (с предупреждением)

# Проверка специальных значений
x = np.inf
print(np.isinf(x))  # True
print(np.isfinite(x))  # False

y = np.nan
print(np.isnan(y))  # True
print(y == y)       # False — NaN не равен самому себе!

6.3. Повышение точности

import numpy as np

# float128 для повышенной точности (если поддерживается)
a = np.float128('0.1')
b = np.float128('0.2')
print(a + b)  # Более точный результат

# Для целых — выбор подходящего типа
data = np.array([1, 2, 3], dtype=np.int64)

Специальные случаи переполнения

7.1. Экспонента и логарифмы

import math

# Переполнение экспоненты
try:
    result = math.exp(1000)
except OverflowError as e:
    print(f"Ошибка: {e}")  # math range error

# Решение: логарифмическая шкала
log_result = 1000  # Работаем с логарифмом
print(f"e^1000 ≈ 10^{log_result / math.log(10):.0f}")

# Или используем специальные функции
print(math.log1p(1e-15))  # Точнее чем math.log(1 + 1e-15)
print(math.expm1(1e-15))  # Точнее чем math.exp(1e-15) - 1

7.2. Комбинаторика

import math
from functools import lru_cache

# Факториал больших чисел
n = 1000
k = 500

# Неэффективно: вычисляем огромные числа
c1 = math.factorial(n) // (math.factorial(k) * math.factorial(n - k))

# Эффективно: используем встроенную функцию
c2 = math.comb(n, k)

print(c1 == c2)  # True
print(len(str(c2)))  # 299 цифр

# Для очень больших значений — логарифмы
log_c = math.lgamma(n + 1) - math.lgamma(k + 1) - math.lgamma(n - k + 1)
print(f"log(C({n},{k})) ≈ {log_c:.2f}")

Практические рекомендации

8.1. Выбор типа данных

ЗадачаРекомендуемый тип
Финансы, деньги Decimal
Научные расчёты float / numpy.float64
Точные дроби Fraction
Подсчёт, индексы int
Криптография int (произвольной точности)

8.2. Чек-лист для надёжных вычислений

# 1. Никогда не сравнивайте float напрямую
# if a == b:
# if math.isclose(a, b):

# 2. Для суммирования используйте math.fsum
# sum(float_list)
# math.fsum(float_list)

# 3. Создавайте Decimal из строк
# Decimal(0.1)
# Decimal('0.1')

# 4. Для денег — всегда Decimal с округлением
# price.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)

# 5. Проверяйте граничные случаи
# if math.isfinite(result):

8.3. Отладка проблем с точностью

def debug_float(value: float) -› None:
    """Диагностика числа с плавающей точкой."""
    from decimal import Decimal
    import struct

    print(f"Значение: {value}")
    print(f"repr(): {repr(value)}")
    print(f"Точное: {Decimal(value)}")

    # Бинарное представление
    packed = struct.pack('d', value)
    binary = ''.join(f'{byte:08b}' for byte in packed[::-1])
    print(f"Биты: {binary[0]} {binary[1:12]} {binary[12:]}")
    print(f"      (знак) (экспонента) (мантисса)")

debug_float(0.1)

Заключение

Ключевые выводы:

  1. float неточен по своей природе — это не баг, а особенность представления чисел в памяти.
  2. Целые числа в Python безопасны — они не переполняются (но могут замедлять программу при очень больших значениях).
  3. Используйте правильный инструмент:
    • Decimal для финансов и точных вычислений
    • Fraction для работы с дробями
    • math.isclose() для сравнения float
    • math.fsum() для точного суммирования
  4. Понимайте компромиссы: точность vs производительность vs удобство.

Полезные ресурсы:

  • [Что каждый компьютерщик должен знать о представлении чисел с плавающей точкой](https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html)
  • [Документация модуля decimal](https://docs.python.org/3/library/decimal.html)
  • [PEP 327 — Decimal Data Type](https://peps.python.org/pep-0327/)

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

Конспект:
Суббота, 19 апреля 2025
Переполнение точности в Python