Фаза 03 · урок 07

Регуляризация

Цель урока: Нейронная сеть с достаточным числом параметров может запомнить любой набор данных. Это не гипотеза — Zhang et al. (2017) доказали это, обучив стандартные сети на ImageNet со случайными метками. Сети достигли почти нулевой обучающей…

Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.

Курс
AI Engineering from Scratch
Фаза
Основы глубокого обучения
Чтение
20 мин.
Проверено
Содержание урока
  1. Цели обучения
  2. Проблема
  3. Концепция
  4. Спектр переобучения
  5. Dropout
  6. Затухание весов (L2-регуляризация)
  7. Пакетная нормализация
  8. Нормализация по слоям
  9. RMSNorm
  10. Сравнение нормализаций
  11. Аугментация данных как регуляризация
  12. Ранняя остановка
  13. Что и когда применять
  14. Соберите это
  15. Шаг 1: Dropout (режимы обучения и eval)
  16. Шаг 2: L2-затухание весов
  17. Шаг 3: Пакетная нормализация
  18. Шаг 4: Нормализация по слоям
  19. Шаг 5: RMSNorm
  20. Шаг 6: Обучение с регуляризацией и без неё
  21. Используйте это
  22. Выпустите это
  23. Упражнения
  24. Ключевые термины
  25. Дополнительное чтение

Ваша модель получает 99% на обучающих данных и 60% на тестовых. Она запомнила, а не научилась. Регуляризация — это «налог» на сложность, который заставляет модель обобщать.

Тип: Сборка Языки: Python Предварительные требования: Урок 03.06 (Оптимизаторы) Время: ~75 минут

Цели обучения

  • Реализовать с нуля dropout с инвертированным масштабированием, L2-затухание весов, пакетную нормализацию, нормализацию по слоям и RMSNorm
  • Измерять разрыв между точностью на обучающей и тестовой выборках и диагностировать переобучение с помощью экспериментов с регуляризацией
  • Объяснять, почему трансформеры используют LayerNorm вместо BatchNorm и почему современные LLM предпочитают RMSNorm
  • Применять правильную комбинацию методов регуляризации в зависимости от тяжести переобучения

Проблема

Нейронная сеть с достаточным числом параметров может запомнить любой набор данных. Это не гипотеза — Zhang et al. (2017) доказали это, обучив стандартные сети на ImageNet со случайными метками. Сети достигли почти нулевой обучающей функции потерь при полностью случайном назначении меток. Они запомнили миллион случайных пар вход—выход, в которых не было закономерности для изучения. Обучающая функция потерь была идеальной. Тестовая точность была нулевой.

Это проблема переобучения, и с ростом моделей она усугубляется. У GPT-3 175 миллиардов параметров. В обучающем наборе около 500 миллиардов токенов. При таком числе параметров модель обладает достаточной ёмкостью, чтобы дословно запоминать существенные фрагменты обучающих данных. Без регуляризации она просто воспроизводила бы обучающие примеры вместо изучения обобщаемых закономерностей.

Разность между качеством на обучении и на тесте — это разрыв переобучения. Каждый метод в этом уроке атакует этот разрыв с другой стороны. Dropout заставляет сеть не полагаться на какой-либо один нейрон. Затухание весов не даёт отдельному весу стать слишком большим. Пакетная нормализация сглаживает ландшафт функции потерь, поэтому оптимизатор находит более плоские, лучше обобщающиеся минимумы. Нормализация по слоям делает то же, но работает там, где пакетная нормализация не справляется: на маленьких пакетах и последовательностях переменной длины. RMSNorm делает это на 10% быстрее, отказываясь от вычисления среднего. Каждый метод прост. Вместе они определяют разницу между моделью, которая запоминает, и моделью, которая обобщает.

Концепция

Спектр переобучения

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

Диаграмма к уроку «Регуляризация»

Dropout

Самый простой метод регуляризации с наиболее элегантной интерпретацией. Во время обучения случайно устанавливайте выход каждого нейрона в ноль с вероятностью p.

output = activation(z) * mask    where mask[i] ~ Bernoulli(1 - p)

