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

⏱ 35 мин · 🧩 заданий: 5

Зачем нужна нелинейность

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

  • нелинейной — иначе глубина бесполезна;
  • дифференцируемой (хотя бы почти везде) — иначе не работает градиентный спуск;
  • дешёвой — она вычисляется для каждого нейрона на каждом объекте;
  • с хорошими градиентами — не слишком маленькими, чтобы сигнал доходил до первых слоёв.
import numpy as np
import vis

z = np.linspace(-4, 4, 400)
sig = 1 / (1 + np.exp(-z))
gelu = 0.5 * z * (1 + np.tanh(np.sqrt(2 / np.pi) * (z + 0.044715 * z ** 3)))
vis.line(z, {"сигмоида": sig, "tanh": np.tanh(z), "ReLU": np.maximum(0, z),
             "Leaky ReLU (0.1)": np.where(z > 0, z, 0.1 * z), "GELU": gelu},
         title="Функции активации", xlabel="z")
vis.line(z, {"сигмоида": sig * (1 - sig), "tanh": 1 - np.tanh(z) ** 2, "ReLU": (z > 0).astype(float),
             "Leaky ReLU (0.1)": np.where(z > 0, 1, 0.1)},
         title="Их производные", xlabel="z")

Сигмоида и tanh

\sigma(z) = \frac{1}{1 + e^{-z}} \in (0, 1), \qquad \tanh z = \frac{e^z - e^{-z}}{e^z + e^{-z}} \in (-1, 1)

Исторически первые активации — гладкие «ступеньки», похожие на срабатывание биологического нейрона. У них две проблемы:

  1. Насыщение. При больших |z| функция почти плоская, производная почти 0. Нейрон, который «насытился», почти не передаёт градиент назад.
  2. Маленькая производная даже в лучшем случае: у сигмоиды не больше 0.25. Через 10 слоёв градиент уменьшится как минимум в 4^{10} \approx 10^6 раз — затухающие градиенты.

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

Сегодня сигмоиду используют на выходе для бинарной классификации (вероятность) и внутри «вентилей» (gates) — например, в LSTM. tanh — в рекуррентных сетях.

ReLU

\text{ReLU}(z) = \max(0, z)

Простейшая нелинейность, ставшая стандартом около 2010 года (её успех в AlexNet, 2012, сыграл огромную роль):

  • не насыщается при положительных z: производная ровно 1, градиент проходит через слой без затухания;
  • дешёвая: сравнение с нулём;
  • разреженная: примерно половина нейронов выдаёт 0, что делает представления проще.

Главный недостаток — мёртвые нейроны. Если вход нейрона отрицателен на всех объектах (например, после слишком большого шага сдвиг стал очень отрицательным), он всегда выдаёт 0, градиент через него — тоже 0, и нейрон больше никогда не оживёт.

import numpy as np
import nanotorch as nt
import ml_data

X, y = ml_data.load_digits()
X = X / 16.0
for lr in (0.05, 2.0):
    nt.manual_seed(0)
    model = nt.Sequential(nt.Linear(64, 100), nt.ReLU(), nt.Linear(100, 10))
    nt.fit(model, X, y, nt.cross_entropy, nt.SGD(model.parameters(), lr=lr), epochs=5, batch_size=32, verbose=False)
    with nt.no_grad():
        h = model[1](model[0](nt.Tensor(X))).data
    dead = (h.max(axis=0) == 0).mean()
    print(f"lr = {lr}: мёртвых нейронов {dead:.0%}, точность {nt.accuracy(model(nt.Tensor(X)), y):.1%}")

При слишком большом шаге почти все нейроны умирают навсегда, и сеть выдаёт одно и то же для любой картинки (точность 10%). Средства: разумный шаг обучения, хорошая инициализация, нормализация — и «протекающие» варианты ReLU.

Семейство ReLU

  • Leaky ReLU: \max(az, z) с маленьким a (0.01). Слева небольшой наклон — градиент никогда не равен нулю.
  • ELU: z при z > 0, иначе e^z - 1. Гладкая слева, выход может быть отрицательным (среднее ближе к нулю).
  • GELU (Gaussian Error Linear Unit): z \cdot \Phi(z), где \Phi — функция нормального распределения. Плавная версия ReLU: «пропусти z с вероятностью, зависящей от его величины». Стандарт в трансформерах — BERT, GPT.
  • SiLU / Swish: z \cdot \sigma(z) — похожа на GELU, используется в LLaMA и других современных моделях (в варианте SwiGLU).
import numpy as np
import vis

z = np.linspace(-4, 3, 300)
sig = 1 / (1 + np.exp(-z))
vis.line(z, {"ReLU": np.maximum(0, z),
             "ELU": np.where(z > 0, z, np.exp(np.minimum(z, 0)) - 1),
             "GELU": 0.5 * z * (1 + np.tanh(np.sqrt(2 / np.pi) * (z + 0.044715 * z ** 3))),
             "SiLU": z * sig},
         title="Гладкие родственники ReLU", xlabel="z")

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

