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

Мини-проекты: собираем всё вместе

Три законченные программы шаг за шагом — «угадай число», частотный словарь русского текста по словам и по буквам и трекер привычек с сохранением в JSON — и метод, как разбивать любую задачу на функции.

УРОКstage-0/0-2-python/06-mini-projects.mdx2 ч 30 минУрок 7 из 7
После урока вы сможете
  • Разбивать задачу на функции: сначала план и сигнатуры, потом код; логику держать отдельно от ввода-вывода
  • Написать целиком игру «угадай число», частотный словарь русского текста по словам и по буквам и трекер привычек с JSON
  • Проверять свои функции через assert и запускать скрипт с аргументами командной строки
  • Объяснить, как частотный словарь связан со словарём токенизатора LLM и законом Ципфа

За модуль 0.2 у вас накопился полный ящик инструментов: переменные и строки, условия и циклы, словари, функции, классы, файлы, JSON, исключения, отладка. Но инструменты — ещё не умение строить. Самоучки часто застревают именно здесь: каждую тему поняли, а перед пустым файлом «напиши программу» не знают, с чего начать.

Этот урок — мастерская. Мы соберём три законченные программы, каждая чуть сложнее предыдущей:

ПроектЧто тренируетСвязь с LLM
«Угадай число»цикл с вводом, random, try/except, двоичный поискслучайность и seed; поиск делением пополам
Частотный словарьстроки, словари, sorted, подсчёт слов и букв русского текстапервый шаг любого токенизатора; закон Ципфа
Трекер привычекфайлы и JSON, даты, аргументы командной строкиданные, которые переживают перезапуск программы

Главное в уроке — не сами программы, а метод: как превратить задачу в набор небольших функций. Этим методом вы будете пользоваться всю ступень 4, когда соберёте токенизатор, и всю ступень 5, когда напишете свою мини-LLM.

Декомпозициядекомпозиция задачиEN problem decompositionРазбиение большой задачи на шаги, каждый из которых решает отдельная небольшая функция. — разбиение большой задачи на шаги, каждый из которых решает небольшая функция. Порядок действий одинаков для любой задачи:

  1. Одна фраза и пример. «Программа печатает пять самых частых слов текста». Вход: файл с текстом. Выход: таблица из пяти строк «слово — частота». Не можете сформулировать — писать код рано.

  2. Шаги-глаголы. Прочитать текст → разбить на слова → посчитать → отобрать топ → напечатать. Каждый шаг описывается глаголом и понятен без остальных.

  3. Функция на каждый шаг. Сначала сигнатура и docstring с примером, потом тело. Именно так выглядят заготовки домашних работ курса (урок 0.1.3): договор о входе и выходе записан до кода.

  4. Логика отдельно от ввода-вывода. Функции, которые считают, не вызывают input() и print(). Такие чистые функциичистая функцияEN pure functionРезультат зависит только от аргументов, а сама функция ничего не печатает, не читает с клавиатуры и не меняет внешние данные. Такие функции легко тестировать. зависят только от аргументов, поэтому их легко проверить. Ввод-вывод собирается в тонкую функцию main.

  5. Проверка сразу. Написали функцию — проверьте её парой assert, включая краевые случаикраевой случайEN edge caseНеобычный вход на границе допустимого — пустой список, ноль, пустая строка. Именно на них чаще всего ломается код.: пустой текст, ноль, один элемент. Баг, пойманный сразу, стоит минуту; баг, найденный после сборки всей программы, — вечер (урок 0.2.5).

  6. Сборка. main() соединяет функции, а if __name__ == "__main__": запускает её только при запуске файла как скрипта (урок 0.2.4).

flowchart TB
T["Задача: пять самых частых слов текста"] --> A["прочитать файл<br/>Path.read_text"]
T --> B["разбить на слова<br/>words_ru"]
T --> C["посчитать<br/>count"]
T --> D["отобрать топ<br/>top_n"]
T --> E["напечатать<br/>print_table"]
Декомпозиция: задача → шаги → функции. Каждую функцию можно написать и проверить отдельно

