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

Функции, генераторы списков, лямбды

Как писать свои функции и вызывать чужие с именованными аргументами, как собрать новый список одной строкой (генераторы списков), как сортировать по своему правилу и как функция может вызывать саму себя — на примерах из кода обучения LLM.

УРОКstage-0/0-2-python/02-functions.mdx2 чУрок 3 из 7
После урока вы сможете
  • Читать сигнатуры вроде AdamW(params, lr=0.001, ..., *, fused=None) и вызывать функции с именованными аргументами, *args и **kwargs
  • Избегать ловушки изменяемого аргумента по умолчанию
  • Писать генераторы списков и словарей, использовать zip, enumerate и sorted(..., key=...)
  • Решать задачи на вложенные структуры рекурсией

До сих пор вы писали программы сверху вниз, строка за строкой. Но если одно и то же действие нужно в десяти местах, копировать его десять раз — путь к ошибкам: исправите в одном месте и забудете в другом. Функция решает это так же, как кнопка на калькуляторе: один раз описали действие, дали ему имя — и вызываете сколько угодно раз с разными числами. Готовыми функциями (print, len, round) вы пользуетесь с урока 0.2.0, а свою первую написали в уроке 0.1.3.

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

  1. Вызовы с именованными аргументами: F.cross_entropy(logits, targets, ignore_index=-100, label_smoothing=0.1). Что такое logits и cross-entropy, вы узнаете в модуле 1.3, а пока важна форма вызова. Чтобы понимать такой код, нужно читать сигнатуры — заголовки функций: какие аргументы обязательны, какие имеют значение по умолчанию и какое.
  2. Однострочная обработка данных: ids = [vocab[t] for t in tokens], itos = {i: s for s, i in stoi.items()}.
  3. Передача функций как данных: sorted(..., key=...), DataLoader(..., collate_fn=...), планировщики learning rate.

Этот урок — про всё это. В конце вы напишете пять функций, которые позже встретятся вам в токенизаторе и в загрузке конфигов.

ФункцияфункцияEN functionВ Python функция — обычный объект, её можно передать как аргумент или вернуть из другой функции. — именованный кусок кода, который можно вызывать много раз с разными данными. В уроке 0.1.3 вы уже писали def double(x): return x * 2. Разберём устройство функции целиком:

def mean(xs):
"""Среднее арифметическое списка чисел."""
total = 0
for x in xs:
total += x
return total / len(xs)
losses = [2.5, 2.1, 1.9, 1.5] # ошибка модели (loss) на четырёх шагах
print(mean(losses)) # → 2.0
print(mean([1, 2])) # → 1.5
  • def mean(xs): — заголовок: имя функции и параметры в скобках. Двоеточие в конце обязательно.
  • Тело пишется с отступом. Строка в тройных кавычках сразу после заголовка — docstring, документация, которую показывают help(mean) и редакторы кода.
  • return возвращает результат и сразу завершает функцию. Функция без return возвращает None.
  • Вызов mean(losses) выполняет тело, подставив вместо xs переданный список. Значение, переданное при вызове, называют аргументом. Параметр — «пустое место с именем» в рецепте, аргумент — то, что на это место положили.

Проследим вызов mean([1, 2]) по шагам, как в таблице трассировки из урока 0.2.0:

ШагЧто выполняетсяxsxtotal
1вызов: xs указывает на [1, 2][1, 2]——
2total = 0[1, 2]—0
3первый повтор цикла: total += x[1, 2]11
4второй повтор цикла: total += x[1, 2]23
5return total / len(xs) → 3 / 2возвращается 1.5

Переменные, созданные внутри функции (total, x), — локальные: они существуют только во время вызова, и снаружи их не видно. После вызова print(total) даст NameError: name 'total' is not defined.

Вспомните урок 0.2.1: переменная — это имя-наклейка. Параметр функции — такая же наклейка на тот же объект, который передали. Поэтому если функция меняет список «на месте», изменение увидит и тот, кто её вызвал:

def add_bos(tokens):
tokens.insert(0, "<bos>") # вставить в начало; <bos> — метка «начало текста»
my_tokens = ["я", "люблю"]
add_bos(my_tokens)
print(my_tokens) # → ['<bos>', 'я', 'люблю'] — список изменился снаружи

