Глава 8. Оптимизаторы и регуляризация

127 просмотров
0 лайков
0 в избранном
Войдите, чтобы поставить лайк. Лайков:

Введение

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

Вторая половина главы — регуляризация: набор приёмов против переобучения. Из главы 7 ты, скорее всего, вынес разрыв между обучением и валидацией; теперь мы его сократим.

Что не так с чистым SGD

Представь длинный узкий овраг: по одной оси поверхность потерь падает круто, по другой — почти плоская. Градиент указывает в основном поперёк оврага, и SGD зигзагом мечется между стенками, медленно продвигаясь вдоль. Уменьшишь шаг — перестанет метаться, но и вдоль поползёт совсем медленно.

Второй недостаток — шум. Градиент по батчу из 64 примеров — оценка настоящего градиента, и она шумит. Каждый шаг чуть-чуть не туда.

Третий — одинаковый масштаб для всех параметров. Веса первого слоя и последнего живут в разных диапазонах, а lr у них общий.

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

Интерфейс оптимизатора

Вынесем шаг обновления из сети в отдельную сущность — это позволит менять алгоритм, не трогая слои.

// Optimizer обновляет параметры по их градиентам.
// Состояние (моменты, счётчики) хранится внутри реализации.
type Optimizer interface {
    Step(params, grads []*matrix.Matrix)
}

// Update обновляет всю сеть: собирает параметры слоёв и передаёт оптимизатору.
func (n *Network) Update(opt Optimizer) {
    for _, l := range n.Layers {
        params, grads := l.Params()
        if len(params) == 0 {
            continue
        }
        opt.Step(params, grads)
    }
}

Важная деталь: оптимизатор с состоянием (Momentum, Adam) хранит по одному буферу на каждый параметр. Значит, состояние надо привязывать к конкретной матрице, а не к её позиции в списке — иначе при изменении архитектуры буферы разъедутся. Проще всего использовать map[*matrix.Matrix]*matrix.Matrix: указатель на параметр как ключ.

Momentum: инерция

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

// SGD с моментом. Beta = 0 даёт обычный SGD.
type SGD struct {
    LR   float64
    Beta float64 // коэффициент инерции, обычно 0.9

    velocity map[*matrix.Matrix]*matrix.Matrix
}

func (o *SGD) Step(params, grads []*matrix.Matrix) {
    if o.velocity == nil {
        o.velocity = make(map[*matrix.Matrix]*matrix.Matrix)
    }
    for i, p := range params {
        g := grads[i]
        v, ok := o.velocity[p]
        if !ok {
            v = matrix.New(p.Rows, p.Cols)
            o.velocity[p] = v
        }
        for j := range p.Data {
            // Скорость — экспоненциальное среднее градиентов.
            v.Data[j] = o.Beta*v.Data[j] + (1-o.Beta)*g.Data[j]
            p.Data[j] -= o.LR * v.Data[j]
        }
    }
}

Что это даёт: вдоль оврага градиенты последовательно смотрят в одну сторону и складываются — движение ускоряется. Поперёк оврага они чередуют знак и гасят друг друга — колебания затухают. Плюс шум батчей усредняется. При Beta = 0.9 скорость — это примерно среднее последних десяти градиентов.

RMSProp: свой масштаб для каждого веса

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

type RMSProp struct {
    LR    float64
    Beta  float64 // обычно 0.9
    Eps   float64 // 1e-8, защита от деления на ноль

    sq map[*matrix.Matrix]*matrix.Matrix // среднее квадратов градиентов
}

func (o *RMSProp) Step(params, grads []*matrix.Matrix) {
    for i, p := range params {
        g := grads[i]
        s := o.state(p)
        for j := range p.Data {
            s.Data[j] = o.Beta*s.Data[j] + (1-o.Beta)*g.Data[j]*g.Data[j]
            p.Data[j] -= o.LR * g.Data[j] / (math.Sqrt(s.Data[j]) + o.Eps)
        }
    }
}

Eps здесь не косметика: на старте s равно нулю, и без него первое же деление даст бесконечность.

Adam: momentum плюс адаптивный масштаб

Adam объединяет обе идеи: хранит и среднее градиентов (первый момент), и среднее квадратов (второй момент). Это оптимизатор по умолчанию для большинства задач.

// Adam — адаптивный оптимизатор с коррекцией смещения моментов.
type Adam struct {
    LR    float64 // обычно 0.001
    Beta1 float64 // 0.9  — инерция градиента
    Beta2 float64 // 0.999 — инерция квадрата градиента
    Eps   float64 // 1e-8

    t int // номер шага, нужен для коррекции смещения
    m map[*matrix.Matrix]*matrix.Matrix
    v map[*matrix.Matrix]*matrix.Matrix
}

