Модули, файлы, исключения
Как разложить программу по нескольким файлам и подключать чужой код через import; как читать и записывать текстовые файлы и JSON, чтобы русский и китайский текст не превращался в кракозябры; как программе не падать на ошибках — try/except.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Учебная LLM — это не один файл. В репозитории MiniMind, например, есть папки model/ (архитектура), dataset/
(загрузка данных), trainer/ (скрипты обучения) и scripts/. Код раскладывают по модулям и пакетам, а связывает их import.
Данные для обучения лежат в файлах — обычно в JSONL, по одной записи на строку. Эти записи на русском, английском и китайском, а значит, вы обязательно столкнётесь с кодировками. И что-то обязательно пойдёт не так: файла нет, строка обрезана, в тексте попался битый байт. Обучение на миллиардах токенов не должно падать из-за одной плохой строки — для этого есть исключения.
С этим уроком у вас будет полный набор базового Python. Вы сможете написать настоящий скрипт: прочитать корпус, посчитать статистику, сохранить результат и не упасть на грязных данных.
Модули и import
Заголовок раздела «Модули и import»МодульмодульEN moduleФайл .py, который можно импортировать. Не называйте свои файлы как модули стандартной библиотеки (json.py, random.py). — это просто файл .py. Инструкция import находит его, один раз выполняет
и даёт доступ к его именам — функциям, классам, переменным. Повторный import того же модуля ничего не выполняет:
Python помнит уже загруженные модули (они хранятся в sys.modules).
Проще всего увидеть это на двух своих файлах в одной папке:
# файл greetings.pydef hello(name): return f"Привет, {name}!"# файл main.py — лежит рядом, запускаем: python main.pyimport greetings # имя файла без .pyprint(greetings.hello("Мэй")) # → Привет, Мэй! (имя_модуля.имя_функции)Так же подключаются модули, которые написали другие люди: из стандартной библиотеки (она идёт вместе с Python)
и из пакетов, установленных через pip (урок 0.1.2). Три формы записи:
import math # весь модуль: math.sqrt(2)from collections import Counter # одно имя из модуляimport numpy as np # псевдоним — так принято для numpy, torch.nn.functional as F и т. п.
print(Counter("мама мыла раму мама".split()).most_common(2)) # → [('мама', 2), ('мыла', 1)]Counter из стандартного модуля collections делает одной строкой то, что в уроке 0.2.1 занимало цикл со словарём:
считает частоты, а most_common(k) возвращает k самых частых элементов. Так выглядит главная польза модулей: готовый
проверенный код, который не нужно писать заново.
Где Python ищет модули — по порядку:
- Папка запущенного скрипта (или текущая папка в интерпретаторе и Jupyter).
- Стандартная библиотека.
- Папка
site-packagesвашего виртуального окружениявиртуальное окружениеEN virtual environment (venv)Отдельная папка со своим Python и своими библиотеками для одного проекта. — тудаpip installкладёт пакеты (урок 0.1.2).
Полный список путей — в sys.path.
if __name__ == "__main__":
Заголовок раздела «if __name__ == "__main__":»У каждого модуля есть переменная __name__. При запуске файла как скрипта (python tokenizer.py) она равна "__main__",
а при импорте — имени модуля ("mini_llm.tokenizer"). Поэтому код «для ручной проверки» прячут под условие:
class CharTokenizer: ...
if __name__ == "__main__": # выполняется при запуске файла напрямую, но не при import print("самопроверка:", CharTokenizer("абв").encode("ва"))Стандартная библиотека, которая нужна в ML
Заголовок раздела «Стандартная библиотека, которая нужна в ML»| Модуль | Зачем |
|---|---|
collections | Counter (частоты), defaultdict (словарь со значением по умолчанию) |
json | конфиги, датасеты, результаты экспериментов |
pathlib | пути к файлам, поиск файлов по маске |
re | регулярные выражения — мини-язык шаблонов для поиска в тексте («все слова из букв», «все числа»): очистка и разбиение текста (токенизаторы GPT-2 и Qwen режут текст регулярным выражением, правда, через более мощный сторонний пакет regex) |
random, math | сэмплирование, логарифмы, экспоненты |
time, logging, argparse | замеры скорости, логи, аргументы командной строки скриптов обучения |
unicodedata | нормализация Юникода (ниже) |
ПакетпакетEN packageПапка с модулями (обычно с файлом __init__.py). Так же называют дистрибутивы, устанавливаемые через pip. — папка с модулями. Обычно в ней лежит файл __init__.py: он выполняется
при импорте пакета и может «поднять» нужные имена на верхний уровень. Типичная структура маленького проекта:
my_project/├── train.py # точка входа: python train.py└── mini_llm/ # пакет ├── __init__.py # from .tokenizer import CharTokenizer ├── tokenizer.py └── data.py # from .tokenizer import CharTokenizerfrom mini_llm import CharTokenizer # благодаря __init__.pyfrom mini_llm.data import load_ids # абсолютный импорт: путь от корня проектаВнутри пакета модули импортируют друг друга относительно: точка в from .tokenizer import ... значит «из этого же
пакета». Если запустить такой модуль напрямую (python mini_llm/data.py), Python не поймёт, в каком он пакете:
ImportError: attempted relative import with no known parent package. Запускайте его из корня проекта как модуль:
python -m mini_llm.data.
Пакеты из pip устроены так же, просто лежат в site-packages. Имя для установки и имя для импорта иногда различаются:
pip install scikit-learn → import sklearn, pip install pyyaml → import yaml.
Файлы: open и with
Заголовок раздела «Файлы: open и with»Файл открывают функцией open(путь, режим, encoding=...) и почти всегда — внутри with. Запись with open(...) as f:
даёт открытому файлу имя f на время блока с отступом. Когда блок закончится, with сам закроет файл, даже если
внутри произошла ошибка. Незакрытые файлы — частая причина потерянных данных: часть записанного может остаться
в памяти и не попасть на диск.
from pathlib import PathPath("data").mkdir(exist_ok=True) # папка data/ должна существовать (подробнее — в разделе о pathlib)
with open("data/corpus.txt", "w", encoding="utf-8") as f: f.write("Москва — столица России.\n") # write НЕ добавляет перевод строки сам f.write("Пекин — столица Китая. 🐼\n") f.write("Paris is the capital of France.\n")
with open("data/corpus.txt", encoding="utf-8") as f: # режим "r" — по умолчанию for line in f: # строки читаются лениво, по одной line = line.rstrip("\n") # срезать перевод строки справа print(len(line), line)# → 24 Москва — столица России.# → 24 Пекин — столица Китая. 🐼# → 31 Paris is the capital of France.| Режим | Что делает | Осторожно |
|---|---|---|
"r" | чтение текста (по умолчанию) | файла нет → FileNotFoundError |
"w" | запись текста | стирает существующий файл |
"a" | дозапись в конец | удобно для логов и JSONL |
"rb", "wb" | чтение и запись байтов | encoding не указывается |
Открытый файл — ленивый итератор, как генератор из урока 0.2.2: for line in f читает файл в 50 ГБ построчно,
не заполняя память. Методы f.read() и f.readlines() читают файл целиком — для больших корпусов они не годятся.
pathlib: пути как объекты
Заголовок раздела «pathlib: пути как объекты»Модуль pathlib превращает путь из строки в объект с удобными методами. Оператор / склеивает части пути —
класс Path просто определил магический метод __truediv__, который Python вызывает для / (прошлый урок), — и сам ставит правильный разделитель на Windows и Linux:
from pathlib import Path
data_dir = Path("data")data_dir.mkdir(parents=True, exist_ok=True) # создать папку (и родителей), если её нетpath = data_dir / "corpus.txt"print(path.name, path.stem, path.suffix) # → corpus.txt corpus .txttext = path.read_text(encoding="utf-8") # прочитать целиком — для небольших файловprint(len(text), path.stat().st_size) # → 82 125 символов и байт — числа разные! # (на Windows байт будет 128: перевод строки там — 2 байта \r\n)for p in sorted(data_dir.glob("*.jsonl")): # все файлы по маске; rglob — рекурсивно print(p) # (пока .jsonl-файлов нет — цикл ничего не напечатает)82 символа, но 125 байт. Почему — в следующем разделе.
Кодировки: русский и китайский текст
Заголовок раздела «Кодировки: русский и китайский текст»В уроке 0.0.2 вы видели, что компьютер хранит текст байтамибайтEN byte8 бит, 256 возможных значений (от 0 до 255). В байтах измеряют размер файлов, памяти и моделей.. В Python это два разных типа: str — текст из символов
ЮникодаЮникодEN UnicodeЕдиная таблица символов всех письменностей; каждому символу соответствует номер (code point), например «я» = U+044F, «λ» = U+03BB., bytes — последовательность байтов. Переход между ними — это encode и decode,
а правило перевода — кодировкакодировка (символов)EN character encodingСпособ записать символы байтами. UTF-8: латиница — 1 байт, кириллица — 2, китайские иероглифы — 3.. Стандарт де-факто — UTF-8: символ занимает
от 1 до 4 байт.
for ch in ["A", "я", "€", "😀"]: b = ch.encode("utf-8") print(ch, f"U+{ord(ch):04X}", len(b), b)# → A U+0041 1 b'A'# → я U+044F 2 b'\xd1\x8f'# → € U+20AC 3 b'\xe2\x82\xac'# → 😀 U+1F600 4 b'\xf0\x9f\x98\x80'Как читать этот вывод. U+044F — номер символа в Юникоде, записанный в шестнадцатеричной системе (цифры 0–9
и буквы A–F); так номера принято писать в таблицах Юникода. Это тот же номер, что даёт ord: 0x44F = 1103 для «я».
Запись b'\xd1\x8f' — значение типа bytes: буква b перед кавычкой значит «это байты, а не текст», а каждое
\xd1 — один байт, записанный двумя шестнадцатеричными цифрами. У «я» два байта: d1 и 8f. Переводить
шестнадцатеричные числа в уме не нужно: достаточно видеть, сколько байт получилось.
| Фраза | Символов (len) | Байт в UTF-8 | Байт на символ |
|---|---|---|---|
Hello, world! | 13 | 13 | 1.00 |
Привет, мир! | 12 | 21 | 1.75 |
🙂🐼🐉 | 3 | 12 | 4.00 |
Почему это важно для LLM. Токенизаторы GPT-2, Qwen и DeepSeek — byte-level BPEбайтовый BPE (byte-level BPE)EN byte-level BPE (BBPE)BPE, у которого базовый словарь — 256 байтов UTF-8. Любой текст кодируется без <unk>. Используется в GPT-2, GPT-4o, Llama 3, Qwen, DeepSeek.: они начинают с 256 возможных байтов и учатся склеивать частые последовательности. Кириллическая буква до склеек — это 2 байта, китайский иероглиф или знак € — 3, эмодзи — 4. Если в данных обучения токенизатора мало русского, склеек для него выучено меньше, и тот же смысл стоит больше токенов, а значит, больше денег за API и меньше полезного текста в окне контекста. Подробно — в модуле 4.2.
Кракозябры: когда кодировки не совпали
Заголовок раздела «Кракозябры: когда кодировки не совпали»Если прочитать байты UTF-8 как текст в другой кодировке, получатся кракозябрыкракозябры (mojibake)EN mojibakeНечитаемый текст из-за декодирования байтов не той кодировкой, например «РџСЂРёРІРµС‚» вместо «Привет».:
print("Привет, мир!".encode("utf-8").decode("cp1251")) # → Привет, РјРёСЂ!print("Цена: 5 €".encode("utf-8").decode("cp1252")) # → Цена: 5 €print("Ура 🙂".encode("utf-8").decode("cp1252")) # → Ура 🙂Первая строка — русская кодировка Windows (cp1251), две другие — западноевропейская (cp1252).
Иногда вместо кракозябр будет исключение: байты строки "Москва — столица России.", прочитанные как cp1252, дают
UnicodeDecodeError: 'charmap' codec can't decode byte 0x81 in position 5: character maps to <undefined>. Исключение здесь даже
лучше: кракозябры молча попадают в датасет, и модель учится на «РџСЂРёРІРµС‚», а об ошибке вы узнаете сразу.
Откуда берётся чужая кодировка? Если не указать encoding, open() берёт кодировку операционной системы. На Linux
и macOS это UTF-8, а на Windows — региональная: cp1251 на русской системе, cp1252 на английской и cp936 (GBK) на китайской. Код, который
у вас работал на Linux, у коллеги на Windows прочитает корпус как кракозябры. Как распознать и починить кракозябры
в настоящей выгрузке из CRM или Excel — в уроке 11.1.1.
Ещё три инструмента для грязных данных из интернета:
errors="replace"заменяет нечитаемые байты на�вместо исключения:b"\xd0\x9f\xd1".decode("utf-8", errors="replace")даёт'П�'(последняя буква обрезана посередине).errors="ignore"их просто выбрасывает.encoding="utf-8-sig"убирает BOM — невидимую метку\ufeff, которую некоторые программы Windows ставят в начало файла. Без этого первый ключ CSV или JSON может оказаться"\ufeffid"вместо"id".unicodedata.normalize("NFKC", s)приводит «одинаковые на вид» символы к одному виду: лигатураfiпревращается в две буквыfi, верхний индекс²— в2, а неразрывный пробел — в обычный. В китайских текстах так же превращают «широкие» латинские буквы, цифры и знаки препинания в обычные. Подобную нормализацию делают многие токенизаторы (например, в SentencePiece нормализация на основе NFKC включена по умолчанию).
JSON и JSONL
Заголовок раздела «JSON и JSONL»JSON — текстовый формат для словарей и списков: в нём хранят конфиги моделей, разметку, результаты экспериментов.
Модуль json переводит Python-объекты в строку и обратно:
import json
record = {"text": "мир", "tokens": [1, 2, 3], "ok": True, "meta": None}print(json.dumps(record))# → {"text": "\u043c\u0438\u0440", "tokens": [1, 2, 3], "ok": true, "meta": null}print(json.dumps(record, ensure_ascii=False))# → {"text": "мир", "tokens": [1, 2, 3], "ok": true, "meta": null}По умолчанию json.dumps экранирует всё, что не входит в ASCII (английскую таблицу из урока 0.0.2): заменяет
каждый такой символ его номером в виде \u043c (это «м»). Это корректный JSON, но читать его невозможно, а файл раздувается:
кириллическая буква вместо 2 байт занимает 6 (\u044f), знак € — 6 вместо 3, а эмодзи — целых 12 вместо 4 (\ud83d\ude42 — это «🙂»). Для русского и китайского всегда передавайте ensure_ascii=False.
С файлами работают json.dump и json.load (без s на конце):
cfg = {"hidden_size": 512, "num_hidden_layers": 8, "vocab_size": 6400}with open("data/config.json", "w", encoding="utf-8") as f: json.dump(cfg, f, ensure_ascii=False, indent=2) # indent — красивые отступыwith open("data/config.json", encoding="utf-8") as f: assert json.load(f) == cfgТипы Python и JSON соответствуют не один к одному:
| Python | JSON | Подвох |
|---|---|---|
dict | объект {...} | ключи всегда строки: {1: "a"} вернётся как {"1": "a"} |
list, tuple | массив [...] | кортеж вернётся списком |
str, int, float | строка, число | float("nan") — не стандартный JSON |
True, False, None | true, false, null |
Поэтому json.loads(json.dumps({"shape": (2, 3), 1: "a"})) даёт {'shape': [2, 3], '1': 'a'} — уже не равно исходному.
Ещё одна частая ошибка — одинарные кавычки и запятая в конце: {'a': 1} и {"a": 1,} — не JSON
(JSONDecodeError: Expecting property name enclosed in double quotes).
JSONL: формат датасетов
Заголовок раздела «JSONL: формат датасетов»JSONL (JSON Lines) — по одной JSON-записи на строку. В нём хранят почти все датасеты для обучения LLM:
- файл можно читать потоково, строка за строкой, даже если он больше оперативной памяти;
- в него можно дописывать записи режимом
"a"; - одна битая строка не портит весь файл — её можно пропустить и посчитать.
Надёжное чтение JSONL — это файл, цикл по строкам и try/except вокруг каждой записи. Подробно try/except разберём
в следующем разделе, а пока достаточно знать: если код внутри try бросил ошибку JSONDecodeError, Python не падает,
а переходит в блок except и продолжает работу после него.
def read_jsonl(path): records, bad = [], 0 with open(path, encoding="utf-8") as f: for lineno, line in enumerate(f, start=1): # start=1 — нумерация строк с единицы, как в редакторе line = line.strip() if not line: # пустые строки пропускаем молча continue try: records.append(json.loads(line)) except json.JSONDecodeError as e: bad += 1 print(f"строка {lineno}: {e}") return records, badПроверим на файле из пяти строк: три корректные, одна пустая и одна обрезанная.
lines = [ '{"text": "Москва — столица России."}', '{"text": "Пекин — столица Китая. 🐼"}', '', '{"text": оборванная строка', '{"text": "Paris is the capital of France."}',]Path("data/sample.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8")
records, bad = read_jsonl("data/sample.jsonl") # → строка 4: Expecting value: line 1 column 10 (char 9)print(len(records), bad) # → 3 1print(records[1]["text"]) # → Пекин — столица Китая. 🐼Номер строки файла (4) печатаем мы сами, а line 1 column 10 из сообщения JSONDecodeError — позиция внутри этой
строки: на 10-м символе разборщик ждал значение (например, текст в кавычках), а нашёл букву «о».
Исключения
Заголовок раздела «Исключения»Когда операция не может выполниться, Python бросает исключениеисключениеEN exception: создаёт объект ошибки и прерывает выполнение, пока кто-нибудь его не поймает. Как читать tracebackтрассировка ошибки (traceback)EN tracebackСообщение Python об ошибке — цепочка вызовов, которая к ней привела. Читается снизу вверх. и искать баги, разберём в уроке 0.2.5. Здесь — как обрабатывать ошибки, которые вы ожидаете.
Самый простой случай: int() не может превратить текст в число и бросает ValueError. Перехватим его:
text = "сорок два"try: n = int(text) # здесь бросится ValueErrorexcept ValueError as e: # as e — имя для объекта ошибки print("не число:", e) # → не число: invalid literal for int() with base 10: 'сорок два' n = 0print(n) # → 0 — программа не упала и пошла дальшеПолная форма конструкции состоит из четырёх частей:
try:— код, который может упасть;except ТипОшибки:— выполняется, если вtryвозникло исключение этого типа. Блоковexceptможет быть несколько, срабатывает первый подходящий;else:— выполняется, только если исключения не было;finally:— выполняется всегда: и после успеха, и после ошибки. Здесь закрывают ресурсы и сохраняют логи.
А инструкция raise бросает исключение самостоятельно. Все четыре части в одной функции:
def load_config(path): try: with open(path, encoding="utf-8") as f: cfg = json.load(f) except FileNotFoundError: print(f"нет файла {path}, беру значения по умолчанию") cfg = {} except json.JSONDecodeError as e: raise ValueError(f"конфиг {path} повреждён: {e}") from e else: print("конфиг прочитан") # только если исключения НЕ было finally: print("finally выполняется всегда") # закрыть ресурсы, сохранить лог return cfgflowchart TD T["try: код, который может упасть"] -->|"исключения нет"| E["else: продолжение"] T -->|"FileNotFoundError"| X1["except FileNotFoundError"] T -->|"JSONDecodeError"| X2["except JSONDecodeError<br/>raise ValueError … from e"] E --> F["finally: всегда"] X1 --> F X2 --> F F -->|"ошибку обработали"| R["return cfg"] F -->|"исключение не поймано или брошено заново"| U["исключение летит дальше — к вызвавшему коду"]
Правила хорошего тона:
- Ловите конкретные исключения.
except FileNotFoundError:, а не голыйexcept:. Голыйexceptловит вообще всё, включая опечаткуNameErrorв вашем коде иKeyboardInterrupt— и тогда обучение не остановить через Ctrl+C. Если действительно нужно поймать «любую ошибку» (например, чтобы записать её в лог и пропустить пример), пишитеexcept Exception as e:— он не трогает Ctrl+C — и обязательно печатайтеe. - Исключения образуют иерархию, и
exceptловит класс со всеми потомками:json.JSONDecodeError— потомокValueError,FileNotFoundError— потомокOSError,KeyErrorиIndexError— потомкиLookupError. - Бросайте исключения с понятным сообщением:
raise ValueError(f"dim={dim} не делится на n_heads={n_heads}"). Конструкцияraise ... from eсохраняет исходную причину — в traceback будут видны обе ошибки. - Свои исключения — это классы-наследники
Exception(наследование из прошлого урока):
class TokenizerError(Exception): """Ошибка токенизатора."""
def encode_strict(s, stoi): unknown = sorted({ch for ch in s if ch not in stoi}) if unknown: raise TokenizerError(f"символов нет в словаре: {unknown}") return [stoi[ch] for ch in s]
encode_strict("кот!", {"к": 0, "о": 1, "т": 2}) # → TokenizerError: символов нет в словаре: ['!']В Python принят стиль EAFP — «проще попросить прощения, чем разрешения»: не проверять заранее, существует ли файл, а
попробовать открыть его и поймать FileNotFoundError. Между проверкой и открытием файл может исчезнуть, а try честно
обработает любой исход.
Мини-задача: что напечатает load_config для существующего корректного файла и в каком порядке?
Сначала конфиг прочитан (блок else), потом finally выполняется всегда, и функция вернёт словарь. Для отсутствующего
файла — сообщение из except FileNotFoundError, затем finally, и вернётся {}.
Практика
Заголовок раздела «Практика»Соберите мини-конвейер подготовки данных: разберите JSONL с записями на русском, на английском и из одних эмодзи (с битыми строками), определите «язык» каждой записи по преобладающей письменности — каких символов больше: кириллицы, латиницы или эмодзи, — посчитайте документы, символы и байты UTF-8 по группам и сохраните статистику в JSON так, чтобы русский текст и эмодзи в файле остались читаемыми. В браузере файлы тоже работают: они живут во временной памяти страницы и исчезают после перезагрузки.
import json
from pathlib import Path
def parse_jsonl(text):
"""Разобрать JSONL (одна JSON-запись на строку) -> (records, bad).
- пустые строки и строки из одних пробелов пропускаются (это не ошибка);
- запись годится, если json.loads её разобрал И это словарь, у которого "text" — строка;
- всё остальное (битый JSON, список, словарь без "text") — плохая строка: bad += 1.
Возвращает кортеж: (список хороших словарей, число плохих строк).
"""
# TODO: for line in text.splitlines(): ... try: json.loads(...) except json.JSONDecodeError: ...
...
def detect_lang(text):
r"""«Язык» текста по преобладающей письменности: "ru", "en", "emoji" или "unknown".
Кириллица: после lower() символ в диапазоне "а"–"я" или "ё".
Латиница: после lower() символ в диапазоне "a"–"z".
Эмодзи: "\U0001F300" <= ch <= "\U0001FAFF" — основной блок эмодзи (\U и 8 цифр — символ по номеру).
Остальное (цифры, пробелы, знаки, €) не считается. Если таких символов нет — "unknown".
При равенстве счётчиков побеждает тот, кто раньше в порядке "ru", "en", "emoji".
"""
# TODO
...
def corpus_stats(records):
"""Статистика по «языкам»: {язык: {"docs": число записей, "chars": символов, "bytes": байт в UTF-8}}.
Язык записи определяется detect_lang(record["text"]). В результате только встретившиеся языки.
"""
# TODO: подсказка — len(text.encode("utf-8"))
...
def save_json(obj, path):
"""Сохранить obj в JSON-файл: кодировка UTF-8, ensure_ascii=False, indent=2.
Если папки из пути не существуют — создать их."""
# TODO: Path(path).parent.mkdir(...), затем open(..., "w", encoding="utf-8") и json.dump(...)
...
def load_json(path):
"""Прочитать JSON-файл в кодировке UTF-8 и вернуть объект."""
# TODO
...
# Проверьте себя кнопкой «Запустить»:
SAMPLE = """{"text": "Москва — столица России."}
{"text": "🚀🔥👍"}
{"text": оборванная строка
{"text": "Paris is the capital of France."}"""
result = parse_jsonl(SAMPLE)
print(result)
if result:
records, bad = result
print(corpus_stats(records))
Проверь себя
Заголовок раздела «Проверь себя»- Модуль — файл
.py, пакет — папка с модулями и__init__.py. Импорт выполняет модуль один раз; не называйте свои файлыjson.pyилиrandom.py. Код самопроверки прячьте подif __name__ == "__main__":. - Файлы открывайте через
with open(..., encoding="utf-8")илиPath(...).read_text(encoding="utf-8"). Режим"w"стирает файл,"a"дописывает;for line in fчитает лениво. - UTF-8: 1 байт на латиницу, 2 на кириллицу, 3 на знак € (и на китайский иероглиф), 4 на эмодзи. Чужая кодировка даёт кракозябры или
UnicodeDecodeError. - JSON —
ensure_ascii=Falseдля русского и китайского; JSONL — одна запись на строку, стандарт датасетов LLM. - Ловите конкретные исключения, бросайте понятные, используйте
else/finallyиraise ... from e.
Дальше: урок 0.2.5 «Ошибки и отладка» — как читать traceback снизу вверх, ставить точки останова и находить баги быстрее, чем они находят вас.