Перейти к содержимому
Ступень 33.5 Компьютерное зрение и свёрточные сети

Свёрточный слой изнутри

Свёрточный слой как в PyTorch — батчи, каналы, padding, stride и dilation; размер выхода, параметры и рецептивное поле; свёртка циклами и через im2col + матричное умножение; обратный проход и особые свёртки 1×1, depthwise и причинные.

УРОКstage-3/3-5-computer-vision/01-convolution-from-scratch.mdx2 чУрок 1 из 2
После урока вы сможете
  • Посчитать для свёрточного слоя размер выхода, число параметров, число умножений и рецептивное поле с учётом каналов, padding, stride и dilation
  • Реализовать conv2d на NumPy двумя способами — циклом по позициям и через im2col + матричное умножение — и объяснить, почему на GPU свёртку сводят к умножению матриц
  • Вывести градиенты свёртки по весам, смещению и входу и проверить их численным градиентом
  • Узнавать свёртки 1×1, depthwise и причинные в ResNet, MobileNet, ViT и гибридных LLM

В уроке 3.4.3 вы посчитали свёртку руками на картинке 5×5, вывели формулу размера выхода и увидели, почему свёртка экономнее полносвязного слоя. Там был один канал и одно ядро. Настоящий слой nn.Conv2d работает с батчем цветных картинок, у него десятки ядер, и каждое ядро трёхмерное. Сегодня соберём такой слой целиком: разберём размерности, посчитаем параметры, умножения и рецептивное поле, реализуем свёртку на NumPy двумя способами и выведем обратный проход.

Курс про языковые модели, но свёртки в нём встретятся ещё не раз. Патч-эмбеддинг ViT — это свёртка (урок 9.1.1). В слоях линейного внимания Qwen3-Next и Kimi Linear стоят короткие причинные свёртки (урок 5.5.3). Денойзер диффузии долго был свёрточной U-Net (урок 9.1.2), а Whisper начинает обработку звука с двух одномерных свёрток. Есть и инженерный урок: на GPU свёртку считают через умножение матриц, ту же операцию, на которой держится трансформер. Поняв этот трюк, вы поймёте, почему всё в глубоком обучении сводят к матричным умножениям.

PyTorch хранит батч картинок как тензор формы (N,C,H,W)(N, C, H, W): NN — число картинок в батче, CC — число каналов, HH и WW — высота и ширина. Батч из 8 цветных картинок 32×32 — это (8,3,32,32)(8, 3, 32, 32). Каналы входной картинки — это R, G, B. После первого слоя каналов становится больше, например 64, и каждый канал — это карта признаковкарта признаковEN feature map (activation map)Выход одного ядра свёртки — двумерная «картинка» откликов. Слой с C_out ядрами выдаёт C_out карт признаков (каналов)., выход одного ядра.

Веса слоя nn.Conv2d(C, C_out, k) — тензор (Cout,C,k,k)(C_{out}, C, k, k): CoutC_{out} ядер, и каждое ядро имеет ту же глубину CC, что и вход. Ещё есть смещение формы (Cout,)(C_{out},), по одному числу на ядро.

Выход в точке (i,j)(i, j) канала oo для картинки nn:

y[n,o,i,j]=bo+∑c=0C−1∑u=0k−1∑v=0k−1W[o,c,u,v]⋅xp[n, c, is+u, js+v].y[n, o, i, j] = b_o + \sum_{c=0}^{C-1} \sum_{u=0}^{k-1} \sum_{v=0}^{k-1} W[o, c, u, v] \cdot x_p[n,\, c,\, i s + u,\, j s + v].

Здесь xpx_p — вход с рамкой из нулей (padding), ss — шаг. Словами: ядро номер oo накладываем на кусок входа размером C×k×kC \times k \times k, который начинается в точке (is,js)(is, js), перемножаем поэлементно и всё складываем. Суммирование идёт и по каналам: одно ядро смотрит сразу на все CC каналов и выдаёт одну карту признаков. Сколько ядер, столько каналов на выходе.

Пример. Вход из двух каналов 3×3, одно ядро 2×2×2, смещение b=1b = 1:

xR=(120013210),xG=(011102111),WR=(100−1),WG=(0110).x_R = \begin{pmatrix} 1 & 2 & 0 \\ 0 & 1 & 3 \\ 2 & 1 & 0 \end{pmatrix},\quad x_G = \begin{pmatrix} 0 & 1 & 1 \\ 1 & 0 & 2 \\ 1 & 1 & 1 \end{pmatrix},\quad W_R = \begin{pmatrix} 1 & 0 \\ 0 & -1 \end{pmatrix},\quad W_G = \begin{pmatrix} 0 & 1 \\ 1 & 0 \end{pmatrix}.

