Перейти к содержимому
Ступень 00.2 Python с нуля

Классы и магические методы

Как создавать свои типы данных — классы: от простого счётчика до мини-версии слоя нейросети; как научить свой объект складываться через + и умножаться через * (это понадобится для своего автограда) и как коротко описывать настройки модели.

УРОКstage-0/0-2-python/03-classes.mdx2 чУрок 4 из 7
После урока вы сможете
  • Писать классы с __init__, методами, @property и @classmethod и понимать, что такое self
  • Объяснить, почему model(x) в PyTorch вызывает forward, и собрать мини-версию nn.Module через наследование
  • Перегружать операторы +, *, @ и понимать, зачем нужны __radd__ и __rmul__
  • Описывать конфиги моделей датаклассами и не попадаться на изменяемые значения по умолчанию

Вы уже пользовались объектами, которые умеют что-то делать сами: строка знает .upper() и .split(), список — .append() и .sort(), словарь — .get(). Данные и действия над ними живут вместе. Класс — способ создать такой тип данных самому: например, «токенизатор», который хранит свой словарь и умеет encode и decode.

Вот как выглядит описание модели в PyTorch. Запускать этот код пока не нужно (PyTorch мы разберём в модуле 3.3), но к концу урока вы сможете объяснить каждую его строку:

import torch.nn as nn
class MLP(nn.Module):
def __init__(self, d):
super().__init__()
self.fc = nn.Linear(d, 4 * d)
def forward(self, x):
return self.fc(x)

Здесь сразу четыре идеи этого урока: класс, наследование (nn.Module), вызов конструктора родителя (super().__init__()) и магия, благодаря которой self.fc(x) — вызов объекта как функции. А выражения a + b, W @ x, 2 * loss с тензорами работают потому, что тензор перегружает операторы.

В модуле 3.2 вы напишете собственный автоград — класс Value, который складывается через +, умножается через * и сам считает производные. Этот урок даёт для этого весь синтаксис. В практике вы соберёте класс Vector: маленький «тензор» на чистом Python.

КлассклассEN classШаблон для создания объектов. Любая модель в PyTorch — класс-наследник nn.Module. — шаблон, по которому создаются объектыобъект, экземплярEN object, instance (экземпляры). Класс похож на чертёж, а экземпляры — на дома, построенные по нему: устройство у всех одно, а жильцы (данные) у каждого свои. Класс объединяет данные (атрибуты) и поведение (методы).

Вы уже пользовались классами, просто не знали об этом. str, list и dict — классы: type("кот") печатает <class 'str'>, а "кот".upper() вызывает метод класса str у конкретного экземпляра — строки "кот". Теперь научимся писать свои классы. Начнём с самого простого — счётчика шагов обучения:

class Counter:
"""Счётчик шагов: у каждого счётчика своё число."""
def __init__(self): # вызывается сам при создании объекта
self.steps = 0 # атрибут: число, которое хранит ЭТОТ объект
def tick(self): # метод: действие объекта
self.steps += 1
c1 = Counter() # создаём два независимых объекта по одному чертежу
c2 = Counter()
c1.tick()
c1.tick()
c2.tick()
print(c1.steps, c2.steps) # → 2 1

Что здесь важно:

  • class Counter: — заголовок класса (имена классов принято писать с большой буквы). Внутри — функции с отступом, их называют методами.
  • Counter() — создать новый объект. При этом Python сам вызывает метод __init__ (два подчёркивания с каждой стороны, от «initialize» — подготовить). Он заполняет атрибуты объекта — его личные данные.
  • self — «этот самый объект». Когда вы пишете c1.tick(), Python подставляет c1 на место self, и self.steps += 1 увеличивает счётчик именно у c1. У c2 свой steps, поэтому вышло 2 и 1.

Теперь пример посерьёзнее — символьный токенизатор, такой же, как в учебном примере shakespeare_char из nanoGPT. Внутри — приёмы из прошлых уроков: sorted(set(...)) и генераторы словарей:

