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

Объяснение модели

Почему модель поставила документ B выше документа A? ranklens.explain.pairwise раскладывает разницу скоров \(s(x^B) - s(x^A)\) по признакам.

Путь от A к B

Пойдём от признаков A к признакам B, заменяя по одному: каждому признаку припишем изменение скора, которое вызвала его замена. Если признаки не взаимодействуют (скор — сумма функций от отдельных признаков), ответ не зависит от порядка. Если взаимодействуют — зависит. Пример: \(s = x_1 x_2\), \(A = (0, 0)\), \(B = (1, 1)\): меняем сначала \(x_1\) — скор не изменился, весь прирост достаётся \(x_2\); меняем сначала \(x_2\) — наоборот.

Значение Шепли

Честный ответ — среднее по всем порядкам. Для признака \(i\) из \(k\) и набора \(S\) признаков, уже взятых у B (\(v(S)\) — скор точки, где признаки из \(S\) взяты у B, остальные у A):

\[ \varphi_i = \sum_{S \subseteq \{1..k\} \setminus \{i\}} \frac{|S|!\,(k - |S| - 1)!}{k!}\,\bigl(v(S \cup \{i\}) - v(S)\bigr). \]

Свойства, которые проверяются тестами:

  • сумма вкладов равна разнице скоров: \(\sum_i \varphi_i = v(\text{все}) - v(\varnothing) = s(x^B) - s(x^A)\);
  • признак, который у A и B одинаков, получает 0;
  • для линейной модели \(\varphi_i = w_i\,(x^B_i - x^A_i)\);
  • для \(x_1 x_2\) признак получает \(\tfrac12 (x^B_1 - x^A_1)(x^A_2 + x^B_2)\) — среднее двух порядков.

Точно или по выборке

При \(k \le 12\) перебираются все \(2^k\) наборов: 4096 точек — один вызов модели. Дальше \(2^k\) растёт слишком быстро, и усредняются случайные порядки (n_permutations). Каждый порядок — путь от A к B, поэтому и по выборке сумма вкладов равна разнице скоров точно; от размера выборки зависит только то, как поделена эта разница.

import numpy as np

from ranklens.explain import CallableAdapter, pairwise

# скор: релевантность текста, умноженная на свежесть, плюс небольшой бонус за цену
model = CallableAdapter(lambda x: x[:, 0] * x[:, 1] + 0.1 * x[:, 2], ["bm25", "freshness", "price"])
a = [2.0, 0.2, 1.0]  # хороший текст, старый документ
b = [1.5, 0.9, 1.0]  # текст похуже, но свежий
result = pairwise(model, a, b)
print(f"s(A) = {result.score_a:.3f}, s(B) = {result.score_b:.3f}, разница {result.delta:+.3f}")
for name, contribution in result.ranked():
    print(f"  {name:10} {contribution:+.3f}")
s(A) = 0.500, s(B) = 1.450, разница +0.950
  freshness  +1.225
  bm25       -0.275
  price      +0.000

B выше за счёт свежести; часть её вклада съедает более слабый текст. Цена у A и B одинакова и не получает ничего.

Какие признаки двигают метрику

Попарное объяснение говорит о скоре. Но признак может сильно двигать скоры и не менять порядок — тогда для ранжирования он не важен. listwise меряет вклад в метрику:

  1. перемешать значения признака \(f\) среди документов запроса;
  2. пересчитать скоры, заново отсортировать;
  3. вклад \(= M(\text{исходный порядок}) - M(\text{новый})\), среднее по n_repeats перемешиваниям и по запросам.

Перемешивание внутри запроса сохраняет распределение признака в запросе и рвёт только его связь с документами. Признак, одинаковый у всех документов запроса, получает ровно 0, даже с огромным весом в модели: он сдвигает все скоры запроса одинаково. Вклад по запросам лежит в MetricResult, к нему применимы compare и bootstrap.

Группы коррелированных признаков

Два варианта BM25 несут один сигнал. Перемешаем один — второй его заменит, и оба выглядят неважными. Пример из тестов: признаки-двойники по отдельности дают падение NDCG@10 на 0.097 и 0.091, вместе — на 0.254, больше суммы.

correlated_groups связывает признаки с \(|\rho| \ge\) порога и возвращает компоненты связности — это single-linkage кластеризация, обрезанная на пороге, в двадцать строк на union-find, без scipy. Single linkage «сцепляет»: \(a \sim b\) и \(b \sim c\) дают одну группу, даже если \(a\) и \(c\) слабо связаны. Для «перемешивать вместе» это нормально; группа — не клика.

import numpy as np

from ranklens.explain import CallableAdapter, QueryFeatures, correlated_groups, listwise

rng = np.random.default_rng(0)
names = ["bm25", "bm25_title", "freshness"]
queries = []
for q in range(30):
    x = rng.normal(size=(10, 3))
    x[:, 1] = x[:, 0] + rng.normal(0, 0.1, 10)  # почти тот же сигнал
    grades = np.digitize(x[:, 0], [-0.5, 0.5, 1.2])
    docs = [f"d{i}" for i in range(10)]
    queries.append(QueryFeatures.of(f"q{q}", docs, x, dict(zip(docs, grades))))

model = CallableAdapter(lambda x: x[:, 0] + x[:, 1] + 0.3 * x[:, 2], names)
groups = correlated_groups(queries, names)
print("группы:", groups)
for label, drop in listwise(model, queries, "ndcg@10", groups=groups).ranked():
    print(f"  {label:18} падение NDCG@10 {drop:.3f}")
группы: (('bm25', 'bm25_title'),)
  bm25+bm25_title    падение NDCG@10 0.238
  freshness          падение NDCG@10 0.000

Ограничения

  • Точки «наполовину A, наполовину B» могут быть нереалистичными (свежий документ с признаками старого): модель оценивается там, где не обучалась. Это общий недостаток интервенционного Шепли; на коррелированных признаках вклады размазываются — для них есть группировка (выше).
  • Попарное объяснение — про скор, а не про позицию; про метрику — listwise.
  • Перемешивание создаёт сочетания признаков, которых в данных не бывает (та же проблема, что у точек «наполовину A, наполовину B»); группировка её смягчает, но не убирает.