MicroGPT: Полное руководство по GPT для начинающих с нуля — Блог
$ cat learn/blogopost/detailed-microgpt-analysis.md

MicroGPT: Полное руководство по GPT для начинающих с нуля

Введение: Что такое GPT и зачем нужен MicroGPT?

Представьте, что вы хотите научить компьютер писать тексты, как человек. Не программировать, а именно писать — рассказы, письма, даже имена. GPT (Generative Pre-trained Transformer) — это именно такая модель, которая обучена на огромных текстах и умеет генерировать продолжения.

Но промышленные GPT (как GPT-3 или GPT-4) — это сложнейшие системы с миллиардами параметров, распределённые на сотнях GPU, написанные на C++ и Python с кучей оптимизаций. Как понять, что внутри? Как увидеть суть без всей этой сложности?

MicroGPT — ответ. Это 200 строк чистого Python от Андрея Карпати, который показывает GPT «в голом виде». Никаких библиотек, только базовые математические операции. Если вы хотите понять, а не просто использовать — это идеальный старт.

«Наиболее атомарный способ обучения и выполнения вывода для GPT. Всё остальное — просто эффективность.» — @karpathy

В этой статье мы пройдём по каждой части MicroGPT, объясним каждый термин, приведём аналогии из жизни и покажем соответствующий код.


1. Автоград: Как модель «учится на ошибках»?

Определение: Автоматическое дифференцирование (Autograd) — это техника, которая позволяет компьютеру автоматически вычислять производные (градиенты) сложных функций. В нейронных сетях градиенты показывают, «в какую сторону двигать» каждый параметр, чтобы уменьшить ошибку.

Пример из жизни: Представьте, что вы в тёмной комнате и хотите найти выход. Вы делаете шаг вперёд — становится светлее (ошибка уменьшилась). Шаг назад — темнее (ошибка увеличилась). Автоград — это ваш внутренний компас, который после каждого шага говорит: «Иди туда, где светлее».

Код MicroGPT (строки 30–73):

class Value:
    __slots__ = ('data', 'grad', '_children', '_local_grads')

    def __init__(self, data, children=(), local_grads=()):
        self.data = data                # значение узла
        self.grad = 0                   # градиент (производная)
        self._children = children       # дочерние узлы
        self._local_grads = local_grads # локальные производные

    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        # Локальная производная сложения: 1 по каждому аргументу
        return Value(self.data + other.data, (self, other), (1, 1))

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        # Локальная производная умножения: other.data по self, self.data по other
        return Value(self.data * other.data, (self, other), (other.data, self.data))

    def backward(self):
        # Топологическая сортировка графа
        topo = []
        visited = set()
        def build_topo(v):
            if v not in visited:
                visited.add(v)
                for child in v._children:
                    build_topo(child)
                topo.append(v)
        build_topo(self)
        
        # Обратное распространение
        self.grad = 1
        for v in reversed(topo):
            for child, local_grad in zip(v._children, v._local_grads):
                child.grad += local_grad * v.grad

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

  • Каждая математическая операция (+, *, log, exp, relu) создаёт новый узел Value.
  • Узел хранит data (результат), ссылки на детей и локальные производные (как результат зависит от детей).
  • При вызове loss.backward() граф обходится от loss назад, и градиенты накапливаются по цепному правилу.

Простой пример:

a = Value(2.0)
b = Value(3.0)
c = a * b  # 6.0
d = c + 1  # 7.0
d.backward()
print(a.grad)  # 3.0 (производная d по a = b)
print(b.grad)  # 2.0 (производная d по b = a)

2. Токенизация: Как превратить текст в числа?

Определение: Токенизация — процесс разбиения текста на части (токены) и присвоения им числовых идентификаторов. В MicroGPT токены — это отдельные символы.

Пример из жизни: Представьте, что вы учите иностранный язык. Сначала вы запоминаете алфавит (символы), потом учитесь складывать буквы в слова. Токенизация — это создание словаря: «а» → 1, «б» → 2, и т.д.

Код MicroGPT (строки 24–27, 156–157):

