← Все статьи

Урок 7. Tiny CNN — Обучаем CNN и CRNN с нуля

Серия «Обучаем CNN и CRNN с нуля»: свёртка руками, базовые модели, стартовый loss и первая сеть empty/value на 23 538 параметрах.

Полупрозрачные кубы CNN над рукописными кропами — Tiny CNN учится отличать пустое от цифры
Содержание

«Точность 96 %» звучит как победа — пока не узнаешь, что каждая пятнадцатая клетка с цифрой объявлена пустой. В проде это не «чуть хуже», а безвозвратно потерянное значение.

В уроке 6 мы собрали Dataset и безопасные аугментации. Сегодня серия «Обучаем CNN и CRNN с нуля» учит первую настоящую модель: маленькую свёрточную сеть empty / value. Но прежде чем нажимать «обучить», нужно ответить на три вопроса.

Accuracy скрывает потери: нужен value_recall

Файлы урока

  • src/conv_from_scratch.py — свёртка руками, размер выхода, receptive field, формы и параметры tiny CNN.
  • src/metrics.py — binary_report: accuracy и recall по каждому классу.
  • src/baselines.py — дополненная версия: к 1-NN из урока 5 добавились самый частый класс и правила по «чернилам».
  • src/compare_baselines.py — обучает базовые модели и печатает таблицу.
  • src/models/tiny_cnn.py и src/models/__init__.py — сама сеть.
  • src/train_tiny_cnn.py — обучение с проверками и итоговой таблицей.
  • tests/test_conv_from_scratch.py, tests/test_baselines_simple.py, tests/test_metrics.py, tests/test_tiny_cnn.py — тесты.

Эталон для сверки, если обучение или тесты не сходятся: lesson-07. Клонировать его не нужно: файлы урока вы дописываете в свой репозиторий и коммитите.

Предскажи

Раздел «Предскажи» — не экзамен. Запишите ожидание до таблиц и обучения.

Перед запуском запишите ответы:

  1. Какая accuracy на val у наивной модели «всегда отвечай самым частым классом»? А у простого правила «мало ярких пикселей — значит, пусто»: выше 0.9 или ниже 0.8?
  2. Какое receptive field (сколько пикселей входа видит одна клетка карты признаков) будет у последнего слоя tiny CNN? Сравнимо ли оно с высотой цифры (около 30 пикселей)?
  3. Мы дважды применяем к необученной модели на двух классах функцию потерь «кросс-энтропия». Какое число вы ожидаете увидеть как стартовый loss?

Три вопроса урока

Свёртка, baselines, две проверки до полного обучения

  1. Что такое свёртка на самом деле? Мы напишем её руками на NumPy и сверим с nn.Conv2d.
  2. С чем сравнивать модель? «Точность 0.96» ничего не значит, пока не известно, сколько даёт глупое правило.
  3. Как понять, что обучение вообще работает? Две проверки до полного обучения: стартовый loss и переобучение одного батча.

Часть 1. Свёртка руками

Свёртка — это скользящее окно: берём маленький фрагмент картинки, умножаем поэлементно на ядро (набор весов), складываем и записываем одно число. Сдвигаем окно, повторяем. Одно и то же ядро применяется ко всем местам картинки, поэтому свёрточный слой имеет мало параметров и находит один и тот же узор где угодно.

Умножаем поэлементно → складываем → сдвигаем окно

Пример, который стоит проверить на бумаге. Картинка 3×3 с числами от 1 до 9 и ядро 2×2:

вход:     1 2 3         ядро:   1  0
          4 5 6                 0 -1
          7 8 9

Для левого верхнего окна получаем 1·1 + 2·0 + 4·0 + 5·(-1) = -4. Ядро не переворачивается: nn.Conv2d делает кросс-корреляцию; для нейросетей это не важно — веса обучаются.

Размер выхода: (n + 2·padding - k) // stride + 1 по каждой оси. С padding=1 и окном 3×3 размер сохраняется; пулинг 2×2 с шагом 2 уменьшает его вдвое.

receptive field. Одна клетка выхода зависит не от всей картинки, а от окна входа. После нескольких слоёв окно растёт.

Поле последнего слоя 22 px, высота цифры ~30 px

Слой Поле, px «Прыжок» (расстояние между соседними клетками, px)
conv1 3 1
pool1 4 2
conv2 8 2
pool2 10 4
conv3 18 4
pool3 22 8

Ни одна клетка последней карты не видит цифру целиком (~30 px). Сеть собирает решение из локальных кусочков.

Часть 2. С чем сравнивать: базовые модели

Метки: 1 = empty, 0 = value. Базовые модели:

  • самый частый класс: всегда один и тот же ответ;
  • порог по «чернилам»: «мало чернил — пусто» (два варианта признака);
  • 1-NN по пикселям из урока 5.
python src/compare_baselines.py --data-dir data/processed

Результат (val) на теге lesson-07:

модель                              accuracy  value_recall  empty_recall   (val)
всегда самый частый класс              0.622         1.000         0.000
порог по сумме пикселей                0.632         0.966         0.080
порог по числу ярких пикселей          0.709         0.789         0.578
1-NN по пикселям                       0.959         0.934         1.000

