Умножение массивов: поэлементное против матричного
Урок разбирает фундаментальное для линейной алгебры различие: поэлементное умножение и матричное произведение — это совершенно разные операции.
Оператор * в NumPy означает поэлементное умножение, а оператор @ — матричное произведение; путаница между ними — одна из самых частых ошибок.
Две разные операции под похожими именами
Тех, кто пришёл из MATLAB, это сбивает с толку: там * означает матричное умножение. В NumPy всё иначе и логично: a * b перемножает соответствующие элементы (с broadcasting, как любая ufunc), а a @ b выполняет матричное произведение по правилам линейной алгебры. Это разные результаты и разные требования к формам.
import numpy as np
a = np.array([[1, 2],
[3, 4]])
b = np.array([[5, 6],
[7, 8]])
print(a * b) # поэлементно: каждый элемент умножается на парный
print(a @ b) # матричное произведение
Вывод:
[[ 5 12] [21 32]] [[19 22] [43 50]]
Видно, что результаты совершенно разные. Поэлементное a * b даёт [[1·5, 2·6], [3·7, 4·8]]. А матричное a @ b считает каждый элемент как сумму произведений строки на столбец: верхний левый — это 1·5 + 2·7 = 19.
Скалярное произведение векторов руками
Чтобы прочувствовать, что такое матричное умножение, начнём с простейшего кирпичика — скалярного произведения двух векторов: перемножить попарно и сложить. Реализуем на чистом Python:
def dot(u, v):
return sum(u[i] * v[i] for i in range(len(u)))
a = [1, 2, 3]
b = [4, 5, 6]
print("Скалярное произведение:", dot(a, b)) # 1*4 + 2*5 + 3*6
Вывод:
Скалярное произведение: 32
В NumPy это a @ b или np.dot(a, b) для одномерных массивов — и даёт одно число 32. Теперь матричное умножение — это просто скалярное произведение каждой строки левой матрицы на каждый столбец правой. Соберём его руками, чтобы развеять всю «магию»:
def matmul(A, B):
n, m, p = len(A), len(B), len(B[0])
result = [[0] * p for _ in range(n)]
for i in range(n): # строки A
for j in range(p): # столбцы B
s = 0
for k in range(m): # суммируем произведения
s += A[i][k] * B[k][j]
result[i][j] = s
return result
A = [[1, 2], [3, 4]]
B = [[5, 6], [7, 8]]
for row in matmul(A, B):
print(row)
Вывод:
[19, 22] [43, 50]
Это ровно то, что NumPy делает за A @ B, только в C и с оптимизированными библиотеками (BLAS), поэтому на больших матрицах он в тысячи раз быстрее нашего тройного цикла.
Почему различие так важно: цена ошибки
Путаница между * и @ — не безобидная стилистическая мелочь, а источник тихих ошибок, которые не всегда дают исключение. Иногда формы случайно совместимы и для поэлементного, и для матричного умножения, так что неверный оператор не падает, а молча возвращает бессмысленный результат — например, матрицу не того размера или не те числа. Такую ошибку трудно поймать: код работает, но выдаёт неправильные ответы где-то дальше по конвейеру. Поэтому при каждом умножении массивов осознанно спрашивайте себя: мне нужно «перемножить соответствующие элементы» (тогда *) или «выполнить матричное произведение по правилам линейной алгебры» (тогда @)? Эти две операции отвечают на принципиально разные вопросы, и выбор между ними должен быть сознательным, а не случайным. Особенно внимательны будьте при переносе кода из MATLAB или R, где смысл * иной.
Правило размеров матричного произведения
Матричное умножение требует согласования внутренних размеров: чтобы умножить (m, n) @ (n, p), число столбцов левой матрицы (n) должно совпасть с числом строк правой (n). Результат имеет форму (m, p) — «внешние» размеры. Если внутренние не совпадают, получите ValueError.
import numpy as np
A = np.arange(6).reshape(2, 3) # (2, 3)
B = np.arange(12).reshape(3, 4) # (3, 4)
print((A @ B).shape) # (2, 4): внутренние 3 совпали
# C = np.arange(6).reshape(2, 3)
# A @ C -> ValueError: (2,3) и (2,3) — внутренние 3 и 2 не совпадают
Вывод:
(2, 4)
Запомнить легко: «внутренние размеры сокращаются, внешние остаются». (2,3)@(3,4)→(2,4).
Геометрически матричное произведение — это применение преобразования: умножая вектор на матрицу, вы поворачиваете, растягиваете или проецируете его в новое пространство. Умножая матрицу на матрицу, вы композируете два преобразования в одно. Эта интерпретация помогает понять, почему размеры должны согласовываться: выход одного преобразования должен «подходить» на вход следующего. И почему порядок важен: A @ B в общем случае не равно B @ A — применить сначала поворот, потом растяжение не то же, что наоборот. Матричное умножение некоммутативно, и это не каприз, а отражение того, что порядок применения преобразований имеет значение.
dot, matmul и @: в чём разница
Есть несколько способов записать матричное произведение, и у них тонкие различия:
@— оператор, читается как «матмул», самый идиоматичный современный способ. Эквивалентенnp.matmul.np.dot— старая универсальная функция: для 1D даёт скалярное произведение, для 2D — матричное. Для многомерных ведёт себя иначе, чемmatmul.np.matmul/@— для многомерных массивов трактует последние две оси как матрицы и выполняет «пакетное» умножение (batch), что обычно и нужно в современных задачах.
import numpy as np
u = np.array([1, 2, 3])
v = np.array([4, 5, 6])
print(u @ v) # 32 — скалярное произведение
print(np.dot(u, v)) # 32 — то же для 1D
print(u.dot(v)) # 32 — метод тоже есть
Вывод:
32 32 32
Практический совет: для обычного матричного умножения используйте @ — это читаемо и работает предсказуемо, в том числе для пакетов матриц. np.dot оставьте для скалярных произведений 1D-векторов или старого кода.
Пакетное умножение: matmul в N измерениях
Современная причина предпочитать @/matmul функции dot — поведение в многомерном случае. matmul трактует последние две оси массива как матрицы, а все предшествующие оси — как «пакет» (batch) независимых матриц, и перемножает их попарно. Это ровно то, что нужно в машинном обучении и обработке данных, где часто есть стопка из сотен матриц, которые надо перемножить одинаковым образом. Например, массив формы (100, 3, 4), умноженный на (100, 4, 5), даст (100, 3, 5) — сто независимых матричных произведений за один вызов, без цикла. np.dot в многомерном случае ведёт себя иначе (он считает суммы произведений по другим осям) и почти никогда не делает того, что интуитивно ожидается. Поэтому для любых матричных операций, особенно когда есть «пакетное» измерение, используйте @: оно предсказуемо и совпадает с математической интуицией «перемножить матрицы поэлементно по пакету».
Зачем различать: пример из практики
Матричное умножение — не абстракция, а вычислительное сердце огромного числа алгоритмов. Линейная регрессия, нейронные сети, преобразования координат в графике, цепи Маркова, обработка сигналов — всё это в основе своей последовательности матричных произведений. Каждый «слой» нейросети — это умножение входа на матрицу весов плюс смещение; повернуть или спроецировать набор точек в 3D — умножить их координаты на матрицу преобразования; найти стационарное распределение цепи Маркова — возводить матрицу переходов в степень. Понимание, что за лаконичным X @ W стоит миллионы скалярных произведений, выполняемых оптимизированной библиотекой, даёт правильную интуицию о том, где в ваших вычислениях основная нагрузка и почему именно матричные операции часто определяют скорость всей программы.
Представьте: у вас матрица признаков объектов и вектор весов. Чтобы получить взвешенную сумму признаков для каждого объекта (линейная модель), нужно матричное умножение X @ w. А чтобы, скажем, поэлементно масштабировать каждый признак своим коэффициентом — поэлементное X * scale с broadcasting. Перепутать их — получить бессмысленный результат или ошибку формы. Понимание, какая операция вам нужна, — основа корректных вычислений.
# Линейная модель руками: X @ w
X = [[1, 2], [3, 4], [5, 6]] # 3 объекта, 2 признака
w = [10, 100] # веса признаков
predictions = [sum(X[i][k] * w[k] for k in range(2)) for i in range(3)]
print("Предсказания (X @ w):", predictions)
Вывод:
Предсказания (X @ w): [210, 430, 650]
Подводные камни
- Привычка из MATLAB. Там
*— матричное умножение; в NumPy*поэлементное, матричное — это@. - Несогласованные размеры. Для
@внутренние размеры обязаны совпадать; иначе ValueError. Проверяйте формы. - dot для многомерных. Поведение
np.dotна 3D+ отличается отmatmul; для пакетов матриц берите@. - Скаляр vs матрица.
u @ vдля двух 1D даёт число (скаляр), а не вектор.
Скорость матричного умножения: BLAS под капотом
Стоит понимать, почему наш учебный тройной цикл и реальный @ в NumPy отличаются по скорости на порядки. Матричное умножение NumPy перенаправляет в библиотеки BLAS (Basic Linear Algebra Subprograms) — десятилетиями оптимизируемые реализации, написанные с учётом архитектуры процессора: они эффективно используют кэш, векторные инструкции и несколько ядер сразу. Наивный тройной цикл, даже на C, не приближается к их производительности, потому что не учитывает иерархию памяти. Практический вывод: никогда не пишите матричное умножение вручную через циклы — @ почти всегда будет в десятки и сотни раз быстрее, и чем больше матрицы, тем больше разрыв. Этот же принцип распространяется на всю линейную алгебру NumPy: решение систем, разложения, обращение — всё опирается на оптимизированные библиотеки (LAPACK поверх BLAS), и переписывать их «руками» бессмысленно. Ваша задача — правильно сформулировать операцию в терминах NumPy, а скорость обеспечат проверенные библиотеки.
Лучшие практики
- Для матричного произведения используйте оператор
@— он читаем и предсказуем. - Чётко решайте, нужна ли поэлементная (
*) или матричная (@) операция, исходя из смысла задачи. - Проверяйте согласование размеров:
(m,n)@(n,p)→(m,p). np.dotприменяйте для скалярных произведений 1D-векторов.
Итог
*— поэлементное умножение (с broadcasting);@— матричное произведение.- Матричное умножение — это скалярные произведения строк на столбцы; внутренние размеры должны совпасть.
- Форма результата
@:(m,n)@(n,p)→(m,p). - Для современных задач (в том числе пакетов матриц) предпочтительнее
@/matmul, а неdot.