Функции активации: почему ReLU и GELU решают всё

opensourceaillmactivation-functionsdeep-learningit
← Back to Blog

Введение: зачем нужны функции активации

Без функции активации нейронная сеть — просто линейная регрессия, сколько бы слоёв ни было.

Без активации:
  y = W2 * (W1 * x + b1) + b2
  y = W2*W1*x + W2*b1 + b2
  y = W'*x + b'  ← один линейный слой!

С активацией:
  y = W2 * σ(W1 * x + b1) + b2
  ← нелинейность! модель может аппроксимировать любые функции

Уровень 1: Step Function (исторический)

Первая нейронная сеть (McCulloch-Pitts, 1943)

Step(x) = 1, если x >= 0
        = 0, если x < 0

Проблемы:
  - Не дифференцируема (нет градиента в 0)
  - Градиент = 0 везде кроме одной точки
  - Обучение через градиенты невозможно

Уровень 2: Sigmoid

Сигмоида: от 0 до 1

def sigmoid(x):
    return 1 / (1 + torch.exp(-x))

# Свойства:
#   sigmoid(0) = 0.5
#   sigmoid(∞) = 1.0
#   sigmoid(-∞) = 0.0
#   derivative = sigmoid(x) * (1 - sigmoid(x))

Визуализация

x:      -5    -3    -1     0     1     3     5
sigmoid: 0.01  0.05  0.27  0.50  0.73  0.95  0.99

Проблема: vanishing gradients

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

При x = 5:  sigmoid'(5) = 0.99 * 0.01 = 0.0099  ← почти 0!
При x = -5: sigmoid'(-5) = 0.01 * 0.99 = 0.0099  ← почти 0!

В глубокой сети (100 слоёв):
  общий_градиент = 0.01^100 = 10^-200  ← градиент затух!

Где используется sigmoid?

+ Вероятности в output layer (бинарная классификация)
+ Gate mechanisms в LSTM (input gate, forget gate, output gate)
- Скрытые слои в больших моделях (vanishing gradients)
# LSTM gates используют sigmoid
class LSTMCell(nn.Module):
    def forward(self, x, h_prev, c_prev):
        # Все gates используют sigmoid → output в [0, 1]
        forget_gate = sigmoid(W_f @ [x, h_prev] + b_f)
        input_gate = sigmoid(W_i @ [x, h_prev] + b_i)
        output_gate = sigmoid(W_o @ [x, h_prev] + b_o)
        
        # Candidate использует tanh
        candidate = tanh(W_c @ [x, h_prev] + b_c)
        
        c_new = forget_gate * c_prev + input_gate * candidate
        h_new = output_gate * tanh(c_new)

Уровень 3: Tanh

Гиперболический тангенс

def tanh(x):
    return (torch.exp(x) - torch.exp(-x)) / (torch.exp(x) + torch.exp(-x))
    # или проще:
    return torch.tanh(x)

# Свойства:
#   tanh(0) = 0
#   tanh(∞) = 1.0
#   tanh(-∞) = -1.0
#   derivative = 1 - tanh(x)^2

Визуализация

x:      -5    -3    -1     0     1     3     5
tanh:   -0.99 -0.99 -0.76  0.00  0.76  0.99  0.99

Sigmoid vs Tanh

                | Sigmoid             | Tanh
----------------|---------------------|---------------------
Диапазон        | [0, 1]              | [-1, 1]
Центрирован     | Нет (mean≈0.5)      | Да (mean=0)
Градиент        | max 0.25            | max 1.0
Использование   | gates, output       | скрытые слои (мало)

Tanh лучше sigmoid:
  + Zero-centered (лучше для градиентов)
  + Сильнее градиент (max=1 vs max=0.25)
  - Всё ещё vanishing gradients

Уровень 4: ReLU

Rectified Linear Unit — прорыв 2011

def relu(x):
    return torch.max(x, 0)
    # или: return x.clamp_min(0)

# Свойства:
#   relu(x) = x, если x > 0
#   relu(x) = 0, если x <= 0
#   derivative = 1, если x > 0
#   derivative = 0, если x <= 0

Визуализация

x:      -3    -1     0     1     3
relu:    0     0     0     1     3

derivative:
x <= 0:  0
x > 0:   1

Почему ReLU так хорош?

1. Не линейная по всему диапазону:
   ReLU(x) = 0 для x < 0 → нелинейность!

2. Градиент = 1 для x > 0:
   Нет vanishing gradients для положительных значений!
   1^100 = 1 ← градиент течёт через 100 слоёв

3. Вычислительно эффективна:
   relu(x) = max(x, 0) ← одно сравнение!
   sigmoid(x) = 1 / (1 + exp(-x)) ← exp, деление

4. Разреженность:
   50% нейронов "выключены" (output=0)
   → Регуляризация, меньше overfitting

Проблема: Dying ReLU

Проблема:
  Если нейрон всегда output < 0 → gradient = 0 → weights не обновляются
  Нейрон "умирает" и никогда не оживёт

Пример:
  x = [-2, -3, -1, -4, -2, ...]  ← все отрицательные
  relu(x) = [0, 0, 0, 0, 0, ...]
  gradient = 0 ← нейрон мёртв

Решение:
  - Меньший learning rate
  - Leaky ReLU / Parametric ReLU

Уровень 5: Leaky ReLU и варианты

Leaky ReLU

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

# Свойства:
#   leaky_relu(x) = x, если x > 0
#   leaky_relu(x) = 0.01 * x, если x <= 0
#   derivative = 1, если x > 0
#   derivative = 0.01, если x <= 0

Parametric ReLU (PReLU)

