Фаза 03 · урок 04

Функции активации

Цель урока: Сложите два линейных преобразования: y = W2(W1x + b1) + b2 . Раскроем: y = W2W1x + W2b1 + b2 . Это всего лишь y = Ax + c — одно линейное преобразование. Сколько бы линейных слоёв вы ни сложили, результат сворачивается в одно умножение…

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

Курс
AI Engineering from Scratch
Фаза
Основы глубокого обучения
Чтение
18 мин.
Проверено
Содержание урока
  1. Цели обучения
  2. Проблема
  3. Концепция
  4. Почему необходима нелинейность
  5. Сигмоида
  6. Tanh
  7. ReLU: прорыв
  8. Leaky ReLU
  9. GELU: современный вариант по умолчанию
  10. Swish / SiLU
  11. Softmax: выходная активация
  12. Сравнение форм
  13. Сравнение потока градиентов
  14. Какую активацию когда использовать
  15. Соберите это
  16. Шаг 1: реализуйте все функции активации с производными
  17. Шаг 2: визуализируйте, где умирают градиенты
  18. Шаг 3: эксперимент с затухающими градиентами
  19. Шаг 4: детектор мёртвых нейронов
  20. Шаг 5: сравнение обучения — сигмоида, ReLU и GELU
  21. Используйте это
  22. Выпустите это
  23. Упражнения
  24. Ключевые термины
  25. Дополнительное чтение

Без нелинейности ваша сеть из 100 слоёв — просто модное умножение матриц. Активации — это ворота, которые позволяют нейронным сетям мыслить кривыми.

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

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

  • Реализовать с нуля сигмоиду, tanh, ReLU, Leaky ReLU, GELU, Swish и softmax вместе с их производными
  • Диагностировать проблему затухающих градиентов, измеряя величины активаций в 10+ слоях с разными функциями активации
  • Обнаруживать мёртвые нейроны в сети с ReLU и объяснять, почему GELU избегает этого режима отказа
  • Выбирать правильную функцию активации для конкретной архитектуры (transformer, CNN, RNN, выходной слой)

Проблема

Сложите два линейных преобразования: y = W2(W1x + b1) + b2. Раскроем: y = W2W1x + W2b1 + b2. Это всего лишь y = Ax + c — одно линейное преобразование. Сколько бы линейных слоёв вы ни сложили, результат сворачивается в одно умножение матриц. Ваша сеть из 100 слоёв обладает той же выразительной силой, что и один слой.

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

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

Концепция

Почему необходима нелинейность

Умножение матриц композиционно. Умножение вектора сначала на матрицу A, а затем на матрицу B идентично умножению на AB. Поэтому стек из десяти линейных слоёв математически эквивалентен одному линейному слою с одной большой матрицей. Все эти параметры, вся эта глубина — впустую. Нужен механизм, разрывающий цепочку. Именно это делают функции активации.

Вот доказательство. Линейный слой вычисляет f(x) = Wx + b. Сложим два:

Layer 1: h = W1 * x + b1
Layer 2: y = W2 * h + b2

Подставим:

y = W2 * (W1 * x + b1) + b2
y = (W2 * W1) * x + (W2 * b1 + b2)
y = A * x + c

Один слой. Вставьте нелинейную активацию g() между слоями:

h = g(W1 * x + b1)
y = W2 * h + b2

Теперь подстановка нарушается. W2 * g(W1 * x + b1) + b2 нельзя свести к одному линейному преобразованию. Сеть может представлять нелинейные функции. Каждый дополнительный слой с активацией добавляет выразительную способность.

Сигмоида

Исходная функция активации для нейронных сетей.

sigmoid(x) = 1 / (1 + e^(-x))

Диапазон выхода: (0, 1). Гладкая, дифференцируемая, отображает любое вещественное число в значение, похожее на вероятность.

Производная:

sigmoid'(x) = sigmoid(x) * (1 - sigmoid(x))

Максимальное значение этой производной — 0.25, оно достигается при x = 0. При обратном распространении градиенты перемножаются при прохождении через слои. Десять слоёв с сигмоидой означают, что градиент умножается максимум на 0.25 десять раз:

0.25^10 = 0.000000953674

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

Дополнительная проблема: выходы сигмоиды всегда положительны (от 0 до 1), то есть градиенты весов всегда имеют один знак. Это вызывает зигзагообразное движение при градиентном спуске.