Признаки хорошей функции: делает одно дело, и имя говорит, что она делает или что возвращает (load, mark, top_n, streak); помещается на экран (ориентир — до 20 строк); по docstring понятно, что она вернёт, не читая тела. Если описание функции требует союза «и» («читает файл и считает слова»), скорее всего, это две функции.

Задача. Компьютер загадывает число от 1 до 100, человек угадывает. На каждую попытку — подсказка «больше» или «меньше», всего 7 попыток. На буквы вместо числа программа не падает, а переспрашивает.

План. Три функции: hint — логика подсказки (чистая), ask_number — ввод с проверкой, play — одна партия.

import random
def hint(secret, guess):
"""Подсказка игроку: "больше", "меньше" или "угадали"."""
if guess < secret:
return "больше"
if guess > secret:
return "меньше"
return "угадали"
def ask_number(prompt, low, high):
"""Спрашивает число, пока человек не введёт целое от low до high."""
while True: # повторяем, пока не получим годный ответ
text = input(prompt)
try:
n = int(text)
except ValueError: # «двадцать» вместо 20 — не падаем, а переспрашиваем
print("Это не целое число, попробуйте ещё раз")
continue
if low <= n <= high:
return n # return выходит и из цикла, и из функции
print(f"Нужно число от {low} до {high}")
def play(low=1, high=100, max_tries=7):
"""Одна партия. Возвращает номер удачной попытки или None."""
secret = random.randint(low, high) # случайное целое, обе границы включены
print(f"Я загадал число от {low} до {high}. У вас {max_tries} попыток.")
for attempt in range(1, max_tries + 1):
guess = ask_number(f"Попытка {attempt}: ", low, high)
answer = hint(secret, guess)
if answer == "угадали":
print(f"Угадали с {attempt}-й попытки!")
return attempt
print("Моё число", answer)
print(f"Попытки кончились. Я загадал {secret}.")
return None
assert hint(50, 30) == "больше" # проверки чистой функции — без клавиатуры
assert hint(50, 70) == "меньше"
assert hint(50, 50) == "угадали"
if __name__ == "__main__":
play()

Сохраните код в guess.py и запустите python guess.py. Партия может выглядеть так:

Я загадал число от 1 до 100. У вас 7 попыток.
Попытка 1: 50
Моё число меньше
Попытка 2: двадцать
Это не целое число, попробуйте ещё раз
Попытка 2: 25
Моё число больше
Попытка 3: 37
Моё число больше
Попытка 4: 43
Моё число меньше
Попытка 5: 40
Моё число больше
Попытка 6: 42
Угадали с 6-й попытки!

Обратите внимание на устройство. while True в ask_number — намеренно бесконечный цикл: выйти из него можно только через return, когда ответ годный. Случайно написанный бесконечный циклбесконечный циклEN infinite loopЦикл, условие выхода из которого никогда не выполняется. Остановить программу — Ctrl+C. останавливают Ctrl+C. А проверить через assert удаётся только hint: функции с input() ждут человека. Именно поэтому логику отделяют от ввода-вывода.

Лучшая стратегия — называть середину оставшегося диапазона. Ответ «больше» или «меньше» отбрасывает половину вариантов: 100 → 50 → 25 → 13 → 7 → 4 → 2 → 1. Это двоичный поискдвоичный поискEN binary searchПоиск делением диапазона пополам. Среди 100 вариантов находит ответ не больше чем за 7 шагов, потому что 2^7 = 128 ≥ 100.. За kk попыток он различает 2k2^k вариантов, а 27=128≥1002^7 = 128 \ge 100. Проверим ботом, который играет по этой стратегии против всех 100 возможных чисел:

def solve(secret, low=1, high=100):
"""Бот: угадывает число делением пополам, возвращает число попыток."""
tries = 0
while True:
tries += 1
guess = (low + high) // 2 # середина оставшегося диапазона
answer = hint(secret, guess)
if answer == "угадали":
return tries
if answer == "больше":
low = guess + 1 # отбрасываем левую половину
else:
high = guess - 1 # отбрасываем правую половину
results = [solve(s) for s in range(1, 101)]
print("худший случай:", max(results), "| в среднем:", sum(results) / len(results))
худший случай: 7 | в среднем: 5.8

