Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- `decisionrl.ope`: off-policy evaluation for contextual bandits. Estimate what a target
policy would earn from a log of `(context, action, propensity, reward)`, without deploying
it, using inverse propensity scoring, self-normalized IPS, the direct method, or doubly
robust. Includes `collect_bandit_log` and behaviour-policy helpers. Pure NumPy, no torch.

### Changed
- The top-level package now resolves its public names lazily (PEP 562 `__getattr__`),
so `import decisionrl` imports nothing on its own. `decisionrl.envs`,
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,8 @@ correctly.
- Contextual bandits for one-shot decisions such as pricing and recommendation: LinUCB,
linear Thompson sampling, and an epsilon-greedy baseline, with exact regret tracking
(`decisionrl.bandits`).
- Off-policy evaluation of a new bandit policy from logged data, before deploying it: IPS,
self-normalized IPS, the direct method, and doubly robust (`decisionrl.ope`).
- Preference-based RLHF and DPO on control tasks (`decisionrl.rlhf`).
- RLHF on a character-level GPT (`decisionrl.text`): supervised pre-training, then
reward fine-tuning with a KL penalty to the reference model.
Expand Down
3 changes: 3 additions & 0 deletions docs/bandits.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,6 @@ Cumulative regret over 3000 rounds on a 6-arm, 8-feature problem, averaged over
The confidence-based methods explore where they are uncertain rather than uniformly, so
their regret grows sublinearly and stays a small fraction of both epsilon-greedy and a
random policy.

To estimate what a new bandit policy would earn from a log of past decisions, before
deploying it, see [off-policy evaluation](ope.md).
54 changes: 54 additions & 0 deletions docs/ope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Off-policy evaluation

Before you ship a new pricing or recommendation rule, you want to know what it would earn,
without exposing customers to it first. You usually cannot A/B test every candidate, but
you do have a log of what the current rule did and what happened. Off-policy evaluation
estimates the value of a target policy from that log alone.

`decisionrl.ope` provides four estimators for the contextual-bandit case. They take a log
of `(context, action, propensity, reward)` and the target policy's action probabilities on
the logged contexts, and differ in how they trade bias against variance:

| Estimator | Idea | Trade-off |
|---|---|---|
| `inverse_propensity_score` | reweight logged rewards by target/behaviour probability | unbiased, high variance |
| `self_normalized_ips` | IPS divided by the mean weight | consistent, much steadier |
| `direct_method` | fit a reward model, average it under the target | low variance, biased if the model is wrong |
| `doubly_robust` | direct method plus an IPS correction on its residuals | unbiased if *either* piece is right |

The one requirement is coverage: the behaviour policy must explore, giving positive
probability to the actions the target might take. A purely greedy log cannot tell you about
actions it never tried.

## Usage

```python
from decisionrl.envs import ContextualBandit
from decisionrl.ope import (collect_bandit_log, uniform_behavior,
greedy_target_probs, doubly_robust)
import numpy as np

env = ContextualBandit(n_arms=5, n_features=6)
log = collect_bandit_log(env, uniform_behavior(5), n_rounds=6000)

# Evaluate the greedy policy under a learned reward scorer, from the log alone.
target = greedy_target_probs(scorer, log.contexts, n_arms=5)
value = doubly_robust(log, target)
```

## Does it work?

On a synthetic bandit where the true value is known, all four estimators recover it from a
log collected under a uniform behaviour policy (6000 rounds, true optimal value 0.443):

| Estimator | Estimate |
|---|---:|
| Inverse propensity score | 0.447 |
| Self-normalized IPS | 0.447 |
| Direct method | 0.444 |
| Doubly robust | 0.447 |

The "doubly" in doubly robust is worth seeing: give it a hopeless reward model (predictions
collapse to zero) and the direct method falls to about zero, but doubly robust still returns
the correct value, because the importance-weighted correction carries it. A test covers this
exact case.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ nav:
- AlphaZero: alphazero.md
- Meta-RL (RL²): meta.md
- Contextual bandits: bandits.md
- Off-policy evaluation: ope.md
- Imitation learning: imitation.md
- LLM alignment (RLHF): text.md
- Evolution & swarm: evolution.md
Expand Down
170 changes: 170 additions & 0 deletions src/decisionrl/ope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""Off-policy evaluation (OPE) for contextual bandits.

Estimate the value of a *target* policy from data logged under a different *behaviour*
policy, without deploying the target. This is how you decide whether a new pricing or
recommendation rule is worth shipping: you already have a log of what the old rule did and
what happened, and you want the counterfactual "what would the new rule have earned?".

