Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей

в 7:55, , рубрики: AI, python, ИИ, полисемантичность, разрежённый автокодировщик, трансформеры
Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 1

Современные крупные языковые модели, такие как ChatGPT, Claude или Gemini, поражают своими возможностями. Но главный вопрос остаётся открытым: как именно они думают?

С момента появления открытых LLM метод изучения их мышления был довольно прост: разобрать их архитектуру, ведь каждая такая модель состоит из нейронов. Анализ их работы означает исследование того, какие именно нейроны активируются при заданном вводе. Например, если пользователь спрашивает: «Что такое звук?» — можно выяснить, какие именно нейроны включаются при формировании ответа. Так мы получаем возможность буквально заглянуть в процесс мышления нейросети.

Чтобы разобраться в этом глубже, мы создадим однослойный трансформер с разрежённым автокодировщиком, способный грамотно формулировать текст с соблюдением пунктуации. Затем мы изучим его внутреннюю архитектуру, анализируя, какие нейроны активируются в ответ на различные запросы. Возможно, этот процесс поможет нам обнаружить интересные закономерности. Мы будем опираться на последние исследования Anthropic, что даст нам более чёткое представление о том, как именно происходит «мышление» внутри LLM.

В чём проблема?

Главная сложность при изучении работы языковых моделей — это феномен полисемантичности нейронов: один и тот же нейрон может одновременно реагировать на различные явления — математические уравнения, французскую поэзию, JSON‑код и восклицание «Эврика!» на разных языках. Теперь представьте огромную LLM с миллиардами параметров, где тысячи нейронов активируются одновременно и каждый из них содержит множество разных смыслов. В таких условиях становится практически невозможно понять, как на самом деле модель формирует свои ответы.

Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 2

Недавние исследования предложили альтернативный подход: вместо анализа отдельных нейронов можно рассматривать линейные комбинации нейронов, которые кодируют конкретные концепции. Этот метод называется моносемантичностью, а ключевой инструмент для его реализации — разрежённое словарное обучение (sparse dictionary learning, SDL).

Краткий экскурс в нейросети

Скорее всего, вы уже знакомы с принципами работы нейросетей, но давайте быстро напомним основные моменты. Для компьютера языковая модель — это всего лишь набор матриц, которые умножаются и складываются определённым образом. Однако людям удобнее представлять нейросети как системы, состоящие из искусственных нейронов. Обычно они выглядят примерно так:

Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 3

Две вертикальные колонки зелёных кругов называются слоями. Когда входной сигнал проходит через сеть слева направо, слои генерируют активации, которые можно представить в виде векторов. Эти векторы показывают, что именно думает модель на основе входных данных, и позволяют нам анализировать работу отдельных нейронов.

Подготовка обучающих данных

Для корректной работы наша модель должна обучаться на разнородных данных, содержащих информацию из разных областей. Оптимальным выбором для этого является датасет 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

В случае языковых моделей трансформер получает на вход строку текста (закодированную в виде последовательности токенов) и выдаёт вероятности возможных следующих токенов. Обычные трансформеры состоят из множества трансформерных блоков, за которыми следует полносвязный слой, однако в нашем исследовании используется упрощённая версия с одним трансформерным блоком — самая простая языковая модель, которую мы всё равно не до конца понимаем.

