March 2

Грокаем Эндрю Траска: Глава 8

В этой главе автор впервые предлагает обучить нейронную сеть на полноценном наборе данных — классическом датасете MNIST (70 000 изображений рукописных цифр).

  • Цель: Научить сеть распознавать цифры от 0 до 9.
  • Проблема: Сеть быстро достигает 100% точности на обучающих данных, но на «новых» изображениях (тестовом наборе) она ошибается гораздо чаще. Это и есть переобучение.

Задача распознавания рукописных цифр MNIST (Modified National Institute of Standards and Technology) — это классический «Hello World» в мире глубокого обучения. В 8-й главе Эндрю Траск использует её, чтобы показать разницу между «заучиванием» (переобучением) и реальным пониманием данных.

Вот подробный разбор того, как эта задача реализована в книге, с ключевыми фрагментами кода.


1. Подготовка данных: Из картинки в числа

Каждое изображение в MNIST — это картинка

        28×2828 \times 2828×28
      

пикселей.

  1. Преобразование в вектор: Нейросеть не видит «картинку», она видит длинный ряд чисел. Поэтому массив пикселей 28×2828 \times 2828×28 «разворачивают» в один вектор из 784 чисел.
  2. Нормализация: Значения пикселей (от 0 до 255) делятся на 255, чтобы все входные данные находились в диапазоне от 0 до 1.
  3. One-Hot Encoding (Прямое кодирование): Вместо того чтобы сказать сети «это цифра 3», мы даем ей вектор из 10 элементов, где на 3-й позиции стоит единица: [0, 0, 0, 1, 0, 0, 0, 0, 0, 0].
import sys, numpy as np
from keras.datasets import mnist

# Загрузка данных
(x_train, y_train), (x_test, y_test) = mnist.load_data()

# Выбираем первые 1000 изображений для примера и нормализуем их
images, labels = (x_train[0:1000].reshape(1000, 28*28) / 255, y_train[0:1000])

# Создаем One-hot метки (вектор из 10 элементов)
one_hot_labels = np.zeros((len(labels), 10))
for i, l in enumerate(labels):
    one_hot_labels[i][l] = 1
labels = one_hot_labels

# Подготовка тестовых данных
test_images = x_test.reshape(len(x_test), 28*28) / 255
test_labels = np.zeros((len(y_test), 10))
for i, l in enumerate(y_test):
    test_labels[i][l] = 1

Архитектура сети

Автор строит трехслойную сеть (Вход -> Скрытый слой -> Выход):

  • Вход: 784 узла (пиксели).
  • Скрытый слой: 40 узлов (автор выбрал это число экспериментально).
  • Выход: 10 узлов (вероятность для каждой цифры от 0 до 9).

Функция активации: Используется ReLU (если число меньше 0, оно превращается в 0).

np.random.seed(1)
relu = lambda x: (x >= 0) * x # Сама ReLU
relu2deriv = lambda x: x >= 0 # Производная ReLU для обратного распространения

alpha, iterations, hidden_size = (0.005, 350, 40)
pixels_per_image, num_labels = (784, 10)

# Случайная инициализация весов
weights_0_1 = 0.2 * np.random.random((pixels_per_image, hidden_size)) - 0.1
weights_1_2 = 0.2 * np.random.random((hidden_size, num_labels)) - 0.1

3. Цикл обучения (Forward + Backward)

Это «сердце» программы. Сеть смотрит на картинку, делает предсказание, считает ошибку и корректирует веса.

for j in range(iterations):
    error, correct_cnt = (0.0, 0)
    
    for i in range(len(images)):
        # ПРЯМОЕ РАСПРОСТРАНЕНИЕ (Forward Prop)
        layer_0 = images[i:i+1]
        layer_1 = relu(np.dot(layer_0, weights_0_1))
        layer_2 = np.dot(layer_1, weights_1_2)

        # Считаем ошибку и количество верных ответов
        error += np.sum((labels[i:i+1] - layer_2) ** 2)
        correct_cnt += int(np.argmax(layer_2) == np.argmax(labels[i:i+1]))

        # ОБРАТНОЕ РАСПРОСТРАНЕНИЕ (Backprop)
        layer_2_delta = (labels[i:i+1] - layer_2)
        # Переносим ошибку на скрытый слой через производную ReLU
        layer_1_delta = layer_2_delta.dot(weights_1_2.T) * relu2deriv(layer_1)

        # ОБНОВЛЕНИЕ ВЕСОВ
        weights_1_2 += alpha * layer_1.T.dot(layer_2_delta)
        weights_0_1 += alpha * layer_0.T.dot(layer_1_delta)

    # Каждые 10 итераций проверяем точность на ТЕСТОВЫХ данных (которых сеть не видела)
    if(j % 10 == 0 or j == iterations - 1):
        # (Код для проверки на test_images аналогичен Forward Prop выше)
        print(f"Итерация: {j}, Ошибка: {error/float(len(images))}, Точность: {correct_cnt/float(len(images))}")

