Свёрточный слой изнутри
Свёрточный слой как в PyTorch — батчи, каналы, padding, stride и dilation; размер выхода, параметры и рецептивное поле; свёртка циклами и через im2col + матричное умножение; обратный проход и особые свёртки 1×1, depthwise и причинные.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В уроке 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
Заголовок раздела «Свёрточный слой в формате PyTorch»Четыре оси: NCHW
Заголовок раздела «Четыре оси: NCHW»PyTorch хранит батч картинок как тензор формы : — число картинок в батче, — число каналов, и — высота и ширина. Батч из 8 цветных картинок 32×32 — это . Каналы входной картинки — это R, G, B. После первого слоя каналов становится больше, например 64, и каждый канал — это карта признаковкарта признаковEN feature map (activation map)Выход одного ядра свёртки — двумерная «картинка» откликов. Слой с C_out ядрами выдаёт C_out карт признаков (каналов)., выход одного ядра.
Веса слоя nn.Conv2d(C, C_out, k) — тензор : ядер, и каждое ядро имеет ту же глубину ,
что и вход. Ещё есть смещение формы , по одному числу на ядро.
Формула с каналами
Заголовок раздела «Формула с каналами»Выход в точке канала для картинки :
Здесь — вход с рамкой из нулей (padding), — шаг. Словами: ядро номер накладываем на кусок входа размером , который начинается в точке , перемножаем поэлементно и всё складываем. Суммирование идёт и по каналам: одно ядро смотрит сразу на все каналов и выдаёт одну карту признаков. Сколько ядер, столько каналов на выходе.
Пример. Вход из двух каналов 3×3, одно ядро 2×2×2, смещение :
В позиции срез ядра видит окно : . Срез видит : . Итог: . Остальные три позиции посчитайте сами, а проверим в PyTorch:
import torchimport torch.nn as nnimport 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]) 73856torch.Size([1, 128, 56, 56])Padding, stride, dilation и размер выхода
Заголовок раздела «Padding, stride, dilation и размер выхода»Padding и шаг вы знаете из урока 3.4.3. Третий параметр — dilation , «расширение». Это расширенная («дырявая») свёрткарасширенная («дырявая») свёрткаEN dilated (atrous) convolutionЯдро с промежутками: при dilation = d между соседними элементами ядра d − 1 пропущенных пикселей. Ядро 3×3 с d = 2 охватывает окно 5×5 без новых параметров.: между соседними элементами ядра пропускается пикселей. Ядро 3×3 с берёт пиксели через один и охватывает окно 5×5, а весов у него по-прежнему 9. Такое ядро занимает пикселей, и общая формула размера выхода получается подстановкой этого числа вместо :
При это ровно формула из 3.4.3: . Типичные сочетания для входа :
| Слой | Выход | Зачем | ||||
|---|---|---|---|---|---|---|
| «same»-свёртка | 3 | 1 | 1 | 1 | 32 | размер сохраняется, сеть можно делать глубокой |
| свёртка с шагом | 3 | 1 | 2 | 1 | 16 | уменьшить карту вдвое вместо пулинга |
| расширенная | 3 | 2 | 1 | 2 | 32 | шире поле зрения при тех же 9 весах |
| патч-эмбеддинг ViT | 16 | 0 | 16 | 1 | 2 | окна не перекрываются (для 224 будет 14) |
Правило для «same» при шаге 1 и нечётном : . Для получаем , для — .
Параметры и вычисления
Заголовок раздела «Параметры и вычисления»Параметры слоя: . Для Conv2d(64, 128, 3) это
— от размера картинки не зависит.
Вычисления зависят. Каждое из выходных чисел требует умножений со сложением (MAC, multiply–accumulate). На карте 56×56 тот же слой делает млн 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. С шагами и пулингом поле растёт быстрее, и это удобно считать по рекуррентной формуле. Пусть — поле после слоя, — «прыжок»: на сколько пикселей входа сдвигается окно, когда мы сдвигаемся на один пиксель карты. Начинаем с , , и каждый слой с ядром , шагом и расширением делает
Словами: ядро добавляет к полю «шагов» текущего масштаба, а шаг слоя растягивает масштаб для всех следующих слоёв. Маленькая сеть:
| Слой | ||
|---|---|---|
| свёртка 3×3 | 3 | 1 |
| свёртка 3×3 | 5 | 1 |
| пулинг 2×2, шаг 2 | 6 | 2 |
| свёртка 3×3 | 10 | 2 |
| свёртка 3×3 | 14 | 2 |
| пулинг 2×2, шаг 2 | 16 | 4 |
| свёртка 3×3 | 24 | 4 |
После каждого пулинга свёртка 3×3 добавляет к полю уже не 2, а пикселей. У ResNet-18 на выходе последней стадии теоретическое поле — 435 пикселей, больше самой картинки 224×224. Но вклад пикселей внутри поля неравномерен: центр влияет сильно, края почти никак. Ло, Ли, Уртасун и Земел (Luo, Li, Urtasun, Zemel, 2016) назвали реальную область влияния эффективным рецептивным полем, и она заметно меньше теоретической.
Расширенные свёртки растят поле экспоненциально. Стопка свёрток 3×3 с имеет поле , а параметров у неё как у четырёх обычных свёрток. Так устроен WaveNet (DeepMind, 2016) — модель, которая генерирует звук по одному отсчёту: одномерные свёртки с ядром 2 и видят тысячу прошлых отсчётов. Карпатый в лекции makemore Part 5 доводит до этой идеи нейросетевую языковую модель, которую вы соберёте в уроке 4.1.4, — к ней стоит вернуться после него.
пулингпулинг (субдискретизация)EN poolingСжатие карты признаков — максимум (max pooling) или среднее по окну, например 2×2. Обучаемых параметров нет. вы видели в 3.4.3: окно скользит по карте и выдаёт максимум (max pooling) или среднее (average pooling). Параметров нет, каналы обрабатываются независимо, размер выхода считается по той же формуле. Пример, окно 2×2 с шагом 2:
Обратный проход у max pooling простой: градиент из каждой клетки выхода уходит целиком в тот пиксель, который был максимумом, остальные три получают ноль. Это как ReLU: «кто прошёл, тот и получает градиент». У среднего градиент делится поровну на четыре пикселя.
Особый случай — глобальный средний пулингглобальный средний пулингEN global average pooling (GAP)Усредняет каждую карту признаков целиком в одно число: (N, C, H, W) → (N, C). Стоит перед классификатором в ResNet и позволяет подавать картинки любого размера.: среднее по всей карте, . Он стоит в конце ResNet перед классификатором. Последний линейный слой получает чисел при любом размере картинки, поэтому одну и ту же сеть можно применять к картинкам 224×224 и 320×320. Современные сети часто вообще обходятся без max pooling и уменьшают карту свёрткой с шагом 2: так уменьшение размера тоже обучается.
Способ 1: циклы
Заголовок раздела «Способ 1: циклы»Формула свёртки — это семь вложенных циклов: по картинкам, ядрам, строкам и столбцам выхода, каналам и двум осям ядра. Вот она дословно на 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 это млн шагов внутреннего цикла, и Python тратит на них секунды. Первое ускорение — оставить циклы только по позициям выхода , а внутри одной операцией NumPy перемножить окно со всеми ядрами сразу. Итераций становится , и каждая — маленькое матричное умножение. Это первая функция вашего задания.
Способ 2: im2col + матричное умножение
Заголовок раздела «Способ 2: im2col + матричное умножение»Второе ускорение — убрать и этот цикл. Заметим: в каждой позиции выход — это скалярное произведение окна на ядро, вытянутые в векторы длины . Значит, если выписать все окна строками одной матрицы, все выходы получатся одним умножением матриц. Этот приём называется 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 = 3rows = []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) попадают в несколько строк. В общем случае с батчем и каналами матрица окон имеет форму , а ядра вытягиваются в матрицу :
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 каждый пиксель попадает в окон, и матрица окон в раз больше входа. Для карты и ядра 3×3 это 1.8 млн чисел вместо 200 тысяч. Поэтому библиотека cuDNN, которой PyTorch пользуется на NVIDIA, не строит матрицу явно. Она вычисляет нужные окна «на лету» внутри ядра умножения (implicit GEMM), а для свёрток 3×3 умеет ещё и алгоритм Винограда, которому нужно меньше умножений.
Замер. Батч , 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. Пусть сверху пришёл градиент той же формы, что выход. Нужно получить , и (последний — чтобы передать градиент предыдущему слою).
Сначала в одномерном случае
Заголовок раздела «Сначала в одномерном случае»Вход , ядро , без смещения. Выход: , то есть . Пусть сверху пришло .
По весам. Вес участвует в каждом выходе с множителем . Складываем вклады всех позиций:
Посмотрите на формулу: скользит по как ядро. Градиент по весам — это свёртка входа с градиентом выхода. Градиент смещения — просто сумма : смещение входит в каждый выход с множителем 1, итого .
По входу. Каждый выход раздаёт градиент обратно тем входам, которые были в его окне, с весами ядра:
получает , а получает . Окна перекрываются, поэтому вклады складываются — то самое
+= из урока 3.2.2:
Это «полная» свёртка с перевёрнутым ядром: края дополнены нулями, и ядро идёт в обратном порядке. Такую операцию
называют транспонированная свёрткатранспонированная свёртка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 позиции окна длины 2g = 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 = gL = lambda w_: sum(g[i] * (x[i:i + 2] @ w_) for i in range(3))h = 1e-6print("численно 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.]Приём с стоит запомнить: производная такой функции по равна ровно . Поэтому с ним удобно проверять любой обратный проход численно (проверка градиентапроверка градиентаEN gradient checkСравнение аналитического градиента с численным (центральные разности) по относительной ошибке. Главный способ найти баг в backward. из модуля 1.2): подставляем произвольный , сдвигаем веса на и сравниваем.
В общем виде
Заголовок раздела «В общем виде»В двумерном случае с каналами формулы те же, только индексов больше:
В терминах im2col всё ещё проще. Прямой проход был . Это обычный линейный слой, только его «примеры» — окна. Его градиенты из урока 3.1.2:
- — одно умножение матриц, где — градиент, переставленный в форму ;
- — градиент по каждому окну. Его раскладывают обратно по пикселям операцией col2im, и там, где окна перекрывались, вклады складываются.
Обратный проход — это два умножения матриц того же размера, что прямой. Поэтому шаг обучения свёрточной сети стоит примерно втрое больше прямого прохода. То же правило «×3» вы встретите при подсчёте вычислений для LLM.
Особые свёртки: 1×1, depthwise, причинные
Заголовок раздела «Особые свёртки: 1×1, depthwise, причинные»| Вид | В PyTorch | Параметров | Где встречается |
|---|---|---|---|
| обычная | Conv2d(C, C_out, k) | любая CNN | |
| 1×1 (pointwise) | Conv2d(C, C_out, 1) | блоки ResNet-50, смешивание каналов | |
| depthwise | Conv2d(C, C, k, groups=C) | MobileNet, ConvNeXt | |
| причинная 1D | Conv1d(C, C, k, groups=C, padding=k-1) + срез | WaveNet, Mamba, Qwen3-Next | |
| патч-эмбеддинг | Conv2d(3, d, p, stride=p) | ViT и все VLM |
Свёртка 1×1 (свёртка 1×1свёртка 1×1 (pointwise)EN pointwise (1×1) convolutionЯдро 1×1 смешивает каналы в каждом пикселе независимо — это линейный слой, применённый к каждому пикселю. Параметров C_in · C_out.) не смотрит на соседей. В каждом пикселе она берёт вектор из
каналов и умножает его на матрицу . Это просто 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².) делает наоборот: у каждого канала свой фильтр , и каналы не смешиваются. Пара «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 смешивает каналы. При , : обычная свёртка — параметров, разделимая — , в 8.7 раза меньше.
Причинная свёртка (причинная свёрткапричинная свёрткаEN causal convolutionОдномерная свёртка по времени, которая видит только текущий и прошлые шаги: рамку из нулей добавляют только слева (k − 1 нулей). Так устроены WaveNet и короткие свёртки в слоях линейного внимания.) — одномерная свёртка по времени, которая не смотрит в будущее.
Рамку из нулей добавляют только слева. Для ядра выход , и
последовательность превращается в : каждый выход зависит только от текущего и
прошлого шага. Именно такая свёртка, depthwise и с ядром 4, стоит перед вычислением запросов, ключей и значений в слоях Gated
DeltaNet у Qwen3-Next (вспомните linear_conv_kernel_dim = 4 из 3.4.3) и в слоях Mamba. Она дёшево подмешивает к токену
три предыдущих токена. Сами слои линейного внимания разберём в уроке 5.5.3.
import numpy as nprng = 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×8y_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_tpadded = 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 — свёртка с ядром и шагом : окна не перекрываются, каждое видит ровно один патч. В уроке 9.1.1 вы проверите, что это то же самое, что нарезка на патчи плюс линейный слой. Вход Whisper — мел-спектрограмма, то есть последовательность векторов частот. Её сначала пропускают через две одномерные свёртки с ядром 3, и вторая с шагом 2 вдвое сокращает длину (урок 9.1.5).
Практика
Заголовок раздела «Практика»Реализуйте свёрточный слой на NumPy: размер выхода, свёртку циклом по позициям, im2col, свёртку одним умножением матриц
и градиенты по весам и смещению. Тесты сравнят ваши функции с независимым эталоном при разных stride и padding и
проверят градиенты численно. В конце ваш conv2d_im2col с фильтром Собеля найдёт края квадрата, нарисованного NumPy.
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 на краях квадрата
После решения поэкспериментируйте:
- Замерьте
conv2d_loopsиconv2d_im2colчерезtime.perf_counter()на батче с 32 ядрами. Какой способ быстрее у вас? А на картинке 128×128 с 3 каналами? - Транспонируйте ядро Собеля (
sobel_x.T). Какие края теперь находит фильтр? - Придумайте ядро 3×3, которое размывает картинку: каждый пиксель заменяется средним по окну. Что станет с краями квадрата?
Проверь себя
Заголовок раздела «Проверь себя»- Слой
Conv2d(C, C_out, k): вход , веса . Каждое ядро смотрит на все входные каналы и выдаёт одну карту признаков. Размер выхода: . - Параметров , от картинки они не зависят. Вычислений MAC, пропорционально площади. Рецептивное поле растёт на за слой, а шаги и пулинг увеличивают прыжок .
- im2col превращает свёртку в одно умножение матриц: окна — строки, ядра — столбцы. Это быстро, потому что GEMM оптимизирован лучше всего, но матрица окон в раз больше входа. cuDNN считает так же, только без явной матрицы.
- Обратный проход: — свёртка входа с градиентом выхода, — сумма градиента, — транспонированная свёртка (перекрытия складываются). Шаг обучения ≈ 3 прямых прохода.
- Свёртка 1×1 — линейный слой в каждом пикселе, depthwise — фильтр на канал, причинная 1D-свёртка видит только прошлое (её используют гибридные LLM), патч-эмбеддинг ViT — свёртка с ядром и шагом .
Дальше: «Обучаем CNN: от CIFAR-10 до переноса обучения» — соберём на PyTorch простую CNN и маленький ResNet, обучим их на CIFAR-10 с аугментациями, дообучим предобученную сеть на своих картинках и посмотрим, как устроены детекция и U-Net.