Skip to content

Commit 36782aa

Browse files
committed
Refactor OPE API for reusable schemas and simplify tutorial
1 parent fbf0727 commit 36782aa

7 files changed

Lines changed: 769 additions & 52408 deletions

File tree

README.md

Lines changed: 81 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,103 +1,40 @@
1-
# Policyscope: офлайн-оценка рекомендательных систем
1+
# Policyscope: офлайноценка политик рекомендаций (переиспользуемый пайплайн)
22

3-
**Policyscope** помогает сравнивать рекомендательные модели без запуска дорогостоящих A/B‑тестов.
4-
Библиотека переиспользует логи текущей политики и оценивает, насколько другая политика могла бы увеличить целевую метрику.
5-
Все оценщики ведут подробное логирование на русском языке.
3+
`Policyscope` помогает оценивать новую политику **B** по логам текущей политики **A** без онлайн A/B‑теста.
64

7-
## Как это работает
8-
9-
1. **Собираем логи** текущей политики A: какое действие показали, с какой вероятностью и как реагировал пользователь.
10-
2. **Определяем новую политику B** — например, другую модель рекомендаций.
11-
3. **Пере‑взвешиваем** наблюдения из логов A и получаем приближённое значение метрики под политикой B.
12-
13-
## Реализованные алгоритмы
14-
15-
- **Replay** — учитывает только те логи, где B совпадает с A.
16-
- **IPS** — взвешивает отклики по отношению вероятностей выбора в B и A.
17-
- **SNIPS** — нормализует веса IPS для меньшей дисперсии.
18-
- **DM (Direct Method)** — строит модель отклика и прогнозирует исходы под политикой B.
19-
- **Doubly Robust (DR)** — комбинирует Direct Method и IPS; достаточно корректности хотя бы одной из них.
20-
- **SN-DR** — нормализует поправку IPS на общий вес, улучшая устойчивость.
21-
- **Switch-DR** — вариант DR, использующий DM для наблюдений с очень большим весом IPS, чтобы уменьшить разброс.
22-
23-
## Предположения и ограничения
24-
25-
- **Replay** — новая политика должна часто совпадать со старой, иначе большинство логов отбрасывается.
26-
- **IPS** — требует точного знания вероятностей действий в обеих политиках; большие веса увеличивают дисперсию.
27-
- **SNIPS** — нормализует веса IPS и снижает дисперсию, но остаётся чувствительным к ошибкам вероятностей и малым объёмам данных.
28-
- **DM (Direct Method)** — зависит от точности модели отклика и может смещаться вне обучающей области.
29-
- **Doubly Robust (DR)** — корректность достигается, если верна хотя бы модель отклика или пропенсити, но метод чувствителен к ошибкам обеих моделей и выбору клиппинга.
30-
- **SN-DR** — нормализует поправку IPS на общий вес, уменьшает дисперсию DR, но наследует его предположения.
31-
- **Switch-DR** — отбрасывает экстремальные веса, сочетая DM и DR, но выбор порога влияет на смещение.
32-
33-
## Jupyter-туториал
34-
35-
Интерактивный ноутбук с теорией и примером расчёта ATE доступен в файле [examples/tutorial.ipynb](examples/tutorial.ipynb).
36-
В нём разница между политиками вычисляется как `V_DR(B) - V_DR(A)`,
37-
а истинный эффект оценивается по 100 MC-сэмплам симулятора.
5+
Главное в текущей версии:
6+
- API стал **универсальным**: названия колонок (`a_A`, `a_B`, целевая метрика, `user_id`) и список признаков задаются аргументами.
7+
- Туториал стал короче и практичнее: есть компактный сценарий «взял свой DataFrame → получил все OPE‑оценки».
8+
- Бутстрэп для DR можно вызывать одной функцией (`dr_with_bootstrap_ci`) без ручной сборки циклов.
389

3910
## Установка
4011

4112
```bash
42-
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
13+
python -m venv .venv && source .venv/bin/activate
4314
pip install -r requirements.txt
44-
```
45-
46-
или как пакет:
47-
48-
```bash
15+
# или
4916
pip install -e .
5017
```
5118