2. В чем разница между Сигналом и Шумом?

  • Сигнал: Это общие характеристики данных, которые повторяются во всех примерах одного класса. Например, для цифры «2» сигналом являются характерная дуга сверху и горизонтальная линия снизу. Это то, что позволяет сети узнать «двойку» на новой картинке.
  • Шум: Это особенности конкретного примера, которые не имеют отношения к общему классу. Например, если кто-то при написании цифры «3» случайно поставил кляксу или у него дрогнула рука.
  • Проблема: Нейросеть — это «машина корреляций». Если в обучающем наборе из 1000 картинок цифра «3» часто встречается с «кляксой» в углу, сеть может решить, что «клякса» — это часть цифры «3». Это и есть обучение на шуме.

Программная проблема: Переобучение на шуме

Когда сеть учит шум, она показывает 100% точность на тренировочных данных, но проваливается на тестах. В коде это выглядит так:

# Результат обучения БЕЗ защиты от шума:
# I:349 (Итерация 350)
# Train-Acc: 1.0   (Сеть выучила всё идеально, включая шум)
# Test-Acc: 0.7073 (На новых данных точность всего 70%)

Решение: Метод «Прореживания» (Dropout)

Чтобы заставить сеть игнорировать шум и искать только стабильный сигнал, Траск вводит Dropout.
Идея: Во время обучения мы случайно «выключаем» (обнуляем) половину нейронов скрытого слоя.

Почему это помогает?
Если сеть пытается выучить «кляксу» (шум), она полагается на конкретные узлы. Но если этот узел в следующий момент может быть выключен, сети приходится искать несколько путей для принятия решения. Она вынуждена опираться на общие признаки (сигнал), которые распределены по многим нейронам.

Программная реализация Dropout:

Вот ключевой фрагмент кода, который разделяет сигнал и шум:

import numpy as np

# ... инициализация весов и данных как в примере с MNIST ...

for j in range(iterations):
    error, correct_cnt = (0.0, 0)
    for i in range(len(images)):
        layer_0 = images[i:i+1]
        
        # 1. Прямое распространение через скрытый слой
        layer_1 = relu(np.dot(layer_0, weights_0_1))
        
        # --- ФИЛЬТР ШУМА (DROPOUT) ---
        # Создаем маску: случайно 0 или 1 для каждого нейрона
        dropout_mask = np.random.randint(2, size=layer_1.shape)
        
        # Выключаем нейроны по маске и масштабируем результат на 2
        # Умножение на 2 нужно, чтобы общая "громкость" сигнала 
        # осталась прежней (так как активна только половина нейронов)
        layer_1 *= dropout_mask * 2 
        # -----------------------------
        
        layer_2 = np.dot(layer_1, weights_1_2)

        # ... расчет ошибки ...

        # 2. Обратное распространение
        layer_2_delta = (labels[i:i+1] - layer_2)
        layer_1_delta = layer_2_delta.dot(weights_1_2.T) * relu2deriv(layer_1)
        
        # ВАЖНО: При обратном проходе мы тоже применяем маску!
        # Если нейрон был выключен при прогнозе, он не должен учиться
        layer_1_delta *= dropout_mask 

        # 3. Обновление весов
        weights_1_2 += alpha * layer_1.T.dot(layer_2_delta)
        weights_0_1 += alpha * layer_0.T.dot(layer_1_delta)

Итог: Как меняются показатели

Благодаря тому, что Dropout мешает сети «зубрить» шум, картина меняется:

# Результат обучения С Dropout:
# I:290 (Итерация 300)
# Train-Acc: 0.908 (На обучении точность НИЖЕ, чем была)
# Test-Acc: 0.8181 (Зато на тестах точность ВЫШЕ: 82% против 70%!)

3. Как создается «слепок» данных

Представьте, что вы хотите создать устройство для распознавания вилок.

  • Ваш инструмент: Кусок сырой глины (это веса вашей нейросети).
  • Ваш метод: Вы вдавливаете вилку в глину, чтобы получить форму-шаблон.

Сценарий А: Обобщение (Сигнал)

Если вы возьмете 100 разных вилок (у одной чуть короче зубцы, у другой шире ручка) и будете по очереди вдавливать их в одно и то же место, глина примет усредненную форму. Этот слепок будет игнорировать царапины конкретной вилки, но идеально запомнит, что у вилки должно быть несколько параллельных зубцов. Это — выделение сигнала.