Это та же идея, что «биты удваивают число вариантов» из урока 0.0.2 и «делим код пополам» из урока 0.2.5. Для чисел от 1 до 1000 понадобится 10 попыток: 210=10242^{10} = 1024.

Как улучшить. Счёт по нескольким партиям; уровни сложности (1–10, 1–100, 1–1000) с автоматическим числом попыток — наименьшим kk, при котором 2k≥N2^k \ge N (в математике его записывают ⌈log⁡2N⌉\lceil \log_2 N \rceil, урок 1.0.2); обратная игра, где загадываете вы, а угадывает solve.

Задача. Напечатать самые частые слова русского текста, а потом — самые частые буквы и их сочетания. Звучит скромно, но это первый шаг любого токенизаторатокенизаторEN tokenizer: словарь LLM строят, начиная с подсчёта того, какие слова и кусочки встречаются в корпусе чаще всего (модуль 4.2).

flowchart LR
A["текст"] --> B["нормализация<br/>lower, ё → е"]
B --> C["токены<br/>слова (words) или буквы (letters)"]
C --> D["частоты<br/>count без стоп-слов"]
D --> E["топ-N<br/>top_n"]
D --> F["покрытие<br/>coverage"]
E --> G["таблица<br/>print_table"]
Конвейер частотного словаря: каждый блок — отдельная функция

Пять функций конвейера — words_ru, letters, count, top_n и coverage — вы напишете сами в практике в конце урока, их сигнатуры и примеры уже там. Можно сначала решить практику, а потом вернуться сюда. Здесь — «клей», который превращает эти функции в программу freq.py:

import sys
from pathlib import Path
# сюда вставьте пять функций из практики: words_ru, letters, count, top_n, coverage
# Учебный список стоп-слов, подобранный под наш абзац. Готовые списки длиннее: сотни слов.
STOP_RU = {"и", "в", "на", "а", "с", "из", "что", "чтобы", "очень", "какие", "свои", "всего"}
def letter_ngrams(text, n):
"""Цепочки из n соседних букв внутри слов; через пробелы и знаки препинания цепочки не строятся."""
grams = []
for word in words_ru(text):
for i in range(len(word) - n + 1): # все срезы длины n: word[0:n], word[1:n+1], …
grams.append(word[i:i + n])
return grams
def print_table(pairs, width):
"""Таблица: токен, частота и полоска из █ — гистограмма прямо в терминале."""
for tok, c in pairs:
print(f"{tok:<{width}}{c:>3} {'█' * c}")
def main(args):
if len(args) != 2 or args[0] not in ("words", "letters"):
print("Использование: python freq.py words|letters файл.txt")
return
mode, path = args
text = Path(path).read_text(encoding="utf-8")
if mode == "words":
tokens = words_ru(text)
freq = count(tokens, STOP_RU)
else:
tokens = letters(text)
freq = count(tokens)
print(f"токенов: {len(tokens)}, разных: {len(set(tokens))}")
print_table(top_n(freq, 5), width=14 if mode == "words" else 3)
print(f"половину употреблений дают {coverage(freq, 0.5)} самых частых из {len(freq)}")
if mode == "letters":
for n, name in [(2, "пары"), (3, "тройки")]:
print(f"самые частые {name} букв:")
print_table(top_n(count(letter_ngrams(text, n)), 5), width=4)
if __name__ == "__main__":
main(sys.argv[1:])

Слова после имени скрипта — аргументы командной строкиаргументы командной строкиEN command-line argumentsСлова после имени скрипта: в python habits.py done python это done и python. В Python доступны через sys.argv.. Python кладёт их в список sys.argv: sys.argv[0] — имя скрипта, дальше — всё, что вы написали. Поэтому main получает sys.argv[1:], например ["words", "ru.txt"]. Обратите внимание на {tok:<{width}}: ширину поля в f-строке можно взять из переменной.

Сохраните в ru.txt (в кодировке UTF-8) абзац о том, как модель учится на тексте:

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

Запустим python freq.py words ru.txt:

токенов: 53, разных: 45
слова 3 ███
модель 2 ██
предсказывать 2 ██
текст 2 ██
токены 2 ██
половину употреблений дают 14 самых частых из 34

