← Артефакты
Фаза 3. Практик·Урок 35

Урок 35. Знакомство с PyTorch / TensorFlow

Цель: пересобрать сеть из Урока 34 на настоящем фреймворке и понять, что он делает за тебя Время: ~70 минут Источник: AI-For-Beginners + документация PyTorch

Зачем тебе этот урок

В Уроке 34 ты руками написал Linear, ReLU, Sigmoid, MSE и backward к каждому слою. Теперь ты знаешь, что внутри фреймворка: PyTorch для тебя не магия, а автоматизация того, что ты уже делал вручную.

Тот же XOR и та же архитектура — но на инструменте, на котором пишут настоящие модели.

1. Что framework даёт поверх твоего NumPy-кода

Ты писал сам в Уроке 34Что даёт PyTorch
backward() к каждому слоюAutograd — считает градиенты сам
NumPy, только CPUТот же код на видеокарте (CUDA) или на чипе Apple (MPS)
Linear, ReLU, SigmoidСотни слоёв: Conv2d, LSTM, Attention, Dropout, BatchNorm
w -= lr * gradОптимизаторы SGD, Adam, AdamW и планировщики learning rate
Датасет одним куском в памятьDataset и DataLoader: батчи, перемешивание, параллельная подгрузка
НичегоЭкосистема: torchvision, Hugging Face, предобученные модели

Последняя строка весит больше остальных: в Уроке 38 ты дообучишь готовую сеть одной строкой именно потому, что веса лежат в общем формате.

2. Tensor — главный объект

Tensor — многомерный массив чисел: скаляр, вектор, матрица, картинка RGB. Внешне почти np.ndarray, API нарочно сделан похожим.

a = torch.tensor([[1., 2.], [3., 4.]])
print(a.shape)    # torch.Size([2, 2])  — форма
print(a.dtype)    # torch.float32       — тип чисел
print(a.device)   # cpu                 — где лежат данные

b = torch.from_numpy(arr)   # из NumPy, память общая;  a.numpy() — обратно

Три отличия от NumPy:

  1. device. Знает, в какой памяти лежит: cpu, cuda (NVIDIA), mps (Apple Silicon).
  2. Градиенты. Помнит, как получился, и умеет по этой истории считать производные.
  3. dtype float32 по умолчанию, а не float64: видеокарты считают его быстрее, точности хватает.
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
x = torch.randn(1000, 1000).to(device)   # перенесли на устройство
y = (x @ x).to("cpu")                    # посчитали там, вернули обратно

Главное правило: модель и данные лежат на одном устройстве, иначе PyTorch скажет «expected all tensors to be on the same device».

3. Autograd — почему больше не надо писать backward

В Уроке 34 ты для каждого слоя выводил производную и писал backward. Autograd делает это сам: с флагом requires_grad=True tensor на лету пишет граф вычислений — какие операции над ним делали. Потом PyTorch идёт по нему назад и применяет chain rule.

x = torch.tensor(3.0, requires_grad=True)
y = x ** 2 + 5 * x        # PyTorch запомнил все операции
y.backward()              # проход назад по графу
print(x.grad)             # tensor(11.)  —  dy/dx = 2x + 5 = 11 при x = 3

У параметров модели флаг стоит автоматически: вызвал loss.backward() — во всех весах появилось .grad.

Почему градиенты обнуляют. PyTorch не заменяет .grad, а прибавляет к нему — так можно накопить градиент по нескольким мелким батчам и шагнуть один раз. Забудешь zero_grad() — сложатся все прошлые итерации, шаг выйдет огромным, обучение развалится.

Ещё режим torch.no_grad(): граф не строится и память не тратится — оборачивай в него инференс и валидацию.

4. Та же сеть, но через nn.Module

В Уроке 34 ты писал классы со своими forward и backward и собирал их в Network — ~80 строк. Стало одной строкой: nn.Sequential(nn.Linear(2, 4), nn.ReLU(), nn.Linear(4, 1)).

nn.Sequential годится, когда слои идут строго друг за другом. Появились ветвления — пишут свой класс, наследник nn.Module. Так выглядит почти весь код, который ты будешь читать:

class XORNet(nn.Module):
    def __init__(self):
        super().__init__()          # обязательно, иначе слои не зарегистрируются
        self.fc1 = nn.Linear(2, 8)
        self.fc2 = nn.Linear(8, 1)
        self.relu = nn.ReLU()

    def forward(self, x):           # только прямой проход
        return self.fc2(self.relu(self.fc1(x)))

Ключевое отличие: метода backward здесь нет. Ты описываешь прямой проход, обратный autograd соберёт сам. Веса nn.Linear инициализируются разумно из коробки — про He и Xavier думать не нужно.

5. Цикл обучения построчно

Тот самый цикл из Урока 34. Выучи наизусть: он один и для картинок, и для текста, и для языковых моделей.

