Функции активации
Зачем нужна нелинейность
Мы уже дважды убедились: без нелинейных функций между слоями сеть любой глубины — это один линейный слой. Активация — то, что даёт нейросети её силу. При этом она должна быть:
- нелинейной — иначе глубина бесполезна;
- дифференцируемой (хотя бы почти везде) — иначе не работает градиентный спуск;
- дешёвой — она вычисляется для каждого нейрона на каждом объекте;
- с хорошими градиентами — не слишком маленькими, чтобы сигнал доходил до первых слоёв.
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
Исторически первые активации — гладкие «ступеньки», похожие на срабатывание биологического нейрона. У них две проблемы:
- Насыщение. При больших |z| функция почти плоская, производная почти 0. Нейрон, который «насытился», почти не передаёт градиент назад.
- Маленькая производная даже в лучшем случае: у сигмоиды не больше 0.25. Через 10 слоёв градиент уменьшится как минимум в 4^{10} \approx 10^6 раз — затухающие градиенты.
У сигмоиды есть и третья проблема: выход всегда положительный (не центрирован около нуля), из-за чего градиенты весов следующего слоя имеют одинаковый знак, и спуск идёт «зигзагом». tanh центрирован, поэтому в скрытых слоях он лучше сигмоиды.
Сегодня сигмоиду используют на выходе для бинарной классификации (вероятность) и внутри «вентилей» (gates) — например, в LSTM. tanh — в рекуррентных сетях.
ReLU
Простейшая нелинейность, ставшая стандартом около 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
Свойства функций активации
| сигмоида | tanh | ReLU | GELU | |
|---|---|---|---|---|
| Значения в диапазоне (0, 1) | ||||
| Центрирована около нуля (выход может быть отрицательным и симметричным) | ||||
| Производная равна 1 для всех положительных входов | ||||
| Гладкая (без излома) | ||||
| Используется в трансформерах (BERT, GPT) |
Что такое «мёртвый» ReLU-нейрон?
Сеть решает задачу регрессии (предсказывает цену). Какую активацию поставить на выходной слой?
Чему равна производная сигмоиды в точке z = 0?
Реализуйте на 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}"
import numpy as np
def sigmoid(z):
return 1 / (1 + np.exp(-z))
def leaky_relu(z, a=0.01):
return np.where(z > 0, z, a * z)
def d_leaky_relu(z, a=0.01):
return np.where(z > 0, 1.0, a)
def elu(z):
return np.where(z > 0, z, np.exp(np.minimum(z, 0)) - 1)
def d_elu(z):
return np.where(z > 0, 1.0, np.exp(np.minimum(z, 0)))
def silu(z):
return z * sigmoid(z)
def d_silu(z):
s = sigmoid(z)
return s * (1 + z * (1 - s))
Войдите, чтобы отмечать пройденные уроки.