Обучим одинаковые сети из 8 скрытых слоёв с разными активациями и посмотрим, как падают потери. Инициализацию возьмём ту, что подходит каждой активации (о ней — в уроке про инициализацию).

import numpy as np
import nanotorch as nt
import ml_data
import vis

X, y = ml_data.load_digits()
X = X / 16.0

curves = {}
for name, act, init in [("сигмоида", nt.Sigmoid, "xavier"), ("tanh", nt.Tanh, "xavier"),
                        ("ReLU", nt.ReLU, "kaiming"), ("GELU", nt.GELU, "kaiming")]:
    nt.manual_seed(1)
    layers, d = [], 64
    for _ in range(8):
        layers += [nt.Linear(d, 32, init=init), act()]
        d = 32
    model = nt.Sequential(*layers, nt.Linear(32, 10, init=init))
    h = nt.fit(model, X, y, nt.cross_entropy, nt.SGD(model.parameters(), lr=0.1), epochs=10, batch_size=32, verbose=False)
    curves[name] = h["loss"]
    print(f"{name:9} loss после 10 эпох: {h['loss'][-1]:.3f}")
vis.line(curves, title="Обучение сети из 8 скрытых слоёв", xlabel="эпоха", ylabel="кросс-энтропия")

Сеть с сигмоидой не учится вовсе — потери застряли на \ln 10 \approx 2.3 (уровень угадывания): градиент затухает, не дойдя до первых слоёв. tanh, ReLU и GELU учатся. На такой маленькой задаче разница между ними невелика; в больших сетях ReLU-семейство выигрывает и по качеству, и по скорости вычислений.

На выходе сети

Активация последнего слоя определяется задачей, а не соображениями о градиентах:

Задача Выход Функция потерь
Регрессия без активации (линейный) MSE
Бинарная классификация 1 логит → сигмоида бинарная кросс-энтропия
Многоклассовая C логитов → softmax кросс-энтропия
Несколько меток сразу (multi-label) C логитов → сигмоида у каждого BCE по каждой метке

На практике сигмоиду и softmax часто не ставят в модель явно, а встраивают в функцию потерь (binary_cross_entropy и cross_entropy в nanotorch принимают логиты): так вычисления численно устойчивее.

Итог

  • Активация даёт нелинейность; должна быть дифференцируемой, дешёвой и не гасить градиент.
  • Сигмоида и tanh насыщаются, производная сигмоиды ≤ 0.25 — затухающие градиенты в глубоких сетях.
  • ReLU: производная 1 для z > 0, быстро и просто; риск мёртвых нейронов.
  • Leaky ReLU, ELU, GELU, SiLU — сглаженные и «протекающие» варианты; GELU и SiLU — стандарт трансформеров.
  • Выходная активация выбирается по задаче: линейная, сигмоида или softmax.

Практика решено 0 из 5

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Заполнить таблицу

Свойства функций активации

сигмоидаtanhReLUGELU
Значения в диапазоне (0, 1)
Центрирована около нуля (выход может быть отрицательным и симметричным)
Производная равна 1 для всех положительных входов
Гладкая (без излома)
Используется в трансформерах (BERT, GPT)
Отметьте ячейку — «+», пустая — «−».
Задание 2 Один вариант ответа

Что такое «мёртвый» ReLU-нейрон?

Задание 3 Один вариант ответа

Сеть решает задачу регрессии (предсказывает цену). Какую активацию поставить на выходной слой?

Задание 4 Ввод ответа

Чему равна производная сигмоиды в точке z = 0?

Задание 5 Программа на Python

Реализуйте на NumPy функции активации и их производные (все работают поэлементно с массивами):

  • leaky_relu(z, a=0.01) и d_leaky_relu(z, a=0.01) — z при z > 0, иначе az;
  • elu(z) и d_elu(z) — z при z > 0, иначе e^z - 1 (производная e^z);
  • silu(z) и d_silu(z) — z\,\sigma(z), производная \sigma(z)\,(1 + z(1 - \sigma(z))).
Какие тесты проверяют код (2)
z = np.array([-2.0, -0.5, 0.3, 1.7])
assert np.allclose(leaky_relu(z), [-0.02, -0.005, 0.3, 1.7])
assert np.allclose(elu(z), [np.exp(-2) - 1, np.exp(-0.5) - 1, 0.3, 1.7])
assert np.allclose(silu(z), z / (1 + np.exp(-z)))

z = np.array([-2.0, -0.5, 0.3, 1.7])
h = 1e-6
for f, df, name in [(leaky_relu, d_leaky_relu, "leaky_relu"), (elu, d_elu, "elu"), (silu, d_silu, "silu")]:
    num = (f(z + h) - f(z - h)) / (2 * h)
    assert np.allclose(df(z), num, atol=1e-5), f"Производная {name} не совпадает с численной: {df(z)} vs {num}"

Войдите, чтобы отмечать пройденные уроки.