Сценарий Б: Переобучение (Шум)

Если вы возьмете одну конкретную вилку и будете вдавливать её в глину 10 000 раз подряд, глина станет невероятно детальной. Она запомнит не только «форму вилки», но и микроскопическую трещинку на левом зубце и крохотную зазубрину на ручке.

  • Когда вы попытаетесь вставить в этот слепок другую вилку, она не влезет! Почему? Потому что у другой вилки нет той самой специфической трещинки.
  • Глина «переобучилась» под одну вилку и перестала узнавать класс «вилки» вообще.

Программное воплощение: «Вдавливание» данных MNIST

В программе из книги этот процесс выглядит как бесконечный цикл обучения на ограниченном наборе данных (1000 картинок).

Вот код, который иллюстрирует, как мы «вдавливаем вилку»:

# Мы берем только 1000 изображений (наша "конкретная вилка")
images, labels = (x_train[0:1000].reshape(1000, 28*28) / 255, y_train[0:1000])

# Запускаем 350 итераций "вдавливания"
for j in range(350):
    error, correct_cnt = (0.0, 0)
    for i in range(len(images)):
        # Прямой проход
        layer_0 = images[i:i+1]
        layer_1 = relu(np.dot(layer_0, weights_0_1))
        layer_2 = np.dot(layer_1, weights_1_2)

        # Обратный проход и обновление весов (глина меняет форму)
        layer_2_delta = (labels[i:i+1] - layer_2)
        layer_1_delta = layer_2_delta.dot(weights_1_2.T) * relu2deriv(layer_1)
        weights_1_2 += alpha * layer_1.T.dot(layer_2_delta)
        weights_0_1 += alpha * layer_0.T.dot(layer_1_delta)

Момент «затвердевания» глины (Шок от результата)

Когда программа доходит до конца, мы видим результат «переобученного слепка». Автор показывает ловушку, в которую попадает разработчик:

# Вывод программы в конце обучения:
# Train-Acc: 1.0  <-- Наша "глина" идеально повторяет форму 1000 картинок.
#                     Мы распознаем их со стопроцентной точностью.

# Но теперь мы берем "другую вилку" (тестовые данные, которые сеть не видела):
# Test-Acc: 0.7073 <-- Точность упала до 70%. 
#                      Сеть не узнает цифры, если в них нет тех "микроцарапин", 
#                      которые были в обучающем наборе.

Почему это произошло?
Сеть научилась распознавать конкретные пиксели, которые случайно оказались черными в обучающем наборе (шум), а не общую форму цифры (сигнал).


Решение задачи: Как не дать глине «запомнить лишнее»?

Траск предлагает метод Dropout (Прореживание). Программный смысл Dropout в контексте этой аналогии: мы каждый раз немного «трясем» глину, когда вдавливаем в неё вилку.

В коде это реализуется маской:

# Во время обучения мы случайно обнуляем часть нейронов
dropout_mask = np.random.randint(2, size=layer_1.shape)
layer_1 *= dropout_mask * 2 # Трясем глину!

Как это работает в аналогии:
Если при каждом нажатии вилкой мы будем случайно убирать кусочки глины в разных местах, слепок никогда не сможет запомнить микроцарапины (шум). Ему придется сосредоточиться на самых крупных, «неубиваемых» чертах — общих контурах вилки (сигнале).

Результат новой программы:

  • Train-Acc больше не достигает 1.0 (сеть не может вызубрить всё идеально).
  • Зато Test-Acc поднимается до 0.82 (82%).

4. Когда «лучшее» враг «хорошего»

В процессе обучения нейросеть проходит через две фазы:

  1. Фаза изучения сигнала: В начале обучения веса меняются так, чтобы уловить самые крупные и очевидные закономерности (например, что у «нуля» есть дырка посередине).
  2. Фаза изучения шума: Когда основные черты выучены, сеть начинает подстраиваться под мелкие случайности конкретных картинок из обучающей выборки, чтобы довести ошибку до абсолютного нуля.

Ранняя остановка — это прерывание процесса обучения ровно в тот момент, когда фаза изучения сигнала закончилась, а фаза зазубривания шума еще не началась.


Программное доказательство

Автор приводит результаты теста нейросети на MNIST. Программа выводит точность на обучающих данных (Train-Acc) и на тестовых (Test-Acc), которые сеть никогда не видела.

Посмотрим на цифры из книги:

# Итерация 0:
# Train-Acc: 0.537   Test-Acc: 0.6488