Tanh

Центрированная версия сигмоиды.

tanh(x) = (e^x - e^(-x)) / (e^x + e^(-x))

Диапазон выхода: (-1, 1). Центрирована относительно нуля, что устраняет проблему зигзага.

Производная:

tanh'(x) = 1 - tanh(x)^2

Максимальная производная равна 1.0 при x = 0 — в четыре раза лучше сигмоиды. Но проблема затухающих градиентов всё ещё существует. Для больших положительных или отрицательных входов производная стремится к нулю. Десять слоёв всё ещё подавляют градиент, просто менее агрессивно.

ReLU: прорыв

Rectified Linear Unit. Популяризирована в глубоком обучении Наиром и Хинтоном в 2010 году (сама функция восходит к работе Фукусимы 1969 года) и изменила всё.

relu(x) = max(0, x)

Диапазон выхода: [0, infinity). Производная тривиально проста:

relu'(x) = 1  if x > 0
            0  if x <= 0

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

Но есть режим отказа: проблема мёртвых нейронов. Если взвешенный вход нейрона всегда отрицателен (из-за большого отрицательного смещения или неудачной инициализации весов), его выход всегда ноль, градиент всегда ноль, и он никогда не обновляется. Он умирает навсегда. На практике во время обучения может погибнуть 10–40 % нейронов сети с ReLU.

Leaky ReLU

Самое простое исправление мёртвых нейронов.

leaky_relu(x) = x        if x > 0
                alpha * x if x <= 0

Здесь alpha — небольшая константа, обычно 0.01. У отрицательной части есть небольшой наклон вместо нуля, поэтому мёртвые нейроны всё ещё получают градиентный сигнал и могут восстановиться.

GELU: современный вариант по умолчанию

Gaussian Error Linear Unit. Представлена Хендриксом и Гимпелем в 2016 году. Активация по умолчанию в BERT, GPT и большинстве современных transformer-моделей.

gelu(x) = x * Phi(x)

Здесь Phi(x) — функция распределения стандартной нормальной величины. Практически используемая аппроксимация:

gelu(x) ~= 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3)))

GELU гладка всюду, допускает небольшие отрицательные значения (в отличие от ReLU, жёстко обрезающей их до нуля) и имеет вероятностную интерпретацию: она взвешивает каждый вход вероятностью того, что он положителен при гауссовом распределении. Такое гладкое пропускание превосходит ReLU в transformer-архитектурах, поскольку обеспечивает лучший поток градиентов и полностью избегает проблемы мёртвых нейронов.

Swish / SiLU

Самоуправляемая активация, открытая Рамачандраном и соавторами в 2017 году автоматизированным поиском.

swish(x) = x * sigmoid(x)

Формально Swish — это x * sigmoid(x). Google обнаружил её автоматизированным поиском в пространстве функций активации: нейронная сеть проектировала части нейронных сетей.

Как и GELU, она гладкая, немонотонная и допускает небольшие отрицательные значения. Различие тонкое: Swish использует сигмоиду для пропускания, а GELU — гауссову CDF. На практике производительность почти одинакова. Swish применяется в EfficientNet и некоторых моделях компьютерного зрения. В языковых моделях преобладает GELU.

Softmax: выходная активация

Не используется в скрытых слоях. Softmax преобразует вектор необработанных оценок (логитов) в распределение вероятностей.

softmax(x_i) = e^(x_i) / sum(e^(x_j) for all j)

Каждый выход находится между 0 и 1. Сумма всех выходов равна 1. Поэтому это стандартная финальная активация для многоклассовой классификации. Наибольший логит получает наибольшую вероятность, но, в отличие от argmax, softmax дифференцируема и сохраняет информацию об относительной уверенности.

Сравнение форм

Диаграмма к уроку «Функции активации»

Сравнение потока градиентов

Диаграмма к уроку «Функции активации»

Какую активацию когда использовать

Диаграмма к уроку «Функции активации»

softmax-temperature

Соберите это

Шаг 1: реализуйте все функции активации с производными

Каждая функция принимает одно число с плавающей точкой и возвращает число с плавающей точкой. Каждая функция производной принимает тот же вход и возвращает градиент.

import math

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

def sigmoid_derivative(x):
    s = sigmoid(x)
    return s * (1 - s)