Первая идея не сработала. Сумма пикселей ≈ наивный ответ: после инверсии фон тусклой бумаги тоже «светится», и сумма измеряет тусклость бумаги, а не чернила.

Вторая идея лучше, но слабая. Число пикселей ярче 0.3 даёт 0.709. Трудные пустые из урока 4 ломают правило:

Штриховка 0 %, царапины 20 % — порог по яркости не спасает

Вид Верно
clean 0.96
specks 0.89
scratch 0.20
hatch 0.00

Accuracy скрывает потери. У 1-NN accuracy 0.959, но value_recall 0.934. Поэтому в таблице три числа, а не одно.

На test (другие 38 листов):

модель                              accuracy  value_recall  empty_recall   (test)
всегда самый частый класс              0.578         1.000         0.000
порог по сумме пикселей                0.592         0.986         0.053
порог по числу ярких пикселей          0.712         0.879         0.483
1-NN по пикселям                       0.983         0.970         1.000

Ориентир для сети: не хуже 0.99 на val (линейные модели на пикселях уже дают около этого).

Часть 3. Модель

Три блока признаков → AdaptiveAvgPool → Linear → [2]; 23 538 параметров

class TinyCNN(nn.Module):
    def __init__(self, channels=(16, 32, 64), num_classes: int = 2) -> None:
        super().__init__()
        layers: list[nn.Module] = []
        c_in = 1
        for c in channels:
            layers += [
                nn.Conv2d(c_in, c, kernel_size=3, padding=1, bias=False),
                nn.BatchNorm2d(c),
                nn.ReLU(inplace=True),
                nn.MaxPool2d(2),
            ]
            c_in = c
        self.features = nn.Sequential(*layers)
        self.pool = nn.AdaptiveAvgPool2d(1)
        self.head = nn.Linear(c_in, num_classes)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        x = self.features(x)
        x = self.pool(x).flatten(1)
        return self.head(x)
  • Три блока: свёртка → BatchNorm → ReLU → MaxPool.
  • bias=False у свёртки: смещение даёт BatchNorm.
  • AdaptiveAvgPool2d(1) — голове всё равно, какой ширины картинка.
  • Голова — Linear на два класса.

Формы (считает conv_from_scratch.py):

вход             (1, 48, 160)
conv1+BN+ReLU    (16, 48, 160)
pool1            (16, 24, 80)
conv2+BN+ReLU    (32, 24, 80)
pool2            (32, 12, 40)
conv3+BN+ReLU    (64, 12, 40)
pool3            (64, 6, 20)
global avg pool  (64,)
голова           (2,)
параметров: 23538

Часть 4. Обучение и две проверки

Стартовый loss и overfit одного батча — обязательны до полного обучения

Если хотя бы одна проверка не проходит, дальше не идём: ошибка в коде или данных, а не «мало эпох».

Проверка 1: стартовый loss. У необученной модели на двух классах кросс-энтропия около ln 2 ≈ 0.693.

Необученная сеть: стартовый loss ~0.693

Проверка 2: переобучение одного батча. За 200 шагов loss уходит почти в ноль, accuracy на батче 1.000.

Обе проверки встроены в train_tiny_cnn.py. Файл набираете сами. Если стартовый loss или переобучение батча не сходятся, сверьте его с эталоном lesson-07 в браузере.

Команды

python src/conv_from_scratch.py
python src/compare_baselines.py --data-dir data/processed
python src/train_tiny_cnn.py --data-dir data/processed --overfit-batch
python src/train_tiny_cnn.py --data-dir data/processed --epochs 10 --out checkpoints/tiny_cnn.pt
python -m pytest tests/test_conv_from_scratch.py tests/test_baselines_simple.py tests/test_metrics.py tests/test_tiny_cnn.py -q

Короткий прогон

python src/conv_from_scratch.py
python src/compare_baselines.py --data-dir data/processed-smoke
python src/train_tiny_cnn.py --data-dir data/processed-smoke --overfit-batch
python src/train_tiny_cnn.py --data-dir data/processed-smoke --epochs 1 --max-batches 3 --out checkpoints/tiny_cnn_smoke.pt
python -m pytest tests/test_conv_from_scratch.py tests/test_baselines_simple.py tests/test_metrics.py tests/test_tiny_cnn.py -q

Ожидаемое

Вывод conv_from_scratch.py (захвачено на теге lesson-07):

форма результата: (4, 12, 20)
naive vs fast: max |разница| = 2.86e-06
naive vs torch nn.Conv2d: max |разница| = 2.86e-06

recept. поле tiny CNN (слой: поле в пикселях входа, прыжок):
  conv1  поле   3 px, прыжок 1
  pool1  поле   4 px, прыжок 2
  conv2  поле   8 px, прыжок 2
  pool2  поле  10 px, прыжок 4
  conv3  поле  18 px, прыжок 4
  pool3  поле  22 px, прыжок 8
...
параметров: 23538