func NewAdam(lr float64) *Adam {
    return &Adam{
        LR: lr, Beta1: 0.9, Beta2: 0.999, Eps: 1e-8,
        m: make(map[*matrix.Matrix]*matrix.Matrix),
        v: make(map[*matrix.Matrix]*matrix.Matrix),
    }
}

func (o *Adam) Step(params, grads []*matrix.Matrix) {
    o.t++ // ВАЖНО: счётчик увеличивается один раз на шаг, а не на параметр

    // Коррекция смещения: моменты стартуют с нулей и в начале занижены.
    bc1 := 1 - math.Pow(o.Beta1, float64(o.t))
    bc2 := 1 - math.Pow(o.Beta2, float64(o.t))

    for i, p := range params {
        g := grads[i]
        m, v := o.moment(o.m, p), o.moment(o.v, p)

        for j := range p.Data {
            m.Data[j] = o.Beta1*m.Data[j] + (1-o.Beta1)*g.Data[j]
            v.Data[j] = o.Beta2*v.Data[j] + (1-o.Beta2)*g.Data[j]*g.Data[j]

            mHat := m.Data[j] / bc1
            vHat := v.Data[j] / bc2

            p.Data[j] -= o.LR * mHat / (math.Sqrt(vHat) + o.Eps)
        }
    }
}

Зачем коррекция смещения. Оба момента инициализированы нулями, поэтому на первых шагах они сильно занижены: при Beta2 = 0.999 второму моменту нужны сотни шагов, чтобы «набрать» реальное значение. Деление на 1 - beta^t компенсирует это ровно настолько, насколько нужно, и с ростом t множитель плавно стремится к единице. Без коррекции первые сотни шагов Adam делает неоправданно маленькие обновления.

Тонкость реализации: o.t++ стоит в Step, а не внутри цикла по параметрам. Если увеличивать счётчик на каждый параметр, коррекция схлопнется за несколько шагов и потеряет смысл. Ошибка тихая — обучение всё равно идёт, просто хуже.

Практическое правило по learning rate: для SGD типичны 0,01–0,5, для Adam — 0,001. Разница на два порядка, и попытка запустить Adam с lr = 0,5 закономерно всё разрушит.

Расписание learning rate

Даже с Adam полезно уменьшать шаг к концу обучения: в начале нужны крупные движения, в конце — аккуратная подстройка.

// StepDecay: каждые Every эпох умножаем lr на Gamma.
func StepDecay(base float64, every int, gamma float64) func(epoch int) float64 {
    return func(epoch int) float64 {
        return base * math.Pow(gamma, float64(epoch/every))
    }
}

// CosineDecay: плавное снижение от base до нуля за total эпох.
func CosineDecay(base float64, total int) func(epoch int) float64 {
    return func(epoch int) float64 {
        return base * 0.5 * (1 + math.Cos(math.Pi*float64(epoch)/float64(total)))
    }
}

Косинусное затухание — хороший выбор по умолчанию: нет резких скачков и не нужно подбирать момент снижения. Ступенчатое проще для понимания и легко читается в логе — видно момент, когда loss делает ступеньку вниз.

L2-регуляризация и weight decay

Переобучение часто сопровождается ростом весов: сеть «затачивается» под конкретные примеры всё более острыми настройками. L2-регуляризация добавляет к потерям штраф за величину весов и тем самым удерживает их около нуля.

L_total = L_data + (lambda / 2) * сумма(w²)

Производная штрафа по весу — просто lambda * w, поэтому в коде регуляризация выражается одной строкой:

// Прибавляем штраф к градиенту ПЕРЕД шагом оптимизатора.
func applyL2(params, grads []*matrix.Matrix, lambda float64) {
    for i, p := range params {
        if p.Rows == 1 {
            continue // смещения не регуляризуем
        }
        g := grads[i]
        for j := range p.Data {
            g.Data[j] += lambda * p.Data[j]
        }
    }
}

Почему смещения не трогают: bias не умножается на входы и не участвует в «заточке» под конкретные признаки, а его сжатие к нулю только мешает слою сдвигать порог. Типичное значение lambda — 1e-4…1e-3; подбирается по валидации.

Отдельное замечание про Adam: прибавление lambda*w к градиенту у него работает не так, как задумано, — штраф проходит через адаптивное деление на корень второго момента и получается неравномерным. Правильный вариант (он называется AdamW) вычитает lr * lambda * w прямо из веса, минуя моменты. Разница заметна на длинном обучении, и если ты используешь Adam с регуляризацией — делай именно так.