При p = 0.5 на каждом прямом проходе зануляется половина нейронов. Сеть должна учить избыточные представления, поскольку не может предсказать, какие нейроны будут доступны. Это предотвращает коадаптацию — обучение нейронов полагаться на присутствие конкретных других нейронов.

Интерпретация через ансамбль: сеть с N нейронами и dropout создаёт 2^N возможных подсетей (все комбинации включённых и выключенных нейронов). Обучение с dropout приблизительно обучает все 2^N подсетей одновременно, каждую на разных мини-пакетах. На этапе тестирования используются все нейроны (без dropout), а выходы масштабируются на (1 - p), чтобы совпасть с ожидаемым значением при обучении. Это эквивалентно усреднению предсказаний 2^N подсетей — огромного ансамбля из одной модели.

На практике масштабирование выполняют во время обучения, а не тестирования (инвертированный dropout):

During training:  output = activation(z) * mask / (1 - p)
During testing:   output = activation(z)   (no change needed)

Это удобнее, потому что тестовому коду вообще не нужно знать о dropout.

Значения по умолчанию: p = 0.1 для трансформеров, p = 0.5 для MLP, p = 0.2-0.3 для CNN. Более высокий dropout = более сильная регуляризация = больший риск недообучения.

Затухание весов (L2-регуляризация)

Добавьте к функции потерь квадраты величин всех весов:

total_loss = task_loss + (lambda / 2) * sum(w_i^2)

Градиент члена регуляризации равен lambda * w. Это означает, что на каждом шаге каждый вес сжимается к нулю на долю, пропорциональную его величине. Большие веса штрафуются сильнее. Модель направляется к решениям, где ни один отдельный вес не доминирует.

Почему это улучшает обобщение: переобученные модели обычно имеют большие веса, усиливающие шум в обучающих данных. Затухание весов сохраняет веса маленькими, ограничивая эффективную ёмкость модели и вынуждая её опираться на устойчивые, обобщаемые признаки, а не на запомненные особенности.

Гиперпараметр lambda задаёт силу регуляризации. Типичные значения:

  • 0.01 для AdamW на трансформерах
  • 1e-4 для SGD на CNN
  • 0.1 для сильно переобученных моделей

Как обсуждалось в уроке 06: затухание весов и L2-регуляризация эквивалентны в SGD, но не в Adam. При обучении с Adam всегда используйте AdamW (отвязанное затухание весов).

Пакетная нормализация

Нормализуйте выход каждого слоя по мини-пакету, прежде чем передавать его следующему слою.

Для мини-пакета активаций на некотором слое:

mu = (1/B) * sum(x_i)           (batch mean)
sigma^2 = (1/B) * sum((x_i - mu)^2)   (batch variance)
x_hat = (x_i - mu) / sqrt(sigma^2 + eps)   (normalize)
y = gamma * x_hat + beta        (scale and shift)

Gamma и beta — обучаемые параметры, которые позволяют сети отменить нормализацию, если это оптимально. Без них вы принудительно задавали бы выходу каждого слоя нулевое среднее и единичную дисперсию, что может быть не тем, чего хочет сеть.

Разделение обучения и инференса: во время обучения mu и sigma вычисляются по текущему мини-пакету. Во время инференса используются накопленные при обучении скользящие средние (экспоненциальное скользящее среднее с momentum = 0.1, то есть 90% старого + 10% нового).

Вопрос о том, почему BatchNorm работает, всё ещё обсуждается. Исходная статья утверждала, что метод уменьшает «внутренний ковариатный сдвиг» (изменение распределения входов слоя по мере обновления ранних слоёв). Santurkar et al. (2018) показали, что это объяснение неверно. Настоящая причина: BatchNorm делает ландшафт функции потерь более гладким. Градиенты становятся более предсказуемыми, константы Липшица меньше, а оптимизатор может безопасно делать более крупные шаги. Именно поэтому BatchNorm позволяет использовать более высокие скорости обучения и быстрее сходиться.

У BatchNorm есть фундаментальное ограничение: он зависит от статистик пакета. При размере пакета 1 среднее и дисперсия не имеют смысла. На маленьких пакетах (< 32) статистики шумные и ухудшают качество. Это важно для таких задач, как детекция объектов (где память ограничивает размер пакета) и языковое моделирование (где длины последовательностей различаются).