Переобучение одного батча:

Device: mps
параметров: 23538
стартовый loss: 0.689 (ln 2 = 0.693)  OK
  шаг   0: loss 0.7339
  шаг  25: loss 0.0570
  ...
  шаг 200: loss 0.0015
overfit одного батча: loss 0.0015, accuracy 1.000  OK

Полное обучение (10 эпох; лучшая — эпоха 7):

эпоха  1: train loss 0.1720 acc 0.946 | val loss 0.0941 acc 0.991 ...  <- лучшая
...
эпоха  7: train loss 0.0113 acc 0.999 | val loss 0.0119 acc 1.000 (value_recall 1.000, empty_recall 1.000)  <- лучшая
...
чекпоинт: checkpoints/tiny_cnn.pt (эпоха 7)

модель                              accuracy  value_recall  empty_recall   (val)
всегда самый частый класс              0.622         1.000         0.000
порог по сумме пикселей                0.632         0.966         0.080
порог по числу ярких пикселей          0.709         0.789         0.578
1-NN по пикселям                       0.959         0.934         1.000
tiny CNN (эпоха 7)                     1.000         1.000         1.000

У вас устройство может быть cpu или cuda, а лучшая эпоха — другая; важно: стартовый loss около 0.69, overfit OK, val accuracy не хуже 0.99.

Тесты: 20 passed (вся сюита уроков 2–7: 90 passed).

Сравните с предсказаниями.

  1. Самый частый класс: 0.622 на val. Правило по ярким пикселям: 0.709 (ниже 0.8).
  2. receptive field последнего слоя 22 пикселя < высоты цифры (~30).
  3. Стартовый loss около ln 2 = 0.693 (у нас 0.689, OK).

Упражнение (эксперимент)

  1. Уберите BatchNorm. В TinyCNN удалите nn.BatchNorm2d(c) и верните свёртке bias=True. Как изменились кривая loss и итоговая точность?
  2. Уменьшите сеть вдвое. Поставьте channels=(8, 16, 32). Параметров станет 6010 (tiny_cnn_summary(channels=(8, 16, 32))). Сколько точности потеряли?
  3. Включите аугментацию. Добавьте --augment и сравните разрыв train/val accuracy с запуском без неё.

Запишите для каждого варианта: val accuracy, value_recall, empty_recall.

Если не так

  • ModuleNotFoundError: No module named 'torch'. Окружение из урока 1 не активировано.
  • Стартовый loss сильно отличается от 0.69. Проверьте метки (только 0 и 1), форму входа [B, 1, 48, 160] и голову Linear(64, 2).
  • Переобучение одного батча не получается. Слишком маленький/большой lr, перепутанные метки, модель в eval, «пустые» данные. Сначала почините --overfit-batch.
  • val acc застыла на 0.62. Модель всегда отвечает одним классом: проверьте model.train(), optimizer.step(), lr.
  • RuntimeError: mat1 and mat2 shapes cannot be multiplied. Размер входа головы не 64.
  • Обучение медленное на CPU. --max-batches для отладки или --workers 2.
  • Тест test_matches_scipy_correlate пропущен. scipy не установлен; это нормально.

Чек-лист

  • Файлы урока в вашем репозитории и закоммичены
  • conv_from_scratch.py: naive ≈ torch, RF до 22 px, 23538 параметров
  • Таблица baselines на val прочитана
  • --overfit-batch → OK
  • Полное обучение: val acc ≥ 0.99, чекпоинт сохранён
  • pytest → 20 passed
  • Сверены три прогноза из «Предскажи»

FAQ

Почему метка 1 = empty, а не value?

Так договорено в Dataset (урок 6): EMPTY_CLASS = 1. P(empty) в следующих уроках — вероятность класса 1.

Зачем BatchNorm в такой маленькой сети?

Он стабилизирует масштабы активаций и позволяет убрать bias у свёртки. В упражнении можно убрать BatchNorm и сравнить кривую loss.

Почему не смотреть сразу на test?

Лучшую эпоху мы выбираем по val. Итог на test откроем в уроке 8, когда появится порог и честное разведение настройки и итога.

У меня на CPU обучение долгое. Это нормально?

Да. На Apple Silicon / GPU 10 эпох занимают минуты; на CPU — дольше. Для отладки используйте --max-batches и датасет smoke.

Accuracy 1.000 на val — это не подозрительно?

На нашей синтетике задача empty/value действительно лёгкая для обучаемой модели (линейные модели уже ~0.99). Подозрительнее было бы 0.62. В проде появятся реальные сканы и фотографии бланков — тогда цифры упадут, а порог из урока 8 станет критичен.

Нужно ли коммитить checkpoints/tiny_cnn.pt?

Нет. Папка в .gitignore; чекпоинт пересоздаётся командой обучения.

Что дальше

argmax — не прод-решение. Следующий урок — Урок 8. Порог решения. Прод-рецепт (баланс, early stopping, config) — урок 9.

Эталон для сверки: lesson-07. Клонировать его не нужно. Предыдущий: Урок 6. Dataset и аугментации.