Умножение массивов: поэлементное против матричного

Урок разбирает фундаментальное для линейной алгебры различие: поэлементное умножение и матричное произведение — это совершенно разные операции.

Оператор * в 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.
Проверьте себя
1. Чем различаются операторы * и @ для двумерных массивов NumPy?
AНичем, оба выполняют матричное умножение
B* выполняет поэлементное умножение соответствующих элементов, а @ — матричное произведение (строки на столбцы)
C* выполняет матричное умножение, а @ — поэлементное
D@ работает только с векторами, а * — с матрицами
2. Какую форму имеет результат матричного произведения (2, 3) @ (3, 4)?
A(2, 3)
B(3, 4)
C(2, 4) — внутренние размеры (3) сокращаются, остаются внешние
DОшибку, размеры несовместимы
3. Что вернёт u @ v для двух одномерных массивов u и v одинаковой длины?
AМатрицу попарных произведений
BСкалярное произведение — одно число (сумма произведений соответствующих элементов)
CПоэлементное произведение в виде вектора
DОшибку, @ не работает с 1D
Поддержать проект