![Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 1 Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 1](https://www.pvsm.ru/images/2025/02/10/tvoi-ii-tebya-ponimaet-razbiraem-tainy-vnutrennego-mira-yazykovyh-modelei.jpg)
Современные крупные языковые модели, такие как ChatGPT, Claude или Gemini, поражают своими возможностями. Но главный вопрос остаётся открытым: как именно они думают?
С момента появления открытых LLM метод изучения их
Чтобы разобраться в этом глубже, мы создадим однослойный трансформер с разрежённым автокодировщиком, способный грамотно формулировать текст с соблюдением пунктуации. Затем мы изучим его внутреннюю архитектуру, анализируя, какие нейроны активируются в ответ на различные запросы. Возможно, этот процесс поможет нам обнаружить интересные закономерности. Мы будем опираться на последние исследования Anthropic, что даст нам более чёткое представление о том, как именно происходит «мышление» внутри LLM.
В чём проблема?
Главная сложность при изучении работы языковых моделей — это феномен полисемантичности нейронов: один и тот же нейрон может одновременно реагировать на различные явления — математические уравнения, французскую поэзию, JSON‑код и восклицание «Эврика!» на разных языках. Теперь представьте огромную LLM с миллиардами параметров, где тысячи нейронов активируются одновременно и каждый из них содержит множество разных смыслов. В таких условиях становится практически невозможно понять, как на самом деле модель формирует свои ответы.
![Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 2 Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 2](https://www.pvsm.ru/images/2025/02/10/tvoi-ii-tebya-ponimaet-razbiraem-tainy-vnutrennego-mira-yazykovyh-modelei-2.jpg)
Недавние исследования предложили альтернативный подход: вместо анализа отдельных нейронов можно рассматривать линейные комбинации нейронов, которые кодируют конкретные концепции. Этот метод называется моносемантичностью, а ключевой инструмент для его реализации — разрежённое словарное обучение (sparse dictionary learning, SDL).
Краткий экскурс в нейросети
Скорее всего, вы уже знакомы с принципами работы нейросетей, но давайте быстро напомним основные моменты. Для компьютера языковая модель — это всего лишь набор матриц, которые умножаются и складываются определённым образом. Однако людям удобнее представлять нейросети как системы, состоящие из искусственных нейронов. Обычно они выглядят примерно так:
![Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 3 Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 3](https://www.pvsm.ru/images/2025/02/10/tvoi-ii-tebya-ponimaet-razbiraem-tainy-vnutrennego-mira-yazykovyh-modelei-3.jpg)
Две вертикальные колонки зелёных кругов называются слоями. Когда входной сигнал проходит через сеть слева направо, слои генерируют активации, которые можно представить в виде векторов. Эти векторы показывают, что именно думает модель на основе входных данных, и позволяют нам анализировать работу отдельных нейронов.
Подготовка обучающих данных
Для корректной работы наша модель должна обучаться на разнородных данных, содержащих информацию из разных областей. Оптимальным выбором для этого является датасет The Pile. Его общий объём составляет 825 ГБ, но мы возьмём лишь небольшую его часть — 5%. Сначала загрузим датасет и разберёмся, как он устроен. Мы будем использовать версию, доступную на Hugging Face.
# Загружаем валидационный датасет
!wget https://huggingface.co/datasets/monology/pile-uncopyrighted/resolve/main/val.jsonl.zst
# Загружаем первую часть обучающего датасета
!wget https://huggingface.co/datasets/monology/pile-uncopyrighted/resolve/main/train/00.jsonl.zst
Загрузка займёт некоторое время, но её можно ускорить, ограничив датасет всего одним файлом — 00.jsonl.zst
, вместо трёх. Данные уже разделены на train/val/test — после загрузки убедитесь, что файлы находятся в нужных директориях.
import os
import shutil
import glob
# Определяем структуру каталогов
train_dir = "data/train" # Папка для обучающих данных
val_dir = "data/val" # Папка для валидационных данных
# Создаем папки, если они еще не существуют
os.makedirs(train_dir, exist_ok=True)
os.makedirs(val_dir, exist_ok=True)
# Перемещаем все файлы датасета (например, 00.jsonl.zst, 01.jsonl.zst и т. д.)
train_files = glob.glob("*.jsonl.zst") # Ищем все файлы с нужным расширением
for file in train_files:
if file.startswith("val"):
dest = os.path.join(val_dir, file)
else:
dest = os.path.join(train_dir, file)
shutil.move(file, dest) # Перемещаем файл в нужное место
Файл имеет формат .jsonl.zst
, который представляет собой сжатый архив, удобный для хранения больших объёмов данных. Он содержит файлы в формате JSON Lines (.jsonl
) — где каждая строка отдельный JSON‑объект, — сжатые алгоритмом Zstandard (.zst
). Считаем небольшой фрагмент одного из загруженных файлов, чтобы понять его структуру:
in_file = "data/val/val.jsonl.zst" # Путь к валидационному файлу
with zstd.open(in_file, 'r') as in_f:
for i, line in tqdm(enumerate(in_f)): # Читаем первые 5 строк
data = json.loads(line)
print(f"Строка {i}: {data}") # Выводим данные для проверки
if i == 2:
break
#### ВЫВОД ####
Строка: 0
{
"text": "Влияние качества сна <...> эпилепсией.",
"meta": {
"pile_set_name": "Рефераты PubMed"
}
}
Строка: 1
{
"text": "LLMops — новый репозиторий на GitHub...",
"meta": {
"pile_set_name": "Github"
}
}
Теперь нам нужно закодировать, или токенизировать, наш датасет. Главная цель — научить LLM выдавать текст с правильным написанием слов. Для этого мы используем уже готовый инструмент — tiktoken, опенсорсный токенизатор от OpenAI. Мы будем использовать его вариант r50k_base, который применяется в модели ChatGPT-3.
Чтобы избежать дублирования кода, создадим функцию, которая обработает сразу и обучающий (train), и валидационный (validation) датасеты.
def process_files(input_dir, output_file):
"""
Обрабатывает все .zst-файлы в указанной папке
и сохраняет закодированные токены в HDF5-файл.
Args:
input_dir (str): Папка с входными .zst-файлами.
output_file (str): Путь к выходному HDF5-файлу.
"""
with h5py.File(output_file, 'w') as out_f:
# Создаем расширяемый датасет 'tokens' в HDF5-файле
dataset = out_f.create_dataset('tokens', (0,), maxshape=(None,), dtype='i')
start_index = 0
# Перебираем все .zst-файлы в указанной папке
for filename in sorted(os.listdir(input_dir)):
if filename.endswith(".jsonl.zst"):
in_file = os.path.join(input_dir, filename)
print(f"Обрабатываем: {in_file}")
# Открываем .zst-файл для чтения
with zstd.open(in_file, 'r') as in_f:
# Читаем файл построчно
for line in tqdm(in_f, desc=f"Обработка {filename}"):
# Загружаем строку как JSON
data = json.loads(line)
# Добавляем маркер конца текста и кодируем строку
text = data['text'] + "<|endoftext|>"
encoded = enc.encode(text, allowed_special={'<|endoftext|>'})
encoded_len = len(encoded)
# Определяем конечный индекс для новых токенов
end_index = start_index + encoded_len
# Расширяем размер датасета и записываем закодированные токены
dataset.resize(dataset.shape[0] + encoded_len, axis=0)
dataset[start_index:end_index] = encoded
# Обновляем начальный индекс для следующей порции токенов
start_index = end_index
Отмечу, что мы храним токенизированные данные в формате HDF5, что позволяет значительно ускорить доступ к ним при обучении модели. Добавление токена <|endoftext|>
в конце каждого фрагмента текста даёт модели сигнал о завершении осмысленного контекста, что помогает генерировать связные тексты.
Теперь можем закодировать наши датасеты train и validation:
# Определяем пути для сохранения токенизированных данных
out_train_file = "data/train/pile_train.h5" # Файл для обучающего набора
out_val_file = "data/val/pile_dev.h5" # Файл для валидационного набора
# Загружаем токенизатор GPT-2- или GPT-3-модели
enc = tiktoken.get_encoding('r50k_base')
# Обрабатываем обучающий датасет
process_files(train_dir, out_train_file)
# Обрабатываем валидационный датасет
process_files(val_dir, out_val_file)
Посмотрим, как выглядит образец токенизированных данных:
with h5py.File(out_val_file, 'r') as file:
# Открываем датасет 'tokens'
tokens_dataset = file['tokens']
# Выводим тип данных в датасете
print(f"Тип данных в датасете 'tokens': {tokens_dataset.dtype}")
# Загружаем и выводим первые несколько элементов датасета
print("Первые несколько элементов датасета 'tokens':")
print(tokens_dataset[:10]) # Первые 10 токенов
#### ВЫВОД ####
Тип данных в датасете 'tokens': int32
Первые несколько элементов датасета 'tokens':
[ 2725 6557 83 23105 157 119 229 77 5846 2429]
Рассмотрим однослойные трансформеры
Современные языковые модели практически всегда основаны на архитектуре трансформеров. Углубляться в её устройство не будем, но рассмотрим основные принципы работы:
![Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 4 Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 4](https://www.pvsm.ru/images/2025/02/10/tvoi-ii-tebya-ponimaet-razbiraem-tainy-vnutrennego-mira-yazykovyh-modelei-4.jpg)
В случае языковых моделей трансформер получает на вход строку текста (закодированную в виде последовательности токенов) и выдаёт вероятности возможных следующих токенов. Обычные трансформеры состоят из множества трансформерных блоков, за которыми следует полносвязный слой, однако в нашем исследовании используется упрощённая версия с одним трансформерным блоком — самая простая языковая модель, которую мы всё равно не до конца понимаем.
Внутри трансформерного блока есть два ключевых компонента:
-
Механизм самовнимания (attention mechanism) — определяет, на каких частях входных данных стоит сфокусироваться;
-
Многослойный перцептрон (multi‑layer perceptron, MLP) — выполняет основную обработку информации, то есть, условно говоря, думает. MLP представляет собой простую полносвязную нейросеть, состоящую из входного слоя (получает данные от механизма самовнимания), скрытого слоя (где происходит обработка) и выходного слоя.
Скрытый слой играет ключевую роль в понимании работы модели, поэтому в нашем исследовании мы сосредоточимся именно на нём, игнорируя остальные компоненты. Каждый раз, когда трансформер обрабатывает текст, активации скрытого слоя представляют собой векторы, которые показывают, как модель думает. В дальнейшем мы будем называть их векторами активации MLP или просто векторами активации.
Многослойный перцептрон
Многослойный перцептрон (MLP) — ключевой компонент прямого распространения в архитектуре трансформеров, он позволяет модели выявлять сложные зависимости в данных. MLP состоит из двух основных частей: скрытого слоя, который увеличивает размер входных данных (обычно в четыре раза) и использует функцию активации ReLU
, а также финального слоя, который уменьшает размер обратно до исходного. Функция ReLu
возвращает 0, если входное значение < 0, и возвращает само входное значение, если оно ≥ 0.
Такая структура помогает улучшать представления данных, которые извлекаются механизмом внимания. Параметр n_embed
определяет размер входного вектора.
class MLP(nn.Module):
def __init__(self, n_embed):
super().__init__()
# Определяем простую многослойную нейросеть (MLP)
# с двумя полносвязными (Linear) слоями и функцией активации ReLU
self.net = nn.Sequential(
nn.Linear(n_embed, 4 * n_embed), # Первый слой увеличивает размерность до 4× от исходной
nn.ReLU(), # Функция активации ReLU добавляет нелинейность
nn.Linear(4 * n_embed, n_embed) # Второй слой возвращает размерность обратно
)
def forward(self, x):
# Пропускаем входные данные через сеть MLP
return self.net(x)
Сначала мы определяем последовательную Sequential
‑сеть, состоящую из двух полносвязных Linear
‑слоёв. Первый слой расширяет входной размер n_embed
до 4 * n_embed
, создавая пространство для более сложных преобразований. Функция активации ReLU
вносит нелинейность, помогая модели выявлять сложные закономерности. Второй Linear
‑слой возвращает размерность обратно к n_embed
, что обеспечивает совместимость выходных данных с трансформером.
Механизм внимания
Голова внимания определяет, на какие части входной последовательности модель должна обратить внимание. Ключевой параметр n_embed
определяет размер входных данных для механизма внимания, а context_length
— задаёт длину контекста, необходимую для создания каузальной маски.
Голова внимания использует линейные слои для вычисления ключей, запросов и значений без добавления смещений. Для обеспечения каузального внимания применяется нижнетреугольная матрица, которая предотвращает обработку будущих токенов.
class Attention(nn.Module):
def __init__(self, n_embed, context_length):
super().__init__()
# Полносвязный слой для вычисления матриц запросов (Q), ключей (K) и значений (V)
self.qkv = nn.Linear(n_embed, n_embed * 3, bias=False)
# Создаем нижнетреугольную матрицу для каузальной маскировки
# (запрещает доступ к будущим токенам)
self.tril = torch.tril(torch.ones(context_length, context_length))
def forward(self, x):
B, T, C = x.shape # B = размер пакета, T = длина последовательности, C = размерность эмбеддингов
# Разделяем выход слоя qkv на три матрицы: запросы (q), ключи (k) и значения (v)
q, k, v = self.qkv(x).chunk(3, dim=-1)
# Вычисляем коэффициенты внимания через скалярное произведение запросов и ключей
attn = (q @ k.transpose(-2, -1)) / C**0.5
# Накладываем каузальную маску, чтобы модель не смотрела на будущие токены
attn = attn.masked_fill(self.tril[:T, :T] == 0, float('-inf'))
# Применяем softmax к коэффициентам внимания и вычисляем взвешенные значения
return (F.softmax(attn, dim=-1) @ v)
qkv
— единый линейный слой, который генерирует три матрицы: запрос — q
, ключ — k
и значение — v
. Скалярное произведение q
и k
создаёт коэффициенты внимания, которые затем масштабируются: C**0.5
. Каузальная маска tril
предотвращает обращение к будущим токенам, чтобы модель учитывала только предыдущие токены, что критично для авторегрессивных задач. Также применяется многопеременная логистическая функция, или softmax
, преобразующая коэффициенты внимания в вероятности, которые затем используются для взвешивания значений v
.
Блок трансформера
Блок трансформера представляет собой сочетание механизма внимания и многослойного перцептрона, обёрнутое в нормализацию слоя (LayerNorm
) и остаточные связи, которые помогают стабилизировать обучение. Поскольку в данном случае мы не используем многоголовочное внимание, параметр n_block
, определяющий количество блоков, не требуется.
class TransformerBlock(nn.Module):
def __init__(self, n_embed, context_length):
super().__init__()
# Инициализируем механизм внимания (Attention)
self.attn = Attention(n_embed, context_length)
# Инициализируем многослойный перцептрон
self.mlp = MLP(n_embed)
# Слой нормализации для повышения стабильности обучения
self.ln = nn.LayerNorm(n_embed)
def forward(self, x):
# Применяем LayerNorm и механизм внимания, затем добавляем остаточную связь
x = x + self.attn(self.ln(x))
# Применяем LayerNorm и MLP, снова добавляем остаточную связь
return x + self.mlp(self.ln(x))
LayerNorm
выполняет нормализацию входных данных перед их преобразованием. Сначала применяется механизм внимания, а его выход добавляется обратно к входным данным (остаточная связь), затем применяется многослойный перцептрон с очередной остаточной связью. Эти шаги позволяют стабилизировать процесс обучения и способствуют более эффективному обучению признаков.
Обучение однослойного трансформера
Теперь, когда у нас есть все необходимые компоненты, мы можем приступить к обучению модели. Но перед этим нам нужно интегрировать токенные и позиционные эмбеддинги с блоками трансформера для обработки последовательностей.
class Transformer(nn.Module):
def __init__(self, n_embed, context_length, vocab_size):
super().__init__()
# Встраивание для представления токенов
self.embed = nn.Embedding(vocab_size, n_embed)
# Встраивание для позиционных кодировок
self.pos_embed = nn.Embedding(context_length, n_embed)
# Определение блока трансформера (несколько слоёв внимания и MLP)
self.block = TransformerBlock(n_embed, context_length)
# Финальный линейный слой, который преобразует скрытые состояния
# в пространство словаря для предсказания токенов
self.lm_head = nn.Linear(n_embed, vocab_size)
# Регистрация буфера с индексами позиций
# для их стабильного использования при генерации текста
self.register_buffer('pos_idxs', torch.arange(context_length))
def forward(self, idx):
# Получение векторов вложений для входных токенов и их позиций
x = self.embed(idx) + self.pos_embed(self.pos_idxs[:idx.shape[1]])
# Пропуск входных данных через блок трансформера
x = self.block(x)
# Преобразование выходного представления в вероятности предсказания токенов
return self.lm_head(x)
def generate(self, idx, max_new_tokens):
# Генерация новых токенов по одному, пока не достигнуто заданное количество
for _ in range(max_new_tokens):
# Получение логитов для последнего токена в последовательности
logits = self(idx)[:, -1, :]
# Выбор следующего токена с использованием функции softmax
idx_next = torch.multinomial(F.softmax(logits, dim=-1), 1)
# Добавление предсказанного токена к текущей последовательности
idx = torch.cat((idx, idx_next), dim=1)
# Возврат сгенерированной последовательности токенов
return idx
Теперь, когда мы реализовали самый простой трансформер, следующий шаг — задать параметры обучения.
# Определение размеров словаря и параметров трансформера
VOCAB_SIZE = 50304 # Общее количество уникальных токенов в словаре
BLOCK_SIZE = 512 # Максимальная длина последовательности для модели
N_EMBED = 2048 # Размерность пространства эмбеддингов
N_HEAD = 16 # Число голов внимания в каждом блоке трансформера
# Параметры обучения
BATCH_SIZE = 64 # Размер пакета для обучения
MAX_ITER = 100 # Число итераций (уменьшено для быстрого примера)
LEARNING_RATE = 3e-4 # Скорость обучения
DEVICE = 'cpu' # Устройство для вычислений ('cuda' при наличии GPU)
# Пути к обучающим и проверочным наборам данных
TRAIN_PATH = "data/train/pile_val.h5" # Путь к файлу обучающего набора данных
DEV_PATH = "data/val/pile_val.h5" # Путь к файлу валидационного набора данных
Далее напишем простой цикл обучения и запустим тренировку нашей однослойной модели трансформера, чтобы проверить, пригодна ли она для последующего анализа и отладки.
# Загрузка обучающих данных из HDF5-файла
train_data = h5py.File(TRAIN_PATH, 'r')['tokens']
# Функция для формирования пакета обучения или валидации
def get_batch(split):
# Для простоты используем одни и те же данные (train_data) для обучения и валидации
data = train_data
# Случайный выбор начальных индексов для каждой последовательности в пакете
ix = torch.randint(0, data.shape[0] - BLOCK_SIZE, (BATCH_SIZE,))
# Формирование входных (x) и целевых (y) последовательностей для каждого элемента пакета
x = torch.stack([torch.tensor(data[i:i+BLOCK_SIZE], dtype=torch.long) for i in ix])
y = torch.stack([torch.tensor(data[i+1:i+BLOCK_SIZE+1], dtype=torch.long) for i in ix])
# Перемещение входных и целевых данных на вычислительное устройство (CPU/GPU)
x, y = x.to(DEVICE), y.to(DEVICE)
return x, y
# Инициализация модели трансформера, оптимизатора и функции потерь
model = Transformer(N_EMBED, CONTEXT_LENGTH, VOCAB_SIZE).to(DEVICE) # Определяем модель
optimizer = torch.optim.AdamW(model.parameters(), lr=LEARNING_RATE) # Оптимизатор AdamW
loss_fn = nn.CrossEntropyLoss() # Функция потерь на основе перекрёстной энтропии
# Цикл обучения
for iter in tqdm(range(MAX_ITER)): # Итерации в рамках заданного количества эпох
# Получаем пакет обучающих данных
xb, yb = get_batch('train')
# Прямой проход через модель для получения логитов (предсказаний)
logits = model(xb)
# Преобразуем логиты в нужную форму для вычисления функции потерь
B, T, C = logits.shape # B — размер пакета, T — длина последовательности, C — размер словаря
logits = logits.view(B*T, C) # Объединяем измерения пакета и временной оси
yb = yb.view(B*T) # Аналогично преобразуем целевые метки
# Вычисляем функцию потерь, сравнивая логиты с целевыми значениями
loss = loss_fn(logits, yb)
# Обнуляем градиенты оптимизатора
optimizer.zero_grad()
# Обратное распространение ошибки для вычисления градиентов
loss.backward()
# Обновление параметров модели с помощью оптимизатора
optimizer.step()
# Вывод значения функции потерь каждые 10 итераций
if iter % 10 == 0:
print(f"Итерация {iter}: Потери {loss.item():.4f}")
# Сохранение обученной модели в файл
MODEL_SAVE_PATH = "one_layer_transformer_short.pth" # Определяем путь для сохранения модели
torch.save(model.state_dict(), MODEL_SAVE_PATH) # Сохраняем веса модели
print(f"Модель сохранена в {MODEL_SAVE_PATH}")
print("Обучение завершено.")
Во время обучения модель будет выводить значение функции потерь каждые 10 итераций:
Итерация 0: 0.1511241
Итерация 1: 0.1412412
Итерация 2: 0.1401021
...
Генерация текста
После завершения обучения на 100 эпохах мы можем проверить, насколько наша модель справляется с пунктуацией и грамматикой, сгенерировав образец текста:
# Определяем путь к модели, входной текст и другие параметры
model_path = 'one_layer_transformer_short.pth'
input_text = "|<end_of_text>|"
max_new_tokens = 50
device = 'cuda' # Или 'cpu' при отсутствии GPU
# Загрузка контрольной точки модели
checkpoint = torch.load(model_path, map_location=torch.device(device))
# Инициализация модели с использованием конфигурации из config.py
model = Transformer(
n_head=config['n_head'],
n_embed=config['n_embed'],
context_length=config['context_length'],
vocab_size=config['vocab_size'],
)
# Загрузка весов модели
model.load_state_dict(checkpoint['model_state_dict'])
model.eval().to(device)
# Загрузка токенизатора
enc = tiktoken.get_encoding("r50k_base")
# Кодирование входного текста в последовательность идентификаторов токенов
start_ids = enc.encode_ordinary(input_text)
context = torch.tensor(start_ids, dtype=torch.long, device=device).unsqueeze(0)
# Процесс генерации текста
with torch.no_grad():
generated_tokens = model.generate(context, max_new_tokens=max_new_tokens)[0].tolist()
# Декодирование сгенерированных токенов обратно в текст
output_text = enc.decode(generated_tokens)
print(output_text)
★ "Twisted Roads in Sunset Drive" (A variation of "I’m aware my partner
heads out next week for business")
The Valley's quiet hum, observed by Montgomery.
Across America, various institutions (referencing European models)
John Robinson has delved deeply into these shifts, with
reflections now influencing the broader landscape.
The Leader’s thoughts are yet to be fully captured.
★ "Извилистые дороги на закате" (вариация "Я знаю, что мой партнёр уезжает в командировку на следующей неделе")
Тихий гул долины, замеченный Монтгомери.
По всей Америке различные институты (вдохновлённые европейскими моделями)Джон Робинсон глубоко изучил эти перемены,
и его размышления теперь влияют на общую картину.
Мысли Предводителя пока не зафиксированы полностью.
Полученный текст не отличается смысловой связностью, но его грамматика и пунктуация выглядят корректно, что говорит о достаточном качестве модели для перехода к следующему этапу.
Распределение нейронов
Чтобы понять, как ведут себя нейроны в обученной модели, можно изучить гистограмму плотности их активации.
![Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 5 Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 5](https://www.pvsm.ru/images/2025/02/10/tvoi-ii-tebya-ponimaet-razbiraem-tainy-vnutrennego-mira-yazykovyh-modelei-5.jpg)
Гистограмма показывает, как часто нейроны активируются при подаче входных данных. Мы случайным образом выбрали множество фрагментов текста, зафиксировали активации нейронов и записали, как часто каждый из них срабатывал.
На графике видно, что большинство нейронов активируется более чем в 10% случаев, а около половины — более чем в 25%. Это не очень хорошо с точки зрения интерпретируемости, так как означает, что нейроны реагируют на слишком широкие или пересекающиеся концепции. Для лучшей интерпретации нам нужно, чтобы нейроны отвечали за конкретные, узкие категории, что требует их более редкой и разрежённой активации.
Разрежённый автокодировщик
Чтобы сделать активации более разрежёнными, используется разрежённый автокодировщик. Это отдельная нейросеть, обучаемая параллельно с трансформером. Автокодировщик принимает вектор активации MLP, кодирует его, а затем пытается восстановить. Разрежённость вводится в скрытом слое, чтобы добиться интерпретируемости активаций MLP.
![Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 6 Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 6](https://www.pvsm.ru/images/2025/02/10/tvoi-ii-tebya-ponimaet-razbiraem-tainy-vnutrennego-mira-yazykovyh-modelei-6.jpg)
Автокодировщик состоит из одного скрытого слоя, входной и выходной размеры совпадают с размером вектора активации. Средний слой имеет фиксированный размер — например, 1024 признака, что достаточно для эффективного обучения, но при этом позволяет выделять значимые особенности.
Для принудительного создания разрежённости добавляется L1-регуляризация (сумма модулей активаций) в функцию потерь наряду с MSE (среднеквадратичной ошибкой): это стимулирует нейросеть занулять малые активации, создавая разрежённые представления.
Разрежённое словарное обучение
Исследователи из Anthropic добавили в процесс обучения разрежённое словарное обучение с автокодировщиком, чтобы ещё больше повысить разрежённость активаций.
![Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 7 Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 7](https://www.pvsm.ru/images/2025/02/10/tvoi-ii-tebya-ponimaet-razbiraem-tainy-vnutrennego-mira-yazykovyh-modelei-7.jpg)
Как это работает:
-
Каждая строка матрицы весов декодера нормализуется так, чтобы иметь норму L2, равную 1, и нулевое среднее значение. Это гарантирует, что каждое направление активации является уникальным, подобно элементам словаря.
-
Вектор активации MLP представляется как линейная комбинация этих словарных элементов, а каждая активация в скрытом слое служит коэффициентом для одного из элементов словаря.
В процессе обучения веса декодера приводятся к нулевому среднему, что помогает автокодировщику формировать эффективные разрежённые представления. Итоговая функция потерь включает L1-норму кодированного представления, контролируя уровень разрежённости. Степень разрежённости можно регулировать гиперпараметром.
Давайте теперь реализуем разрежённый автокодировщик с механизмом словарного обучения, а затем проверим, улучшилось ли распределение активаций нейронов:
class SparseAutoencoder(nn.Module):
def __init__(self, n_features, n_embed):
super().__init__()
# Кодировщик: полносвязный слой, преобразующий входные данные в пространство кодировки.
# Размер входа — n_embed * 4, размер выхода — n_features.
self.encoder = nn.Linear(n_embed * 4, n_features)
# Декодировщик: полносвязный слой, восстанавливающий исходные данные из кодировки.
# Размер входа — n_features, размер выхода — n_embed * 4.
self.decoder = nn.Linear(n_features, n_embed * 4)
# Функция активации ReLU, применяемая после кодирования.
self.relu = nn.ReLU()
def encode(self, x_in):
# Вычитание смещения декодера из входных данных для нормализации процесса кодирования.
x = x_in - self.decoder.bias
# Применение кодировщика (линейное преобразование + активация ReLU).
f = self.relu(self.encoder(x))
return f
def forward(self, x_in, compute_loss=False):
# Кодирование входных данных для получения их представления в признаковом пространстве.
f = self.encode(x_in)
# Декодирование признакового представления обратно в исходное пространство.
x = self.decoder(f)
# Если compute_loss = True, вычисляем потери восстановления и регуляризации.
if compute_loss:
# Ошибка восстановления: среднеквадратичное отклонение
# между реконструированным и исходным входом.
recon_loss = F.mse_loss(x, x_in)
# Регуляризационные потери:
# L1-норма закодированных признаков (способствует разрежённости).
reg_loss = f.abs().sum(dim=-1).mean()
else:
# Если compute_loss = False, возвращаем None для обеих функций потерь.
recon_loss = None
reg_loss = None
return x, recon_loss, reg_loss
def normalize_decoder_weights(self):
with torch.no_grad():
# Нормализация весов декодера по признаковому измерению (ось 1).
self.decoder.weight.data = nn.functional.normalize(self.decoder.weight.data, p=2, dim=1)
Функция encode
сначала вычитает смещение декодера перед применением кодера и функции активации ReLU
. Функция forward
выполняет декодирование и рассчитывает два типа потерь: ошибки восстановления и регуляризационные потери. Функция normalize_decoder_weights
гарантирует, что веса декодера имеют единичную норму и нулевое среднее значение.
Цикл обучения
Для цикла обучения Anthropic использует большой набор векторов активации MLP, полученных путём обработки значительной части The Pile через трансформер и сохранения активаций. Эти векторы занимают много памяти: каждый токен весит 4 байта, вектор размером 512 элементов в формате FP16 занимает 1024 байта. Из‑за ограничений по памяти вычисления необходимо выполнять пакетно.
# Цикл по количеству шагов обучения
for _ in range(num_training_steps):
# Получаем пакет входных данных (xb) из итератора пакет, игнорируя метки
xb, _ = next(batch_iterator)
# Отключаем вычисление градиентов, так как модель используется
# только для генерации вложений (без обучения)
with torch.no_grad():
# Получаем векторы вложений (MLP-активации) для входного пакета из модели
x_embedding, _ = model.forward_embedding(xb)
# Обнуляем градиенты оптимизатора перед обратным распространением ошибки
optimizer.zero_grad()
# Пропускаем вложения через автокодировщик
# и вычисляем потери восстановления и регуляризации
outputs, recon_loss, reg_loss = autoencoder(x_embedding, compute_loss=True)
# Добавляем регуляризационный член с коэффициентом масштабирования (lambda_reg)
reg_loss = lambda_reg * reg_loss
# Итоговая функция потерь — сумма потерь восстановления и регуляризационного штрафа
loss = recon_loss + reg_loss
# Обратное распространение ошибки для вычисления градиентов
loss.backward()
# Обновляем веса модели с помощью оптимизатора
optimizer.step()
# Нормализуем веса декодера после каждого шага обучения
autoencoder.normalize_decoder_weights()
После завершения цикла вызывается normalize_decoder_weights
для нормализации весов декодера.
Когда трансформер уже обучен, мы можем запустить этот цикл обучения, чтобы автокодировщик научился формировать разрежённые представления активаций MLP. После этого для нового токенизированного входа можно выполнить следующее:
# Получение признаков из обученной языковой модели
features = autoencoder.encode(transformer.forward_embedding(tokens))
Теперь переменная features
будет содержать разрежённое представление токенизированного текста. Если всё работает правильно, элементы в features
должны быть интерпретируемыми и обладать ясным значением.
Гистограммы плотности
Чтобы убедиться, что признаки действительно разрежённые, необходимо сравнить гистограммы плотности активаций MLP и активаций автокодировщика. Эти графики показывают, как часто нейроны или признаки активируются на выборке входных данных. Сравнив их, можно определить, действительно ли автокодировщик формирует разрежённые представления.
![Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 8 Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 8](https://www.pvsm.ru/images/2025/02/10/tvoi-ii-tebya-ponimaet-razbiraem-tainy-vnutrennego-mira-yazykovyh-modelei-8.jpg)
Активации автокодировщика (жёлтые) значительно реже срабатывают, чем нейроны трансформера (фиолетовые). График имеет логарифмическую шкалу, где с каждым делением‑порядком масштаб уменьшается слева направо, снижая на 90% вероятность активации. В то же время, как мы видим, значительное число признаков всё ещё активируется в чуть более чем 10% случаев, что неидеально: это связано с балансом между увеличением разрежённости и предотвращением появления мёртвых признаков (которые никогда не активируются). Крупный всплеск на левом краю графика указывает на группу признаков с чрезвычайно низкой плотностью активации — ultralow density cluster, как называют его исследователи Anthropic.
Теперь можно сгенерировать вектор признаков для нового входа и проверить, насколько эти признаки интерпретируемы.
Результаты и анализ
Всего было выделено 576 нейронов, что меньше, чем их количество в скрытом слое автокодировщика: это связано с наличием признаков с низкой плотностью активации. Эти малозначимые нейроны практически не влияют на функцию потерь, поэтому я исключил их из анализа для большей ясности.
После отсеивания признаков, которые активировались реже чем в 1 из 10 000 токенов, осталось чуть больше половины от изначального числа. Это по‑прежнему больше, чем количество входных признаков трансформера, что соответствует нашим ожиданиям.
Нейрон 169: активация на неанглийских языках
Признак активируется на постфиксах слов в испанском, французском и португальском, но между тем не на любом тексте на этих языках.
ID токена |
Интенсивность активации |
Токен |
Контекст |
972 |
2,67 |
|
|
29690 |
2,59 |
|
|
28749 |
2,51 |
|
|
8466 |
2,40 |
|
|
24496 |
2,39 |
|
|
9390 |
2,29 |
|
|
64 |
2,28 |
|
|
8591 |
2,22 |
|
|
Таблица показывает, что модель распознаёт языковые паттерны, такие как суффиксы, и обрабатывает их по‑разному, понимая в разных языках части слов, а не только целые слова.
Нейрон 224: алфавитно-цифровые строки
Этот признак активируется на алфавитно‑цифровых строках, особенно шестнадцатеричных и base64-последовательностях, которые часто встречаются в техническом контексте.
ID токена |
Интенсивность активации |
Токен |
Контекст |
405 |
2,30 |
|
|
24 |
2,22 |
|
|
18 |
2,07 |
|
|
1495 |
2,05 |
|
|
24 |
1,80 |
|
|
37452 |
1,69 |
|
|
17 |
1,68 |
|
|
24 |
1,64 |
|
|
Данный нейрон восприимчив к структурированным данным — он не просто распознаёт символы, а идентифицирует закономерности в кодах и шифрах. Фактически модель выделяет скрытые структуры внутри текста, что делает её полезной для обработки технического языка и программирования.
Нейрон 76: возможность
Этот признак активируется при обнаружении модальных глаголов вроде could
, might
, must
, которые выражают возможность или необходимость.
ID токена |
Интенсивность активации |
Токен |
Контекст |
714 |
3,84 |
|
|
460 |
3,68 |
|
|
743 |
3,63 |
|
|
1244 |
3,57 |
|
|
460 |
3,47 |
|
|
1276 |
3,34 |
|
|
460 |
3,21 |
|
|
460 |
3,19 |
|
|
Данные свидетельствуют о том, что модель не просто распознаёт слова, но и понимает их смысловые оттенки, она способна интерпретировать выражения неуверенности и обязательности, что важно для более глубокого анализа текста.
Нейрон 44: математика
Признак срабатывает при встрече дефисов, особенно в контексте отрицания или вычитания: например, not good
(нехорошо) или 5-3
.
ID токена |
Интенсивность активации |
Токен |
Контекст |
532 |
2,36 |
|
|
532 |
2,35 |
|
|
532 |
2,35 |
|
|
532 |
2,33 |
|
|
532 |
2,31 |
|
|
532 |
2,29 |
|
|
532 |
2,22 |
|
|
532 |
2,20 |
|
|
Это говорит о том, что нейрон распознаёт не просто символы, а их роль в контексте — он понимает, когда дефис указывает на математическую операцию, а когда изменяет смысл фразы. Таким образом, модель учитывает структуру текста, анализируя его семантику.
Нейрон 991: медицина
Активируется при обнаружении терминов, связанных с биологией.
ID токена |
Интенсивность активации |
Токен |
Контекст |
9471 |
2,34 |
|
|
6086 |
2,06 |
|
|
1868 |
1,80 |
|
|
4778 |
1,76 |
|
|
20296 |
1,76 |
|
|
7532 |
1,73 |
|
|
39880 |
1,67 |
|
|
2685 |
1,63 |
|
|
Анализ показал, что этот нейрон особенно чувствителен к терминам клеточной и молекулярной биологии, таким как virus
(вирус), protein
(белок), cell
(клетка). Можно сделать вывод, что модель умеет распознавать и обрабатывать сложные научные понятия, и это делает её пригодной для работы с медицинскими и биологическими текстами.
Нейрон 13: un
Этот нейрон реагирует на слова с приставкой un
, вроде undo
(отменить) и unhappy
(несчастный). Модель распознаёт смысловые оттенки отрицания и противопоставления.
ID токена |
Интенсивность активации |
Токен |
Контекст |
555 |
0,45 |
|
|
555 |
0,39 |
|
|
555 |
0,38 |
|
|
555 |
0,36 |
|
|
555 |
0,35 |
|
|
555 |
0,35 |
|
|
555 |
0,34 |
|
|
555 |
0,34 |
|
|
Нейроны 100 и 229: LaTeX
Нейрон 100 реагирует на различные токены, встречающиеся в LaTeX‑выражениях, особенно когда в них присутствуют открывающие скобки {
и [
. Это указывает на способность модели правильно интерпретировать сложные уравнения, выражения и ссылки, встречающиеся в научной литературе.
ID токена |
Интенсивность активации |
Токен |
Контекст |
90 |
2,75 |
|
|
469 |
2,36 |
|
|
58 |
2,33 |
|
|
58 |
2,14 |
|
|
1462 |
2,12 |
|
|
90 |
2,10 |
|
|
58 |
2,05 |
|
|
27 |
1,94 |
|
|
Когда модель встречает математические выражения LaTeX, заключённые в знаки доллара, например $x$
или $N$
, активируется нейрон 229.
ID токена |
Интенсивность активации |
Токен |
Контекст |
87 |
1,81 |
|
|
87 |
1,74 |
|
|
16 |
1,64 |
|
|
17 |
1,62 |
|
|
53 |
1,59 |
|
|
16 |
1,53 |
|
|
45 |
1,51 |
|
|
18 |
1,51 |
|
|
Нейрон 211: Let
Этот признак включается при обработке закрывающей скобки в математических выражениях, начинающихся с Let
, например в конструкциях вида Let f(x) = …
.
ID токена |
Интенсивность активации |
Токен |
Контекст |
8 |
1,35 |
|
|
8 |
1,24 |
|
|
8 |
1,18 |
|
|
8 |
1,18 |
|
|
8 |
1,17 |
|
|
8 |
1,16 |
|
|
8 |
1,16 |
|
|
8 |
1,16 |
|
|
Нейрон 2: принятие решений
Этот нейрон трудно поддаётся однозначной интерпретации, однако он, по‑видимому, активируется в контекстах, связанных с аргументацией или процессом принятия решений. Возможно, анализ более длинных фрагментов текста поможет точнее определить его назначение, но некоторые особенности работы автокодировщика остаются сложными для расшифровки.
ID токена |
Интенсивность активации |
Токен |
Контекст |
20087 |
2,10 |
|
|
2761 |
2,08 |
|
|
4056 |
2,01 |
|
|
3190 |
1,99 |
|
|
3190 |
1,88 |
|
|
5348 |
1,88 |
|
|
1690 |
1,87 |
|
|
4695 |
1,83 |
|
|
Нейрон 3: запятые
Этот нейрон реагирует на запятые в различных контекстах. Он выполняет структурную функцию в тексте, активируясь всякий раз, когда в предложении используется запятая.
ID токена |
Интенсивность активации |
Токен |
Контекст |
11 |
1,48 |
|
|
11 |
1,47 |
|
|
11 |
1,36 |
|
|
11 |
1,29 |
|
|
828 |
1,24 |
|
|
11 |
1,22 |
|
|
11 |
1,20 |
|
|
11 |
1,17 |
|
|
Нейрон 5: математическая интерпретация
Этот нейрон включается в математических контекстах, особенно в учебных материалах, таких как доказательства и экзаменационные вопросы. Он ориентирован на академические и технические тексты, где обсуждаются математические концепции.
ID токена |
Интенсивность активации |
Токен |
Контекст |
356 |
2,62 |
|
|
356 |
2,44 |
|
|
13 |
2,41 |
|
|
198 |
2,33 |
|
|
737 |
2,32 |
|
|
13 |
2,22 |
|
|
356 |
2,21 |
|
|
13 |
2,16 |
|
|
Что показывают результаты?
Мы словно заглянули под микроскопом в
Чтобы навести порядок в этом шуме, мы применили изящную технику — разрежённый автокодировщик, и это сработало: внезапно некоторые нейроны начали чётко специализироваться на определённых понятиях. Например, один из них активировался исключительно при встрече испанских суффиксов, другой реагировал на шестнадцатеричный код, третий включался при словах‑маркерах вероятности вроде could
и must
. Более того, обнаружились нейроны, которые распознают математические выражения в LaTeX, а один даже мог быть связан с логическим обоснованием доводов — намёк на зачатки убедительной аргументации.
Некоторые из этих находок пока остаются расплывчатыми, но это огромный шаг вперёд: теперь перед нами не просто гигантская непостижимая матрица чисел, а набор специализированных функций, словно у LLM есть крошечные цепи, отвечающие за разные типы знаний.
Исследование проводилось на сравнительно маленькой модели, но оно доказывает важную вещь: понять, как думает LLM, вполне возможно. Это сложная задача, но её можно решать — шаг за шагом, нейрон за нейроном. И возможно, с помощью таких методов, как разрежённое словарное обучение, мы однажды сможем полностью картировать
Однако перед нами ещё множество вопросов. Можно ли научиться объяснять каждое решение модели так, чтобы оно было прозрачным? Жду ваши комментарии.
Чтобы поэкспериментировать с нейросетями, загляните в наш агрегатор нейросетей BotHub — у нас собраны лучшие модели, готовые к запуску сразу после регистрации. ChatGPT‑o1-mini, ChatGPT‑o3-mini, DeepSeek‑r1, а также Dalle-3, Midjourney и другие топовые модели — без ВПН и сложностей. Ну а если хотите обсудить тонкости работы ИИ, получить советы и инсайты — присоединяйтесь к нашему сообществу BotHub Community, мы там делимся лайфхаками, обсуждаем новинки и тестируем нейросети. Не забудьте заглянуть в телеграм‑канал, а также блог фаундера — там тоже много интересного.
Автор: dmitrifriend