Типы данных (dtype): точность, переполнение и приведение

Урок объясняет систему типов NumPy: какие бывают dtype, как выбирать разрядность и какие ловушки таят переполнение и приведение.

dtype — описание типа элементов массива: их категория (целое, вещественное, логическое, комплексное) и размер в байтах, единый для всего массива.

Почему у массива один общий тип

В отличие от списка Python, где каждый элемент — самостоятельный объект со своим типом, весь ndarray имеет единый dtype. Это и есть условие непрерывного хранения: раз все элементы одного размера (скажем, по 8 байт), их можно уложить подряд и вычислять адрес умножением. Универсальность теряется, скорость — приобретается.

dtype состоит из категории и разрядности. Имена говорящие: int32 — целое в 32 бита (4 байта), float64 — вещественное в 64 бита (8 байт, «double» в терминах C), uint8 — беззнаковое целое в 1 байт (0..255, классика для пикселей изображения).

КатегорияПримеры dtypeДиапазон / точность
Целые со знакомint8, int16, int32, int64int8: −128..127; int64: ±9.2·10¹⁸
Целые без знакаuint8, uint16, uint32, uint64uint8: 0..255
Вещественныеfloat16, float32, float64float64: ~15–16 значащих цифр
Комплексныеcomplex64, complex128две float-части
Логическиеbool_True / False, 1 байт

Переполнение целых: тихая катастрофа

Это самая опасная ловушка NumPy для тех, кто привык к Python. Встроенный int Python имеет неограниченную разрядность: он растёт сколько угодно. А целые NumPy фиксированной ширины: int8 хранит значения только от −128 до 127. Если результат вылезает за границу, он молча «заворачивается» по модулю — без исключения, без предупреждения. Это поведение целых фиксированной ширины, как в C.

Смоделируем «заворачивание» int8 на чистом Python, чтобы понять механику. 8 бит со знаком кодируют 256 значений в диапазоне −128..127:

def wrap_int8(x):
    # Приводим к диапазону 0..255, затем интерпретируем старший бит как знак
    x &= 0xFF                 # оставляем 8 бит
    if x >= 128:
        x -= 256              # значения 128..255 -> -128..-1
    return x

print("127 + 1  =", wrap_int8(127 + 1))    # переполнение вверх
print("100 + 50 =", wrap_int8(100 + 50))   # тоже за границу
print("-128 - 1 =", wrap_int8(-128 - 1))   # переполнение вниз

Вывод:

127 + 1  = -128
100 + 50 = -106
-128 - 1 = 127

Именно так поведёт себя массив int8 в NumPy: np.array([127], dtype=np.int8) + 1 даст -128, а не 128. Вот соответствующий код для чтения:

import numpy as np

a = np.array([127], dtype=np.int8)
print(a + 1)           # переполнение: -128, без ошибки!

big = np.array([2_000_000_000], dtype=np.int32)
print(big + big)       # int32 переполнен -> отрицательное число

Вывод:

[-128]
[-294967296]

Вывод практический: для счётчиков, сумм и индексов, которые могут вырасти, используйте достаточно широкий тип (обычно int64) и помните о границах uint8/int16 при работе с изображениями и компактными данными.

Точность float: не всё представимо

Вещественные числа хранятся в формате IEEE 754 с конечным числом бит. float64 даёт около 15–16 значащих десятичных цифр, float32 — лишь около 7. Многие «простые» дроби (например, 0.1) не представимы точно в двоичной системе, поэтому появляются крошечные погрешности. Это не баг NumPy — так устроена любая плавающая арифметика, включая обычный Python.

x = 0.1 + 0.2
print(x)                      # не ровно 0.3
print(x == 0.3)               # False!
print(abs(x - 0.3) < 1e-9)    # правильный способ сравнения

Вывод:

0.30000000000000004
False
True

