12 KiB
02. Массивы NumPy: shape, axis, dtype, indexing и broadcasting
NumPy хранит однородные данные в многомерной форме и выполняет операции над целыми осями.
Мотивация
В ML размерности несут смысл: строки могут быть объектами, столбцы — признаками, третья ось — каналами. Многие ошибки NumPy не завершаются исключением: broadcasting строит допустимый массив неправильного смысла. Поэтому форму и оси нужно читать как часть типа.
Модель ndarray
Массив NumPy описывается:
shape— длиной каждой оси;ndim— числом осей;dtype— типом каждого элемента;size— общим числом элементов;strides— шагами в байтах при движении по осям.
import numpy as np
X = np.array([[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0]])
print(X.shape, X.ndim, X.dtype, X.size, X.strides)
\mathbf{X}\in\mathbb{R}^{n\times d} при n=2,d=3 соответствует форме
(2, 3): axis 0 перечисляет объекты, axis 1 — признаки.
Массив формы (d,) не является ни строкой (1, d), ни столбцом (d, 1).
У одномерного массива транспонирование ничего не меняет:
x = np.array([1.0, 2.0, 3.0])
print(x.shape, x.T.shape)
print(x[:, None].shape, x[None, :].shape)
dtype и численная семантика
Однородный dtype позволяет хранить данные компактно и выполнять операции в
скомпилированных циклах. Но тип ограничивает диапазон и точность.
import numpy as np
a = np.array([120, 120], dtype=np.int8)
print(a + np.array([10, 10], dtype=np.int8)) # переполнение
b = np.array([1, 2, 3], dtype=np.float64)
print(b / 2)
Целые типы переполняются по модулю без обязательной ошибки. float32 занимает
меньше памяти и часто быстрее на GPU, но хранит меньше значащих цифр, чем
float64. Сравнивать вычисленные float через == обычно неверно; нужен допуск.
astype создаёт преобразованный массив:
x = np.array([1.2, 2.9])
print(x.astype(np.int64)) # усечение к нулю: [1 2]
Создание и изменение формы
import numpy as np
zeros = np.zeros((2, 3), dtype=np.float64)
grid = np.arange(12).reshape(3, 4)
points = np.linspace(0.0, 1.0, num=5)
print(zeros, grid, points, sep="\n")
В reshape(3, -1) NumPy выводит одну неизвестную длину. Общее число элементов
должно сохраняться. ravel() стремится вернуть view, flatten() всегда копирует.
Объединение не равно добавлению оси:
np.concatenateсоединяет по существующей оси;np.stackсоздаёт новую ось;np.squeezeудаляет только оси длины 1;np.expand_dimsдобавляет ось.
Индексирование, view и копия
Базовый slice обычно возвращает view на те же данные:
import numpy as np
x = np.arange(6)
view = x[1:4]
view[0] = 99
print(x) # исходный массив изменился
Продвинутое индексирование списком индексов или маской обычно создаёт копию:
x = np.arange(6)
selected = x[[1, 3, 5]]
selected[0] = 99
print(x)
Не следует строить логику только на слове «обычно»: если владение памятью важно,
создавайте явную copy() и проверяйте np.shares_memory.
Булева маска должна соответствовать индексируемой оси:
X = np.array([[1, 10], [2, 20], [3, 30]])
mask = X[:, 0] >= 2
print(X[mask])
Оси и редукции
Редукция уничтожает выбранную ось. Для \mathbf{X}\in\mathbb{R}^{n\times d}:
\mu_j=\frac1n\sum_{i=1}^{n}X_{ij}
усредняет axis 0 и возвращает вектор признаков формы (d,).
import numpy as np
X = np.array([[1.0, 10.0], [3.0, 14.0], [5.0, 18.0]])
feature_means = X.mean(axis=0)
row_means = X.mean(axis=1)
print(feature_means.shape, feature_means)
print(row_means.shape, row_means)
keepdims=True сохраняет редуцированную ось длины 1. Это помогает сделать
последующий broadcasting намеренным:
means = X.mean(axis=0, keepdims=True) # (1, d)
centered = X - means # (n, d)
Broadcasting: точное правило
Формы сравниваются справа налево. Две размерности совместимы, если:
- они равны; или
- одна из них равна 1; или
- у одной формы ось отсутствует.
Для форм (4, 3, 2) и (3, 1):
4 3 2
3 1
-----
4 3 2
Последняя ось: 2 и 1; следующая: 3 и 3; ведущая отсутствует и считается
совместимой. Данные логически растягиваются без обязательного физического копирования.
Разобранный пример: стандартизация признаков
Дано \mathbf{X}\in\mathbb{R}^{3\times2}. Для каждого признака:
\mu_j=\frac13\sum_{i=1}^3 X_{ij},\qquad
\sigma_j=\sqrt{\frac13\sum_{i=1}^3(X_{ij}-\mu_j)^2},
\qquad Z_{ij}=\frac{X_{ij}-\mu_j}{\sigma_j}.
Для первого столбца [1,3,5]: \mu_1=3,
\sigma_1=\sqrt{(4+0+4)/3}=\sqrt{8/3}.
Для второго [10,14,18]: \mu_2=14,
\sigma_2=\sqrt{32/3}.
import numpy as np
X = np.array([[1.0, 10.0],
[3.0, 14.0],
[5.0, 18.0]])
mean = X.mean(axis=0, keepdims=True)
std = X.std(axis=0, keepdims=True)
if np.any(std == 0):
raise ValueError("Обнаружен константный признак")
Z = (X - mean) / std
print("shapes:", X.shape, mean.shape, std.shape, Z.shape)
print("mean:", Z.mean(axis=0))
print("std:", Z.std(axis=0))
Формы (3,2) - (1,2) совместимы: строка средних применяется к каждому объекту.
Проверка результата подтверждает среднее около нуля и стандартное отклонение 1.
Batch-пример: линейный слой XW + b
Пусть в batch n объектов, у каждого d входных признаков, а слой создаёт
k выходов:
\mathbf X\in\mathbb R^{n\times d},\quad
\mathbf W\in\mathbb R^{d\times k},\quad
\mathbf b\in\mathbb R^k.
Тогда X @ W имеет форму (n, k), а b формы (k,) распространяется по
строкам batch. Результат Y = X @ W + b также имеет форму (n, k).
import numpy as np
X = np.array([[1., 2., 3.], [4., 5., 6.]]) # (n=2, d=3)
W = np.array([[1., 0.], [0., 1.], [1., 1.]]) # (d=3, k=2)
b = np.array([10., -10.]) # (k=2,)
Y = X @ W + b # (n=2, k=2)
assert X.shape[1] == W.shape[0]
assert b.shape == (W.shape[1],)
assert Y.shape == (X.shape[0], W.shape[1])
print(Y) # [[14. -5.], [20. 1.]]
Форма b=(1, k) дала бы тот же результат, но (k, 1) несовместима с (n, k)
в общем случае и не выражает «один bias на каждый выход». Проверяйте контракт
форм явно: допустимый broadcasting ещё не доказывает правильность смысла.
Матричное и поэлементное умножение
Для \mathbf{X}\in\mathbb{R}^{n\times d} и
\mathbf{w}\in\mathbb{R}^{d} выражение X @ w имеет форму (n,).
X * w выполняет поэлементное умножение с broadcasting и имеет форму (n,d).
X = np.array([[1.0, 2.0], [3.0, 4.0]])
w = np.array([0.5, -1.0])
print(X @ w)
print(X * w)
Обе операции законны, но выражают разные математики. Формы нужно выводить до запуска.
Пропущенные значения
np.nan — специальное значение float. Обычные редукции распространяют его:
np.mean([1, np.nan]) даёт nan; np.nanmean игнорирует его. Игнорирование
не всегда корректно статистически и должно быть явным решением.
Проверки:
x = np.array([1.0, np.nan, np.inf])
print(np.isnan(x))
print(np.isfinite(x))
Типичные ошибки
- Перепутанная axis. Подпишите смысл каждой размерности до редукции.
- Ожидание, что
x.Tпревратит(d,)в столбец. Используйтеx[:, None]. - Случайный broadcasting
(n,1)с(n,). Результат станет(n,n). - Изменение view. Делайте
copy(), если нужен независимый массив. - Целочисленное переполнение. Выбирайте диапазон dtype осознанно.
np.emptyпринимается за нули. Он содержит неинициализированную память.- Сравнение float через
==. Используйтеnp.isclose/np.allclose. - Поэлементное
*вместо@. Проверяйте математический смысл и форму.
Краткое резюме
shape,axisиdtypeявляются частью контракта массива.(d,),(d,1)и(1,d)— разные формы.- Редукция убирает ось;
keepdims=Trueсохраняет её для явного broadcasting. - Broadcasting сопоставляет формы справа налево и может скрывать смысловую ошибку.
- Базовый slice часто разделяет память, продвинутое индексирование обычно копирует.
Связанные материалы
- Предыдущая глава:
01_python_data_model.md. - Следующая глава:
03_vectorization_testing.md. - Практика:
../assignments/01_vectorization.md. - Практика:
../assignments/02_linear_layer.md. - Термины:
glossary.md.