В позиции (0,0)(0, 0) срез ядра WRW_R видит окно (1201)\begin{pmatrix} 1 & 2 \\ 0 & 1 \end{pmatrix}: 1⋅1+1⋅(−1)=01 \cdot 1 + 1 \cdot (-1) = 0. Срез WGW_G видит (0110)\begin{pmatrix} 0 & 1 \\ 1 & 0 \end{pmatrix}: 1+1=21 + 1 = 2. Итог: 0+2+1=30 + 2 + 1 = 3. Остальные три позиции посчитайте сами, а проверим в PyTorch:

import torch
import torch.nn as nn
import torch.nn.functional as F
x = torch.tensor([[[1, 2, 0], [0, 1, 3], [2, 1, 0]], # канал R
[[0, 1, 1], [1, 0, 2], [1, 1, 1]]], # канал G
dtype=torch.float32)[None] # [None] добавляет ось батча: (1, 2, 3, 3)
W = torch.tensor([[[1, 0], [0, -1]], # срез ядра для R
[[0, 1], [1, 0]]], dtype=torch.float32)[None] # (C_out=1, C=2, 2, 2)
b = torch.tensor([1.0])
print(F.conv2d(x, W, b))
conv = nn.Conv2d(64, 128, kernel_size=3, padding=1)
print(conv.weight.shape, conv.bias.shape, sum(p.numel() for p in conv.parameters()))
print(conv(torch.randn(1, 64, 56, 56)).shape)
tensor([[[[3., 1.],
[1., 5.]]]])
torch.Size([128, 64, 3, 3]) torch.Size([128]) 73856
torch.Size([1, 128, 56, 56])

Padding pp и шаг ss вы знаете из урока 3.4.3. Третий параметр — dilation dd, «расширение». Это расширенная («дырявая») свёрткарасширенная («дырявая») свёрткаEN dilated (atrous) convolutionЯдро с промежутками: при dilation = d между соседними элементами ядра d − 1 пропущенных пикселей. Ядро 3×3 с d = 2 охватывает окно 5×5 без новых параметров.: между соседними элементами ядра пропускается d−1d - 1 пикселей. Ядро 3×3 с d=2d = 2 берёт пиксели через один и охватывает окно 5×5, а весов у него по-прежнему 9. Такое ядро занимает d(k−1)+1d(k - 1) + 1 пикселей, и общая формула размера выхода получается подстановкой этого числа вместо kk:

nout=⌊n+2p−d(k−1)−1s⌋+1.n_{out} = \left\lfloor \frac{n + 2p - d(k-1) - 1}{s} \right\rfloor + 1.

При d=1d = 1 это ровно формула из 3.4.3: ⌊(n+2p−k)/s⌋+1\lfloor (n + 2p - k)/s \rfloor + 1. Типичные сочетания для входа n=32n = 32:

СлойkkppssddВыходЗачем
«same»-свёртка311132размер сохраняется, сеть можно делать глубокой
свёртка с шагом312116уменьшить карту вдвое вместо пулинга
расширенная321232шире поле зрения при тех же 9 весах
патч-эмбеддинг ViT1601612окна не перекрываются (для 224 будет 14)

Правило для «same» при шаге 1 и нечётном kk: p=d(k−1)/2p = d(k - 1)/2. Для k=3,d=1k = 3, d = 1 получаем p=1p = 1, для k=3,d=2k = 3, d = 2 — p=2p = 2.

Параметры слоя: Cout⋅C⋅k2+CoutC_{out} \cdot C \cdot k^2 + C_{out}. Для Conv2d(64, 128, 3) это 128⋅64⋅9+128=73 856128 \cdot 64 \cdot 9 + 128 = 73\,856 — от размера картинки не зависит.

Вычисления зависят. Каждое из Cout⋅Hout⋅WoutC_{out} \cdot H_{out} \cdot W_{out} выходных чисел требует C⋅k2C \cdot k^2 умножений со сложением (MAC, multiply–accumulate). На карте 56×56 тот же слой делает 128⋅56⋅56⋅64⋅9≈231128 \cdot 56 \cdot 56 \cdot 64 \cdot 9 \approx 231 млн MAC на одну картинку. Вдвое больше сторона картинки — вчетверо больше вычислений при тех же весах. Поэтому CNN обычно уменьшают карту (шагом или пулингом) и одновременно увеличивают число каналов: 64 канала на 56×56, затем 128 на 28×28, 256 на 14×14. Вычисления на слой при этом остаются примерно одинаковыми.

рецептивное полерецептивное полеEN receptive fieldОбласть входа, от которой зависит один нейрон. Две свёртки 3×3 подряд видят 5×5, три — 7×7: глубина расширяет «взгляд». нейрона — область исходной картинки, от которой он зависит. В 3.4.3 мы видели: две свёртки 3×3 подряд видят 5×5, три — 7×7. С шагами и пулингом поле растёт быстрее, и это удобно считать по рекуррентной формуле. Пусть rr — поле после слоя, jj — «прыжок»: на сколько пикселей входа сдвигается окно, когда мы сдвигаемся на один пиксель карты. Начинаем с r0=1r_0 = 1, j0=1j_0 = 1, и каждый слой с ядром kk, шагом ss и расширением dd делает