В первой строке посчитаны все слова, во второй — только те, что остались после удаления стоп-слов: 45 разных слов минус 11 стоп-слов из нашего списка дают 34. При равной частоте слова идут по алфавиту — так устроена top_n.

Две находки. Первая — морфология. В тексте слово «текст» встречается четыре раза, но в трёх формах: «текст» (2), «текста», «тексте». Для словаря это три разных слова. Можно приводить слова к начальной форме морфологическим анализатором (для русского известен pymorphy), но LLM поступают иначе: их токенизатор режет слова на подслова, и общий кусок «текст» становится одним токеном для всех форм (урок 4.2.1).

Вторая — стоп-словастоп-словаEN stop wordsСамые частые служебные слова («и», «в», «на», «же»), которые убирают при подсчёте частот, чтобы увидеть содержательные слова.. Без фильтра на втором месте стояло бы «и». Служебные слова встречаются везде и ничего не говорят о теме текста, поэтому при анализе частот их убирают. Но из корпуса для обучения LLM их не выбрасывают никогда: модель должна уметь писать «и», «в» и «на» не хуже, чем «нейросеть».

В китайском письме пробелов между словами нет, поэтому split() там не поможет (урок 0.2.1), и самый простой токен — отдельный иероглиф. Посмотрим так же на русский текст: забудем о словах и посчитаем отдельные буквы, а потом пары и тройки соседних букв. Запускаем python freq.py letters ru.txt:

токенов: 314, разных: 28
о 35 ███████████████████████████████████
е 32 ████████████████████████████████
т 32 ████████████████████████████████
а 26 ██████████████████████████
с 26 ██████████████████████████
половину употреблений дают 6 самых частых из 28
самые частые пары букв:
ст 8 ████████
ва 7 ███████
ов 7 ███████
то 7 ███████
ре 6 ██████
самые частые тройки букв:
лов 5 █████
ова 5 █████
сло 5 █████
екс 4 ████
кст 4 ████

Буквы распределены очень неравномерно: «о», «е» и «т» вместе дают почти треть текста, а половину всех букв покрывают 6 самых частых из 28. А тройки соседних букв показывают удивительную вещь: куски слов проступают сами из частот. «Сло» и «лов» — это «слово», «слова» и «словарь», а «тек», «екс» и «кст» — «текст» во всех формах, хотя программа о словах ничего не знает. С китайским текстом происходит то же самое: частые пары соседних иероглифов чаще всего оказываются настоящими словами, хотя среди них попадаются и стыки двух слов. Отличать слова от стыков умеют специальные сегментаторы вроде jieba (урок 0.2.1). А алгоритм BPE, которым строят словари Qwen и DeepSeek, идёт по пути наших пар: снова и снова склеивает самую частую пару соседей (урок 4.2.2).

Функция coverage(freq, share) отвечает на вопрос: сколько самых частых токенов дают долю share всех употреблений? В нашем коротком абзаце эффект слабый: половину употреблений дают 14 слов из 34, потому что почти каждое слово встречается один раз. Но чем больше текст, тем сильнее перекос. Его описывает закон Ципфазакон ЦипфаEN Zipf's lawЧастота слова примерно обратно пропорциональна его месту в частотном списке, поэтому немногие частые слова покрывают большую часть любого текста.: частота слова примерно обратно пропорциональна его месту в списке. Второе по частоте слово встречается примерно вдвое реже первого, третье — втрое и так далее. Горстка частых слов покрывает огромную долю любого текста, а длинный хвост редких встречается по разу. Для китайских иероглифов есть точные цифры — о них в «Китайском углу».

Отсюда главный компромисс токенизатора, к которому мы вернёмся на ступени 4: частые слова выгодно хранить целиком, а редкие собирать из кусочков. Запустите freq.py на большой книге в UTF-8 и посмотрите, как растёт перекос.