def tanh_act(x):
    return math.tanh(x)

def tanh_derivative(x):
    t = math.tanh(x)
    return 1 - t * t

def relu(x):
    return max(0.0, x)

def relu_derivative(x):
    return 1.0 if x > 0 else 0.0

def leaky_relu(x, alpha=0.01):
    return x if x > 0 else alpha * x

def leaky_relu_derivative(x, alpha=0.01):
    return 1.0 if x > 0 else alpha

def gelu(x):
    return 0.5 * x * (1 + math.tanh(math.sqrt(2 / math.pi) * (x + 0.044715 * x ** 3)))

def gelu_derivative(x):
    phi = 0.5 * (1 + math.erf(x / math.sqrt(2)))
    pdf = math.exp(-0.5 * x * x) / math.sqrt(2 * math.pi)
    return phi + x * pdf

def swish(x):
    return x * sigmoid(x)

def swish_derivative(x):
    s = sigmoid(x)
    return s + x * s * (1 - s)

def softmax(xs):
    max_x = max(xs)
    exps = [math.exp(x - max_x) for x in xs]
    total = sum(exps)
    return [e / total for e in exps]

Шаг 2: визуализируйте, где умирают градиенты

Вычислите градиент в 100 равномерно расположенных точках от -5 до 5. Выведите текстовую гистограмму, показывающую, где градиент каждой активации близок к нулю.

def gradient_scan(name, derivative_fn, start=-5, end=5, n=100):
    step = (end - start) / n
    near_zero = 0
    healthy = 0
    for i in range(n):
        x = start + i * step
        g = derivative_fn(x)
        if abs(g) < 0.01:
            near_zero += 1
        else:
            healthy += 1
    pct_dead = near_zero / n * 100
    print(f"{name:15s}: {healthy:3d} healthy, {near_zero:3d} near-zero ({pct_dead:.0f}% dead zone)")

gradient_scan("Sigmoid", sigmoid_derivative)
gradient_scan("Tanh", tanh_derivative)
gradient_scan("ReLU", relu_derivative)
gradient_scan("Leaky ReLU", leaky_relu_derivative)
gradient_scan("GELU", gelu_derivative)
gradient_scan("Swish", swish_derivative)

Шаг 3: эксперимент с затухающими градиентами

Выполните прямой проход сигнала через N слоёв с сигмоидой и ReLU. Измерьте, как меняется величина активации.

import random

def vanishing_gradient_experiment(activation_fn, name, n_layers=10, n_inputs=5):
    random.seed(42)
    values = [random.gauss(0, 1) for _ in range(n_inputs)]

    print(f"\n{name} through {n_layers} layers:")
    for layer in range(n_layers):
        weights = [random.gauss(0, 1) for _ in range(n_inputs)]
        z = sum(w * v for w, v in zip(weights, values))
        activated = activation_fn(z)
        magnitude = abs(activated)
        bar = "#" * int(magnitude * 20)
        print(f"  Layer {layer+1:2d}: magnitude = {magnitude:.6f} {bar}")
        values = [activated] * n_inputs

vanishing_gradient_experiment(sigmoid, "Sigmoid")
vanishing_gradient_experiment(relu, "ReLU")
vanishing_gradient_experiment(gelu, "GELU")

Шаг 4: детектор мёртвых нейронов

Создайте сеть с ReLU, пропустите через неё случайные входы и посчитайте, сколько нейронов ни разу не сработали.