rl=rl−1+(kl−1) dl jl−1,jl=jl−1⋅sl.r_l = r_{l-1} + (k_l - 1)\, d_l\, j_{l-1}, \qquad j_l = j_{l-1} \cdot s_l.

Словами: ядро добавляет к полю k−1k - 1 «шагов» текущего масштаба, а шаг слоя растягивает масштаб для всех следующих слоёв. Маленькая сеть:

Слойrrjj
свёртка 3×331
свёртка 3×351
пулинг 2×2, шаг 262
свёртка 3×3102
свёртка 3×3142
пулинг 2×2, шаг 2164
свёртка 3×3244

После каждого пулинга свёртка 3×3 добавляет к полю уже не 2, а 2j2j пикселей. У ResNet-18 на выходе последней стадии теоретическое поле — 435 пикселей, больше самой картинки 224×224. Но вклад пикселей внутри поля неравномерен: центр влияет сильно, края почти никак. Ло, Ли, Уртасун и Земел (Luo, Li, Urtasun, Zemel, 2016) назвали реальную область влияния эффективным рецептивным полем, и она заметно меньше теоретической.

Расширенные свёртки растят поле экспоненциально. Стопка свёрток 3×3 с d=1,2,4,8d = 1, 2, 4, 8 имеет поле 3,7,15,313, 7, 15, 31, а параметров у неё как у четырёх обычных свёрток. Так устроен WaveNet (DeepMind, 2016) — модель, которая генерирует звук по одному отсчёту: одномерные свёртки с ядром 2 и d=1,2,4,…,512d = 1, 2, 4, \ldots, 512 видят тысячу прошлых отсчётов. Карпатый в лекции makemore Part 5 доводит до этой идеи нейросетевую языковую модель, которую вы соберёте в уроке 4.1.4, — к ней стоит вернуться после него.

пулингпулинг (субдискретизация)EN poolingСжатие карты признаков — максимум (max pooling) или среднее по окну, например 2×2. Обучаемых параметров нет. вы видели в 3.4.3: окно скользит по карте и выдаёт максимум (max pooling) или среднее (average pooling). Параметров нет, каналы обрабатываются независимо, размер выхода считается по той же формуле. Пример, окно 2×2 с шагом 2:

(1320421101522234)→ max⁡ (4225),→ среднее (2.511.253.5).\begin{pmatrix} 1 & 3 & 2 & 0 \\ 4 & 2 & 1 & 1 \\ 0 & 1 & 5 & 2 \\ 2 & 2 & 3 & 4 \end{pmatrix} \xrightarrow{\ \max\ } \begin{pmatrix} 4 & 2 \\ 2 & 5 \end{pmatrix}, \qquad \xrightarrow{\ \text{среднее}\ } \begin{pmatrix} 2.5 & 1 \\ 1.25 & 3.5 \end{pmatrix}.

Обратный проход у max pooling простой: градиент из каждой клетки выхода уходит целиком в тот пиксель, который был максимумом, остальные три получают ноль. Это как ReLU: «кто прошёл, тот и получает градиент». У среднего градиент делится поровну на четыре пикселя.

Особый случай — глобальный средний пулингглобальный средний пулингEN global average pooling (GAP)Усредняет каждую карту признаков целиком в одно число: (N, C, H, W) → (N, C). Стоит перед классификатором в ResNet и позволяет подавать картинки любого размера.: среднее по всей карте, (N,C,H,W)→(N,C)(N, C, H, W) \to (N, C). Он стоит в конце ResNet перед классификатором. Последний линейный слой получает CC чисел при любом размере картинки, поэтому одну и ту же сеть можно применять к картинкам 224×224 и 320×320. Современные сети часто вообще обходятся без max pooling и уменьшают карту свёрткой с шагом 2: так уменьшение размера тоже обучается.

Формула свёртки — это семь вложенных циклов: по картинкам, ядрам, строкам и столбцам выхода, каналам и двум осям ядра. Вот она дословно на Python:

import numpy as np
def conv2d_python(x, W, b, padding=1):
"""Семь вложенных циклов на чистом Python — дословная формула свёртки (stride = 1)."""
N, C, H, Wd = x.shape
C_out, _, k, _ = W.shape
xp = np.pad(x, ((0, 0), (0, 0), (padding, padding), (padding, padding))) # рамка из нулей
H_out, W_out = H + 2 * padding - k + 1, Wd + 2 * padding - k + 1
out = np.zeros((N, C_out, H_out, W_out))
for n in range(N): # картинка батча
for o in range(C_out): # ядро = выходной канал
for i in range(H_out): # строка выхода
for j in range(W_out): # столбец выхода
s = b[o]
for c in range(C): # входной канал
for u in range(k): # строка ядра
for v in range(k): # столбец ядра
s += W[o, c, u, v] * xp[n, c, i + u, j + v]
out[n, o, i, j] = s
return out

