ai_learning/modules/01_python_numpy/notes/02_numpy_arrays.md

12 KiB
Raw Permalink Blame History

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. они равны; или
  2. одна из них равна 1; или
  3. у одной формы ось отсутствует.

Для форм (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 часто разделяет память, продвинутое индексирование обычно копирует.

Связанные материалы