Проект: распознаём рукописные цифры
План
Это итоговый проект модуля. Мы пройдём весь путь, которым идёт любой проект машинного обучения:
- посмотреть на данные;
- честно разделить их на обучающую, валидационную и тестовую части;
- подготовить признаки;
- построить простую базовую модель — точку отсчёта;
- обучить нейросеть и подобрать гиперпараметры по валидации;
- один раз измерить качество на тесте;
- разобрать ошибки.
И всё это — на библиотеке 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
Для чего используется каждая часть данных?
| обучающая | валидационная | тестовая | |
|---|---|---|---|
| По ней считаются градиенты и обновляются веса | |||
| По ней выбирают архитектуру, lr, момент ранней остановки | |||
| На ней один раз измеряют итоговое качество | |||
| По ней считают среднее и std для нормализации |
Вы перепробовали 50 вариантов модели и для каждого смотрели точность на тестовой выборке, а потом выбрали лучший. Что не так?
Нормализуем признаки: (x - \mu)/\sigma. Откуда брать \mu и \sigma?
Стандартизация без утечки. Напишите 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)
import numpy as np
def standardize(X_train, X_other):
mu = X_train.mean(axis=0)
sd = X_train.std(axis=0) + 1e-8
return (X_train - mu) / sd, (X_other - mu) / sd
Главные путаницы. Дана матрица ошибок 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)], "Диагональ и нули не включаются"
import numpy as np
def top_confusions(cm, k):
cm = np.asarray(cm)
cells = [(int(cm[i, j]), i, j) for i in range(len(cm)) for j in range(len(cm)) if i != j and cm[i, j] > 0]
cells.sort(key=lambda c: -c[0])
return [(i, j, n) for n, i, j in cells[:k]]
Ваша сеть. Напишите 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%"
import numpy as np
import nanotorch as nt
def build_and_train(X_train, y_train):
nt.manual_seed(0)
model = nt.Sequential(nt.Linear(64, 64), nt.ReLU(), nt.Linear(64, 10))
nt.fit(model, X_train / 16.0, y_train, nt.cross_entropy, nt.Adam(model.parameters(), lr=0.003),
epochs=15, batch_size=32, verbose=False)
return model
Войдите, чтобы отмечать пройденные уроки.