В современных нейросетях, включая LLM на базе Transformer, стандартом стали неограниченные функции активации — ReLU и GELU. Их основное преимущество, хорошая проходимость градиентов и быстрое обучение глубоких моделей.
Однако на практике наблюдается проблема: при появлении доминирующих паттернов или высокочастотного шума во входном контексте (длинные диалоги, шумные данные, повторяющиеся или доминирующие токены) модели становятся нестабильными и склонными к деградации генерации и галлюцинациям.
В этой статье я попытался выяснить, может ли быть связан принципиально выбор функции активации с галлюцинациями LLM.
Что такое GELU и Tanh
GELU (Gaussian Error Linear Unit) — гладкая версия ReLU, используемая в большинстве современных LLM. Она пропускает положительные значения без жёсткого потолка и подавляет отрицательные. GELU улучшает обучение и качество на чистых данных, не ограничивая амплитуду активаций.
Tanh (гиперболический тангенс) — ограниченная функция активации с выходом в диапазоне [-1, 1]. При больших входных значениях функция насыщается, что ограничивает влияние отдельных нейронов. Проблема, из-за которой похоже перешли на GELU, более сложное обучение из-за затухания градиентов. Ниже остановлюсь, почему эта проблема сегодня не критична.
Описание эксперимента
Цель эксперимента — изолировать влияние функции активации, не меняя архитектуру и задачу. Для этого использовалась задача классификация MNIST (базовый тест способности сети извлекать и удерживать признаки). Задача выбрана намеренно простой для изоляции эффекта функции активации.
Эксперимент проводился на трёх идентичных MLP
Linear → LayerNorm → Activation → Linear
(Activation ∈ {ReLU, GELU, Tanh})
20 независимых прогонов для каждой конфигурации, при всех искажениях сохраняется суммарная энергия сигнала. Меняется только распределение энергии (энтропия, концентрация), но не её величина.
Проведены три типа стресс-тестов, моделирующих сбои внимания и контекста в LLM.
Код эксперимента под спойлером:
Скрытый текст
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
import numpy as np
import time
# --- КОНФИГУРАЦИЯ ---
CONFIG = {
'INPUT_SIZE': 784,
'HIDDEN_SIZE': 10,
'OUTPUT_SIZE': 10,
'BATCH_SIZE': 512,
'EPOCHS': 12,
'LR': 0.003,
'NUM_RUNS': 20,
'DEVICE': "cuda" if torch.cuda.is_available() else "cpu",
# Добавили 0.0 (Baseline) во все тесты
'LOBOTOMY_LEVELS': [0.0, 0.30, 0.50, 0.70, 0.90],
'SPIKE_LEVELS': [0.0, 0.30, 0.50, 0.70, 0.90],
'NOISE_LEVELS': [0.0, 0.5, 1.0, 2.0, 3.0]
}
# --- ЗАГРУЗКА ДАННЫХ ---
class FastMNIST:
def __init__(self, train=True, device='cpu'):
transform = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,))])
dataset = datasets.MNIST('./data', train=train, download=True, transform=transform)
loader = torch.utils.data.DataLoader(dataset, batch_size=len(dataset))
self.data, self.targets = next(iter(loader))
self.data = self.data.to(device)
self.targets = self.targets.to(device)
self.n_samples = len(self.data)
def get_batches(self, batch_size, shuffle=True):
if shuffle:
indices = torch.randperm(self.n_samples, device=self.data.device)
else:
indices = torch.arange(self.n_samples, device=self.data.device)
for start_idx in range(0, self.n_samples, batch_size):
idx = indices[start_idx : start_idx + batch_size]
yield self.data[idx], self.targets[idx]
print(f"🔥 DEVICE: {CONFIG['DEVICE']}")
train_data = FastMNIST(train=True, device=CONFIG['DEVICE'])
test_data = FastMNIST(train=False, device=CONFIG['DEVICE'])
# --- МОДЕЛЬ ---
class PrismNet(nn.Module):
def __init__(self, act_fn, name):
super().__init__()
self.name = name
self.fc1 = nn.Linear(CONFIG['INPUT_SIZE'], CONFIG['HIDDEN_SIZE'])
self.ln = nn.LayerNorm(CONFIG['HIDDEN_SIZE'])
self.act = act_fn()
self.fc2 = nn.Linear(CONFIG['HIDDEN_SIZE'], CONFIG['OUTPUT_SIZE'])
def forward(self, x, mask=None):
x = x.view(-1, CONFIG['INPUT_SIZE'])
pre_latent = self.fc1(x)
if mask is not None:
pre_latent = pre_latent * mask
latent = self.act(self.ln(pre_latent))
return self.fc2(latent)
# --- ГЕНЕРАТОРЫ МАСОК ---
def get_lobotomy_mask(hidden_size, severity, seed, device):
torch.manual_seed(seed)
mask = torch.ones(hidden_size, device=device)
n_killed = int(hidden_size * severity)
if n_killed >= hidden_size: n_killed = hidden_size - 1
perm = torch.randperm(hidden_size, device=device)
killed_indices = perm[:n_killed]
mask[killed_indices] = 0.0
n_alive = hidden_size - n_killed
scale = np.sqrt(hidden_size / n_alive)
return mask * scale
def get_spike_mask(hidden_size, severity, seed, device):
torch.manual_seed(seed)
mask = torch.ones(hidden_size, device=device)
victim = torch.randint(0, hidden_size, (1,)).item()
E_total = float(hidden_size)
E_spike = severity * E_total
E_noise = (1.0 - severity) * E_total
amp_spike = np.sqrt(E_spike)
amp_noise = np.sqrt(E_noise / (hidden_size - 1))
mask[:] = amp_noise
mask[victim] = amp_spike
return mask
def get_noise_mask(hidden_size, intensity, seed, device):
torch.manual_seed(seed)
raw_noise = torch.randn(hidden_size, device=device) * intensity
mask = torch.exp(raw_noise)
current_E = (mask**2).sum()
target_E = float(hidden_size)
scale = torch.sqrt(target_E / current_E)
return mask * scale
# --- ЯДРО ЭКСПЕРИМЕНТА ---
print(f"n=== PRISM FINAL: BASELINE & TRINITY (N={CONFIG['NUM_RUNS']}) ===n")
models_config = [(nn.ReLU, "ReLU"), (nn.Tanh, "Tanh"), (nn.GELU, "GELU")]
# Хранилище
res_lobo = {name: {lvl: [] for lvl in CONFIG['LOBOTOMY_LEVELS']} for _, name in models_config}
res_spike = {name: {lvl: [] for lvl in CONFIG['SPIKE_LEVELS']} for _, name in models_config}
res_noise = {name: {lvl: [] for lvl in CONFIG['NOISE_LEVELS']} for _, name in models_config}
total_start = time.time()
for run in range(CONFIG['NUM_RUNS']):
run_start = time.time()
print(f"Run {run+1:02d}/{CONFIG['NUM_RUNS']}...", end=" ", flush=True)
# 1. Train
trained_models = []
for act_fn, name in models_config:
model = PrismNet(act_fn, name).to(CONFIG['DEVICE'])
opt = optim.Adam(model.parameters(), lr=CONFIG['LR'])
for epoch in range(CONFIG['EPOCHS']):
model.train()
for data, target in train_data.get_batches(CONFIG['BATCH_SIZE']):
opt.zero_grad()
logits = model(data)
loss = nn.CrossEntropyLoss()(logits, target)
loss.backward()
opt.step()
trained_models.append(model)
# Helper for running tests
def run_test_batch(level_list, result_dict, mask_gen_func):
for lvl in level_list:
# Special case for Baseline
if lvl == 0.0:
mask = None
else:
mask = mask_gen_func(CONFIG['HIDDEN_SIZE'], lvl, seed=1000+run+int(lvl*100), device=CONFIG['DEVICE'])
for model in trained_models:
model.eval()
correct = 0; total = 0
with torch.no_grad():
for data, target in test_data.get_batches(2000, shuffle=False):
logits = model(data, mask)
correct += logits.argmax(1).eq(target).sum().item()
total += target.size(0)
result_dict[model.name][lvl].append(100. * correct / total)
# 2. Run Tests
run_test_batch(CONFIG['LOBOTOMY_LEVELS'], res_lobo, get_lobotomy_mask)
run_test_batch(CONFIG['SPIKE_LEVELS'], res_spike, get_spike_mask)
run_test_batch(CONFIG['NOISE_LEVELS'], res_noise, get_noise_mask)
print(f"Done ({time.time() - run_start:.1f}s)")
# --- ОТЧЕТ ---
def print_table(title, levels, results_dict, metric_name):
print(f"nn### {title}")
print(f"{metric_name:<10} | {'Model':<6} | {'Accuracy':<9} | {'StdDev':<8} | {'95% CI':<16}")
print("|" + "-"*65 + "|")
for lvl in levels:
label = str(lvl)
if lvl == 0.0: label = "0.0 (Base)"
print(f"| **{label}** | | | | |")
for _, name in models_config:
data = results_dict[name][lvl]
mean = np.mean(data)
std = np.std(data)
ci = 1.96 * std / np.sqrt(len(data))
# Simple highlight logic
mean_str = f"{mean:.2f}%"
if lvl > 0.0 and name == "Tanh" and mean > 60:
mean_str = f"**{mean_str}**"
print(f"| | {name:<6} | {mean_str:<9} | {std:.2f} | [{mean-ci:.2f}, {mean+ci:.2f}] |")
print("|" + "-"*65 + "|")
print_table("TEST 1: LOBOTOMY (Потеря информации)", CONFIG['LOBOTOMY_LEVELS'], res_lobo, "Dead %")
print_table("TEST 2: SPIKE (Паразитная доминанта)", CONFIG['SPIKE_LEVELS'], res_spike, "Energy %")
print_table("TEST 3: NOISE (Энтропия / Хаос)", CONFIG['NOISE_LEVELS'], res_noise, "Noise Lvl")
print(f"nTotal Experiment Time: {time.time() - total_start:.1f}s")
1. Базовый тест
Цель — оценить поведение модели в базовых условиях.
|
Модель |
Точность (Mean) |
Стабильность (StdDev) |
Комментарий |
|
GELU |
92.84% |
±0.42 |
Стандарт индустрии. Лучшая динамика обучения. |
|
ReLU |
92.65% |
±0.45 |
Базовая модель. |
|
Tanh |
92.06% |
±0.28 |
Самая стабильная, но уступает 0.78% в точности |
Tanh отстаёт от GELU примерно на 0.8%. Возможно это и есть плата за ограниченную активацию в условиях чистых данных.
2. Тест на доминирующий нейрон
Искусственно концентрируем часть энергии слоя в одном нейроне. Например, уровень 0.5 означает, что 50% всей энергии слоя приходится на один нейрон, остальная энергия распределена по прочим.
|
Сила Спайка(% энергии в 1 нейроне) |
GELU(Accuracy) |
Tanh(Accuracy) |
Δ (Tanh – GELU) |
Интерпретация |
|
0.0 (Clean) |
92.84% |
92.06% |
-0.78% |
В норме GELU лучше. |
|
0.3 (30%) |
91.48% |
91.77% |
+0.29% |
Точка перелома. |
|
0.5 (50%) |
87.76% |
90.94% |
+3.18% |
Зона риска. Tanh игнорирует атаку. |
|
0.7 (70%) |
80.93% |
88.41% |
+7.48% |
GELU теряет стабильность. |
|
0.9 (90%) |
66.75% |
77.77% |
+11.02% |
Коллапс GELU. |
GELU демонстрирует почти линейное падение точности по мере роста спайка. Tanh деградирует значительно медленнее и сохраняет стабильность.
За счёт насыщения tanh вклад доминирующего нейрона ограничен. Даже при сильной концентрации энергии сеть продолжает использовать остальной контекст.
Дополнительно, при среднем уровне спайка разброс результатов (StdDev) у GELU в несколько раз выше, чем у Tanh, что указывает на повышенную чувствительность GELU к случайным флуктуациям.
Отмечу, что этим экспериментом я хотел продемонстрировать аналогичный механизм, наблюдаемый в LLM. При подавлении повторов энергия концентрируется в альтернативных токенах. В длинных контекстах внимание схлопывается на небольшое число позиций.
Предполагаю, что Tanh в FFN-слоях может сгладить эти артефакты.
3. Тест на рост энтропии
На активации накладывается мультипликативный шум при сохранении общей энергии.
Это моделирует длинный, зашумлённый или плохо структурированный контекст.
|
Уровень Шума(σ) |
GELU(Accuracy) |
Tanh(Accuracy) |
Δ (Tanh – GELU) |
Интерпретация |
|
0.0 (Clean) |
92.84% |
92.06% |
-0.78% |
Бэйслайн. |
|
0.5 (Low) |
87.29% |
90.25% |
+2.96% |
Начало деградации контекста. |
|
1.0 (High) |
62.68% |
77.68% |
+15.00% |
Tanh работает как фильтр. |
|
2.0 (Chaos) |
42.64% |
58.70% |
+16.06% |
GELU генерирует хаос. |
-
При слабом шуме разница умеренная.
-
При высоком шуме точность GELU резко падает.
-
Tanh сохраняет существенно более высокий уровень корректной классификации.
Фактически, GELU продолжает интерпретировать шум как полезный сигнал.
Tanh ограничивает вклад случайных флуктуаций и по сути выполняет роль порогового фильтра.
4. Тест удаление нейронов
Случайно удаляется часть нейронов слоя, а оставшиеся усиливаются так, чтобы суммарная энергия сохранялась.
|
% Удаленных нейронов |
GELU(Accuracy) |
Tanh(Accuracy) |
Δ (Tanh – GELU) |
Интерпретация |
|
0.0 (Clean) |
92.84% |
92.06% |
-0.78% |
Все нейроны на месте. |
|
0.3 (30%) |
67.61% |
81.27% |
+13.66% |
Tanh сохранил образ. |
|
0.5 (50%) |
50.44% |
62.65% |
+12.21% |
Tanh держится на половине сети |
|
0.7 (70%) |
31.33% |
38.52% |
+7.19% |
Критическая потеря для всех. |
-
При удалении 30–50% нейронов Tanh сохраняет значительно более высокую точность.
-
GELU деградирует быстрее.
В сетях с Tanh информация распределена более равномерно. В сетях с GELU признаки кодируются более локально, поэтому потеря нейронов критичнее.
Итоговые выводы
Эксперимент выявляет инженерный компромисс, а не лучшую функцию активации.
GELU / ReLU (неограниченные)
Плюсы:
-
Быстрое обучение
-
Лучшие результаты на чистых бенчмарках
Минусы:
-
Высокая чувствительность к доминирующим активациям
-
Низкая устойчивость при росте энтропии
-
Повышенный риск деградации и нестабильного поведения
Tanh (ограниченная)
Плюсы:
-
Высокая устойчивость к шуму и спайкам
-
Более равномерное распределение информации
-
Предсказуемая деградация
Минусы:
-
Сложнее обучать
-
Небольшое отставание в идеальных условиях
Причины отказа от Tanh
В начале развития LLM Tanh считался стандартом, но проблема затухания градиентов, мотивировала к переходу на GELU. Сейчас уже разработаны методики, в значительной степени эту проблему решающие или обходящие — LayerNorm / RMSNorm, корректная инициализация (Xavier / orthogonal), residual-соединения.
Так что вопрос уже стоит не в невозможности использования Tanh, а в выборе места и способа его использования. Даже если сети с Tanh немного сложнее обучать, потенциальный выигрыш в устойчивости должен это полностью компенсировать.
Практические предложения
Для систем, где важна надёжность, устойчивость к шумному контексту и отказоустойчивость (safety-critical или reasoning-ориентированные модели), отказ от Tanh требует пересмотра.
Перспективный подход — гибридная архитектура:
-
использовать GELU на ранних слоях для извлечения признаков,
-
использовать Tanh в узких местах сети (bottlenecks, attention-контуры, memory-блоки) для стабилизации и фильтрации аномалий.
Эксперимент показывает, что выбор функции активации существенно влияет на поведение нейросетей в части их устойчивости.
Автор: Kamil_GR