Внутри трансформерного блока есть два ключевых компонента:

  • Механизм самовнимания (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

Гистограмма показывает, как часто нейроны активируются при подаче входных данных. Мы случайным образом выбрали множество фрагментов текста, зафиксировали активации нейронов и записали, как часто каждый из них срабатывал.

На графике видно, что большинство нейронов активируется более чем в 10% случаев, а около половины — более чем в 25%. Это не очень хорошо с точки зрения интерпретируемости, так как означает, что нейроны реагируют на слишком широкие или пересекающиеся концепции. Для лучшей интерпретации нам нужно, чтобы нейроны отвечали за конкретные, узкие категории, что требует их более редкой и разрежённой активации.

Разрежённый автокодировщик

Чтобы сделать активации более разрежёнными, используется разрежённый автокодировщик. Это отдельная нейросеть, обучаемая параллельно с трансформером. Автокодировщик принимает вектор активации MLP, кодирует его, а затем пытается восстановить. Разрежённость вводится в скрытом слое, чтобы добиться интерпретируемости активаций MLP.

Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 6

Автокодировщик состоит из одного скрытого слоя, входной и выходной размеры совпадают с размером вектора активации. Средний слой имеет фиксированный размер — например, 1024 признака, что достаточно для эффективного обучения, но при этом позволяет выделять значимые особенности.

Для принудительного создания разрежённости добавляется L1-регуляризация (сумма модулей активаций) в функцию потерь наряду с MSE (среднеквадратичной ошибкой): это стимулирует нейросеть занулять малые активации, создавая разрежённые представления.

Разрежённое словарное обучение

Исследователи из Anthropic добавили в процесс обучения разрежённое словарное обучение с автокодировщиком, чтобы ещё больше повысить разрежённость активаций.

Твой ИИ тебя понимает? Разбираем тайны внутреннего мира языковых моделей - 7

Как это работает:

  • Каждая строка матрицы весов декодера нормализуется так, чтобы иметь норму 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

Активации автокодировщика (жёлтые) значительно реже срабатывают, чем нейроны трансформера (фиолетовые). График имеет логарифмическую шкалу, где с каждым делением‑порядком масштаб уменьшается слева направо, снижая на 90% вероятность активации. В то же время, как мы видим, значительное число признаков всё ещё активируется в чуть более чем 10% случаев, что неидеально: это связано с балансом между увеличением разрежённости и предотвращением появления мёртвых признаков (которые никогда не активируются). Крупный всплеск на левом краю графика указывает на группу признаков с чрезвычайно низкой плотностью активации — ultralow density cluster, как называют его исследователи Anthropic.

Теперь можно сгенерировать вектор признаков для нового входа и проверить, насколько эти признаки интерпретируемы.

Результаты и анализ

Всего было выделено 576 нейронов, что меньше, чем их количество в скрытом слое автокодировщика: это связано с наличием признаков с низкой плотностью активации. Эти малозначимые нейроны практически не влияют на функцию потерь, поэтому я исключил их из анализа для большей ясности.

После отсеивания признаков, которые активировались реже чем в 1 из 10 000 токенов, осталось чуть больше половины от изначального числа. Это по‑прежнему больше, чем количество входных признаков трансформера, что соответствует нашим ожиданиям.

Нейрон 169: активация на неанглийских языках

Признак активируется на постфиксах слов в испанском, французском и португальском, но между тем не на любом тексте на этих языках.

ID токена

Интенсивность активации

Токен

Контекст

972

2,67

ement

isation du Gouvernement.n

29690

2,59

ía

a. Ofrecía, en tal sent

28749

2,51

ão

ela Organização das Naç

8466

2,40

lla

uelta en ella.nPero

24496

2,39

idas

ões Unidas para a Educa

9390

2,29

uto

siempre diminuto comparado con la

64

2,28

a

de Burr, prima carnal de Clint

8591

2,22

la

la memoria, la «microfis

Таблица показывает, что модель распознаёт языковые паттерны, такие как суффиксы, и обрабатывает их по‑разному, понимая в разных языках части слов, а не только целые слова.

Нейрон 224: алфавитно-цифровые строки

Этот признак активируется на алфавитно‑цифровых строках, особенно шестнадцатеричных и base64-последовательностях, которые часто встречаются в техническом контексте.

ID токена

Интенсивность активации

Токен

Контекст

405

2,30

00

28732c8700b66becaf

24

2,22

9

f14dfe9b6f71

18

2,07

3

23f08c3e16f14

1495

2,05

25

ZSBDb25zdHJH

24

1,80

9

8e1f9e42d128

37452

1,69

576

0b3f576bn0,

17

1,68

2

91e53a2aafd71

24

1,64

9

LW5u9jFbB

Данный нейрон восприимчив к структурированным данным — он не просто распознаёт символы, а идентифицирует закономерности в кодах и шифрах. Фактически модель выделяет скрытые структуры внутри текста, что делает её полезной для обработки технического языка и программирования.

Нейрон 76: возможность

Этот признак активируется при обнаружении модальных глаголов вроде could, might, must, которые выражают возможность или необходимость.

ID токена

Интенсивность активации

Токен

Контекст

714

3,84

could

a car, there could be denied. Meanwhile

460

3,68

can

basic configuration, you can install and run Elastic

743

3,63

may

so the release itself may not be cause for

1244

3,57

might

iani admits the idea might not be realistic,

460

3,47

can

are better as they can encapsulate the validation

1276

3,34

must

, all community members must respect all applicable copyright

460

3,21

can

whether or not you can be perceived as

460

3,19

can

head means that it can be used as an

Данные свидетельствуют о том, что модель не просто распознаёт слова, но и понимает их смысловые оттенки, она способна интерпретировать выражения неуверенности и обязательности, что важно для более глубокого анализа текста.

Нейрон 44: математика

Признак срабатывает при встрече дефисов, особенно в контексте отрицания или вычитания: например, not good (нехорошо) или 5-3.

ID токена

Интенсивность активации

Токен

Контекст

532

2,36

-

= 59/3 - 20. Suppose 0

532

2,35

-

= 299/5 - 61. Suppose 5

532

2,35

-

**6/144 - d**5/

532

2,33

-

1997. Solve -d*w +

532

2,31

-

'). Solve 2019 - b = -11

532

2,29

-

**3 - 40*w**

532

2,22

-

Let p = 2 - 0. Calculate

532

2,20

-

nLet v = -56/5 -

Это говорит о том, что нейрон распознаёт не просто символы, а их роль в контексте — он понимает, когда дефис указывает на математическую операцию, а когда изменяет смысл фразы. Таким образом, модель учитывает структуру текста, анализируя его семантику.

Нейрон 991: медицина

Активируется при обнаружении терминов, связанных с биологией.

ID токена

Интенсивность активации

Токен

Контекст

9471

2,34

virus

's and thereby inhibits virus attachment and entry'

6086

2,06

oma

particularly basal cell carcinoma and medullobl

1868

1,80

oid

`eicosanoid production.<

4778

1,76

cells

than those documented in cells, such as micro

20296

1,76

organisms

eukaryotic organisms are highly organised within

7532

1,73

protein

is the only structural protein and main antigen of

39880

1,67

embryo

specific function in the embryo, and for which

2685

1,63

cell

in the endothelial cell lining of the end

Анализ показал, что этот нейрон особенно чувствителен к терминам клеточной и молекулярной биологии, таким как virus (вирус), protein (белок), cell (клетка). Можно сделать вывод, что модель умеет распознавать и обрабатывать сложные научные понятия, и это делает её пригодной для работы с медицинскими и биологическими текстами.

Нейрон 13: un

Этот нейрон реагирует на слова с приставкой un, вроде undo (отменить) и unhappy (несчастный). Модель распознаёт смысловые оттенки отрицания и противопоставления.

ID токена

Интенсивность активации

Токен

Контекст

555

0,45

un

to whichnthe uncollected overiss

555

0,39

un

and of handling an unruly mob are among

555

0,38

un

All surgeries were uneventful without intra

555

0,36

un

taking care to not unsettle the dirt

555

0,35

un

committed, but serves unerringly to

555

0,35

un

on the record is unimportant, so that

555

0,34

un

the time needed to unload $1.

555

0,34

un

act occurred where the uncharged misconduct and the

Нейроны 100 и 229: LaTeX

Нейрон 100 реагирует на различные токены, встречающиеся в LaTeX‑выражениях, особенно когда в них присутствуют открывающие скобки { и [. Это указывает на способность модели правильно интерпретировать сложные уравнения, выражения и ссылки, встречающиеся в научной литературе.

ID токена

Интенсивность активации

Токен

Контекст

90

2,75

{

\}$$, ${r_1,

469

2,36

ge

_{{xi\geq 0}} }

58

2,33

[

\]] and \[thm:local

58

2,14

[

with the substrate \[^14^C

1462

2,12

to

isms $Z$\to A$ with $

90

2,10

{

)^{\frac{1}{3}{\s

58

2,05

[

side of (\[eq: S-

27

1,94

<

*p* \< 0.01;

Когда модель встречает математические выражения LaTeX, заключённые в знаки доллара, например $x$ или $N$, активируется нейрон 229.

ID токена

Интенсивность активации

Токен

Контекст

87

1,81

x

small values of $x$. Since only part

87

1,74

x

has dimensions of $x,$ i.

16

1,64

1

M_D=1.87$ Ge

17

1,62

2

$ has exactly $2p$ zero Ly

53

1,59

V

the size of $V^c_U$

16

1,53

1

) greater than $1$

45

1,51

N

we have varied $N$, $M$,

18

1,51

3

For $k=3$: $$

Нейрон 211: Let

Этот признак включается при обработке закрывающей скобки в математических выражениях, начинающихся с Let, например в конструкциях вида Let f(x) = ….

ID токена

Интенсивность активации

Токен

Контекст

8

1,35

)

Let l(f) = -689*

8

1,24

)

Let o(n) be the first derivative

8

1,18

)

Let g(x) = 3*x

8

1,18

)

Let s(h) = -10*

8

1,17

)

Let l(o) = -4*

8

1,16

)

Let r(f) = 15*f

8

1,16

)

Let t(r) = -r**

8

1,16

)

Let o(m) = -5 -

Нейрон 2: принятие решений

Этот нейрон трудно поддаётся однозначной интерпретации, однако он, по‑видимому, активируется в контекстах, связанных с аргументацией или процессом принятия решений. Возможно, анализ более длинных фрагментов текста поможет точнее определить его назначение, но некоторые особенности работы автокодировщика остаются сложными для расшифровки.

ID токена

Интенсивность активации

Токен

Контекст

20087

2,10

stimulation

that is rich in stimulation. Curiousity will

2761

2,08

problems

" you're making up problems where there are none"

4056

2,01

programs

they believed zoo breeding programs using best practice can

3190

1,99

completely

mitting you are not completely sure why you follow

3190

1,88

completely

the funds together to completely pay it off before

5348

1,88

communities

a diverse mix of communities, individuals and interests

1690

1,87

often

and consensus . We often have confidence that there

4695

1,83

animals

way to save endangered animals was to protect the

Нейрон 3: запятые

Этот нейрон реагирует на запятые в различных контекстах. Он выполняет структурную функцию в тексте, активируясь всякий раз, когда в предложении используется запятая.

ID токена

Интенсивность активации

Токен

Контекст

11

1,48

,

National Labor Relations Act, Precision bears the burden

11

1,47

,

a conviction and sentence, including the initial forfeiture

11

1,36

,

assessment of its conduct, contending only that

11

1,29

,

to dismiss her case, it could not have

828

1,24

),

charged as a conspiracy), all reasonably foreseeable acts

11

1,22

,

increase to $10,000 per month.

11

1,20

,

a party in interest, and after notice and

11

1,17

,

benset aside, the Board affirmed the

Нейрон 5: математическая интерпретация

Этот нейрон включается в математических контекстах, особенно в учебных материалах, таких как доказательства и экзаменационные вопросы. Он ориентирован на академические и технические тексты, где обсуждаются математические концепции.

ID токена

Интенсивность активации

Токен

Контекст

356

2,62

we

dx]. Even though we are zooming close

356

2,44

we

\] for which we can derive a closed

13

2,41

.

-2 - g. List the prime factors

198

2,33

n

4890153?n-5868185

737

2,32

).

2/(-9).n1008n

13

2,22

.

*p + 212. Calculate the least

356

2,21

we

ation. Thus, we could be sure at

13

2,16

.

7 = -23. What is the highest

Что показывают результаты?

Мы словно заглянули под микроскопом в мозг нашей миниатюрной LLM, чтобы понять, как она думает, но вместо стройных, логически упорядоченных мыслей обнаружили хаос: отдельные нейроны не отвечали за конкретные концепции, а одновременно активировались в ответ на самые разные идеи. Это то самое явление полисемантичности, и по ощущениям оно было похоже на попытку разобраться в устройстве города, слушая тысячи голосов, кричащих одновременно.

Чтобы навести порядок в этом шуме, мы применили изящную технику — разрежённый автокодировщик, и это сработало: внезапно некоторые нейроны начали чётко специализироваться на определённых понятиях. Например, один из них активировался исключительно при встрече испанских суффиксов, другой реагировал на шестнадцатеричный код, третий включался при словах‑маркерах вероятности вроде could и must. Более того, обнаружились нейроны, которые распознают математические выражения в LaTeX, а один даже мог быть связан с логическим обоснованием доводов — намёк на зачатки убедительной аргументации.

Некоторые из этих находок пока остаются расплывчатыми, но это огромный шаг вперёд: теперь перед нами не просто гигантская непостижимая матрица чисел, а набор специализированных функций, словно у LLM есть крошечные цепи, отвечающие за разные типы знаний.

Исследование проводилось на сравнительно маленькой модели, но оно доказывает важную вещь: понять, как думает LLM, вполне возможно. Это сложная задача, но её можно решать — шаг за шагом, нейрон за нейроном. И возможно, с помощью таких методов, как разрежённое словарное обучение, мы однажды сможем полностью картировать мозг языковой модели и раскрыть неожиданные способы, которыми ИИ структурирует знания.


Однако перед нами ещё множество вопросов. Можно ли научиться объяснять каждое решение модели так, чтобы оно было прозрачным? Жду ваши комментарии.

Чтобы поэкспериментировать с нейросетями, загляните в наш агрегатор нейросетей BotHub — у нас собраны лучшие модели, готовые к запуску сразу после регистрации. ChatGPT‑o1-mini, ChatGPT‑o3-mini, DeepSeek‑r1, а также Dalle-3, Midjourney и другие топовые модели — без ВПН и сложностей. Ну а если хотите обсудить тонкости работы ИИ, получить советы и инсайты — присоединяйтесь к нашему сообществу BotHub Community, мы там делимся лайфхаками, обсуждаем новинки и тестируем нейросети. Не забудьте заглянуть в телеграм‑канал, а также блог фаундера — там тоже много интересного.

Автор: dmitrifriend

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js