Проект: распознаём рукописные цифры

⏱ 50 мин · 🧩 заданий: 6

План

Это итоговый проект модуля. Мы пройдём весь путь, которым идёт любой проект машинного обучения:

  1. посмотреть на данные;
  2. честно разделить их на обучающую, валидационную и тестовую части;
  3. подготовить признаки;
  4. построить простую базовую модель — точку отсчёта;
  5. обучить нейросеть и подобрать гиперпараметры по валидации;
  6. один раз измерить качество на тесте;
  7. разобрать ошибки.

И всё это — на библиотеке nanotorch, которую мы собрали сами: автодифференцирование, слои, функции потерь, оптимизаторы, BatchNorm, Dropout.

1. Данные

Датасет — 1797 рукописных цифр, каждая — картинка 8 \times 8 пикселей с яркостью от 0 до 16 (оцифровка бланков, которые заполняли 43 человека).

import numpy as np
import ml_data
import vis

X, y = ml_data.load_digits()
print("X:", X.shape, " y:", y.shape, " значения пикселей от", X.min(), "до", X.max())
digits, counts = np.unique(y, return_counts=True)
print("примеров каждой цифры:", dict(zip(digits.tolist(), counts.tolist())))
vis.images(X[:24].reshape(-1, 8, 8), titles=y[:24].tolist(), cols=12)

Классы сбалансированы — около 180 примеров на цифру, так что точность (accuracy) здесь честная метрика.

2. Разделение

Делим на три части: 60% — обучение, 20% — валидация (для выбора модели), 20% — тест (смотрим один раз в конце). Затем нормализуем каждый пиксель статистиками обучающей части.

import numpy as np
import nanotorch as nt
import ml_data

X, y = ml_data.load_digits()
X_tmp, X_test, y_tmp, y_test = nt.train_test_split(X, y, test_size=0.2, seed=42)
X_train, X_val, y_train, y_val = nt.train_test_split(X_tmp, y_tmp, test_size=0.25, seed=42)
print(f"обучение {len(X_train)}, валидация {len(X_val)}, тест {len(X_test)}")

mu, sd = X_train.mean(axis=0), X_train.std(axis=0) + 1e-8
print("пикселей с нулевой дисперсией:", int((X_train.std(axis=0) == 0).sum()))

Несколько пикселей по краям на всех обучающих картинках равны нулю — без + 1e-8 получилось бы деление на ноль.

3. Базовая модель

Прежде чем строить сложное, обучим самое простое: один линейный слой и softmax — многоклассовую логистическую регрессию. Это точка отсчёта: если нейросеть её не обгонит, что-то не так.

import numpy as np
import nanotorch as nt
import ml_data

X, y = ml_data.load_digits()
X_tmp, X_test, y_tmp, y_test = nt.train_test_split(X, y, test_size=0.2, seed=42)
X_train, X_val, y_train, y_val = nt.train_test_split(X_tmp, y_tmp, test_size=0.25, seed=42)
mu, sd = X_train.mean(axis=0), X_train.std(axis=0) + 1e-8
norm = lambda A: (A - mu) / sd

nt.manual_seed(0)
baseline = nt.Sequential(nt.Linear(64, 10))
h = nt.fit(baseline, norm(X_train), y_train, nt.cross_entropy, nt.Adam(baseline.parameters(), lr=0.01),
           epochs=30, batch_size=32, X_val=norm(X_val), y_val=y_val, metric=nt.accuracy, verbose=False)
print(f"логистическая регрессия: точность на валидации {h['val_metric'][-1]:.1%}")

Уже около 96% — цифры 8 \times 8 неплохо разделяются даже линейно. Сможет ли сеть со скрытым слоем лучше?

4. Нейросеть и подбор гиперпараметров

Возьмём сеть с одним скрытым слоем, BatchNorm, ReLU и Dropout, оптимизатор AdamW. Подберём шаг обучения и ширину слоя по валидации:

import numpy as np
import nanotorch as nt
import ml_data
import vis

X, y = ml_data.load_digits()
X_tmp, X_test, y_tmp, y_test = nt.train_test_split(X, y, test_size=0.2, seed=42)
X_train, X_val, y_train, y_val = nt.train_test_split(X_tmp, y_tmp, test_size=0.25, seed=42)
mu, sd = X_train.mean(axis=0), X_train.std(axis=0) + 1e-8
norm = lambda A: (A - mu) / sd

def make_model(width):
    return nt.Sequential(nt.Linear(64, width), nt.BatchNorm1d(width), nt.ReLU(), nt.Dropout(0.3),
                         nt.Linear(width, 10))