Для батча 8×16×32×32 и 32 ядер 3×3 это 8⋅32⋅32⋅32⋅16⋅9≈388 \cdot 32 \cdot 32 \cdot 32 \cdot 16 \cdot 9 \approx 38 млн шагов внутреннего цикла, и Python тратит на них секунды. Первое ускорение — оставить циклы только по позициям выхода (i,j)(i, j), а внутри одной операцией NumPy перемножить окно (N,C,k,k)(N, C, k, k) со всеми ядрами сразу. Итераций становится Hout⋅Wout=1024H_{out} \cdot W_{out} = 1024, и каждая — маленькое матричное умножение. Это первая функция вашего задания.

Второе ускорение — убрать и этот цикл. Заметим: в каждой позиции выход — это скалярное произведение окна на ядро, вытянутые в векторы длины C⋅k2C \cdot k^2. Значит, если выписать все окна строками одной матрицы, все выходы получатся одним умножением матриц. Этот приём называется im2colim2col (развёртка окон в матрицу)EN im2col (image to column)Каждое окно свёртки вытягивают в строку матрицы, и свёртка превращается в одно матричное умножение. Так считал свёртку Caffe; cuDNN делает то же без явной матрицы (implicit GEMM). (image to column).

Пример на картинке 4×4 с числами 0…15 и ядре 3×3: окон четыре, в каждом 9 чисел.

import numpy as np
x = np.arange(16).reshape(4, 4) # картинка 4×4: числа 0…15, чтобы видеть, откуда что взялось
k = 3
rows = []
for i in range(4 - k + 1): # 2 позиции по вертикали
for j in range(4 - k + 1): # 2 позиции по горизонтали
rows.append(x[i:i + k, j:j + k].ravel()) # окно 3×3 → строка из 9 чисел
cols = np.array(rows) # матрица окон: (4 окна, 9 чисел)
print(cols)
w = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]) # ядро Собеля: «ищет» рост яркости слева направо
y = cols @ w.ravel() # все 4 позиции — одним умножением матрицы на вектор
print(y.reshape(2, 2))
[[ 0 1 2 4 5 6 8 9 10]
[ 1 2 3 5 6 7 9 10 11]
[ 4 5 6 8 9 10 12 13 14]
[ 5 6 7 9 10 11 13 14 15]]
[[8 8]
[8 8]]

Видно главное свойство im2col: соседние окна перекрываются, и одни и те же пиксели (5, 6, 9, 10) попадают в несколько строк. В общем случае с батчем и каналами матрица окон имеет форму (N⋅Hout⋅Wout, Ck2)(N \cdot H_{out} \cdot W_{out},\ C k^2), а ядра вытягиваются в матрицу (Cout, Ck2)(C_{out},\ C k^2):

flowchart LR
X["Вход (N, C, H, W)"] -->|"im2col"| M["Окна строками<br/>(N·H_out·W_out, C·k·k)"]
K["Ядра (C_out, C, k, k)"] -->|"reshape"| KT["Ядра столбцами<br/>(C·k·k, C_out)"]
M --> G["Одно умножение матриц (GEMM)"]
KT --> G
G --> R["(N·H_out·W_out, C_out)"] -->|"reshape + transpose"| Y["Выход (N, C_out, H_out, W_out)"]
Свёртка как умножение матриц: окна — строки, ядра — столбцы

Почему это быстрее, хотя арифметики столько же? Умножение больших матриц — GEMMGEMM (общее матричное умножение)EN general matrix multiplication (GEMM)Операция C = αAB + βC из библиотек BLAS. Самая оптимизированная операция на CPU и GPU; к ней сводят свёртки, линейные слои и внимание. — самая вылизанная операция в вычислениях. Библиотеки BLAS на CPU и cuBLAS на GPU (с тензорными ядрами) выжимают из железа почти максимум: правильно раскладывают данные по кэшу, считают блоками, используют векторные инструкции. Свести задачу к GEMM — значит получить всю эту оптимизацию бесплатно.

Цена — память. При шаге 1 каждый пиксель попадает в k2k^2 окон, и матрица окон в k2k^2 раз больше входа. Для карты 64×56×5664 \times 56 \times 56 и ядра 3×3 это 1.8 млн чисел вместо 200 тысяч. Поэтому библиотека cuDNN, которой PyTorch пользуется на NVIDIA, не строит матрицу явно. Она вычисляет нужные окна «на лету» внутри ядра умножения (implicit GEMM), а для свёрток 3×3 умеет ещё и алгоритм Винограда, которому нужно меньше умножений.

Замер. Батч 8×16×32×328 \times 16 \times 32 \times 32, 32 ядра 3×3, padding=1, один поток CPU:

СпособВремя
7 циклов на чистом Python≈ 1.1 с на одну картинку, ≈ 9 с на батч
цикл по позициям + NumPy≈ 30 мс
im2col + матричное умножение (NumPy)≈ 18 мс, из них само умножение ≈ 3 мс
F.conv2d в PyTorch, float32≈ 1 мс