class PReLU(nn.Module):
    def __init__(self, channels):
        super().__init__()
        # alpha обучается!
        self.alpha = nn.Parameter(torch.ones(channels) * 0.25)
    
    def forward(self, x):
        return torch.where(x > 0, x, self.alpha * x)

Сравнение ReLU вариантов

Метод         | x > 0       | x <= 0      | "Мёртвые" нейроны
--------------|-------------|-------------|------------------
ReLU          | x           | 0           | Да (проблема)
Leaky ReLU    | x           | 0.01*x      | Нет
PReLU         | x           | α*x (learn)| Нет
ELU           | x           | α*(e^x-1)   | Нет
GELU          | x*Φ(x)      | x*Φ(x)      | Нет (очень мало)

GELU: Gaussian Error Linear Unit

GELU — функция активации BERT и GPT

def gelu(x):
    # GELU(x) = x * Φ(x) где Φ(x) — CDF стандартного нормального
    return x * 0.5 * (1.0 + torch.erf(x / math.sqrt(2.0)))

# Аппроксимация (используется в большинстве реализаций):
def gelu_approx(x):
    return 0.5 * x * (1.0 + torch.tanh(
        math.sqrt(2.0 / math.pi) * (x + 0.044715 * x**3)
    ))

Почему GELU лучше ReLU?

ReLU:
  output = x, если x > 0
  output = 0, если x <= 0
  ← жёсткий порог, не плавно

GELU:
  output ≈ x, если x >> 0
  output ≈ 0, если x << 0
  output ≈ x/2, если x ≈ 0
  ← плавный переход, стохастический по природе

GELU(x) = x * P(x >= 0 | x)
→ Как будто dropout с вероятностью, зависящей от x

Визуализация GELU

x:      -3    -1    -0.5    0     0.5     1     3
GELU:  -1.0  -0.35 -0.22   0     0.25    0.65  2.5

ReLU:    0     0      0     0     0.5     1     3

GELU для малых x ≠ 0!
→ Малые значения не обрезаются полностью
→ Больше информации проходит через слой

SwiGLU: современная функция для LLM

GLU: Gate Linear Unit

class GLU(nn.Module):
    def forward(self, x):
        # Разделяем пополам: одна часть — input, другая — gate
        input_part, gate = x.chunk(2, dim=-1)
        return input_part * sigmoid(gate)

SwiGLU: Swish + GLU

class SwiGLU(nn.Module):
    def __init__(self, hidden_size, ff_size):
        super().__init__()
        self.w1 = nn.Linear(hidden_size, ff_size)      # Linear
        self.w2 = nn.Linear(hidden_size, ff_size)      # Linear
        self.w3 = nn.Linear(ff_size, hidden_size)      # Linear
    
    def forward(self, x):
        # SwiGLU(x) = SwiGLU(w1(x)) * w2(x)
        swish = self.w1(x) * sigmoid(self.w1(x))  # Swish = x * sigmoid(x)
        return self.w3(swish * self.w2(x))  # GLU = input * sigmoid(gate)

Где используется SwiGLU?

Модель           | Функция активации
-----------------|---------------------------
BERT             | GELU
GPT-2            | GeLU (approx)
GPT-3            | GeLU
Llama 1, 2       | SwiGLU
Llama 3          | SwiGLU
Mistral          | SwiGLU
Mixtral          | SwiGLU
PaLM             | SwiGLU

SwiGLU стал стандартом для всех современных LLM!

Почему SwiGLU лучше?

1. Gate mechanism (как в GLU):
   → Динамически фильтрует информацию
   → Лучше обучается

2. Swish активация:
   → Нелинейность для малых значений
   → Плавный переход

3. Два линейных слоя (w1, w2):
   → Больше параметров при том же ff_size
   → Лучше качество

Сравнение функций активации

Метод    | Скорость | Градиент | Разреженность | LLM
---------|----------|----------|---------------|----
Sigmoid  | Медленно | Затухает | Высокая       | Нет
Tanh     | Средне   | Затухает | Низкая        | Нет
ReLU     | Быстро   | 1 или 0  | 50%           | Редко
LeakyReLU| Быстро   | 1 или α  | Низкая        | Иногда
GELU     | Средне   | Плавный  | Низкая        | BERT, GPT
SwiGLU   | Медленнее| Плавный  | Низкая        | Llama, Mistral

Практические аспекты

Выбор функции активации

Для Transformer decoder:
  → SwiGLU (стандарт для LLM)

Для encoder:
  → GELU (хороший баланс)

Для быстрых моделей:
  → ReLU (просто и быстро)

Для LSTM:
  → sigmoid + tanh

Влияние на обучение

С ReLU:
  loss = [10.2, 7.5, 6.1, 5.3, ...]  ← может быть нестабильно

С GELU:
  loss = [10.2, 7.2, 5.8, 5.0, ...]  ← плавнее

С SwiGLU:
  loss = [10.2, 6.8, 5.2, 4.3, ...]  ← лучше качество, но медленнее

Квантование и активации

При квантовании:
  ReLU: легко квантуется (max(0, x) → clamp)
  GELU: сложнее (нужна аппроксимация)
  SwiGLU: sigmoid сложно квантовать
  
  Решение:
    Выполнять активации в float32
    Квантовать только linear weights

Итоги

  • Без функции активации нейросеть — просто линейная модель
  • Sigmoid и tanh страдают от vanishing gradients
  • ReLU — прорыв: простой, быстрый, без vanishing gradients
  • GELU — лучше для BERT и GPT-подобных моделей
  • SwiGLU — стандарт для всех современных LLM (Llama, Mistral)
  • Выбор функции активации влияет на качество и скорость обучения