Модуль 3. Бандиты, исследование и одношаговые решения
Здесь можно прочитать пояснения и код имеющихся ноутбуков или скачать файлы. Код на сайте не запускается.
Скачать файлы курса (.zip) 382,1 КБАрхив содержит файлы на обоих языках и вспомогательные материалы с сохранением структуры каталогов.
Модуль 3. Бандиты, исследование и оценивание по журналам
Основной ноутбук модуля
Скачать .ipynb module-03-bandits-ope.ru.ipynb · 21,1 КБ
Показать текст ноутбука
Модуль 3. Бандиты, исследование и оценивание по журналам
Этот ноутбук соединяет четыре уровня, которые в лекции вводятся последовательно: классический стационарный бандит, нестационарность, контекстная маршрутизация и оценивание новой политики по данным старой. Все эксперименты воспроизводимы и выполняются на CPU.
Профили. smoke рассчитан на быстрый учебный запуск. full увеличивает число повторов и размер журнала, чтобы кривые и оценки были устойчивее. Числа ниже являются результатами кода курса при указанном протоколе, а не внешним сравнением алгоритмов.
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
import sys
import math
import numpy as np
import matplotlib.pyplot as plt
def find_course_root() -> Path:
candidates = [Path.cwd(), *Path.cwd().parents]
for candidate in candidates:
if (candidate / "modules").is_dir() and (candidate / "COURSE_MODULE_REVISION_PROTOCOL.md").is_file():
return candidate
raise FileNotFoundError("Course root was not found. Run the notebook from the repository tree.")
ROOT = find_course_root()
np.set_printoptions(precision=4, suppress=True)
print(f"Course root: {ROOT}")
M3_DIR = ROOT / "modules" / "module-03"
if str(M3_DIR) not in sys.path:
sys.path.insert(0, str(M3_DIR))
from module_3_reference import (
simulate_many_bandits,
summarize_many,
ips_value,
snips_value,
doubly_robust_value,
categorical_entropy,
)
PROFILE = "smoke" # change to "full" for the larger protocol
assert PROFILE in {"smoke", "full"}
print("Profile:", PROFILE)
1. Стационарный бандит: сравниваем не один удачный прогон, а распределение прогонов
Один seed легко создаёт ложную историю. Поэтому ниже каждая стратегия запускается многократно на одной и той же пятирукой Bernoulli-среде. Мы измеряем псевдосожаление: на каждом раунде добавляется разность между средней наградой лучшей руки и средней наградой выбранной руки.
probabilities = np.array([0.20, 0.35, 0.42, 0.55, 0.60])
horizon = 1_200 if PROFILE == "smoke" else 3_000
n_runs = 120 if PROFILE == "smoke" else 500
algorithms = {
"greedy": ("greedy", 0.0),
"epsilon-greedy (0.1)": ("epsilon_greedy", 0.1),
"UCB1": ("ucb1", 0.0),
"Thompson": ("thompson", 0.0),
}
curves = {}
for label, (algorithm, epsilon) in algorithms.items():
result = simulate_many_bandits(probabilities, horizon, n_runs, 20260804, algorithm, epsilon=epsilon)
curves[label] = summarize_many(result)
plt.figure(figsize=(9, 5))
for label, summary in curves.items():
plt.plot(summary.mean_regret, label=label)
plt.fill_between(np.arange(horizon), summary.lower_95, summary.upper_95, alpha=0.12)
plt.xlabel("Раунд")
plt.ylabel("Среднее накопленное псевдосожаление")
plt.title("Стационарный Bernoulli-бандит: среднее и 95% интервал")
plt.legend()
plt.tight_layout()
plt.show()
for label, summary in curves.items():
print(f"{label:24s} final mean pseudo-regret = {summary.mean_regret[-1]:8.2f}; "
f"optimal-arm rate = {summary.mean_optimal_rate[-1]:.3f}")
График не доказывает универсального превосходства одного метода. Он показывает, как конкретные стратегии ведут себя при фиксированных вероятностях, горизонте, схеме разрыва ничьих и наборе seed. На другой задаче порядок может измениться.
2. Нестационарность: накопленная история может стать помехой
В середине следующего эксперимента лучшая рука меняется. Обычное выборочное среднее хранит всю историю с одинаковым весом. Постоянный размер шага забывает старые награды и быстрее адаптируется, но его оценка продолжает колебаться.
def stable_sigmoid(x: np.ndarray) -> np.ndarray:
x = np.asarray(x, dtype=float)
out = np.empty_like(x)
positive = x >= 0
out[positive] = 1.0 / (1.0 + np.exp(-x[positive]))
z = np.exp(x[~positive])
out[~positive] = z / (1.0 + z)
return out
def row_softmax(logits: np.ndarray) -> np.ndarray:
logits = np.asarray(logits, dtype=float)
shifted = logits - np.max(logits, axis=1, keepdims=True)
e = np.exp(shifted)
return e / e.sum(axis=1, keepdims=True)
def contextual_probabilities(x: np.ndarray) -> np.ndarray:
"""True expected reward q(x, a) for three routing actions."""
x = np.asarray(x, dtype=float)
z = np.column_stack([
0.35 + 0.70 * x[:, 0] - 0.25 * x[:, 1],
0.10 - 0.35 * x[:, 0] + 0.80 * x[:, 1] + 0.25 * x[:, 2],
-0.15 + 0.30 * x[:, 0] + 0.10 * x[:, 1] + 0.95 * x[:, 2],
])
return stable_sigmoid(z)
def logging_policy(x: np.ndarray, floor: float = 0.08) -> np.ndarray:
logits = np.column_stack([
0.25 + 0.35 * x[:, 0],
0.05 + 0.30 * x[:, 1],
-0.10 + 0.25 * x[:, 2],
])
raw = row_softmax(logits)
k = raw.shape[1]
return (1.0 - k * floor) * raw + floor
def target_policy(x: np.ndarray, sharpness: float = 2.0) -> np.ndarray:
q = contextual_probabilities(x)
# The target prefers reward but includes small action-dependent deployment costs.
costs = np.array([0.03, 0.08, 0.17])
return row_softmax(sharpness * (q - costs))
def sample_log(n: int, seed: int, floor: float = 0.08) -> dict[str, np.ndarray]:
rng = np.random.default_rng(seed)
x = rng.normal(size=(n, 3))
mu = logging_policy(x, floor=floor)
q = contextual_probabilities(x)
u = rng.random(n)
cdf = np.cumsum(mu, axis=1)
actions = (u[:, None] > cdf).sum(axis=1)
rewards = (rng.random(n) < q[np.arange(n), actions]).astype(float)
return {"x": x, "mu": mu, "q": q, "actions": actions, "rewards": rewards}
def features(x: np.ndarray) -> np.ndarray:
x = np.asarray(x, dtype=float)
return np.column_stack([
np.ones(len(x)),
x,
x ** 2,
x[:, 0] * x[:, 1],
x[:, 0] * x[:, 2],
x[:, 1] * x[:, 2],
])
def cross_fitted_reward_model(x: np.ndarray, actions: np.ndarray, rewards: np.ndarray,
folds: int = 5, ridge: float = 2.0, seed: int = 0) -> np.ndarray:
"""Per-action ridge reward model with grouped cross-fitting."""
rng = np.random.default_rng(seed)
n = len(x)
order = rng.permutation(n)
fold_id = np.empty(n, dtype=int)
fold_id[order] = np.arange(n) % folds
phi = features(x)
pred = np.empty((n, 3), dtype=float)
eye = np.eye(phi.shape[1])
eye[0, 0] = 0.0 # do not regularize the intercept
for fold in range(folds):
train = fold_id != fold
valid = ~train
for action in range(3):
rows = train & (actions == action)
if rows.sum() < phi.shape[1]:
raise RuntimeError("Insufficient action coverage for reward-model fitting")
lhs = phi[rows].T @ phi[rows] + ridge * eye
rhs = phi[rows].T @ rewards[rows]
coef = np.linalg.solve(lhs, rhs)
pred[valid, action] = phi[valid] @ coef
return np.clip(pred, 0.0, 1.0)
def ope_estimates(log: dict[str, np.ndarray], sharpness: float = 2.0,
clip: float | None = None, seed: int = 0) -> dict[str, float]:
x, mu, actions, rewards = log["x"], log["mu"], log["actions"], log["rewards"]
pi = target_policy(x, sharpness=sharpness)
p_logged = mu[np.arange(len(x)), actions]
p_target = pi[np.arange(len(x)), actions]
weights = p_target / p_logged
if clip is not None:
weights_used = np.minimum(weights, clip)
p_target_used = weights_used * p_logged
else:
weights_used = weights
p_target_used = p_target
rhat = cross_fitted_reward_model(x, actions, rewards, seed=seed)
dm_rows = np.sum(pi * rhat, axis=1)
ips = float(np.mean(weights_used * rewards))
snips = float(np.sum(weights_used * rewards) / np.sum(weights_used))
dr = float(np.mean(dm_rows + weights_used * (rewards - rhat[np.arange(len(x)), actions])))
ess = float(weights_used.sum() ** 2 / np.sum(weights_used ** 2))
truth = float(np.mean(np.sum(pi * log["q"], axis=1)))
return {
"truth": truth,
"IPS": ips,
"SNIPS": snips,
"DM": float(np.mean(dm_rows)),
"DR": dr,
"ESS": ess,
"max_weight": float(np.max(weights_used)),
"mean_weight": float(np.mean(weights_used)),
}
def nonstationary_epsilon_greedy(horizon: int, seed: int, alpha: float | None) -> np.ndarray:
"""Regret when the best arm changes halfway through the run.
alpha=None uses the full sample average; a finite alpha uses constant-step
recency weighting and therefore forgets old rewards.
"""
rng = np.random.default_rng(seed)
means_before = np.array([0.65, 0.45, 0.25])
means_after = np.array([0.25, 0.45, 0.70])
estimates = np.zeros(3)
counts = np.zeros(3, dtype=int)
regret = np.zeros(horizon)
running = 0.0
for t in range(horizon):
means = means_before if t < horizon // 2 else means_after
if rng.random() < 0.10:
arm = int(rng.integers(3))
else:
best = np.flatnonzero(np.isclose(estimates, estimates.max()))
arm = int(rng.choice(best))
reward = float(rng.random() < means[arm])
counts[arm] += 1
step = (1.0 / counts[arm]) if alpha is None else alpha
estimates[arm] += step * (reward - estimates[arm])
running += float(means.max() - means[arm])
regret[t] = running
return regret
ns_horizon = 1_500
seeds = np.arange(80 if PROFILE == "smoke" else 300) + 9000
sample_average = np.stack([nonstationary_epsilon_greedy(ns_horizon, int(s), None) for s in seeds])
recency_weighted = np.stack([nonstationary_epsilon_greedy(ns_horizon, int(s), 0.10) for s in seeds])
plt.figure(figsize=(9, 5))
plt.plot(np.median(sample_average, axis=0), label="sample average")
plt.plot(np.median(recency_weighted, axis=0), label="constant step size 0.10")
plt.axvline(ns_horizon // 2, linestyle="--", label="best arm changes")
plt.xlabel("Раунд")
plt.ylabel("Медианное накопленное псевдосожаление")
plt.title("Нестационарный бандит: адаптация после смены лучшей руки")
plt.legend()
plt.tight_layout()
plt.show()
3. Контекстный журнал и истинная ценность целевой политики
Теперь действие зависит от признаков запроса. В синтетической модели мы знаем истинную вероятность успеха каждого маршрута, но в журнал записываем только награду выбранного действия и его logging propensity. Это позволяет сравнить offline-оцениватели с известной истиной, не выдавая синтетический пример за реальный продуктовый результат.
n = 6_000 if PROFILE == "smoke" else 25_000
log = sample_log(n, seed=20260804, floor=0.08)
base = ope_estimates(log, sharpness=2.0, seed=44)
for key, value in base.items():
print(f"{key:12s}: {value:.6f}")
assert base["ESS"] > 0
assert np.isfinite(list(base.values())).all()
IPS использует только наблюдённые награды и отношения вероятностей. Direct method опирается на модель награды. DR начинает с модельного прогноза и добавляет importance-weighted остаток. Даже корректная формула не спасает при отсутствии носителя: если logging policy почти не выбирала нужное действие, веса концентрируются на нескольких строках.
4. Стресс-тест перекрытия носителей
Увеличиваем резкость целевой политики. Она всё сильнее предпочитает узкий набор действий, тогда как журнал собран более мягкой политикой. Следим одновременно за ошибкой оценивателей, максимальным весом и эффективным размером выборки.
sharpness_grid = np.linspace(0.5, 8.0, 16)
rows = [ope_estimates(log, sharpness=float(s), seed=55) for s in sharpness_grid]
plt.figure(figsize=(9, 5))
for name in ["IPS", "SNIPS", "DM", "DR"]:
error = [row[name] - row["truth"] for row in rows]
plt.plot(sharpness_grid, error, marker="o", label=name)
plt.axhline(0.0, linewidth=1)
plt.xlabel("Резкость целевой политики")
plt.ylabel("Ошибка оценки ценности")
plt.title("OPE при ухудшении перекрытия носителей")
plt.legend()
plt.tight_layout()
plt.show()
plt.figure(figsize=(9, 5))
plt.plot(sharpness_grid, [row["ESS"] for row in rows], marker="o", label="ESS")
plt.xlabel("Резкость целевой политики")
plt.ylabel("Эффективный размер выборки")
plt.title("Концентрация importance weights")
plt.tight_layout()
plt.show()
print("last point:", rows[-1])
5. LLM-подобная маршрутизация: качество, стоимость и задержка
В продуктивной системе награда часто является не «истинным качеством», а явно заданной полезностью. Ниже три действия интерпретируются как дешёвая модель, более сильная модель и модель с дополнительным инструментом. Полезность вычитает стоимость и задержку. Изменение коэффициентов меняет оптимальную политику — это часть постановки, а не досадная инженерная деталь.
rng = np.random.default_rng(777)
x_router = rng.normal(size=(12_000, 3))
quality = contextual_probabilities(x_router)
cost = np.array([0.02, 0.11, 0.28])
latency = np.array([0.10, 0.35, 0.80])
for cost_weight, latency_weight in [(0.2, 0.1), (0.8, 0.4), (1.8, 0.8)]:
utility = quality - cost_weight * cost - latency_weight * latency
chosen = np.argmax(utility, axis=1)
shares = np.bincount(chosen, minlength=3) / len(chosen)
print(f"cost_weight={cost_weight:3.1f}, latency_weight={latency_weight:3.1f}, route shares={shares}")
6. Что считать результатом ноутбука
Мы получили не один «победивший алгоритм», а набор проверяемых инженерных контрактов: стратегия исследования должна соответствовать стационарности среды; журнал обязан хранить вероятность выбранного действия; IPS/DR требуют перекрытия носителей; ESS и хвост весов нужно смотреть рядом с одной итоговой оценкой; полезность маршрутизатора должна явно включать качество, цену и задержку. Именно эти контракты переходят к RL for LLM.