Устройство¶
Как устроен пакет и почему так. Каждое правило ниже либо проверяется тестом, либо следует из задачи.
Слои¶
core типы, протоколы, результаты, исключения только stdlib
├── io чтение прогонов, разметки, логов, таблиц core (+ pyarrow лениво)
├── metrics реестр, NDCG/MAP/MRR/ERR/RBP/RBO, judged core, numpy
├── stats bootstrap, перестановки, BH, MDE, PPI++ core, numpy
├── viz графики результатов core (+ plotly лениво)
│
├── aggregate сравнение по сегментам core, metrics, stats, pandas
├── offpolicy IPS/SNIPS, симулятор, лог из событий core, metrics, stats, numpy, pandas
├── explain адаптеры моделей, Шепли, listwise core, metrics, numpy (+ модели лениво)
└── report атомарная запись, HTML и Markdown core, viz
api import ranklens as rl: таблицы → сравнение всё выше, numpy, pandas
cli связывает всё; сам сторонних библиотек не импортирует
core— словарь проекта: типы (RankedList,Qrels,Impression), результаты (MetricResult,ComparisonResult, …), протоколы, исключения. Только стандартная библиотека: его импортирует каждый слой, и он не должен тянуть numpy.- Слой видит только то, что ниже:
metricsне знает оstats,io— о numpy,explain— обio. Поэтому таблицу признаков читаетio(на stdlib), матрицу из неё собираетexplain, а связывает их CLI. - Тяжёлые и необязательные библиотеки — лениво, внутри функций: pyarrow, plotly,
CatBoost, LightGBM, XGBoost, scikit-learn, torch, onnxruntime.
import ranklensих не загружает; без библиотеки —MissingDependencyErrorс именем extra.
Как проверяется. tests/test_architecture.py разбирает AST каждого модуля и сверяет
импорты с таблицей слоёв — и межпакетные, и сторонние, и «только лениво». Нарушение —
упавший тест с указанием файла и строки (так был пойман import numpy в CLI).
Потоки, а не таблицы в памяти¶
Прогон — это миллионы строк. iter_run — генератор: держит в памяти строки одного
запроса и отдаёт RankedList по одному; evaluate считает метрики пачками и тоже
не хранит прогон. Два прогона сразу — PairedRuns, свой итератор: merge-join двух
отсортированных потоков с буфером в одну выдачу на сторону. zip спарил бы
несвязанные запросы, как только в одном прогоне запрос пропущен.
Целиком в память грузится то, что нужно для случайного доступа: разметка (Qrels —
по ней ищут оценку пары запрос–документ) и таблицы признаков для объяснений
(выборка запросов).
Данные на входе¶
- Идентификаторы — строки.
QueryIdиDocId—NewTypeнадstr, целые id приводятся к строке при чтении. Иначе pandas прочитает разметку с idint64, а выдачу — со строками,qrels[1]не найдёт"1", и метрика молча станет 0. Ведущие нули ("007") сохраняются, а merge-join двух прогонов получает полный порядок. Целый float становится целым: pandas хранит целые id с пропусками как float, и7.0должно совпасть с"7". - Два слоя чтения.
io/formats.pyпревращает строку любого формата вRecordсо строковыми полями и ничего не знает о прогонах;io/schema.pyзнает колонки и типы. Новый формат не трогает разбор, новая колонка — форматы. parquet, Feather и таблицы в памяти (pandas, polars, pyarrow, DuckDB) читаются через протокол Arrow C stream: pandas и polars не импортируются, pyarrow — лениво, пачками по 64 тыс. строк. - Контракт порядка. По умолчанию строки запроса идут подряд (
check="grouped"): хранится только множество id законченных запросов. Двум прогонам рядом (PairedRuns) нужна сортировка id как строк (check="sorted", память O(1)); численно отсортированный TREC-файл (9,10) в ней падает, поэтому это не умолчание. Нарушение контракта — ошибка и в нестрогом режиме: пропустить «часть запроса» нельзя. - Что фатально. Битая строка в нестрогом режиме пропускается и считается; нет обязательной колонки или формат неизвестен — ошибка всегда: пропускать нечего.
Метрики: умолчания как в trec_eval¶
Одна и та же «NDCG@10» у trec_eval и у LTR-библиотек — разные числа. По умолчанию ranklens совпадает с trec_eval и с опубликованными результатами:
ndcg— линейный gain, отрицательная оценка — 0, IDCG — по всем размеченным документам запроса, обрезанным на k;ndcg(gain=exp)— 2^rel − 1, как в LightGBM, CatBoost и XGBoost.mapиmrr— бинарная релевантностьrel ≥ 1; знаменатель AP — все релевантные документы запроса, поэтому AP@k < 1, если их больше k.- Неразмеченный документ нерелевантен; запрос без релевантных документов — 0, число таких запросов лежит в результате отдельно.
- TREC-прогон упорядочивается по score по убыванию, ничьи — по id документа по убыванию
как строки; порядок строк и колонка rank не важны. Так же сортирует
rl.run(score=...). - Классы названы по значению на запросе (
AP,RR), имена в реестре — по среднему, которое попадает в отчёт (map,mrr).
Совпадение проверяет golden-тест: эталон один раз получен через pytrec_eval
(scripts/make_golden.py) и закоммичен, так что CI pytrec_eval не нужен. ndcg,
ndcg@5/10/100, map, map@10/100 и mrr совпадают по каждому запросу до 1e-9.
- ERR и RBP:
max_rel— верх шкалы, а не максимум по запросу, иначе запросы с разными шкалами несравнимы; по умолчанию 4, как в TREC gdeval. Оценка выше шкалы считаетсяmax_rel. Идеальная выдача у них не даёт 1. - RBO сравнивает два порядка без разметки, поэтому это функция
rbo, а не метрика реестра. - Позиции и ранги. В структурах позиция считается с нуля (индекс в кортеже), в формулах
ранг — с единицы:
rank = position + 1. Ошибка на единицу в дисконте — самый частый тихий баг метрик ранжирования.
Пачки. Поиск оценки документа в словаре остаётся циклом Python, но матрица оценок
строится один раз на пачку из 1024 запросов и общая для всех метрик, а арифметика идёт
в numpy — в 7.5 раза быстрее поштучного расчёта. Метрика включается в это методом batch
(протокол BatchMetric); сторонние метрики без него считаются по запросу в том же проходе.
Hypothesis проверяет, что пачка совпадает с поштучным расчётом.
Структуры данных¶
| Структура | Где | Почему |
|---|---|---|
list |
буфер строк текущего запроса в ридерах, накопление значений | дописывание в конец — амортизированное O(1); собранная выдача сразу превращается в tuple |
tuple |
RankedList.docs, SegmentKey, вклады признаков |
порядок — это позиция; неизменяем и хешируем, годится в поле frozen-объекта и в ключ словаря |
dict |
Qrels, реестр метрик имя → фабрика, ADAPTERS |
поиск оценки пары за O(1) в горячем цикле; реестры убирают цепочки if и дают точку расширения |
set |
проверка дубликатов документов, пересечение запросов двух прогонов, union-find групп признаков | дубликат — O(n) вместо O(n²); пересечение запросов — ровно & |
| генератор | iter_run, iter_clicklog, iter_records |
константная память на прогоне любого размера |
| свой итератор | PairedRuns |
merge-join с подсчётом непарных запросов (only_left, only_right) |
MappingProxyType |
MetricResult.per_query, Evaluation.segments |
результаты делят статистика и отчёты — ни те, ни другие не должны их менять |
frozen=True неизменяемость не гарантирует: запрещено присваивание полей, но список
в поле можно менять. Поэтому в полях — tuple, MappingProxyType и read-only массивы
numpy (QueryFeatures копирует матрицу и снимает флаг записи). slots=True — меньше
памяти и ошибка при опечатке в имени атрибута.
Протоколы, а не базовые классы¶
Метрика — любой вызываемый объект (ranked, judgements, k) -> float, в том числе обычная
функция; модель для объяснений — любой объект с feature_names, score и
native_importance. Это Protocol (структурная типизация): реализация не наследуется
от наших классов, и чужую модель или функцию не нужно оборачивать в иерархию. Имя
метрики живёт в спецификации (ndcg@10), а не в самой метрике: у функции нет атрибута
name, и протокол с ним не принял бы её.
Добавить модель — класс-адаптер и одна строка в ADAPTERS; адаптер выбирается по MRO
класса модели, поэтому подкласс LGBMRanker из пользовательского кода тоже распознаётся.
Скор — сырой выход модели (margin), а не вероятность и не класс: ранжированию нужен
порядок, а predict классификатора вернул бы метки.
Плагины метрик. Реестр хранит фабрики имя → factory(**params), спецификация — как
в ir_measures: ndcg, ndcg@10, ndcg(gain=exp)@10. Сторонние пакеты добавляют метрики
через entry points группы ranklens.metrics (рецепт — «Своя метрика»). Плагины грузятся
при первом обращении к реестру, а не при import ranklens: импорт не исполняет чужой код.
Сломанный плагин пропускается с PluginWarning, а встроенное имя плагин не перекрывает:
подмена ndcg чужой реализацией была бы тихой ошибкой в результатах.
Ошибки и коды выхода¶
RankLensError
├── DataError битые входные данные → код 3
│ ├── MalformedRowError, UnsortedInputError, UngroupedInputError
│ ├── MissingColumnError, DuplicateDocumentError, MissingQrelsError
├── ConfigError конфигурация, метрики, плагины → код 1
│ ├── MetricNotFoundError (с подсказкой «did you mean»), MetricSpecError
│ ├── DuplicateMetricError, UnsupportedFormatError, MissingDependencyError
├── ModelError → UnsupportedModelError (с подсказкой про CallableAdapter)
└── StatisticalError → InsufficientSampleError
Остальные RankLensError (модель, статистика) — код 1; код 2 — неверные аргументы
(argparse), код 0 — успех. Ошибки несут поля, а не только
текст (line_no, path, column), и передают аргументы в super().__init__ —
иначе их не восстановить через pickle (тест проверяет). Битая строка в нестрогом режиме
пропускается и считается (ErrorCollector), в --strict — останавливает работу:
разведка данных и CI-гейт — два разных сценария.
Предупреждения — отдельная иерархия RankLensWarning: малая выборка, вырожденные веса,
пропущенные строки, сломанный плагин. Тесты запускаются с filterwarnings = error:
неожиданное предупреждение — упавший тест.
Воспроизводимость¶
Каждый случайный результат печатает свой seed. Независимые процедуры получают
независимые потоки из него через SeedSequence: bootstrap и перестановки в одном
сравнении, каждый сегмент — свой поток (иначе сегменты одного размера получали бы
одинаковые ресемплы). Поток сегмента выводится из seed и значений самого сегмента
(стабильный crc32, а не hash(), который меняется между запусками Python), поэтому
цифры сегмента не меняются, когда в данных появляются другие сегменты.
Командная строка и import ranklens as rl¶
- В CLI три команды:
eval,compare,explain— сценарии без Python и гейты в CI по коду выхода. Off-policy и список метрик — в Python: лог показов почти всегда готовят там же. Меньше команд — меньше мест, где CLI расходится с библиотекой. --order auto: прогон с колонкойscoreранжируется по ней, иначе поrank, иначе по порядку строк; TREC — всегда по score. Читать выгрузку модели в порядке строк значило бы сравнивать случайный порядок экспорта. Явный--orderбез нужной колонки — ошибка.iter_runв Python остаётся явным: угадывание — удобство командной строки.rl— тонкий слой поверх модулей. Он загружается лениво (__getattr__пакета, PEP 562), поэтомуimport ranklensне тянет pandas.rl.runстроит выдачи одной сортировкой по целочисленным кодам (np.lexsort) вместо построчного разбора: 10 млн строк — около 12 секунд. Несколько выдач на запрос (list_id=) усредняются внутри запроса, а сиды одной модели — по запросу до статистики: разница сидов — шум обучения, а не свойство модели.
Графики и отчёты¶
Графики ничего не считают: принимают готовые *Result, и каждое число на графике есть
в результате. HTML-отчёт самодостаточен — plotly.js встроен один раз, с первым графиком,
и файл открывается без сети. Markdown-отчёт — те же таблицы без графиков.
AtomicReport пишет во временный файл в той же директории и заменяет цель через
os.replace только после fsync: при ошибке или прерывании остаётся прежний отчёт,
а не половина нового. with open(path, "w") сначала обнуляет цель.