Задача. Отмечать из терминала, что вы сегодня сделали («занимался Python», «учил английский»), и видеть отчёт: серию дней подряд и сколько дней из последних семи привычка выполнялась. Данные должны пережить перезапуск программы — храним их в JSON. Это отметка о занятияхотметка о занятияхEN check-in (daily study log)Буквально «пробить карточку». В учебных сообществах — публикация конспекта или решения в срок как подтверждение, что вы занимаетесь. из урока 0.0.3, только своими руками.

Данные. Словарь «привычка → список дат». Даты храним строками формата ГГГГ-ММ-ДД: такие строки читаются человеком, сортируются правильно и без проблем ложатся в JSON.

{
"python": ["2026-09-28", "2026-09-30", "2026-10-01", "2026-10-02", "2026-10-03"],
"английский": ["2026-09-29", "2026-10-03"]
}

Новые инструменты — модуль datetime из стандартной библиотеки:

КодРезультатЧто делает
date.today()date(2026, 10, 3)сегодняшняя дата
date(2026, 10, 3).isoformat()'2026-10-03'дата → строка
date.fromisoformat("2026-10-03")date(2026, 10, 3)строка → дата
date(2026, 10, 3) - timedelta(days=1)date(2026, 10, 2)вчера; timedelta — промежуток времени
"""Трекер привычек (отметки о занятиях) с сохранением в JSON.
Запуск:
python habits.py done python # отметить привычку «python» сегодня
python habits.py report # отчёт: серии и дни за неделю
"""
import json
import sys
from datetime import date, timedelta
from pathlib import Path
DATA_FILE = Path("habits.json")
WEEK_GOAL = 4 # цель недели — 4 дня из 7 (урок 0.0.3 советует начинать с 3–4)
# ─── Хранение ────────────────────────────────────────────────────────────────
def load(path):
"""Читает трекер из JSON. Если файла ещё нет — пустой трекер."""
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8"))
def save(data, path):
"""Сохраняет трекер: UTF-8, русский текст и эмодзи остаются читаемыми."""
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
# ─── Логика: чистые функции, без input/print и без date.today() ─────────────
def mark(data, habit, day):
"""Отмечает привычку в день day (строка "2026-10-03"). Повторная отметка ничего не меняет."""
days = data.setdefault(habit, [])
if day not in days:
days.append(day)
days.sort()
def streak(days, today):
"""Сколько дней подряд выполнялась привычка. Если сегодня ещё не отмечено,
серия считается до вчера: день ещё не закончился."""
done = set(days)
d = today if today.isoformat() in done else today - timedelta(days=1)
count = 0
while d.isoformat() in done:
count += 1
d -= timedelta(days=1)
return count
def week_count(days, today):
"""Сколько дней из последних семи (включая сегодня) привычка выполнялась."""
week = {(today - timedelta(days=i)).isoformat() for i in range(7)}
return len(week & set(days))
def report(data, today):
"""Строки отчёта по всем привычкам в алфавитном порядке."""
lines = []
for habit in sorted(data):
days = data[habit]
week = week_count(days, today)
goal = "✓ цель недели" if week >= WEEK_GOAL else ""
lines.append(f"{habit:<10} серия {streak(days, today):>2} дн. | за неделю {week}/7 {goal}")
return lines
# ─── Ввод-вывод: тонкая обёртка над логикой ─────────────────────────────────
def main(args):
today = date.today()
data = load(DATA_FILE)
if len(args) == 2 and args[0] == "done":
mark(data, args[1], today.isoformat())
save(data, DATA_FILE)
print(f"Отмечено! {args[1]} — {today.isoformat()}")
elif args == ["report"]:
for line in report(data, today) or ["Пока нет ни одной привычки"]:
print(line)
else:
print("Использование: python habits.py done <привычка> | report")
if __name__ == "__main__":
main(sys.argv[1:])

Разберём решения, которые делают этот код надёжным:

  • today — аргумент, а не вызов date.today() внутри. Иначе streak давала бы разные ответы в разные дни, и проверить её было бы невозможно. С аргументом проверка пишется на фиксированной дате.
  • Краевой случай серии. Вечером, пока вы ещё не отметились, серия не должна обнуляться: день не закончился. Поэтому отсчёт идёт от вчерашнего дня, если сегодня пусто. Такие правила лучше записывать прямо в docstring.
  • Повторная отметка безопасна. mark проверяет day not in days, поэтому двойной запуск done не испортит данные.
  • setdefault. data.setdefault(habit, []) возвращает список дат привычки, а если её ещё нет — сначала кладёт в словарь пустой список и возвращает его. Одна строка вместо проверки if habit not in data (этот приём был в подсказках практики урока 0.2.4).
  • ensure_ascii=False — чтобы «английский» в файле читалось по-русски, а не как \u0430\u043d\u0433… (урок 0.2.4).