Ещё три факта, которые понадобятся в этом уроке:

  • «Несколько значений» возвращаются кортежем: return acc, errors, а при вызове распаковываются: acc, errors = evaluate(...).
  • Аннотации типов вроде def mean(xs: list[float]) -> float: подсказывают людям и редактору, что функция принимает и что возвращает. Python их не проверяет. В чужом коде они встречаются постоянно.
  • Функция — обычный объект. Её можно положить в переменную, передать аргументом, вернуть из другой функции: f = mean (без скобок!) — и f([1, 2]) даст 1.5. На этом держится половина урока.

Аргументы: позиционные, именованные, по умолчанию

Заголовок раздела «Аргументы: позиционные, именованные, по умолчанию»

Параметру можно задать значение по умолчанию: если аргумент не передан, возьмётся оно. Пример из настоящего обучения: learning rateскорость обученияEN learning rate первые warmup шагов линейно растёт, а потом держится постоянным. Такой «разогрев» используют почти во всех LLM, подробно о нём — в уроке 4.6.3.

def lr_at(step, base_lr=3e-4, warmup=100):
"""Линейный разогрев: lr растёт от base_lr / warmup до base_lr за warmup шагов."""
if step < warmup:
return base_lr * (step + 1) / warmup
return base_lr
print(f"{lr_at(0):.1e} {lr_at(49):.1e} {lr_at(500):.1e}") # → 3.0e-06 1.5e-04 3.0e-04
print(lr_at(9, warmup=10)) # → 0.0003 step по позиции, base_lr по умолчанию
print(lr_at(step=9, base_lr=1e-3, warmup=10)) # → 0.001

Проверим первую строку вывода руками. lr_at(0): шаг 0 меньше warmup = 100, поэтому 0.0003⋅(0+1)/100=0.0000030.0003 \cdot (0 + 1) / 100 = 0.000003, то есть 3.0e-06 в научной записи. lr_at(49): 0.0003⋅50/100=0.000150.0003 \cdot 50 / 100 = 0.00015 (1.5e-04). lr_at(500): разогрев закончился, возвращается сам base_lr = 0.0003. Формат :.1e значит «научная запись с одной цифрой после точки».

Аргумент можно передать по позиции или по имени. Именованные аргументы можно писать в любом порядке, и код с ними читается без заглядывания в документацию. В ML-коде так и пишут: lr_at(step, warmup=2000).

Ловушка: изменяемое значение по умолчанию

Заголовок раздела «Ловушка: изменяемое значение по умолчанию»

Значение по умолчанию вычисляется один раз — когда Python выполняет строку def, а не при каждом вызове. Если это список или словарь, он будет общим для всех вызовов:

def add_end(L=[]):
L.append("END")
return L
print(add_end()) # → ['END']
print(add_end()) # → ['END', 'END'] — список «помнит» прошлый вызов!

Правильно — использовать None как метку «аргумент не передан»:

def add_end(L=None):
if L is None:
L = []
L.append("END")
return L

Правило: значение по умолчанию должно быть неизменяемым (число, строка, None, кортеж). На этой ловушке спотыкаются все, и она пригодится вам ещё раз в следующем уроке, в датаклассах.

Только именованные аргументы: звёздочка в сигнатуре

Заголовок раздела «Только именованные аргументы: звёздочка в сигнатуре»

Посмотрите на настоящую сигнатуру (заголовок функции) из PyTorch 2.x. Это AdamW — оптимизатор, то есть та часть обучения, которая подкручивает веса модели (ступень 1). Что значат все эти параметры, сейчас неважно, смотрите на форму:

AdamW(params, lr=0.001, betas=(0.9, 0.999), eps=1e-08, weight_decay=0.01, amsgrad=False,
*, maximize=False, foreach=None, capturable=False, differentiable=False, fused=None)

Одинокая * значит: всё, что после неё, можно передать только по имени. AdamW(params, 3e-4) сработает (lr — позиционный), а флаг fused придётся писать явно: fused=True. Так авторы библиотек защищают пользователей от путаницы в длинных списках флагов.

def scale(x, *, factor):
return x * factor
print(scale(3, factor=2)) # → 6
# scale(3, 2) # TypeError: scale() takes 1 positional argument but 2 were given