# Итерация 20 (ЗОЛОТАЯ СЕРЕДИНА):
# Train-Acc: 0.93    Test-Acc: 0.8111  <-- Пик точности на тестах!

# Итерация 100:
# Train-Acc: 0.984   Test-Acc: 0.7706  <-- Точность на тестах начала падать

# Итерация 349 (КОНЕЦ):
# Train-Acc: 1.0     Test-Acc: 0.7073  <-- Сеть выучила всё идеально, но на тестах провал

Анализ:

  • До 20-й итерации нейросеть учит полезные признаки. Точность растет везде.
  • После 20-й итерации точность на обучении (Train) продолжает расти до 100%, но точность на тестах (Test) падает с 81% до 70%.
  • Вывод: Идеальный момент для остановки — итерация №20.

Как это реализуется в программе

Хотя в книге нет отдельной функции early_stopping(), автор описывает алгоритм его внедрения в основной цикл обучения:

  1. Разделение данных: Нужно иметь «проверочный набор» (Validation Set).
  2. Контроль: Внутри цикла обучения (который мы рассматривали в прошлых ответах) нужно добавить проверку.

Примерная логика изменений в коде:

# Псевдокод логики ранней остановки на основе MNIST-программы Траска:

best_test_acc = 0

for j in range(iterations):
    # ... здесь идет код обучения на images (стр. 180) ...
    
    # ПРОВЕРКА (раз в 10 итераций)
    current_test_acc = correct_cnt / float(len(test_images))
    
    if current_test_acc > best_test_acc:
        best_test_acc = current_test_acc
        # Сохраняем лучшие веса
        # best_weights = (weights_0_1.copy(), weights_1_2.copy())
    else:
        # Если точность на тестах начала падать — СТОП!
        print("Стоп! Сеть начала учить шум.")
        break

Почему это работает (Аналогия с вилкой)

Возвращаясь к примеру с глиной:

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

5. В чем главная идея прореживания?

Траск объясняет: когда нейронная сеть большая, нейроны начинают «полагаться» друг на друга. Один нейрон учит шум, а другой — исправляет ошибку первого. В итоге они работают в связке (коадаптация), которая эффективна только на знакомых картинках.

Dropout ломает эти связи. Во время обучения мы случайным образом «выключаем» (обнуляем) часть нейронов.

  • Результат: Нейроны больше не могут надеяться на соседей. Каждому узлу приходится учить какой-то реальный, полезный признак (сигнал) самостоятельно.

Программная реализация: Прямое распространение

В коде это реализуется через создание маски. Маска — это вектор такой же длины, как скрытый слой, состоящий из нулей и единиц.

import numpy as np

# ... (инициализация весов weights_0_1 и weights_1_2) ...

# 1. Считаем выход скрытого слоя
layer_1 = relu(np.dot(layer_0, weights_0_1))

# 2. СОЗДАЕМ МАСКУ DROPOUT
# Генерируем случайные 0 или 1 (вероятность 50/50)
dropout_mask = np.random.randint(2, size=layer_1.shape)

# 3. ПРИМЕНЯЕМ МАСКУ И МАСШТАБИРУЕМ
# Мы обнуляем часть нейронов и умножаем оставшиеся на 2
layer_1 *= dropout_mask * 2 

# 4. Считаем финальный выход
layer_2 = np.dot(layer_1, weights_1_2)

Почему в коде стоит * 2?

Это важнейший нюанс. Если мы выключим 50% нейронов, то суммарный сигнал, который придет в следующий слой (layer_2), станет в два раза слабее. Это собьет сеть с толку. Чтобы «громкость» сигнала осталась прежней, мы удваиваем значения тех нейронов, которые остались включенными.


Программная реализация: Обратное распространение

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

code Python

download

content_copy

expand_less

# Считаем разность для выходного слоя
layer_2_delta = (labels[i:i+1] - layer_2)

# Считаем разность для скрытого слоя
layer_1_delta = layer_2_delta.dot(weights_1_2.T) * relu2deriv(layer_1)

# ПРИМЕНЯЕМ ТУ ЖЕ МАСКУ К ДЕЛЬТЕ
# Те нейроны, что были "выключены", не получают обновления весов
layer_1_delta *= dropout_mask

# Обновляем веса
weights_1_2 += alpha * layer_1.T.dot(layer_2_delta)
weights_0_1 += alpha * layer_0.T.dot(layer_1_delta)

Dropout во время тестирования

Это критическое правило: Dropout используется только во время обучения. Когда мы проверяем сеть на тестовых данных, мы не создаем маску и не выключаем нейроны. На тесте нам нужна вся мощь сети, где каждый нейрон уже натренирован быть «автономной боевой единицей».