def dead_neuron_detector(n_inputs=5, hidden_size=20, n_samples=1000):
    random.seed(0)
    weights = [[random.gauss(0, 1) for _ in range(n_inputs)] for _ in range(hidden_size)]
    biases = [random.gauss(0, 1) for _ in range(hidden_size)]

    fire_counts = [0] * hidden_size

    for _ in range(n_samples):
        inputs = [random.gauss(0, 1) for _ in range(n_inputs)]
        for neuron_idx in range(hidden_size):
            z = sum(w * x for w, x in zip(weights[neuron_idx], inputs)) + biases[neuron_idx]
            if relu(z) > 0:
                fire_counts[neuron_idx] += 1

    dead = sum(1 for c in fire_counts if c == 0)
    rarely_fire = sum(1 for c in fire_counts if 0 < c < n_samples * 0.05)
    healthy = hidden_size - dead - rarely_fire

    print(f"\nDead Neuron Report ({hidden_size} neurons, {n_samples} samples):")
    print(f"  Dead (never fired):     {dead}")
    print(f"  Barely alive (<5%):     {rarely_fire}")
    print(f"  Healthy:                {healthy}")
    print(f"  Dead neuron rate:       {dead/hidden_size*100:.1f}%")

    for i, c in enumerate(fire_counts):
        status = "DEAD" if c == 0 else "WEAK" if c < n_samples * 0.05 else "OK"
        bar = "#" * (c * 40 // n_samples)
        print(f"  Neuron {i:2d}: {c:4d}/{n_samples} fires [{status:4s}] {bar}")

dead_neuron_detector()

Шаг 5: сравнение обучения — сигмоида, ReLU и GELU

Обучите одну и ту же двухслойную сеть на наборе данных «круг» (точки внутри круга = класс 1, снаружи = класс 0) с тремя разными активациями. Сравните скорость сходимости.

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 ActivationNetwork:
    def __init__(self, activation_fn, activation_deriv, hidden_size=8, lr=0.1):
        random.seed(0)
        self.act = activation_fn
        self.act_d = activation_deriv
        self.lr = lr
        self.hidden_size = hidden_size

        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):
        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(self.act(z))

        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):
        error = self.out - target
        d_out = error * self.out * (1 - self.out)

        for i in range(self.hidden_size):
            d_h = d_out * self.w2[i] * self.act_d(self.z1[i])
            self.w2[i] -= self.lr * d_out * self.h[i]
            for j in range(2):
                self.w1[i][j] -= self.lr * d_h * self.x[j]
            self.b1[i] -= self.lr * d_h
        self.b2 -= self.lr * d_out

    def train(self, data, epochs=200):
        losses = []
        for epoch in range(epochs):
            total_loss = 0
            correct = 0
            for x, y in data:
                pred = self.forward(x)
                self.backward(y)
                total_loss += (pred - y) ** 2
                if (pred >= 0.5) == (y >= 0.5):
                    correct += 1
            avg_loss = total_loss / len(data)
            accuracy = correct / len(data) * 100
            losses.append(avg_loss)
            if epoch % 50 == 0 or epoch == epochs - 1:
                print(f"    Epoch {epoch:3d}: loss={avg_loss:.4f}, accuracy={accuracy:.1f}%")
        return losses


data = make_circle_data()

configs = [
    ("Sigmoid", sigmoid, sigmoid_derivative),
    ("ReLU", relu, relu_derivative),
    ("GELU", gelu, gelu_derivative),
]

results = {}
for name, act_fn, act_d_fn in configs:
    print(f"\n=== Training with {name} ===")
    net = ActivationNetwork(act_fn, act_d_fn, hidden_size=8, lr=0.1)
    losses = net.train(data, epochs=200)
    results[name] = losses

print("\n=== Final Loss Comparison ===")
for name, losses in results.items():
    print(f"  {name:10s}: start={losses[0]:.4f} -> end={losses[-1]:.4f} (improvement: {(1 - losses[-1]/losses[0])*100:.1f}%)")

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

PyTorch предоставляет всё это и в функциональной форме, и в виде модулей:

import torch
import torch.nn as nn
import torch.nn.functional as F

x = torch.randn(4, 10)

relu_out = F.relu(x)
gelu_out = F.gelu(x)
sigmoid_out = torch.sigmoid(x)
swish_out = F.silu(x)

logits = torch.randn(4, 5)
probs = F.softmax(logits, dim=1)

model = nn.Sequential(
    nn.Linear(10, 64),
    nn.GELU(),
    nn.Linear(64, 32),
    nn.GELU(),
    nn.Linear(32, 5),
)

Скрытые слои transformer: GELU. Скрытые слои CNN: ReLU. Выходной слой для классификации: softmax. Выходной слой для регрессии: ничего (линейный). Выходной слой для вероятностей: сигмоида. Вот и всё. Начинайте с этих вариантов по умолчанию. Меняйте их, только когда у вас есть основания.

RNN и LSTM используют tanh для скрытого состояния и сигмоиду для ворот, но если вы сегодня создаёте модель с нуля, вы, вероятно, не используете RNN. Если нейроны умирают в вашей сети с ReLU, переключитесь на GELU. Не тянитесь к Leaky ReLU без конкретной причины: GELU решает проблему мёртвых нейронов и обеспечивает лучший поток градиентов.

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

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

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

