Skip to content

Commit 331e199

Browse files
Merge pull request #109 from davidecoraci/add-dr-estimator
Add DR estimator and RTG regression model
2 parents 0246ed2 + 24ee384 commit 331e199

4 files changed

Lines changed: 1293 additions & 14 deletions

File tree

hopes/ope/estimators.py

Lines changed: 297 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from hopes.assert_utils import check_array
88
from hopes.dev_utils import override
9-
from hopes.rew.rewards import RegressionBasedRewardModel
9+
from hopes.rew.rewards import RegressionBasedRewardModel, RTGQModelHGBoost
1010

1111

1212
class BaseEstimator(ABC):
@@ -45,8 +45,8 @@ def set_importance_ratios(self, importance_ratios: np.ndarray | None) -> None:
4545
stickiness correction outside the estimator.
4646
4747
param importance_ratios: Precomputed importance ratios for the logged actions.
48-
Supported shapes are ``(n_samples,)`` or ``(n_episodes, steps_per_episode)``.
49-
:raises ValueError: If the shape of ``importance_ratios`` is invalid.
48+
Supported shapes are `(n_samples,)` or `(n_episodes, steps_per_episode)`.
49+
:raises ValueError: If the shape of `importance_ratios` is invalid.
5050
"""
5151

5252
if importance_ratios is None:
@@ -619,17 +619,17 @@ def compute_weighted_rewards(
619619
step-wise ratios directly instead of recomputing them from policy probabilities.
620620
621621
:param target_policy_action_probabilities: Target policy action probabilities,
622-
shape ``(n_samples, n_actions)``.
622+
shape `(n_samples, n_actions)`.
623623
:param behavior_policy_action_probabilities: Behavior policy action probabilities,
624-
shape ``(n_samples, n_actions)``.
625-
:param rewards: Logged rewards, shape ``(n_samples,)``.
624+
shape `(n_samples, n_actions)`.
625+
:param rewards: Logged rewards, shape `(n_samples,)`.
626626
:param steps_per_episode: Number of steps per episode.
627-
:param discount_factor: Discount factor in ``[0, 1]``.
627+
:param discount_factor: Discount factor in `[0, 1]`.
628628
:param is_per_decision: Whether to compute per-decision or trajectory-wise weighting.
629629
:param importance_ratios: Optional precomputed step-wise importance ratios for the
630-
logged actions. Supported shapes are ``(n_samples,)`` or
631-
``(n_episodes, steps_per_episode)``.
632-
:return: Weighted rewards per episode, shape ``(n_episodes, 1)``.
630+
logged actions. Supported shapes are `(n_samples,)` or
631+
`(n_episodes, steps_per_episode)`.
632+
:return: Weighted rewards per episode, shape `(n_episodes, 1)`.
633633
"""
634634

