ranklens 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. ranklens-0.2.0/LICENSE +21 -0
  2. ranklens-0.2.0/PKG-INFO +302 -0
  3. ranklens-0.2.0/README.md +263 -0
  4. ranklens-0.2.0/pyproject.toml +138 -0
  5. ranklens-0.2.0/src/ranklens/__init__.py +43 -0
  6. ranklens-0.2.0/src/ranklens/__main__.py +3 -0
  7. ranklens-0.2.0/src/ranklens/aggregate/__init__.py +5 -0
  8. ranklens-0.2.0/src/ranklens/aggregate/segments.py +171 -0
  9. ranklens-0.2.0/src/ranklens/api/__init__.py +24 -0
  10. ranklens-0.2.0/src/ranklens/api/compare.py +254 -0
  11. ranklens-0.2.0/src/ranklens/api/data.py +256 -0
  12. ranklens-0.2.0/src/ranklens/api/explain.py +160 -0
  13. ranklens-0.2.0/src/ranklens/cli.py +715 -0
  14. ranklens-0.2.0/src/ranklens/core/__init__.py +88 -0
  15. ranklens-0.2.0/src/ranklens/core/exceptions.py +310 -0
  16. ranklens-0.2.0/src/ranklens/core/feedback.py +108 -0
  17. ranklens-0.2.0/src/ranklens/core/protocols.py +21 -0
  18. ranklens-0.2.0/src/ranklens/core/registry.py +180 -0
  19. ranklens-0.2.0/src/ranklens/core/result.py +310 -0
  20. ranklens-0.2.0/src/ranklens/core/types.py +81 -0
  21. ranklens-0.2.0/src/ranklens/explain/__init__.py +22 -0
  22. ranklens-0.2.0/src/ranklens/explain/adapters/__init__.py +56 -0
  23. ranklens-0.2.0/src/ranklens/explain/adapters/base.py +94 -0
  24. ranklens-0.2.0/src/ranklens/explain/adapters/libraries.py +129 -0
  25. ranklens-0.2.0/src/ranklens/explain/adapters/neural.py +96 -0
  26. ranklens-0.2.0/src/ranklens/explain/data.py +66 -0
  27. ranklens-0.2.0/src/ranklens/explain/diagnose.py +61 -0
  28. ranklens-0.2.0/src/ranklens/explain/grouping.py +61 -0
  29. ranklens-0.2.0/src/ranklens/explain/listwise.py +102 -0
  30. ranklens-0.2.0/src/ranklens/explain/loading.py +83 -0
  31. ranklens-0.2.0/src/ranklens/explain/pairwise.py +112 -0
  32. ranklens-0.2.0/src/ranklens/io/__init__.py +35 -0
  33. ranklens-0.2.0/src/ranklens/io/arrow.py +90 -0
  34. ranklens-0.2.0/src/ranklens/io/clicklog.py +215 -0
  35. ranklens-0.2.0/src/ranklens/io/errors.py +38 -0
  36. ranklens-0.2.0/src/ranklens/io/features.py +163 -0
  37. ranklens-0.2.0/src/ranklens/io/formats.py +247 -0
  38. ranklens-0.2.0/src/ranklens/io/paired.py +91 -0
  39. ranklens-0.2.0/src/ranklens/io/readers.py +218 -0
  40. ranklens-0.2.0/src/ranklens/io/schema.py +123 -0
  41. ranklens-0.2.0/src/ranklens/metrics/__init__.py +33 -0
  42. ranklens-0.2.0/src/ranklens/metrics/base.py +57 -0
  43. ranklens-0.2.0/src/ranklens/metrics/builtin.py +24 -0
  44. ranklens-0.2.0/src/ranklens/metrics/err_rbp.py +93 -0
  45. ranklens-0.2.0/src/ranklens/metrics/evaluation.py +104 -0
  46. ranklens-0.2.0/src/ranklens/metrics/judged.py +33 -0
  47. ranklens-0.2.0/src/ranklens/metrics/map_mrr.py +82 -0
  48. ranklens-0.2.0/src/ranklens/metrics/ndcg.py +49 -0
  49. ranklens-0.2.0/src/ranklens/metrics/rbo.py +34 -0
  50. ranklens-0.2.0/src/ranklens/metrics/vectorized.py +56 -0
  51. ranklens-0.2.0/src/ranklens/offpolicy/__init__.py +24 -0
  52. ranklens-0.2.0/src/ranklens/offpolicy/diagnostics.py +50 -0
  53. ranklens-0.2.0/src/ranklens/offpolicy/estimators.py +227 -0
  54. ranklens-0.2.0/src/ranklens/offpolicy/events.py +232 -0
  55. ranklens-0.2.0/src/ranklens/offpolicy/propensity.py +88 -0
  56. ranklens-0.2.0/src/ranklens/offpolicy/simulator.py +130 -0
  57. ranklens-0.2.0/src/ranklens/py.typed +0 -0
  58. ranklens-0.2.0/src/ranklens/report/__init__.py +30 -0
  59. ranklens-0.2.0/src/ranklens/report/atomic.py +81 -0
  60. ranklens-0.2.0/src/ranklens/report/html.py +71 -0
  61. ranklens-0.2.0/src/ranklens/report/markdown.py +44 -0
  62. ranklens-0.2.0/src/ranklens/report/sections.py +26 -0
  63. ranklens-0.2.0/src/ranklens/stats/__init__.py +52 -0
  64. ranklens-0.2.0/src/ranklens/stats/bootstrap.py +104 -0
  65. ranklens-0.2.0/src/ranklens/stats/chunking.py +19 -0
  66. ranklens-0.2.0/src/ranklens/stats/compare.py +64 -0
  67. ranklens-0.2.0/src/ranklens/stats/multiple.py +57 -0
  68. ranklens-0.2.0/src/ranklens/stats/permutation.py +51 -0
  69. ranklens-0.2.0/src/ranklens/stats/power.py +69 -0
  70. ranklens-0.2.0/src/ranklens/stats/ppi.py +152 -0
  71. ranklens-0.2.0/src/ranklens/viz/__init__.py +19 -0
  72. ranklens-0.2.0/src/ranklens/viz/charts.py +174 -0
