Broadcasting: как NumPy сопоставляет массивы разных форм
Урок детально разбирает broadcasting — механизм, позволяющий выполнять операции над массивами разных форм без явного копирования и циклов.
Broadcasting — правило, по которому NumPy автоматически «растягивает» массивы меньшей формы до большей, сопоставляя их оси справа налево, чтобы выполнить поэлементную операцию.
Зачем нужен broadcasting
Самый простой случай вы уже видели: a + 1 прибавляет единицу к каждому элементу. Здесь скаляр 1 «растягивается» до формы массива. Broadcasting обобщает эту идею на массивы: он позволяет, например, прибавить вектор к каждой строке матрицы или нормировать столбцы — без циклов и без ручного копирования. Это один из самых элегантных и одновременно запутывающих механизмов NumPy.
Ключевая мысль: при broadcasting данные физически не копируются. NumPy лишь делает вид, что ось длины 1 повторяется нужное число раз, используя нулевой stride. Память не растёт, скорость остаётся высокой.
Правило выравнивания: справа налево
Чтобы понять, совместимы ли две формы, NumPy сопоставляет их оси начиная с последней (самой правой) и двигаясь влево. Если у массивов разное число осей, более короткую форму мысленно дополняют единицами слева. Затем для каждой пары осей проверяется совместимость.
Две оси совместимы, если выполнено одно из двух:
- их размеры равны, либо
- одна из них равна 1 (она «растянется» до размера другой).
Если хотя бы одна пара осей не удовлетворяет этому, NumPy выбрасывает ValueError: operands could not be broadcast together. Итоговая форма по каждой оси — максимум из двух размеров.
| Форма A | Форма B | Результат |
| (4, 3) | (3,) | (4, 3) |
| (4, 3) | (1,) | (4, 3) |
| (4, 1) | (1, 3) | (4, 3) |
| (15, 3, 5) | (15, 1, 5) | (15, 3, 5) |
| (15, 3, 5) | (3, 5) | (15, 3, 5) |
| (3,) | (4,) | ОШИБКА |
| (2, 1) | (8, 4, 3) | ОШИБКА |
Самый частый случай: вектор и матрица
Прибавление вектора длины 3 к матрице 4×3. Выравниваем справа: последняя ось матрицы (3) совпадает с осью вектора (3) — совместимо. У вектора нет второй оси, она дополняется до 1, а 1 растягивается до 4. Результат — каждая строка матрицы получает один и тот же вектор.
import numpy as np
m = np.array([[0, 0, 0],
[10, 10, 10],
[20, 20, 20],
[30, 30, 30]]) # (4, 3)
v = np.array([1, 2, 3]) # (3,)
print(m + v) # v прибавляется к каждой строке
Вывод:
[[ 1 2 3] [11 12 13] [21 22 23] [31 32 33]]
Без broadcasting пришлось бы писать цикл по строкам. Воспроизведём смысл на чистом Python, чтобы стало видно, что именно «делает» NumPy:
m = [[0, 0, 0], [10, 10, 10], [20, 20, 20], [30, 30, 30]]
v = [1, 2, 3]
# v растягивается на каждую строку
result = [[m[i][j] + v[j] for j in range(3)] for i in range(len(m))]
for row in result:
print(row)
Вывод:
[1, 2, 3] [11, 12, 13] [21, 22, 23] [31, 32, 33]
Заметьте побочное наблюдение: broadcasting делает код не только короче, но и устойчивее к ошибкам. Цикл по индексам легко написать с опечаткой в границах или перепутать i и j; векторное выражение m + v такой возможности не даёт — либо формы совместимы и операция корректна, либо NumPy сразу сообщит об ошибке. Меньше ручного управления индексами — меньше мест для багов.
Растяжение по двум осям сразу: столбец + строка
Когда у одного массива форма (4, 1), а у другого (1, 3), broadcasting растягивает обе единичные оси: первая до 4, вторая до 3, давая (4, 3). Так строятся таблицы из двух векторов. Здесь видно, почему в прошлом разделе мы добавляли оси через np.newaxis.
import numpy as np
col = np.array([0, 10, 20, 30])[:, np.newaxis] # (4, 1)
row = np.array([1, 2, 3])[np.newaxis, :] # (1, 3)
print((col + row).shape)
print(col + row)
Вывод:
(4, 3) [[ 1 2 3] [11 12 13] [21 22 23] [31 32 33]]
Реализуем правило broadcasting сами
Чтобы окончательно закрепить правило, напишем на чистом Python функцию, которая по двум формам вычисляет результирующую (или сообщает об ошибке). Это ровно та логика, что внутри NumPy:
def broadcast_shape(s1, s2):
# Выравниваем справа: разворачиваем и идём по парам
r1, r2 = list(reversed(s1)), list(reversed(s2))
n = max(len(r1), len(r2))
out = []
for i in range(n):
a = r1[i] if i < len(r1) else 1 # недостающие оси = 1
b = r2[i] if i < len(r2) else 1
if a == b or a == 1 or b == 1:
out.append(max(a, b))
else:
return "ОШИБКА: %d и %d несовместимы" % (a, b)
return tuple(reversed(out))
print(broadcast_shape((4, 3), (3,)))
print(broadcast_shape((4, 1), (1, 3)))
print(broadcast_shape((15, 3, 5), (3, 5)))
print(broadcast_shape((3,), (4,)))
Вывод:
(4, 3) (4, 3) (15, 3, 5) ОШИБКА: 3 и 4 несовместимы
Этот код — и есть «алгоритм broadcasting» в миниатюре. Поняв его, вы сможете в уме предсказывать форму результата и причину любой ошибки.
Стоит проговорить, почему правило именно такое — выравнивание справа, а не слева. Последние оси массива обычно «самые конкретные»: для таблицы это столбцы-признаки, для изображения — каналы цвета, для временного ряда — отсчёты. Первые же оси чаще «контейнерные»: номер строки, номер картинки, номер партии. Выравнивая справа, NumPy сопоставляет однотипные «конкретные» оси и позволяет добавлять «контейнерные» оси слева бесплатно. Поэтому вектор-признаков естественно растягивается на все строки таблицы: его единственная ось совпадает с последней осью матрицы (признаки), а недостающая слева ось (строки) дополняется и растягивается. Это правило не произвольно — оно отражает типичную семантику осей в реальных данных.
Broadcasting со скаляром — это тоже broadcasting
Самый первый пример, который все видят — a + 1 — это уже broadcasting в вырожденной форме. Скаляр можно мыслить как массив формы () (ноль осей), который растягивается до любой формы. Поэтому правила, которые мы разобрали, едины: и для «массив + скаляр», и для «матрица + вектор», и для «столбец + строка» работает один и тот же механизм выравнивания осей и растяжения единиц. Это приятная цельность: не нужно держать в голове отдельные правила для скаляров — они частный случай общего. Когда вы пишете data * 255 или (data - mean) / std, вы пользуетесь broadcasting, даже если не называете это так.
Память: broadcasting не копирует, но результат — полноразмерный
Ключевое для производительности: само растяжение по broadcasting не выделяет память. Ось длины 1 «повторяется» через нулевой stride — это иллюзия повторения без реальных копий, как мы обсуждали в уроке про strides. Но результат операции — это уже настоящий полноразмерный массив. Сложив столбец (1000, 1) со строкой (1, 1000), вы не потратите память на растяжение операндов, но получите матрицу-результат на миллион элементов (8 МБ для float64). Это важно осознавать: broadcasting позволяет элегантно записать операцию, но не отменяет стоимость хранения её результата. Классическая ошибка — нечаянно породить гигантскую матрицу из двух больших векторов через внешнюю операцию, не заметив, что результат на порядки больше входов. Перед такими операциями прикиньте форму и размер результата.
Почему возникает ValueError и как чинить
Самая частая ошибка — попытка сложить, скажем, массив (4, 3) с вектором (4,). Выравнивание справа сопоставляет 3 и 4 — не равны и ни одна не 1, значит ошибка. Намерение здесь обычно «прибавить вектор к каждому столбцу», и чинится оно превращением вектора в столбец: (4,) → (4, 1) через v[:, np.newaxis]. Тогда формы (4, 3) и (4, 1) совместимы.
import numpy as np
m = np.arange(12).reshape(4, 3)
v = np.array([1, 2, 3, 4]) # (4,)
# m + v -> ValueError: (4,3) и (4,) несовместимы
print(m + v[:, np.newaxis]) # (4,3) + (4,1) -> ок, по столбцам
Вывод:
[[ 1 2 3] [ 5 6 7] [ 9 10 11] [13 14 15]]
Правило диагностики: если получили «could not be broadcast together», выпишите обе формы, выровняйте справа и найдите первую пару осей, которая не «равна или одна = 1». Почти всегда лекарство — добавить ось через np.newaxis в нужное место.
Подводные камни
- Выравнивание идёт справа, а не слева. (4, 3) и (4,) НЕ совместимы, хотя «4» есть у обоих — потому что сопоставляются 3 и 4.
- Молчаливое неожиданное растяжение. Иногда формы совместимы случайно, и операция «срабатывает», но даёт не то, что задумано. Проверяйте форму результата.
- Память для итога. Сам broadcasting не копирует, но результат операции — полноразмерный массив. (1000, 1) + (1, 1000) даст матрицу на миллион элементов.
Практический смысл: нормировка и центрирование
Чтобы broadcasting перестал быть абстракцией, посмотрим, где он незаменим в реальной работе. Допустим, у вас матрица «образцы × признаки», и каждый признак (столбец) нужно привести к нулевому среднему и единичному разбросу — стандартная предобработка для машинного обучения. Среднее по столбцам — это вектор длины «число признаков», и его нужно вычесть из каждой строки. Благодаря broadcasting это пишется как X - X.mean(axis=0): вектор средних (форма как у строки) автоматически растягивается на все строки. Деление на стандартное отклонение / X.std(axis=0) — точно так же. Без broadcasting понадобился бы цикл по строкам с повторным вычитанием — медленно и громоздко. Если же нормировать нужно по строкам (например, каждый образец к единичной длине), среднее берут с axis=1, keepdims=True, чтобы получить столбец (n, 1), который растянется по столбцам. Эта пара приёмов — вычитание вектора-строки и вектора-столбца — покрывает огромную долю задач предобработки данных, и все они держатся на broadcasting.
Лучшие практики
- Перед операцией мысленно выровняйте формы справа и проверьте совместимость осей.
- Управляйте broadcasting явно через
np.newaxis/reshape, делая столбцы и строки. - При ошибке выписывайте формы операндов — причина почти всегда в одной паре несовместимых осей.
- Следите за размером результата: broadcasting легко породить огромную матрицу.
Итог
- Broadcasting сопоставляет оси справа налево; недостающие оси дополняются единицами слева.
- Оси совместимы, если равны или одна равна 1; ось-1 растягивается без копирования данных.
- Результирующий размер по оси — максимум из двух; несовместимость даёт ValueError.
- Чинят ошибки добавлением осей через
np.newaxis, превращая векторы в строки/столбцы.