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

Первые шаги

За десять минут: от файлов с выдачей и разметкой до честного сравнения двух моделей.

Коротко: данные в pandas

Если выдачи и оценки уже в таблице (как обычно в ноутбуке), хватает import ranklens as rl:

import numpy as np
import pandas as pd

import ranklens as rl

# таблица «запрос, документ, оценка» и скоры двух моделей; у каждой два сида обучения
rng = np.random.default_rng(0)
rows = []
for q in range(200):
    device = "mobile" if q % 2 else "desktop"
    for d in range(10):
        grade = int(rng.integers(0, 4))
        better = 1.0 if device == "mobile" else 1.6  # кандидат лучше на mobile
        rows.append(
            (
                q,
                d,
                grade,
                device,
                *(grade + rng.normal(0, 1.6, 2)),
                *(grade + rng.normal(0, better, 2)),
            )
        )
df = pd.DataFrame(
    rows, columns=["qid", "sku", "grade", "device", "base_s0", "base_s1", "cand_s0", "cand_s1"]
)

qrels = rl.qrels(df, query="qid", doc="sku", relevance="grade")
runs = {
    m: rl.run(df, query="qid", doc="sku", score=m, segments="device")
    for m in ["base_s0", "base_s1", "cand_s0", "cand_s1"]
}

print(rl.evaluate(runs["base_s0"], qrels, ["ndcg@10", "mrr@10"]).table, "\n")

# сиды усредняются по запросу; by= — сравнение внутри сегментов с поправкой BH
result = rl.compare(
    [runs["base_s0"], runs["base_s1"]],
    [runs["cand_s0"], runs["cand_s1"]],
    qrels,
    ["ndcg@10"],
    by="device",
)
print(result.table[["mean_a", "mean_b", "delta", "ci_low", "ci_high", "mde", "q_value"]].round(4))
print(result.coverage)
             mean  queries
metric                    
ndcg@10  0.912094      200
mrr@10   0.985000      200 

                 mean_a  mean_b   delta  ci_low  ci_high     mde  q_value
metric  device                                                           
ndcg@10 desktop  0.9124  0.9223  0.0099 -0.0029   0.0224  0.0179   0.1266
        mobile   0.9171  0.9527  0.0356  0.0252   0.0462  0.0150   0.0002
{'judged@10 A': 1.0, 'judged@10 B': 1.0}

В ноутбуке результаты показываются таблицами. Ещё в этом слое: list_id= в rl.run — несколько выдач на запрос (по магазинам, сессиям), метрика усредняется внутри запроса; rl.agreement и rl.merge_qrels — когда системы размечали разные партии асессоров; rl.explainer(model, table, ...) — importance(), worst(), pair(). Ниже — то же по шагам, на файлах и нижнем уровне библиотеки.

Что нужно

Два файла. Прогон — что модель выдала по каждому запросу, разметка — насколько документы релевантны. Формат TREC понятен без схемы:

run.trec
q1 Q0 d3 1 3.0 baseline
q1 Q0 d1 2 2.0 baseline
q2 Q0 d4 1 1.0 baseline
qrels.trec
q1 0 d1 2
q1 0 d3 1
q2 0 d4 1

Столбцы прогона: запрос, служебное поле, документ, ранг, score, метка прогона. Разметка: запрос, служебное поле, документ, релевантность.

csv, jsonl, parquet и DataFrame

Формат определяется по расширению. Для csv и jsonl имена колонок задаются схемой RunSchema, а parquet, Feather и таблицы pandas и polars читаются напрямую: iter_run(df).

Шаг 1. Посчитать метрики

from ranklens.io import iter_run, read_qrels
from ranklens.metrics import evaluate

qrels = read_qrels("docs/examples/qrels.trec")
result = evaluate(iter_run("docs/examples/run.trec"), qrels, ["ndcg@10", "mrr@10"])