52-
## Пример: синтетический эксперимент
53-
54-
В репозитории есть скрипт, который генерирует пользователей и сравнивает две политики.
55-
56-
```bash
57-
python examples/run_synthetic_experiment.py \
58-
--n_users 50000 \
59-
--seed 42 \
60-
--policyA epsilon_greedy --epsilon 0.15 \
61-
--policyB softmax --tau 0.7 \
62-
--horizon 90 \
63-
--weight_clip 20
64-
```
65-
66-
После запуска создаётся папка `artifacts` с логами, оценками и коротким текстовым отчётом.
67-
68-
При работе на синтетике полезно сравнивать офлайн‑оценки с истинным эффектом (oracle). Такое сравнение позволяет проверить состоятельность методов OPE и убедиться, что оценщики не дают систематического смещения.
69-
70-
## Требования к входным данным
71-
72-
Логи политики A должны содержать обязательные поля:
19+
## Что реализовано
7320

74-
- `user_id` — идентификатор пользователя;
75-
- `a_A` — действие, которое показала политика A;
76-
- `propensity_A` — вероятность выбора этого действия политикой A;
77-
- `accept` и/или `cltv` — отклик и ценность;
78-
- признаки пользователя (возраст, доход и др.), используемые моделью.
21+
- Replay
22+
- IPS / SNIPS
23+
- DM (Direct Method)
24+
- DR / SNDR / Switch-DR
25+
- Кластерный и обычный бутстрэп (если `cluster_col=None`)
7926

80-
Числовые поля `age`, `risk` и `income` можно передавать в исходном масштабе:
81-
функции обучения (`train_pi_hat`, `train_mu_hat`) автоматически выполняют их
82-
нормализацию.
27+
## Минимальный формат данных
8328

84-
## Анализ входных данных
29+
Нужны:
30+
- колонка действия, зафиксированного в логах A (по умолчанию `a_A`),
31+
- целевая метрика (например, `accept`, `cltv` или ваша `reward`),
32+
- признаки (`feature_cols`),
33+
- опционально `user_id` для кластерного бутстрэпа.
8534

86-
Чтобы быстро проверить логи и понять, какие методы off-policy оценки доступны,
87-
воспользуйтесь утилитой:
35+
Дополнительно можно хранить `a_B` (действие рекомендованное B) для диагностики и таблиц в туториале.
8836

89-
```python
90-
from policyscope.report import analyze_logs
91-
print(analyze_logs(df, policyB))
92-
```
93-
94-
Функция сообщит о наличии ключевых колонок, пересечении политик для Replay и
95-
подскажет, что требуется для IPS/SNIPS, DM и DR.
96-
97-
## Пример применения на своих данных
98-
99-
Функции обучения выполняют внутреннюю нормализацию числовых признаков,
100-
поэтому в DataFrame достаточно сырых столбцов `age`, `risk` и `income`.
37+
## Универсальный пример на своих данных
10138

10239
```python
10340
import numpy as np
@@ -107,63 +44,81 @@ from policyscope.estimators import (
10744
pi_hat_predict,
10845
train_mu_hat,
10946
prepare_piB_taken,
110-
replay_value,
47+
take_action_probabilities,
11148
ips_value,
11249
snips_value,
11350
dm_value,
11451
dr_value,
115-
sndr_value,
116-
switch_dr_value,
52+
dr_with_bootstrap_ci,
11753
)
118-
from policyscope.policies import make_policy
11954

120-
df = pd.read_csv("logs_without_propensity.csv")
121-
policyB = make_policy("softmax", tau=0.7)
122-
piB_taken = prepare_piB_taken(df, policyB)
123-
pi_model = train_pi_hat(df)
124-
pA_all = pi_hat_predict(pi_model, df)
125-
pA_taken = pA_all[np.arange(len(df)), df["a_A"].values]
126-
mu_hat = train_mu_hat(df, target="accept")
127-
V_replay = replay_value(df, policyB, target="accept")
128-
V_ips, ess_ips, clip_ips = ips_value(df, piB_taken, pA_taken, target="accept")
129-
V_snips, ess_snips, clip_snips = snips_value(df, piB_taken, pA_taken, target="accept")
130-
V_dm = dm_value(df, policyB, mu_hat, target="accept")
131-
V_dr, ess_dr, clip_dr = dr_value(df, policyB, mu_hat, pA_taken, target="accept")
132-
V_sndr, ess_sndr, clip_sndr = sndr_value(df, policyB, mu_hat, pA_taken, target="accept")
133-
V_switch, ess_switch, share_switch = switch_dr_value(df, policyB, mu_hat, pA_taken, tau=20, target="accept")
134-
print(V_replay, V_ips, V_snips, V_dm, V_dr, V_sndr, V_switch)
135-
```
55+
# ваш датасет
56+
# df columns example:
57+
# user_col, logged_action, candidate_action, reward, f1, f2, f3
13658