class CharTokenizer:
"""Символьный токенизатор: каждый символ текста — отдельный токен."""
def __init__(self, text):
chars = sorted(set(text)) # воспроизводимый порядок (урок 0.2.1)
self.stoi = {ch: i for i, ch in enumerate(chars)}
self.itos = {i: ch for ch, i in self.stoi.items()}
def encode(self, s):
return [self.stoi[ch] for ch in s]
def decode(self, ids):
return "".join(self.itos[i] for i in ids)
@property
def vocab_size(self):
return len(self.stoi)
tok = CharTokenizer("🙂, мир! hello")
print(tok.vocab_size) # → 11
ids = tok.encode("мир🙂")
print(ids) # → [8, 7, 9, 10]
print(tok.decode(ids)) # → мир🙂

Разберём по частям.

  • CharTokenizer("...") создаёт объект и вызывает __init__ — инициализатор. Он заполняет атрибуты нового объекта.
  • self — ссылка на сам объект. В Python её пишут явно первым параметром каждого метода. Вызов tok.encode("мир") — это сокращение для CharTokenizer.encode(tok, "мир"): Python сам подставляет tok в self.
  • Атрибуты (self.stoi) создаются присваиванием, заранее объявлять их не нужно.
  • @property превращает метод в «вычисляемый атрибут»: пишем tok.vocab_size без скобок.

Строка, которая начинается с @ и стоит прямо над def или class, — декоратордекораторEN decoratorСтрока вида @имя над def или class: оборачивает функцию или класс и меняет поведение. Примеры — @property, @classmethod, @dataclass.. Он «оборачивает» функцию или класс и меняет их поведение. Писать свои декораторы в этом курсе не понадобится, а встретятся вам три готовых: @property, @classmethod (ниже) и @dataclass (в конце урока).

Обратите внимание на порядок символов: пробел, !, запятая, латиница, кириллица и в конце эмодзи 🙂 (у него очень большой номер в Юникоде — 128578). sorted упорядочивает по номерам символов, и это даёт одинаковые id при каждом запуске. Поэтому м получил id 8, и — 7, а 🙂 — 10.

Мини-задача: что напечатает print(tok) для этого класса?

Что-то вроде <__main__.CharTokenizer object at 0x7f1ff496e6d0> — имя класса и адрес в памяти. Неинформативно. Как это исправить, вы узнаете в разделе о магических методах (__repr__).

Атрибуты класса и альтернативные конструкторы

Заголовок раздела «Атрибуты класса и альтернативные конструкторы»

Переменная, объявленная прямо в теле класса, — атрибут класса: она одна на все экземпляры. Отсюда старая знакомая ловушка:

class Logger:
history = [] # ОДИН список на все объекты!
def log(self, x):
self.history.append(x)
a, b = Logger(), Logger()
a.log(1); b.log(2)
print(a.history) # → [1, 2] — b записал в «чужую» историю

Это та же проблема, что и с def add_end(L=[]) из прошлого урока: изменяемый объект создан один раз и разделён между всеми. Изменяемое состояние создавайте в __init__: self.history = [].

Атрибуты класса годятся для констант. Например, в MiniMind у класса конфига есть атрибут model_type = "minimind".

Декоратор @classmethod делает метод, который получает класс (cls) вместо экземпляра. Так пишут альтернативные конструкторы. Самый знаменитый из них — AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B") в библиотеке transformers: он создаёт объект не из аргументов, а из файлов модели.

class CharTokenizer:
def __init__(self, chars):
self.stoi = {ch: i for i, ch in enumerate(chars)}
@classmethod
def from_text(cls, text):
return cls(sorted(set(text))) # cls — это сам класс CharTokenizer
tok = CharTokenizer.from_text("мама мыла раму")

НаследованиенаследованиеEN inheritance позволяет создать класс на основе существующего: потомок получает все методы родителя и может переопределить часть из них. В PyTorch каждый слой и каждая модель — потомок nn.Module. Соберём упрощённую копию этой идеи на чистом Python:

class Module:
"""Базовый класс «слоя» — упрощённый nn.Module."""
def __call__(self, x):
return self.forward(x) # model(x) превращается в model.forward(x)
def forward(self, x):
raise NotImplementedError("наследник должен определить forward")
def num_params(self):
return 0
class Linear(Module):
"""y = w * x + b для одного числа — игрушечный линейный слой."""
def __init__(self, w, b):
self.w, self.b = w, b
def forward(self, x):
return self.w * x + self.b
def num_params(self):
return 2
class ReLU(Module):
"""ReLU: отрицательное число → 0, остальные без изменений (подробно — урок 1.0.1)."""
def forward(self, x):
return max(0.0, x)
class Sequential(Module):
def __init__(self, *layers): # *args из прошлого урока
self.layers = layers
def forward(self, x):
for layer in self.layers:
x = layer(x) # каждый слой вызывается как функция
return x
def num_params(self):
return sum(layer.num_params() for layer in self.layers)
model = Sequential(Linear(2.0, -1.0), ReLU(), Linear(-3.0, 0.5))
print(model(2.0), model(0.0), model.num_params()) # → -8.5 0.5 4
flowchart BT
L["Linear<br/>forward: w·x + b"] --> M["Module<br/>__call__ → forward"]
R["ReLU<br/>forward: max(0, x)"] --> M
S["Sequential<br/>forward: слои по очереди"] --> M
M --> O["object"]
Иерархия классов мини-nn.Module: стрелка ведёт от потомка к родителю

Что здесь происходит:

  • class Linear(Module) — Linear наследует от Module. Метод __call__ он получил «бесплатно», а forward и num_params переопределил.
  • model(2.0) вызывает Module.__call__, тот — self.forward(x), а Python ищет forward начиная с класса самого объекта. Один и тот же вызов работает для любого слоя — это называется полиморфизмом.
  • Настоящий nn.Module.__call__ делает то же самое плюс служебную работу: запускает хуки (hooks — дополнительные функции, которые PyTorch вызывает до и после forward, например для отладки и замеров скорости). Поэтому в PyTorch всегда пишут model(x), а не model.forward(x).

Если потомку нужен свой __init__, а родитель тоже что-то инициализирует, вызывайте родителя через super():

class ScaledLinear(Linear):
def __init__(self, w, b, scale):
super().__init__(w, b) # пусть Linear сам заполнит w и b
self.scale = scale
def forward(self, x):
return super().forward(x) * self.scale
print(ScaledLinear(2.0, 1.0, 10)(3.0)) # → 70.0 — (2·3 + 1) · 10
print([c.__name__ for c in ScaledLinear.__mro__])
# → ['ScaledLinear', 'Linear', 'Module', 'object'] порядок поиска методов (MRO)

Последние две строки — для любопытных: так можно посмотреть, в каком порядке Python ищет метод — сначала в самом классе, потом у родителя, потом у «дедушки». object — общий предок всех классов в Python.

Магические методымагический метод (dunder-метод)EN special method, magic methodМетоды вида __add__, __len__, __call__: Python вызывает их сам, когда вы пишете a + b, len(a), a(x). (их ещё называют dunder-методами — от «double underscore») Python вызывает сам, когда вы пишете привычный синтаксис. Определив их, вы учите свой класс вести себя как встроенный тип. Это и есть перегрузка операторовперегрузка операторовEN operator overloading.

Вы пишетеPython вызываетПример в ML
repr(x), вывод в консолиx.__repr__()tensor([1., 2.])
print(x), str(x)x.__str__(), иначе __repr__
len(x)x.__len__()размер Dataset
x[i]x.__getitem__(i)dataset[i], tensor[0, :5]
x(...)x.__call__(...)model(batch)
x + y, x - yx.__add__(y), x.__sub__(y)сложение тензоров
x * yx.__mul__(y)поэлементное умножение
x @ yx.__matmul__(y)матричное умножение
-xx.__neg__()
x == yx.__eq__(y)

Добавьте в класс CharTokenizer ещё один метод:

def __repr__(self):
return f"CharTokenizer(vocab_size={len(self.stoi)})"

Теперь print(tok) и список [tok] покажут CharTokenizer(vocab_size=11). __repr__ — для разработчика: однозначно и по возможности так, чтобы строку можно было вставить обратно в код. __str__ — «красивый» вариант для пользователя; если его нет, print использует __repr__. Поэтому почти всегда достаточно одного __repr__.