results = {}
for lr in (0.001, 0.003, 0.01):
    for width in (32, 128):
        nt.manual_seed(0)
        model = make_model(width)
        h = nt.fit(model, norm(X_train), y_train, nt.cross_entropy,
                   nt.AdamW(model.parameters(), lr=lr, weight_decay=0.01),
                   epochs=20, batch_size=32, X_val=norm(X_val), y_val=y_val, metric=nt.accuracy, verbose=False)
        results[(lr, width)] = h["val_metric"][-1]
        print(f"lr = {lr:<6} ширина {width:4}: валидация {h['val_metric'][-1]:.1%}")

best = max(results, key=results.get)
print("лучшее:", best)
vis.heatmap([[results[(lr, w)] for w in (32, 128)] for lr in (0.001, 0.003, 0.01)],
            xlabels=["32", "128"], ylabels=["0.001", "0.003", "0.01"], fmt=".1%", title="Точность на валидации: lr × ширина")

Узкой сети с маленьким шагом 20 эпох не хватает — она недоучилась. Лучший вариант — lr = 0.003, ширина 128, и он обгоняет базовую модель почти на 3 процентных пункта. Но не стоит гнаться за последней десятой: разница в полпроцента на валидации из 360 картинок — это одна-две картинки, и отчасти она случайна (попробуйте поменять nt.manual_seed).

5. Финальная модель и тест

Выбор сделан, валидация больше не нужна — обучим финальную модель на обучающей и валидационной частях вместе (больше данных — лучше), а затем один раз посмотрим на тест:

import numpy as np
import nanotorch as nt
import ml_data
import vis

X, y = ml_data.load_digits()
X_tmp, X_test, y_tmp, y_test = nt.train_test_split(X, y, test_size=0.2, seed=42)
mu, sd = X_tmp.mean(axis=0), X_tmp.std(axis=0) + 1e-8      # обучение + валидация
norm = lambda A: (A - mu) / sd

nt.manual_seed(0)
model = nt.Sequential(nt.Linear(64, 128), nt.BatchNorm1d(128), nt.ReLU(), nt.Dropout(0.3), nt.Linear(128, 10))
print(model)
print("параметров:", model.num_params())
h = nt.fit(model, norm(X_tmp), y_tmp, nt.cross_entropy, nt.AdamW(model.parameters(), lr=0.003, weight_decay=0.01),
           epochs=20, batch_size=32, metric=nt.accuracy, verbose=False)

model.eval()
pred = model(nt.tensor(norm(X_test))).data.argmax(axis=1)
print(f"точность на тесте: {(pred == y_test).mean():.1%}")

cm = nt.confusion_matrix(y_test, pred, 10)
vis.heatmap(cm, xlabels=[str(d) for d in range(10)], ylabels=[str(d) for d in range(10)], fmt=".0f", title="Матрица ошибок (строки — истина)")

wrong = np.where(pred != y_test)[0]
print(f"ошибок: {len(wrong)} из {len(y_test)}")
vis.images(X_test[wrong[:12]].reshape(-1, 8, 8), titles=[f"{t}→{p}" for t, p in zip(y_test[wrong], pred[wrong])], cols=12)

Почти 99% на картинках, которых модель никогда не видела. Подписи под ошибками — «истина → ответ сети». Посмотрите на них: некоторые и человек прочитает не сразу, особенно в разрешении 8 \times 8.

6. Разбор ошибок

По матрице ошибок видно, какие цифры сеть путает. Что можно сделать дальше:

  • больше данных или аугментация — сдвиги, небольшие повороты и растяжения картинок;
  • свёрточная сеть — она учитывает, что пиксели образуют картинку, и лучше переносит сдвиги (это следующий модуль);
  • ансамбль из нескольких сетей.

Полносвязная сеть видит картинку как список из 64 чисел и ничего не знает о том, что соседние пиксели связаны. Если перемешать пиксели во всех картинках одинаковым образом, она обучится так же хорошо — а человек такие картинки не распознает. Свёрточные сети используют структуру изображения, и для больших картинок без них не обойтись.

7. Попробуйте сами

Ниже — сеть 64 \to 64 \to 10, обученная тем же nanotorch на тех же цифрах (со сдвигами на пиксель для устойчивости). Сценарий обучения лежит в проекте: tools/train_digit_widget.py. Веса сохранены в файл, а прямой проход — две матрицы и ReLU — выполняется прямо в вашем браузере. Нарисуйте цифру:

Рисунок приводится к виду датасета: штрихи вписываются в квадрат 32 \times 32 по центру и прорисовываются толстым пером (как ручкой на бланке), а затем в каждом блоке 4 \times 4 считаются «чернила» — получается картинка 8 \times 8 со значениями 0..16. Сеть учили на почерке 43 человек, поэтому ваш почерк может её удивить — особенно единицы с «носиком» и семёрки с перечёркиванием. Это и есть сдвиг распределения: данные в работе отличаются от обучающих. Справа видно, какие нейроны скрытого слоя загораются на ваш рисунок.

Что мы построили

Вспомним, из чего состоит эта сеть — и что мы написали сами:

Часть Урок
Матричное умножение в линейном слое Линейная алгебра, линейный слой
Градиенты через граф вычислений Граф вычислений, автодифференцирование
ReLU, softmax, кросс-энтропия Функции активации, классификация
Инициализация Кайминга Инициализация весов
BatchNorm, Dropout, weight decay BatchNorm, переобучение, Dropout
AdamW Оптимизаторы
Точность, матрица ошибок Метрики

Никакой магии: каждая строчка nt.fit(...) раскрывается в то, что мы разобрали и запрограммировали руками. Остаётся задание — собрать и обучить свою сеть.

Практика решено 0 из 6

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Заполнить таблицу

Для чего используется каждая часть данных?

обучающаявалидационнаятестовая
По ней считаются градиенты и обновляются веса
По ней выбирают архитектуру, lr, момент ранней остановки
На ней один раз измеряют итоговое качество
По ней считают среднее и std для нормализации
Отметьте ячейку — «+», пустая — «−».
Задание 2 Один вариант ответа

Вы перепробовали 50 вариантов модели и для каждого смотрели точность на тестовой выборке, а потом выбрали лучший. Что не так?

Задание 3 Один вариант ответа

Нормализуем признаки: (x - \mu)/\sigma. Откуда брать \mu и \sigma?

Задание 4 Программа на Python

Стандартизация без утечки. Напишите standardize(X_train, X_other): посчитайте среднее и стандартное отклонение каждого признака по обучающей выборке и верните пару (X_train_norm, X_other_norm).

Осторожно: в цифрах есть пиксели, которые на всех картинках равны 0 (края), у них \sigma = 0. Чтобы не делить на ноль, используйте std + 1e-8.

Какие тесты проверяют код (2)
rng = np.random.default_rng(0)
A = rng.normal([1, 5, 10], [1, 2, 3], size=(500, 3))
B = rng.normal([1, 5, 10], [1, 2, 3], size=(100, 3)) + 100
An, Bn = standardize(A, B)
assert np.allclose(An.mean(axis=0), 0, atol=1e-9) and np.allclose(An.std(axis=0), 1, atol=1e-6), "Обучающая выборка: среднее 0 и std 1 по каждому признаку"
assert np.allclose(Bn, (B - A.mean(0)) / (A.std(0) + 1e-8)), "Вторую выборку нормализуйте статистиками обучающей"

A = np.array([[0.0, 1.0], [0.0, 3.0]])
B = np.array([[0.0, 2.0]])
An, Bn = standardize(A, B)
assert np.all(np.isfinite(An)) and np.all(np.isfinite(Bn)), "Признак с нулевой дисперсией не должен давать nan или inf"
assert np.allclose(Bn, [[0, 0]], atol=1e-6)

Задание 5 Программа на Python

Главные путаницы. Дана матрица ошибок cm (строки — истинный класс, столбцы — предсказанный). Напишите top_confusions(cm, k): верните список из k троек (истинный, предсказанный, сколько) для недиагональных клеток с наибольшими значениями, по убыванию. Клетки с нулём не включайте.

Какие тесты проверяют код (1)
cm = np.array([[50, 2, 0], [7, 40, 1], [0, 4, 45]])
assert top_confusions(cm, 2) == [(1, 0, 7), (2, 1, 4)], "Самые частые ошибки: 1→0 (7 раз), затем 2→1 (4 раза)"
assert top_confusions(cm, 10) == [(1, 0, 7), (2, 1, 4), (0, 1, 2), (1, 2, 1)], "Диагональ и нули не включаются"

Задание 6 Программа на Python

Ваша сеть. Напишите build_and_train(X_train, y_train), которая создаёт модель на nanotorch, обучает её и возвращает. На вход — цифры: X_train формы (N, 64) со значениями 0..16, y_train — цифры 0..9. Проверка возьмёт отложенные картинки, которых сеть не видела, и потребует точность не ниже 95%.

Предсказание тест делает так: model.eval(), затем model(nt.tensor(X_test / 16.0)).data.argmax(axis=1) — значит, внутри функции подавайте в сеть X_train / 16.0. Время проверки ограничено, поэтому не делайте сеть огромной: 15–20 эпох небольшой сети вполне хватит.

Какие тесты проверяют код (1)
import ml_data
X, y = ml_data.load_digits()
X_tr, X_te, y_tr, y_te = nt.train_test_split(X, y, test_size=0.25, seed=123)
model = build_and_train(X_tr, y_tr)
assert isinstance(model, nt.Module), "Функция должна вернуть модель nanotorch"
model.eval()
pred = model(nt.tensor(X_te / 16.0)).data.argmax(axis=1)
acc = (pred == y_te).mean()
assert acc >= 0.95, f"Точность на отложенных картинках {acc:.1%}, нужно не меньше 95%"

Войдите, чтобы отмечать пройденные уроки.