Звёздочки в определении функции собирают «лишние» аргументы: *args — позиционные в кортеж, **kwargs — именованные в словарь. Имена args и kwargs — соглашение, важны только звёздочки.

def show(*args, **kwargs):
print(args, kwargs)
show(1, 2, lr=3e-4, warmup=100) # → (1, 2) {'lr': 0.0003, 'warmup': 100}

В вызове звёздочки работают наоборот — распаковывают:

params = {"base_lr": 1e-3, "warmup": 10}
print(lr_at(9, **params)) # → 0.001 то же, что lr_at(9, base_lr=1e-3, warmup=10)
shape = (2, 5)
def area(h, w):
return h * w
print(area(*shape)) # → 10

Самый частый сценарий в ML — конфиг со значениями по умолчанию, которые можно переопределить:

def make_config(**overrides):
defaults = {"hidden_size": 768, "num_hidden_layers": 8, "vocab_size": 6400}
return {**defaults, **overrides} # ** раскладывает словари в новый; правый перекрывает левый
print(make_config(hidden_size=512, dropout=0.1))
# → {'hidden_size': 512, 'num_hidden_layers': 8, 'vocab_size': 6400, 'dropout': 0.1}

Сводка видов параметров в порядке, в котором они обязаны идти в def:

ВидПример в defКак передаётся
позиционныйstepпо позиции или по имени
со значением по умолчаниюwarmup=100можно не передавать
сборщик позиционных*argsлишние позиционные → кортеж
только именованныйfused=None после * или *argsтолько fused=...
сборщик именованных**kwargsлишние именованные → словарь
Мини-задача: что напечатает def f(a, *rest, sep="-", **opts): print(a, rest, sep, opts) при вызове f(1, 2, 3, sep="+", lr=0.1)?

1 (2, 3) + {'lr': 0.1}. Первый позиционный идёт в a, остальные — в кортеж rest; sep после *rest можно передать только по имени; незнакомое имя lr попадает в opts.

Очень частая задача — пройти по коллекции и собрать новый список: из каждого элемента что-то вычислить, а лишние пропустить. Циклом это четыре строки:

words = "Мама мыла раму , мама !".split()
clean = []
for w in words:
if w.isalpha(): # только слова из букв: «,» и «!» пропускаем
clean.append(w.lower())
print(clean) # → ['мама', 'мыла', 'раму', 'мама']

Генератор спискагенератор списка (списковое включение)EN list comprehensionЗапись вида [f(x) for x in xs if cond]. В популярном китайском учебнике Python Ляо Сюэфэна это называется «выражение, порождающее список». (list comprehension) записывает то же самое одной строкой:

clean = [w.lower() for w in words if w.isalpha()] # тот же результат

Читается слева направо как фраза: «w.lower() для каждого w из words, если w.isalpha()». Часть с if необязательна: [len(w) for w in words] — длины всех слов. Те же скобки, но фигурные и с двоеточием, дают генератор словаря; без двоеточия — множества. Функция enumerate, которая нумерует элементы, подробно разобрана ниже:

stoi = {w: i for i, w in enumerate(sorted(set(clean)))} # строка → id
itos = {i: w for w, i in stoi.items()} # id → строка (обратный словарь)
print(stoi) # → {'мама': 0, 'мыла': 1, 'раму': 2}
print(itos) # → {0: 'мама', 1: 'мыла', 2: 'раму'}

Пара stoi / itos (string-to-index и обратно) — словарьсловарь (токенизатора)EN vocabulary (vocab)Множество всех токенов с их номерами (id). Размер словаря V задаёт размер матрицы эмбеддингов и выходного слоя (V × d). токенизатора. Сравните с мини-задачей урока 0.2.1: обратный словарь, который там занимал цикл из трёх строк, здесь — одна строка. В учебных проектах Карпатого точно так же устроен словарь символов, и такой же вы напишете в модуле 4.1.

Циклов внутри может быть несколько, их порядок такой же, как у вложенных for:

corpus = ["я люблю python", "python любит меня"]
tokens = [tok for sent in corpus for tok in sent.split()] # «для каждого предложения, для каждого слова в нём»
print(tokens) # → ['я', 'люблю', 'python', 'python', 'любит', 'меня']