Разница видна на строках: str("кот") — это кот, а repr("кот") — 'кот', в кавычках, как в коде. В f-строке запись {x!r} подставляет именно repr(x): f"Token({'кот'!r})" даёт Token('кот').

Вот класс, с которого начнётся модуль 3.2. Пока он умеет только складывать и умножать числа, но уже через операторы:

class Value:
"""Число-обёртка. В модуле 3.2 сюда добавятся градиент и история операций."""
def __init__(self, data):
self.data = data
def __repr__(self):
return f"Value(data={self.data})"
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other) # 3 → Value(3)
return Value(self.data + other.data)
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
return Value(self.data * other.data)
def __radd__(self, other): # other + self, когда other — обычное число
return self + other
def __rmul__(self, other): # other * self
return self * other
a, b = Value(2.0), Value(-3.0)
print(a * b + 10) # → Value(data=4.0)
print(a * 3, 3 * a, 1 + a) # → Value(data=6.0) Value(data=6.0) Value(data=3.0)
print(sum([Value(1), Value(2), Value(3)])) # → Value(data=6)

Как Python вычисляет 3 * a. Слева от знака * стоит обычное число 3, справа — наш объект a; то, над чем выполняется операция, называют операндами:

  1. Сначала пробует (3).__mul__(a). Встроенный int не знает про Value и возвращает специальное значение NotImplemented — «не умею».
  2. Тогда Python пробует «отражённый» метод правого операнда: a.__rmul__(3).
  3. Если и он отсутствует или вернул NotImplemented, получается TypeError: unsupported operand type(s) for *: 'int' and 'Value'.

Без __radd__ не заработал бы и sum(...): встроенная сумма начинает с 0, и первым шагом считает 0 + Value(1). В настоящем micrograd Карпатого у класса Value определены __add__, __mul__, __pow__, __neg__, __radd__, __sub__, __rsub__, __rmul__, __truediv__, __rtruediv__ и __repr__ — та же схема, доведённая до конца.

В PyTorch датасет — это любой объект с __len__ и __getitem__. Сделаем такой для языковой модели: по списку id токенов он выдаёт пары «окно контекста → то же окно, сдвинутое на один токен»:

class TextDataset:
"""Пары «вход → цель» для языковой модели, как torch.utils.data.Dataset."""
def __init__(self, ids, block_size):
self.ids, self.block_size = ids, block_size
def __len__(self):
return len(self.ids) - self.block_size
def __getitem__(self, i):
if not 0 <= i < len(self):
raise IndexError(i)
x = self.ids[i : i + self.block_size]
y = self.ids[i + 1 : i + self.block_size + 1]
return x, y
ds = TextDataset([10, 11, 12, 13, 14, 15], block_size=3)
print(len(ds), ds[0]) # → 3 ([10, 11, 12], [11, 12, 13])
for x, y in ds:
print(x, "->", y) # три пары, последняя: [12, 13, 14] -> [13, 14, 15]

Цикл for по объекту без __iter__ Python делает через __getitem__(0), __getitem__(1), … пока не получит IndexError. Поэтому проверка границ в __getitem__ обязательна: срез списка за его пределами не бросает ошибку, а молча возвращает пустой список, и цикл стал бы бесконечным.

Это тонкость, к которой можно вернуться позже. Без __eq__ оператор == сравнивает идентичность объектов (как is из урока 0.2.1): два разных Value(1) не равны. Определив __eq__, вы сравниваете содержимое. Но тогда Python отключает хеширование (__hash__ = None), и объект нельзя положить в set или использовать ключом словаря: TypeError: unhashable type. Это логично: изменяемый объект с «содержательным» равенством мог бы потеряться в хеш-таблице после изменения.

Для классов, которые в основном хранят данные, писать __init__, __repr__ и __eq__ вручную скучно. Декоратор @dataclassдатакласс (класс данных)EN dataclassДекоратор @dataclass из стандартной библиотеки сам пишет __init__, __repr__ и __eq__. Удобен для конфигов моделей. генерирует их по аннотациям полей. Вот как описан конфиг в nanoGPT Карпатого (значения как в оригинале):

