Итоги курса и что дальше
Что мы прошли
Начали с векторов и производных, а закончили механизмом, на котором работают современные языковые модели. Вспомним путь:
| Модуль | Что узнали | Что написали сами |
|---|---|---|
| Линейная алгебра | векторы, матрицы, преобразования, собственные векторы | PCA |
| Матанализ | производные, цепное правило, градиент | численный градиент |
| Как учатся модели | функция потерь, градиентный спуск, граф вычислений | движок автодифференцирования, nanotorch |
| Нейронные сети | нейрон, слои, активации, потери, инициализация, нормализация, оптимизаторы, регуляризация | полносвязную сеть, распознающую цифры |
| Свёрточные сети | свёртка, пулинг, архитектуры | свёртку, свёрточную сеть |
| Трансформеры | токены, эмбеддинги, внимание, энкодер | BPE, word2vec, свой трансформер |
Вся библиотека nanotorch, которой мы пользовались, — это около тысячи строк на Python и NumPy, и каждую её часть мы разобрали. Остался последний шаг — модель, которая пишет текст.
Декодер и GPT
Энкодер читает весь текст сразу. GPT (Generative Pre-trained Transformer) устроен почти так же — те же блоки, — но учится предсказывать следующий токен:
Чтобы задача не была тривиальной, позиция t не должна видеть позиции после себя. Для этого в каждом блоке включают причинную маску: веса внимания выше диагонали обнуляются. Тогда за один прямой проход модель учится сразу на всех T позициях: по первому символу предсказать второй, по двум — третий и так далее.
Генерация — цикл: подаём текст, берём распределение следующего токена, выбираем токен, дописываем, повторяем. Выбирать можно жадно (самый вероятный) или случайно из распределения \text{softmax}(z / \tau), где \tau — температура: маленькая — предсказуемый текст, большая — более смелый и бессвязный (вспомните виджет softmax из урока о классификации).
Мини-GPT на Пушкине
Обучим маленький GPT по символам на прологе к «Руслану и Людмиле». Модель: эмбеддинги символов и позиций, один блок трансформера с причинной маской, линейная голова на размер алфавита. Обучение займёт около полуминуты:
import numpy as np
import nanotorch as nt
import vis
text = """У лукоморья дуб зелёный;
Златая цепь на дубе том:
И днём и ночью кот учёный
Всё ходит по цепи кругом;
Идёт направо — песнь заводит,
Налево — сказку говорит.
Там чудеса: там леший бродит,
Русалка на ветвях сидит;
Там на неведомых дорожках
Следы невиданных зверей;
Избушка там на курьих ножках
Стоит без окон, без дверей;
Там лес и дол видений полны;
Там о заре прихлынут волны
На брег песчаный и пустой,
И тридцать витязей прекрасных
Чредой из вод выходят ясных,
И с ними дядька их морской;
Там королевич мимоходом
Пленяет грозного царя;
Там в облаках перед народом
Через леса, через моря
Колдун несёт богатыря;
В темнице там царевна тужит,
А бурый волк ей верно служит;
Там ступа с Бабою Ягой
Идёт, бредёт сама собой;
Там царь Кащей над златом чахнет;
Там русский дух… там Русью пахнет!
И там я был, и мёд я пил;
У моря видел дуб зелёный;
Под ним сидел, и кот учёный
Свои мне сказки говорил."""
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
data = np.array([stoi[c] for c in text])
V, T, D = len(chars), 16, 32
print(f"символов в тексте: {len(text)}, алфавит: {V}")
mask = np.triu(np.ones((T, T), dtype=bool), k=1) # причинная маска
class TinyGPT(nt.Module):
def __init__(self):
self.tok = nt.Embedding(V, D)
self.pos = nt.Embedding(T, D)
self.block = nt.TransformerEncoderLayer(D, num_heads=2, d_ff=4 * D)
self.ln = nt.LayerNorm(D)
self.head = nt.Linear(D, V)
def forward(self, ids):
t = ids.shape[1]
h = self.tok(ids) + self.pos(np.arange(t))
h = self.block(h, mask[:t, :t]) # тот же блок, но с маской — это и есть декодер
return self.head(self.ln(h))
nt.manual_seed(0)
model = TinyGPT()
print("параметров:", model.num_params())
opt = nt.AdamW(model.parameters(), lr=0.003, weight_decay=0.01)
rng = np.random.default_rng(0)
def generate(prompt, n, temperature):
model.eval()
ids = [stoi[c] for c in prompt]
for _ in range(n):
logits = model(np.array([ids[-T:]])).data[0, -1] / temperature
p = np.exp(logits - logits.max())
ids.append(int(rng.choice(V, p=p / p.sum())))
model.train()
return "".join(chars[i] for i in ids)
losses = []
for step in range(401):
starts = rng.integers(0, len(data) - T - 1, size=32)
x = np.stack([data[s:s + T] for s in starts])
y = np.stack([data[s + 1:s + T + 1] for s in starts]) # цель — сдвиг на один символ
loss = nt.cross_entropy(model(x).reshape(-1, V), y.reshape(-1))
opt.zero_grad()
loss.backward()
opt.step()
losses.append(loss.data.item())
if step in (0, 100, 400):
print(f"\n--- шаг {step}, потери {loss.data.item():.2f} ---")
print(generate("Там ", 80, temperature=0.8))
vis.line(losses, title="Потери мини-GPT", xlabel="шаг")
Посмотрите, как меняется текст. На шаге 0 — случайные символы. Через сотню шагов появляются пробелы, переносы строк и слоги, похожие на русские. К концу — слова из текста, точки с запятой в конце строк и «пушкинский» ритм, хотя смысла почти нет. Модель с 17 тысячами параметров, обученная полминуты на 900 символах, выучила статистику: какие буквы следуют за какими, где ставить пробел и перенос строки.
Попробуйте изменить код: температуру (0.3 или 1.5), начало текста, число шагов, размер модели D, число блоков. Если обучать дольше, модель начнёт просто запоминать стихотворение наизусть — данных слишком мало, это переобучение в чистом виде.
Чем настоящие модели отличаются от нашей
По устройству — почти ничем: эмбеддинги, позиционные кодировки, блоки «внимание + FFN» с LayerNorm и остаточными связями, причинная маска, голова на словарь, кросс-энтропия, AdamW. Разница — в масштабе и в том, как их учат:
- Размер. У нас 17 тысяч параметров и 1 блок; у больших моделей — миллиарды и сотни миллиардов параметров, десятки и сотни блоков, d_{\text{model}} в тысячи, десятки голов.
- Данные. У нас 900 символов; большие модели учат на триллионах токенов — значительной части интернета, книг и кода. Токены — части слов (BPE), а не символы.
- Вычисления. Тысячи видеокарт неделями и месяцами. Всё, что мы делали циклами на NumPy, там — оптимизированные ядра на GPU, смешанная точность, распределение модели по многим устройствам.
- Этапы обучения. Предобучение на предсказании следующего токена даёт модель, которая умеет продолжать текст. Чтобы она стала полезным ассистентом, её дообучают на примерах диалогов (instruction tuning) и затем по оценкам людей (RLHF) — модель учится отвечать так, как люди считают хорошим и безопасным.
- Улучшения архитектуры. RMSNorm вместо LayerNorm, поворотные позиционные кодировки (RoPE), активации SwiGLU в FFN, KV-cache и группированные ключи для быстрой генерации, смесь экспертов (MoE). Всё это — вариации того, что мы прошли.
Бывают и другие варианты трансформера: энкодер-декодер (исходный трансформер, T5) — для перевода и пересказа; декодер в нём смотрит на выход энкодера через перекрёстное внимание, как в модели перевода из урока о внимании. Трансформеры работают и с картинками (ViT), звуком, видео, а мультимодальные модели принимают всё сразу.
От nanotorch к PyTorch
Интерфейс nanotorch мы специально сделали похожим на PyTorch — главную библиотеку глубокого обучения. Переход почти механический:
| nanotorch | PyTorch |
|---|---|
import nanotorch as nt |
import torch, torch.nn as nn, torch.nn.functional as F |
nt.tensor(x) |
torch.tensor(x) |
nt.Linear, nt.Conv2d, nt.Embedding, nt.LayerNorm |
nn.Linear, nn.Conv2d, nn.Embedding, nn.LayerNorm |
class Net(nt.Module) + forward |
class Net(nn.Module) + __init__ с super().__init__() + forward |
nt.cross_entropy(logits, y) |
F.cross_entropy(logits, y) |
nt.AdamW(model.parameters(), lr=…) |
torch.optim.AdamW(model.parameters(), lr=…) |
opt.zero_grad(); loss.backward(); opt.step() |
то же самое |
model.train(), model.eval() |
то же самое |
nt.fit(...) |
цикл обучения пишут сами (или берут Lightning, Hugging Face Trainer) |
Главное отличие — PyTorch считает на видеокартах (model.to("cuda")) в сотни раз быстрее и содержит готовые реализации всего, что мы писали руками. Но теперь вы знаете, что происходит внутри каждого вызова.
Что изучать дальше
- Практика. Установите PyTorch и повторите проекты курса: распознавание цифр (на полноценном MNIST 28 \times 28), свёрточную сеть на CIFAR-10, мини-GPT на большом тексте. Соревнования и датасеты — на Kaggle.
- Андрей Карпати, «Neural Networks: Zero to Hero» — видеокурс, где с нуля пишут движок автодифференцирования (micrograd) и GPT; после нашего курса он пойдёт легко.
- «Погружение в глубокое обучение» (Dive into Deep Learning, d2l.ai) — бесплатный учебник с кодом, есть перевод на русский.
- Курсы Stanford CS231n (свёрточные сети и компьютерное зрение) и CS224n (обработка естественного языка) — лекции и задания в открытом доступе.
- Hugging Face — библиотека
transformersи тысячи предобученных моделей; там же бесплатный курс по работе с ними. - Книга Гудфеллоу, Бенжио, Курвилля «Глубокое обучение» — фундаментальный учебник по теории.
- Для классического машинного обучения (деревья решений, градиентный бустинг, кластеризация) — библиотека scikit-learn и её документация.
Итог курса
- Нейросеть — это композиция простых дифференцируемых функций с обучаемыми параметрами.
- Обучение — градиентный спуск по функции потерь; градиенты считает обратное распространение по графу вычислений.
- Архитектура встраивает знания о данных: свёртки — о локальности и сдвигах в картинках, внимание — о связях между элементами последовательности.
- Большие языковые модели — это трансформеры-декодеры, обученные предсказывать следующий токен на огромных данных.
Вы прошли путь от вектора до GPT и написали каждую деталь сами. Дальше — практика.
Практика решено 0 из 5
Зачем в GPT причинная (causal) маска внимания?
Что происходит при генерации, если поставить температуру очень близкой к нулю?
Сколько раз нужно запустить модель (прямой проход), чтобы сгенерировать 100 новых токенов?
Причинная маска. Напишите causal_mask(T) — булев массив (T, T), где True означает «смотреть нельзя»:
позиция i может смотреть только на позиции j \le i.
Какие тесты проверяют код (2)
m = causal_mask(4) assert m.dtype == bool and m.shape == (4, 4) assert np.array_equal(m, [[0, 1, 1, 1], [0, 0, 1, 1], [0, 0, 0, 1], [0, 0, 0, 0]]), "True строго выше диагонали" import nanotorch as nt rng = np.random.default_rng(0) q = nt.tensor(rng.normal(size=(5, 4))) _, w = nt.scaled_dot_product_attention(q, q, q, mask=causal_mask(5)) assert np.allclose(np.triu(w.data, 1), 0), "С такой маской веса над диагональю нулевые"
import numpy as np
def causal_mask(T):
return np.triu(np.ones((T, T), dtype=bool), k=1)
Жадная генерация. Напишите greedy_generate(next_logits, prompt, n): начните со списка токенов prompt
и n раз добавьте токен с наибольшим логитом. next_logits(tokens) — функция, которая по списку токенов
возвращает массив логитов следующего токена. Верните весь список (подсказка + новые токены).
Какие тесты проверяют код (2)
# «модель»: следующий токен = (последний + 1) mod 5 f = lambda toks: np.eye(5)[(toks[-1] + 1) % 5] assert greedy_generate(f, [3], 4) == [3, 4, 0, 1, 2] f = lambda toks: np.eye(3)[len(toks) % 3] prompt = [0] out = greedy_generate(f, prompt, 3) assert out == [0, 1, 2, 0], "Каждый шаг — новый вызов модели на всём накопленном тексте" assert prompt == [0], "Не меняйте переданный список подсказки"
import numpy as np
def greedy_generate(next_logits, prompt, n):
tokens = list(prompt)
for _ in range(n):
tokens.append(int(np.argmax(next_logits(tokens))))
return tokens
Войдите, чтобы отмечать пройденные уроки.