Фаза 04 · урок 04
Классификация изображений
Цель урока: Классификатор — это функция, переводящая пиксели в распределение вероятностей по классам. Всё остальное — обвязка.
Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.
Содержание урока
- Цели обучения
- Задача
- Концепция
- Конвейер классификации
- Кросс-энтропия, логиты и softmax
- Почему работает аугментация
- Mixup и cutmix
- Сглаживание меток
- Оценка за пределами точности
- Соберите сами
- Шаг 1: Детерминированный синтетический датасет
- Шаг 2: Нормализация и аугментация
- Шаг 3: Mixup
- Шаг 4: Цикл обучения
- Шаг 5: Соберите всё вместе
- Шаг 6: Читайте матрицу ошибок
- Используйте
- Подготовьте к поставке
- Упражнения
- Ключевые термины
- Дополнительные материалы
Классификатор — это функция, переводящая пиксели в распределение вероятностей по классам. Всё остальное — обвязка.
Тип: Сборка Языки: Python Предварительные требования: Фаза 02, урок 09 (Оценка моделей), фаза 03, урок 10 (Мини-фреймворк), фаза 04, урок 03 (CNN) Время: ~75 минут
Цели обучения
- Собрать сквозной конвейер классификации изображений на CIFAR-10: датасет, аугментация, модель, цикл обучения и оценка.
- Объяснить роль каждого компонента (dataloader, функция потерь, оптимизатор, scheduler, аугментация) и предсказать, как поломка любого из них проявится на кривой потерь.
- Реализовать с нуля mixup, cutout и сглаживание меток (label smoothing), а также обосновать, когда стоит добавлять каждый из методов.
- Читать матрицу ошибок и таблицу precision/recall по классам, чтобы диагностировать сбои датасета и модели за пределами сводной точности.
Задача
Каждая поставляемая задача компьютерного зрения на некотором уровне сводится к классификации изображений. Детекция классифицирует области. Сегментация классифицирует пиксели. Поиск ранжирует результаты по сходству с центроидами классов. Умение правильно собрать классификацию — цикл работы с датасетом, политику аугментации, функцию потерь и оценку — переносится на каждую другую задачу этой фазы.
Большинство ошибок классификации находится не в модели. Они живут в конвейере: сломанная нормализация, неперемешанный обучающий набор, аугментация, искажающая метки, валидационная выборка, загрязнённая обучающими данными, скорость обучения, которая незаметно расходится после 30-й эпохи. CNN, которая при правильной настройке достигла бы 93% на CIFAR-10, при сломанной обычно получает 70–75%, а кривая потерь всё это время выглядит правдоподобно.
В этом уроке мы вручную свяжем весь конвейер, чтобы каждую его часть можно было проверить. Вы не будете использовать ничего из torchvision.datasets, что могло бы скрыть ошибку.
Концепция
Конвейер классификации
В каждой строке этого цикла может жить ошибка. Кросс-энтропия принимает сырые логиты, а не выход softmax, поэтому model(x).softmax() перед функцией потерь незаметно вычисляет неправильный градиент. Аугментации применяются только ко входам, а не к меткам — кроме mixup, который смешивает и то, и другое. optimizer.zero_grad() должен выполняться один раз на шаг; его пропуск накапливает градиенты и выглядит как крайне нестабильная скорость обучения. Каждая из этих ошибок уплощает кривую обучения, не выбрасывая исключения.
Кросс-энтропия, логиты и softmax
Классификатор выдаёт для каждого изображения C чисел, называемых логитами. Применение softmax превращает их в распределение вероятностей:
softmax(z)_i = exp(z_i) / sum_j exp(z_j)
Кросс-энтропия измеряет отрицательный логарифм вероятности правильного класса:
CE(z, y) = -log( softmax(z)_y )
= -z_y + log( sum_j exp(z_j) )
Форма справа численно устойчива (log-sum-exp). nn.CrossEntropyLoss в PyTorch объединяет softmax + NLL в одной операции и принимает сырые логиты напрямую. Самостоятельно применять softmax заранее почти всегда ошибка: вы вычисляете log(softmax(softmax(z))) — бессмысленную величину.
Почему работает аугментация
CNN имеет индуктивное смещение к сдвигу (из-за разделения весов), но не имеет встроенной инвариантности к обрезке, отражению, изменению цвета или перекрытию. Единственный способ научить её этим инвариантностям — показать ей пиксели, в которых они проявляются. Каждое случайное преобразование во время обучения говорит: «у этих двух изображений одна и та же метка; выучи признаки, игнорирующие различие».
Original crop: "dog facing left"
Flip: "dog facing right" <- same label, different pixels
Rotate(+15): "dog, slight tilt"
Colour jitter: "dog in warmer light"
RandomErasing: "dog with patch missing"
Правило: аугментация должна сохранять метку. Cutout и поворот цифры могут превратить «6» в «9»; для такого датасета используют меньшие диапазоны поворота и выбирают аугментации, соблюдающие инвариантности конкретных цифр.
Mixup и cutmix
Обычная аугментация преобразует пиксели, но сохраняет one-hot метки. Mixup и cutmix нарушают это правило, интерполируя и то, и другое.
Mixup:
lambda ~ Beta(a, a)
x = lambda * x_i + (1 - lambda) * x_j
y = lambda * y_i + (1 - lambda) * y_j
Cutmix:
paste a random rectangle of x_j into x_i
y = area-weighted mix of y_i and y_j
Почему это помогает: модель перестаёт запоминать остроконечные one-hot цели и учится интерполировать между классами. Обучающая потеря растёт, тестовая точность растёт. Это самое дешёвое улучшение устойчивости для любого классификатора.
Сглаживание меток
Родственник mixup. Вместо обучения на [0, 0, 1, 0, 0] обучайтесь на [eps/C, eps/C, 1-eps, eps/C, eps/C] для малого eps, например 0.1. Это мешает модели выдавать сколь угодно резкие логиты и почти без затрат улучшает калибровку. Метод встроен в nn.CrossEntropyLoss(label_smoothing=0.1) начиная с PyTorch 1.10.
Оценка за пределами точности
Сводная точность скрывает дисбаланс. Бинарный классификатор с соотношением классов 90–10, всегда предсказывающий большинство, получит 90%. Инструменты, которые действительно показывают происходящее:
- Точность по классам — одно число на класс; сразу выявляет отстающие категории.
- Матрица ошибок — сетка C x C, где строка i, столбец j = число объектов истинного класса i, предсказанных как класс j; диагональ верна, внедиагональные элементы показывают, где живёт ваша модель.
- Top-1 / Top-5 — попадает ли правильный класс в одно или пять лучших предсказаний; Top-5 важен для ImageNet, потому что классы вроде «норвич-терьер» и «норфолк-терьер» действительно неоднозначны.
- Калибровка (ECE) — оказывается ли предсказание с уверенностью 0.8 правильным в 80% случаев? Современные сети систематически чрезмерно уверены; исправляйте это temperature scaling или сглаживанием меток.
receptive-field
Соберите сами
Шаг 1: Детерминированный синтетический датасет
CIFAR-10 находится на диске. Чтобы урок был воспроизводимым и быстрым, мы построим синтетический датасет, похожий на CIFAR: RGB-изображения 32x32 со специфичной для класса структурой, которую модель должна выучить. Тот же самый конвейер без изменений работает на настоящем CIFAR-10.
import numpy as np
import torch
from torch.utils.data import Dataset
def synthetic_cifar(num_per_class=1000, num_classes=10, seed=0):
rng = np.random.default_rng(seed)
X = []
Y = []
for c in range(num_classes):
centre = rng.uniform(0, 1, (3,))
freq = 2 + c
for _ in range(num_per_class):
yy, xx = np.meshgrid(np.linspace(0, 1, 32), np.linspace(0, 1, 32), indexing="ij")
r = np.sin(xx * freq) * 0.5 + centre[0]
g = np.cos(yy * freq) * 0.5 + centre[1]
b = (xx + yy) * 0.5 * centre[2]
img = np.stack([r, g, b], axis=-1)
img += rng.normal(0, 0.08, img.shape)
img = np.clip(img, 0, 1)
X.append(img.astype(np.float32))
Y.append(c)
X = np.stack(X)
Y = np.array(Y)
idx = rng.permutation(len(X))
return X[idx], Y[idx]
class ArrayDataset(Dataset):
def __init__(self, X, Y, transform=None):
self.X = X
self.Y = Y
self.transform = transform
def __len__(self):
return len(self.X)
def __getitem__(self, i):
img = self.X[i]
if self.transform is not None:
img = self.transform(img)
img = torch.from_numpy(img).permute(2, 0, 1)
return img, int(self.Y[i])
Каждый класс получает собственную цветовую палитру и частотный паттерн, а также гауссов шум, вынуждающий модель учить сигнал, а не запоминать пиксели. Десять классов, по тысяче изображений в каждом, затем перемешивание.
Шаг 2: Нормализация и аугментация
Два преобразования, которые есть в каждом конвейере компьютерного зрения.
def standardize(mean, std):
mean = np.array(mean, dtype=np.float32)
std = np.array(std, dtype=np.float32)
def _fn(img):
return (img - mean) / std
return _fn
def random_hflip(p=0.5):
def _fn(img):
if np.random.random() < p:
return img[:, ::-1, :].copy()
return img
return _fn
def random_crop(pad=4):
def _fn(img):
h, w = img.shape[:2]
padded = np.pad(img, ((pad, pad), (pad, pad), (0, 0)), mode="reflect")
y = np.random.randint(0, 2 * pad)
x = np.random.randint(0, 2 * pad)
return padded[y:y + h, x:x + w, :]
return _fn
def compose(*fns):
def _fn(img):
for fn in fns:
img = fn(img)
return img
return _fn
Перед обрезкой добавляйте отражающее дополнение (reflect-pad), а не нулевое: чёрные границы — это сигнал, который модель научится игнорировать не приносящим пользы способом.
Шаг 3: Mixup
Смешивает два изображения и две метки внутри шага обучения. Реализован как преобразование батча, поэтому находится рядом с прямым проходом, а не внутри датасета.
def mixup_batch(x, y, num_classes, alpha=0.2):
if alpha <= 0:
return x, torch.nn.functional.one_hot(y, num_classes).float()
lam = float(np.random.beta(alpha, alpha))
idx = torch.randperm(x.size(0), device=x.device)
x_mixed = lam * x + (1 - lam) * x[idx]
y_onehot = torch.nn.functional.one_hot(y, num_classes).float()
y_mixed = lam * y_onehot + (1 - lam) * y_onehot[idx]
return x_mixed, y_mixed
def soft_cross_entropy(logits, soft_targets):
log_probs = torch.log_softmax(logits, dim=-1)
return -(soft_targets * log_probs).sum(dim=-1).mean()
soft_cross_entropy — кросс-энтропия по распределению мягких меток. Она сводится к обычному one-hot случаю, когда цель точно one-hot.
Шаг 4: Цикл обучения
Полный рецепт: один проход по данным, градиенты один раз на батч, scheduler делает шаг один раз на эпоху.
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from torch.optim import SGD
from torch.optim.lr_scheduler import CosineAnnealingLR
def train_one_epoch(model, loader, optimizer, device, num_classes, use_mixup=True):
model.train()
total, correct, loss_sum = 0, 0, 0.0
for x, y in loader:
x, y = x.to(device), y.to(device)
if use_mixup:
x_m, y_soft = mixup_batch(x, y, num_classes)
logits = model(x_m)
loss = soft_cross_entropy(logits, y_soft)
else:
logits = model(x)
loss = nn.functional.cross_entropy(logits, y, label_smoothing=0.1)
optimizer.zero_grad()
loss.backward()
optimizer.step()
loss_sum += loss.item() * x.size(0)
total += x.size(0)
# Training accuracy vs the un-mixed labels `y` is only an approximation
# when mixup is on (the model saw soft targets, not y). Treat it as a
# rough progress signal; rely on val accuracy for real performance.
with torch.no_grad():
pred = logits.argmax(dim=-1)
correct += (pred == y).sum().item()
return loss_sum / total, correct / total
@torch.no_grad()
def evaluate(model, loader, device, num_classes):
model.eval()
total, correct = 0, 0
loss_sum = 0.0
cm = torch.zeros(num_classes, num_classes, dtype=torch.long)
for x, y in loader:
x, y = x.to(device), y.to(device)
logits = model(x)
loss = nn.functional.cross_entropy(logits, y)
pred = logits.argmax(dim=-1)
for t, p in zip(y.cpu(), pred.cpu()):
cm[t, p] += 1
loss_sum += loss.item() * x.size(0)
total += x.size(0)
correct += (pred == y).sum().item()
return loss_sum / total, correct / total, cm
Пять инвариантов, которые нужно проверять каждый раз при написании цикла обучения:
model.train()перед обучением,model.eval()перед оценкой — переключают поведение dropout и batchnorm..zero_grad()перед.backward()..item()при накоплении метрик, чтобы ничто не удерживало граф вычислений живым.@torch.no_grad()при оценке — экономит память и время, предотвращает незаметные случайности.- Argmax по сырым логитам, а не по softmax — тот же результат, на одну операцию меньше.
Шаг 5: Соберите всё вместе
Используйте TinyResNet из предыдущего урока, обучите её несколько эпох, выполните оценку.
from main import synthetic_cifar, ArrayDataset
from main import standardize, random_hflip, random_crop, compose
from main import mixup_batch, soft_cross_entropy
from main import train_one_epoch, evaluate
# TinyResNet comes from the previous lesson (03-cnns-lenet-to-resnet).
# Adjust the import path to wherever you stored the previous lesson's code.
from cnns_lenet_to_resnet import TinyResNet # example placeholder
X, Y = synthetic_cifar(num_per_class=500)
split = int(0.9 * len(X))
X_train, Y_train = X[:split], Y[:split]
X_val, Y_val = X[split:], Y[split:]
mean = [0.5, 0.5, 0.5]
std = [0.25, 0.25, 0.25]
train_tf = compose(random_hflip(), random_crop(pad=4), standardize(mean, std))
eval_tf = standardize(mean, std)
train_ds = ArrayDataset(X_train, Y_train, transform=train_tf)
val_ds = ArrayDataset(X_val, Y_val, transform=eval_tf)
train_loader = DataLoader(train_ds, batch_size=128, shuffle=True, num_workers=0)
val_loader = DataLoader(val_ds, batch_size=256, shuffle=False, num_workers=0)
device = "cuda" if torch.cuda.is_available() else "cpu"
model = TinyResNet(num_classes=10).to(device)
optimizer = SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4, nesterov=True)
scheduler = CosineAnnealingLR(optimizer, T_max=10)
for epoch in range(10):
tr_loss, tr_acc = train_one_epoch(model, train_loader, optimizer, device, 10, use_mixup=True)
va_loss, va_acc, _ = evaluate(model, val_loader, device, 10)
scheduler.step()
print(f"epoch {epoch:2d} lr {scheduler.get_last_lr()[0]:.4f} "
f"train {tr_loss:.3f}/{tr_acc:.3f} val {va_loss:.3f}/{va_acc:.3f}")
На синтетическом датасете это даёт почти идеальную валидационную точность за пять эпох — в этом и смысл: конвейер корректен, модель способна выучить всё, что можно выучить. Замените датасет на настоящий CIFAR-10, и тот же цикл без изменений обучится примерно до 90%.
Шаг 6: Читайте матрицу ошибок
Одна лишь точность никогда не расскажет, где модель ошибается. Матрица ошибок расскажет.
def print_confusion(cm, labels=None):
c = cm.shape[0]
labels = labels or [str(i) for i in range(c)]
print(f"{'':>6}" + "".join(f"{l:>5}" for l in labels))
for i in range(c):
row = cm[i].tolist()
print(f"{labels[i]:>6}" + "".join(f"{v:>5}" for v in row))
print()
tp = cm.diag().float()
fp = cm.sum(dim=0).float() - tp
fn = cm.sum(dim=1).float() - tp
prec = tp / (tp + fp).clamp_min(1)
rec = tp / (tp + fn).clamp_min(1)
f1 = 2 * prec * rec / (prec + rec).clamp_min(1e-9)
for i in range(c):
print(f"{labels[i]:>6} prec {prec[i]:.3f} rec {rec[i]:.3f} f1 {f1[i]:.3f}")
_, _, cm = evaluate(model, val_loader, device, 10)
print_confusion(cm)
Строки — истинные классы, столбцы — предсказания. Группа внедиагональных значений между классами 3 и 5 означает, что модель путает эти два класса, и даёт вам точку начала для целевого сбора данных или аугментации под конкретный класс.
Используйте
torchvision оборачивает всё написанное выше в идиоматические компоненты. Для настоящего CIFAR-10 весь конвейер — это четыре строки и цикл обучения.
from torchvision.datasets import CIFAR10
from torchvision.transforms import Compose, RandomCrop, RandomHorizontalFlip, ToTensor, Normalize
mean = (0.4914, 0.4822, 0.4465)
std = (0.2470, 0.2435, 0.2616)
train_tf = Compose([
RandomCrop(32, padding=4, padding_mode="reflect"),
RandomHorizontalFlip(),
ToTensor(),
Normalize(mean, std),
])
eval_tf = Compose([ToTensor(), Normalize(mean, std)])
train_ds = CIFAR10(root="./data", train=True, download=True, transform=train_tf)
val_ds = CIFAR10(root="./data", train=False, download=True, transform=eval_tf)
Обратите внимание на две вещи: mean/std специфичны для датасета — вычислены на обучающем наборе CIFAR-10, а не ImageNet — и отражающее дополнение является принятым в сообществе стандартом для обрезки. Копирование сюда статистик ImageNet даёт потерю примерно 1% точности, которую никто не замечает, пока кто-нибудь не профилирует модель.
Подготовьте к поставке
Этот урок создаёт:
outputs/prompt-classifier-pipeline-auditor.md— промпт, проверяющий обучающий скрипт на пять перечисленных инвариантов и выявляющий первое нарушение.outputs/skill-classification-diagnostics.md— навык, который по матрице ошибок и списку имён классов обобщает ошибки по классам и предлагает одно наиболее влиятельное исправление.
Упражнения
- (Легко) Обучите одну и ту же модель с mixup и без него пять эпох на синтетическом датасете. Постройте графики обучающей и валидационной потерь для обоих вариантов. Объясните, почему обучающая потеря с mixup выше, хотя валидационная точность похожа или лучше.
- (Средне) Реализуйте Cutout — зануление случайного квадрата 8x8 в каждом обучающем изображении — и проведите абляцию: без аугментации, hflip+crop, hflip+crop+cutout, hflip+crop+mixup. Укажите валидационную точность каждого варианта.
- (Сложно) Постройте конвейер CIFAR-100 (100 классов, тот же размер входа) и воспроизведите запуск обучения ResNet-34 с точностью в пределах 1% от опубликованной. Дополнительно: переберите три скорости обучения и два weight decay, сохраните логи в локальный CSV, создайте итоговую таблицу наиболее частых путаниц из матрицы ошибок.
Ключевые термины
| Термин | Как говорят | Что это на самом деле означает |
|---|---|---|
| Логиты | «Сырые выходы» | Вектор из C чисел до softmax для каждого изображения; кросс-энтропия ожидает именно его, а не значения после softmax |
| Кросс-энтропия | «Функция потерь» | Отрицательный логарифм вероятности правильного класса; объединяет log-softmax и NLL в одной устойчивой операции |
| DataLoader | «Батчер» | Оборачивает датасет перемешиванием, разбиением на батчи и (опционально) многопоточной загрузкой; его винят в половине ошибок обучения |
| Аугментация | «Случайные преобразования» | Любое преобразование пикселей во время обучения, сохраняющее метку; обучает инвариантностям, которых у CNN нет изначально |
| Mixup / Cutmix | «Смешать два изображения» | Смешивает и входы, и метки, чтобы классификатор учил гладкие интерполяции, а не жёсткие границы |
| Сглаживание меток | «Более мягкие цели» | Заменяет one-hot на (1-eps, eps/(C-1), …); улучшает калибровку и немного повышает точность |
| Top-k accuracy | «Top-5» | Правильный класс входит в k предсказаний с наивысшей вероятностью; используется на датасетах с действительно неоднозначными классами |
| Матрица ошибок | «Где живут ошибки» | Таблица C x C, где элемент (i, j) считает изображения истинного класса i, предсказанные как j; диагональ верна, внедиагональные элементы указывают, что исправлять |
Дополнительные материалы
- CS231n: Training Neural Networks — всё ещё самый ясный одностраничный обзор конвейера обучения.
- Bag of Tricks for Image Classification (He et al., 2019) — все небольшие приёмы, которые вместе добавляют 3–4% к точности ResNet на ImageNet.
- mixup: Beyond Empirical Risk Minimization (Zhang et al., 2017) — исходная статья о mixup: три страницы теории и убедительные эксперименты.
- Why temperature scaling matters (Guo et al., 2017) — статья, показавшая, что современные сети плохо калиброваны, и исправившая это одним скалярным параметром.
Источник: Image Classification 04.03 — CNN: от LeNet до ResNet · Фаза 04 — Компьютерное зрение · 04.05 — Перенос обучения · Полный каталог