137-
## Валидация оценок
59+
df = pd.read_csv("my_logs.csv")
13860

139-
- **ESS** — проверяйте эффективный размер выборки, чтобы убедиться в достаточном покрытии новой политики.
140-
- **Клиппинг** — ограничивайте большие веса IPS, чтобы уменьшить дисперсию и влияние выбросов.
141-
- **Бутстрэп** — оценивайте доверительные интервалы путём повторной выборки логов.
61+
feature_cols = ["f1", "f2", "f3"]
62+
action_col = "logged_action"
63+
target_col = "reward"
14264

143-
## Логирование
65+
policyB = ... # объект с методом action_probs(df) -> (n, k)
14466

145-
Функции‑оценщики выводят подробные сообщения на русском языке. Для каждого
146-
алгоритма логируется начало работы, проверки корректности пропенсити,
147-
значение ESS с предупреждением при низком покрытии, доля клиппинга
148-
и итоговое значение метрики. По умолчанию логирование настроено (формат
149-
`%(message)s`), поэтому дополнительные настройки не требуются.
67+
# 1) Вероятность того, что B выбрала бы логированное действие
68+
piB_taken = prepare_piB_taken(df, policyB, action_col=action_col)
15069

151-
## Разработка
70+
# 2) Оценка модели поведения A: pA(a|x)
71+
pi_model = train_pi_hat(df, feature_cols=feature_cols, action_col=action_col)
72+
pA_all = pi_hat_predict(pi_model, df)
73+
pA_taken = take_action_probabilities(
74+
pA_all,
75+
df[action_col].values,
76+
action_space=pi_model.classes_,
77+
)
15278