from dataclasses import dataclass, field, asdict, replace
@dataclass
class GPTConfig:
block_size: int = 1024
vocab_size: int = 50304 # словарь GPT-2 (50257), дополненный до кратного 64
n_layer: int = 12
n_head: int = 12
n_embd: int = 768
dropout: float = 0.0
bias: bool = True
cfg = GPTConfig(n_layer=6, n_embd=384)
print(cfg)
# → GPTConfig(block_size=1024, vocab_size=50304, n_layer=6, n_head=12, n_embd=384, dropout=0.0, bias=True)
print(cfg.n_embd // cfg.n_head) # → 32 384 // 12 (что такое «головы внимания» — ступень 4)
print(asdict(cfg)["n_layer"]) # → 6 asdict превращает конфиг в словарь — удобно сохранить в JSON
small = replace(cfg, n_layer=2) # копия с изменённым полем; cfg не меняется

Полезные возможности:

  • Изменяемые значения по умолчанию запрещены. files: list = [] даст ValueError: mutable default <class 'list'> for field files is not allowed: use default_factory. Правильно — files: list[str] = field(default_factory=list): новый список для каждого экземпляра.
  • frozen=True делает экземпляры неизменяемыми: попытка cfg.n_layer = 4 даст FrozenInstanceError. Конфиг уже обученной модели менять не стоит.
  • __post_init__ вызывается после сгенерированного __init__. Удобное место для проверок и производных полей. Проверку удобно писать через assert условие, "сообщение", знакомый вам по тестам из урока 0.1.3: если условие ложно, программа падает с AssertionError и этим сообщением.
@dataclass
class ModelArgs:
dim: int = 512
n_heads: int = 8
def __post_init__(self):
assert self.dim % self.n_heads == 0, "dim должно делиться на n_heads"
self.head_dim = self.dim // self.n_heads
print(ModelArgs().head_dim) # → 64
# ModelArgs(dim=500) # AssertionError: dim должно делиться на n_heads
Мини-задача: что напечатает GPTConfig() == GPTConfig()? А для обычного класса без @dataclass?

Для датакласса — True: сгенерированный __eq__ сравнивает поля. Для обычного класса — False: это два разных объекта, а == по умолчанию сравнивает идентичность.

Напишите класс Vector с арифметикой через операторы: +, -, умножение на число с обеих сторон, @ для скалярного произведения, ==, len, индексация и norm(). Это ровно те приёмы, которые понадобятся в micrograd и которые реализует каждый тензор в PyTorch.

Вектор здесь — просто список чисел, например [1, 2, 3]. Всё, что нужно о его арифметике (подробно и с картинками — в уроке 1.1.1):

ОперацияЧто делаетПример
v + w, v - wскладывает или вычитает числа на одинаковых местах[1, 2, 3] + [4, 5, 6] → [5, 7, 9]
v * 2, 2 * vумножает каждое число на 2[1, 2, 3] * 2 → [2, 4, 6]
v @ w — скалярное произведениескалярное произведениеEN dot product (inner product)a · b = Σ aᵢbᵢ = ‖a‖‖b‖cos θ. Основа нейрона, внимания (q · k) и выходного слоя LLM.перемножает числа на одинаковых местах и складывает результаты; ответ — одно число[1, 2, 3] @ [4, 5, 6] = 1·4 + 2·5 + 3·6 = 32
v.norm() — длина векторанорма вектораEN vector norm«Размер» вектора. L2-норма — длина по Пифагору √(Σ vᵢ²), L1-норма — сумма модулей Σ |vᵢ|.корень из скалярного произведения вектора на себя[3, 4]: 3⋅3+4⋅4=253 \cdot 3 + 4 \cdot 4 = 25, 25=5\sqrt{25} = 5

Складывать векторы разной длины нельзя: в этом случае метод должен остановить программу с ошибкой. Это делает команда raise ValueError("сообщение") — «бросить ошибку ValueError с таким текстом» (подробно об исключениях — в уроке 0.2.4). Квадратный корень даёт math.sqrt(x): модуль math уже подключён в заготовке строкой import math.

Класс Vector с перегрузкой операторов7 тестов
import math


class Vector:
    """Вектор из чисел с арифметикой через операторы — «игрушечный тензор» на чистом Python.

    v = Vector([1, 2, 3]),  w = Vector([4, 5, 6])
    repr(v)       -> "Vector([1, 2, 3])"
    len(v), v[0]  -> 3, 1
    v + w, w - v  -> Vector([5, 7, 9]), Vector([3, 3, 3]) — числа на одинаковых местах; при разной длине — ValueError
    v * 2, 2 * v  -> Vector([2, 4, 6]) — каждое число умножается на 2 (с обеих сторон)
    v @ w         -> 32 — скалярное произведение: 1*4 + 2*5 + 3*6 (одно число)
    Vector([3, 4]).norm() -> 5.0 — длина вектора: sqrt(3*3 + 4*4) = sqrt(v @ v)
    v == w        -> True, если все числа совпадают

    Ошибку бросают так: raise ValueError("длины векторов не совпадают")
    """

    def __init__(self, values):
        self.values = list(values)  # копия: внешний список не должен влиять на вектор

    def __repr__(self):
        # TODO: верните строку "Vector([1, 2, 3])" — подсказка: список в f-строке уже печатается как [1, 2, 3]
        ...

    def __len__(self):
        # TODO
        ...

    def __getitem__(self, i):
        # TODO
        ...

    def __eq__(self, other):
        # TODO: если other не Vector — верните NotImplemented
        ...

    def __add__(self, other):
        # TODO: новый Vector; если длины разные — raise ValueError("...")
        ...

    def __sub__(self, other):
        # TODO
        ...

    def __mul__(self, scalar):
        # TODO: умножение на число (int или float)
        ...

    def __rmul__(self, scalar):
        # TODO: 2 * v — Python вызовет v.__rmul__(2)
        ...

    def __matmul__(self, other):
        # TODO: v @ w — скалярное произведение; при разной длине — ValueError
        ...

    def norm(self):
        # TODO: math.sqrt(...) — квадратный корень; модуль math подключён в первой строке
        ...


print("Раскомментируйте строки ниже, когда реализуете методы, и нажмите «Запустить».")
# v = Vector([1, 2, 3])
# w = Vector([4, 5, 6])
# print(v, len(v), v[0])        # Vector([1, 2, 3]) 3 1
# print(v + w, w - v)           # Vector([5, 7, 9]) Vector([3, 3, 3])
# print(v * 2, 0.5 * w)         # Vector([2, 4, 6]) Vector([2.0, 2.5, 3.0])
# print(v @ w)                  # 32
# print(Vector([3, 4]).norm())  # 5.0
Проверь себя5 вопросов
1 В классе Value есть __mul__, но нет __rmul__. Что даст 3 * Value(2)?
2 Что произойдёт при model(x), если model — экземпляр нашего класса Sequential(Module)?
3 Какие утверждения о @dataclass верны?Можно выбрать несколько ответов
4 Сколько элементов вернёт len(TextDataset(list(range(100)), block_size=8)) для класса из урока?
5 Два экземпляра класса class Logger: history = [] вызывают log(1) и log(2) через self.history.append(x). Сколько элементов в history у первого?
  • Класс объединяет данные и методы; __init__ заполняет атрибуты, self — сам объект, @classmethod — альтернативный конструктор вроде from_pretrained.
  • Наследование и super() — основа PyTorch: каждый слой — потомок nn.Module, а model(x) через __call__ вызывает forward.
  • Магические методы подключают свой класс к синтаксису Python: __repr__, __len__, __getitem__, __add__, __mul__, __matmul__. Для 2 * v и sum(...) нужны __rmul__ и __radd__.
  • @dataclass — короткий способ описать конфиг; изменяемые значения по умолчанию — только через default_factory.

Классы — главный инструмент, когда программа моделирует живую предметную область: курсы, записи, платежи. Как проектировать такие классы так, чтобы код говорил языком бизнеса, — в отдельной программе DDD; для старта в ней достаточно ступени 0.

Дальше: урок 0.2.4 — модули и пакеты, файлы, JSON и исключения — и почему китайский текст в UTF-8 весит втрое больше латиницы.