Генератор списка обычно не медленнее цикла с append, а часто немного быстрее (насколько — зависит от версии Python и задачи). Но главное — читаемость. Если выражение не помещается в строку или в нём больше двух for, пишите обычный цикл.

Мини-задача: транспонируйте матрицу [[1, 2, 3], [4, 5, 6]] (список строк таблицы) одним генератором списка — строки должны стать столбцами.
matrix = [[1, 2, 3], [4, 5, 6]]
print([[row[j] for row in matrix] for j in range(3)]) # → [[1, 4], [2, 5], [3, 6]]

В NumPy это будет просто matrix.T (модуль 0.3), но полезно один раз сделать руками.

Замените квадратные скобки круглыми, и получится генераторгенератор (ленивый итератор)EN generatorФункция с yield или выражение в круглых скобках; выдаёт значения по одному и не держит всё в памяти. Не путать с генератором списка. — объект, который выдаёт значения по одному и по требованию, не держа их все в памяти. Названия похожи, но не путайте: генератор списка сразу строит готовый список, а просто генератор вычисляет следующее значение, только когда его попросят. sys.getsizeof показывает, сколько байт занимает сам объект:

import sys
print(sys.getsizeof([x * x for x in range(1_000_000)])) # → 8448728 (~8 МБ на один только список ссылок)
print(sys.getsizeof((x * x for x in range(1_000_000)))) # → 208 (размер не зависит от длины)
# точные числа — для Python 3.11, в других версиях они немного другие
print(sum(x * x for x in range(1_000_000))) # → 333332833333500000

Функция с yield вместо return тоже становится генератором. yield отдаёт одно значение и «замораживает» функцию до следующей просьбы; следующий запрос продолжает работу с того же места. Так читают огромные датасеты кусками — батчами:

def batches(items, batch_size):
for i in range(0, len(items), batch_size): # i = 0, 3, 6 при длине 7 и batch_size = 3
yield items[i:i + batch_size] # срез: items[0:3], items[3:6], items[6:9]
for b in batches(list(range(7)), 3):
print(b) # → [0, 1, 2], затем [3, 4, 5], затем [6]

Встроенная функция next(gen) берёт у генератора следующее значение, а цикл for делает это сам. Генератор одноразовый: после того как значения кончились, next() выбрасывает StopIteration, а повторный for ничего не выдаст. В уроке 0.2.4 вы увидите, что открытый файл ведёт себя так же: его строки читаются лениво.

enumerate даёт пары «номер, элемент» — не нужно заводить счётчик вручную:

for i, tok in enumerate(["<bos>", "я", "люблю"]):
print(i, tok) # → 0 <bos>, 1 я, 2 люблю

zip идёт по нескольким последовательностям параллельно и останавливается на самой короткой. На нём держатся две ключевые для LLM конструкции: пары соседних токенов для алгоритма BPEBPE, кодирование пар байтовEN Byte Pair Encoding (BPE)Алгоритм токенизации — многократно сливает самую частую пару соседних токенов в новый токен. Изначально (Gage, 1994) — алгоритм сжатия данных. (он ищет самые частые пары и склеивает их в новый токен) и пары «вход → цель» для обучения языковой модели (она видит токен и учится угадывать следующий):

toks = ["к", "о", "т", "о", "к"]
print(list(zip(toks, toks[1:])))
# → [('к', 'о'), ('о', 'т'), ('т', 'о'), ('о', 'к')] пары соседей — основа BPE (урок 4.2.2)
for x, y in zip(toks[:-1], toks[1:]):
print(f"{x} -> {y}", end="; ")
# → к -> о; о -> т; т -> о; о -> к; вход → цель: так учат языковую модель (урок 4.1.1)

Если длины обязаны совпадать, передайте strict=True (Python 3.10+): вместо тихой обрезки получите ValueError. Из двух списков легко сделать словарь: dict(zip(keys, values)). А zip(*pairs) «расстёгивает» список пар обратно в два кортежа.