# Уникальные символы из датасета имён
uchars = sorted(set(''.join(docs)))  # например: ['a', 'b', 'c', ... 'z']
BOS = len(uchars)  # специальный токен начала последовательности
vocab_size = len(uchars) + 1  # размер словаря

# Токенизация имени "alice"
doc = "alice"
tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]
# BOS имеет id=26 (если 25 букв), 'a'→0, 'l'→11, 'i'→8, 'c'→2, 'e'→4
# Результат: [26, 0, 11, 8, 2, 4, 26]

Почему BOS? Специальный токен Beginning of Sequence отмечает начало и конец последовательности. Это как сказать модели: «Вот где начинается текст» и «Вот где он заканчивается».


3. Эмбеддинги: Как представить слова как векторы?

Определение: Эмбеддинг — это представление дискретного объекта (токена, позиции) в виде вектора чисел. Вектор «захватывает» смысл объекта в многомерном пространстве.

Пример из жизни: Представьте, что вы описываете фрукты по трём характеристикам: сладость (0–10), кислинка (0–10), твёрдость (0–10). Яблоко → [7, 3, 6], лимон → [2, 9, 5]. Это 3D-эмбеддинги фруктов!

Код MicroGPT (строки 80–81, 109–111):

# Матрица токенных эмбеддингов: vocab_size × n_embd
wte = matrix(vocab_size, n_embd)  # wte[token_id] — вектор размерности n_embd

# Матрица позиционных эмбеддингов: block_size × n_embd  
wpe = matrix(block_size, n_embd)  # wpe[pos_id] — вектор для позиции

# В функции gpt:
tok_emb = state_dict['wte'][token_id]  # эмбеддинг токена
pos_emb = state_dict['wpe'][pos_id]    # эмбеддинг позиции
x = [t + p for t, p in zip(tok_emb, pos_emb)]  # складываем

Что происходит:

  • Каждый токен (например, буква «а») получает вектор из n_embd=16 чисел.
  • Каждая позиция (например, 1-я буква в слове) тоже получает вектор.
  • Они складываются — так модель знает и что (токен), и где (позиция).

Аналогия: Представьте, что вы на вечеринке. У каждого гостя (токена) есть характер (токенный эмбеддинг). Но его поведение также зависит от того, где он стоит: у стойки бара (позиция 1) или в углу (позиция 10). Позиционный эмбеддинг — это «контекст места».


4. Внимание: Как модель «фокусируется» на важном?

Определение: Механизм внимания позволяет модели при генерации каждого следующего токена «смотреть» на предыдущие токены и решать, какие из них важнее. В многоголовом внимании несколько таких «взглядов» работают параллельно.

Пример из жизни: Читая предложение «Яблоко упало с дерева, которое было высоким», вы интуитивно связываете «которое» с «дерева», а не с «яблоко». Внимание — это формализация этой интуиции.

Код MicroGPT (строки 116–134):

# Линейные проекции для запроса, ключа, значения
q = linear(x, state_dict[f'layer{li}.attn_wq'])  # query — что ищем
k = linear(x, state_dict[f'layer{li}.attn_wk'])  # key — что предлагаем
v = linear(x, state_dict[f'layer{li}.attn_wv'])  # value — что возвращаем

# Для каждого «взгляда» (head)
for h in range(n_head):
    hs = h * head_dim
    q_h = q[hs:hs+head_dim]  # часть вектора запроса для этого head
    k_h = [ki[hs:hs+head_dim] for ki in keys[li]]  # ключи
    v_h = [vi[hs:hs+head_dim] for vi in values[li]]  # значения
    
    # Скалярное произведение: насколько запрос похож на каждый ключ
    attn_logits = [sum(q_h[j] * k_h[t][j] for j in range(head_dim)) / head_dim**0.5 
                   for t in range(len(k_h))]
    
    # Softmax превращает похожесть в веса (в сумме = 1)
    attn_weights = softmax(attn_logits)
    
    # Взвешенная сумма значений
    head_out = [sum(attn_weights[t] * v_h[t][j] for t in range(len(v_h))) 
                for j in range(head_dim)]
    x_attn.extend(head_out)