Упражнения

  1. Реализуйте Parametric ReLU (PReLU), где отрицательный наклон alpha — обучаемый параметр. Обучите её на наборе данных «круг» и сравните с фиксированной Leaky ReLU.

  2. Запустите эксперимент с затухающими градиентами с 50 слоями вместо 10. Постройте график величины на каждом слое для сигмоиды, tanh, ReLU и GELU. На каком слое сигнал каждой активации фактически достигает нуля?

  3. Реализуйте ELU (Exponential Linear Unit): elu(x) = x if x > 0, alpha * (e^x - 1) if x <= 0. Сравните долю мёртвых нейронов с ReLU в той же сети.

  4. Создайте «монитор здоровья градиентов», работающий во время обучения: в каждой эпохе вычисляйте среднюю величину градиента в каждом слое. Выводите предупреждение, когда градиент любого слоя падает ниже 0.001 или превышает 100.

  5. Измените сравнение обучения, чтобы использовать набор данных XOR из урока 01 вместо кругов. Какая активация быстрее всего сходится на XOR? Почему это отличается от результатов на круге?

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

Термин Как обычно говорят Что это действительно означает
Функция активации «Нелинейная часть» Функция, применяемая к выходу каждого нейрона и разрушающая линейность, чтобы сеть могла учить нелинейные отображения
Затухающий градиент «В глубоких сетях исчезают градиенты» Градиенты экспоненциально уменьшаются при прохождении через слои, когда производная активации меньше 1, из-за чего ранние слои невозможно обучить
Взрывающийся градиент «Градиенты взрываются» Градиенты экспоненциально растут при прохождении через слои, когда эффективный множитель превышает 1, что делает обучение нестабильным
Мёртвый нейрон «Нейрон перестал учиться» Нейрон ReLU, чей вход постоянно отрицателен, из-за чего выход и градиент равны нулю
Сигмоида «Сжимает значения до 0–1» Логистическая функция 1/(1+e^-x), исторически важная, но вызывающая затухание градиентов в глубоких сетях
ReLU «Обрезает отрицательные значения до нуля» max(0, x) — активация, сделавшая глубокое обучение практичным благодаря сохранению величины градиента
GELU «Активация для transformer» Gaussian Error Linear Unit, гладкая активация, взвешивающая входы их вероятностью быть положительными
Swish/SiLU «Самоуправляемая ReLU» x * sigmoid(x), найденная автоматизированным поиском и применяемая в EfficientNet
Softmax «Преобразует оценки в вероятности» Нормализует вектор логитов в распределение вероятностей, где все значения принадлежат (0,1) и суммируются до 1
Leaky ReLU «ReLU, которая не умирает» max(alpha*x, x), где alpha мало (0.01); предотвращает смерть нейронов небольшими отрицательными градиентами
Насыщение «Плоская часть сигмоиды» Области, в которых производная активации стремится к нулю и блокирует поток градиента
Логит «Необработанная оценка перед softmax» Ненормализованный выход последнего слоя перед применением softmax или сигмоиды

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

  • Nair & Hinton, «Rectified Linear Units Improve Restricted Boltzmann Machines» (2010) — статья, представившая ReLU и сделавшая возможным обучение глубоких сетей
  • Hendrycks & Gimpel, «Gaussian Error Linear Units (GELUs)» (2016) — представила функцию активации, ставшую вариантом по умолчанию для transformer-моделей
  • Ramachandran и соавт., «Searching for Activation Functions» (2017) — использовала автоматизированный поиск для обнаружения Swish и показала, что проектирование активаций можно автоматизировать
  • Glorot & Bengio, «Understanding the difficulty of training deep feedforward neural networks» (2010) — статья, диагностировавшая затухающие/взрывающиеся градиенты и предложившая инициализацию Xavier
  • Goodfellow, Bengio, Courville, «Deep Learning», глава 6.3 (https://www.deeplearningbook.org/) — строгое изложение скрытых единиц и функций активации

Источник: Activation Functions 03.03 — Обратное распространение ошибки с нуля · Фаза 3 — Основы глубокого обучения · Полный каталог · 03.05 — Функции потерь