flowchart LR
A["«Мама мыла раму»"] --> B["split + lower<br/>генератор списка"]
B --> C["['мама', 'мыла', 'раму']"]
C --> D["zip(toks, toks[1:])"]
D --> E["пары соседей<br/>('мама', 'мыла') …"]
C --> F["enumerate + dict comprehension"]
F --> G["stoi: {'мама': 0, …}"]
Маленький конвейер подготовки текста из инструментов этого урока

Функция высшего порядкафункция высшего порядкаEN higher-order functionФункция, которая принимает или возвращает другие функции. Примеры — sorted с key, map, filter. принимает или возвращает другую функцию. Самая частая в ML-коде — sorted. Без дополнительных аргументов она сортирует по возрастанию, а аргумент key принимает функцию, которая для каждого элемента возвращает «ключ сравнения»: сортировка идёт по ключам.

words = ["трансформер", "кот", "автоград"]
print(sorted(words)) # → ['автоград', 'кот', 'трансформер'] по алфавиту
print(sorted(words, key=len)) # → ['кот', 'автоград', 'трансформер'] по длине
print(sorted([3, 1, 2], reverse=True)) # → [3, 2, 1] по убыванию

Здесь key=len — встроенная функция len, переданная без скобок: мы отдаём саму функцию, а sorted вызовет её для каждого слова. Если нужной функции нет, её удобно записать прямо на месте как лямбда-функциюлямбда-функцияEN lambda (anonymous function): выражение lambda аргументы: результат. Запись lambda w: w[-1] — то же самое, что def f(w): return w[-1], только без имени.

На словаре частот:

counts = {"кот": 3, "и": 5, "пёс": 3, "ёж": 1, "дом": 3}
print(sorted(counts, key=counts.get, reverse=True)) # слова по убыванию частоты
# → ['и', 'кот', 'пёс', 'дом', 'ёж']
print(sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])))
# → [('и', 5), ('дом', 3), ('кот', 3), ('пёс', 3), ('ёж', 1)]
print(max(counts, key=counts.get)) # → и (слово с наибольшей частотой)

Последняя строка ищет не самую большую частоту, а слово, у которого она самая большая. В математике и ML такую операцию называют argmax — «аргумент максимума» (подробно — в уроке 1.0.3). Именно так модель выбирает самый вероятный следующий токен.

sorted(counts) перебирает ключи словаря, а key=counts.get для каждого слова возвращает его частоту. Во втором примере сортируются пары (слово, частота) из counts.items(): в лямбде kv[0] — слово, kv[1] — частота, и ключ — кортеж (-частота, слово). Кортежи сравниваются поэлементно: сначала -частота (минус превращает сортировку по возрастанию в убывание частоты), при равенстве — само слово. Так вы получаете детерминированный порядок, что важно для воспроизводимостивоспроизводимостьEN reproducibilityВозможность получить тот же результат, повторно запустив тот же код на тех же данных.: словарь токенизатора не должен зависеть от случайностей.

Ещё три вещи, которые нужно знать про сортировку:

  • sorted(x) возвращает новый список, а x.sort() сортирует на месте и возвращает None. Код x = x.sort() превращает ваш список в None.
  • Сортировка в Python устойчивая: элементы с равным ключом сохраняют исходный порядок. Это видно в первом примере: «кот», «пёс», «дом» идут в порядке вставки в словарь.
  • Строки сравниваются по номерам символов в Юникоде (ord из урока 0.0.2). Поэтому заглавные идут раньше строчных (ord("Я") = 1071, ord("а") = 1072), а «ё» (номер 1105) — после «я» (номер 1103): sorted(["ёж", "еж", "яма", "Яблоко"]) даёт ['Яблоко', 'еж', 'яма', 'ёж']. Для «человеческого» порядка нужен key, например lambda w: w.lower().replace("ё", "е").

Функция может и возвращать функцию. Внутренняя функция «запоминает» переменные внешней, такая конструкция называется замыканиемзамыканиеEN closureВнутренняя функция, которая запоминает переменные внешней функции и после её завершения. Так устроены планировщики learning rate.:

def make_scheduler(base_lr, warmup):
def lr_at(step):
return base_lr * min(1.0, (step + 1) / warmup)
return lr_at
sched = make_scheduler(1e-3, 10)
print(sched(0), sched(4), sched(100)) # → 0.0001 0.0005 0.001