Проверки логики — отдельный файл test_habits.py рядом:

from datetime import date
from habits import mark, streak, week_count
today = date(2026, 10, 3)
days = ["2026-09-30", "2026-10-01", "2026-10-02", "2026-10-03"]
assert streak(days, today) == 4
assert streak(days[:-1], today) == 3 # сегодня ещё не отмечено — серия не прервана
assert streak(["2026-09-30"], today) == 0
assert week_count(days + ["2026-09-01"], today) == 4
data = {}
mark(data, "python", "2026-10-03")
mark(data, "python", "2026-10-03") # повторная отметка
assert data == {"python": ["2026-10-03"]}
print("все проверки прошли")

А так выглядит работа с трекером в терминале (отчёт — после нескольких дней отметок):

$ python habits.py done python
Отмечено! python — 2026-10-03
$ python habits.py done английский
Отмечено! английский — 2026-10-03
$ python habits.py report
python серия 4 дн. | за неделю 5/7 ✓ цель недели
английский серия 1 дн. | за неделю 2/7
карточки серия 1 дн. | за неделю 2/7

«Карточки» в последний раз отмечены вчера, поэтому серия равна 1, а не 0.

Как улучшить. Команда undo, чтобы снять ошибочную отметку; календарь месяца из · и █; статистика по дням недели; своя цель для каждой привычки.

Соберите ядро частотного словаря: пять функций конвейера из проекта 2. Тесты проверят каждую по отдельности, а последний — весь конвейер на коротком русском тексте, по словам и по буквам. Функции зависят друг от друга, поэтому идите сверху вниз и после каждой нажимайте «▶ Запустить».

Частотный словарь: слова и буквы7 тестов
# Мини-проект: частотный словарь русского текста — по словам и по буквам.
# Пять функций — пять шагов конвейера: текст → токены → частоты → топ → покрытие.
# Замените ... в каждой функции своим кодом. Порядок важен: следующие функции
# используют предыдущие, поэтому идите сверху вниз и проверяйте кнопкой «▶ Запустить».


def words_ru(text):
    """Слова русского текста.

    Правила: всё в нижний регистр, «ё» заменить на «е»; слово — непрерывная
    цепочка букв (ch.isalpha()); всё остальное (пробелы, знаки, цифры) — разделители.

    words_ru("Ёжик в тумане, ёжик!") -> ["ежик", "в", "тумане", "ежик"]
    """
    # TODO: подсказка — замените каждый «не буквенный» символ пробелом и вызовите .split()
    ...


def letters(text):
    """Список русских букв текста по порядку — токенизация по буквам.

    Правила: всё в нижний регистр, «ё» заменить на «е»; буква — символ ch,
    для которого "а" <= ch <= "я" (строки сравниваются по номерам символов в Юникоде).
    Пробелы, знаки препинания, цифры, латиница и эмодзи пропускаются.

    letters("Ёж и hedgehog 🦔!") -> ["е", "ж", "и"]
    """
    # TODO
    ...


def count(tokens, stopwords=()):
    """Частоты токенов: словарь {токен: сколько раз встретился}.

    Токены из stopwords не считаются.

    count(["кот", "и", "кот"], {"и"}) -> {"кот": 2}
    """
    # TODO: идиома из урока 0.2.1 — freq[tok] = freq.get(tok, 0) + 1
    ...


def top_n(freq, n):
    """n самых частых токенов: список пар (токен, частота).

    Порядок — по убыванию частоты, при равной частоте — по алфавиту (обычное сравнение строк).

    top_n({"б": 2, "а": 2, "в": 5}, 2) -> [("в", 5), ("а", 2)]
    """
    # TODO: sorted(..., key=...) с ключом-кортежем из урока 0.2.2
    ...


