Матрицы и умножение матриц

⏱ 35 мин · 🧩 заданий: 6

Матрица — таблица чисел

Матрица размера m×n — прямоугольная таблица из m строк и n столбцов. Элемент в строке i и столбце j обозначают a_{ij}.

A = \begin{pmatrix} 1 & 2 & 3 \\ 4 & 5 & 6 \end{pmatrix} \quad \text{— матрица } 2 \times 3

В ML матрица чаще всего — это:

  • данные: строка — объект, столбец — признак. Таблица из 1000 квартир по 5 признаков — матрица 1000×5;
  • веса слоя нейросети: матрица, которая превращает вектор из d_in чисел в вектор из d_out чисел;
  • картинка: матрица яркостей пикселей.
import numpy as np
import vis

A = np.array([[1, 2, 3],
              [4, 5, 6]])
print(A.shape)       # (2, 3): 2 строки, 3 столбца
print(A[1, 0])       # элемент a₂₁ = 4 (в NumPy индексы с нуля)
print(A[0])          # первая строка — вектор
print(A[:, 2])       # третий столбец — вектор
vis.heatmap(A, title="Матрица как таблица", fmt=".0f")

Простые операции

  • Сложение и умножение на число — поэлементно, как у векторов (размеры должны совпадать).
  • Транспонирование A^T — строки становятся столбцами: (A^T)_{ij} = a_{ji}. Матрица m×n превращается в n×m.
import numpy as np

A = np.array([[1, 2, 3], [4, 5, 6]])
B = np.ones((2, 3), dtype=int)
print(A + B)
print(3 * A)
print(A.T, A.T.shape)

Матрица на вектор: два взгляда

Пусть A — матрица m×n, x — вектор из n чисел. Результат Ax — вектор из m чисел. Его можно понимать двумя способами, и оба важны.

Взгляд 1: строки. Каждый элемент результата — скалярное произведение строки A на x:

\begin{pmatrix} 1 & 2 \\ 3 & 4 \\ 5 & 6 \end{pmatrix} \begin{pmatrix} 10 \\ 1 \end{pmatrix} = \begin{pmatrix} 1\cdot 10 + 2\cdot 1 \\ 3\cdot 10 + 4 \cdot 1 \\ 5\cdot 10 + 6\cdot 1 \end{pmatrix} = \begin{pmatrix} 12 \\ 34 \\ 56 \end{pmatrix}

Если каждая строка — веса одного нейрона, то Ax — это выходы m нейронов сразу.

Взгляд 2: столбцы. Результат — линейная комбинация столбцов A с коэффициентами из x:

Ax = 10 \cdot \begin{pmatrix} 1 \\ 3 \\ 5 \end{pmatrix} + 1 \cdot \begin{pmatrix} 2 \\ 4 \\ 6 \end{pmatrix}

Этот взгляд объясняет, куда матрица «переносит» пространство, — об этом следующий урок.

import numpy as np

A = np.array([[1, 2], [3, 4], [5, 6]])
x = np.array([10, 1])

print("готовое:          ", A @ x)
print("строки · x:       ", np.array([row @ x for row in A]))
print("комбинация столбцов:", x[0] * A[:, 0] + x[1] * A[:, 1])

Умножение матриц

Произведение C = AB матрицы A размера m×n на B размера n×k — матрица m×k:

c_{ij} = \sum_{t=1}^{n} a_{it}\, b_{tj} \quad \text{— строка } i \text{ матрицы } A \text{ на столбец } j \text{ матрицы } B
A B C = A @ B (m × n) @ (n × k) = (m × k) └──┬──┘ внутренние размеры должны совпасть и «сокращаются»

Иначе говоря, AB — это A, умноженная на каждый столбец B, а результаты сложены в столбцы.

import numpy as np

A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])

print(A @ B)
print("c₁₂ вручную:", A[0] @ B[:, 1])     # 1·6 + 2·8 = 22
print(B @ A)                              # другой результат!
print(A * B)                              # а это поэлементное произведение — совсем не то же самое

@ и * — разные операции

A @ B — матричное умножение. A * B — поэлементное (произведение Адамара), формы должны совпадать или быть совместимыми для broadcasting. Путать их — частая ошибка.

Свойства

  • Не коммутативно: в общем случае AB \ne BA (и даже формы могут не позволить переставить).
  • Ассоциативно: (AB)C = A(BC) — можно расставлять скобки как удобно.
  • Дистрибутивно: A(B + C) = AB + AC.
  • Транспонирование меняет порядок: (AB)^T = B^T A^T.

Единичная матрица

I — квадратная матрица с единицами на диагонали и нулями вне её. Умножение на неё ничего не меняет: AI = IA = A. Это «единица» для матриц.

import numpy as np

A = np.array([[2, 1], [0, 3]])
I = np.eye(2)
print(A @ I)
print(np.allclose(A @ I, A), np.allclose(I @ A, A))

Сколько это стоит

Для умножения m×n на n×k нужно m·n·k умножений. Две матрицы 1000×1000 — миллиард операций. Именно поэтому нейросети обучают на видеокартах: GPU умеют делать тысячи умножений параллельно. А NumPy использует оптимизированные библиотеки (BLAS), которые в сотни раз быстрее тройного цикла на Python:

import numpy as np, time