Именно так устроены планировщики в PyTorch: LambdaLR(optimizer, lr_lambda=...) принимает функцию «шаг → множитель».

В Python есть и классические функции высшего порядка map(f, xs) (применить f к каждому элементу) и filter(f, xs) (оставить элементы, для которых f истинна), но генератор списка почти всегда читается лучше: вместо list(filter(lambda w: len(w) > 2, words)) пишите [w for w in words if len(w) > 2].

РекурсиярекурсияEN recursion — функция, которая вызывает саму себя на меньшей части задачи. Нужны два элемента: база (когда остановиться) и шаг (как свести задачу к меньшей). Классический пример — факториал n!n! (читается «эн факториал»): произведение всех целых чисел от 1 до nn, n!=n⋅(n−1)⋅…⋅1n! = n \cdot (n-1) \cdot \ldots \cdot 1. Например, 5!=5⋅4⋅3⋅2⋅1=1205! = 5 \cdot 4 \cdot 3 \cdot 2 \cdot 1 = 120. Заметьте: 5!=5⋅4!5! = 5 \cdot 4! — факториал выражается через факториал меньшего числа, n!=n⋅(n−1)!n! = n \cdot (n-1)!. По договорённости 0!=10! = 1 — это и будет база:

def factorial(n):
if n == 0: # база: 0! = 1, дальше не идём
return 1
return n * factorial(n - 1) # шаг: свели задачу к меньшему n
print(factorial(5)) # → 120

Вызов factorial(5) ждёт результата factorial(4), тот — factorial(3), и так до factorial(0), который сразу возвращает 1. Потом ответы «всплывают» обратно: 1→1→2→6→24→1201 \to 1 \to 2 \to 6 \to 24 \to 120. Факториал проще посчитать циклом, но рекурсия незаменима для вложенных структур произвольной глубины — списков в списках, словарей в словарях:

def flatten(nested):
out = []
for item in nested:
if isinstance(item, list): # isinstance(x, тип) — является ли x объектом этого типа
out.extend(flatten(item)) # шаг: развернуть вложенный список той же функцией
else:
out.append(item) # база: обычный элемент
return out
print(flatten([1, [2, [3, [4]]], 5])) # → [1, 2, 3, 4, 5]

Конфиги моделей — как раз такие деревья. В PyTorch веса модели хранятся в плоском словаре с ключами через точку (вида layers.0.attention.q_proj.weight), а получается такой ключ рекурсивным обходом вложенных модулей. Свёртку вложенного словаря в плоский вы напишете в практике.

Мини-задача: напишите depth(x) — глубину вложенности списка. depth(7) == 0, depth([1, [2, [3]]]) == 3.
def depth(x):
if not isinstance(x, list):
return 0
return 1 + max((depth(i) for i in x), default=0)

default=0 нужен для пустого списка: max от пустой последовательности иначе выбросит ValueError.

Пять функций — по одной на каждый инструмент урока: генератор словаря с условием, sorted с ключом-кортежем, zip для пар токенов, функция, возвращающая функцию, и рекурсия по вложенному конфигу.

В примере к compose встречается запись str.strip — это метод strip, взятый у самого типа строк, как обычная функция: str.strip(" кот ") делает то же, что " кот ".strip(). Так методы удобно передавать в другие функции.

Генераторы, sorted, zip и рекурсия7 тестов
def prune_vocab(counts, min_count):
    """Оставить только слова, встретившиеся не меньше min_count раз (одной строкой — dict comprehension).

    prune_vocab({"кот": 3, "ёж": 1, "дом": 2}, 2) -> {"кот": 3, "дом": 2}
    """
    # TODO: генератор словаря с условием — {ключ: значение for ключ, значение in counts.items() if ...}
    #       Исходный словарь не меняйте: генератор и так строит новый.
    ...


def top_k(counts, k):
    """k самых частых слов списком пар (слово, частота).

    Сортировка: по частоте по убыванию, при равной частоте — по алфавиту.
    top_k({"кот": 3, "и": 5, "пёс": 3, "дом": 3}, 3) -> [("и", 5), ("дом", 3), ("кот", 3)]
    """
    # TODO: 1) отсортируйте пары counts.items() функцией sorted с key=lambda kv: (...);
    #          kv — пара (слово, частота): kv[0] — слово, kv[1] — частота;
    #          ключ-кортеж (-частота, слово) даёт «по убыванию частоты, при равенстве — по алфавиту»;
    #       2) возьмите первые k элементов срезом [:k] и верните их.
    ...