Сравнение результатов

Автор показывает, как меняется поведение сети на MNIST при добавлении этих трех строк кода с маской:

Без Dropout:

  • Train Accuracy: 100% (Сеть всё зазубрила).
  • Test Accuracy: 70.7% (Плохо обобщает).

С Dropout:

  • Train Accuracy: ~90% (Сеть не может выучить всё идеально из-за "помех").
  • Test Accuracy: 82.3% (Победа! Точность на новых данных выросла более чем на 11%).

6. В чем проблема стохастического спуска и при чём тут пакетный градиентный спуск?

До этого момента в наших программах веса обновлялись после каждого примера (каждой картинки). Траск объясняет это так:

  • Шум: Каждый отдельный пример может быть «шумным» (плохо написанная цифра). Если обновлять веса сразу, сеть будет метаться из стороны в сторону, пытаясь подстроиться под каждую случайность.
  • Скорость: Процессорам гораздо проще и быстрее перемножить две большие матрицы один раз, чем делать 100 маленьких перемножений по очереди.

Решение: Группировка (Mini-batch)

Вместо того чтобы учиться на одной картинке, мы берем пакет (batch) — например, 100 картинок. Мы пропускаем их через сеть, считаем среднюю ошибку для всей группы и только тогда один раз обновляем веса.

Аналогия с компасом:
Представьте, что вы идете по компасу, но у него дрожит стрелка. Если вы сделаете шаг по первому же показанию, вы можете уйти не туда. Но если вы посмотрите на стрелку 100 раз и возьмете среднее значение, вы определите направление гораздо точнее.


Программная реализация пакетного спуска

Ключевое отличие этой программы — работа с матрицами строк вместо одного вектора.

import numpy as np

# Основные параметры
batch_size = 100  # Размер пакета
alpha = 0.001     # Скорость обучения (для пакетов она обычно выше)
iterations = 300

# ... (инициализация весов weights_0_1 и weights_1_2) ...

for j in range(iterations):
    error, correct_cnt = (0.0, 0)
    
    # Мы идем по данным не по одному, а прыжками по 100 (batch_size)
    for i in range(int(len(images) / batch_size)):
        batch_start = i * batch_size
        batch_end = (i + 1) * batch_size
        
        # 1. ПРЯМОЕ РАСПРОСТРАНЕНИЕ (сразу для 100 картинок)
        layer_0 = images[batch_start:batch_end]
        layer_1 = relu(np.dot(layer_0, weights_0_1))
        
        # (Опционально здесь может быть Dropout маска)
        dropout_mask = np.random.randint(2, size=layer_1.shape)
        layer_1 *= dropout_mask * 2
        
        layer_2 = np.dot(layer_1, weights_1_2)

        # 2. РАСЧЕТ ОШИБКИ ПО ПАКЕТУ
        # Считаем разность сразу для 100 предсказаний
        error += np.sum((labels[batch_start:batch_end] - layer_2) ** 2)
        
        # Считаем сколько цифр из 100 угадано верно
        for k in range(batch_size):
            correct_cnt += int(np.argmax(layer_2[k:k+1]) == \
                               np.argmax(labels[batch_start+k:batch_start+k+1]))

        # 3. ОБРАТНОЕ РАСПРОСТРАНЕНИЕ
        # Важно: мы делим дельту на batch_size, чтобы получить СРЕДНЕЕ значение
        layer_2_delta = (labels[batch_start:batch_end] - layer_2) / batch_size
        
        layer_1_delta = layer_2_delta.dot(weights_1_2.T) * relu2deriv(layer_1)
        layer_1_delta *= dropout_mask

        # 4. ОБНОВЛЕНИЕ ВЕСОВ (один раз за пакет)
        weights_1_2 += alpha * layer_1.T.dot(layer_2_delta)
        weights_0_1 += alpha * layer_0.T.dot(layer_1_delta)

Почему это работает лучше?

  1. Плавность: Если посмотреть на графики точности, при пакетном обучении кривая становится гораздо более гладкой. Сеть меньше «нервничает» из-за плохих примеров.
  2. Эффективность NumPy: Траск подчеркивает, что функция np.dot() при работе с матрицами (пакетами) выполняется на порядки быстрее. Это происходит благодаря параллельным вычислениям в библиотеке NumPy.
  3. Выбор Alpha: При пакетном обучении можно ставить более высокую скорость обучения (alpha), так как усредненный сигнал более надежен, чем сигнал от одной картинки.

Рад был поделиться обучением по книге Эндрю Траска "Грокаем глубокое обучение". Читайте также интересные посты в канале @cat_with_code