Нормализация по слоям

Нормализуйте по признакам, а не по пакету. Для одного примера:

mu = (1/D) * sum(x_j)           (feature mean)
sigma^2 = (1/D) * sum((x_j - mu)^2)   (feature variance)
x_hat = (x_j - mu) / sqrt(sigma^2 + eps)
y = gamma * x_hat + beta

D — размерность признаков. Каждый пример нормализуется независимо — нет зависимости от размера пакета. Поэтому трансформеры используют LayerNorm вместо BatchNorm. Последовательности имеют переменную длину, размеры пакетов часто малы (или равны 1 при генерации), а вычисление одинаково на обучении и при инференсе.

LayerNorm в трансформерах применяется после каждого блока self-attention и каждого feed-forward блока (Post-LN) либо перед ними (Pre-LN, что устойчивее при обучении).

RMSNorm

LayerNorm без вычитания среднего. Предложена Zhang & Sennrich (2019).

rms = sqrt((1/D) * sum(x_j^2))
y = gamma * x / rms

Вот и всё. Нет вычисления среднего, нет параметра beta. Наблюдение состоит в том, что центрирование (вычитание среднего) в LayerNorm очень мало влияет на качество модели, но требует вычислений. Его удаление даёт ту же точность примерно с на 10% меньшими накладными расходами.

LLaMA, LLaMA 2, LLaMA 3, Mistral и большинство современных LLM используют RMSNorm вместо LayerNorm. В масштабе миллиардов параметров и триллионов токенов эта экономия в 10% существенна.

Сравнение нормализаций

Диаграмма к уроку «Регуляризация»

Аугментация данных как регуляризация

Это не модификация модели, а модификация данных. Преобразуйте входы обучения, сохраняя метки:

  • Изображения: случайный crop, отражение, поворот, цветовой jitter, cutout
  • Текст: замена синонимов, обратный перевод, случайное удаление
  • Аудио: растяжение по времени, сдвиг высоты тона, добавление шума

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

Ранняя остановка

Самый простой регуляризатор: прекратите обучение, когда валидационная функция потерь начинает расти. В этот момент модель ещё не переобучилась. На практике вы отслеживаете валидационную функцию потерь каждую эпоху, сохраняете лучшую модель и продолжаете обучение в окне «терпения» (обычно 5–20 эпох). Если валидационная функция потерь не улучшается в пределах окна терпения, вы останавливаетесь и загружаете лучшую сохранённую модель.

Что и когда применять

Диаграмма к уроку «Регуляризация»

l2-regularization

Соберите это

Шаг 1: Dropout (режимы обучения и eval)

import random
import math


class Dropout:
    def __init__(self, p=0.5):
        self.p = p
        self.training = True
        self.mask = None

    def forward(self, x):
        if not self.training:
            return list(x)
        self.mask = []
        output = []
        for val in x:
            if random.random() < self.p:
                self.mask.append(0)
                output.append(0.0)
            else:
                self.mask.append(1)
                output.append(val / (1 - self.p))
        return output

    def backward(self, grad_output):
        grads = []
        for g, m in zip(grad_output, self.mask):
            if m == 0:
                grads.append(0.0)
            else:
                grads.append(g / (1 - self.p))
        return grads

Шаг 2: L2-затухание весов

def l2_regularization(weights, lambda_reg):
    penalty = 0.0
    for w in weights:
        penalty += w * w
    return lambda_reg * 0.5 * penalty

def l2_gradient(weights, lambda_reg):
    return [lambda_reg * w for w in weights]

Шаг 3: Пакетная нормализация

