- Представлять изображение тензором (N, C, H, W) и нормализовать его
- Вычислять свёртку вручную, находить размер выхода и число параметров
- Строить небольшую CNN для MNIST и прослеживать формы слой за слоем
- Объяснять идею переноса обучения и адаптировать предобученную модель
Камера телефона распознаёт лица, программы-помощники врачей находят изменения на рентгеновских снимках, автомобили читают дорожные знаки. За большинством этих систем стоят свёрточные нейронные сети (CNN). В прошлом уроке мы видели, что полносвязный слой для цветного изображения 224 × 224 требует более 150 миллионов параметров. CNN опираются на два простых наблюдения: осмысленные узоры на изображении локальны (края, углы, текстуры), и один и тот же узор может появиться в любом месте снимка. Поэтому маленький фильтр «скользит» по всему изображению, а его веса общие для всех позиций.
Изображения как тензоры
Серое изображение 28 × 28 — это тензор формы (1, 28, 28): один канал, 28 строк, 28 столбцов. У цветного изображения три канала — красный, зелёный, синий: (3, H, W). Для пакетов PyTorch использует порядок осей (N, C, H, W). Пиксели обычно — целые числа 0–255; transforms.ToTensor() переводит их в интервал [0; 1], а затем каждый канал нормализуется.
- xзначение пикселя в [0; 1]
- μ, σсреднее и стандартное отклонение канала по обучающей выборке (для MNIST 0,1307 и 0,3081)
После нормализации входы примерно центрированы около нуля и имеют единичный масштаб — это ускоряет градиентный спуск.
Операция свёртки
Фильтр (ядро, kernel) — маленькая матрица весов, например 3 × 3. Он скользит по изображению, и в каждой позиции поэлементно умножается на пиксели под ним, а произведения складываются. Получается карта признаков (feature map): в ней большие значения там, где есть узор, который «ищет» фильтр. В свёрточном слое несколько фильтров, и каждый создаёт свой выходной канал; каждый фильтр охватывает все входные каналы.
- Xвходное изображение (или карта предыдущего слоя)
- Kфильтр K × K — обучаемые веса
- bсмещение фильтра
- Y[i, j]элемент (i, j) карты признаков
Двумерная свёртка для одного канала. При нескольких входных каналах сумма берётся и по каналам.
Код ниже вычисляет свёртку с нуля на NumPy. Левая половина изображения тёмная (0), правая — светлая (9); фильтр реагирует на рост яркости слева направо. Запусти код и проверь результат сам:
import numpy as np
image = np.array([
[0, 0, 0, 9, 9, 9],
[0, 0, 0, 9, 9, 9],
[0, 0, 0, 9, 9, 9],
[0, 0, 0, 9, 9, 9],
[0, 0, 0, 9, 9, 9],
])
kernel = np.array([
[-1, 0, 1],
[-1, 0, 1],
[-1, 0, 1],
])
K = kernel.shape[0]
H, W = image.shape
out = np.zeros((H - K + 1, W - K + 1), dtype=int)
for i in range(out.shape[0]):
for j in range(out.shape[1]):
out[i, j] = np.sum(image[i:i + K, j:j + K] * kernel)
print(out)▸ Ожидаемый результат
[[ 0 27 27 0] [ 0 27 27 0] [ 0 27 27 0]]
В обученных CNN фильтры первых слоёв обычно реагируют на края и цветовые переходы, средние слои — на текстуры и части (глаза, колёса), а последние — на целые объекты. После каждого слоя свёртки и пулинга область, которую «видит» нейрон, — его рецептивное поле — растёт.
- Wширина (или высота) входа в пикселях
- Kразмер ядра
- Pдополнение (padding): нулевые пиксели по краям
- Sшаг (stride): на сколько пикселей сдвигается фильтр
- Oширина выхода; ⌊ ⌋ — округление вниз
Формула размера выхода; для высоты — то же самое. Для пулинга обычно P = 0 и S = K.
- Cin, Coutчисло входных и выходных каналов
Число параметров свёрточного слоя не зависит от размера изображения — веса общие для всех позиций.
Для входа 28 × 28 найди размер выхода: а) K = 3, P = 1, S = 1; б) K = 5, P = 0, S = 1; в) K = 3, P = 1, S = 2. г) Сколько параметров в слое Conv2d(1, 16, 3) с 16 фильтрами?
Показать решениеСкрыть решение
б) (28 − 5 + 0) / 1 + 1 = 24.
в) ⌊(28 − 3 + 2) / 2⌋ + 1 = ⌊13,5⌋ + 1 = 14 — размер уменьшается вдвое.
г) (3 · 3 · 1 + 1) · 16 = 160. Для сравнения: у полносвязного слоя от изображения 28 × 28 к выходу 16 × 28 × 28 было бы 784 · 12 544 ≈ 9,8 миллиона весов!
import torch
from torch import nn
torch.manual_seed(42)
x = torch.randn(8, 1, 28, 28)
conv = nn.Conv2d(in_channels=1, out_channels=16, kernel_size=3, padding=1)
print(conv(x).shape)
print(nn.Conv2d(1, 16, kernel_size=5)(x).shape)
print(nn.Conv2d(1, 16, kernel_size=3, stride=2, padding=1)(x).shape)
print(nn.MaxPool2d(kernel_size=2)(conv(x)).shape)
print(conv.weight.shape, sum(p.numel() for p in conv.parameters()))torch.Size([8, 16, 28, 28]) torch.Size([8, 16, 24, 24]) torch.Size([8, 16, 14, 14]) torch.Size([8, 16, 14, 14]) torch.Size([16, 1, 3, 3]) 160
Пулинг и CNN для изображений 28 × 28
Max pooling оставляет из каждого окна (обычно 2 × 2) только наибольшее значение. Он вдвое уменьшает размер карты, удешевляет вычисления, добавляет устойчивость к небольшим сдвигам и вообще не имеет параметров. Average pooling берёт среднее.
Примени 2 × 2 max и average pooling (S = 2) к карте 4 × 4:
1 3 2 1
4 6 5 0
7 2 1 9
3 8 4 2
Показать решениеСкрыть решение
Max: [[6, 5], [8, 9]].
Average: [[3,5; 2], [5; 4]].
Выход 2 × 2: O = ⌊(4 − 2) / 2⌋ + 1 = 2.
Теперь соберём небольшую CNN для MNIST: два блока «свёртка → ReLU → max pooling» извлекают признаки, а в конце Flatten и полносвязный слой выдают логиты для 10 цифр. Печатаем форму после каждого слоя — это самый надёжный способ отладки CNN.
import torch
from torch import nn
class SmallCNN(nn.Module):
def __init__(self):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
)
self.classifier = nn.Sequential(nn.Flatten(), nn.Linear(32 * 7 * 7, 10))
def forward(self, x):
return self.classifier(self.features(x))
torch.manual_seed(42)
model = SmallCNN()
x = torch.randn(64, 1, 28, 28)
for layer in model.features:
x = layer(x)
print(f'{type(layer).__name__:9s} {tuple(x.shape)}')
print(model(torch.randn(64, 1, 28, 28)).shape)
print(sum(p.numel() for p in model.parameters()))Conv2d (64, 16, 28, 28) ReLU (64, 16, 28, 28) MaxPool2d (64, 16, 14, 14) Conv2d (64, 32, 14, 14) ReLU (64, 32, 14, 14) MaxPool2d (64, 32, 7, 7) torch.Size([64, 10]) 20490
Проследи формы SmallCNN и посчитай её параметры вручную. Сравни с MLP из 4-го урока (101 770 параметров).
Показать решениеСкрыть решение
conv1: (3 · 3 · 1 + 1) · 16 = 160.
conv2: (3 · 3 · 16 + 1) · 32 = 4640.
fc: (1568 + 1) · 10 = 15 690.
Итого: 20 490 — примерно в 5 раз меньше, чем у MLP, но на MNIST обычно точнее, потому что сеть использует структуру изображений.
MNIST в torchvision
Пакет torchvision (pip install torchvision) даёт готовые наборы данных (MNIST, CIFAR-10, формат ImageNet), преобразования изображений (transforms) и предобученные модели. MNIST состоит из 70 000 изображений рукописных цифр: 60 000 обучающих и 10 000 тестовых, каждое — серое 28 × 28. download=True при первом запуске скачивает файлы в папку data, а потом читает их с диска.
import torch
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
torch.manual_seed(42)
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)),
])
train_set = datasets.MNIST('data', train=True, download=True, transform=transform)
test_set = datasets.MNIST('data', train=False, download=True, transform=transform)
train_loader = DataLoader(train_set, batch_size=64, shuffle=True)
test_loader = DataLoader(test_set, batch_size=1000)
images, labels = next(iter(train_loader))
print(len(train_set), len(test_set))
print(images.shape, labels.shape)60000 10000 torch.Size([64, 1, 28, 28]) torch.Size([64])
model = SmallCNN().to(device). Такая небольшая CNN за несколько эпох обычно достигает на тесте точности около 98–99 %.Перенос обучения (transfer learning)
Чтобы обучить большую CNN с нуля, нужны миллионы изображений и мощные GPU. Но края, текстуры и формы, которые выучили первые слои сети, обученной на ImageNet (1000 классов, более миллиона обучающих изображений), полезны почти для любой задачи с изображениями. Перенос обучения работает так: берём предобученную модель, замораживаем её слои, заменяем последний классифицирующий слой новым под свои классы и обучаем только его.
import torch
from torch import nn
from torchvision import models
model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
for p in model.parameters():
p.requires_grad = False
print(model.fc)
model.fc = nn.Linear(model.fc.in_features, 3)
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
print(trainable, total)Linear(in_features=512, out_features=1000, bias=True) 1539 11178051
В новом слое (512 + 1) · 3 = 1539 параметров. Столь малое число параметров быстро обучается на нескольких сотнях изображений, даже на CPU. Если данных больше, на втором этапе можно «разморозить» и несколько последних слоёв и дообучить (fine-tuning) всю сеть с очень маленькой скоростью обучения. Передавай оптимизатору только обучаемые параметры: torch.optim.Adam(model.fc.parameters(), lr=1e-3).
Главное
- Изображения в PyTorch — тензоры (N, C, H, W); входы нормализуют по формуле (x − μ) / σ.
- Свёртка сдвигает маленький фильтр по изображению; общие веса делают параметров мало: (K·K·Cin + 1)·Cout.
- Размер выхода: O = ⌊(W − K + 2P) / S⌋ + 1; фильтр 3 × 3 с P = 1 сохраняет размер, S = 2 или пулинг 2 × 2 уменьшают его вдвое.
- Типичная CNN: [Conv → ReLU → Pool] × n → Flatten → Linear; печатай формы после каждого слоя.
- Перенос обучения: заморозь предобученную модель, замени последний слой и обучай только его.
Проверь себя
Вопросов: 10. Каждый правильный ответ приносит XP.