|
6 | 6 |
|
7 | 7 | from hopes.assert_utils import check_array |
8 | 8 | from hopes.dev_utils import override |
9 | | -from hopes.rew.rewards import RegressionBasedRewardModel |
| 9 | +from hopes.rew.rewards import RegressionBasedRewardModel, RTGQModelHGBoost |
10 | 10 |
|
11 | 11 |
|
12 | 12 | class BaseEstimator(ABC): |
@@ -45,8 +45,8 @@ def set_importance_ratios(self, importance_ratios: np.ndarray | None) -> None: |
45 | 45 | stickiness correction outside the estimator. |
46 | 46 |
|
47 | 47 | 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. |
50 | 50 | """ |
51 | 51 |
|
52 | 52 | if importance_ratios is None: |
@@ -619,17 +619,17 @@ def compute_weighted_rewards( |
619 | 619 | step-wise ratios directly instead of recomputing them from policy probabilities. |
620 | 620 |
|
621 | 621 | :param target_policy_action_probabilities: Target policy action probabilities, |
622 | | - shape ``(n_samples, n_actions)``. |
| 622 | + shape `(n_samples, n_actions)`. |
623 | 623 | :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,)`. |
626 | 626 | :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]`. |
628 | 628 | :param is_per_decision: Whether to compute per-decision or trajectory-wise weighting. |
629 | 629 | :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)`. |
633 | 633 | """ |
634 | 634 |
|
635 | 635 | # rewards, shape: (n, T) |
@@ -1067,3 +1067,290 @@ def _bootstrap_sample_policy_value( |
1067 | 1067 | den = float(np.sum(W_b)) |
1068 | 1068 |
|
1069 | 1069 | 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