pred = model(xb)               # 1. forward  — прогон вперёд
loss = loss_fn(pred, yb)       # 2. loss     — насколько ошиблись
optimizer.zero_grad()          # 3. стереть градиенты прошлой итерации
loss.backward()                # 4. backward — посчитать новые
optimizer.step()               # 5. update   — сдвинуть веса

6. Оптимизаторы: SGD и Adam

SGD — то, что ты писал сам: w ← w − lr · grad, один размер шага для всех параметров. Momentum добавляет разгон туда, куда градиент указывает стабильно: шарик катится по склону, а не пересчитывает маршрут каждый сантиметр.

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

opt = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
opt = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01)

Что брать. По умолчанию Adam или AdamW с lr=1e-3: обучается почти всегда и без настройки. SGD с momentum — когда выжимают последние доли качества. AdamW — стандарт трансформеров.

7. Данные: Dataset, DataLoader, батчи

Dataset отвечает на два вопроса: сколько элементов и как достать i-й. DataLoader оборачивает его и выдаёт батчи.

from torch.utils.data import Dataset, DataLoader, TensorDataset

class MyDataset(Dataset):
    def __len__(self):         return len(self.X)
    def __getitem__(self, i):  return self.X[i], self.y[i]

loader = DataLoader(TensorDataset(X, y), batch_size=32, shuffle=True)

Зачем батчи. Память: датасет целиком в видеопамять не влезет, батч из 32 картинок влезет. Скорость: шаг раз в 32 примера, а не раз в 50000. Качество: шумный градиент по батчу помогает выбираться из плохих локальных минимумов — размер батча это гиперпараметр, а не только память.

Зачем shuffle. Если данные лежат по порядку (сначала нули, потом единицы), батчи выйдут однородными и модель будет мотать. Ставят на train, на валидации не нужен.

8. Режимы train() и eval()

Часть слоёв ведёт себя по-разному в обучении и в инференсе:

model.train()     # dropout активен, batchnorm обновляет статистику
model.eval()      # dropout выключен, batchnorm использует накопленное

with torch.no_grad():
    preds = model(X_test)

Это разные вещи: eval() переключает поведение слоёв, no_grad() отключает граф. На валидации нужны обе, и обе легко забыть — ничего не падает, метрики просто врут.

9. Сохранение и загрузка модели

state_dict — словарь «имя параметра → tensor с весами». Именно его пишут на диск.

torch.save(model.state_dict(), "model.pt")

model = XORNet()                                  # сначала та же архитектура
model.load_state_dict(torch.load("model.pt"))     # потом залить веса
model.eval()

Чтобы продолжить обучение с того же места, в файл кладут и optimizer.state_dict(). Модель целиком не сохраняют: такой файл привязан к структуре проекта, переименуешь класс — загрузка сломается. С чужими весами осторожно: формат допускает выполнение кода. В свежих версиях weights_only=True стоит по умолчанию, в старых — указывай явно.

10. Полный пример: XOR на PyTorch

Тот же XOR, но с шумом и в 400 точках, чтобы батчи имели смысл.

import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader

# 1. Данные: четыре угла XOR с шумом
corners = torch.tensor([[0., 0.], [0., 1.], [1., 0.], [1., 1.]])
X = corners.repeat(100, 1) + torch.randn(400, 2) * 0.1
y = torch.tensor([[0.], [1.], [1.], [0.]]).repeat(100, 1)
loader = DataLoader(TensorDataset(X, y), batch_size=32, shuffle=True)

# 2. Устройство: NVIDIA, чип Apple или CPU
device = torch.device("cuda" if torch.cuda.is_available()
                      else "mps" if torch.backends.mps.is_available() else "cpu")

# 3. Модель — архитектура из Урока 34
model = nn.Sequential(
    nn.Linear(2, 8),
    nn.ReLU(),
    nn.Linear(8, 1),              # логиты, sigmoid сидит внутри loss
).to(device)

loss_fn = nn.BCEWithLogitsLoss()  # sigmoid + cross-entropy, так устойчивее
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)

# 4. Цикл обучения
model.train()
for epoch in range(50):
    for xb, yb in loader:
        xb, yb = xb.to(device), yb.to(device)
        loss = loss_fn(model(xb), yb)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
    if epoch % 10 == 0:
        print(f"epoch {epoch:3d}   loss {loss.item():.4f}")

# 5. Проверка и сохранение
model.eval()
with torch.no_grad():
    probs = torch.sigmoid(model(corners.to(device)))
    print(probs.round().flatten().tolist())   # ждём [0.0, 1.0, 1.0, 0.0]

torch.save(model.state_dict(), "xor.pt")

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

11. PyTorch vs TensorFlow / Keras

PyTorchTensorFlow / Keras
СтильОбычный код Python, граф строится по ходуРаньше граф объявляли заранее, теперь тоже по ходу
УровеньЦикл обучения пишешь самKeras: model.fit() в одну строку
Где силёнИсследования, Hugging FaceПродакшн: сервинг, мобильные, браузер