Цифры зависят от процессора и нагрузки: на многоядерном BLAS умножение быстрее, а копирование окон — нет. Видно, куда уходит время в нашей NumPy-версии: на копирование окон, а не на арифметику. Поэтому оптимизированные библиотеки и избегают явной матрицы. Повторите замер у себя после задания.

Свёртка — линейная операция: каждый выход — сумма произведений весов на входы. Значит, градиенты выводятся тем же цепным правилом, что для линейного слоя в уроке 3.1.2. Пусть сверху пришёл градиент G=∂L/∂yG = \partial L / \partial y той же формы, что выход. Нужно получить ∂L/∂W\partial L/\partial W, ∂L/∂b\partial L/\partial b и ∂L/∂x\partial L/\partial x (последний — чтобы передать градиент предыдущему слою).

Вход x=(1,2,3,4)x = (1, 2, 3, 4), ядро w=(1,−1)w = (1, -1), без смещения. Выход: yi=w0xi+w1xi+1y_i = w_0 x_i + w_1 x_{i+1}, то есть y=(−1,−1,−1)y = (-1, -1, -1). Пусть сверху пришло g=(1,0,2)g = (1, 0, 2).

По весам. Вес wuw_u участвует в каждом выходе yiy_i с множителем xi+ux_{i+u}. Складываем вклады всех позиций:

∂L∂wu=∑igi xi+u:∂L∂w0=1⋅1+0⋅2+2⋅3=7,∂L∂w1=1⋅2+0⋅3+2⋅4=10.\frac{\partial L}{\partial w_u} = \sum_i g_i\, x_{i+u}: \qquad \frac{\partial L}{\partial w_0} = 1 \cdot 1 + 0 \cdot 2 + 2 \cdot 3 = 7, \quad \frac{\partial L}{\partial w_1} = 1 \cdot 2 + 0 \cdot 3 + 2 \cdot 4 = 10.

Посмотрите на формулу: gg скользит по xx как ядро. Градиент по весам — это свёртка входа с градиентом выхода. Градиент смещения — просто сумма gg: смещение входит в каждый выход с множителем 1, итого 1+0+2=31 + 0 + 2 = 3.

По входу. Каждый выход yiy_i раздаёт градиент gig_i обратно тем входам, которые были в его окне, с весами ядра: xix_i получает giw0g_i w_0, а xi+1x_{i+1} получает giw1g_i w_1. Окна перекрываются, поэтому вклады складываются — то самое += из урока 3.2.2:

∂L∂x=(g0w0, g0w1+g1w0, g1w1+g2w0, g2w1)=(1,−1,2,−2).\frac{\partial L}{\partial x} = (g_0 w_0,\ g_0 w_1 + g_1 w_0,\ g_1 w_1 + g_2 w_0,\ g_2 w_1) = (1, -1, 2, -2).

Это «полная» свёртка gg с перевёрнутым ядром: края дополнены нулями, и ядро идёт в обратном порядке. Такую операцию называют транспонированная свёрткатранспонированная свёрткаEN transposed convolutionОперация, которая увеличивает карту признаков (например, 16×16 → 32×32). Математически это обратный проход обычной свёртки по входу; в PyTorch — nn.ConvTranspose2d. Встречается в декодере U-Net.. Она же отдельный слой nn.ConvTranspose2d, который увеличивает карту признаков. Он встретится в декодере U-Net в следующем уроке.

import numpy as np
x = np.array([1.0, 2, 3, 4])
w = np.array([1.0, -1])
y = np.array([x[i:i + 2] @ w for i in range(3)]) # прямой проход: 3 позиции окна длины 2
g = np.array([1.0, 0, 2]) # dL/dy — «пришло сверху»
dw = np.array([g @ x[u:u + 3] for u in range(2)]) # dw_u = Σ_i g_i · x_{i+u}: g скользит по входу
dx = np.zeros(4)
for i in range(3): # каждое окно возвращает свою долю градиента
dx[i:i + 2] += g[i] * w # += : окна перекрываются, вклады складываются
print("y =", y, " dw =", dw, " db =", g.sum(), " dx =", dx)
# проверка численным градиентом: L(w) = Σ y·g, тогда dL/dy = g
L = lambda w_: sum(g[i] * (x[i:i + 2] @ w_) for i in range(3))
h = 1e-6
print("численно dw:", np.round([(L(w + h * e) - L(w - h * e)) / (2 * h) for e in np.eye(2)], 6))
y = [-1. -1. -1.] dw = [ 7. 10.] db = 3.0 dx = [ 1. -1. 2. -2.]
численно dw: [ 7. 10.]

Приём с L=∑y⋅gL = \sum y \cdot g стоит запомнить: производная такой функции по yy равна ровно gg. Поэтому с ним удобно проверять любой обратный проход численно (проверка градиентапроверка градиентаEN gradient checkСравнение аналитического градиента с численным (центральные разности) по относительной ошибке. Главный способ найти баг в backward. из модуля 1.2): подставляем произвольный gg, сдвигаем веса на ±h\pm h и сравниваем.

