Перейти к содержанию
Educora
Университет25 мин40 / 42

PyTorch: цикл обучения модели

Dataset и DataLoader, функции потерь, оптимизаторы SGD и Adam, полный цикл обучения, режим eval и точность, сохранение модели через state_dict — на примере обучения небольшого классификатора на синтетических данных.

Проверь себя
В этом уроке ты узнаешь
  • Делить данные на пакеты с помощью Dataset и DataLoader
  • Выбирать функцию потерь и оптимизатор под задачу
  • Писать полный цикл обучения и оценивать модель на валидационных/тестовых данных
  • Сохранять модель через state_dict и загружать её обратно

Теперь у нас есть все детали: тензоры, autograd и nn.Module. Соберём их в стандартный рецепт, который специалисты пишут каждый день: данные → DataLoader → модель → потери → оптимизатор → цикл обучения → оценка → сохранение. Любопытно, что этот каркас почти одинаков и для крошечного классификатора, и для языковой модели с миллиардами параметров — меняется лишь размер данных и модели.

Dataset и DataLoader

Dataset — объект, который ведёт себя как список примеров: __len__ возвращает число примеров, а __getitem__(i) — i-ю пару (x, y). Если данные уже в тензорах, хватит готового TensorDataset; для чтения из файлов (изображений, текстов) пишут собственный класс. DataLoader собирает из Dataset пакеты, при необходимости перемешивает примеры каждую эпоху (shuffle=True) и умеет загружать данные параллельно через num_workers.

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

class SquaresDataset(Dataset):
    def __init__(self, n):
        self.x = torch.arange(n, dtype=torch.float32)

    def __len__(self):
        return len(self.x)

    def __getitem__(self, i):
        return self.x[i], self.x[i] ** 2

ds = SquaresDataset(10)
print(len(ds), ds[3])
loader = DataLoader(ds, batch_size=4)
for xb, yb in loader:
    print(xb.tolist(), yb.tolist())
Ожидаемый результат
10 (tensor(3.), tensor(9.))
[0.0, 1.0, 2.0, 3.0] [0.0, 1.0, 4.0, 9.0]
[4.0, 5.0, 6.0, 7.0] [16.0, 25.0, 36.0, 49.0]
[8.0, 9.0] [64.0, 81.0]
10 примеров, размер пакета 4: два полных пакета и последний из 2 примеров. DataLoader сам собирает отдельные примеры в тензоры.
Определение
Пакет, шаг (итерация) и эпоха

Пакет (батч) — группа примеров, обрабатываемых вместе для одного обновления. Шаг, или итерация, — одно обновление параметров по одному пакету. Эпоха — один проход, за который каждый обучающий пример «увиден» один раз. Обучение обычно длится десятки эпох, и каждая эпоха состоит из множества шагов.

steps per epoch = ⌈N / B⌉ updates = epochs · ⌈N / B⌉steps per epoch = ⌈N / B⌉ updates = epochs · ⌈N / B⌉
где:
  • Nчисло примеров в обучающей выборке
  • Bразмер пакета (batch size)
  • ⌈ ⌉округление вверх: последний неполный пакет — тоже шаг

Функции потерь и оптимизаторы

ЗадачаПоследний слойПотериЦели
Регрессияnn.Linear(h, 1)nn.MSELossчисла float32
Бинарная классификацияnn.Linear(h, 1)nn.BCEWithLogitsLoss0,0 или 1,0 (float32)
Классификация на K классовnn.Linear(h, K)nn.CrossEntropyLossномера классов 0…K−1 (int64)
L = −zₜ + ln ∑ⱼ₌₁ᴷ eᶻʲ
где:
  • zсырые логиты модели
  • tномер правильного класса

nn.CrossEntropyLoss объединяет log-softmax и перекрёстную энтропию: −ln softmax(z)ₜ = −zₜ + ln ∑ eᶻʲ. Для пакета потери усредняются.

Пример 1: потери по логитам

а) Логиты z = (2; 0,5; −1), правильный класс 0. Чему равна CrossEntropyLoss?
б) Предсказания (2,5; 0), истинные значения (3; −0,5). Чему равна MSELoss?

Показать решение
а) e² + √e + e⁻¹ ≈ 7,389 + 1,649 + 0,368 = 9,406; ln 9,406 ≈ 2,2413.
L = −2 + 2,2413 = 0,2413.
б) Ошибки −0,5 и 0,5, их квадраты 0,25 и 0,25; среднее 0,25.
Python
import torch
from torch import nn

logits = torch.tensor([[2.0, 0.5, -1.0]])
target = torch.tensor([0])
print(round(nn.CrossEntropyLoss()(logits, target).item(), 4))
pred = torch.tensor([2.5, 0.0])
true = torch.tensor([3.0, -0.5])
print(round(nn.MSELoss()(pred, true).item(), 4))
Ожидаемый результат
0.2413
0.25