635635
# rewards, shape: (n, T)
@@ -1067,3 +1067,290 @@ def _bootstrap_sample_policy_value(
10671067
den = float(np.sum(W_b))
10681068

10691069
return float(num / np.maximum(den, self.eps))
1070+
1071+
1072+
class SequentialDoublyRobust(BaseEstimator):
1073+
r"""Sequential Doubly Robust estimator.
1074+
1075+
This estimator computes a per-decision doubly robust estimate using a
1076+
temporal-difference-style formulation. It combines model-based predictions
1077+
with cumulative importance weights built from behavior and target policy
1078+
action probabilities.
1079+
1080+
The per-episode estimate is computed as:
1081+
1082+
.. math::
1083+
\hat{V}_{\mathrm{DR}}^{(i)} =
1084+
\hat{V}(s_{i,0}) +
1085+
\sum_{t=0}^{T-1}
1086+
W_{i,t}
1087+
\left(
1088+
r_{i,t}
1089+
+ \gamma \hat{V}(s_{i,t+1})
1090+
- \hat{Q}(s_{i,t}, a_{i,t})
1091+
\right)
1092+
1093+
where:
1094+
1095+
.. math::
1096+
W_{i,t} = \prod_{k=0}^{t} \rho_{i,k}
1097+
1098+
and
1099+
1100+
.. math::
1101+
\rho_{i,t} =
1102+
\frac{\pi_e(a_{i,t} \mid s_{i,t})}{\pi_b(a_{i,t} \mid s_{i,t})}
1103+
1104+
with:
1105+
1106+
- :math:`i` denoting the episode index,
1107+
- :math:`t` denoting the timestep index,
1108+
- :math:`r_{i,t}` the observed reward at timestep :math:`t`,
1109+
- :math:`\hat{Q}(s_{i,t}, a_{i,t})` the estimated action-value for the logged action,
1110+
- :math:`\hat{V}(s_{i,t})` the estimated state value under the target policy,
1111+
- :math:`\gamma` the discount factor,
1112+
- :math:`\pi_e` the target policy,
1113+
- :math:`\pi_b` the behavior policy.
1114+
1115+
If precomputed step-wise importance ratios are provided, they are used directly.
1116+
Otherwise, the ratios are constructed from the target and behavior policy action
1117+
probabilities and the logged actions.
1118+
1119+
Stickiness handling, when needed, must be applied upstream during preprocessing.
1120+
Reference paper: https://arxiv.org/abs/1511.03722
1121+
"""
1122+
1123+
def __init__(
1124+
self,
1125+
*,
1126+
steps_per_episode: int,
1127+
discount_factor: float = 1.0,
1128+
eps: float = 1e-12,
1129+
clip: float | None = None,
1130+
) -> None:
1131+
r"""Initialize the Sequential Doubly Robust estimator.
1132+
1133+
:param steps_per_episode: Number of timesteps in each episode.
1134+
:param discount_factor: Discount factor
1135+
:math:`\gamma` used in the TD correction term. Must be in
1136+
:math:`[0, 1]`.
1137+
:param eps: Numerical stabilizer used in importance-ratio computation to avoid
1138+
division by zero.
1139+
:param clip: Optional symmetric clipping threshold applied to step-wise importance
1140+
ratios as
1141+
:math:`\rho_t \leftarrow \mathrm{clip}(\rho_t, 1 / c, c)`. When provided, it must
1142+
satisfy
1143+
:math:`c \geq 1`.
1144+
"""
1145+
super().__init__()
1146+
1147+
assert steps_per_episode > 0, "The number of steps per episode must be positive."
1148+
assert 0 <= discount_factor <= 1, "The discount factor must be in [0, 1]."
1149+
1150+
self.steps_per_episode = steps_per_episode
1151+
self.discount_factor = discount_factor
1152+
self.eps = eps
1153+
self.clip = clip
1154+
1155+
self.logged_actions: np.ndarray | None = None
1156+
self.q_values: np.ndarray | None = None
1157+
1158+
def set_logged_actions(self, logged_actions: np.ndarray) -> None:
1159+
"""Set logged actions.
1160+
1161+
:param logged_actions: Logged action indices with shape `(n_samples,)`.
1162+
"""
1163+
self.logged_actions = np.asarray(logged_actions, dtype=np.int64).reshape(-1)
1164+
1165+
def set_model_predictions(self, *, q_values: np.ndarray) -> None:
1166+
r"""Set model-based predictions used by the sequential DR estimator.
1167+
1168+
:param q_values: Estimated action-values for all actions, shape `(n_samples,
1169+
n_actions)`. Each row must contain the estimated action-values
1170+
:math:`[\hat{Q}(s_t, a)]_{a \in \mathcal{A}}` for the corresponding state.
1171+
"""
1172+
self.q_values = np.asarray(q_values, dtype=np.float32)
1173+
1174+
def fit(
1175+
self,
1176+
*,
1177+
obs_flat: np.ndarray,
1178+
act_flat: np.ndarray,
1179+
rew_flat: np.ndarray,
1180+
num_actions: int,
1181+
q_model_params: dict | None = None,
1182+
random_state: int = 0,
1183+
) -> RTGQModelHGBoost:
1184+
"""Fit an internal :class:`~hopes.rew.rewards.RTGQModelHGBoost` Q model from raw
1185+
logged data, then populate :attr:`q_values` and :attr:`logged_actions` automatically.
1186+
1187+
This mirrors the design of :meth:`DirectMethod.fit`: rather than building and fitting
1188+
the Q model externally and injecting the predictions via
1189+
:meth:`set_model_predictions`, you can pass the raw trajectory data directly and let
1190+
the estimator handle the model training. The two workflows remain interchangeable —
1191+
:meth:`set_model_predictions` is still available for cases where Q-values come from
1192+
an external source.
1193+
1194+
:param obs_flat: Observations, shape `(n_samples, obs_dim)`.
1195+
:param act_flat: Discrete action indices, shape `(n_samples,)`.
1196+
:param rew_flat: Rewards, shape `(n_samples,)`.
1197+
:param num_actions: Total number of discrete actions.
1198+
:param q_model_params: Optional hyper-parameters forwarded to
1199+
:class:`~hopes.rew.rewards.RTGQModelHGBoost`.
1200+
:param random_state: Random seed for the underlying gradient-boosting model.
1201+
:return: The fitted :class:`~hopes.rew.rewards.RTGQModelHGBoost` instance.
1202+
"""
1203+
obs_flat = np.asarray(obs_flat, dtype=np.float32)
1204+
act_flat = np.asarray(act_flat, dtype=np.int64).reshape(-1)
1205+
rew_flat = np.asarray(rew_flat, dtype=np.float32).reshape(-1)
1206+
1207+
q_model = RTGQModelHGBoost(
1208+
steps_per_episode=self.steps_per_episode,
1209+
num_actions=num_actions,
1210+
discount_factor=self.discount_factor,
1211+
model_params=q_model_params or {},
1212+
random_state=random_state,
1213+
)
1214+
q_model.fit(obs_flat=obs_flat, act_flat=act_flat, rew_flat=rew_flat)
1215+
1216+
# Populate q_values for all (state, action) pairs and store logged actions
1217+
q_values = q_model.predict_q_values(obs_flat=obs_flat) # shape: (n_samples, num_actions)
1218+
self.set_model_predictions(q_values=q_values)
1219+
self.set_logged_actions(act_flat)
1220+
1221+
return q_model
1222+
1223+
@override(BaseEstimator)
1224+
def short_name(self) -> str:
1225+
return "SDR"
1226+
1227+
@override(BaseEstimator)
1228+
def check_parameters(self) -> None:
1229+
"""Check if the estimator parameters are valid."""
1230+
super().check_parameters()
1231+
1232+
if self.eps <= 0:
1233+
raise ValueError("eps must be > 0.")
1234+
1235+
if self.clip is not None and self.clip < 1.0:
1236+
raise ValueError("clip must be >= 1.0 when provided.")
1237+
1238+
n_samples = self.rewards.shape[0]
1239+
n_actions = self.target_policy_action_probabilities.shape[1]
1240+
1241+
if n_samples % self.steps_per_episode != 0:
1242+
raise ValueError("The number of samples must be divisible by steps_per_episode.")
1243+
1244+
if self.q_values is None:
1245+
raise ValueError("q_values not set. Call set_model_predictions(...).")
1246+
1247+
if self.q_values.ndim != 2:
1248+
raise ValueError("q_values must be a 2D array of shape (n_samples, n_actions).")
1249+
1250+
if self.q_values.shape != (n_samples, n_actions):
1251+
raise ValueError(
1252+
"q_values must have shape (n_samples, n_actions), matching "
1253+
"target_policy_action_probabilities."
1254+
)
1255+
1256+
if self.logged_actions is None:
1257+
raise ValueError("logged_actions must be provided.")
1258+
1259+
if self.logged_actions.shape[0] != n_samples:
1260+
raise ValueError("logged_actions length must match rewards length.")
1261+
1262+
if np.any(self.logged_actions < 0) or np.any(self.logged_actions >= n_actions):
1263+
raise ValueError("logged_actions contains invalid action indices.")
1264+
1265+
def _get_stepwise_importance_ratios(self) -> np.ndarray:
1266+
"""Get the step-wise importance ratios.
1267+
1268+
:return: Step-wise importance ratios, shape `(n_episodes, steps_per_episode)`.
1269+
"""
1270+
1271+
n_samples = self.rewards.shape[0]
1272+
n_episodes = n_samples // self.steps_per_episode
1273+
1274+
if self.importance_ratios is not None:
1275+
rho = np.asarray(self.importance_ratios, dtype=np.float32)
1276+
if rho.ndim == 1:
1277+
rho = rho.reshape(n_episodes, self.steps_per_episode)
1278+
return rho
1279+
1280+
idx = np.arange(n_samples, dtype=np.int64)
1281+
actions = np.asarray(self.logged_actions, dtype=np.int64).reshape(-1)
1282+
1283+
p_e_taken = self.target_policy_action_probabilities[idx, actions].astype(np.float32)
1284+
p_b_taken = self.behavior_policy_action_probabilities[idx, actions].astype(np.float32)
1285+
1286+
rho = p_e_taken / np.maximum(p_b_taken, self.eps)
1287+
1288+
if self.clip is not None:
1289+
rho = np.clip(rho, 1.0 / self.clip, self.clip)
1290+
1291+
return rho.reshape(n_episodes, self.steps_per_episode)
1292+
1293+
@override(BaseEstimator)
1294+
def estimate_weighted_rewards(self) -> np.ndarray:
1295+
"""Estimate episode-level sequential DR contributions.
1296+
1297+
:return: Episode-level DR estimates, shape `(n_episodes, 1)`.
1298+
"""
1299+
self.check_parameters()
1300+
1301+
# shape: (n_episodes, horizon)
1302+
rewards = np.asarray(self.rewards, dtype=np.float32).reshape(-1, self.steps_per_episode)
1303+
# shape: (n_samples, n_actions)
1304+
q_values = np.asarray(self.q_values, dtype=np.float32)
1305+
# shape: (n_episodes, horizon)
1306+
rho = self._get_stepwise_importance_ratios()
1307+
1308+
n_episodes, horizon = rewards.shape
1309+
n_samples = n_episodes * horizon
1310+
1311+
# flat index arrays used for advanced indexing into q_values
1312+
logged_actions = np.asarray(self.logged_actions, dtype=np.int64).reshape(
1313+
-1
1314+
) # shape: (n_samples,)
1315+
idx = np.arange(n_samples, dtype=np.int64) # shape: (n_samples,)
1316+
1317+
# Q-value of the action actually taken at each (episode, timestep)
1318+
# shape: (n_episodes, horizon)
1319+
q_logged = q_values[idx, logged_actions].reshape(n_episodes, horizon)
1320+
1321+
# V(s) = Σ_a π_e(a|s) * Q(s, a) — expected value under the target policy
1322+
# shape: (n_episodes, horizon)
1323+
v_values = np.sum(
1324+
self.target_policy_action_probabilities * q_values,
1325+
axis=1,
1326+
).reshape(n_episodes, horizon)
1327+
1328+
# V(s_{t+1}): shift v_values one step forward; terminal step gets 0
1329+
# shape: (n_episodes, horizon)
1330+
v_next = np.zeros_like(v_values)
1331+
v_next[:, :-1] = v_values[:, 1:]
1332+
v_next[:, -1] = 0.0
1333+
1334+
# W_{i,t} = Π_{k=0}^{t} ρ_{i,k} — cumulative importance weights up to each timestep
1335+
# shape: (n_episodes, horizon)
1336+
cumulative_weights = np.cumprod(rho, axis=1)
1337+
1338+
# Initialise each episode's estimate with V(s_0), the model's prediction at the initial state
1339+
# shape: (n_episodes,)
1340+
episode_estimates = v_values[:, 0].copy()
1341+
1342+
# Accumulate TD-style correction terms across timesteps:
1343+
# δ_t = r_t + γ * V(s_{t+1}) - Q(s_t, a_t) (residual / advantage at step t)
1344+
# episode_estimate += W_{i,t} * δ_t (weighted by cumulative IS ratio)
1345+
for t in range(horizon):
1346+
# shape: (n_episodes,)
1347+
td_correction = rewards[:, t] + self.discount_factor * v_next[:, t] - q_logged[:, t]
1348+
episode_estimates += cumulative_weights[:, t] * td_correction
1349+
1350+
# shape: (n_episodes, 1)
1351+
return episode_estimates.reshape(-1, 1).astype(np.float32)
1352+
1353+
@override(BaseEstimator)
1354+
def estimate_policy_value(self) -> float:
1355+
"""Estimate the value of the target policy."""
1356+
return float(np.mean(self.estimate_weighted_rewards()))

0 commit comments

Comments
 (0)