Почему в курсе PyTorch. Подавляющее большинство свежих статей, репозиториев и предобученных моделей выходит с кодом на PyTorch, а чужой код читаешь чаще, чем пишешь свой.

Где встретишь TensorFlow. В компаниях со стеком, собранным несколько лет назад; в мобильном и браузерном инференсе; вокруг инфраструктуры Google. Keras теперь работает поверх нескольких движков, так что «Keras» больше не равно «TensorFlow». Религиозной войны нет: понятия те же, выучив один — второй читаешь за вечер.

12. Где это запускать

Если GPU нет — не блокер: Урок 36 про компьютерное зрение пойдёт и на CPU, а с Урока 37 работают три приёма — уменьшить датасет, уменьшить модель, дообучить последний слой предобученной сети (Урок 38). Третий превращает сутки на видеокарте в пять минут на ноутбуке.

13. Тонкости и подводные камни

  • Забыл zero_grad. Градиенты накапливаются, шаг растёт, loss уходит в разнос или в NaN. Частая ошибка.
  • Данные и модель на разных устройствах. «Expected all tensors to be on the same device» — забыл .to(device), проверяй батчи внутри цикла.
  • Копишь loss как tensor. total += loss тащит весь граф и ест память — только loss.item().
  • Формы не совпали, но broadcasting сработал. Предсказание (N, 1) и ответы (N,) молча дадут матрицу N на N и неверный loss. Проверяй .shape.

14. Глоссарий

Tensor

Многомерный массив в PyTorch: знает форму, тип чисел и устройство.

Autograd

Автоматическое дифференцирование: пишет граф операций и считает по нему градиенты при .backward().

nn.Module

Базовый класс модели и слоя. Пишешь forward, обратный проход собирается сам.

Optimizer

Объект, обновляющий веса по градиентам. SGD, Adam, AdamW.

DataLoader

Обёртка над Dataset: режет данные на батчи, перемешивает, грузит параллельно.

state_dict

Словарь «имя параметра → веса». Его пишут на диск и заливают обратно в модель.

15. Практика (60 минут)

  1. Поставь PyTorch, проверь torch.cuda.is_available() и torch.backends.mps.is_available().
  2. Повтори autograd: x = torch.tensor(2.0, requires_grad=True), y = x ** 3, y.backward() — x.grad даст 12.
  3. Запусти пример из секции 10 — предсказания должны сойтись к [0, 1, 1, 0].
  4. Сломай его: закомментируй optimizer.zero_grad(), посмотри, что станет с loss, верни обратно.
  5. Замени Adam на SGD(lr=0.01), потом на SGD(lr=0.01, momentum=0.9), потом поиграй с batch_size (4, 32, 400). Что меняется в скорости и гладкости падения loss?
  6. Открой код из Урока 34 рядом: найди, где твой net.forward, net.backward, net.step.
  7. Запиши в progress.md: какое устройство доступно, что было без zero_grad, какая пара «оптимизатор + batch_size» сошлась быстрее.

16. Проверь себя

1. Чем tensor отличается от массива NumPy?
Знает своё устройство (cpu, cuda, mps), копит граф вычислений для градиентов, по умолчанию float32.

2. Что делает loss.backward()?
Идёт назад по графу и заполняет .grad у параметров. Веса не меняются — их меняет optimizer.step().

3. Зачем нужен zero_grad()?
PyTorch прибавляет новые градиенты к старым. Без обнуления складываются все прошлые итерации и обучение разваливается.

4. Чем model.eval() отличается от torch.no_grad()?
eval() переключает dropout и batchnorm в режим инференса, no_grad() отключает граф. На валидации нужны обе.

5. Что брать по умолчанию, SGD или Adam?
Adam или AdamW с lr=1e-3: подбирает шаг для каждого параметра сам и обучается без настройки.

17. Что должно остаться в голове

  1. Фреймворк не делает ничего нового относительно Урока 34: автоматизирует backward, даёт GPU, слои и экосистему.
  2. Tensor = массив + устройство + история вычислений; модель и данные — на одном устройстве.
  3. Autograd строит граф на лету, .backward() идёт назад и заполняет .grad.
  4. Цикл обучения один: forward → loss → zero_grad → backward → step.
  5. Наследуешь nn.Module и пишешь только forward: backward не нужен.
  6. Adam с lr=1e-3 — разумный старт, SGD с momentum — для доводки.
  7. Батчи — ради памяти, скорости и шума в градиенте; shuffle=True только на train.
  8. train() и eval() переключают dropout и batchnorm, no_grad() — про память.
  9. Сохраняй state_dict, а не объект модели.
📌 Закрепление: ты не «перешёл на PyTorch» — ты просто перестал писать backward руками. Всё, что ты вывел в Уроке 34, спрятано за loss.backward(). Поэтому, когда сеть в Уроке 37 откажется учиться, ты будешь понимать, где искать.