class BatchNorm:
    def __init__(self, num_features, momentum=0.1, eps=1e-5):
        self.gamma = [1.0] * num_features
        self.beta = [0.0] * num_features
        self.eps = eps
        self.momentum = momentum
        self.running_mean = [0.0] * num_features
        self.running_var = [1.0] * num_features
        self.training = True
        self.num_features = num_features

    def forward(self, batch):
        batch_size = len(batch)
        if self.training:
            mean = [0.0] * self.num_features
            for sample in batch:
                for j in range(self.num_features):
                    mean[j] += sample[j]
            mean = [m / batch_size for m in mean]

            var = [0.0] * self.num_features
            for sample in batch:
                for j in range(self.num_features):
                    var[j] += (sample[j] - mean[j]) ** 2
            var = [v / batch_size for v in var]

            for j in range(self.num_features):
                self.running_mean[j] = (1 - self.momentum) * self.running_mean[j] + self.momentum * mean[j]
                self.running_var[j] = (1 - self.momentum) * self.running_var[j] + self.momentum * var[j]
        else:
            mean = list(self.running_mean)
            var = list(self.running_var)

        self.x_hat = []
        output = []
        for sample in batch:
            normalized = []
            out_sample = []
            for j in range(self.num_features):
                x_h = (sample[j] - mean[j]) / math.sqrt(var[j] + self.eps)
                normalized.append(x_h)
                out_sample.append(self.gamma[j] * x_h + self.beta[j])
            self.x_hat.append(normalized)
            output.append(out_sample)
        return output

Шаг 4: Нормализация по слоям

class LayerNorm:
    def __init__(self, num_features, eps=1e-5):
        self.gamma = [1.0] * num_features
        self.beta = [0.0] * num_features
        self.eps = eps
        self.num_features = num_features

    def forward(self, x):
        mean = sum(x) / len(x)
        var = sum((xi - mean) ** 2 for xi in x) / len(x)

        self.x_hat = []
        output = []
        for j in range(self.num_features):
            x_h = (x[j] - mean) / math.sqrt(var + self.eps)
            self.x_hat.append(x_h)
            output.append(self.gamma[j] * x_h + self.beta[j])
        return output

Шаг 5: RMSNorm

class RMSNorm:
    def __init__(self, num_features, eps=1e-6):
        self.gamma = [1.0] * num_features
        self.eps = eps
        self.num_features = num_features

    def forward(self, x):
        rms = math.sqrt(sum(xi * xi for xi in x) / len(x) + self.eps)
        output = []
        for j in range(self.num_features):
            output.append(self.gamma[j] * x[j] / rms)
        return output

Шаг 6: Обучение с регуляризацией и без неё

def sigmoid(x):
    x = max(-500, min(500, x))
    return 1.0 / (1.0 + math.exp(-x))


def make_circle_data(n=200, seed=42):
    random.seed(seed)
    data = []
    for _ in range(n):
        x = random.uniform(-2, 2)
        y = random.uniform(-2, 2)
        label = 1.0 if x * x + y * y < 1.5 else 0.0
        data.append(([x, y], label))
    return data