Оптимизатор обновляет параметры по их градиентам. Самый простой — torch.optim.SGD: его метод step() применяет θ ← θ − η · g. Код ниже повторяет пример «одного шага» из первого урока (x = 2, y = 6, w = 1, η = 0,1) и получает тот же ответ w = 2,6. А zero_grad() сбрасывает градиенты в None.

Python
import torch

w = torch.tensor([1.0], requires_grad=True)
optimizer = torch.optim.SGD([w], lr=0.1)
loss = ((2 * w - 6) ** 2).sum()
loss.backward()
print(w.grad)
optimizer.step()
print(w)
optimizer.zero_grad()
print(w.grad)
Ожидаемый результат
tensor([-16.])
tensor([2.6000], requires_grad=True)
None
mₜ = β₁·mₜ₋₁ + (1 − β₁)·gₜ vₜ = β₂·vₜ₋₁ + (1 − β₂)·gₜ² θₜ = θₜ₋₁ − η · m̂ₜ / (√v̂ₜ + ε)mₜ = β₁·mₜ₋₁ + (1 − β₁)·gₜ vₜ = β₂·vₜ₋₁ + (1 − β₂)·gₜ² θₜ = θₜ₋₁ − η · m̂ₜ / (√v̂ₜ + ε)
где:
  • gₜградиент на шаге t
  • mₜ, vₜскользящие средние градиента и его квадрата (импульс и масштаб)
  • m̂ₜ, v̂ₜзначения с поправкой на смещение: mₜ/(1 − β₁ᵗ), vₜ/(1 − β₂ᵗ)
  • β₁, β₂, ε, ηв PyTorch по умолчанию 0,9; 0,999; 10⁻⁸; 0,001

Оптимизатор Adam. Каждый параметр получает свой размер шага: параметр с постоянно большими градиентами обновляется осторожно, с маленькими — смелее. SGD с импульсом (momentum) работает по формуле v ← μ·v + g, θ ← θ − η·v.

Цикл обучения

Теперь настоящая задача. Сгенерируем точки в форме двух переплетённых полумесяцев («две луны»): по 500 точек в каждом классе, и прямой линией их не разделить — значит, нужна нелинейная модель. Делим данные на 800 обучающих и 200 тестовых примеров.

Python
import math
import torch
from torch import nn
from torch.utils.data import TensorDataset, DataLoader, random_split

torch.manual_seed(42)

def make_moons(n, noise=0.2):
    t = torch.rand(n) * math.pi
    upper = torch.stack([torch.cos(t), torch.sin(t)], dim=1)
    lower = torch.stack([1 - torch.cos(t), 0.5 - torch.sin(t)], dim=1)
    X = torch.cat([upper, lower]) + noise * torch.randn(2 * n, 2)
    y = torch.cat([torch.zeros(n), torch.ones(n)]).long()
    return X, y

X, y = make_moons(500)
train_set, test_set = random_split(TensorDataset(X, y), [800, 200])
train_loader = DataLoader(train_set, batch_size=32, shuffle=True)
test_loader = DataLoader(test_set, batch_size=100)
xb, yb = next(iter(train_loader))
print(len(train_set), len(test_set), len(train_loader))
print(xb.shape, yb.shape, yb.dtype)
Ожидаемый результат
800 200 25
torch.Size([32, 2]) torch.Size([32]) torch.int64
800 / 32 = 25 шагов за эпоху. Метки имеют тип int64, потому что CrossEntropyLoss ожидает номера классов. Тестовые данные не перемешиваются.
  1. 1
    Обнули градиенты

    optimizer.zero_grad() — очисти градиенты предыдущего шага.

  2. 2
    Прямой проход

    logits = model(xb) — предсказания для пакета.

  3. 3
    Потери

    loss = loss_fn(logits, yb) — один скаляр.

  4. 4
    Обратный проход

    loss.backward() — градиенты всех параметров.

  5. 5
    Шаг

    optimizer.step() — обнови параметры.

Python
device = 'cuda' if torch.cuda.is_available() else 'cpu'
model = nn.Sequential(
    nn.Linear(2, 16), nn.ReLU(),
    nn.Linear(16, 16), nn.ReLU(),
    nn.Linear(16, 2),
).to(device)
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)

for epoch in range(1, 21):
    model.train()
    total = 0.0
    for xb, yb in train_loader:
        xb, yb = xb.to(device), yb.to(device)
        optimizer.zero_grad()
        loss = loss_fn(model(xb), yb)
        loss.backward()
        optimizer.step()
        total += loss.item() * len(xb)
    if epoch == 1 or epoch % 5 == 0:
        print(f'epoch {epoch:2d}  loss {total / len(train_set):.4f}')
Ожидаемый результат
epoch  1  loss 0.4741
epoch  5  loss 0.1200
epoch 10  loss 0.0864
epoch 15  loss 0.0715
epoch 20  loss 0.0823
Продолжение предыдущего кода. Результат получен в PyTorch 2.14 на CPU; в другой версии или на GPU числа могут немного отличаться.