Отсюда железное правило: не сравнивайте float через ==. В NumPy для этого есть np.isclose(a, b) и np.allclose(a, b), которые сравнивают с допуском. У float32 погрешности заметно крупнее, чем у float64: экономия памяти в 2 раза оборачивается падением точности — это компромисс, который выбирают осознанно (например, в нейросетях, где float32 — норма).

Почему float не «чинит» сам себя

Новичков часто удивляет, что погрешности float не накапливаются «во что-то круглое» и не исчезают сами. Причина в том, что компьютер хранит вещественные числа как сумму степеней двойки с фиксированным числом бит мантиссы. Дробь 0.1 в двоичной системе — это бесконечная периодическая дробь, как 1/3 в десятичной. Её обрезают до доступной точности, и обрезок остаётся навсегда. Каждая операция может добавлять свою крошечную погрешность, и в длинных вычислениях они накапливаются. Это не недостаток NumPy — так устроена любая аппаратная плавающая арифметика, включая обычный Python, Java, C и калькулятор в вашем телефоне. Вывод сугубо практический: считайте, что у float-результатов есть «шум» в последних знаках, и стройте сравнения и условия с допуском, а не на точное равенство.

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

Приведение типов: повышение и понижение

Когда в операции встречаются разные dtype, NumPy приводит их к общему по правилам повышения типа (type promotion): к такому, который вмещает оба без потерь. Сложение int32 и float64 даст float64; int8 и int64int64; целое и комплексное — комплексное.

import numpy as np

i = np.array([1, 2, 3], dtype=np.int32)
f = np.array([0.5, 0.5, 0.5], dtype=np.float64)

print((i + f).dtype)     # float64 — повышение к более ёмкому
print((i * 2).dtype)     # int32 — остаётся целым
print(i.astype(np.float32))   # явное приведение

Вывод:

float64
int32
[1. 2. 3.]

Понижение типа (downcasting) опасно и явно: arr.astype(np.int8) отрежет дробную часть у float и «завернёт» большие целые. NumPy сделает это без предупреждения, поэтому понижайте тип, только когда уверены, что значения помещаются. Особая ловушка — присваивание в существующий массив: arr[0] = 3.9, где arr целочисленный, тихо запишет 3, отбросив дробную часть, потому что dtype массива менять нельзя.

Беззнаковые типы и их специфика

Беззнаковые целые (uint8, uint16 и т. д.) не хранят отрицательных значений — весь диапазон отдан положительным. Это удобно для данных, которые по природе неотрицательны: яркость пикселя (0..255 для uint8), счётчики, индексы. Но именно с ними связана особенно коварная ловушка: вычитание. Если из меньшего беззнакового числа вычесть большее, результат не станет отрицательным (отрицательных значений у типа нет) — он «завернётся» к огромному положительному. Например, uint8(0) - uint8(1) даёт не -1, а 255. При обработке изображений это классический источник артефактов: вычли яркость, ушли ниже нуля — и вместо тёмного пикселя получили ярко-белый. Поэтому перед арифметикой с разностями беззнаковые данные часто приводят к знаковому или вещественному типу.

itemsize, память и выбор типа

Размер массива в памяти — это size × itemsize. Миллион чисел в float64 занимает 8 МБ, в float32 — 4 МБ, в int8 — всего 1 МБ. На больших данных выбор dtype напрямую определяет, влезет ли массив в память и в кэш. Поэтому для изображений берут uint8, для больших разреженных индексов — компактные целые, а float32 экономит память там, где точность float64 избыточна.

def array_bytes(n, itemsize):
    return n * itemsize

n = 1_000_000
print("float64:", array_bytes(n, 8), "байт")
print("float32:", array_bytes(n, 4), "байт")
print("int8   :", array_bytes(n, 1), "байт")

Вывод:

float64: 8000000 байт
float32: 4000000 байт
int8   : 1000000 байт

Как NumPy выбирает тип по умолчанию