class RegularizedNetwork:
    def __init__(self, hidden_size=16, lr=0.05, dropout_p=0.0, weight_decay=0.0):
        random.seed(0)
        self.hidden_size = hidden_size
        self.lr = lr
        self.dropout_p = dropout_p
        self.weight_decay = weight_decay
        self.dropout = Dropout(p=dropout_p) if dropout_p > 0 else None

        self.w1 = [[random.gauss(0, 0.5) for _ in range(2)] for _ in range(hidden_size)]
        self.b1 = [0.0] * hidden_size
        self.w2 = [random.gauss(0, 0.5) for _ in range(hidden_size)]
        self.b2 = 0.0

    def forward(self, x, training=True):
        self.x = x
        self.z1 = []
        self.h = []
        for i in range(self.hidden_size):
            z = self.w1[i][0] * x[0] + self.w1[i][1] * x[1] + self.b1[i]
            self.z1.append(z)
            self.h.append(max(0.0, z))

        if self.dropout and training:
            self.dropout.training = True
            self.h = self.dropout.forward(self.h)
        elif self.dropout:
            self.dropout.training = False
            self.h = self.dropout.forward(self.h)

        self.z2 = sum(self.w2[i] * self.h[i] for i in range(self.hidden_size)) + self.b2
        self.out = sigmoid(self.z2)
        return self.out

    def backward(self, target):
        eps = 1e-15
        p = max(eps, min(1 - eps, self.out))
        d_loss = -(target / p) + (1 - target) / (1 - p)
        d_sigmoid = self.out * (1 - self.out)
        d_out = d_loss * d_sigmoid

        for i in range(self.hidden_size):
            d_relu = 1.0 if self.z1[i] > 0 else 0.0
            d_h = d_out * self.w2[i] * d_relu
            self.w2[i] -= self.lr * (d_out * self.h[i] + self.weight_decay * self.w2[i])
            for j in range(2):
                self.w1[i][j] -= self.lr * (d_h * self.x[j] + self.weight_decay * self.w1[i][j])
            self.b1[i] -= self.lr * d_h
        self.b2 -= self.lr * d_out

    def evaluate(self, data):
        correct = 0
        total_loss = 0.0
        for x, y in data:
            pred = self.forward(x, training=False)
            eps = 1e-15
            p = max(eps, min(1 - eps, pred))
            total_loss += -(y * math.log(p) + (1 - y) * math.log(1 - p))
            if (pred >= 0.5) == (y >= 0.5):
                correct += 1
        return total_loss / len(data), correct / len(data) * 100

    def train_model(self, train_data, test_data, epochs=300):
        history = []
        for epoch in range(epochs):
            total_loss = 0.0
            correct = 0
            for x, y in train_data:
                pred = self.forward(x, training=True)
                self.backward(y)
                eps = 1e-15
                p = max(eps, min(1 - eps, pred))
                total_loss += -(y * math.log(p) + (1 - y) * math.log(1 - p))
                if (pred >= 0.5) == (y >= 0.5):
                    correct += 1
            train_loss = total_loss / len(train_data)
            train_acc = correct / len(train_data) * 100
            test_loss, test_acc = self.evaluate(test_data)
            history.append((train_loss, train_acc, test_loss, test_acc))
            if epoch % 75 == 0 or epoch == epochs - 1:
                gap = train_acc - test_acc
                print(f"    Epoch {epoch:3d}: train_acc={train_acc:.1f}%, test_acc={test_acc:.1f}%, gap={gap:.1f}%")
        return history

Используйте это

PyTorch предоставляет всю нормализацию и регуляризацию в виде модулей:

import torch
import torch.nn as nn

model = nn.Sequential(
    nn.Linear(784, 256),
    nn.BatchNorm1d(256),
    nn.ReLU(),
    nn.Dropout(0.3),
    nn.Linear(256, 128),
    nn.BatchNorm1d(128),
    nn.ReLU(),
    nn.Dropout(0.3),
    nn.Linear(128, 10),
)

model.train()
out_train = model(torch.randn(32, 784))

model.eval()
out_test = model(torch.randn(1, 784))

Переключатель model.train() / model.eval() критически важен. Он включает и выключает dropout и указывает BatchNorm использовать статистики пакета либо накопленные статистики. Забыть вызвать model.eval() перед инференсом — одна из самых частых ошибок в глубоком обучении. Тестовая точность будет случайно колебаться, поскольку dropout всё ещё активен, а BatchNorm использует статистики мини-пакета.

Для трансформеров шаблон другой:

class TransformerBlock(nn.Module):
    def __init__(self, d_model=512, nhead=8, dropout=0.1):
        super().__init__()
        self.attention = nn.MultiheadAttention(d_model, nhead, dropout=dropout)
        self.norm1 = nn.LayerNorm(d_model)
        self.ff = nn.Sequential(
            nn.Linear(d_model, d_model * 4),
            nn.GELU(),
            nn.Linear(d_model * 4, d_model),
            nn.Dropout(dropout),
        )
        self.norm2 = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        attended, _ = self.attention(x, x, x)
        x = self.norm1(x + self.dropout(attended))
        x = self.norm2(x + self.ff(x))
        return x

LayerNorm, а не BatchNorm. Dropout p=0.1, а не p=0.5. Это значения по умолчанию для трансформеров.

Выпустите это

Этот урок создаёт:

  • outputs/prompt-regularization-advisor.md — промпт, который диагностирует переобучение и рекомендует правильную стратегию регуляризации