ranklens-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AlexandrBaranov7
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,302 @@
1
+ Metadata-Version: 2.4
2
+ Name: ranklens
3
+ Version: 0.2.0
4
+ Summary: Offline evaluation, statistical comparison and explanation of ranking quality
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: ranking,information-retrieval,ndcg,evaluation,off-policy,learning-to-rank
8
+ Author: AlexandrBaranov7
9
+ Requires-Python: >=3.11
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
19
+ Classifier: Typing :: Typed
20
+ Provides-Extra: arrow
21
+ Provides-Extra: models
22
+ Provides-Extra: neural
23
+ Provides-Extra: viz
24
+ Requires-Dist: catboost ; extra == "models"
25
+ Requires-Dist: lightgbm ; extra == "models"
26
+ Requires-Dist: numpy (>=1.26)
27
+ Requires-Dist: onnxruntime (>=1.17) ; extra == "neural"
28
+ Requires-Dist: pandas (>=2.2)
29
+ Requires-Dist: plotly (>=5.18) ; extra == "viz"
30
+ Requires-Dist: pyarrow (>=16) ; extra == "arrow"
31
+ Requires-Dist: scikit-learn ; extra == "models"
32
+ Requires-Dist: xgboost ; extra == "models"
33
+ Project-URL: Changelog, https://github.com/AlexandrBaranov7/ranklens/blob/main/CHANGELOG.md
34
+ Project-URL: Documentation, https://alexandrbaranov7.github.io/ranklens/
35
+ Project-URL: Homepage, https://github.com/AlexandrBaranov7/ranklens
36
+ Project-URL: Issues, https://github.com/AlexandrBaranov7/ranklens/issues
37
+ Description-Content-Type: text/markdown
38
+
39
+ <h1>
40
+ <picture>
41
+ <source media="(prefers-color-scheme: dark)" srcset="docs/images/brand/logo-wordmark-dark.svg">
42
+ <img src="https://alexandrbaranov7.github.io/ranklens/images/brand/logo-wordmark.svg" alt="ranklens" height="64">
43
+ </picture>
44
+ </h1>
45
+
46
+ [![CI](https://github.com/AlexandrBaranov7/ranklens/actions/workflows/ci.yml/badge.svg)](https://github.com/AlexandrBaranov7/ranklens/actions/workflows/ci.yml)
47
+ [![PyPI](https://img.shields.io/pypi/v/ranklens)](https://pypi.org/project/ranklens/)
48
+ [![Документация](https://img.shields.io/badge/docs-ranklens-00958c)](https://alexandrbaranov7.github.io/ranklens/)
49
+
50
+ Модель поменяли — стало ли лучше, **где** и **почему**? ranklens отвечает на это из
51
+ таблиц pandas одной строкой: разница с интервалом, p- и q-value, минимальный заметный
52
+ эффект (MDE), разбивка по сегментам с поправкой на множественные сравнения. Метрики
53
+ совпадают с trec_eval до 1e-9.
54
+
55
+ ```python
56
+ import ranklens as rl
57
+
58
+ rl.compare(current, new, qrels, ["ndcg@10"], by="device")
59
+ ```
60
+
61
+ ## Типичные задачи
62
+
63
+ Примеры — на синтетическом каталоге из `docs/examples/catalog.py`: запросы, товары
64
+ и оценки выдуманы, но устроены как в настоящем поиске. На
65
+ [главной странице документации](https://alexandrbaranov7.github.io/ranklens/) они
66
+ выполняются при каждой сборке.
67
+
68
+ ```text
69
+ query product grade history base_s0 new_s0
70
+ 0 молоко Молоко Луговое 3,2% 1 л 3 нет 0.92 1.08
71
+ 1 молоко Молоко Северная ферма 2,5% 930 мл 2 нет 0.97 1.04
72
+ ```
73
+
74
+ **Эффект виден только в сегменте.** Новая модель знает, покупал ли пользователь товар
75
+ раньше, — такой признак есть только у пользователей с историей покупок (около 9%
76
+ запросов). У каждой модели два сида: они усредняются по запросу.
77
+
78
+ ```python
79
+ rl.compare([base_s0, base_s1], [new_s0, new_s1], qrels, ["ndcg@10"], by="history")
80
+ ```
81
+
82
+ ```text
83
+ n_queries delta ci_low ci_high mde q_value
84
+ metric history
85
+ ndcg@10 есть 29 0.0387 0.0190 0.0616 0.0308 0.0006
86
+ нет 291 -0.0010 -0.0034 0.0015 0.0035 0.4194
87
+ ```
88
+
89
+ В среднем по всем запросам прирост +0.0026 не отличим от нуля и меньше MDE (0.0046) —
90
+ минимального эффекта, который эти запросы способны показать. Разбивка находит, где он.
91
+
92
+ **Две версии выдачи размечены разными партиями асессоров.**
93
+
94
+ ```python
95
+ rl.agreement(batch_cur, batch_new)
96
+ # Agreement(2495 common pairs: exact 88.8%, mean grade A 1.801, B 1.913, shift +0.112)
97
+ qrels = rl.merge_qrels(batch_cur, batch_new) # одна разметка для обеих сторон
98
+ rl.compare(current, new_version, qrels, ["ndcg@5", "ndcg@10", "err@10"])
99
+ ```
100
+
101
+ ```text
102
+ n_queries delta ci_low ci_high mde q_value
103
+ metric
104
+ ndcg@5 320 -0.0019 -0.0039 0.0000 0.0028 0.0801
105
+ ndcg@10 320 0.0629 0.0559 0.0699 0.0099 0.0003
106
+ err@10 320 0.0019 -0.0016 0.0054 0.0051 0.2966
107
+ ```
108
+
109
+ Вторая партия мягче, поэтому сравниваем на общей разметке. NDCG@10 значимо выше,
110
+ а метрики верха выдачи — нет: новая версия улучшает позиции 6–10.
111
+
112
+ **Почему: вклад признаков в метрику.** Модель — CatBoost, LightGBM, XGBoost,
113
+ scikit-learn, PyTorch, ONNX или любая функция скоринга.
114
+
115
+ ```python
116
+ ex = rl.explainer(model, df, query="query", doc="product", relevance="grade")
117
+ ex.importance("ndcg@10") # насколько падает метрика, если перемешать признак
118
+ ex.worst(20) # худшие запросы и признаки, которые их подвели
119
+ ```
120
+
121
+ ```text
122
+ drop of ndcg@10
123
+ feature
124
+ text_match 0.1548
125
+ bought_before 0.0066
126
+ popularity 0.0046
127
+ price_rank 0.0006
128
+ ```
129
+
130
+ ## Установка
131
+
132
+ ```bash
133
+ pip install "ranklens[viz]"
134
+ ```
135
+
136
+ Extras: `viz` (графики и HTML-отчёты), `arrow` (parquet, Feather), `models` (CatBoost,
137
+ LightGBM, XGBoost, scikit-learn), `neural` (ONNX). PyTorch ставится по инструкции
138
+ pytorch.org. Свежий `main` — `pip install "ranklens @ git+https://github.com/AlexandrBaranov7/ranklens"`.
139
+
140
+ ## Данные и командная строка
141
+
142
+ `rl.run` и `rl.qrels` берут таблицу pandas, polars или pyarrow с одной строкой на пару
143
+ «запрос — документ»; порядок — по скору модели (`score=`), по позиции (`rank=`) или
144
+ порядку строк. Несколько выдач на запрос (по магазинам, сессиям) — `list_id=`.
145
+
146
+ ```python
147
+ qrels = rl.qrels(labels, query="qid", doc="sku", relevance="grade")
148
+ current = rl.run(df_a, query="qid", doc="sku", rank="pos", segments="device")
149
+ new = rl.run(df_b, query="qid", doc="sku", rank="pos", segments="device")
150
+ ```
151
+
152
+ Те же сравнения из терминала — для файлов и CI:
153
+
154
+ ```bash
155
+ ranklens eval --run run.trec --qrels qrels.trec --metrics ndcg@10 mrr@10
156
+ ranklens compare --baseline a.csv --candidate b.csv --qrels qrels.csv \
157
+ --metrics ndcg@10 mrr@10 --segments device locale --report compare.html
158
+ ranklens explain --model model.cbm --features features.csv --top-worst 20
159
+ ```
160
+
161
+ ```text
162
+ metric device locale queries mean A mean B delta 95% CI p q
163
+ ndcg@10 desktop ru 60 0.5352 0.5352 +0.0000 [+0.0000, +0.0000] 1.0000 1.0000
164
+ ndcg@10 mobile en 60 0.6490 0.6329 -0.0161 [-0.0302, -0.0038] 0.0655 0.0982
165
+ ndcg@10 mobile ru 60 0.6137 0.6926 +0.0789 [+0.0409, +0.1234] 0.0005 0.0015 *
166
+ ```
167
+
168
+ `--report compare.html` пишет один файл со всеми графиками, который открывается без
169
+ интернета: таблица, покрытие разметкой, разница по сегментам с интервалами (значимые
170
+ после поправки — синим), распределение поразапросных разниц, метрика по cutoff,
171
+ перемещения документов из топ-10.
172
+
173
+ ![Отчёт сравнения: таблица и разница по сегментам](https://alexandrbaranov7.github.io/ranklens/images/compare-report.jpg)
174
+
175
+ Подробнее — [«Первые шаги»](https://alexandrbaranov7.github.io/ranklens/tutorial/).
176
+
177
+ | Что | Где |
178
+ |---|---|
179
+ | Таблицы pandas/polars → сравнение одной строкой: сиды, сегменты, MDE, покрытие разметки | `import ranklens as rl` |
180
+ | NDCG, MAP, MRR (умолчания trec_eval), ERR, RBP, RBO, judged@k; свои метрики — плагином | `ranklens.metrics` |
181
+ | Парный bootstrap по запросам, перестановочный тест, Бенджамини — Хохберг, MDE | `ranklens.stats` |
182
+ | Сравнение по сегментам с q-values | `ranklens.aggregate` |
183
+ | Неточная разметка: согласие партий, общая разметка, PPI++ (дешёвая proxy-разметка + gold-выборка), judged@k | `rl.agreement`, `rl.merge_qrels`, `ranklens.stats` |
184
+ | Off-policy: IPS и SNIPS по логам кликов, PBM, симулятор с известной истиной, ESS | `ranklens.offpolicy` |
185
+ | Объяснение: Шепли для пары документов, вклад признаков в метрику, группы коррелированных признаков, худшие запросы | `rl.explainer`, `ranklens.explain` |
186
+ | Графики и самодостаточные HTML/Markdown-отчёты | `ranklens.viz`, `ranklens.report` |
187
+
188
+ ## Почему не существующие библиотеки
189
+
190
+ - **[pytrec_eval](https://github.com/cvangysel/pytrec_eval)** — обёртка над trec_eval:
191
+ быстрый эталон метрик, десятки мер. ranklens сверяется с ним golden-тестами и
192
+ проигрывает ему в числе мер. Но pytrec_eval отвечает на «сколько», а не на «отличимо
193
+ ли это от шума» — сравнения, интервалов и сегментов в нём нет.
194
+ - **[ir_measures](https://github.com/terrierteam/ir_measures)** — единый интерфейс
195
+ к нескольким реализациям мер; хорош, когда нужна именно мера из конкретного инструмента.
196
+ Статистику сравнения и объяснения он не делает.
197
+ - **[ranx](https://github.com/AmenRa/ranx)** умеет и метрики, и статистическое
198
+ сравнение прогонов, и fusion — ближайший аналог в части сравнения. ranklens добавляет
199
+ то, чего в нём нет: поправку FDR по сегментам, MDE, PPI для неточной разметки,
200
+ off-policy оценку по кликам и объяснение модели по метрике.
201
+
202
+ ranklens не заменяет их в скорости и полноте набора мер — он закрывает путь от
203
+ «цифра изменилась» до «вот где и почему».
204
+
205
+ ## Архитектура
206
+
207
+ Слои с проверяемыми границами: `core` (типы, результаты, протоколы, исключения — только
208
+ stdlib) → `io`, `metrics`, `stats`, `viz` → `aggregate`, `offpolicy`, `explain`, `report`
209
+ → `api` (`import ranklens as rl`) и `cli`. `tests/test_architecture.py` разбирает AST каждого модуля и падает, если слой
210
+ импортирует то, что ему не положено, или тяжёлую библиотеку не лениво. Прогоны читаются
211
+ потоком (генератор, в памяти — одна выдача), разметка — в словарь для поиска за O(1).
212
+ Подробно — [«Устройство»](https://alexandrbaranov7.github.io/ranklens/design/).
213
+
214
+ ## Выбор структур данных и средств языка
215
+
216
+ | Требование | Где | Почему так |
217
+ |---|---|---|
218
+ | `list` | буфер строк текущего запроса в `iter_run`, `iter_clicklog` | дописывание в конец — амортизированное O(1); собранная выдача сразу становится `tuple` |
219
+ | `tuple` | `RankedList.docs`, `SegmentKey`, `Impression.positions`, вклады признаков | порядок — это позиция; неизменяем и хешируем: поле frozen-объекта и ключ словаря. `list` в frozen dataclass менялся бы на месте |
220
+ | `dict` | `Qrels: dict[QueryId, dict[DocId, float]]`, реестр метрик, `ADAPTERS` | поиск оценки пары запрос–документ за O(1) в горячем цикле; реестры вместо цепочек `if` — точки расширения |
221
+ | `set` | дубликаты документов в `RankedList`, общие запросы двух прогонов, повторы в ридерах | проверка дубликатов O(n) вместо O(n²); общие запросы — ровно `a.keys() & b.keys()` |
222
+ | генератор | `iter_run`, `iter_clicklog`, `iter_records` | в памяти — строки одного запроса: на отсортированном входе память постоянна, на сгруппированном растёт на ~200 байт на запрос (id уже прочитанных); тесты меряют пик `tracemalloc` на файле в 10 раз больше |
223
+ | свой итератор | `PairedRuns` (`__iter__`/`__next__`) | merge-join двух отсортированных потоков; `zip` молча спарил бы разные запросы, когда в одном прогоне запрос пропущен |
224
+ | своё исключение | иерархия `RankLensError`: `DataError`, `ConfigError`, `ModelError`, `StatisticalError` | у ошибок поля (`line_no`, `path`, `column`) и подсказки («did you mean 'ndcg'?»); аргументы в `super().__init__` — иначе не переживают pickle (тест) |
225
+ | осмысленная обработка | `ErrorCollector`: битые строки пропускаются и считаются; `--strict` останавливает | два сценария — разведка данных и CI-гейт; коды выхода 0 / 1 конфигурация / 2 аргументы / 3 данные |
226
+ | контекстный менеджер | `AtomicReport` | временный файл в той же директории, `fsync`, `os.replace` только без исключения; при ошибке или `Ctrl-C` остаётся прежний отчёт. `with open()` сначала обнулил бы цель |
227
+ | аннотации + mypy | весь пакет и тесты, mypy `strict`, `py.typed` | ошибки типов ловятся в CI до тестов; пакет отдаёт типы пользователям |
228
+ | pytest | 750+ тестов, покрытие ~99%, порог 90% | точные значения, property-based (Hypothesis), golden против trec_eval, статистическая валидация на симуляциях |
229
+ | `pyproject.toml` | PEP 621, `[project.scripts]`, entry points `ranklens.metrics`, extras | одна точка для метаданных, команды, плагинов и необязательных зависимостей |
230
+ | установка pip | задание CI `install` | собранный wheel ставится **обычным pip** в чистое окружение, и `scripts/tutorial_smoke.py` проходит «Первые шаги» вне репозитория |
231
+ | pandas + агрегация | `offpolicy/events.py`: `merge`, `groupby().max()`, `explode`, `value_counts`; `aggregate` — таблица по сегментам | склейка событий показов и реакций — табличная операция; сегменты отдаются DataFrame |
232
+
233
+ Неизменяемость результатов: `frozen=True, slots=True` + `tuple` + `MappingProxyType` +
234
+ read-only массивы numpy — `frozen` сам по себе запрещает только присваивание полей.
235
+
236
+ ## Почему Protocol, а не ABC
237
+
238
+ Метрика — любой вызываемый объект `(ranked, judgements, k) -> float`, в том числе обычная
239
+ функция; модель для объяснений — любой объект с `feature_names`, `score`,
240
+ `native_importance`. Это `typing.Protocol`: структурная типизация, реализации не
241
+ наследуются от наших классов, и чужую модель или функцию не нужно встраивать в иерархию.
242
+ С ABC каждую сторонюю модель пришлось бы оборачивать в подкласс. Имя метрики хранится
243
+ в спецификации (`ndcg@10`): у функции нет атрибута `name`, и протокол с ним её бы
244
+ не принял. Новая модель — класс-адаптер и одна строка в `ADAPTERS`; адаптер находится
245
+ по MRO класса модели, поэтому подкласс `LGBMRanker` из чужого кода тоже распознаётся.
246
+
247
+ ## Почему ресемплинг по запросам
248
+
249
+ Единица наблюдения — запрос. Пусть 100 запросов по 10 документов. Ресемплинг строк
250
+ считал бы, что наблюдений 1000, хотя документы одной выдачи зависимы — интервал вышел бы
251
+ примерно в √10 ≈ 3 раза уже правды. Поэтому bootstrap берёт с возвращением **запросы**
252
+ и усредняет поразапросные разницы `M_B(q) − M_A(q)`; перестановочный тест меняет знак
253
+ разницы целиком у запроса. Проверено симуляцией: 95%-й интервал покрывает истинную
254
+ разницу в 95.3% из 1000 повторов. Вывод — в
255
+ [«Статистике сравнения»](https://alexandrbaranov7.github.io/ranklens/math/statistics/).
256
+
257
+ ## Ограничения
258
+
259
+ - **Вход должен быть сгруппирован по запросу** (строки запроса подряд), для `PairedRuns` —
260
+ отсортирован; иначе ошибка, а не тихая потеря запросов. Разметка грузится в память целиком.
261
+ - **Неразмеченный документ считается нерелевантным** (как в trec_eval): прогон, который
262
+ приносит много нового, проигрывает из-за разметки. `compare` показывает judged@k
263
+ и предупреждает о разрыве; PPI++ лечит систематическую ошибку разметки при наличии
264
+ случайной gold-выборки.
265
+ - **Перцентильный bootstrap-интервал** на малых выборках бывает уже нужного; поэтому
266
+ p-value — из перестановочного теста, а меньше 20 запросов — предупреждение.
267
+ - **Off-policy:** PBM с фиксированными propensity не описывает каскадное поведение;
268
+ неверные propensity смещают IPS; документы, которых старая модель не показывала,
269
+ не оцениваются никакими весами. SNIPS здесь — отношение (классический SNIPS
270
+ на PBM-веса не переносится) и оценивает долю, а не клики.
271
+ - **Объяснения:** точки «наполовину A, наполовину B» и перемешанные признаки бывают
272
+ нереалистичными; Шепли точен до 12 признаков, дальше — по выборке порядков;
273
+ текстовые кросс-энкодеры и LLM-реранкеры (без матрицы признаков) не объясняются
274
+ ([#34](https://github.com/AlexandrBaranov7/ranklens/issues/34),
275
+ [#35](https://github.com/AlexandrBaranov7/ranklens/issues/35)).
276
+ - **Модели из pickle** исполняют код при загрузке — только с `--allow-pickle`
277
+ ([SECURITY.md](https://github.com/AlexandrBaranov7/ranklens/blob/main/SECURITY.md)).
278
+
279
+ ## Roadmap
280
+
281
+ - Doubly Robust оценщик и Regression-EM для оценки propensity по логам; каскадные
282
+ click-модели.
283
+ - PPI в сравнении по сегментам и в CLI; стратифицированная gold-выборка.
284
+ - Объяснение текстовых реранкеров ([#34](https://github.com/AlexandrBaranov7/ranklens/issues/34)),
285
+ смещения LLM-реранкеров ([#35](https://github.com/AlexandrBaranov7/ranklens/issues/35)),
286
+ GPU-бэкенд метрик ([#17](https://github.com/AlexandrBaranov7/ranklens/issues/17)).
287
+
288
+ ## Разработка
289
+
290
+ ```bash
291
+ git clone https://github.com/AlexandrBaranov7/ranklens.git
292
+ cd ranklens
293
+ poetry install --all-extras # Poetry >= 2.4
294
+ poetry run pytest
295
+ ```
296
+
297
+ Правила — в [CONTRIBUTING.md](https://github.com/AlexandrBaranov7/ranklens/blob/main/CONTRIBUTING.md), изменения — в [CHANGELOG.md](https://github.com/AlexandrBaranov7/ranklens/blob/main/CHANGELOG.md).
298
+
299
+ ## Лицензия
300
+
301
+ MIT
302
+
@@ -0,0 +1,263 @@
1
+ <h1>
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="docs/images/brand/logo-wordmark-dark.svg">
4
+ <img src="https://alexandrbaranov7.github.io/ranklens/images/brand/logo-wordmark.svg" alt="ranklens" height="64">
5
+ </picture>
6
+ </h1>
7
+
8
+ [![CI](https://github.com/AlexandrBaranov7/ranklens/actions/workflows/ci.yml/badge.svg)](https://github.com/AlexandrBaranov7/ranklens/actions/workflows/ci.yml)
9
+ [![PyPI](https://img.shields.io/pypi/v/ranklens)](https://pypi.org/project/ranklens/)
10
+ [![Документация](https://img.shields.io/badge/docs-ranklens-00958c)](https://alexandrbaranov7.github.io/ranklens/)
11
+
12
+ Модель поменяли — стало ли лучше, **где** и **почему**? ranklens отвечает на это из
13
+ таблиц pandas одной строкой: разница с интервалом, p- и q-value, минимальный заметный
14
+ эффект (MDE), разбивка по сегментам с поправкой на множественные сравнения. Метрики
15
+ совпадают с trec_eval до 1e-9.
16
+
17
+ ```python
18
+ import ranklens as rl
19
+
20
+ rl.compare(current, new, qrels, ["ndcg@10"], by="device")
21
+ ```
22
+
23
+ ## Типичные задачи
24
+
25
+ Примеры — на синтетическом каталоге из `docs/examples/catalog.py`: запросы, товары
26
+ и оценки выдуманы, но устроены как в настоящем поиске. На
27
+ [главной странице документации](https://alexandrbaranov7.github.io/ranklens/) они
28
+ выполняются при каждой сборке.
29
+
30
+ ```text
31
+ query product grade history base_s0 new_s0
32
+ 0 молоко Молоко Луговое 3,2% 1 л 3 нет 0.92 1.08
33
+ 1 молоко Молоко Северная ферма 2,5% 930 мл 2 нет 0.97 1.04
34
+ ```
35
+
36
+ **Эффект виден только в сегменте.** Новая модель знает, покупал ли пользователь товар
37
+ раньше, — такой признак есть только у пользователей с историей покупок (около 9%
38
+ запросов). У каждой модели два сида: они усредняются по запросу.
39
+
40
+ ```python
41
+ rl.compare([base_s0, base_s1], [new_s0, new_s1], qrels, ["ndcg@10"], by="history")
42
+ ```
43
+
44
+ ```text
45
+ n_queries delta ci_low ci_high mde q_value
46
+ metric history
47
+ ndcg@10 есть 29 0.0387 0.0190 0.0616 0.0308 0.0006
48
+ нет 291 -0.0010 -0.0034 0.0015 0.0035 0.4194
49
+ ```
50
+
51
+ В среднем по всем запросам прирост +0.0026 не отличим от нуля и меньше MDE (0.0046) —
52
+ минимального эффекта, который эти запросы способны показать. Разбивка находит, где он.
53
+
54
+ **Две версии выдачи размечены разными партиями асессоров.**
55
+
56
+ ```python
57
+ rl.agreement(batch_cur, batch_new)
58
+ # Agreement(2495 common pairs: exact 88.8%, mean grade A 1.801, B 1.913, shift +0.112)
59
+ qrels = rl.merge_qrels(batch_cur, batch_new) # одна разметка для обеих сторон
60
+ rl.compare(current, new_version, qrels, ["ndcg@5", "ndcg@10", "err@10"])
61
+ ```
62
+
63
+ ```text
64
+ n_queries delta ci_low ci_high mde q_value
65
+ metric
66
+ ndcg@5 320 -0.0019 -0.0039 0.0000 0.0028 0.0801
67
+ ndcg@10 320 0.0629 0.0559 0.0699 0.0099 0.0003
68
+ err@10 320 0.0019 -0.0016 0.0054 0.0051 0.2966
69
+ ```
70
+
71
+ Вторая партия мягче, поэтому сравниваем на общей разметке. NDCG@10 значимо выше,
72
+ а метрики верха выдачи — нет: новая версия улучшает позиции 6–10.
73
+
74
+ **Почему: вклад признаков в метрику.** Модель — CatBoost, LightGBM, XGBoost,
75
+ scikit-learn, PyTorch, ONNX или любая функция скоринга.
76
+
77
+ ```python
78
+ ex = rl.explainer(model, df, query="query", doc="product", relevance="grade")
79
+ ex.importance("ndcg@10") # насколько падает метрика, если перемешать признак
80
+ ex.worst(20) # худшие запросы и признаки, которые их подвели
81
+ ```
82
+
83
+ ```text
84
+ drop of ndcg@10
85
+ feature
86
+ text_match 0.1548
87
+ bought_before 0.0066
88
+ popularity 0.0046
89
+ price_rank 0.0006
90
+ ```
91
+
92
+ ## Установка
93
+
94
+ ```bash
95
+ pip install "ranklens[viz]"
96
+ ```
97
+
98
+ Extras: `viz` (графики и HTML-отчёты), `arrow` (parquet, Feather), `models` (CatBoost,
99
+ LightGBM, XGBoost, scikit-learn), `neural` (ONNX). PyTorch ставится по инструкции
100
+ pytorch.org. Свежий `main` — `pip install "ranklens @ git+https://github.com/AlexandrBaranov7/ranklens"`.
101
+
102
+ ## Данные и командная строка
103
+
104
+ `rl.run` и `rl.qrels` берут таблицу pandas, polars или pyarrow с одной строкой на пару
105
+ «запрос — документ»; порядок — по скору модели (`score=`), по позиции (`rank=`) или
106
+ порядку строк. Несколько выдач на запрос (по магазинам, сессиям) — `list_id=`.
107
+
108
+ ```python
109
+ qrels = rl.qrels(labels, query="qid", doc="sku", relevance="grade")
110
+ current = rl.run(df_a, query="qid", doc="sku", rank="pos", segments="device")
111
+ new = rl.run(df_b, query="qid", doc="sku", rank="pos", segments="device")
112
+ ```
113
+
114
+ Те же сравнения из терминала — для файлов и CI:
115
+
116
+ ```bash
117
+ ranklens eval --run run.trec --qrels qrels.trec --metrics ndcg@10 mrr@10
118
+ ranklens compare --baseline a.csv --candidate b.csv --qrels qrels.csv \
119
+ --metrics ndcg@10 mrr@10 --segments device locale --report compare.html
120
+ ranklens explain --model model.cbm --features features.csv --top-worst 20
121
+ ```
122
+
123
+ ```text
124
+ metric device locale queries mean A mean B delta 95% CI p q
125
+ ndcg@10 desktop ru 60 0.5352 0.5352 +0.0000 [+0.0000, +0.0000] 1.0000 1.0000
126
+ ndcg@10 mobile en 60 0.6490 0.6329 -0.0161 [-0.0302, -0.0038] 0.0655 0.0982
127
+ ndcg@10 mobile ru 60 0.6137 0.6926 +0.0789 [+0.0409, +0.1234] 0.0005 0.0015 *
128
+ ```
129
+
130
+ `--report compare.html` пишет один файл со всеми графиками, который открывается без
131
+ интернета: таблица, покрытие разметкой, разница по сегментам с интервалами (значимые
132
+ после поправки — синим), распределение поразапросных разниц, метрика по cutoff,
133
+ перемещения документов из топ-10.
134
+
135
+ ![Отчёт сравнения: таблица и разница по сегментам](https://alexandrbaranov7.github.io/ranklens/images/compare-report.jpg)
136
+
137
+ Подробнее — [«Первые шаги»](https://alexandrbaranov7.github.io/ranklens/tutorial/).
138
+
139
+ | Что | Где |
140
+ |---|---|
141
+ | Таблицы pandas/polars → сравнение одной строкой: сиды, сегменты, MDE, покрытие разметки | `import ranklens as rl` |
142
+ | NDCG, MAP, MRR (умолчания trec_eval), ERR, RBP, RBO, judged@k; свои метрики — плагином | `ranklens.metrics` |
143
+ | Парный bootstrap по запросам, перестановочный тест, Бенджамини — Хохберг, MDE | `ranklens.stats` |
144
+ | Сравнение по сегментам с q-values | `ranklens.aggregate` |
145
+ | Неточная разметка: согласие партий, общая разметка, PPI++ (дешёвая proxy-разметка + gold-выборка), judged@k | `rl.agreement`, `rl.merge_qrels`, `ranklens.stats` |
146
+ | Off-policy: IPS и SNIPS по логам кликов, PBM, симулятор с известной истиной, ESS | `ranklens.offpolicy` |
147
+ | Объяснение: Шепли для пары документов, вклад признаков в метрику, группы коррелированных признаков, худшие запросы | `rl.explainer`, `ranklens.explain` |
148
+ | Графики и самодостаточные HTML/Markdown-отчёты | `ranklens.viz`, `ranklens.report` |
149
+
150
+ ## Почему не существующие библиотеки
151
+
152
+ - **[pytrec_eval](https://github.com/cvangysel/pytrec_eval)** — обёртка над trec_eval:
153
+ быстрый эталон метрик, десятки мер. ranklens сверяется с ним golden-тестами и
154
+ проигрывает ему в числе мер. Но pytrec_eval отвечает на «сколько», а не на «отличимо
155
+ ли это от шума» — сравнения, интервалов и сегментов в нём нет.
156
+ - **[ir_measures](https://github.com/terrierteam/ir_measures)** — единый интерфейс
157
+ к нескольким реализациям мер; хорош, когда нужна именно мера из конкретного инструмента.
158
+ Статистику сравнения и объяснения он не делает.
159
+ - **[ranx](https://github.com/AmenRa/ranx)** умеет и метрики, и статистическое
160
+ сравнение прогонов, и fusion — ближайший аналог в части сравнения. ranklens добавляет
161
+ то, чего в нём нет: поправку FDR по сегментам, MDE, PPI для неточной разметки,
162
+ off-policy оценку по кликам и объяснение модели по метрике.
163
+
164
+ ranklens не заменяет их в скорости и полноте набора мер — он закрывает путь от
165
+ «цифра изменилась» до «вот где и почему».
166
+
167
+ ## Архитектура
168
+
169
+ Слои с проверяемыми границами: `core` (типы, результаты, протоколы, исключения — только
170
+ stdlib) → `io`, `metrics`, `stats`, `viz` → `aggregate`, `offpolicy`, `explain`, `report`
171
+ → `api` (`import ranklens as rl`) и `cli`. `tests/test_architecture.py` разбирает AST каждого модуля и падает, если слой
172
+ импортирует то, что ему не положено, или тяжёлую библиотеку не лениво. Прогоны читаются
173
+ потоком (генератор, в памяти — одна выдача), разметка — в словарь для поиска за O(1).
174
+ Подробно — [«Устройство»](https://alexandrbaranov7.github.io/ranklens/design/).
175
+
176
+ ## Выбор структур данных и средств языка
177
+
178
+ | Требование | Где | Почему так |
179
+ |---|---|---|
180
+ | `list` | буфер строк текущего запроса в `iter_run`, `iter_clicklog` | дописывание в конец — амортизированное O(1); собранная выдача сразу становится `tuple` |
181
+ | `tuple` | `RankedList.docs`, `SegmentKey`, `Impression.positions`, вклады признаков | порядок — это позиция; неизменяем и хешируем: поле frozen-объекта и ключ словаря. `list` в frozen dataclass менялся бы на месте |
182
+ | `dict` | `Qrels: dict[QueryId, dict[DocId, float]]`, реестр метрик, `ADAPTERS` | поиск оценки пары запрос–документ за O(1) в горячем цикле; реестры вместо цепочек `if` — точки расширения |
183
+ | `set` | дубликаты документов в `RankedList`, общие запросы двух прогонов, повторы в ридерах | проверка дубликатов O(n) вместо O(n²); общие запросы — ровно `a.keys() & b.keys()` |
184
+ | генератор | `iter_run`, `iter_clicklog`, `iter_records` | в памяти — строки одного запроса: на отсортированном входе память постоянна, на сгруппированном растёт на ~200 байт на запрос (id уже прочитанных); тесты меряют пик `tracemalloc` на файле в 10 раз больше |
185
+ | свой итератор | `PairedRuns` (`__iter__`/`__next__`) | merge-join двух отсортированных потоков; `zip` молча спарил бы разные запросы, когда в одном прогоне запрос пропущен |
186
+ | своё исключение | иерархия `RankLensError`: `DataError`, `ConfigError`, `ModelError`, `StatisticalError` | у ошибок поля (`line_no`, `path`, `column`) и подсказки («did you mean 'ndcg'?»); аргументы в `super().__init__` — иначе не переживают pickle (тест) |
187
+ | осмысленная обработка | `ErrorCollector`: битые строки пропускаются и считаются; `--strict` останавливает | два сценария — разведка данных и CI-гейт; коды выхода 0 / 1 конфигурация / 2 аргументы / 3 данные |
188
+ | контекстный менеджер | `AtomicReport` | временный файл в той же директории, `fsync`, `os.replace` только без исключения; при ошибке или `Ctrl-C` остаётся прежний отчёт. `with open()` сначала обнулил бы цель |
189
+ | аннотации + mypy | весь пакет и тесты, mypy `strict`, `py.typed` | ошибки типов ловятся в CI до тестов; пакет отдаёт типы пользователям |
190
+ | pytest | 750+ тестов, покрытие ~99%, порог 90% | точные значения, property-based (Hypothesis), golden против trec_eval, статистическая валидация на симуляциях |
191
+ | `pyproject.toml` | PEP 621, `[project.scripts]`, entry points `ranklens.metrics`, extras | одна точка для метаданных, команды, плагинов и необязательных зависимостей |
192
+ | установка pip | задание CI `install` | собранный wheel ставится **обычным pip** в чистое окружение, и `scripts/tutorial_smoke.py` проходит «Первые шаги» вне репозитория |
193
+ | pandas + агрегация | `offpolicy/events.py`: `merge`, `groupby().max()`, `explode`, `value_counts`; `aggregate` — таблица по сегментам | склейка событий показов и реакций — табличная операция; сегменты отдаются DataFrame |
194
+
195
+ Неизменяемость результатов: `frozen=True, slots=True` + `tuple` + `MappingProxyType` +
196
+ read-only массивы numpy — `frozen` сам по себе запрещает только присваивание полей.
197
+
198
+ ## Почему Protocol, а не ABC
199
+
200
+ Метрика — любой вызываемый объект `(ranked, judgements, k) -> float`, в том числе обычная
201
+ функция; модель для объяснений — любой объект с `feature_names`, `score`,
202
+ `native_importance`. Это `typing.Protocol`: структурная типизация, реализации не
203
+ наследуются от наших классов, и чужую модель или функцию не нужно встраивать в иерархию.
204
+ С ABC каждую сторонюю модель пришлось бы оборачивать в подкласс. Имя метрики хранится
205
+ в спецификации (`ndcg@10`): у функции нет атрибута `name`, и протокол с ним её бы
206
+ не принял. Новая модель — класс-адаптер и одна строка в `ADAPTERS`; адаптер находится
207
+ по MRO класса модели, поэтому подкласс `LGBMRanker` из чужого кода тоже распознаётся.
208
+
209
+ ## Почему ресемплинг по запросам
210
+
211
+ Единица наблюдения — запрос. Пусть 100 запросов по 10 документов. Ресемплинг строк
212
+ считал бы, что наблюдений 1000, хотя документы одной выдачи зависимы — интервал вышел бы
213
+ примерно в √10 ≈ 3 раза уже правды. Поэтому bootstrap берёт с возвращением **запросы**
214
+ и усредняет поразапросные разницы `M_B(q) − M_A(q)`; перестановочный тест меняет знак
215
+ разницы целиком у запроса. Проверено симуляцией: 95%-й интервал покрывает истинную
216
+ разницу в 95.3% из 1000 повторов. Вывод — в
217
+ [«Статистике сравнения»](https://alexandrbaranov7.github.io/ranklens/math/statistics/).
218
+
219
+ ## Ограничения
220
+
221
+ - **Вход должен быть сгруппирован по запросу** (строки запроса подряд), для `PairedRuns` —
222
+ отсортирован; иначе ошибка, а не тихая потеря запросов. Разметка грузится в память целиком.
223
+ - **Неразмеченный документ считается нерелевантным** (как в trec_eval): прогон, который
224
+ приносит много нового, проигрывает из-за разметки. `compare` показывает judged@k
225
+ и предупреждает о разрыве; PPI++ лечит систематическую ошибку разметки при наличии
226
+ случайной gold-выборки.
227
+ - **Перцентильный bootstrap-интервал** на малых выборках бывает уже нужного; поэтому
228
+ p-value — из перестановочного теста, а меньше 20 запросов — предупреждение.
229
+ - **Off-policy:** PBM с фиксированными propensity не описывает каскадное поведение;
230
+ неверные propensity смещают IPS; документы, которых старая модель не показывала,
231
+ не оцениваются никакими весами. SNIPS здесь — отношение (классический SNIPS
232
+ на PBM-веса не переносится) и оценивает долю, а не клики.
233
+ - **Объяснения:** точки «наполовину A, наполовину B» и перемешанные признаки бывают
234
+ нереалистичными; Шепли точен до 12 признаков, дальше — по выборке порядков;
235
+ текстовые кросс-энкодеры и LLM-реранкеры (без матрицы признаков) не объясняются
236
+ ([#34](https://github.com/AlexandrBaranov7/ranklens/issues/34),
237
+ [#35](https://github.com/AlexandrBaranov7/ranklens/issues/35)).
238
+ - **Модели из pickle** исполняют код при загрузке — только с `--allow-pickle`
239
+ ([SECURITY.md](https://github.com/AlexandrBaranov7/ranklens/blob/main/SECURITY.md)).
240
+
241
+ ## Roadmap
242
+
243
+ - Doubly Robust оценщик и Regression-EM для оценки propensity по логам; каскадные
244
+ click-модели.
245
+ - PPI в сравнении по сегментам и в CLI; стратифицированная gold-выборка.
246
+ - Объяснение текстовых реранкеров ([#34](https://github.com/AlexandrBaranov7/ranklens/issues/34)),
247
+ смещения LLM-реранкеров ([#35](https://github.com/AlexandrBaranov7/ranklens/issues/35)),
248
+ GPU-бэкенд метрик ([#17](https://github.com/AlexandrBaranov7/ranklens/issues/17)).
249
+
250
+ ## Разработка
251
+
252
+ ```bash
253
+ git clone https://github.com/AlexandrBaranov7/ranklens.git
254
+ cd ranklens
255
+ poetry install --all-extras # Poetry >= 2.4
256
+ poetry run pytest
257
+ ```
258
+
259
+ Правила — в [CONTRIBUTING.md](https://github.com/AlexandrBaranov7/ranklens/blob/main/CONTRIBUTING.md), изменения — в [CHANGELOG.md](https://github.com/AlexandrBaranov7/ranklens/blob/main/CHANGELOG.md).
260
+
261
+ ## Лицензия
262
+
263
+ MIT