Пошагово:

  1. Запрос (Query): «Что я хочу узнать?» (текущий токен).
  2. Ключ (Key): «Что я могу предложить?» (все предыдущие токены).
  3. Значение (Value): «Что я скажу, если спросят про это?» (информация токена).
  4. Сходство: Скалярное произведение запроса и ключа показывает, насколько они связаны.
  5. Веса: Softmax превращает сходства в вероятности внимания.
  6. Итог: Взвешенная сумма значений — это то, что «увидел» head.

Аналогия с поиском в Google:

  • Запрос — что вы вводите в поиск.
  • Ключи — заголовки веб-страниц.
  • Значения — содержимое страниц.
  • Внимание — ранжирование результатов: чем релевантнее заголовок (больше скалярное произведение), тем больше «вес» у страницы в ответе.

Зачем несколько head? Каждый head может научиться фокусироваться на разных аспектах: один — на грамматике, другой — на смысле, третий — на стиле.


5. MLP и остаточные связи: «Мозг» трансформера

Определение: MLP (Multilayer Perceptron) — простейшая нейронная сеть из полносвязных слоёв. Остаточные связи (Residual connections) пропускают вход слоя напрямую к его выходу, что помогает градиентам лучше распространяться.

Пример из жизни: MLP — как сотрудник, который получает задачу (вектор), думает над ней (нелинейное преобразование) и выдаёт решение. Остаточная связь — как если бы он сказал: «Я подумал и решил, что исходный вариант был почти правильным, вот небольшая корректировка».

Код MicroGPT (строки 135–141):

# MLP блок
x_residual = x  # сохраняем вход
x = rmsnorm(x)  # нормализуем
x = linear(x, state_dict[f'layer{li}.mlp_fc1'])  # расширяем в 4 раза (16 → 64)
x = [xi.relu() for xi in x]  # нелинейность ReLU
x = linear(x, state_dict[f'layer{li}.mlp_fc2'])  # сжимаем обратно (64 → 16)
x = [a + b for a, b in zip(x, x_residual)]  # остаточная связь

Что делает MLP?

  1. Расширение: Увеличивает размерность в 4 раза (с n_embd=16 до 64). Это даёт пространство для сложных преобразований.
  2. Нелинейность (ReLU): max(0, x) — позволяет модели учить нелинейные закономерности.
  3. Сжатие: Возвращает к исходной размерности.
  4. Остаток: Добавляет исходный вход — так модель учится поправкам, а не полностью новому представлению.

Зачем ReLU? Без нелинейности многослойная сеть была бы просто линейным преобразованием — как складывать линейные уравнения, в итоге получится тоже линейное уравнение. ReLU (и другие активации) добавляют «изгибы», что позволяет аппроксимировать любую функцию.


6. RMSNorm: Стабилизируем обучение

Определение: RMSNorm (Root Mean Square Normalization) — упрощённый вариант LayerNorm. Нормализует вектор так, чтобы его среднеквадратичное значение было равно 1.

Пример из жизни: Представьте, что вы настраиваете громкость на разных устройствах. RMSNorm — как автоматическая регулировка громкости: она делает так, чтобы средняя «громкость» активаций была одинаковой, независимо от входных данных.

Код MicroGPT (строки 103–106):

def rmsnorm(x):
    ms = sum(xi * xi for xi in x) / len(x)  # средний квадрат
    scale = (ms + 1e-5) ** -0.5  # 1 / sqrt(ms + маленькое число для стабильности)
    return [xi * scale for xi in x]  # масштабируем

Зачем это нужно? Без нормализации активации могут «взрываться» (становиться слишком большими) или «затухать» (становиться слишком маленькими) по мере прохождения через слои. Нормализация стабилизирует обучение.


7. Softmax и loss: Как модель «ошибается» и учится

Определение: Softmax превращает произвольные числа (логиты) в вероятности (сумма = 1). Loss (функция потерь) измеряет, насколько предсказание модели отличается от истины.

Пример из жизни: На экзамене у вас есть сырые баллы (логиты). Преподаватель применяет softmax, чтобы превратить их в итоговые оценки (вероятности). Ваш loss — это насколько ваша ожидаемая оценка отличается от реальной.

Код MicroGPT (строки 97–101, 167–169):