В двумерном случае с каналами формулы те же, только индексов больше:

∂L∂W[o,c,u,v]=∑n,i,jG[n,o,i,j]  xp[n, c, is+u, js+v],∂L∂bo=∑n,i,jG[n,o,i,j].\frac{\partial L}{\partial W[o, c, u, v]} = \sum_{n, i, j} G[n, o, i, j]\; x_p[n,\, c,\, is + u,\, js + v], \qquad \frac{\partial L}{\partial b_o} = \sum_{n, i, j} G[n, o, i, j].

В терминах im2col всё ещё проще. Прямой проход был Y=cols⋅Wflat⊤Y = \text{cols} \cdot W_{flat}^\top. Это обычный линейный слой, только его «примеры» — окна. Его градиенты из урока 3.1.2:

  • dWflat=Grows⊤⋅colsdW_{flat} = G_{rows}^\top \cdot \text{cols} — одно умножение матриц, где GrowsG_{rows} — градиент, переставленный в форму (N⋅Hout⋅Wout, Cout)(N \cdot H_{out} \cdot W_{out},\ C_{out});
  • d cols=Grows⋅Wflatd\,\text{cols} = G_{rows} \cdot W_{flat} — градиент по каждому окну. Его раскладывают обратно по пикселям операцией col2im, и там, где окна перекрывались, вклады складываются.

Обратный проход — это два умножения матриц того же размера, что прямой. Поэтому шаг обучения свёрточной сети стоит примерно втрое больше прямого прохода. То же правило «×3» вы встретите при подсчёте вычислений для LLM.

ВидВ PyTorchПараметровГде встречается
обычная k×kk \times kConv2d(C, C_out, k)CoutCk2C_{out} C k^2любая CNN
1×1 (pointwise)Conv2d(C, C_out, 1)CoutCC_{out} Cблоки ResNet-50, смешивание каналов
depthwiseConv2d(C, C, k, groups=C)Ck2C k^2MobileNet, ConvNeXt
причинная 1DConv1d(C, C, k, groups=C, padding=k-1) + срезCkC kWaveNet, Mamba, Qwen3-Next
патч-эмбеддингConv2d(3, d, p, stride=p)3dp23 d p^2ViT и все VLM

Свёртка 1×1 (свёртка 1×1свёртка 1×1 (pointwise)EN pointwise (1×1) convolutionЯдро 1×1 смешивает каналы в каждом пикселе независимо — это линейный слой, применённый к каждому пикселю. Параметров C_in · C_out.) не смотрит на соседей. В каждом пикселе она берёт вектор из CC каналов и умножает его на матрицу Cout×CC_{out} \times C. Это просто nn.Linear, применённый к каждому пикселю по отдельности. Ею меняют число каналов: в «бутылочных» блоках ResNet-50 свёртка 1×1 сужает 256 каналов до 64, тяжёлая свёртка 3×3 работает на узком тензоре, и вторая 1×1 возвращает 256.

Depthwise (поканальная (depthwise) свёрткапоканальная (depthwise) свёрткаEN depthwise convolutionСвой фильтр k×k для каждого канала, каналы не смешиваются (groups = C в PyTorch). Параметров C · k² вместо C² · k².) делает наоборот: у каждого канала свой фильтр k×kk \times k, и каналы не смешиваются. Пара «depthwise + 1×1» — разделимая (depthwise separable) свёрткаразделимая (depthwise separable) свёрткаEN depthwise separable convolutionПоканальная свёртка k×k, за ней свёртка 1×1. Основа MobileNet и Xception: при C = 256 и k = 3 параметров в 8.7 раза меньше, чем у обычной свёртки. из MobileNet — разделяет две работы обычной свёртки: depthwise собирает пространственный контекст, а 1×1 смешивает каналы. При C=256C = 256, k=3k = 3: обычная свёртка — 256⋅256⋅9=589 824256 \cdot 256 \cdot 9 = 589\,824 параметров, разделимая — 256⋅9+256⋅256=67 840256 \cdot 9 + 256 \cdot 256 = 67\,840, в 8.7 раза меньше.

Причинная свёртка (причинная свёрткапричинная свёрткаEN causal convolutionОдномерная свёртка по времени, которая видит только текущий и прошлые шаги: рамку из нулей добавляют только слева (k − 1 нулей). Так устроены WaveNet и короткие свёртки в слоях линейного внимания.) — одномерная свёртка по времени, которая не смотрит в будущее. Рамку из k−1k - 1 нулей добавляют только слева. Для ядра (0.5,0.5)(0.5, 0.5) выход yt=0.5xt−1+0.5xty_t = 0.5 x_{t-1} + 0.5 x_t, и последовательность (1,2,3,4)(1, 2, 3, 4) превращается в (0.5,1.5,2.5,3.5)(0.5, 1.5, 2.5, 3.5): каждый выход зависит только от текущего и прошлого шага. Именно такая свёртка, depthwise и с ядром 4, стоит перед вычислением запросов, ключей и значений в слоях Gated DeltaNet у Qwen3-Next (вспомните linear_conv_kernel_dim = 4 из 3.4.3) и в слоях Mamba. Она дёшево подмешивает к токену три предыдущих токена. Сами слои линейного внимания разберём в уроке 5.5.3.