153-
Перед коммитом выполните проверки стиля и тесты:
79+
# 3) Модель исхода mu(x, a)
80+
mu = train_mu_hat(df, target=target_col, feature_cols=feature_cols, action_col=action_col)
81+
82+
# 4) OPE-оценки
83+
v_ips, ess_ips, clip_ips = ips_value(df, piB_taken, pA_taken, target=target_col, action_col=action_col)
84+
v_snips, ess_snips, clip_snips = snips_value(df, piB_taken, pA_taken, target=target_col, action_col=action_col)
85+
v_dm = dm_value(df, policyB, mu, target=target_col)
86+
v_dr, ess_dr, clip_dr = dr_value(df, policyB, mu, pA_taken, target=target_col, action_col=action_col)
87+
88+
# 5) DR + bootstrap CI одной функцией
89+
dr_ci = dr_with_bootstrap_ci(
90+
df,
91+
policyB,
92+
target=target_col,
93+
feature_cols=feature_cols,
94+
action_col=action_col,
95+
cluster_col="user_col", # либо None
96+
n_boot=300,
97+
)
98+
print(v_ips, v_snips, v_dm, v_dr, dr_ci)
99+
```
100+
101+
## Быстрый синтетический запуск
154102

155103
```bash
156-
python -m flake8 src tests
157-
pytest
104+
python examples/run_synthetic_experiment.py --n_users 50000 --seed 42 --policyA epsilon_greedy --policyB softmax
158105
```
159106

160-
CI также запускает синтетический эксперимент `examples/run_synthetic_experiment.py`, чтобы убедиться в корректной работе библиотеки.
107+
Скрипт сохраняет артефакты в `artifacts/`.
108+
109+
## Туториал
161110

162-
## Ссылки
111+
- Основной notebook: `examples/tutorial.ipynb`
112+
- В нём показано:
113+
1. проверка логов,
114+
2. таблица с фичами + `a_A` + `a_B`,
115+
3. компактный расчёт всех метрик,
116+
4. bootstrap через `dr_with_bootstrap_ci`.
163117

164-
- Joachims et al., *Unbiased Learning-to-Rank with Biased Feedback* (WSDM 2017)
165-
- Dudík et al., *Doubly Robust Policy Evaluation and Learning* (ICML 2011)
166-
- Farajtabar et al., *More Robust Doubly Robust Off-policy Evaluation* (arXiv:2205.13421)
167-
- [Counterfactual Evaluation for Recommendation Systems](https://eugeneyan.com/writing/offline-recsys/)
118+
## Проверки перед коммитом
168119

169-
Policyscope распространяется по лицензии MIT.
120+
```bash
121+
python -m flake8 src tests
122+
pytest
123+
jupyter nbconvert --to notebook --execute examples/tutorial.ipynb --inplace
124+
```

examples/run_synthetic_experiment.py

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,6 @@
2323

2424
import argparse
2525
import os
26-
import json
27-
import numpy as np
28-
import pandas as pd
29-
from typing import Tuple
3026

3127
from policyscope.synthetic import SynthConfig, SyntheticRecommenderEnv
3228
from policyscope.policies import make_policy
@@ -41,9 +37,9 @@
4137
train_mu_hat,
4238
train_pi_hat,
4339
pi_hat_predict,
44-
ate_from_values,
40+
take_action_probabilities,
41+
dr_with_bootstrap_ci,
4542
)
46-
from policyscope.bootstrap import paired_bootstrap_ci
4743
from policyscope.report import decision_summary, dump_json
4844

4945

@@ -77,6 +73,7 @@ def main() -> None:
7773

7874
# Генерируем логи A
7975
logsA = env.simulate_logs_A(policyA, X)
76+
logsA["a_B"] = policyB.action_argmax(X)
8077

8178
# On‑policy значения для A
8279
vA_accept = value_on_policy(logsA, target="accept")
@@ -90,7 +87,7 @@ def main() -> None:
9087
mu_cltv = train_mu_hat(logsA, target="cltv")
9188
pi_model = train_pi_hat(logsA)
9289
pA_all = pi_hat_predict(pi_model, logsA)
93-
pA_taken = pA_all[np.arange(len(logsA)), logsA["a_A"].values]
90+
pA_taken = take_action_probabilities(pA_all, logsA["a_A"].values, action_space=pi_model.classes_)
9491

9592
# Replay (на совпадающих действиях)
9693
vB_replay_accept = replay_value(logsA, policyB.action_argmax(X), target="accept")
@@ -133,21 +130,27 @@ def main() -> None:
133130
dr_abs_error_accept = abs(vB_dr_accept - vB_accept_true)
134131
dr_abs_error_cltv = abs(vB_dr_cltv - vB_cltv_true)
135132

136-
# Paired bootstrap for DR
137-
def estimator_pair_accept(df_part: pd.DataFrame) -> Tuple[float, float, float]:
138-
mu_acc = train_mu_hat(df_part, target="accept")
139-
vA = value_on_policy(df_part, target="accept")
140-
vB, _, _ = dr_value(df_part, policyB, mu_acc, target="accept", weight_clip=args.weight_clip)
141-
return vA, vB, ate_from_values(vB, vA)
142-
143-
def estimator_pair_cltv(df_part: pd.DataFrame) -> Tuple[float, float, float]:
144-
mu = train_mu_hat(df_part, target="cltv")
145-
vA = value_on_policy(df_part, target="cltv")
146-
vB, _, _ = dr_value(df_part, policyB, mu, target="cltv", weight_clip=args.weight_clip)
147-
return vA, vB, ate_from_values(vB, vA)
148-
149-
res_accept = paired_bootstrap_ci(logsA, estimator_pair_accept, cluster_col="user_id", n_boot=300, alpha=0.05)
150-
res_cltv = paired_bootstrap_ci(logsA, estimator_pair_cltv, cluster_col="user_id", n_boot=300, alpha=0.05)
133+
# Paired bootstrap for DR (внутренняя обёртка)
134+
res_accept = dr_with_bootstrap_ci(
135+
logsA,
136+
policyB,
137+
target="accept",
138+
feature_cols=["loyal", "age", "risk", "income"],
139+
action_col="a_A",
140+
n_boot=300,
141+
alpha=0.05,
142+
weight_clip=args.weight_clip,
143+
)
144+
res_cltv = dr_with_bootstrap_ci(
145+
logsA,
146+
policyB,
147+
target="cltv",
148+
feature_cols=["loyal", "age", "risk", "income"],
149+
action_col="a_A",
150+
n_boot=300,
151+
alpha=0.05,
152+
weight_clip=args.weight_clip,
153+
)
151154

152155
# Диагностика весов
153156
diagnostics = {

0 commit comments

Comments
 (0)