def softmax(logits):
    max_val = max(val.data for val in logits)  # для численной стабильности
    exps = [(val - max_val).exp() for val in logits]  # экспоненты
    total = sum(exps)  # сумма экспонент
    return [e / total for e in exps]  # нормализуем

# Вычисление loss для одного токена
probs = softmax(logits)  # вероятности для каждого токена словаря
loss_t = -probs[target_id].log()  # -log(p(правильный_токен))

Что такое -log(p)? Это отрицательное логарифмическое правдоподобие (кросс-энтропия).

  • Если модель уверена в правильном токене (p ≈ 1), -log(p) ≈ 0 — маленькая ошибка.
  • Если модель не уверена (p ≈ 0), -log(p) большое — большая ошибка.
  • Если модель уверена в неправильном токене (p(правильный) ≈ 0), loss огромный.

Аналогия: Представьте, что вы учитесь играть в дартс. Loss — это расстояние от вашего броска до центра мишени. Чем ближе к центру, тем меньше loss. -log(p) — особый способ измерять это расстояние для вероятностей.


8. Adam: Оптимизатор, который «запоминает» градиенты

Определение: Adam (Adaptive Moment Estimation) — алгоритм оптимизации, который для каждого параметра хранит скользящие средние градиентов (первый момент) и их квадратов (второй момент), что позволяет адаптивно настраивать шаг обучения.

Пример из жизни: Представьте, что вы спускаетесь с холма. Если склон крутой (большой градиент), вы делаете маленькие осторожные шаги. Если пологий (маленький градиент) — большие. Adam ещё и «помнит», каким был склон раньше, чтобы не метаться.

Код MicroGPT (строки 146–182):

learning_rate, beta1, beta2, eps_adam = 0.01, 0.85, 0.99, 1e-8
m = [0.0] * len(params)  # первый момент (среднее градиентов)
v = [0.0] * len(params)  # второй момент (среднее квадратов)

for step in range(num_steps):
    # ... forward, backward ...
    
    lr_t = learning_rate * (1 - step / num_steps)  # линейное затухание
    
    for i, p in enumerate(params):
        m[i] = beta1 * m[i] + (1 - beta1) * p.grad  # обновляем среднее
        v[i] = beta2 * v[i] + (1 - beta2) * p.grad ** 2  # обновляем среднее квадратов
        
        # Коррекция смещения (bias correction)
        m_hat = m[i] / (1 - beta1 ** (step + 1))
        v_hat = v[i] / (1 - beta2 ** (step + 1))
        
        # Обновление параметра
        p.data -= lr_t * m_hat / (v_hat ** 0.5 + eps_adam)
        p.grad = 0  # обнуляем градиент для следующего шага

Что делают моменты?

  • Первый момент (m): Скользящее среднее градиентов. Если градиенты колеблются, m их сглаживает.
  • Второй момент (v): Скользящее среднее квадратов градиентов. Оценивает «разброс»: если градиенты большие и переменчивые, v большое.

Зачем bias correction? В начале обучения m и v инициализированы нулями, поэтому они смещены в сторону 0. Деление на 1 - beta^step корректирует это смещение.

Почему m_hat / sqrt(v_hat)? Это адаптивный шаг обучения:

  • Если v_hat большое (градиенты «шумные»), шаг уменьшается.
  • Если v_hat маленькое (градиенты стабильные), шаг увеличивается.

9. Генерация: Как модель «сочиняет» текст

Определение: Генерация (инференс) — процесс, когда обученная модель по начальному контексту производит продолжение. Температура — гиперпараметр, контролирующий случайность выбора: низкая температура → консервативные предсказания, высокая → креативные.

Пример из жизни: Представьте, что вы рассказываете историю. Начав «Жил-был…», вы можете продолжать предсказуемо («…царь») или неожиданно («…робот»). Температура — это ваша склонность к риску.

Код MicroGPT (строки 186–200):

temperature = 0.5  # от 0.1 (консервативно) до 1.0 (креативно)

