В современных нейросетях, включая LLM на базе Transformer, стандартом стали неограниченные функции активации — ReLU и GELU. Их основное преимущество, хорошая проходимость градиентов и быстрое обучение глубоких моделей.
Однако на практике наблюдается проблема: при появлении доминирующих паттернов или высокочастотного шума во входном контексте (длинные диалоги, шумные данные, повторяющиеся или доминирующие токены) модели становятся нестабильными и склонными к деградации генерации и галлюцинациям.
В этой статье я попытался выяснить, может ли быть связан принципиально выбор функции активации с галлюцинациями LLM.
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"\n\n### {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")
Цель — оценить поведение модели в базовых условиях.
|
Модель |
Точность (Mean) |
Стабильность (StdDev) |
Комментарий |
|
GELU |
92.84% |
±0.42 |
Стандарт индустрии. Лучшая динамика обучения. |
|
ReLU |
92.65% |
±0.45 |
Базовая модель. |
|
Tanh |
92.06% |
±0.28 |
Самая стабильная, но уступает 0.78% в точности |
Tanh отстаёт от GELU примерно на 0.8%. Возможно это и есть плата за ограниченную активацию в условиях чистых данных.
Искусственно концентрируем часть энергии слоя в одном нейроне. Например, уровень 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-слоях может сгладить эти артефакты.
На активации накладывается мультипликативный шум при сохранении общей энергии.
Это моделирует длинный, зашумлённый или плохо структурированный контекст.
|
Уровень Шума(σ) |
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 ограничивает вклад случайных флуктуаций и по сути выполняет роль порогового фильтра.
Случайно удаляется часть нейронов слоя, а оставшиеся усиливаются так, чтобы суммарная энергия сохранялась.
|
% Удаленных нейронов |
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 признаки кодируются более локально, поэтому потеря нейронов критичнее.
Эксперимент выявляет инженерный компромисс, а не лучшую функцию активации.
Плюсы:
Быстрое обучение
Лучшие результаты на чистых бенчмарках
Минусы:
Высокая чувствительность к доминирующим активациям
Низкая устойчивость при росте энтропии
Повышенный риск деградации и нестабильного поведения
Плюсы:
Высокая устойчивость к шуму и спайкам
Более равномерное распределение информации
Предсказуемая деградация
Минусы:
Сложнее обучать
Небольшое отставание в идеальных условиях
В начале развития LLM Tanh считался стандартом, но проблема затухания градиентов, мотивировала к переходу на GELU. Сейчас уже разработаны методики, в значительной степени эту проблему решающие или обходящие — LayerNorm / RMSNorm, корректная инициализация (Xavier / orthogonal), residual-соединения.
Так что вопрос уже стоит не в невозможности использования Tanh, а в выборе места и способа его использования. Даже если сети с Tanh немного сложнее обучать, потенциальный выигрыш в устойчивости должен это полностью компенсировать.
Для систем, где важна надёжность, устойчивость к шумному контексту и отказоустойчивость (safety-critical или reasoning-ориентированные модели), отказ от Tanh требует пересмотра.
Перспективный подход — гибридная архитектура:
использовать GELU на ранних слоях для извлечения признаков,
использовать Tanh в узких местах сети (bottlenecks, attention-контуры, memory-блоки) для стабилизации и фильтрации аномалий.
Эксперимент показывает, что выбор функции активации существенно влияет на поведение нейросетей в части их устойчивости.
Источник