import numpy as np
rng = np.random.default_rng(0)
# 1) свёртка 1×1 — это линейный слой в каждом пикселе
x = rng.standard_normal((2, 8, 5, 5)) # (N, C=8, 5, 5)
W = rng.standard_normal((4, 8, 1, 1)) # 4 ядра 1×1×8
y_conv = np.einsum("nchw,oc->nohw", x, W[:, :, 0, 0]) # свёртка 1×1 по определению
y_lin = (x.transpose(0, 2, 3, 1) @ W[:, :, 0, 0].T).transpose(0, 3, 1, 2) # Linear(8→4) к каждому пикселю
print(np.allclose(y_conv, y_lin))
# 2) причинная свёртка по времени: рамка из нулей только слева
seq = np.array([1.0, 2, 3, 4])
kern = np.array([0.5, 0.5]) # y_t = 0.5·x_(t−1) + 0.5·x_t
padded = np.concatenate([np.zeros(len(kern) - 1), seq]) # k − 1 нулей слева
print(np.array([padded[t:t + len(kern)] @ kern for t in range(len(seq))]))
True
[0.5 1.5 2.5 3.5]

Запись np.einsum("nchw,oc->nohw", ...) читается так: перемножить элементы с совпадающей буквой c и просуммировать по ней, а оси n, h, w, o оставить в указанном порядке.

Патч-эмбеддинг ViT — свёртка с ядром и шагом pp: окна не перекрываются, каждое видит ровно один патч. В уроке 9.1.1 вы проверите, что это то же самое, что нарезка на патчи плюс линейный слой. Вход Whisper — мел-спектрограмма, то есть последовательность векторов частот. Её сначала пропускают через две одномерные свёртки с ядром 3, и вторая с шагом 2 вдвое сокращает длину (урок 9.1.5).

Реализуйте свёрточный слой на NumPy: размер выхода, свёртку циклом по позициям, im2col, свёртку одним умножением матриц и градиенты по весам и смещению. Тесты сравнят ваши функции с независимым эталоном при разных stride и padding и проверят градиенты численно. В конце ваш conv2d_im2col с фильтром Собеля найдёт края квадрата, нарисованного NumPy.

Свёрточный слой на NumPy: циклы, im2col, обратный проходnumpy · нужен интернет7 тестов
import numpy as np

# Формат как в PyTorch: x — (N, C, H, W): батч, каналы, высота, ширина;
# W — (C_out, C, k, k): C_out ядер, каждое размером C × k × k; b — (C_out,).


def conv_output_size(n, k, padding=0, stride=1, dilation=1):
    """Размер выхода свёртки по одной оси: ⌊(n + 2p − d·(k − 1) − 1) / s⌋ + 1.

    Подсказка: d·(k − 1) + 1 — сколько пикселей занимает ядро «с дырками»; // — деление с округлением вниз.
    """
    # TODO
    ...


def pad(x, p):
    """Рамка из нулей шириной p вокруг каждой картинки (только по осям H и W). Готово."""
    return np.pad(x, ((0, 0), (0, 0), (p, p), (p, p)))


def conv2d_loops(x, W, b, stride=1, padding=0):
    """Свёртка циклом по позициям выхода (i, j). Возвращает массив (N, C_out, H_out, W_out).

    В позиции (i, j) окно — это xp[:, :, i*stride : i*stride + k, j*stride : j*stride + k], форма (N, C, k, k).
    out[:, :, i, j] = сумма произведений окна и каждого ядра по осям (C, k, k) + b.
    Удобно: np.tensordot(window, W, axes=([1, 2, 3], [1, 2, 3])) даёт сразу (N, C_out).
    """
    # TODO: размеры выхода через conv_output_size, xp = pad(x, padding), два цикла по i и j
    ...


def im2col(x, k, stride=1, padding=0):
    """Все окна k×k как строки матрицы: (N, C, H, W) → (N·H_out·W_out, C·k·k).

    Порядок строк: сначала все окна картинки 0 (по строкам выхода, слева направо), затем картинки 1 и т. д.
    Порядок внутри строки: как у window.ravel() для окна формы (C, k, k).
    Быстрый способ: массив cols формы (N, C, k, k, H_out, W_out); цикл по (u, v) внутри ядра —
    cols[:, :, u, v] = xp[:, :, u : u + stride*H_out : stride, v : v + stride*W_out : stride];
    в конце transpose(0, 4, 5, 1, 2, 3) и reshape.
    """
    # TODO
    ...


