Типы данных (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, int64 | int8: −128..127; int64: ±9.2·10¹⁸ |
| Целые без знака | uint8, uint16, uint32, uint64 | uint8: 0..255 |
| Вещественные | float16, float32, float64 | float64: ~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 и int64 — int64; целое и комплексное — комплексное.
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 повышает к более ёмкому; понижение делается явно и осторожно.