Когда вы создаёте массив из списка без явного dtype, NumPy выводит тип сам — и тут есть тонкости, которые стоит знать. Список целых даёт целочисленный массив (на большинстве платформ int64); список со смесью целых и дробных — вещественный float64, потому что float «шире» и вмещает оба; одно лишь присутствие True/False рядом с числами поднимет тип до числового. Иными словами, NumPy выбирает наименьший тип, способный без потерь представить все элементы. Это разумно, но иногда не то, что вам нужно: например, вы хотели компактный float32, а получили вдвое более тяжёлый float64. Поэтому в коде, где важна память или совместимость с другими массивами, тип лучше задавать явно, а не полагаться на автоопределение.

Ещё одна тонкость — платформенная зависимость. Тип целых «по умолчанию» исторически различался между операционными системами (особенно на Windows, где он бывал 32-битным). Чтобы код вёл себя предсказуемо везде, при работе с большими целыми (суммы, счётчики) безопаснее явно писать dtype=np.int64, а не доверять платформенному выбору.

Подводные камни

  • Переполнение фиксированных целых. int8/int16/int32 «заворачиваются» молча. Сумма больших чисел может стать отрицательной. Берите int64 при риске роста.
  • Сравнение float через ==. Из-за погрешностей почти всегда даёт неожиданный результат. Используйте np.isclose/np.allclose.
  • Тихое понижение при присваивании. Запись float в целочисленный массив отбрасывает дробь без предупреждения.
  • float32 ради экономии. Помните, что точность падает примерно с 16 до 7 значащих цифр — для накопления сумм это может быть критично.

Комплексные и логические типы

Помимо целых и вещественных, NumPy поддерживает комплексные числа (complex64, complex128) — пара вещественных частей, действительная и мнимая. Они нужны в обработке сигналов, преобразовании Фурье, некоторых задачах физики и инженерии. Арифметика с ними поэлементная и подчиняется тем же правилам, просто над комплексными значениями. Отдельно стоит логический тип bool_: его значения — True/False, занимающие по одному байту. Булевы массивы — не просто экзотика, а рабочая лошадка фильтрации: именно их возвращают сравнения, и именно по ним строится вся продвинутая индексация, которую мы разберём в следующем разделе. Важная деталь: при арифметике True ведёт себя как 1, а False как 0, поэтому сумма булева массива — это число истинных элементов. Эта связь между «логикой» и «числами» делает булевы массивы удивительно выразительными.

Лучшие практики

  • Задавайте dtype осознанно: int64 для счётчиков, float64 для расчётов, uint8 для байтовых данных, float32 — когда память важнее точности.
  • Сравнивайте вещественные массивы через np.allclose, а не ==.
  • Понижайте тип через astype только после проверки, что значения помещаются.
  • На больших данных оценивайте память как size × itemsize заранее.

Итог

  • Весь массив имеет один dtype — это условие непрерывного хранения и скорости.
  • Целые фиксированной ширины переполняются молча; в отличие от безразмерного int Python.
  • float имеет конечную точность: float64 ~16 цифр, float32 ~7; сравнивать через допуск.
  • При смешении типов NumPy повышает к более ёмкому; понижение делается явно и осторожно.
Проверьте себя
1. Что произойдёт при выполнении np.array([127], dtype=np.int8) + 1?
AРезультат будет [128]
BNumPy выбросит исключение о переполнении
CРезультат «завернётся» и станет [-128] без предупреждения
DТип автоматически повысится до int64 и результат будет [128]
2. Почему 0.1 + 0.2 == 0.3 даёт False и в Python, и в NumPy?
AЭто баг конкретной версии NumPy
BЧисла с плавающей точкой (IEEE 754) хранят дроби приближённо, и 0.1, 0.2 не представимы точно в двоичной системе
CПотому что NumPy всегда округляет в большую сторону
DПотому что 0.3 хранится как целое число
3. Какой dtype получит результат сложения массива int32 и массива float64?
Aint32 — целое имеет приоритет
Bfloat64 — повышение к более ёмкому типу, вмещающему оба без потерь
Cfloat32 — компромисс между типами
DОперация вызовет ошибку из-за несовместимых типов
Поддержать проект