def coverage(freq, share):
    """Сколько самых частых токенов нужно взять, чтобы они покрыли
    не меньше доли share всех употреблений (share от 0 до 1).

    Для пустого словаря — 0.

    coverage({"а": 5, "б": 3, "в": 2}, 0.8) -> 2   # 5 + 3 = 8 из 10 — это 80%
    """
    # TODO: идите по top_n(freq, len(freq)) и копите сумму частот
    ...


# ─── Демонстрация (кнопка «▶ Запустить») ─────────────────────────────────────
TEXT_RU = "Модель читает текст и предсказывает следующее слово. Чтобы предсказывать хорошо, модель читает очень много текста."
STOP_RU = {"и", "в", "на", "чтобы", "очень"}

try:
    freq_words = count(words_ru(TEXT_RU), STOP_RU)
    print("слова:", top_n(freq_words, 3))
    freq_letters = count(letters(TEXT_RU))
    print("буквы:", top_n(freq_letters, 3))
    print("покрытие 50% (слова):", coverage(freq_words, 0.5))
except Exception as e:
    print("Демо упало:", type(e).__name__, e)

Модуль «Python с нуля» пройден. Дальше по программе — модуль 0.3 «NumPy — язык тензоров»: те же подсчёты, но без циклов, над миллионами чисел сразу. Именно на NumPy (а потом на PyTorch) написан весь код ступеней 1–5.

Знаний уже хватает на первую домашнюю работу курса — s0-hw01-python-warmup: функции для анализа русского и английского текста с эмодзи и автотестами. По программе она завершает ступень 0 после NumPy, но начать можно сейчас:

Окно терминала
python homework/check.py s0-hw01-python-warmup

Ещё лучшая практика — свои мини-проекты. Несколько идей по силам уже сейчас:

  • Карточки из глоссария. Программа задаёт термин по-русски и просит английский перевод; ошибки сохраняет в JSON — своя маленькая тетрадь ошибок.
  • Счётчик байтов и токенов-символов для ваших заметок: сколько в них русских букв, латиницы и эмодзи и сколько байт они занимают в UTF-8.
  • Генератор текста по биграммам. Посчитайте, какое слово чаще всего идёт после каждого, и начните «писать»: берёте слово, выбираете случайного соседа с учётом частот, повторяете. Это и есть простейшая языковая модель — биграммная, ей посвящён урок 4.1.2.

Каждый проект кладите в отдельную папку под Git (урок 0.1.1) с коротким README: что делает и как запустить.

Проверь себя6 вопросов
1 Почему в «угадай число» подсказка вынесена в отдельную функцию hint(secret, guess) без input() и print()?
2 Сколько попыток в худшем случае нужно при оптимальной стратегии, если число загадано от 1 до 1000?
3 Какие утверждения о стоп-словах верны?Можно выбрать несколько ответов
4 Чему равно coverage({"а": 5, "б": 3, "в": 2}, 0.8)?
5 Почему streak(days, today) принимает today аргументом, а не вызывает date.today() внутри?
6 В частотном словаре русского текста «текст», «текста» и «тексте» посчитаны как разные слова. Как с этим справляются токенизаторы LLM?
  • Любую задачу раскладывайте по одной схеме: фраза и пример → шаги-глаголы → функция на шаг с docstring → логика без ввода-вывода → assert сразу → сборка в main().
  • «Угадай число»: цикл while True с try/except для ввода и двоичный поиск — на NN вариантов хватает kk попыток, если 2k≥N2^k \ge N.
  • Частотный словарь: слова, буквы, пары и тройки букв, стоп-слова, топ-N и покрытие. Частоты подчиняются закону Ципфа, и на этом стоит словарь любого токенизатора.
  • Трекер привычек: JSON переживает перезапуск, datetime считает дни, sys.argv даёт аргументы командной строки, а дата, переданная аргументом, делает логику проверяемой.

Дальше: модуль 0.3 «NumPy — язык тензоров», урок 0.3.1 «Массивы, формы, индексация»: почему векторизация в сотни раз быстрее циклов, которые вы писали весь этот модуль.