Упражнения

  1. Реализуйте пространственный dropout для 2D-данных: вместо исключения отдельных нейронов исключайте целые каналы признаков. Смоделируйте это, рассматривая группы последовательных признаков как каналы и исключая целые группы. Сравните разрыв обучение—тест со стандартным dropout на наборе данных с кругом при hidden_size=32.

  2. Объедините сглаживание меток из урока 05 с dropout из этого урока. Обучите четыре конфигурации: ни один метод, только dropout, только сглаживание меток, оба метода. Измерьте финальный разрыв точности обучение—тест для каждой. Какая комбинация даёт наименьший разрыв?

  3. Добавьте слой BatchNorm между скрытым слоем и активацией в вашей сети для набора данных с кругом. Обучите с BatchNorm и без него при скоростях обучения 0.01, 0.05 и 0.1. BatchNorm должен позволить устойчивое обучение на более высоких скоростях обучения, на которых обычная сеть расходится.

  4. Реализуйте раннюю остановку: отслеживайте тестовую функцию потерь каждую эпоху, сохраняйте лучшие веса и останавливайтесь, если тестовая функция потерь не улучшалась 20 эпох. Запустите регуляризованную сеть на 1000 эпох. Сообщите, на какой эпохе была лучшая тестовая точность и сколько эпох вычислений вы сэкономили.

  5. Сравните LayerNorm и RMSNorm на 4-слойной сети (а не только на 2-слойной). Инициализируйте обе одинаковыми весами. Обучайте 200 эпох и сравните финальную точность, скорость обучения (время на эпоху) и величины градиентов на первом слое. Подтвердите, что RMSNorm быстрее при той же точности.

Ключевые термины

Термин Как обычно говорят Что это действительно означает
Переобучение «Модель запомнила данные» Когда качество модели на обучении значительно превосходит качество на тесте, что указывает на изучение шума вместо сигнала
Регуляризация «Предотвращение переобучения» Любой метод, ограничивающий сложность модели для улучшения обобщения: dropout, затухание весов, нормализация, аугментация
Dropout «Случайное удаление нейронов» Зануление случайных нейронов с вероятностью p во время обучения, принуждающее к избыточным представлениям; эквивалентно обучению ансамбля
Затухание весов «L2-штраф» Сжатие всех весов к нулю вычитанием lambda * w на каждом шаге; штрафует сложность через величины весов
Пакетная нормализация «Нормализация на пакет» Нормализация выходов слоя по измерению пакета с использованием статистик пакета на обучении и накопленных средних при инференсе
Нормализация по слоям «Нормализация на пример» Нормализация по признакам внутри каждого примера; не зависит от пакета, используется в трансформерах, где размер пакета меняется
RMSNorm «LayerNorm без среднего» Нормализация по среднеквадратичному значению; убирает вычитание среднего из LayerNorm, обеспечивая 10% ускорения при равной точности
Ранняя остановка «Остановиться до переобучения» Прекращение обучения, когда валидационная функция потерь перестаёт улучшаться; простейший регуляризатор, часто применяемый с другими
Аугментация данных «Больше данных из меньшего» Преобразование входов обучения (отражение, crop, шум) для увеличения эффективного размера набора данных и обучения инвариантности
Разрыв обобщения «Разделение обучение—тест» Разность между качеством на обучении и тесте; регуляризация стремится минимизировать этот разрыв

Дополнительное чтение

  • Srivastava et al., «Dropout: A Simple Way to Prevent Neural Networks from Overfitting» (2014) — исходная статья о dropout с интерпретацией через ансамбль и обширными экспериментами
  • Ioffe & Szegedy, «Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift» (2015) — представила BatchNorm и процедуру её обучения; одна из самых цитируемых статей по глубокому обучению
  • Zhang & Sennrich, «Root Mean Square Layer Normalization» (2019) — показала, что RMSNorm достигает точности LayerNorm при меньших вычислениях; метод принят LLaMA и Mistral
  • Zhang et al., «Understanding Deep Learning Requires Rethinking Generalization» (2017) — знаковая статья, показавшая, что нейронные сети способны запоминать случайные метки и поставившая под сомнение традиционные представления об обобщении

Источник: Regularization 03.06 — Оптимизаторы · Фаза 3 — Основы глубокого обучения · Полный каталог · 03.08 — Инициализация весов