Перейти к содержанию

Устройство

Как устроен пакет и почему так. Каждое правило ниже либо проверяется тестом, либо следует из задачи.

Слои

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 прочитает разметку с id int64, а выдачу — со строками, 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") сначала обнуляет цель.