Given a log of ``(context, action, propensity, reward)`` and the target policy's action
probabilities on the same contexts, four estimators with different bias/variance
trade-offs:

* :func:`inverse_propensity_score` (IPS): unbiased, but high variance when the target and
behaviour policies disagree.
* :func:`self_normalized_ips` (SNIPS): IPS divided by the mean importance weight; slightly
biased, consistent, and far steadier than raw IPS.
* :func:`direct_method` (DM): fit a reward model and average its predictions under the
target; low variance, but biased when the model is wrong.
* :func:`doubly_robust` (DR): DM plus an IPS-style correction. Unbiased if *either* the
propensities or the reward model is right, and usually the lowest error of the four.

Everything here is pure NumPy; there is no training loop and no PyTorch dependency.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Callable, Tuple

import numpy as np

__all__ = [
"BanditLog",
"collect_bandit_log",
"uniform_behavior",
"epsilon_greedy_behavior",
"greedy_target_probs",
"inverse_propensity_score",
"self_normalized_ips",
"direct_method",
"doubly_robust",
]


@dataclass
class BanditLog:
"""A logged contextual-bandit dataset for off-policy evaluation.

``propensities[i]`` is the probability the behaviour policy assigned to the action it
actually took in context ``i``; it must be positive for the log to be usable.
"""

contexts: np.ndarray # (n, d)
actions: np.ndarray # (n,) int in [0, n_arms)
propensities: np.ndarray # (n,) behaviour prob of the taken action, in (0, 1]
rewards: np.ndarray # (n,)
n_arms: int

def __len__(self) -> int:
return int(self.actions.shape[0])


def collect_bandit_log(env, behavior: Callable[[np.ndarray], Tuple[int, float]],
n_rounds: int, seed: int = 0) -> BanditLog:
"""Run a stochastic behaviour policy on a contextual-bandit env and log the result.

``behavior(context) -> (action, propensity)`` returns the sampled action and the
probability the behaviour policy gave it. The behaviour must explore (assign positive
probability to the actions the target might take), or the log cannot support evaluating
that target.
"""
n_arms = int(env.action_space.n)
obs, _ = env.reset(seed=seed)
contexts, actions, propensities, rewards = [], [], [], []
for _ in range(n_rounds):
action, propensity = behavior(np.asarray(obs, dtype=np.float64))
next_obs, reward, terminated, truncated, _ = env.step(action)
contexts.append(np.asarray(obs, dtype=np.float64))
actions.append(int(action))
propensities.append(float(propensity))
rewards.append(float(reward))
obs = next_obs
if terminated or truncated:
obs, _ = env.reset()
return BanditLog(np.asarray(contexts), np.asarray(actions, dtype=int),
np.asarray(propensities), np.asarray(rewards), n_arms)


def uniform_behavior(n_arms: int, seed: int = 0) -> Callable[[np.ndarray], Tuple[int, float]]:
"""A behaviour policy that picks arms uniformly at random (full coverage)."""
rng = np.random.default_rng(seed)

def behave(context: np.ndarray) -> Tuple[int, float]:
return int(rng.integers(n_arms)), 1.0 / n_arms

return behave


def epsilon_greedy_behavior(scorer: Callable[[np.ndarray], np.ndarray], n_arms: int,
epsilon: float = 0.2, seed: int = 0):
"""An epsilon-greedy behaviour policy over ``scorer(context) -> per-arm scores``."""
rng = np.random.default_rng(seed)

def behave(context: np.ndarray) -> Tuple[int, float]:
greedy = int(np.argmax(scorer(context)))
arm = int(rng.integers(n_arms)) if rng.random() < epsilon else greedy
propensity = epsilon / n_arms + (1.0 - epsilon) * (1.0 if arm == greedy else 0.0)
return arm, propensity

return behave


def greedy_target_probs(scorer: Callable[[np.ndarray], np.ndarray], contexts: np.ndarray,
n_arms: int) -> np.ndarray:
"""Action probabilities of the deterministic policy that plays ``argmax scorer``."""
probs = np.zeros((len(contexts), n_arms))
for i, context in enumerate(contexts):
probs[i, int(np.argmax(scorer(context)))] = 1.0
return probs


def _importance_weights(log: BanditLog, target_probs: np.ndarray) -> np.ndarray:
taken = target_probs[np.arange(len(log)), log.actions]
return taken / log.propensities


def inverse_propensity_score(log: BanditLog, target_probs: np.ndarray) -> float:
"""IPS estimate of the target policy's value. Unbiased, high variance."""
return float(np.mean(_importance_weights(log, target_probs) * log.rewards))


