Основы глубокого обучения

Глубокое обучение — это не какая-то загадочная черная технология; по сути, это математический аппроксиматор функций — дайте ему большие объемы входных и выходных данных, и он автоматически найдет сложные взаимосвязи между ними.

Например: вы даете ему миллион изображений кошек, где каждому изображению соответствует метка "cat". Глубокое обучение автоматически определяет, какие комбинации пикселей выглядят как кошка.

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

Основное преимущество глубокого обучения заключается в том, что с увеличением объема данных его производительность продолжает улучшаться.Это то, чего не может сделать традиционное машинное обучение.

В этой главе мы начнем с нуля и шаг за шагом разберем каждый компонент глубокого обучения:

  • Математические основы нейронных сетей
  • Вычислительный процесс прямого распространения
  • Роль функций активации
  • Проектирование функции потерь
  • Принцип обратного распространения
  • Выбор оптимизатора
  • Методы регуляризации
  • Методы нормализации
  • Планирование скорости обучения

Наконец, мы реализуем полный многослойный перцептрон (MLP) с помощью PyTorch, связывая все изученные темы воедино.

В этой главе есть немного математики, но не волнуйтесь — мы объясним каждую формулу интуитивно, а не попросим вас заучить её. Главное — понять, почему она устроена именно так, а не как её вычислять.


Математические основы нейронных сетей

Глубокое обучение строится на трёх разделах математики: линейной алгебре, математическом анализе и теории вероятностей.

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

Линейная алгебра: умножение матриц

Самая базовая операция в нейронных сетях — это умножение матриц.

Почему используются матрицы? Потому что они позволяют кратко представить процесс «преобразования нескольких входов несколькими нейронами».

Сначала рассмотрим наглядный пример:

Пример

# ============================================
# Интуитивное понимание умножения матриц
# ============================================

import numpy as np

# Предположим, что вход — 3-мерный вектор: [height, weight, age]
# Единицы измерения: сантиметры, килограммы, годы
x = np.array([175, 70, 25])  # Входной вектор, форма (3,)
print(f"Вход x: {x}")
print(f"Форма входа: {x.shape}")

# Матрица весов W: 2 нейрона, каждый нейрон получает 3 входа
# Форма — (размерность выхода, размерность входа) = (2, 3)
W = np.array([
    [0.1, 0.2, 0.3],   # Веса 1-го нейрона
    [0.4, 0.5, 0.6],   # Веса 2-го нейрона
])
print(f"\nМатрица весов W:\n{W}")
print(f"Форма весов: {W.shape}")

# Смещение b: одно смещение на нейрон
b = np.array([0.1, 0.2])  # Форма (2,)
print(f"\nСмещение b: {b}")
print(f"Форма смещения: {b.shape}")

# Умножение матриц: y = W · x + b
# Примечание: оператор @ в numpy обозначает умножение матриц
y = W @ x + b
print(f"\nВыход y: {y}")
print(f"Форма выхода: {y.shape}")

# Проверим вручную
y1 = 0.1 * 175 + 0.2 * 70 + 0.3 * 25 + 0.1  # 1-й нейрон
y2 = 0.4 * 175 + 0.5 * 70 + 0.6 * 25 + 0.2  # 2-й нейрон
print⟦Проверка ручного расчёта: [{y1}, {y2}]"(f"\nManual calculation verification: [{y1}, {y2}]")

Геометрический смысл умножения матриц — это «линейное преобразование» — оно может поворачивать, растягивать и растягивать векторные пространства.

Но одних линейных преобразований недостаточно.

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

Вот почему нам нужны функции активации — чтобы внести нелинейность.

Математический анализ: производные и правило цепочки

Ядро обучения нейронных сетей — это «градиентный спуск» — поиск направления изменения параметров, которое минимизирует потери.

Градиент — это «производная функции многих переменных» — она показывает, насколько меняются потери при небольшом изменении каждого параметра.

Рассмотрим простой пример:

Пример

# ============================================
# Интуитивное понимание производных
# ============================================

def f(x):
    """Простая функция: f(x) = x²"""
    return x ** 2

def numerical_derivative(f, x, h=1e-6):
    """Численная производная: используйте малое приращение h, чтобы аппроксимировать производную
Определение производной: f'(x) = lim(h→0) [f(x+h) - f(x)] / h
    """

    return (f(x + h) - f(x)) / h

# Вычислить производную в точке x=3
x = 3.0
df_dx = numerical_derivative(f, x)
print(f"f({x}) = {f(x)}")
print(f"f'({x}) ≈ {df_dx}")
print(f"Аналитическое решение (точное значение): 2 * {x} = {2 * x}")

# Понимание смысла производной:
# Производная 6 означает: при x=3, если x увеличивается на 1, f(x) увеличивается примерно на 6
x_new = x + 0.01
f_new = f(x_new)
print(f"\nx увеличивается с {x} до {x_new}")
print(f"f(x) изменяется с {f(x)} до {f_new}")
print(f"Фактическое увеличение: {f_new - f(x)}")
print(f"Предсказание производной: {df_dx * 0.01}")

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

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

Пример

# ============================================
# Интуитивное понимание правила цепочки
# ============================================

# Рассмотрим сложную функцию: y = f(g(x))
# Где: g(x) = x², f(z) = z³
# Тогда: y = (x²)³ = x⁶

def g(x):
    return x ** 2

def f(z):
    return z ** 3

def y(x):
    return f(g(x))

# Вычислим производную вручную (правило цепочки):
# dy/dx = df/dz * dz/dx = 3*z² * 2*x = 3*(x²)² * 2*x = 6*x⁵

x = 2.0
z = g(x)  # z = 4
dy_dx_chain = 3 * (z ** 2) * 2 * x  # Вычисление по правилу цепочки
print(f"Вычисление по правилу цепочки: dy/dx = {dy_dx_chain}")

# Численная проверка производной
def numerical_derivative_y(x, h=1e-6):
    return (y(x + h) - y(x)) / h

dy_dx_numerical = numerical_derivative_y(x)
print(f"Проверка численной производной: dy/dx ≈ {dy_dx_numerical}")

# Аналитическое решение: 6*x^5 = 6*32 = 192
print(f"Аналитическое решение: 6 * {x}^5 = {6 * (x ** 5)}")

Основная идея цепного правила:Разбейте сложную функцию на простые функции, продифференцируйте каждую, затем перемножьте их.。

Обратное распространение — это применение цепного правила в нейронных сетях: начиная с выхода, оно распространяет градиенты обратно слой за слоем.

Теория вероятностей: условная вероятность

В задачах классификации нейронные сети часто выдают «распределение вероятностей» — при заданном входе какова вероятность каждого класса.

Условная вероятность P(Y|X) представляет «вероятность наступления Y при условии, что X наступило».

:.2%}

:.2%}

Пример

# ============================================
# Используйте нейронную сеть, чтобы вывести распределение вероятностей
# ============================================

import numpy as np

def softmax(x):
    """Функция Softmax: преобразует произвольные вещественные числа в распределение вероятностей
Выходные значения в сумме дают 1, и каждое значение находится в диапазоне [0, 1]
    """

    # Вычитаем максимальное значение, чтобы предотвратить числовое переполнение
    exp_x = np.exp(x - np.max(x))
    return exp_x / np.sum(exp_x)

# Предположим, что последний слой нейронной сети выводит 3 "оценки" (логиты)
# Они соответствуют "кошке", "собаке", "птице" соответственно
logits = np.array([2.0, 1.0, 0.5])
print(fВывод нейронной сети (логиты): {logits})

# Используйте Softmax для преобразования в вероятности
probabilities = softmax(logits)
print(fРаспределение вероятностей: {probabilities})
print(fСумма вероятностей: {np.sum(probabilities)})