Dropout

Dropout на каждом шаге обучения случайно «выключает» часть нейронов. Сеть вынуждена не полагаться на отдельные нейроны и распределять знание — это резко снижает переобучение.

// Dropout — слой без параметров. В режиме обучения обнуляет
// долю P активаций, в режиме вывода пропускает всё без изменений.
type Dropout struct {
    P        float64 // вероятность выключения, например 0.2
    training bool
    rng      *rand.Rand
    mask     *matrix.Matrix // кеш маски для обратного прохода
}

func (d *Dropout) SetTraining(v bool) { d.training = v }

func (d *Dropout) Forward(x *matrix.Matrix) *matrix.Matrix {
    if !d.training || d.P <= 0 {
        return x // на инференсе dropout выключен полностью
    }
    keep := 1 - d.P
    d.mask = matrix.New(x.Rows, x.Cols)
    out := matrix.New(x.Rows, x.Cols)
    for i := range x.Data {
        if d.rng.Float64() < keep {
            // Inverted dropout: делим на keep сразу, чтобы матожидание
            // активации не менялось и на инференсе ничего не пришлось править.
            d.mask.Data[i] = 1 / keep
        }
        out.Data[i] = x.Data[i] * d.mask.Data[i]
    }
    return out
}

func (d *Dropout) Backward(gradOut *matrix.Matrix) *matrix.Matrix {
    if !d.training || d.P <= 0 {
        return gradOut
    }
    // Через выключенные нейроны градиент не проходит.
    return matrix.Hadamard(gradOut, d.mask)
}

Приём с делением на keep называется inverted dropout и является стандартом. Альтернатива — умножать активации на keep при инференсе — работает, но требует помнить об этом в коде предсказания, а забыть там куда легче.

Ключевое требование: у сети должны быть режимы обучения и вывода, и переключать их надо явно.

// SetTraining переводит все поддерживающие это слои в нужный режим.
func (n *Network) SetTraining(v bool) {
    for _, l := range n.Layers {
        if t, ok := l.(interface{ SetTraining(bool) }); ok {
            t.SetTraining(v)
        }
    }
}

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

Про batch normalization — коротко

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

Кейс: честное сравнение SGD и Adam

Сравним три конфигурации на MNIST при одинаковом сиде, архитектуре и числе эпох.

type experiment struct {
    name string
    opt  nn.Optimizer
}

func main() {
    train, val, _, _ := dataset.Load("data/mnist")

    experiments := []experiment{
        {"SGD lr=0.1", &nn.SGD{LR: 0.1}},
        {"SGD+momentum lr=0.1", &nn.SGD{LR: 0.1, Beta: 0.9}},
        {"Adam lr=0.001", nn.NewAdam(0.001)},
    }

    for _, e := range experiments {
        rng := rand.New(rand.NewPCG(42, 43)) // ОДИН сид на все эксперименты
        net := buildNet(rng)

        for epoch := 1; epoch <= 10; epoch++ {
            net.SetTraining(true)
            trainEpoch(net, loss, train, e.opt, rng)
            net.SetTraining(false)
            _, acc := Evaluate(net, loss, val, 256)
            fmt.Printf("%-22s эпоха %2d  acc %.2f%%\n", e.name, epoch, acc*100)
        }
    }
}

Что ты увидишь примерно: чистый SGD после первой эпохи около 92%, SGD с моментом — около 95%, Adam — около 96%. К десятой эпохе разрыв сокращается: все три подходят к 97–98%. Это типичная картина — Adam выигрывает в скорости старта и в том, что почти не требует подбора lr, а хорошо настроенный SGD с моментом на длинной дистанции часто догоняет и иногда обобщает чуть лучше.

Практический вывод: начинай с Adam при lr = 0,001. Если хочется выжать последние доли процента и есть время на подбор — пробуй SGD с моментом и расписанием.

Типичные ошибки

1. Adam с learning rate от SGD

NewAdam(0.5) — loss улетает в NaN на первых шагах. Adam уже нормирует шаг по величине градиента, дополнительный множитель 0,5 избыточен на два порядка. Дефолт 0,001 — не случайное число.

2. Забыть выключить dropout при оценке

Валидационная точность прыгает от запуска к запуску и заметно ниже реальной: сеть оценивается с выключенными наугад нейронами. Вызов SetTraining(false) перед Evaluate и true перед обучением — обязательная пара, которую легко потерять при рефакторинге.

3. Инкремент t внутри цикла по параметрам