for sample_idx in range(20):
    keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
    token_id = BOS  # начинаем с токена начала
    sample = []
    
    for pos_id in range(block_size):
        logits = gpt(token_id, pos_id, keys, values)  # предсказание
        # Делим логиты на температуру
        probs = softmax([l / temperature for l in logits])
        # Сэмплируем следующий токен по вероятностям
        token_id = random.choices(range(vocab_size), weights=[p.data for p in probs])[0]
        
        if token_id == BOS:  # если модель предсказала конец
            break
        sample.append(uchars[token_id])  # переводим id обратно в символ
    
    print(f"sample {sample_idx+1:2d}: {''.join(sample)}")

Как работает температура?

  • temperature → 0: softmax(l/temperature) превращается в argmax — модель всегда выбирает самый вероятный токен. Текст повторяющийся, скучный.
  • temperature = 1: Обычный softmax. Баланс между предсказуемостью и разнообразием.
  • temperature > 1: Вероятности сглаживаются, модель чаще выбирает маловероятные токены. Текст может быть бессвязным.

Почему семплирование, а не argmax? Потому что язык по природе вероятностный. Даже в правильном тексте есть вариативность. Если всегда выбирать самое вероятное, получается неестественно повторяющийся текст (см. «проклятие argmax»).


10. Полный цикл обучения: От данных до генерации

Давайте соберём всё вместе на примере одного шага обучения с именем «alice»:

  1. Токенизация: "alice"[BOS, 'a', 'l', 'i', 'c', 'e', BOS]
  2. Контекст: Берём первые block_size токенов, например [BOS, 'a', 'l', 'i']
  3. Forward pass:
    • Для позиции 0 (BOS) предсказываем следующий токен (‘a’)
    • Для позиции 1 (‘a’) предсказываем ‘l’
    • Для позиции 2 (‘l’) предсказываем ‘i’
    • Для позиции 3 (‘i’) предсказываем ‘c’
  4. Loss: Суммируем -log(p(правильный_токен)) для всех позиций, усредняем.
  5. Backward: Вычисляем градиенты всех параметров через autograd.
  6. Adam: Обновляем параметры с учётом моментов.
  7. Повторяем 1000 шагов на разных именах.

После обучения:

  • Модель выучила статистику имён: какие буквы идут после каких.
  • При генерации начинает с BOS, предсказывает букву за буквой, пока не получит BOS или не достигнет лимита.
  • Результат: новые, но правдоподобные имена в стиле обучающего датасета.

Заключение: Что MicroGPT учит нас о GPT?

  1. GPT — это не магия. Это комбинация понятных компонентов: эмбеддинги, внимание, MLP, нормализация, оптимизация.
  2. Автоград — основа. Всё обучение сводится к цепному правилу, реализованному в 40 строках кода.
  3. Внимание — ключевая инновация. Оно позволяет модели учитывать контекст любой длины, «фокусируясь» на релевантных частях.
  4. Обучение — это итеративное уточнение. Adam с моментами — сложный, но понятный алгоритм настройки тысяч параметров.
  5. Генерация — вероятностный процесс. Температура и семплирование делают текст естественным.

Чему можно научиться на MicroGPT?

  • Понимать, как работают промышленные GPT (GPT-3, LLaMA, Claude).
  • Реализовывать собственные модели с нуля.
  • Отлаживать проблемы обучения (взрывающиеся градиенты, затухающие активации).
  • Экспериментировать: менять архитектуру, добавлять слои, пробовать другие датасеты.

Что дальше?

  1. Увеличьте n_layer до 2–3, чтобы увидеть, как растёт «ёмкость» модели.
  2. Замените датасет имён на что-то другое: города, химические формулы, код Python.
  3. Реализуйте кеширование ключей/значений для эффективного инференса.
  4. Добавьте dropout для регуляризации.
  5. Попробуйте другие оптимизаторы (SGD, RMSProp).

MicroGPT — это образовательный мост между теорией и практикой глубокого обучения. Пройдя по этому мосту, вы не просто узнаете, что такое GPT, а почувствуете, как он работает изнутри.

«Give a man a fish and you feed him for a day; teach a man to backprop and you feed him for a lifetime.» — современная перефразировка

Дополнительные ресурсы:

Статья создана для образовательных целей на основе анализа microgpt.py. Все примеры кода взяты непосредственно из файла.