def self_normalized_ips(log: BanditLog, target_probs: np.ndarray) -> float:
"""Self-normalised IPS (SNIPS): steadier than IPS, consistent, mildly biased."""
w = _importance_weights(log, target_probs)
total = np.sum(w)
return float(np.sum(w * log.rewards) / total) if total > 0 else float("nan")


def _fit_reward_model(log: BanditLog, ridge: float) -> np.ndarray:
"""Per-arm ridge regression of reward on context; predict for every arm and row."""
n, d = log.contexts.shape
rhat = np.zeros((n, log.n_arms))
for arm in range(log.n_arms):
mask = log.actions == arm
if not mask.any():
continue
x_arm, r_arm = log.contexts[mask], log.rewards[mask]
gram = x_arm.T @ x_arm + ridge * np.eye(d)
theta = np.linalg.solve(gram, x_arm.T @ r_arm)
rhat[:, arm] = log.contexts @ theta
return rhat


def direct_method(log: BanditLog, target_probs: np.ndarray, ridge: float = 1.0) -> float:
"""Direct method: average a fitted reward model under the target policy."""
rhat = _fit_reward_model(log, ridge)
return float(np.mean(np.sum(target_probs * rhat, axis=1)))


def doubly_robust(log: BanditLog, target_probs: np.ndarray, ridge: float = 1.0) -> float:
"""Doubly robust: the direct method plus an IPS correction on its residuals.

Unbiased when either the propensities or the reward model is correct, and typically the
most accurate of the four estimators here.
"""
n = len(log)
rhat = _fit_reward_model(log, ridge)
baseline = np.sum(target_probs * rhat, axis=1)
weights = _importance_weights(log, target_probs)
correction = weights * (log.rewards - rhat[np.arange(n), log.actions])
return float(np.mean(baseline + correction))
59 changes: 59 additions & 0 deletions tests/test_ope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Tests for off-policy evaluation of contextual bandits."""

import numpy as np

from decisionrl.envs import ContextualBandit
from decisionrl.ope import (
collect_bandit_log,
direct_method,
doubly_robust,
greedy_target_probs,
inverse_propensity_score,
self_normalized_ips,
uniform_behavior,
)


def _log_and_target(n_arms=5, n_features=6, n_rounds=6000):
env = ContextualBandit(n_arms=n_arms, n_features=n_features, horizon=10**9, noise=0.1, seed=1)
log = collect_bandit_log(env, uniform_behavior(n_arms, seed=0), n_rounds, seed=0)
scorer = lambda x: env.theta @ x # noqa: E731 - true expected reward per arm
target = greedy_target_probs(scorer, log.contexts, n_arms)
truth = float(np.mean(np.sum(target * (log.contexts @ env.theta.T), axis=1)))
return log, target, truth


def test_all_estimators_recover_target_value():
log, target, truth = _log_and_target()
for name, est in [
("IPS", inverse_propensity_score(log, target)),
("SNIPS", self_normalized_ips(log, target)),
("DM", direct_method(log, target)),
("DR", doubly_robust(log, target)),
]:
assert abs(est - truth) < 0.05, f"{name} {est:.3f} vs truth {truth:.3f}"


def test_ips_of_behavior_policy_equals_mean_reward():
# Evaluating the (uniform) behaviour policy itself should return its own mean reward.
log, _, _ = _log_and_target()
uniform_target = np.full((len(log), log.n_arms), 1.0 / log.n_arms)
assert abs(inverse_propensity_score(log, uniform_target) - log.rewards.mean()) < 1e-9


def test_doubly_robust_survives_a_broken_reward_model():
# With a hopeless reward model (huge ridge -> predictions collapse to zero) the direct
# method is badly biased, but doubly robust still recovers the value via the IPS term.
log, target, truth = _log_and_target()
assert abs(direct_method(log, target, ridge=1e9)) < 0.05 # DM collapses toward 0
assert abs(doubly_robust(log, target, ridge=1e9) - truth) < 0.05 # DR stays accurate


def test_collect_bandit_log_shapes_and_propensities():
env = ContextualBandit(n_arms=4, n_features=3, horizon=50, seed=2)
log = collect_bandit_log(env, uniform_behavior(4, seed=0), 200, seed=0)
assert len(log) == 200
assert log.contexts.shape == (200, 3)
assert log.n_arms == 4
assert np.all((log.propensities > 0) & (log.propensities <= 1))
assert np.all((log.actions >= 0) & (log.actions < 4))