def conv2d_im2col(x, W, b, stride=1, padding=0):
    """Свёртка одним матричным умножением: im2col(x) @ W.reshape(C_out, -1).T + b.

    Результат формы (N·H_out·W_out, C_out) нужно вернуть к (N, C_out, H_out, W_out):
    reshape(N, H_out, W_out, C_out), затем transpose(0, 3, 1, 2).
    """
    # TODO
    ...


def conv2d_backward(x, W, dout, stride=1, padding=0):
    """Градиенты по весам и смещению. dout = dL/d(выход), форма (N, C_out, H_out, W_out).

    Возвращает (dW той же формы, что W; db формы (C_out,)).
    dW[o, c, u, v] = Σ по n, i, j: dout[n, o, i, j] · xp[n, c, i·s + u, j·s + v] —
    в матричном виде: dout_rows.T @ im2col(x), где dout_rows — dout, переставленный в (N·H_out·W_out, C_out).
    db[o] — сумма dout по всем осям, кроме оси каналов.
    """
    # TODO
    ...


# ── Проверка для кнопки «Запустить»: фильтр Собеля на картинке, нарисованной NumPy ──
img = np.zeros((1, 1, 12, 12))
img[0, 0, 3:9, 3:9] = 1.0                                         # белый квадрат на чёрном фоне
sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=float)
edges = conv2d_im2col(img, sobel_x[None, None], np.zeros(1), padding=1)
if edges is None:
    print("conv2d_im2col пока ничего не возвращает — начните с conv_output_size и conv2d_loops")
else:
    print("отклик Собеля по x, строка 5:", edges[0, 0, 5].astype(int))   # ожидается ±4 на краях квадрата

После решения поэкспериментируйте:

  1. Замерьте conv2d_loops и conv2d_im2col через time.perf_counter() на батче (8,16,32,32)(8, 16, 32, 32) с 32 ядрами. Какой способ быстрее у вас? А на картинке 128×128 с 3 каналами?
  2. Транспонируйте ядро Собеля (sobel_x.T). Какие края теперь находит фильтр?
  3. Придумайте ядро 3×3, которое размывает картинку: каждый пиксель заменяется средним по окну. Что станет с краями квадрата?
Проверь себя6 вопросов
1 Вход 64×64, ядро 3×3, padding 2, stride 1, dilation 2. Какой размер выхода по одной оси?
2 Сколько параметров (с bias) у nn.Conv2d(128, 256, kernel_size=3)?
3 Вход (1,3,32,32)(1, 3, 32, 32), ядро 3×3, padding 1, stride 1. Какой формы будет матрица im2col?
4 Сеть: свёртка 3×3 → пулинг 2×2 с шагом 2 → свёртка 3×3 → свёртка 3×3 (все свёртки с шагом 1). Какое рецептивное поле у последнего слоя (сторона квадрата в пикселях)?
5 Выберите верные утверждения про обратный проход свёртки.Можно выбрать несколько ответов
6 Сколько весов (без смещений) у depthwise-свёртки 3×3 на 64 каналах, то есть Conv2d(64, 64, 3, groups=64, bias=False)?
  • Слой Conv2d(C, C_out, k): вход (N,C,H,W)(N, C, H, W), веса (Cout,C,k,k)(C_{out}, C, k, k). Каждое ядро смотрит на все входные каналы и выдаёт одну карту признаков. Размер выхода: ⌊(n+2p−d(k−1)−1)/s⌋+1\lfloor (n + 2p - d(k-1) - 1)/s \rfloor + 1.
  • Параметров CoutCk2+CoutC_{out} C k^2 + C_{out}, от картинки они не зависят. Вычислений CoutHoutWoutCk2C_{out} H_{out} W_{out} C k^2 MAC, пропорционально площади. Рецептивное поле растёт на (k−1)dj(k-1) d j за слой, а шаги и пулинг увеличивают прыжок jj.
  • im2col превращает свёртку в одно умножение матриц: окна — строки, ядра — столбцы. Это быстро, потому что GEMM оптимизирован лучше всего, но матрица окон в k2k^2 раз больше входа. cuDNN считает так же, только без явной матрицы.
  • Обратный проход: dWdW — свёртка входа с градиентом выхода, dbdb — сумма градиента, dxdx — транспонированная свёртка (перекрытия складываются). Шаг обучения ≈ 3 прямых прохода.
  • Свёртка 1×1 — линейный слой в каждом пикселе, depthwise — фильтр на канал, причинная 1D-свёртка видит только прошлое (её используют гибридные LLM), патч-эмбеддинг ViT — свёртка с ядром и шагом pp.

Дальше: «Обучаем CNN: от CIFAR-10 до переноса обучения» — соберём на PyTorch простую CNN и маленький ResNet, обучим их на CIFAR-10 с аугментациями, дообучим предобученную сеть на своих картинках и посмотрим, как устроены детекция и U-Net.