n = 60
rng = np.random.default_rng(0)
A, B = rng.normal(size=(n, n)), rng.normal(size=(n, n))

t = time.perf_counter()
C = [[sum(A[i, t_] * B[t_, j] for t_ in range(n)) for j in range(n)] for i in range(n)]
t_loop = time.perf_counter() - t

t = time.perf_counter()
C2 = A @ B
t_np = time.perf_counter() - t
print(f"тройной цикл: {t_loop:.2f} с, NumPy: {t_np * 1000:.2f} мс")
print("результаты совпадают:", np.allclose(C, C2))

Батч через слой: X @ W

Самое важное применение. Пусть у нас батч из N объектов, каждый — вектор из d_in признаков. Сложим их строками в матрицу X размера N×d_in. Веса слоя — матрица W размера d_in×d_out, сдвиги — вектор b из d_out чисел. Тогда

Y = XW + b

даёт матрицу N×d_out: выходы слоя для всех объектов сразу, одной операцией. Сдвиг b прибавляется к каждой строке благодаря broadcasting.

X W Y (N × d_in) @ (d_in × d_out) = (N × d_out) + b (d_out,) к каждой строке объекты столбец j — строка — выход в строках веса нейрона j слоя для объекта
import numpy as np

rng = np.random.default_rng(0)
X = rng.normal(size=(4, 3))       # 4 объекта по 3 признака
W = rng.normal(size=(3, 2))       # слой: 3 входа → 2 выхода
b = np.array([0.5, -1.0])

Y = X @ W + b
print("форма выхода:", Y.shape)
print(Y.round(3))

# то же самое циклом по объектам — медленнее, но смысл один
print(np.allclose(Y, np.array([x @ W + b for x in X])))

Это и есть линейный слой нейросети. Через несколько уроков мы напишем его как класс и научимся подбирать W и b.

Почему W размера (d_in, d_out), а не наоборот

В PyTorch веса nn.Linear хранятся как (d_out, d_in), а слой считает X @ W.T + b. Математически это одно и то же. В нашей библиотеке nanotorch мы храним (d_in, d_out), чтобы формула была без транспонирования.

Итог

  • Матрица m×n — таблица чисел; в ML это данные (объекты × признаки) или веса слоя.
  • Ax — это и «скалярные произведения строк на x», и «линейная комбинация столбцов».
  • (m \times n) \cdot (n \times k) = (m \times k): внутренние размеры совпадают и сокращаются; c_{ij} — строка на столбец.
  • AB \ne BA, но (AB)C = A(BC) и (AB)^T = B^T A^T; @ — матричное умножение, * — поэлементное.
  • Линейный слой на батче — одна операция XW + b.

Практика решено 0 из 6

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Ввод ответа

Какой формы будет результат умножения матрицы (5, 3) на матрицу (3, 7)? Ответ в виде m, k, например 2, 4.

Задание 2 Один вариант ответа

Какое умножение невозможно?

Задание 3 Ввод ответа

Вычислите вручную произведение

\begin{pmatrix} 1 & 2 \\ 3 & 4 \end{pmatrix} \begin{pmatrix} 5 \\ 6 \end{pmatrix}

Ответ запишите как два числа через запятую, например 7, 8.

Задание 4 Несколько вариантов

Какие утверждения верны для матриц A, B, C подходящих размеров?

Задание 5 Программа на Python

Напишите matmul(A, B) — умножение матриц, заданных списками списков, на чистом Python (без NumPy). Если внутренние размеры не совпадают, бросьте ValueError.

Элемент результата C[i][j] — скалярное произведение i-й строки A на j-й столбец B.

Какие тесты проверяют код (5)
assert matmul([[1, 2], [3, 4]], [[5, 6], [7, 8]]) == [[19, 22], [43, 50]]
assert matmul([[1, 2, 3]], [[1], [0], [-1]]) == [[-2]]
assert matmul([[1], [2]], [[3, 4]]) == [[3, 4], [6, 8]]
try:
    matmul([[1, 2]], [[1, 2]])
    assert False, "Для форм (1, 2) и (1, 2) нужно бросить ValueError"
except ValueError:
    pass

import inspect
assert "numpy" not in inspect.getsource(matmul), "Без NumPy — напишите сами"

Задание 6 Программа на Python

Линейный слой на батче. X — матрица (N, d_in): N объектов по d_in признаков. W — матрица весов (d_in, d_out), b — вектор сдвигов (d_out,). Напишите linear(X, W, b), которая возвращает (N, d_out): для каждого объекта x @ W + b. Одной строкой, без циклов.

Какие тесты проверяют код (2)
X = np.array([[1.0, 2], [0, 1], [3, -1]])
W = np.array([[1.0, 0, 2], [0, 1, -1]])
b = np.array([10.0, 20, 30])
out = linear(X, W, b)
assert np.shape(out) == (3, 3), f"Форма должна быть (3, 3), получено {np.shape(out)}"
assert np.allclose(out, [[11, 22, 30], [10, 21, 29], [13, 19, 37]])

rng = np.random.default_rng(0)
X, W, b = rng.normal(size=(8, 4)), rng.normal(size=(4, 2)), rng.normal(size=2)
assert np.allclose(linear(X, W, b), np.array([x @ W + b for x in X]))

Войдите, чтобы отмечать пройденные уроки.