def bigrams(tokens):
    """Список пар соседних токенов (как в алгоритме BPE).

    bigrams(["к", "о", "т"]) -> [("к", "о"), ("о", "т")]
    """
    # TODO: zip(tokens, tokens[1:]) идёт парами соседей; оберните в list(...), чтобы получить список.
    ...


def compose(*funcs):
    """Вернуть НОВУЮ функцию, которая применяет funcs слева направо.

    clean = compose(str.strip, str.lower)
    clean("  Привет ") -> "привет"
    (str.strip — метод strip, взятый у типа str: str.strip("  a ") == "a")
    compose() без аргументов возвращает функцию, которая отдаёт аргумент без изменений.
    """
    # TODO: план —
    #   def composed(x):          # внутренняя функция: получает одно значение
    #       ...                   # по очереди примените к x каждую функцию из funcs: x = f(x)
    #       return x
    #   return composed           # вернуть саму функцию — БЕЗ скобок
    ...


def flatten_config(cfg, prefix=""):
    """Вложенный словарь -> плоский с ключами через точку (так устроены имена весов в PyTorch).

    flatten_config({"model": {"n_layer": 8, "attn": {"n_head": 4}}, "lr": 0.001})
      -> {"model.n_layer": 8, "model.attn.n_head": 4, "lr": 0.001}
    Значения, которые не являются словарями (числа, строки, списки), копируются как есть.
    """
    # TODO: план —
    #   1) flat = {} — сюда собираем результат;
    #   2) цикл for key, value in cfg.items():
    #   3) полный ключ: f"{prefix}.{key}", а если prefix пустой — просто key;
    #   4) value — словарь (isinstance(value, dict))? Тогда вызовите flatten_config(value, полный_ключ)
    #      и добавьте его ответ в flat методом flat.update(...). Иначе: flat[полный_ключ] = value;
    #   5) после цикла return flat.
    ...


# Проверьте себя кнопкой «Запустить»:
print(prune_vocab({"кот": 3, "ёж": 1, "дом": 2}, 2))
print(top_k({"кот": 3, "и": 5, "пёс": 3, "дом": 3}, 3))
print(bigrams(["к", "о", "т"]))
clean = compose(str.strip, str.lower)
print(clean("  Привет ") if callable(clean) else "compose пока не возвращает функцию")
print(flatten_config({"model": {"n_layer": 8, "attn": {"n_head": 4}}, "lr": 0.001}))
Проверь себя6 вопросов
1 Дано def f(L=[]): L.append(1); return len(L). Чему равен результат третьего вызова f()?
2 Что выведет print(sorted([3, 1, 2]).sort())?
3 Какие вызовы функции def g(x, *, scale=1.0, **kw) корректны?Можно выбрать несколько ответов
4 Сколько пар вернёт list(zip(toks, toks[1:])) для списка из 10 токенов?
5 Чему равно {w: len(w) for w in ["кот", "пёс", "кот"]}?
6 Нужно посчитать сумму квадратов 100 млн чисел, не создавая список в памяти. Что подходит?
  • Значения по умолчанию вычисляются один раз: изменяемые объекты в них не кладите, используйте None.
  • *args и **kwargs собирают лишние аргументы в кортеж и словарь; в вызове * и ** распаковывают. На этом построены конфиги вроде MiniMindConfig.
  • Генераторы списков, словарей и множеств заменяют map/filter; круглые скобки дают ленивый генератор.
  • enumerate нумерует, zip(toks, toks[1:]) даёт пары соседей, sorted(..., key=lambda kv: (-kv[1], kv[0])) — детерминированный топ.
  • Рекурсия — естественный способ обойти вложенные структуры; помните про лимит глубины около 1000.

Дальше: урок 0.2.3 — классы и магические методы. Вы научите свой объект складываться через + и умножаться через *, и это ровно то, что понадобится для собственного автограда в модуле 3.2.