# Интерпретация:
# P(кошка|входное изображение) ≈ 0.67
# P(собака|входное изображение) ≈ 0.24
# P(птица|входное изображение) ≈ 0.09
print("\nВероятности категорий:)
print(fКошка: {probabilities)
print(fСобака: {probabilities)
print(fПтица: {probabilities)

Ключевые моменты трёх математических основ:

Раздел математикиОсновная концепцияПрименение в глубоком обучении
Линейная алгебраУмножение матриц, векторы, тензорыПредставлять структуру сети, эффективно вычислять прямое распространение
Математический анализПроизводные, правило цепочки, градиентыОбратное распространение, обновление параметров
Теория вероятностейУсловная вероятность, распределение вероятностейНеопределённость модели, проектирование функций потерь

Прямое распространение

Прямое распространение — это вычислительный процесс, в котором входные данные «проходят через» нейронную сеть от входного слоя к скрытому слою и к выходному слою.

Граф вычислений

Представление нейронной сети в виде графа вычислений делает логику прямого и обратного распространения более ясной.

Граф вычислений состоит из узлов (операций) и рёбер (потоков данных).

Граф вычислений простой двухслойной нейронной сети:

Пример

# ============================================
# Используйте граф вычислений, чтобы понять прямое распространение
# ============================================

import numpy as np

def relu(x):
    """Функция активации ReLU: max(0, x)"""
    return np.maximum(0, x)

# Простая двухслойная нейронная сеть
# Входной слой (2) → скрытый слой (3) → выходной слой (2)

# Вход: x = [x1, x2]
x = np.array([1.0, 2.0])
print(fВход x: {x})

# Первый слой: вход → скрытый слой
W1 = np.array([
    [0.1, 0.2],  # 1-й скрытый нейрон
    [0.3, 0.4],  # 2-й скрытый нейрон
    [0.5, 0.6],  # 3-й скрытый нейрон
])
b1 = np.array([0.1, 0.2, 0.3])

# Второй слой: скрытый слой → выходной слой
W2 = np.array([
    [0.1, 0.2, 0.3],  # 1-й выходной нейрон
    [0.4, 0.5, 0.6],  # 2-й выходной нейрон
])
b2 = np.array([0.1, 0.2])

# Вычислительный граф прямого распространения
# Шаг 1: линейное преобразование скрытого слоя
z1 = W1 @ x + b1
print(f"\nЛинейное преобразование скрытого слоя z1: {z1})

# Шаг 2: функция активации скрытого слоя
a1 = relu(z1)
print(fАктивация скрытого слоя a1: {a1})

# Шаг 3: линейное преобразование выходного слоя
z2 = W2 @ a1 + b2
print(fЛинейное преобразование выходного слоя z2: {z2})

# Шаг 4: активация выходного слоя (для задач классификации, обычно Softmax)
a2 = softmax(z2)  # Используйте функцию softmax, определённую ранее
print(fАктивация выходного слоя a2: {a2})

Преимущество графа вычислений в том, что каждый шаг ясен, а во время обратного распространения вы просто вычисляете градиенты в обратном порядке.

Нейронные сети с точки зрения матричного умножения

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

Каждый слой можно абстрагировать так:

aᵢ = activation(Wᵢ · aᵢ₋₁ + bᵢ)

где a₀ — это вход x.

Эта формула лаконична, но при этом мощна — она может представлять почти все структуры нейронных сетей, от простой логистической регрессии до сложных Transformer.

Обратите внимание на согласование размерностей для матричного умножения: если входной вектор имеет размерность n, а в следующем слое m нейронов, то матрица весов W должна иметь форму m×n. Так, после W·x вы получаете m-мерный вектор.

Роль функций активации

Как упоминалось ранее: без функций активации глубокая сеть эквивалентна однослойной сети.

Используйте простой пример, чтобы доказать это:

Пример

# ============================================
# Зачем нужны функции активации
# ============================================

import numpy as np

# Предположим, есть двухслойная сеть, но ни в одном слое нет функции активации

# Ввод
x = np.array([1.0, 2.0])

# Веса и смещение первого слоя
W1 = np.array([[0.1, 0.2], [0.3, 0.4]])
b1 = np.array([0.1, 0.2])

# Веса и смещение второго слоя
W2 = np.array([[0.5, 0.6], [0.7, 0.8]])
b2 = np.array([0.3, 0.4])

# Вычислить оба слоя отдельно
z1 = W1 @ x + b1
z2 = W2 @ z1 + b2
print(f"Результаты двухслойного вычисления: {z2}")

# Объединить в один слой для вычисления
# Математически можно доказать: z2 = (W2·W1)·x + (W2·b1 + b2)
W_combined = W2 @ W1
b_combined = W2 @ b1 + b2
z_combined = W_combined @ x + b_combined
print(f"Результат вычисления после объединения в один слой: {z_combined}")

# Результаты в точности совпадают!
print(f"\n"Два результата равны: {np.allclose(z2, z_combined)}")
print("Вывод: без функций активации глубокая сеть = однослойная сеть")

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


Подробно о функциях активации

Функция активации определяет, как выход нейрона реагирует на его вход.

Хорошая функция активации должна удовлетворять нескольким условиям:

  • Нелинейность: это обязательное условие
  • Дифференцируемость: чтобы можно было вычислять градиенты
  • Простота вычислений: прямое и обратное распространение должны быть быстрыми
  • Отсутствие насыщения: градиенты не будут затухать или взрываться

Рассмотрим наиболее часто используемые функции активации.

Сигмоида: насыщение и исчезающие градиенты

Сигмоида — одна из самых первых функций активации. Она сжимает любое вещественное число до значений в интервале (0, 1).

Формула: σ(x) = 1 / (1 + e⁻ˣ)

Пример

# ============================================
# Сигмоидная функция активации
# ============================================

import numpy as np
import matplotlib.pyplot as plt

def sigmoid(x):
    """Сигмоидная функция"""
    return 1 / (1 + np.exp(-x))

def sigmoid_derivative(x):
    """Производная сигмоиды: σ'(x) = σ(x) * (1 - σ(x))"""
    s = sigmoid(x)
    return s * (1 - s)

# Проверим некоторые значения
x_values = [-10, -5, -2, 0, 2, 5, 10]
print("x | sigmoid(x) | sigmoid'(x)")
print("-" * 40)
for x in x_values:
    s = sigmoid(x)
    d = sigmoid_derivative(x)
    print(f"{x:4} | {s:10.6f} | {d:12.6f}")

# Наблюдение: когда x очень большой или очень маленький, производная приближается к 0
# Это проблема "исчезающего градиента"
x_big = 10.0
print(f"\n"Когда x = {x_big}:")
print(f" sigmoid(x) = {sigmoid(x_big):.10f} (почти 1)")
print(f" sigmoid'(x) = {sigmoid_derivative(x_big):.10f} (почти 0)")
print("Градиент исчезает! Во время обратного распространения градиент не может быть передан обратно.")

Проблемы с Sigmoid очевидны:

  • Когда |x| > 6, градиент почти 0, что приводит к исчезающим градиентам
  • Выход не центрирован относительно 0, что влияет на оптимизацию
  • Вычисление экспоненты относительно медленно

Из-за этих проблем Sigmoid редко используется в скрытых слоях в современном глубоком обучении.

ReLU и варианты

ReLU (Rectified Linear Unit) — наиболее часто используемая функция активации сегодня.

Формула: ReLU(x) = max(0, x)

Простая, но очень эффективная.

Пример

# ============================================
# ReLU и его варианты
# ============================================

import numpy as np

def relu(x):
    """ReLU:max(0, x)"""
    return np.maximum(0, x)

def relu_derivative(x):
    """Производная ReLU: 1 при x > 0, иначе 0"""
    return (x > 0).astype(float)

def leaky_relu(x, alpha=0.01):
    """Leaky ReLU: x при x > 0, иначе alpha*x"""
    return np.where(x > 0, x, alpha * x)

def gelu(x):
    """GELU:Гауссова линейная единица ошибки
Это функция активации, обычно используемая в трансформере
Формула приближения: 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715*x^3)))
    """

    cdf = 0.5 * (1.0 + np.tanh(np.sqrt(2 / np.pi) * (x + 0.044715 * np.power(x, 3))))
    return x * cdf

#
x_values = [-5, -2, -1, 0, 1, 2, 5]
print("x    | ReLU | Leaky | GELU")
print("-" * 45)
for x in x_values:
    r = relu(x)
    lr = leaky_relu(x)
    g = gelu(x)
    print(f"{x:4} | {r:4.2f} | {lr:5.2f} | {g:5.2f}")

# Преимущества ReLU:
print("\n"Преимущества ReLU:")
print("1. Простое вычисление: требуется только операция max")
print("2. Отсутствие насыщения (положительный интервал): градиент всегда равен 1")
print("3. Разреженность: некоторые выходы нейронов равны 0, что повышает устойчивость модели")

# Проблема с ReLU: "dying ReLU"
print("\n"Проблема с ReLU: dying ReLU")
print(Если вход нейрона всегда отрицателен, то:)
print(- Выход всегда равен 0)
print(- Градиент всегда равен 0)
print(- Параметры никогда не обновляются)
print(- Этот нейрон 'мёртв')

Сравнение нескольких распространённых функций активации:

Функция активацииФормулаПреимуществаНедостаткиПрименимые сценарии
Сигмоида1/(1+e⁻ˣ)Выход в (0,1), интерпретируемый как вероятностьИсчезающий градиент, не центрирован относительно нуля, медленные вычисленияВыходной слой бинарной классификации (редко используется для скрытых слоев)
ReLUmax(0, x)Быстрые вычисления, отсутствие насыщения, разреженностьМертвая ReLU, выход не центрирован относительно нуляБольшинство скрытых слоев (выбор по умолчанию)
Leaky ReLUmax(αx, x)Решает проблему мертвых ReLUα необходимо выбирать вручнуюАльтернатива, когда ReLU не справляется
GELUx·Φ(x)Гладкая, хорошо работает в TransformerНемного более сложные вычисленияTransformer, BERT, GPT и т. д.
Swish/SwiGLUx·σ(βx)Превосходит ReLU в глубоких сетяхСложные вычисленияБольшие модели, такие как PaLM, LLaMA

Принципы выбора

Рекомендуемые варианты функций активации:

  • Скрытый слой: предпочитайте ReLU — простой и эффективный
  • Если вы столкнулись с мёртвым ReLU: попробуйте Leaky ReLU или GELU
  • Transformer: GELU или SwiGLU — стандартный выбор
  • Выходной слой бинарной классификации: Sigmoid (выдаёт вероятность)
  • Выходной слой многоклассовой классификации: Softmax (выдаёт распределение вероятностей)
  • Выходной слой регрессии: без активации (выдаёт любое действительное число)

Не переусложняйте выбор функции активации. Обычно ReLU вполне достаточно. Когда вы убедитесь, что ReLU — узкое место, переключиться на другие ещё не поздно.


Функции потерь

Функция потерь измеряет разрыв между "предсказанием модели" и "правильным ответом".

Цель обучения — сделать этот разрыв как можно меньше.

Кросс-энтропийная потеря (классификация)

Наиболее часто используемая функция потерь для задач классификации — это кросс-энтропийная потеря.

Её интуитивный смысл: "насколько распределение вероятностей, предсказанное моделью, отличается от истинного распределения."

Пример

# ============================================
# Кросс-энтропийная потеря
# ============================================

import numpy as np

def cross_entropy_loss(probs, target_one_hot):
    """Кросс-энтропийная потеря
probs: предсказанное моделью распределение вероятностей, форма (batch_size, num_classes)
target_one_hot: one-hot-кодирование истинных меток, та же форма, что и выше
    """

    # Добавить epsilon, чтобы предотвратить log(0)
    эпсилон = 1e-12
    probs = np.clip(probs, epsilon, 1.0 - epsilon)
    # cross_entropy = -sum(target * log(pred))
    return -np.sum(target_one_hot * np.log(probs)) / len(probs)

def softmax(x):
    """Функция Softmax"""
    exp_x = np.exp(x - np.max(x, axis=-1, keepdims=True))
    return exp_x / np.sum(exp_x, axis=-1, keepdims=True)

# Пример: задача классификации на три класса, классы — "cat", "dog", "bird"

# Предположим, истинная метка — "cat" (индекс 0), закодирована one-hot
target = np.array([[1, 0, 0]])  # Правильный ответ — класс 0

# Сценарий 1: Модель уверенно предсказывает "cat"
logits1 = np.array([[5.0, 0.5, 0.1]])  # У "cat" наивысший балл
probs1 = softmax(logits1)
loss1 = cross_entropy_loss(probs1, target)
print("Сценарий 1: Модель уверенно предсказывает правильно")
print(f" Распределение вероятностей: {probs1)
print(f" Потеря: {loss1:.6f} (очень маленькая)")

# Сценарий 2: модель не уверена
logits2 = np.array([[1.0, 0.9, 0.8]])  # Оценки трёх классов похожи
probs2 = softmax(logits2)
loss2 = cross_entropy_loss(probs2, target)
print("\n"Сценарий 2: Модель не уверена")
print(f" Распределение вероятностей: {probs2)
print(f" Потеря: {loss2:.6f} (средняя)")

# Сценарий 3: Модель предсказывает неверно
logits3 = np.array([[0.1, 5.0, 0.5]])  # Собака имеет наивысшую оценку
probs3 = softmax(logits3)
loss3 = cross_entropy_loss(probs3, target)
print("\n"Сценарий 3: модель предсказывает неверно")
print(f" Распределение вероятностей: {probs3")
print(f" Потеря: {loss3:.6f} (очень большая)")

Упрощённая версия кросс-энтропийной потери: когда метки — это индексы классов, а не one-hot-кодирование, можно использовать более эффективный метод вычисления:

loss = -log(probs[target_class])

Именно поэтому в PyTorch CrossEntropyLoss напрямую принимает индексы классов в качестве меток.

Потеря MSE (регрессия)

Для задач регрессии (предсказание непрерывных значений) наиболее часто используемой функцией потерь является среднеквадратичная ошибка (MSE).

Формула: MSE = mean((pred - target)²)

Пример

# ============================================
# Потеря MSE
# ============================================

import numpy as np

def mse_loss(pred, target):
    """Потеря среднеквадратичной ошибки"""
    return np.mean((pred - target) ** 2)

def mae_loss(pred, target):
    """Потеря средней абсолютной ошибки"""
    return np.mean(np.abs(pred - target))

# Пример: прогнозирование цен на жилье (единица измерения: десять тысяч юаней)

# Фактические цены на жилье
target = np.array([100, 200, 150])

# Сценарий 1: прогноз очень точен
pred1 = np.array([102, 195, 148])
mse1 = mse_loss(pred1, target)
mae1 = mae_loss(pred1, target)
print("Сценарий 1: точный прогноз")
print(f" Прогноз: {pred1}")
print(f" Фактическое: {target}")
print(f" MSE: {mse1:.2f}")
print(f" MAE: {mae1:.2f}")

# Сценарий 2: Предсказание имеет большую ошибку
pred2 = np.array([102, 280, 148])  # Предсказание для второго дома на 800,000 выше
mse2 = mse_loss(pred2, target)
mae2 = mae_loss(pred2, target)
print("\n"Сценарий 2: Имеется большая ошибка")
print(f" Предсказание: {pred2}")
print(f" Фактическое: {target}")
print(f" MSE: {mse2:.2f} (сильно усилена большой ошибкой)")
print(f" MAE: {mae2:.2f}")

# MSE против MAE
print("\nMSE против MAE:")
print("- MSE более чувствительна к большим ошибкам (из-за возведения в квадрат)")
print("- MAE более устойчива к выбросам")
print("- Обычно MSE используется чаще")

Функция потерь предсказания следующего слова для языковых моделей

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

По сути, это задача многоклассовой классификации — каждое слово в словаре является классом.

Пример

# ============================================
# Функция потерь для языковых моделей
# ============================================

import numpy as np

def language_model_loss(logits, targets, vocab_size):
    """Функция потерь языковой модели
По сути, это кросс-энтропийная потеря на каждой позиции.
    """

    batch_size, seq_len, _ = logits.shape
    total_loss = 0.0

    # Вычислить потери в каждой позиции последовательности
    for i in range(seq_len):
        # Предсказанные оценки в этой позиции
        step_logits = logits[:, i, :]
        # Преобразовать в вероятности
        step_probs = softmax(step_logits)
        # Индекс целевого слова
        step_target = targets[:, i]
        # Взять только вероятность целевого слова
        for b in range(batch_size):
            target_idx = step_target[b]
            target_prob = step_probs[b, target_idx]
            # Потеря = -log(p)
            total_loss += -np.log(target_prob + 1e-12)

    return total_loss / (batch_size * seq_len)

# Простой пример
vocab_size = 10000  # Предположим, размер словаря равен 10,000
batch_size = 2
seq_len = 3

# Выход модели: (batch_size, seq_len, vocab_size)
logits = np.random.randn(batch_size, seq_len, vocab_size)

# Истинные целевые значения: индекс слова, который должен быть предсказан в каждой позиции
targets = np.array([
    [123, 456, 789],   # Целевое слово для первой последовательности
    [234, 567, 890],   # Целевое слово для второй последовательности
])

loss = language_model_loss(logits, targets, vocab_size)
print(f"Потери языковой модели: {loss:.4f}")
print("\n"Смысл этой потери:")
print("- Для каждой позиции в последовательности")
print("- Предсказать следующее слово")
print("- Вычислить среднюю кросс-энтропию по всем позициям")

Сводка распространённых функций потерь:

Тип задачиФункция потерьАктивация выходного слояФормат метки
Бинарная классификацияБинарная кросс-энтропияСигмоида0 или 1
Многоклассовая классификацияКросс-энтропияSoftmaxИндекс класса
Многометочная классификацияБинарная кросс-энтропияСигмоидаНесколько 0/1
РегрессияMSE / MAEОтсутствуетНепрерывное значение
Языковая модельКросс-энтропияSoftmaxИндекс слова

Обратное распространение

Обратное распространение — это основной алгоритм обучения нейронных сетей.

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

Вывод с помощью правила цепочки

Мы будем использовать простой пример, чтобы шаг за шагом вывести обратное распространение.

Пример

# ============================================
# Реализуем обратное распространение вручную
# ============================================

import numpy as np

def relu(x):
    return np.maximum(0, x)

def relu_derivative(x):
    return (x > 0).astype(float)

def mse_loss(pred, target):
    return np.mean((pred - target) ** 2)

# Простейшая нейронная сеть: одно линейное преобразование
# y = w * x + b
# Потеря = (y - target)^2

# Вход и целевое значение
x = np.array(2.0)
target = np.array(7.0)

# Инициализация параметров
w = np.array(1.0)
b = np.array(0.0)

print(f"Начальные параметры: w = {w}, b = {b}")
print(f"Целевое значение: {target}")

# ============================================
# Шаг 1: Прямое распространение
# ============================================
y = w * x + b
loss = (y - target) ** 2
print(f"\n"Прямое распространение:")
print(f"  y = {y}")
print(f" loss = {loss}")

# ============================================
# Шаг 2: Обратное распространение (вычислить градиенты вручную)
# ============================================
# Нам нужно вычислить: d_loss/d_w, d_loss/d_b

# Используйте правило цепочки шаг за шагом:
# loss = (y - target)^2
# d_loss/d_y = 2 * (y - target)
d_loss_d_y = 2 * (y - target)

# y = w * x + b
# d_y/d_w = x
# d_y/d_b = 1
d_y_d_w = x
d_y_d_b = 1.0

# Правило цепочки:
# d_loss/d_w = d_loss/d_y * d_y/d_w
# d_loss/d_b = d_loss/d_y * d_y/d_b
d_loss_d_w = d_loss_d_y * d_y_d_w
d_loss_d_b = d_loss_d_y * d_y_d_b

print(f"\n"Обратное распространение:")
print(f" d_loss/d_y = {d_loss_d_y}")
print(f" d_loss/d_w = {d_loss_d_w}")
print(f" d_loss/d_b = {d_loss_d_b}")

# ============================================
# Шаг 3: Обновление параметров с помощью градиентного спуска
# ============================================
learning_rate = 0.1
w_new = w - learning_rate * d_loss_d_w
b_new = b - learning_rate * d_loss_d_b

print(f"\n"Обновление параметров:")
print(f" w: {w} -> {w_new}")
print(f" b: {b} -> {b_new}")

# Проверка: использовать новые параметры для прямого распространения # Проверка: прямое распространение с новыми параметрами
y_new = w_new * x + b_new
loss_new = (y_new - target) ** 2
print(f"\nПроверка: Проверка:)
print(f" Старое предсказание: {y}, старая потеря: {loss}" " Старое предсказание: {y}, старая потеря: {loss}")
print(f" Новое предсказание: {y_new}, новая потеря: {loss_new}" " Новое предсказание: {y_new}, новая потеря: {loss_new}")
print(f" Потеря уменьшилась!" " Потеря уменьшилась!")

Хотя этот пример прост, он содержит все основные идеи обратного распространения:

  1. Прямое распространение: вычислить все промежуточные переменные Прямое распространение: вычислить все промежуточные переменные
  2. Обратное распространение: начиная с выхода, использовать правило цепочки для послойного вычисления градиентов Обратное распространение: начиная с выхода, использовать правило цепочки для послойного вычисления градиентов
  3. Обновление параметров: использовать градиентный спуск для обновления параметров Обновление параметров: обновить параметры с помощью градиентного спуска

Поток градиента в вычислительных графах

Для многослойных нейронных сетей градиенты распространяются назад через вычислительный граф. Для многослойных нейронных сетей градиенты распространяются назад через вычислительный граф.

Градиент каждого слоя зависит от градиента, переданного из следующего слоя. Градиент каждого слоя зависит от градиента, переданного из следующего слоя.

Пример

# ============================================
# Обратное распространение для двухслойной сети # Обратное распространение для двухслойной сети
# ============================================

import numpy as np

def relu(x):
    return np.maximum(0, x)

def relu_derivative(x):
    return (x > 0).astype(float)

# Двухслойная сеть # Двухслойная сеть
# Вход (2) → Скрытый слой (3) → Выход (1) # Вход (2) → Скрытый слой (3) → Выход (1)

# Данные # Данные
x = np.array([1.0, 2.0])  # Входные данные # Входные данные
target = np.array([5.0])  # Целевой вывод # Целевой вывод

# Инициализация параметров # Инициализация параметров
W1 = np.array([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])  # (3, 2)
b1 = np.array([0.1, 0.2, 0.3])  # (3,)
W2 = np.array([[0.7, 0.8, 0.9]])  # (1, 3)
b2 = np.array([0.4])  # (1,)

print(Начальные параметры установлены.)

# ============================================
# Прямое распространение # Прямое распространение
# ============================================
print("\n=== Прямое распространение ===" === Прямое распространение ===)

# Первый слой # Первый слой
z1 = W1 @ x + b1  # (3,)
a1 = relu(z1)     # (3,)
print(f"z1 = {z1}")
print(f"a1 = {a1}")

# Второй слой # Второй слой
z2 = W2 @ a1 + b2  # (1,)
a2 = z2  # RegressionTask, outputlayerNoaddactivate # Задача регрессии, без активации на выходном слое
print(f"z2 = {z2}")
print(f"a2 = {a2}")

# Потери # Потери
loss = np.mean((a2 - target) ** 2)
print(f"loss = {loss}")

# ============================================
# Обратное распространение # Обратное распространение
# ============================================
print("\n=== Обратное распространение ===" === Обратное распространение ===)

# Градиент выходного слоя # Градиент выходного слоя
d_loss_d_a2 = 2 * (a2 - target)  # (1,)
d_a2_d_z2 = 1.0  # Без функции активации # Без функции активации
d_loss_d_z2 = d_loss_d_a2 * d_a2_d_z2  # (1,)
print(f"d_loss_d_z2 = {d_loss_d_z2}")

# Градиенты параметров второго слоя # Градиенты параметров второго слоя
d_z2_d_W2 = a1  # (3,), потому что z2 = W2@a1 + b2 # (3,), потому что z2 = W2@a1 + b2
d_z2_d_b2 = 1.0  # (1,)
d_z2_d_a1 = W2[0]  # (3,)

# Вычислить градиенты параметров # Вычислить градиенты параметров
d_loss_d_W2 = d_loss_d_z2.reshape(-1, 1) @ d_z2_d_W2.reshape(1, -1)  # (1, 3)
d_loss_d_b2 = d_loss_d_z2 * d_z2_d_b2  # (1,)
print(f"d_loss_d_W2 = {d_loss_d_W2}")
print(f"d_loss_d_b2 = {d_loss_d_b2}")

# Градиент, переданный скрытому слою # Градиент, переданный скрытому слою
d_loss_d_a1 = d_loss_d_z2 @ d_z2_d_a1  # (3,)
print(f"d_loss_d_a1 = {d_loss_d_a1}")

# Градиент скрытого слоя # Градиент скрытого слоя
d_a1_d_z1 = relu_derivative(z1)  # (3,)
d_loss_d_z1 = d_loss_d_a1 * d_a1_d_z1  # (3,)
print(f"d_loss_d_z1 = {d_loss_d_z1}")

# Градиенты параметров первого слоя # Градиенты параметров первого слоя
d_z1_d_W1 = x  # (2,)
d_z1_d_b1 = 1.0  # (3,)

# Вычислить градиенты параметров # Вычислить градиенты параметров
d_loss_d_W1 = d_loss_d_z1.reshape(-1, 1) @ d_z1_d_W1.reshape(1, -1)  # (3, 2)
d_loss_d_b1 = d_loss_d_z1 * d_z1_d_b1  # (3,)
print(f"d_loss_d_W1 = {d_loss_d_W1}")
print(f"d_loss_d_b1 = {d_loss_d_b1}")

# ============================================
# Обновление параметров # Обновление параметров
# ============================================
print("\n=== Обновление параметров === === Обновление параметров ===)
learning_rate = 0.01

W1_new = W1 - learning_rate * d_loss_d_W1
b1_new = b1 - learning_rate * d_loss_d_b1
W2_new = W2 - learning_rate * d_loss_d_W2
b2_new = b2 - learning_rate * d_loss_d_b2

print("Параметры обновлены." "Параметры обновлены.")

Этот пример показывает полный процесс обратного распространения. Этот пример демонстрирует полный процесс обратного распространения.

Основное правило: Основные правила:

  • для линейного слоя y = Wx + b: dL/dW = (dL/dy)·xᵀ, dL/db = dL/dy Для линейного слоя y = Wx + b: dL/dW = (dL/dy)·xᵀ, dL/db = dL/dy
  • для функции активации y = f(z): dL/dz = dL/dy ⊙ f'(z) (⊙ — поэлементное умножение) Для функции активации y = f(z): dL/dz = dL/dy ⊙ f'(z) (⊙ — поэлементное умножение)
  • Градиенты распространяются от конца к началу, каждый слой использует правило цепочки, чтобы "накапливать" градиенты. Градиенты распространяются от конца к началу, и каждый слой использует правило цепочки, чтобы "накапливать" градиенты.

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


Оптимизаторы градиентного спуска

После вычисления градиентов нам нужен оптимизатор, чтобы обновить параметры. После вычисления градиентов нам нужен оптимизатор, чтобы обновить параметры.

Простейший оптимизатор — стохастический градиентный спуск (SGD), но есть много более продвинутых оптимизаторов. Простейший оптимизатор — стохастический градиентный спуск (SGD), но есть много более продвинутых оптимизаторов.

SGD: Стохастический градиентный спуск

Формула: θ = θ - η·∇L(θ) Формула: θ = θ - η·∇L(θ)

где η — скорость обучения. где η — скорость обучения.

Пример

# ============================================
# Оптимизатор SGD # Оптимизатор SGD
# ============================================

import numpy as np

def f(x):
    """Функция, которую мы хотим минимизировать: f(x) = x²""" """Функция, которую мы хотим минимизировать: f(x) = x²"""
    return x ** 2

def f_grad(x):
    """Градиент: f'(x) = 2x""" """Градиент: f'(x) = 2x"""
    return 2 * x

# Оптимизация SGD # Оптимизация SGD
x = 10.0  # Начальное значение # Начальное значение
learning_rate = 0.1  # Скорость обучения # Скорость обучения
steps = 20

print(f"Начальный x = {x}, f(x) = {f(x)}" "Начальный x = {x}, f(x) = {f(x)}")
print("-" * 40)

for step in range(steps):
    grad = f_grad(x)
    x = x - learning_rate * grad  # обновление SGD # обновление SGD
    print(f"Шаг {step+1}: x = {x:.6f}, f(x) = {f(x):.6f}")

print("-" * 40)
print(f"Итоговый x = {x:.6f}, f(x) = {f(x):.6f}" "Итоговый x = {x:.6f}, f(x) = {f(x):.6f}")
print("Теоретический оптимум: x = 0, f(x) = 0" "Теоретический оптимум: x = 0, f(x) = 0")

SGD прост, но есть недостатки: SGD прост, но есть недостатки:

  • Использует одинаковую скорость обучения для всех параметров
  • Сходимость может быть медленной
  • Легко застревает в локальных оптимумах или седловых точках.
  • Чувствителен к шуму

Моментум: ускорение с помощью моментума

Momentum вводит понятие "скорости" — придавая обновлениям градиента инерцию. Momentum вводит понятие "скорости" — придавая обновлениям градиента инерцию.

Формула: Формула:

v = β·v + (1-β)·∇L(θ)
θ = θ - η·v

β обычно устанавливается равным 0.9. β обычно устанавливается равным 0.9.

Пример

# ============================================
# Оптимизатор Momentum # Оптимизатор Momentum
# ============================================

import numpy as np

def f(x):
    """Целевая функция""" """Целевая функция"""
    return x ** 2

def f_grad(x):
    """Градиент""" """Градиент"""
    return 2 * x

def sgd_optimize(x_init, learning_rate, steps):
    """Чистый SGD""" """Чистый SGD"""
    x = x_init
    history = [x]
    for _ in range(steps):
        grad = f_grad(x)
        x = x - learning_rate * grad
        history.append(x)
    return np.array(history)

def momentum_optimize(x_init, learning_rate, beta, steps):
    """Momentum"""
    x = x_init
    v = 0.0  # Начальная скорость # Начальная скорость
    history = [x]
    for _ in range(steps):
        grad = f_grad(x)
        v = beta * v + (1 - beta) * grad
        x = x - learning_rate * v
        history.append(x)
    return np.array(history)

# Сравнение # Сравнение
x_init = 10.0
learning_rate = 0.1
бета = 0.9
steps = 20

sgd_history = sgd_optimize(x_init, learning_rate, steps)
momentum_history = momentum_optimize(x_init, learning_rate, beta, steps)

print("Шаг | SGD | Momentum")
print("-" * 35)
for i in range(steps + 1):
    print(f"{i:4} | {sgd_history[i]:9.6f} | {momentum_history[i]:9.6f}")

print("\nНаблюдение: Momentum сходится быстрее!)
print(Потому что он накапливает прошлые градиенты, придавая ему инерцию.)

Adam: Адаптивная скорость обучения

Adam (Adaptive Moment Estimation) в настоящее время является одним из наиболее часто используемых оптимизаторов.

Он объединяет идеи Momentum (первый момент) и RMSProp (второй момент), чтобы поддерживать адаптивную скорость обучения для каждого параметра.

Формула:

m = β₁·m + (1-β₁)·∇L(θ)
v = β₂·v + (1-β₂)·(∇L(θ))²
m̂ = m / (1-β₁ᵗ)
v̂ = v / (1-β₂ᵗ)
θ = θ - η·m̂ / (√v̂ + ε)

Гиперпараметры по умолчанию: β₁=0.9, β₂=0.999, ε=1e-8, η=1e-3.

Пример

# ============================================
# Оптимизатор Adam
# ============================================

import numpy as np

def adam_optimize(x_init, learning_rate, beta1, beta2, epsilon, steps):
    """Оптимизация Adam"""
    x = x_init
    m = 0.0  # Первый момент
    v = 0.0  # Второй момент
    history = [x]

    for t in range(1, steps + 1):
        grad = 2 * x  # Градиент f(x) = x²
        # Обновление первого и второго моментов
        m = beta1 * m + (1 - beta1) * grad
        v = beta2 * v + (1 - beta2) * (grad ** 2)
        # Коррекция смещения
        m_hat = m / (1 - beta1 ** t)
        v_hat = v / (1 - beta2 ** t)
        # Обновление параметров
        x = x - learning_rate * m_hat / (np.sqrt(v_hat) + epsilon)
        history.append(x)

    return np.array(history)

# Параметры
x_init = 10.0
learning_rate = 0.5
beta1 = 0.9
beta2 = 0.999
эпсилон = 1e-8
steps = 20

# Оптимизация
history = adam_optimize(x_init, learning_rate, beta1, beta2, epsilon, steps)

print("Процесс оптимизации Adam:")
print("Шаг | x | f(x)")
print("-" * 35)
for i in range(steps + 1):
    print(f"{i:4} | {history[i]:9.6f} | {history[i]**2:9.6f}")

AdamW: коррекция затухания весов

AdamW — улучшенная версия Adam: он отделяет затухание весов от градиента.

Это полезно для регуляризации.

Сравнение оптимизаторов:

ОптимизаторФормулаПреимуществаНедостаткиПрименимые сценарии
SGDθ = θ - η·∇LПростой, стабильныйМедленная сходимость, чувствительность к скорости обученияБольшие наборы данных, большой опыт в настройке гиперпараметров
SGD + Momentumv = βv + (1-β)∇L
θ = θ - ηv
Ускоряет сходимость, уменьшает колебанияТребует настройки βКак правило, лучше обычного SGD
AdamАдаптивная скорость обученияБыстрая сходимость, требует меньшей настройки гиперпараметровМожет обобщать хуже, чем SGDБольшинство сценариев (выбор по умолчанию)
AdamWAdam + weight decayЛучшая регуляризацияТребует настройки коэффициента weight decayТрансформеры, большие модели

Рекомендация по выбору оптимизатора: сначала попробуйте Adam со скоростью обучения 1e-3 (3e-4 — более безопасный выбор). Если результаты неудовлетворительны, попробуйте настроить гиперпараметры или переключиться на другой оптимизатор.


Методы регуляризации

Цель регуляризации — предотвратить переобучение, помогая модели хорошо работать на данных, не встречавшихся при обучении.

L1/L2-регуляризация

L1- и L2-регуляризация — самые базовые методы регуляризации.

Они добавляют штраф на параметры в функцию потерь:

L_total = L + λ·L_reg

L1 регуляризация (Lasso) L_reg = |w|

L2 регуляризация (Ridge) L_reg = w²

Пример

# ============================================
# L1/L2 регуляризация
# ============================================

import numpy as np

def l1_regularization(weights, lambda_l1):
    """L1 регуляризация: возвращает член регуляризации и градиент"""
    reg = lambda_l1 * np.sum(np.abs(weights))
    grad = lambda_l1 * np.sign(weights)
    return reg, grad

def l2_regularization(weights, lambda_l2):
    """L2 регуляризация: возвращает член регуляризации и градиент"""
    reg = 0.5 * lambda_l2 * np.sum(weights ** 2)
    grad = lambda_l2 * weights
    return reg, grad

# Пример
weights = np.array([1.0, -2.0, 3.0, 0.5])
print(f"Веса: {weights}")

lambda_l1 = 0.1
lambda_l2 = 0.1

l1_reg, l1_grad = l1_regularization(weights, lambda_l1)
print(f"\nЧлен регуляризации L1: {l1_reg:.4f}")
print(f"Градиент L1: {l1_grad}")

l2_reg, l2_grad = l2_regularization(weights, lambda_l2)
print(f"\nЧлен регуляризации L2: {l2_reg:.4f}")
print(f"Градиент L2: {l2_grad}")

print("\nL1 против L2:")
print("- L1: склонна давать разреженные решения (многие параметры становятся 0), может использоваться для отбора признаков")
print("- L2: стремится сделать все параметры очень маленькими, но не нулевыми")
print("- В глубоком обучении L2 используется чаще (или weight decay через AdamW)")

Dropout

Dropout — это простая, но эффективная техника регуляризации: случайное "отключение" части нейронов во время обучения.

Это заставляет модель изучать более устойчивые признаки — она не может полагаться на какой-либо конкретный нейрон.

Пример

# ============================================
# Dropout
# ============================================

import numpy as np

def dropout_forward(x, dropout_rate, training=True):
    """Прямой проход Dropout"""
    if not training:
        # Во время тестирования dropout не применяется, возвращаем сразу
        return x

    # Сгенерировать маску: случайно установить часть нейронов в 0
    mask = (np.random.rand(*x.shape) > dropout_rate).astype(float)
    # Масштабирование: во время тестирования масштабирование не требуется, поэтому во время обучения делите на (1 - dropout_rate)
    scale = 1.0 / (1.0 - dropout_rate)
    Применить dropout
    out = x * mask * scale

    return out, mask

#
x = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
dropout_rate = 0.4

print(f"Вход x: {x}")
print(f"Коэффициент dropout: {dropout_rate}")

# Запустите несколько раз, чтобы увидеть случайность
print("\nРезультаты многократного dropout:")
for i in range(3):
    out, mask = dropout_forward(x, dropout_rate, training=True)
    print(f" Раунд {i+1}: mask={mask}, out={out}")

# Режим тестирования
out_test = dropout_forward(x, dropout_rate, training=False)
print(f"\nРежим тестирования (без Dropout): {out_test}")

Аугментация данных

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

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

Для текста распространённые методы аугментации данных включают: замену синонимов, случайную вставку, случайное удаление, обратный перевод и т. д.

Сводка методов регуляризации:

МетодПринципПреимуществаНедостатки
L2-регуляризацияШтрафует большие весаПростая и стабильнаяОграниченный эффект
DropoutСлучайно отключает нейроныПросто и эффективноЗамедляет обучение
Аугментация данныхУвеличивает разнообразие обучающих данныхЗначительный эффектТребует знаний предметной области
Ранняя остановкаОстановка, когда валидационная выборка перестаёт улучшатьсяПросто и эффективноТребует отслеживания валидационной выборки
Пакетная нормализацияНормализует входы слояУскоряет обучение, регуляризуетУвеличивает вычислительные затраты

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

Методы нормализации могут ускорить обучение и сделать оптимизацию более стабильной.

Внутренний сдвиг ковариат

При обучении глубоких сетей входное распределение каждого слоя меняется по мере изменения параметров предыдущего слоя. Это «внутренний сдвиг ковариат» (Internal Covariate Shift).

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

Цель методов нормализации — поддерживать входное распределение каждого слоя стабильным.

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

Пакетная нормализация (BatchNorm) нормализует вдоль размерности батча.

Формула:

μ = 1/N · sum(xᵢ)
σ² = 1/N · sum((xᵢ - μ)²)
x̂ᵢ = (xᵢ - μ) / √(σ² + ε)
yᵢ = γ·x̂ᵢ + β

где γ и β — обучаемые параметры.

Пример

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

import numpy as np

def batch_norm(x, gamma, beta, epsilon=1e-5, training=True, running_mean=None, running_var=None, momentum=0.1):
    """Пакетная нормализация
x: вход, форма (batch_size, features)
gamma: параметр масштабирования
beta: параметр смещения
    """

    if training:
        # Режим обучения: использовать статистику текущего пакета
        batch_mean = np.mean(x, axis=0)
        batch_var = np.var(x, axis=0)

        # Обновить скользящую статистику (для использования во время тестирования)
        if running_mean is not None and running_var is not None:
            running_mean[:] = momentum * running_mean + (1 - momentum) * batch_mean
            running_var[:] = momentum * running_var + (1 - momentum) * batch_var

        # Нормализация
        x_normalized = (x - batch_mean) / np.sqrt(batch_var + epsilon)
    else:
        # Режим тестирования: использовать накопленную статистику
        x_normalized = (x - running_mean) / np.sqrt(running_var + epsilon)

    # Масштабирование и сдвиг
    out = gamma * x_normalized + beta
    return out

#
batch_size = 4
features = 3

# Один батч данных
x = np.array([
    [1.0, 2.0, 3.0],
    [2.0, 3.0, 4.0],
    [3.0, 4.0, 5.0],
    [4.0, 5.0, 6.0],
])
print(f"Input x:\n{x}")

# Начальные параметры
gamma = np.ones(features)  # Изначально без масштабирования
beta = np.zeros(features)  # Изначально без смещения
print(f"\ngamma: {gamma}")
print(f"beta: {beta}")

# Применить пакетную нормализацию
out = batch_norm(x, gamma, beta, training=True)
print(f"\nПосле пакетной нормализации:\n{out}")

# Проверка: среднее значение каждого признака должно быть близко к 0, дисперсия — близка к 1
print(f"\nНормализованное среднее: {np.mean(out, axis=0)}")
print(f"Нормализованная дисперсия: {np.var(out, axis=0)}")

Преимущества пакетной нормализации:

  • Ускоряет обучение
  • Позволяет использовать более высокие скорости обучения
  • Снижает чувствительность к инициализации
  • Оказывает небольшой регуляризующий эффект

Недостатки пакетной нормализации:

  • Зависит от размера батча — плохо работает при слишком маленьких батчах
  • Поведение различается между обучением и тестированием
  • Не подходит для последовательностных моделей (например, RNN, Transformer)

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

Нормализация слоя (LayerNorm) нормализует по размерности признаков, а не по размерности батча.

Это делает её независимой от размера батча и очень подходящей для последовательностных моделей.

Пример

# ============================================
# Нормализация слоя
# ============================================

import numpy as np

def layer_norm(x, gamma, beta, epsilon=1e-5):
    """Нормализация слоя
x: входные данные, форма (batch_size, features) или (batch_size, seq_len, features)
    """

    # Вычислить среднее и дисперсию по последней размерности (размерности признаков)
    mean = np.mean(x, axis=-1, keepdims=True)
    var = np.var(x, axis=-1, keepdims=True)

    # Нормализация
    x_normalized = (x - mean) / np.sqrt(var + epsilon)

    # Масштабирование и сдвиг
    out = gamma * x_normalized + beta
    return out

#
batch_size = 2
seq_len = 3
features = 4

# Симулировать вход в Transformer: (batch_size, seq_len, features)
x = np.array([
    [[1.0, 2.0, 3.0, 4.0],
     [2.0, 3.0, 4.0, 5.0],
     [3.0, 4.0, 5.0, 6.0]],
    [[4.0, 5.0, 6.0, 7.0],
     [5.0, 6.0, 7.0, 8.0],
     [6.0, 7.0, 8.0, 9.0]],
])
print(f"Форма входного x: {x.shape}")

# Инициализация параметров
gamma = np.ones(features)
beta = np.zeros(features)

# Применить нормализацию слоя
out = layer_norm(x, gamma, beta)
print(f"Форма после нормализации слоя: {out.shape}")

# Проверить: среднее в каждой позиции близко к 0, дисперсия близка к 1
mean_check = np.mean(out, axis=-1)
var_check = np.var(out, axis=-1)
print(f"\nСреднее в каждой позиции:\n{mean_check}")
print(f"Дисперсия в каждой позиции:\n{var_check}")

Сравнение двух методов нормализации:

МетодРазмерность нормализацииЗависит от батчаПрименимые сценарииТипичные модели
BatchNormразмерность батчаДаCNN, фиксированный размер батчаResNet、VGG
LayerNormразмерность признаковнетTransformer, RNN, последовательности переменной длиныGPT、BERT、LLaMA

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


Планирование скорости обучения

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

Планирование скорости обучения — это динамическая корректировка скорости обучения во время обучения.

Разогрев

На ранней стадии обучения используйте небольшую скорость обучения для «разогрева», чтобы модель стабилизировалась, а затем увеличьте её до целевой скорости обучения.

Это особенно важно для Transformer.

Косинусное затухание

Косинусное затухание постепенно уменьшает скорость обучения в соответствии с формой функции косинуса.

Пример

# ============================================
# Планирование скорости обучения
# ============================================

import numpy as np

def warmup_schedule(step, warmup_steps, base_lr):
    """Линейное расписание разогрева"""
    if step < warmup_steps:
        return base_lr * (step + 1) / warmup_steps
    return base_lr

def cosine_annealing_schedule(step, total_steps, base_lr, min_lr=0.0):
    """Расписание косинусного отжига"""
    return min_lr + 0.5 * (base_lr - min_lr) * (1 + np.cos(np.pi * step / total_steps))

def warmup_cosine_schedule(step, warmup_steps, total_steps, base_lr, min_lr=0.0):
    """Разогрев + косинусный отжиг"""
    if step < warmup_steps:
        return base_lr * (step + 1) / warmup_steps
    return min_lr + 0.5 * (base_lr - min_lr) * (1 + np.cos(np.pi * (step - warmup_steps) / (total_steps - warmup_steps)))

# Параметры
total_steps = 100
warmup_steps = 10
base_lr = 1e-3
min_lr = 1e-5

print("Различные графики скорости обучения:")
print("Шаг | Разогрев | Косинус | Разогрев+Косинус")
print("-" * 55)

for step in range(0, total_steps + 1, 10):
    lr_warmup = warmup_schedule(step, warmup_steps, base_lr)
    lr_cosine = cosine_annealing_schedule(step, total_steps, base_lr, min_lr)
    lr_combined = warmup_cosine_schedule(step, warmup_steps, total_steps, base_lr, min_lr)
    print(f"{step:4} | {lr_warmup:.6f} | {lr_cosine:.6f} | {lr_combined:.6f}")

Влияние скорости обучения на обучение

Влияние скорости обучения:

  • Слишком большой: обучение нестабильно, может разойтись
  • Слишком маленький: медленная сходимость, может застрять в локальных оптимумах
  • Подходящий: быстрая и стабильная сходимость

Распространённая стратегия — «learning rate finder»: сначала обучите несколько шагов со скоростью обучения, увеличивающейся от маленькой к большой, и посмотрите, при какой скорости обучения потери уменьшаются быстрее всего.

Сводка методов планирования скорости обучения:

Метод планированияХарактеристикиПрименимые сценарии
Фиксированная скорость обученияСамый простойНебольшие модели, простые задачи
Step DecayУменьшение каждые несколько шаговCNN и т. д.
Косинусное затуханиеПлавное снижениеБольшинство сценариев
Разогрев + косинусСначала увеличение, затем снижениеTransformer, большие модели (рекомендуется)
Снижение на платоСнижение, когда валидационная выборка не улучшаетсяТребует отслеживания валидационной выборки

Практика: реализация MLP с нуля на PyTorch

Теперь давайте объединим все ранее изученные знания, с помощью PyTorch реализуем полный многослойный перцептрон (MLP) и обучим его на простой задаче классификации.

Пример

# ============================================
# Полный пайплайн обучения MLP, реализованный на PyTorch
# ============================================

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import numpy as np

# ============================================
# 1. Подготовка данных
# ============================================

class SimpleDataset(Dataset):
    """Простой набор данных для бинарной классификации"""

    def __init__(self, n_samples=1000, n_features=10):
        # Генерация синтетических данных
        np.random.seed(42)
        self.X = np.random.randn(n_samples, n_features).astype(np.float32)
        # Простое правило классификации: y = 1, если sum(x[:5]) > 0, иначе 0
        self.y = (np.sum(self.X[:, :5], axis=1) > 0).astype(np.int64)

    def __len__(self):
        return len(self.X)

    def __getitem__(self, idx):
        return torch.tensor(self.X[idx]), torch.tensor(self.y[idx])

# Создание набора данных и загрузчика данных
train_dataset = SimpleDataset(n_samples=800)
val_dataset = SimpleDataset(n_samples=200)

train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False)

print(f"Размер обучающей выборки: {len(train_dataset)}")
print(f"Размер валидационной выборки: {len(val_dataset)}")

# ============================================
# 2. Определяем модель
# ============================================

class MLP(nn.Module):
    """Многослойный перцептрон"""

    def __init__(self, input_dim, hidden_dims, output_dim, dropout_rate=0.1):
        super().__init__()

        layers = []
        prev_dim = input_dim

        # Скрытый слой
        for hidden_dim in hidden_dims:
            layers.append(nn.Linear(prev_dim, hidden_dim))
            layers.append(nn.ReLU())
            layers.append(nn.LayerNorm(hidden_dim))  # Нормализация слоя
            layers.append(nn.Dropout(dropout_rate))  # Дропаут
            prev_dim = hidden_dim

        # Выходной слой
        layers.append(nn.Linear(prev_dim, output_dim))

        self.model = nn.Sequential(*layers)

    def forward(self, x):
        return self.model(x)

# Создание модели
model = MLP(
    input_dim=10,
    hidden_dims=[64, 32],
    output_dim=2,
    dropout_rate=0.1
)
print("\nСтруктура модели:")
print(model)

# ============================================
# 3. Определить функцию потерь и оптимизатор
# ============================================

criterion = nn.CrossEntropyLoss()  # Потеря кросс-энтропии
optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4)  # Оптимизатор AdamW

# Планировщик скорости обучения: разогрев + косинусное затухание
total_epochs = 20
warmup_epochs = 2
total_steps = len(train_loader) * total_epochs
warmup_steps = len(train_loader) * warmup_epochs

def lr_lambda(step):
    if step < warmup_steps:
        return (step + 1) / warmup_steps
    else:
        progress = (step - warmup_steps) / (total_steps - warmup_steps)
        return 0.5 * (1 + np.cos(np.pi * progress))

scheduler = optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=lr_lambda)

print("\nКонфигурация обучения:")
print(f" Эпохи: {total_epochs}")
print(f" Эпохи разогрева: {warmup_epochs}")
print(f" Всего шагов: {total_steps}")
print(f" Шаги разогрева: {warmup_steps}")

# ============================================
# 4. Цикл обучения
# ============================================

print("\nНачало обучения...")
print("-" * 50)

global_step = 0
best_val_acc = 0.0

for epoch in range(total_epochs):
    # Фаза обучения
    model.train()
    train_loss = 0.0
    train_correct = 0
    train_total = 0

    for batch_X, batch_y in train_loader:
        # Прямой проход
        logits = model(batch_X)
        loss = criterion(logits, batch_y)

        # Обратный проход
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        scheduler.step()

        # Статистика
        train_loss += loss.item()
        predicted = torch.argmax(logits, dim=1)
        train_total += batch_y.size(0)
        train_correct += (predicted == batch_y).sum().item()

        global_step += 1

    train_loss /= len(train_loader)
    train_acc = train_correct / train_total

    # Фаза валидации
    model.eval()
    val_loss = 0.0
    val_correct = 0
    val_total = 0

    with torch.no_grad():
        for batch_X, batch_y in val_loader:
            logits = model(batch_X)
            loss = criterion(logits, batch_y)

            val_loss += loss.item()
            predicted = torch.argmax(logits, dim=1)
            val_total += batch_y.size(0)
            val_correct += (predicted == batch_y).sum().item()

    val_loss /= len(val_loader)
    val_acc = val_correct / val_total

    # Сохранить лучшую модель
    if val_acc > best_val_acc:
        best_val_acc = val_acc
        # Здесь можно сохранить контрольную точку модели

    # Вывод журнала
    current_lr = optimizer.param_groups[0]['lr']
    print(f"Эпоха {epoch+1:2d} | lr: {current_lr:.6f} | "
          f"Потери на обучении: {train_loss:.4f} | Точность на обучении: {train_acc:.4f} | "
          f"Потери на валидации: {val_loss:.4f} | Точность на валидации: {val_acc:.4f}")

print("-" * 50)
print(f"Обучение завершено! Лучшая точность на валидации: {best_val_acc:.4f}")

# ============================================
# 5. Пример инференса
# ============================================

print("\nПример инференса:")
model.eval()

# Несколько тестовых примеров
test_samples = [
    np.array([1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float32),  # Должно быть 1
    np.array([-1.0, -1.0, -1.0, -1.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float32),  # Должно быть 0
]

for i, sample in enumerate(test_samples):
    with torch.no_grad():
        input_tensor = torch.tensor(sample).unsqueeze(0)
        logits = model(input_tensor)
        probs = torch.softmax(logits, dim=1)
        predicted_class = torch.argmax(probs, dim=1).item()

    print(f" Пример {i+1}: {sample[:5]}...")
    print(f" Предсказанный класс: {predicted_class}")
    print(f" Вероятности: класс 0={probs[0,0]:.4f}, класс 1={probs[0,1]:.4f}")

Этот пример содержит все стандартные компоненты обучения глубоких нейронных сетей:

  • Обработка данных: Dataset, DataLoader
  • Определение модели: MLP с LayerNorm и Dropout
  • Функция потерь: CrossEntropyLoss
  • Оптимизатор: AdamW
  • Планировщик скорости обучения: Warmup + Cosine
  • Цикл обучения: фаза обучения + фаза валидации
  • Сохранение модели и инференс

Этот рабочий процесс можно напрямую расширить на более сложные задачи и модели.

Другие расширения