
Буквально на днях Андрей Карпаты, один из ранних сооснователей OpenAI, покинувший компанию, исследователь нейросетей, опубликовал на Гитхаб фантастическую вещь: чистый (без специализированных библиотек) 200-строчный python-код трансформера, аналога GPT-2, для изучения всеми желающими. И написал в блоге статью для понимания этого кода (и работы трансформеров). Я перевёл статью и комментарии к коду — ведь этот код (я уверен!) войдёт в ИТ-историю...
Присоединяйтесь к этому завораживающему сеансу разоблачения gpt-магии, за считанные годы овладевшей миром и экономикой!..
12 февраля 2026 года
Это краткое руководство по моему новому арт-проекту microgpt — одному файлу на чистом Python, состоящему из 200 строк кода без внешних зависимостей и библиотек, который реализует обучение и использование (инференс) GPT-модели. В этом файле содержится полный алгоритмический набор компонентов: набор данных документов, токенизатор, движок автоматического дифференцирования (autograd), архитектура нейросети, сходная с архитектурой GPT-2, оптимизатор Adam, цикл обучения и цикл генерации. Всё остальное, что осталось за бортом — это лишь вопросы повышения эффективности LLM. Я не смог бы упростить этот код ещё сильнее. Этот скрипт является кульминацией нескольких проектов (micrograd, makemore, nanogpt и др.) и десятилетнего стремления свести большие языковые модели к их самой сути, и, по-моему, это получилось прекрасно 🥹.
Ниже вы видите код, который идеально разделяется на 3 колонки:

Где всё это найти:
Полный исходный код доступен в этом GitHub gist: microgpt.py
Также он опубликован на этой веб-странице: https://karpathy.ai/microgpt.html
И доступен как Google Colab notebook
"""
Самый простой, элементарный способ обучить и выполнить инференс GPT на чистом Python без внешних зависимостей.
Этот файл содержит полный алгоритм.
Всё остальное — лишь вопросы эффективности.
@karpathy
"""
import os # os.path.exists
import math # math.log, math.exp
import random # random.seed, random.choices, random.gauss, random.shuffle
random.seed(42) # Да будет порядок среди хаоса
# Пусть у нас есть входной dataset `docs`: list[str] - список документов (т.е. dataset имен)
if not os.path.exists('input.txt'):
import urllib.request
names_url = 'https://raw.githubusercontent.com/karpathy/makemore/refs/heads/master/names.txt'
urllib.request.urlretrieve(names_url, 'input.txt')
docs = [l.strip() for l in open('input.txt').read().strip().split('\n') if l.strip()] # list[str] список документов
random.shuffle(docs)
print(f"кол-во документов: {len(docs)}")
# Сделаем Токенизатор для преобразования строк в дискретные символы и обратно
uchars = sorted(set(''.join(docs))) # уникальные символы в наборе данных становятся идентификаторами токенов от 0 до n-1
BOS = len(uchars) # идентификатор токена для специального токена Начала Последовательности (Beginning of Sequence, BOS)
vocab_size = len(uchars) + 1 # общее количество уникальных токенов, +1 для BOS
print(f"размер токенизатора, уникальных токенов: {vocab_size}")
# Сделаем Autograd, чтобы рекурсивно применять правило цепи через вычислительный граф
class Value:
__slots__ = ('data', 'grad', '_children', '_local_grads') # Оптимизация использования памяти в Python
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)
return Value(self.data + other.data, (self, other), (1, 1))
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
return Value(self.data * other.data, (self, other), (other.data, self.data))
def __pow__(self, other): return Value(self.data**other, (self,), (other * self.data**(other-1),))
def log(self): return Value(math.log(self.data), (self,), (1/self.data,))
def exp(self): return Value(math.exp(self.data), (self,), (math.exp(self.data),))
def relu(self): return Value(max(0, self.data), (self,), (float(self.data > 0),))
def __neg__(self): return self * -1
def __radd__(self, other): return self + other
def __sub__(self, other): return self + (-other)
def __rsub__(self, other): return other + (-self)
def __rmul__(self, other): return self * other
def __truediv__(self, other): return self * other**-1
def __rtruediv__(self, other): return other * self**-1
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
# Инициализируем параметры, чтобы хранить знания модели.
n_embd = 16 # размерность эмбеддингов
n_head = 4 # количество голов внимания
n_layer = 1 # количество слоёв
block_size = 16 # максимальная длина последовательности
head_dim = n_embd // n_head # размерность каждой головы
matrix = lambda nout, nin, std=0.08: [[Value(random.gauss(0, std)) for _ in range(nin)] for _ in range(nout)]
state_dict = {'wte': matrix(vocab_size, n_embd), 'wpe': matrix(block_size, n_embd), 'lm_head': matrix(vocab_size, n_embd)}
for i in range(n_layer):
state_dict[f'layer{i}.attn_wq'] = matrix(n_embd, n_embd)
state_dict[f'layer{i}.attn_wk'] = matrix(n_embd, n_embd)
state_dict[f'layer{i}.attn_wv'] = matrix(n_embd, n_embd)
state_dict[f'layer{i}.attn_wo'] = matrix(n_embd, n_embd)
state_dict[f'layer{i}.mlp_fc1'] = matrix(4 * n_embd, n_embd)
state_dict[f'layer{i}.mlp_fc2'] = matrix(n_embd, 4 * n_embd)
params = [p for mat in state_dict.values() for row in mat for p in row] # объединить параметры в один list[Value]
print(f"количество параметров: {len(params)}")
# Определим архитектуру модели: stateless-функция, отображающая последовательность токенов и параметры в логиты следующего токена.
# Следуем GPT-2, благословенной среди GPT, с небольшими отличиями: layernorm -> rmsnorm, без смещений, GeLU -> ReLU
def linear(x, w):
return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w]
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]
def rmsnorm(x):
ms = sum(xi * xi for xi in x) / len(x)
scale = (ms + 1e-5) ** -0.5
return [xi * scale for xi in x]
def gpt(token_id, pos_id, keys, values):
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)] # объединенный эмбеддинг токенов и позиций
x = rmsnorm(x)
for li in range(n_layer):
# 1) Блок многоголового внимания
x_residual = x
x = rmsnorm(x)
q = linear(x, state_dict[f'layer{li}.attn_wq'])
k = linear(x, state_dict[f'layer{li}.attn_wk'])
v = linear(x, state_dict[f'layer{li}.attn_wv'])
keys[li].append(k)
values[li].append(v)
x_attn = []
for h in range(n_head):
hs = h * head_dim
q_h = q[hs:hs+head_dim]
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))]
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)
x = linear(x_attn, state_dict[f'layer{li}.attn_wo'])
x = [a + b for a, b in zip(x, x_residual)]
# 2) MLP блок
x_residual = x
x = rmsnorm(x)
x = linear(x, state_dict[f'layer{li}.mlp_fc1'])
x = [xi.relu() for xi in x]
x = linear(x, state_dict[f'layer{li}.mlp_fc2'])
x = [a + b for a, b in zip(x, x_residual)]
logits = linear(x, state_dict['lm_head'])
return logits
# Создадим Адама, благословенного оптимизатора, и его буферы
learning_rate, beta1, beta2, eps_adam = 0.01, 0.85, 0.99, 1e-8
m = [0.0] * len(params) # буфер первого момента
v = [0.0] * len(params) # буфер второго момента
# Повторяем последовательно
num_steps = 1000 # количество шагов обучения
for step in range(num_steps):
# Берём один документ, токенизируем его, обрамляем специальным токеном BOS с обеих сторон
doc = docs[step % len(docs)]
tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]
n = min(block_size, len(tokens) - 1)
# Прогоняем последовательность токенов через модель, выстраивая вычислительный граф вплоть до функции потерь.
keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
losses = []
for pos_id in range(n):
token_id, target_id = tokens[pos_id], tokens[pos_id + 1]
logits = gpt(token_id, pos_id, keys, values)
probs = softmax(logits)
loss_t = -probs[target_id].log()
losses.append(loss_t)
loss = (1 / n) * sum(losses) # итоговые средние потери по последовательности документа. Да будут они низкими.
# Выполняем обратный проход функции потерь, вычисляя градиенты по всем параметрам модели.
loss.backward()
# Обновление оптимизатором Adam: обновляем параметры модели на основе соответствующих градиентов.
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
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
print(f"step {step+1:4d} / {num_steps:4d} | loss {loss.data:.4f}")
# Инференс: пусть модель бубнит нам в ответ
temperature = 0.5 # в диапазоне (0, 1], контролирует "креативность" генерируемого текста, от низкой до высокой
print("\n--- инференс (новые, сгенерированные имена) ---")
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])
print(f"Образец {sample_idx+1:2d}: {''.join(sample)}")Топливом для больших языковых моделей является поток текстовых данных, часто разделённых на отдельные документы. В промышленных системах каждый документ — это веб-страница из интернета, но в microgpt мы используем более простой пример: 32 000 человеческих имён, по одному на строку.
# Let there be an input dataset `docs`: list[str] of documents (e.g. a dataset of names)
if not os.path.exists('input.txt'):
import urllib.request
names_url = 'https://raw.githubusercontent.com/karpathy/makemore/refs/heads/master/names.txt'
urllib.request.urlretrieve(names_url, 'input.txt')
docs = [l.strip() for l in open('input.txt').read().strip().split('\n') if l.strip()] # list[str] of documents
random.shuffle(docs)
print(f"num docs: {len(docs)}")Пример набора данных: каждое имя — отдельный документ.
emma
olivia
ava
isabella
sophia
charlotte
mia
amelia
harper
... (~32,000 имен)Цель модели — выучить статистические закономерности в этих данных и затем генерировать новые, похожие документы. Спойлер: к концу скрипта наша модель будет генерировать («галлюцинировать»!) новые, правдоподобные имена. Такие, как эти:
sample 1: kamon
sample 2: ann
sample 3: karai
sample 4: jaire
sample 5: vialan
sample 6: karia
sample 7: yeran
sample 8: anna
sample 9: areli
sample 10: kaina
sample 11: konna
sample 12: keylen
sample 13: liole
sample 14: alerin
sample 15: earan
sample 16: lenne
sample 17: kana
sample 18: lara
sample 19: alela
sample 20: antonЭто может показаться незначительным «достижением» приведенного здесь кода, но с точки зрения модели вроде ChatGPT ваш диалог с ней — просто «документ» определенного вида. Когда вы начинаете диалог с запросом (промптом), ответ модели — это просто статистическое продолжение этого документа (промпта).
Нейросети внутри работают с числами, а не с символами, поэтому нам нужен способ преобразовать текст в последовательность целочисленных идентификаторов-токенов и обратно. Промышленные токенизаторы, такие как tiktoken (используемый в GPT-4), работают с блоками (небольшими последовательностями) символов ради эффективности, но самый простой возможный токенизатор просто назначает одно целое число каждому уникальному символу в наборе данных:
# Let there be a Tokenizer to translate strings to discrete symbols and back
uchars = sorted(set(''.join(docs))) # unique characters in the dataset become token ids 0..n-1
BOS = len(uchars) # token id for the special Beginning of Sequence (BOS) token
vocab_size = len(uchars) + 1 # total number of unique tokens, +1 is for BOS
print(f"vocab size: {vocab_size}")В приведённом коде мы собираем все уникальные символы (в данном случае — строчные латинские буквы a–z), сортируем их, и каждому символу присваивается ID по его индексу. Сами значения целых чисел не имеют никакого значения; каждый токен — просто отдельный дискретный символ. Вместо 0, 1, 2 можно было бы использовать разные эмодзи.
Кроме того, мы создаём специальный токен BOS (Beginning of Sequence — начало последовательности), который служит разделителем. Он говорит модели: «здесь начинается (или заканчивается) новый документ». Во время обучения каждый документ оборачивается токенами BOS с обеих сторон: [BOS, e, m, m, a, BOS]. Модель учится, что BOS запускает новое имя, а следующий BOS завершает его.
Таким образом, у нас получается словарь размером 27: 26 строчных букв + 1 специальный токен BOS.
Обучение нейросети требует градиентов: для каждого параметра модели нужно знать, «если я немного увеличу это число, возрастёт или уменьшится функция потерь и насколько?». Вычислительный граф имеет множество входов (параметры модели и входные токены), но сводится к одному скалярному выходу — величине потерь (loss).
Обратное распространение ошибки (backpropagation) начинается с этого единственного выхода и движется назад по графу, вычисляя градиент потери по отношению ко всем входам. Оно основано на правиле цепочки из математического анализа. В промышленных библиотеках, таких как PyTorch, это делается автоматически. Здесь же мы реализуем это с нуля в одном классе Value:
class Value:
__slots__ = ('data', 'grad', '_children', '_local_grads')
def __init__(self, data, children=(), local_grads=()):
self.data = data # scalar value of this node calculated during forward pass
self.grad = 0 # derivative of the loss w.r.t. this node, calculated in backward pass
self._children = children # children of this node in the computation graph
self._local_grads = local_grads # local derivative of this node w.r.t. its children
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
return Value(self.data + other.data, (self, other), (1, 1))
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
return Value(self.data * other.data, (self, other), (other.data, self.data))
def __pow__(self, other): return Value(self.data**other, (self,), (other * self.data**(other-1),))
def log(self): return Value(math.log(self.data), (self,), (1/self.data,))
def exp(self): return Value(math.exp(self.data), (self,), (math.exp(self.data),))
def relu(self): return Value(max(0, self.data), (self,), (float(self.data > 0),))
def __neg__(self): return self * -1
def __radd__(self, other): return self + other
def __sub__(self, other): return self + (-other)
def __rsub__(self, other): return other + (-self)
def __rmul__(self, other): return self * other
def __truediv__(self, other): return self * other**-1
def __rtruediv__(self, other): return other * self**-1
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Я понимаю, что это самая математически и алгоритмически насыщенная часть, и у меня есть 2,5-часовое видео на эту тему: micrograd video. Вкратце: объект Value оборачивает одно скалярное число (data) и отслеживает, как оно было вычислено. Каждая операция — это маленький «конструкторский блок»: она принимает входы, производит выход (прямой проход) и знает, как её выход изменяется относительно каждого входа (локальный градиент). Этой информации достаточно для autograd. Всё остальное — просто правило цепочки, соединяющее блоки.
Каждый раз, когда вы выполняете математические операции с объектами Value (сложение, умножение и т.д.), результат — новый Value, который помнит свои входы (_children) и локальные производные (_local_grads). Например,__mul__ записывает, что
и
.
Полный список таких «блоков лего»:
Операция | Прямой проход | Локальные градиенты |
|---|---|---|
| a + b |
|
| a · b |
|
| aⁿ | |
| ln(a) | |
| eᵃ | |
| max(0,a) |
Метод backward() проходит по графу в обратном топологическом порядке (от величины потери, loss к параметрам), применяя правило цепочки на каждом шаге. Если L — потеря, а узел имеет потомка
с локальным градиентом
, то:
Это может выглядеть пугающе, если вы не уверены в своих познаниях в математике, но на самом деле это просто умножение двух чисел. Например: «Если машина едет вдвое быстрее велосипеда, а велосипед — вчетверо быстрее пешехода, то машина едет в 8 раз быстрее пешехода». Правило цепочки работает так же: перемножаются скорости изменения вдоль пути.
Мы начинаем с установки self.grad = 1 для узла потери, потому что ∂L/∂L = 1. Оттуда правило цепочки просто умножает локальные градиенты вдоль каждого пути к параметрам.
Обратите внимание на оператор += (накопление, а не присваивание). Когда значение используется в нескольких местах графа (граф разветвляется), градиенты возвращаются по каждой ветви независимо и должны суммироваться. Это следствие многомерного правила цепочки: если влияет на
через несколько путей, то общий градиент — это сумма таких вкладов.
После завершения backward(), каждый объект Value в графе содержит .grad = ∂L/∂v — информацию о том, как изменится величина потери, loss при изменении этого значения.
Вот конкретный пример: обратите внимание, что a используется дважды (граф разветвляется), поэтому его градиент является суммой обоих путей:
a = Value(2.0)
b = Value(3.0)
c = a * b # c = 6.0
L = c + a # L = 8.0
L.backward()
print(a.grad) # 4.0 (dL/da = b + 1 = 3 + 1, via both paths)
print(b.grad) # 2.0 (dL/db = a = 2)Именно это и даёт метод .backward() в PyTorch:
import torch
a = torch.tensor(2.0, requires_grad=True)
b = torch.tensor(3.0, requires_grad=True)
c = a * b
L = c + a
L.backward()
print(a.grad) # tensor(4.)
print(b.grad) # tensor(2.)Это тот же самый алгоритм, который выполняет loss.backward() в PyTorch, только на скалярах вместо тензоров (представляющих собой массивы скаляров) — алгоритмически идентичный, значительно меньший и намного проще, но, конечно, гораздо менее эффективный.
Давайте разберем, что дает нам .backward() в примере выше. Autograd вычислил, что если
L = a*b + a, а
a=2 и b=3, то
a.grad = 4.0.
Это говорит нам о локальном влиянии a на L. Если слегка изменить входное значение a, в каком направлении изменится L? Здесь производная L по a равна 4.0, что означает: если увеличить a на небольшую величину (скажем, 0.001), L увеличится примерно в 4 раза сильнее (на 0.004).
Аналогично, b.grad = 2.0 означает, что такое же изменение b приведет к увеличению L примерно в 2 раза сильнее (на 0.002). Другими словами, эти градиенты указывают направление (положительное или отрицательное в зависимости от знака) и крутизну (величину) влияния каждого отдельного входного параметра на конечный результат (функцию потерь). Это, в свою очередь, позволяет нам итеративно изменять параметры нашей нейронной сети, чтобы уменьшить значение функции потерь и, следовательно, улучшить ее прогнозы.
Параметры — это знания модели. Это большой набор чисел с плавающей точкой (обёрнутых в Value для autograd), которые изначально случайны и постепенно оптимизируются в процессе обучения. Их точная роль станет ясна после описания архитектуры, но сейчас нам нужно просто их инициализировать:
n_embd = 16 # embedding dimension
n_head = 4 # number of attention heads
n_layer = 1 # number of layers
block_size = 16 # maximum sequence length
head_dim = n_embd // n_head # dimension of each head
matrix = lambda nout, nin, std=0.08: [[Value(random.gauss(0, std)) for _ in range(nin)] for _ in range(nout)]
state_dict = {'wte': matrix(vocab_size, n_embd), 'wpe': matrix(block_size, n_embd), 'lm_head': matrix(vocab_size, n_embd)}
for i in range(n_layer):
state_dict[f'layer{i}.attn_wq'] = matrix(n_embd, n_embd)
state_dict[f'layer{i}.attn_wk'] = matrix(n_embd, n_embd)
state_dict[f'layer{i}.attn_wv'] = matrix(n_embd, n_embd)
state_dict[f'layer{i}.attn_wo'] = matrix(n_embd, n_embd)
state_dict[f'layer{i}.mlp_fc1'] = matrix(4 * n_embd, n_embd)
state_dict[f'layer{i}.mlp_fc2'] = matrix(n_embd, 4 * n_embd)
params = [p for mat in state_dict.values() for row in mat for p in row]
print(f"num params: {len(params)}")Каждый параметр инициализируется малым случайным числом из гауссового распределения.
state_dict организует их в именованные матрицы (по аналогии с PyTorch): таблицы эмбеддингов, веса внимания, веса MLP и финальную проекцию вывода. Все параметры также сведены в один список params, чтобы оптимизатор мог по ним пройтись.
В нашей крошечной модели — 4192 параметра. У GPT-2 — 1,6 миллиарда, у современных LLM — сотни миллиардов.
Модель — это функция без состояния (stateless function): она принимает токен, позицию, параметры и кэшированные данные ключей/значений от предыдущих шагов и возвращает логиты — оценки того, какой токен, по мнению модели, должен идти следующим.
Мы следуем архитектуре GPT-2 с небольшими упрощениями: используем RMSNorm вместо LayerNorm, нет смещений (biases), и ReLU вместо GeLU.
Сначала рассмотрим три вспомогательные функции:
def linear(x, w):
return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w]linear: умножение матрицы на вектор. Она принимает на вход вектор x и матрицу весов w и вычисляет одно скалярное произведение для каждой строки . Это фундаментальный строительный блок нейронных сетей: обучаемое линейное преобразование.
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]softmax преобразует вектор исходных оценок (логитов), которые могут находиться в диапазоне от −∞ до +∞, в распределение вероятностей: все значения оказываются в интервале [0,1] и в сумме дают 1. Для стабилизации величин мы сначала вычитаем максимальное значение (математически это не меняет результат, но предотвращает переполнение при вычислении exp , экспоненты).
def rmsnorm(x):
ms = sum(xi * xi for xi in x) / len(x)
scale = (ms + 1e-5) ** -0.5
return [xi * scale for xi in x]rmsnorm (Root Mean Square Normalization, нормализация по среднеквадратичному значению) масштабирует вектор так, чтобы его значения имели единичное среднеквадратичное отклонение. Это предотвращает неконтролируемый рост или затухание активаций при прохождении через сеть, что стабилизирует процесс обучения. Это упрощенный вариант LayerNorm, который использовался в оригинальном GPT-2.
Теперь сама модель:
def gpt(token_id, pos_id, keys, values):
tok_emb = state_dict['wte'][token_id] # token embedding
pos_emb = state_dict['wpe'][pos_id] # position embedding
x = [t + p for t, p in zip(tok_emb, pos_emb)] # joint token and position embedding
x = rmsnorm(x)
for li in range(n_layer):
# 1) Multi-head attention block
x_residual = x
x = rmsnorm(x)
q = linear(x, state_dict[f'layer{li}.attn_wq'])
k = linear(x, state_dict[f'layer{li}.attn_wk'])
v = linear(x, state_dict[f'layer{li}.attn_wv'])
keys[li].append(k)
values[li].append(v)
x_attn = []
for h in range(n_head):
hs = h * head_dim
q_h = q[hs:hs+head_dim]
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))]
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)
x = linear(x_attn, state_dict[f'layer{li}.attn_wo'])
x = [a + b for a, b in zip(x, x_residual)]
# 2) MLP block
x_residual = x
x = rmsnorm(x)
x = linear(x, state_dict[f'layer{li}.mlp_fc1'])
x = [xi.relu() for xi in x]
x = linear(x, state_dict[f'layer{li}.mlp_fc2'])
x = [a + b for a, b in zip(x, x_residual)]
logits = linear(x, state_dict['lm_head'])
return logitsФункция обрабатывает один токен (с идентификатором token_id) в конкретной временной позиции (pos_id), используя контекст предыдущих итераций, обобщенный в активациях ключей keysи значений values, известный как KV-кэш.
Вот что происходит шаг за шагом:
Эмбеддинги (Embeddings). Нейросеть не может напрямую обработать сырой идентификатор токена, например 5. Она работает только с векторами (списками чисел). Поэтому мы связываем с каждым возможным токеном обучаемый вектор и подаем его в сеть как его нейронный "сигнатурный" код. И идентификатор токена, и идентификатор позиции извлекают строку из своей таблицы эмбеддингов (wte и wpe соответственно) . Эти два вектора складываются, давая модели представление, которое кодирует и то, что это за токен, и где он находится в последовательности. Современные LLM обычно отказываются от эмбеддингов позиции в пользу других схем, основанных на относительном позиционировании, например, RoPE.
Блок внимания (Attention Block). Текущий токен проецируется в три вектора: запрос (query, Q), ключ (key, K) и значение (value, V). Интуитивно, запрос спрашивает: "что я ищу?", ключ говорит: "что я содержу?", а значение говорит: "что я предлагаю, если меня выберут?".
Например, в имени "emma", когда модель находится на второй букве "m" и пытается предсказать, что будет дальше, она может сформировать запрос вроде: "какие гласные были недавно?". Буква "e" ранее будет иметь ключ, хорошо соответствующий этому запросу, поэтому она получит высокий вес внимания, и ее значение (информация о том, что она гласная) перетечет на текущую позицию.
Ключ и значение добавляются в KV-кэш, чтобы предыдущие позиции были доступны. Каждая голова внимания вычисляет скалярные произведения между своим запросом и всеми закэшированными ключами (масштабируемые на ), применяет
softmax для получения весов внимания и берет взвешенную сумму закэшированных значений. Выходы всех голов объединяются и проецируются через attn_wo. Важно подчеркнуть, что блок Внимания — это единственное место, где токен на позиции может "посмотреть" на токены из прошлого
(0..t-1). Внимание — это механизм коммуникации между токенами.
Блок MLP. MLP расшифровывается как "multilayer perceptron, многослойный перцептрон". Это двухслойная сеть прямого распространения: проецирование в пространство в 4 раза большее размерности эмбеддингов, применение ReLU, проецирование обратно. Здесь модель выполняет основную часть "размышлений" для каждой позиции. В отличие от внимания, эти вычисления полностью локальны для момента времени t. Трансформер чередует коммуникацию (Внимание) с вычислениями (MLP).
Остаточные связи (Residual Connections). И блок внимания, и блок MLP добавляют свой выход обратно к своему входу (x = [a + b for ...]). Это позволяет градиентам течь напрямую через сеть и делает возможным обучение глубоких моделей.
Выход (Output). Финальное скрытое состояние проецируется на размер словаря с помощью lm_head, создавая по одному логиту на каждый токен в словаре. В нашем случае это всего 27 чисел. Более высокий логит означает, что модель считает соответствующий токен более вероятным следующим.
Вы могли заметить, что мы используем KV-кэш во время обучения, что необычно. Обычно KV-кэш ассоциируют только с инференсом. Но концептуально KV-кэш существует всегда, даже во время обучения. В продакшен-реализациях он просто скрыт внутри высоко векторизованного вычисления внимания, которое обрабатывает все позиции последовательности одновременно. Поскольку microgpt обрабатывает по одному токену за раз (нет размерности батча, нет параллельных временных шагов), мы строим KV-кэш явно. И, в отличие от типичного сценария инференса, где KV-кэш хранит "отсоединенные" тензоры, здесь закэшированные ключи и значения являются "живыми" узлами Value в графе вычислений, поэтому мы фактически распространяем ошибку обратно через них.
Теперь всё соединяется вместе. Цикл обучения состоит из повторяющихся этапов:
Выбор документа
Прямой проход модели по токенам
Вычисление величины потери
Обратное распространение (через градиенты)
Обновление параметров.
# Let there be Adam, the blessed optimizer and its buffers
learning_rate, beta1, beta2, eps_adam = 0.01, 0.85, 0.99, 1e-8
m = [0.0] * len(params) # first moment buffer
v = [0.0] * len(params) # second moment buffer
# Repeat in sequence
num_steps = 1000 # number of training steps
for step in range(num_steps):
# Take single document, tokenize it, surround it with BOS special token on both sides
doc = docs[step % len(docs)]
tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]
n = min(block_size, len(tokens) - 1)
# Forward the token sequence through the model, building up the computation graph all the way to the loss.
keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
losses = []
for pos_id in range(n):
token_id, target_id = tokens[pos_id], tokens[pos_id + 1]
logits = gpt(token_id, pos_id, keys, values)
probs = softmax(logits)
loss_t = -probs[target_id].log()
losses.append(loss_t)
loss = (1 / n) * sum(losses) # final average loss over the document sequence. May yours be low.
# Backward the loss, calculating the gradients with respect to all model parameters.
loss.backward()
# Adam optimizer update: update the model parameters based on the corresponding gradients.
lr_t = learning_rate * (1 - step / num_steps) # linear learning rate decay
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
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
print(f"step {step+1:4d} / {num_steps:4d} | loss {loss.data:.4f}")Давайте пройдёмся по каждому этапу:
Токенизация. На каждом шаге обучения выбирается один документ, он оборачивается токенами BOS (начало последовательности) с обеих сторон: имя "emma" превращается в [BOS, e, m, m, a, BOS]. Задача модели — предсказать каждый следующий токен, основываясь на предыдущих.
Прямой проход и функция потерь (Forward pass and loss). Мы подаем токены в модель по одному, постепенно наполняя KV-кэш. На каждой позиции модель выдает 27 логитов, которые мы преобразуем в вероятности с помощью softmax. Потеря (ошибка) на каждой позиции вычисляется как отрицательный логарифм вероятности правильного следующего токена: . Это называется кросс-энтропийной потерей (cross-entropy loss). Интуитивно, потеря измеряет степень ошибки предсказания: насколько модель "удивлена" тем, что произошло на самом деле. Если модель присваивает правильному токену вероятность 1.0, она нисколько не удивлена, и потеря равна 0. Если она присваивает вероятность, близкую к 0, модель очень удивлена, и потеря стремится к бесконечности. Мы усредняем значения потерь по всем позициям в документе, чтобы получить единственное скалярное значение потери.
Обратный проход (Backward pass). Один вызов loss.backward() запускает обратное распространение ошибки через весь граф вычислений: от значения потери, через softmax, через всю модель и вплоть до каждого параметра. После этого атрибут .grad каждого параметра показывает нам, как его нужно изменить, чтобы уменьшить потерю.
Оптимизатор Adam (Adam optimizer). Мы могли бы просто выполнить p.data -= lr * p.grad (градиентный спуск), но Adam работает умнее. Он поддерживает два скользящих средних для каждого параметра: m отслеживает среднее значение недавних градиентов (импульс, momentum — как катящийся шар), а v отслеживает среднее значение квадратов недавних градиентов (адаптируя скорость обучения для каждого параметра). Значения m_hat и v_hat — это скорректированные от смещения оценки, которые учитывают, что m и v инициализируются нулями и им нужен "разогрев". Скорость обучения линейно уменьшается в процессе тренировки. После обновления мы сбрасываем .grad в 0 для следующего шага.
За 1000 шагов обучения значение потери снижается примерно с 3.3 (случайное угадывание среди 27 токенов: -log(1/27) ≈ 3.3) до примерно 2.37. Меньшее значение лучше, и теоретический минимум — 0 (идеальные предсказания), так что еще есть куда стремиться, но модель и при таких значениях потери явно изучает статистические закономерности, присущие именам.
После обучения можно генерировать новые имена. Параметры фиксированы, и мы просто запускаем прямой проход в цикле, подавая сгенерированный токен как следующий вход:
temperature = 0.5 # in (0, 1], control the "creativity" of generated text, low to high
print("\n--- inference (new, hallucinated names) ---")
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])
print(f"sample {sample_idx+1:2d}: {''.join(sample)}")Каждый экземпляр мы начинаем с токена BOS, который говорит модели: "начни новое имя". Модель выдает 27 логитов, мы преобразуем их в вероятности и случайным образом выбираем один токен в соответствии с этими вероятностями. Этот токен снова подается на вход как следующий, и мы повторяем процесс до тех пор, пока модель не сгенерирует токен BOS (означающий "я закончила") или пока не будет достигнута максимальная длина последовательности.
Параметр температура (temperature) контролирует степень случайности. Перед применением softmax мы делим логиты на значение температуры. Температура, равная 1,0, означает сэмплирование непосредственно из распределения, которое выучила модель. Более низкие значения температуры (например, 0,5 , как в данном случае) делают распределение более "острым" (вероятности сконцентрированы на наиболее вероятных вариантах), что приводит к более консервативному поведению модели, когда она с большей вероятностью выбирает свои лучшие варианты. При температуре, стремящейся к нулю, модель всегда будет выбирать самый вероятный токен (это называется жадным декодированием). Более высокие значения температуры, наоборот, сглаживают распределение, делая вывод более разнообразным, но потенциально менее связным.
Всё, что нужно — Python (без pip, без зависимостей):
python train.py(если мы назовём файл train.py)
Скрипт выполняется ~1 минуту на MacBook. Вы увидите, как величину потерь, loss на каждом шаге:
train.py
num docs: 32033
vocab size: 27
num params: 4192
step 1 / 1000 | loss 3.3660
step 2 / 1000 | loss 3.4243
step 3 / 1000 | loss 3.1778
step 4 / 1000 | loss 3.0664
step 5 / 1000 | loss 3.2209
step 6 / 1000 | loss 2.9452
step 7 / 1000 | loss 3.2894
step 8 / 1000 | loss 3.3245
step 9 / 1000 | loss 2.8990
step 10 / 1000 | loss 3.2229
step 11 / 1000 | loss 2.7964
step 12 / 1000 | loss 2.9345
step 13 / 1000 | loss 3.0544
...Увидите, как она падает с ~3.3 (случайный токен) до ~2.37. Чем ниже — тем лучше модель предсказывает следующий токен.
В конце обучения параметры содержат знания о статистических закономерностях имён. Используя их, можно генерировать новые, «галлюцинированные» имена.
Вы увидите примерно такие имена:
sample 1: kamon
sample 2: ann
sample 3: karai
sample 4: jaire
sample 5: vialan
sample 6: karia
sample 7: yeran
sample 8: anna
sample 9: areli
sample 10: kaina
sample 11: konna
sample 12: keylen
sample 13: liole
sample 14: alerin
sample 15: earan
sample 16: lenne
sample 17: kana
sample 18: lara
sample 19: alela
sample 20: antonВы можете:
Запустить скрипт локально на своём компьютере
Использовать Google Colab notebook и задать Gemini вопросы относительно кода
Попробуйте поэкспериментировать: другой датасет, больше шагов num_steps, увеличить размер модели, чтобы получить лучшие результаты.
Чтобы увидеть, как код собирается по частям, словно слои луковицы, рекомендую такую последовательность изучения:
Файл | Что добавляется |
|---|---|
| Таблица частот биграмм — без нейросети, без градиентов |
| MLP + градиенты, вычисляемые вручную (численно и аналитически) + SGD |
| Автоматическое дифференцирование (Autograd) (класс |
| Эмбеддинги позиций (Position embeddings) + внимание с одной головой (single-head attention) + RMSNorm + остаточные связи (residuals) |
| Многоголовое внимание (Multi-head attention) + цикл по слоям — полная архитектура GPT |
| Оптимизатор Adam — это и есть итоговый |
Я создал Gist под названием build_microgpt.py, где в разделе "Revisions" (История версий) вы можете увидеть все эти версии и различия между каждым шагом. Думаю, это может быть полезным способом последовательно разобраться в кодовой базе, добавляя по одному компоненту за раз.
microgpt содержит всю алгоритмическую суть обучения и работы GPT. Но между ним и промышленной LLM, такой как ChatGPT, лежит длинный список усовершенствований. Ни одно из них не меняет базовый алгоритм и общую структуру, но именно они позволяют системе действительно работать в масштабе. Пройдемся по тем же разделам по порядку:
Данные (Data). Вместо 32 тысяч коротких имен, производственные модели обучаются на триллионах токенов текста из интернета: веб-страницы, книги, код и т.д. Данные дедублицируются, фильтруются по качеству и тщательно перемешиваются по доменам.
Токенизатор (Tokenizer). Вместо отдельных символов, производственные модели используют токенизаторы подслов, такие как BPE (Byte Pair Encoding), которые учатся объединять часто встречающиеся последовательности символов в отдельные токены. Распространенные слова, такие как "the", становятся одним токеном, редкие слова разбиваются на части. Это дает словарь примерно из 100 тысяч токенов и гораздо эффективнее, потому что модель видит больше контента на каждой позиции.
Автоматическое дифференцирование (Autograd). microgpt работает со скалярными объектами Value на чистом Python. Производственные системы используют тензоры (большие многомерные массивы чисел) и работают на GPU/TPU, выполняющих миллиарды операций с плавающей запятой в секунду. Такие библиотеки, как PyTorch, обеспечивают автоматическое дифференцирование для тензоров, а ядра CUDA, такие как FlashAttention, объединяют несколько операций для скорости. Математика идентична, просто соответствует множеству скаляров, обрабатываемых параллельно.
Архитектура (Architecture). У microgpt 4192 параметра. Модели класса GPT-4 имеют сотни миллиардов. В целом это очень похожая нейросеть-трансформер, только намного шире (размерность эмбеддингов 10000+) и намного глубже (100+ слоев). Современные LLM также включают несколько дополнительных типов "кирпичиков" и меняют их порядок. Примеры: RoPE (Rotary Position Embeddings — вращательные позиционные эмбеддинги) вместо обучаемых позиционных эмбеддингов, GQA (Grouped Query Attention — группированное внимание запросов) для уменьшения размера KV-кэша, затворные линейные активации (gated linear units) вместо ReLU, слои смеси экспертов (MoE — Mixture of Experts) и т.д. Но базовая структура с чередованием Внимания (коммуникация) и MLP (вычисления) на остаточном потоке (residual stream) хорошо сохраняется.
Обучение (Training). Вместо одного документа за шаг, промышленное обучение использует большие батчи (миллионы токенов за шаг), накопление градиента (gradient accumulation), смешанную точность (float16/bfloat16) и тщательную настройку гиперпараметров. Обучение передовой модели требует тысяч GPU, работающих месяцами.
Оптимизация (Optimization). В microgpt используется Adam с простым линейным затуханием скорости обучения, и на этом всё. В масштабном развёртывании оптимизация становится отдельной дисциплиной. Модели обучаются с пониженной точностью (bfloat16 или даже fp8) и на больших кластерах GPU для эффективности, что создает свои численные проблемы. Настройки оптимизатора (скорость обучения, коэффициент затухания весов (weight decay), параметры beta, график разогрева (warmup), график затухания) должны быть точно подобраны, и правильные значения зависят от размера модели, размера батча и состава набора данных. Законы масштабирования (например, Chinchilla) помогают распределить фиксированный вычислительный бюджет между размером модели и количеством токенов обучения. Ошибка в любой из этих деталей в масштабе может стоить миллионов долларов вычислительных ресурсов, поэтому команды проводят обширные эксперименты на меньших масштабах, чтобы предсказать правильные настройки, прежде чем запускать полное обучение.
Пост-обработка (Post-training). Базовая модель, получаемая в результате обучения (называемая "предобученной" моделью), является "завершателем документов", а не чат-ботом. Превращение ее в ChatGPT происходит в два этапа. Первый — SFT (Supervised Fine-Tuning, контролируемая донастройка): вы просто заменяете документы на подобранные диалоги и продолжаете обучение. Алгоритмически ничего не меняется. Второй — RL (Reinforcement Learning, обучение с подкреплением): модель генерирует ответы, они получают оценку (от людей, другой модели-"судьи" или алгоритма), и модель учится на этой обратной связи. Фундаментально модель все еще обучается на документах, но теперь эти документы состоят из токенов, сгенерированных самой моделью.
Инференс (Inference). Обслуживание модели для миллионов пользователей требует собственного стека инженерных решений: объединение запросов в батчи (batching), управление KV-кэшем и его страничная организация (vLLM и т.д.), спекулятивное декодирование (speculative decoding) для скорости, квантизация (работа в int8/int4 вместо float16) для уменьшения потребления памяти и распределение модели по нескольким GPU. Фундаментально мы всё еще предсказываем следующий токен в последовательности, но с массой инженерных ухищрений, чтобы делать это быстрее.
Все это — важные инженерные и исследовательские достижения, но если вы понимаете microgpt, вы понимаете алгоритмическую суть.
Понимает ли модель что-либо?
Это философский вопрос, но с механической точки зрения никакой магии не происходит. Модель — это большая математическая функция, которая преобразует входные токены в распределение вероятностей для следующего токена. Во время обучения параметры настраиваются так, чтобы сделать правильный следующий токен более вероятным. Составляет ли это "понимание" — решать вам, но механизм полностью описан в 200 строках кода выше.
Почему это работает?
У модели тысячи настраиваемых параметров, и оптимизатор немного изменяет их на каждом шаге, чтобы уменьшить значение функции потерь. За множество шагов параметры устанавливаются в значения, которые отражают статистические закономерности данных. Для имен это означает, например: имена часто начинаются с согласных, сочетание "qu" имеет тенденцию появляться вместе, имена редко содержат три согласных подряд и т.д. Модель не изучает явные правила, она изучает распределение вероятностей, которое, так уж вышло, их отражает.
Как это связано с ChatGPT?
ChatGPT — это тот же самый базовый цикл (предсказать следующий токен, сэмплировать, повторить), но многократно масштабированный, с дополнительным пост-обучением для придания разговорных свойств. Когда вы общаетесь с ним, системный промпт, ваше сообщение и его ответ — это просто токены в последовательности. Модель завершает документ по одному токену за раз, точно так же, как microgpt завершает имя.
В чем прикол с "галлюцинациями"?
Модель генерирует токены, сэмплируя их из распределения вероятностей. У нее нет понимания истины, она знает только, какие последовательности статистически правдоподобны, учитывая обучающие данные. "Галлюцинация" microgpt, придумывающего имя вроде "karia", — это то же самое явление, что и уверенное заявление ChatGPT о ложном факте. И то, и другое — правдоподобно звучащие продолжения, которые, так уж вышло, не являются реальными.
Почему это так медленно?
microgpt обрабатывает по одному скаляру за раз на чистом Python. Один шаг обучения занимает секунды. Та же математика на GPU обрабатывает миллионы скаляров параллельно и работает на порядки быстрее.
Могу ли я заставить его генерировать лучшие имена?
Да. Обучайте дольше (увеличьте num_steps), сделайте модель больше (n_embd, n_layer, n_head) или используйте больший набор данных. Это те же самые рычаги, которые применимы и в больших масштабах.
Что если я изменю набор данных?
Модель выучит те закономерности, которые есть в данных. Подставьте файл с названиями городов, именами покемонов, английскими словами или короткими стихотворениями, и модель научится генерировать их вместо имен. Остальной код менять не нужно.