Коррекция смещения в Adam мгновенно вырождается. Обучение продолжается и выглядит нормально, но первые эпохи работают хуже, чем должны. Ошибка находится только чтением кода — или тестом, который проверяет, что после одного Step счётчик равен единице.

4. Регуляризовать смещения

Даёт небольшое, но систематическое ухудшение: слой теряет свободу сдвигать порог. Пропускай матрицы формы 1×N.

5. Слишком сильный dropout

P = 0.5 на скрытом слое из 128 нейронов оставляет 64 — сеть недообучается. На небольших сетях начинай с 0,1–0,2. Симптом перебора: и обучающая, и валидационная точность низкие и растут медленно.

6. Сравнивать оптимизаторы при разных сидах или разном числе эпох

Единственный корректный протокол: один сид, одна архитектура, одно число эпох, меняется ровно одна вещь. Иначе выводы про «Adam лучше» ничем не подкреплены.

7. Общий буфер состояния на все параметры

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

Практика

Задание 1. Пакет оптимизаторов

Создай internal/nn/optimizer.go с интерфейсом Optimizer и реализациями SGD (с полем Beta для момента), RMSProp и Adam. Состояние храни в map по указателю на параметр. Перепиши цикл обучения так, чтобы он принимал оптимизатор, а не lr.

Задание 2. Тест на Adam

Проверь оптимизатор на задаче с известным ответом: минимизируй f(w) = (w - 3)², стартуя с w = 0. Градиент считается аналитически, оптимизатор должен привести w к 3 с точностью 1e-3 за разумное число шагов. Отдельно проверь, что после одного Step поле t равно единице.

Задание 3. Сравнение трёх оптимизаторов

Прогони SGD, SGD+momentum и Adam по 10 эпох на MNIST с одним сидом. Сведи результаты в таблицу «эпоха → точность» для каждого и построй три кривые на одном графике. Опиши словами, чем отличается характер кривых.

Задание 4. Расписание lr

Добавь StepDecay и CosineDecay, применяй их к полю LR оптимизатора в начале каждой эпохи. Сравни обучение с постоянным lr и с косинусным затуханием на 20 эпохах: разница обычно видна в последних эпохах, где кривая с расписанием опускается ниже.

Задание 5. Dropout и режимы

Реализуй слой Dropout (inverted) и метод Network.SetTraining. Обучи сеть 784-512-10 без dropout и с P = 0.2, сравнивая разрыв между точностью на обучении и на валидации. Именно этот разрыв, а не абсолютная точность, показывает эффект регуляризации.

Задание 6. L2

Добавь L2-регуляризацию с исключением смещений и подбери lambda из ряда 0, 1e-5, 1e-4, 1e-3 по валидационной точности. Отдельно посчитай среднюю абсолютную величину весов первого слоя для каждого значения и убедись, что с ростом lambda веса действительно сжимаются.

Задание 7. Намеренная ошибка

Убери SetTraining(false) перед оценкой и посмотри, как валидационная точность станет ниже и нестабильной от запуска к запуску. Запомни этот симптом — он встречается в реальных проектах чаще, чем хотелось бы.

Чек-лист самопроверки

  • Adam на задаче (w-3)² сходится к 3; счётчик t растёт по одному на шаг.
  • Adam при lr = 0,001 обучается быстрее чистого SGD на первых эпохах.
  • Dropout активен только в режиме обучения; в режиме вывода результат детерминирован (два подряд Evaluate дают одинаковое число).
  • Inverted dropout не меняет средний масштаб активаций: сравни средние значения выхода слоя в двух режимах.
  • С L2 средняя величина весов первого слоя меньше, чем без него.
  • Разрыв между обучающей и валидационной точностью с регуляризацией сократился.
  • Все сравнения сделаны при одном сиде и одинаковом числе эпох.

Итог

Momentum накапливает инерцию и гасит колебания поперёк «оврага», RMSProp подбирает масштаб шага под каждый параметр, Adam объединяет оба механизма и добавляет коррекцию смещения моментов — поэтому он и стал выбором по умолчанию с lr = 0,001. Расписание learning rate добавляет аккуратности в конце обучения.

Против переобучения работают L2 (штраф за величину весов, без смещений) и dropout (случайное выключение нейронов, обязательно с явными режимами train и eval). Оба оцениваются не по абсолютной точности, а по сокращению разрыва между обучающей и валидационной выборками.

Дальше — свёртки, которые учитывают двумерную структуру изображения, и конкурентность, ради которой мы и выбрали Go.

Комментарии 0

Для добавления комментариев необходимо войти или зарегистрироваться.

Пока нет комментариев. Станьте первым!