Потери быстро падают в первые эпохи, а потом колеблются около 0,07–0,09. Небольшой рост на 20-й эпохе — это нормально: мини-батчи случайны, поэтому шаги шумные. Складывая loss.item() * len(xb) и деля на N, мы получаем точное среднее потерь за эпоху (даже если последний пакет меньше).

Размер пакета B — тоже гиперпараметр. Большой пакет точнее оценивает градиент и лучше загружает GPU, но требует больше памяти и даёт меньше обновлений за эпоху. Маленький пакет шумный, однако этот шум иногда помогает обобщению. На практике начинают со значений от 32 до 256; при удвоении пакета обычно можно немного увеличить и скорость обучения.

В реальных проектах в цикл добавляют ещё два элемента. Планировщик скорости обучения (scheduler) со временем уменьшает η: например, torch.optim.lr_scheduler.CosineAnnealingLR, а scheduler.step() вызывают в конце каждой эпохи. Ранняя остановка (early stopping) после каждой эпохи измеряет потери на валидации, сохраняет лучшую модель и прекращает обучение, если потери не улучшаются несколько эпох подряд, — это самая простая защита от переобучения.

Оценка: режим eval и точность

При оценке важны две вещи. model.eval() меняет поведение некоторых слоёв: Dropout отключается, а BatchNorm использует статистики, накопленные при обучении. torch.no_grad() отключает построение графа. Это разные задачи, и обычно нужны обе сразу. Главная метрика в классификации — точность (accuracy):

accuracy = (1/N) · ∑ᵢ [argmax(zᵢ) = yᵢ]accuracy = (1/N) · ∑ᵢ [argmax(zᵢ) = yᵢ]
где:
  • argmax(zᵢ)класс с наибольшим логитом для i-го примера — предсказание
  • [ … ]1, если условие выполнено, иначе 0
Python
def accuracy(model, loader):
    model.eval()
    correct = 0
    with torch.no_grad():
        for xb, yb in loader:
            xb, yb = xb.to(device), yb.to(device)
            correct += (model(xb).argmax(dim=1) == yb).sum().item()
    return correct / len(loader.dataset)

print(f'train accuracy: {accuracy(model, train_loader):.3f}')
print(f'test accuracy:  {accuracy(model, test_loader):.3f}')
Ожидаемый результат
train accuracy: 0.974
test accuracy:  0.975
Точность на обучении и на тесте близка — переобучения нет. Оставшиеся ≈ 2,5 % ошибок приходятся на места, где из-за шума две луны перекрываются.
Пример 2: посчитай шаги и точность

а) Сколько шагов за эпоху при 800 примерах и размере пакета 64? Сколько обновлений за 20 эпох?
б) Из 200 тестовых примеров правильно классифицированы 195. Чему равна точность?

Показать решение
а) 800 / 64 = 12,5 ⇒ ⌈12,5⌉ = 13 шагов (в последнем пакете 32 примера). 20 · 13 = 260 обновлений.
б) 195 / 200 = 0,975, то есть 97,5 % — ровно результат нашей модели.

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

model.state_dict() — словарь «имя параметра → тензор». Рекомендуется сохранять именно этот словарь, а не весь объект. Чтобы загрузить, заново создают модель с той же архитектурой и вызывают load_state_dict; map_location позволяет открыть на CPU файл, сохранённый на GPU.

Python
torch.save(model.state_dict(), 'moons.pt')
restored = nn.Sequential(
    nn.Linear(2, 16), nn.ReLU(),
    nn.Linear(16, 16), nn.ReLU(),
    nn.Linear(16, 2),
).to(device)
restored.load_state_dict(torch.load('moons.pt', map_location=device))
print(list(restored.state_dict().keys()))
print(f'restored test accuracy: {accuracy(restored, test_loader):.3f}')
Ожидаемый результат
['0.weight', '0.bias', '2.weight', '2.bias', '4.weight', '4.bias']
restored test accuracy: 0.975

Чтобы прервать долгое обучение и потом продолжить, сохраняют чекпойнт: torch.save({'epoch': epoch, 'model': model.state_dict(), 'optimizer': optimizer.state_dict()}, 'ckpt.pt'). Статистики импульса Adam тоже хранятся в state_dict оптимизатора — без них продолжение было бы «холодным стартом».

Главное

  • Dataset выдаёт примеры, DataLoader делит их на пакеты и перемешивает; в эпохе ⌈N / B⌉ шагов.
  • Регрессия — MSELoss, классификация на K классов — CrossEntropyLoss с логитами и метками int64.
  • Цикл: zero_grad → прямой проход → потери → backward → step; на новой задаче Adam (lr = 0,001) — хорошее начало.
  • Оценивают с model.eval() и torch.no_grad(); точность = верные предсказания / N.
  • Модель сохраняют через state_dict и загружают в ту же архитектуру через load_state_dict.

Проверь себя

Вопросов: 10. Каждый правильный ответ приносит XP.

1 / 10
Какой порядок шага обучения правильный?