Классы и магические методы
Как создавать свои типы данных — классы: от простого счётчика до мини-версии слоя нейросети; как научить свой объект складываться через + и умножаться через * (это понадобится для своего автограда) и как коротко описывать настройки модели.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Вы уже пользовались объектами, которые умеют что-то делать сами: строка знает .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) # → 11ids = 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("мама мыла раму")Наследование: своя мини-версия nn.Module
Заголовок раздела «Наследование: своя мини-версия nn.Module»Наследованиенаследование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 4flowchart 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"]
Что здесь происходит:
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) · 10print([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 - y | x.__add__(y), x.__sub__(y) | сложение тензоров |
x * y | x.__mul__(y) | поэлементное умножение |
x @ y | x.__matmul__(y) | матричное умножение |
-x | x.__neg__() | |
x == y | x.__eq__(y) |
__repr__: чтобы объект было видно
Заголовок раздела «__repr__: чтобы объект было видно»Добавьте в класс 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('кот').
__add__ и __mul__: зародыш micrograd
Заголовок раздела «__add__ и __mul__: зародыш micrograd»Вот класс, с которого начнётся модуль 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; то, над чем выполняется
операция, называют операндами:
- Сначала пробует
(3).__mul__(a). Встроенныйintне знает проValueи возвращает специальное значениеNotImplemented— «не умею». - Тогда Python пробует «отражённый» метод правого операнда:
a.__rmul__(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__ — та же схема, доведённая до конца.
__len__ и __getitem__: свой Dataset
Заголовок раздела «__len__ и __getitem__: свой Dataset»В 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__ и хеширование
Заголовок раздела «__eq__ и хеширование»Это тонкость, к которой можно вернуться позже. Без __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
@dataclassclass 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 превращает конфиг в словарь — удобно сохранить в JSONsmall = 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и этим сообщением.
@dataclassclass 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]: , |
Складывать векторы разной длины нельзя: в этом случае метод должен остановить программу с ошибкой. Это делает
команда raise ValueError("сообщение") — «бросить ошибку ValueError с таким текстом» (подробно об исключениях — в уроке 0.2.4).
Квадратный корень даёт math.sqrt(x): модуль math уже подключён в заготовке строкой import math.
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
Проверь себя
Заголовок раздела «Проверь себя»- Класс объединяет данные и методы;
__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 весит втрое больше латиницы.