print(f"NDCG@10 = {result['ndcg@10'].mean:.4f}")
print(f"запросов оценено: {result.n_queries}")
print(f"без релевантных документов: {result.n_without_relevant}")
print(f"нет в разметке (пропущены): {result.n_unjudged}")
NDCG@10 = 0.6199
запросов оценено: 3
без релевантных документов: 1
нет в разметке (пропущены): 1

Прогон читается потоково: в памяти держится одна выдача, а не весь файл. Значения по запросам лежат в result["ndcg@10"].per_query — они понадобятся дальше.

То же самое из терминала:

ranklens eval --run run.trec --qrels qrels.trec --metrics ndcg@10 mrr@10

Документы запроса ранжируются по колонке score (по убыванию), если она есть; иначе — по rank, иначе — в порядке строк файла (--order меняет это явно). TREC — всегда по score, как в trec_eval.

Коды возврата различают ситуации: 0 — успех, 1 — ошибка конфигурации (нет файла, неизвестная метрика), 2 — неверные аргументы, 3 — битые данные. Так ranklens eval можно ставить в CI как гейт качества.

Шаг 2. Сравнить две модели

Вопрос «стало лучше?» — это вопрос о том, отличим ли прирост от шума выборки запросов.

import numpy as np

from ranklens.core import MetricResult, QueryId
from ranklens.stats import compare

rng = np.random.default_rng(0)
queries = [QueryId(f"q{i}") for i in range(300)]
baseline = MetricResult(
    "ndcg@10", {q: float(v) for q, v in zip(queries, rng.normal(0.40, 0.10, 300))}
)
candidate = MetricResult(
    "ndcg@10", {q: float(v) for q, v in zip(queries, rng.normal(0.42, 0.10, 300))}
)

verdict = compare(baseline, candidate, seed=1)
print(f"было {verdict.mean_a:.4f} -> стало {verdict.mean_b:.4f}")
print(f"прирост {verdict.delta:+.4f}, интервал [{verdict.ci_low:+.4f}, {verdict.ci_high:+.4f}]")
print(f"p-value {verdict.p_value:.4f}, значимо: {verdict.significant}")
было 0.3964 -> стало 0.4190
прирост +0.0226, интервал [+0.0070, +0.0385]
p-value 0.0053, значимо: True

Как это читать:

  • прирост — среднее по запросам разниц «кандидат минус базовая модель»;
  • интервал — bootstrap по запросам: где правдоподобно лежит прирост, если бы запросы были другой выборкой из той же совокупности;
  • p-value — перестановочный тест: вероятность увидеть такую же разницу или ещё более экстремальную, если на самом деле разницы нет.

Чего это не значит

Значимость не равна пользе: прирост в 0.001 может быть значимым на 100 000 запросов и не значить ничего на практике. И наоборот, на 30 запросах отсутствие значимости не означает, что улучшения нет — просто выборка мала, о чём библиотека предупредит.

Из терминала — два прогона и одна разметка:

ranklens compare --baseline a.csv --candidate b.csv --qrels qrels.csv \
    --metrics ndcg@10 mrr@10 --segments device locale

С --segments сравнение идёт отдельно в каждом сегменте, и среднее по всем запросам больше не прячет, где модель выиграла, а где проиграла:

metric   device   locale  queries  mean A  mean B  delta    95% CI              p       q
ndcg@10  desktop  ru      60       0.5352  0.5352  +0.0000  [+0.0000, +0.0000]  1.0000  1.0000
ndcg@10  mobile   en      60       0.6490  0.6329  -0.0161  [-0.0302, -0.0038]  0.0655  0.0982
ndcg@10  mobile   ru      60       0.6137  0.6926  +0.0789  [+0.0409, +0.1234]  0.0005  0.0015  *
ndcg@10  tablet   ru      1        -       -       -        too few queries     -       -
  • q — p-value с поправкой Бенджамини–Хохберга: при сорока сегментах пара «значимых» появляется случайно, q-value это учитывает. Звёздочка — q < alpha.
  • Сегмент меньше 20 запросов показан, но не сравнивается и в поправку не входит.
  • Интервал считается по сегменту без поправки. В строке mobile/en он не содержит ноль, а q = 0.098: решает q-value. Без --segments поправка идёт по метрикам.
  • Колонки сегментов берутся из прогона, поэтому нужен csv, jsonl или parquet — в TREC таких колонок нет. --format json отдаёт то же для скриптов.
  • Последняя строка — judged@K, доля документов в top-K, у которых есть оценка. Неразмеченный документ считается нерелевантным, поэтому прогон, который приносит много нового, проигрывает из-за разметки, а не из-за качества. Если покрытие у прогонов расходится на 10 п.п. и больше, compare предупреждает.

Шаг 3. Выбрать метрику под задачу

from ranklens.metrics import registry

print(", ".join(registry.names()))
err, judged, map, mrr, ndcg, rbp

Параметры задаются прямо в спецификации:

  • ndcg@10 — как в trec_eval (линейный gain);
  • ndcg(gain=exp)@10 — как в LightGBM и CatBoost;
  • map(rel=2)@100 — релевантными считаются оценки от 2;
  • rbp(p=0.95) — «терпеливый» пользователь;
  • err(max_rel=3)@20 — шкала релевантности 0…3.

Шаг 4. Понять, где модель ошибается

Метрика упала — где и почему? Нужны модель и таблица признаков: query_id, doc_id, relevance и по колонке на признак (как в датасетах learning-to-rank).

ranklens explain --model model.cbm --features features.csv --top-worst 8
ndcg@10 over 120 queries: mean 0.9857
groups shuffled together: bm25+bm25_title

feature                        drop of ndcg@10 when shuffled
bm25+bm25_title                +0.3072
freshness                      +0.0558
price                          +0.0144

worst 8 queries (negative: the feature misleads the model):
q000             0.8388  freshness +0.272, bm25+bm25_title +0.162, price +0.099
q112             0.8953  bm25+bm25_title +0.268, freshness +0.187, price +0.055
...
  • Вклад — в метрику, а не в скор: признак перемешивается среди документов запроса, документы сортируются заново, и меряется, насколько упал NDCG.
  • Коррелированные признаки перемешиваются вместе (два варианта BM25 выше): по одному каждый выглядел бы неважным, потому что второй подменяет его.
  • Худшие запросы с их причинами. Здесь все восемь — «новостные», где по построению решает свежесть: модель, обученная на всех запросах сразу, недооценивает её именно там. Отрицательный вклад значит, что перемешивание признака улучшает выдачу — модель доверяет признаку, который на этом запросе её обманывает.

Модели: CatBoost (.cbm), LightGBM (.txt), XGBoost (.ubj, .json), ONNX (.onnx, в том числе экспорт из PyTorch, TensorFlow, Keras) и scikit-learn (.pkl — только с --allow-pickle, см. SECURITY.md). Из Python то же: ranklens.explain.listwise, diagnose, а для одной пары документов — pairwise.

Шаг 5. Отчёт

У compare и explain есть --report: HTML — один файл со всеми графиками, который открывается без интернета (plotly.js встроен один раз), Markdown — те же таблицы без графиков, для пулл-реквеста или вики.

ranklens compare --baseline a.csv --candidate b.csv --qrels qrels.csv \
    --metrics ndcg@5 ndcg@10 mrr@10 --segments device locale --report compare.html

В отчёте сравнения: таблица, покрытие разметкой, разница по сегментам с интервалами (значимые после BH — цветом), распределение поразапросных разниц (хвосты важнее среднего), метрика по cutoff и куда переместились документы из топ-10. Файл пишется атомарно: при ошибке остаётся прежний отчёт, а не половина нового.

Что дальше