|
| 1 | +# Plan: Add Last Step Action as Input to LSTM in IQN and PPO |
| 2 | + |
| 3 | +## Overview |
| 4 | +This plan outlines the changes needed to concatenate the last step action with gridworld feature extractions before feeding them into the LSTM in both IQN and PPO implementations. |
| 5 | + |
| 6 | +## Current Architecture |
| 7 | + |
| 8 | +### IQN (RecurrentIQNModelCPC) |
| 9 | +- **Current flow**: `o_t -> encoder -> z_t -> LSTM -> h_t -> IQN head` |
| 10 | +- **LSTM input**: Only encoded observation features `z_t` (shape: `hidden_size`) |
| 11 | +- **Actions**: Stored in buffer but not used as LSTM input |
| 12 | + |
| 13 | +### PPO (RecurrentPPOLSTM / RecurrentPPOLSTMCPC) |
| 14 | +- **Current flow**: `o_t -> encoder -> z_t -> LSTM -> h_t -> Actor/Critic` |
| 15 | +- **LSTM input**: Only encoded observation features `z_t` (shape: `hidden_size`) |
| 16 | +- **Actions**: Stored in rollout memory but not used as LSTM input |
| 17 | + |
| 18 | +## Proposed Architecture |
| 19 | + |
| 20 | +### Modified Flow |
| 21 | +- **New flow**: `o_t -> encoder -> z_t` + `a_{t-1} -> embed -> a_emb` -> `concat([z_t, a_emb]) -> LSTM -> h_t -> heads` |
| 22 | +- **LSTM input**: Concatenated features `[z_t, a_emb]` (shape: `hidden_size + action_embed_dim`) |
| 23 | + |
| 24 | +## Implementation Plan |
| 25 | + |
| 26 | +### 1. Action Embedding Strategy |
| 27 | + |
| 28 | +**Option A: One-Hot Encoding (Simple)** |
| 29 | +- Convert action index to one-hot vector (shape: `action_space`) |
| 30 | +- Pros: Simple, no learnable parameters, interpretable |
| 31 | +- Cons: Sparse, doesn't scale well with large action spaces |
| 32 | + |
| 33 | +**Option B: Learned Embedding (Recommended)** |
| 34 | +- Use `nn.Embedding(action_space, action_embed_dim)` |
| 35 | +- Pros: Compact, learnable, scales well |
| 36 | +- Cons: Adds parameters |
| 37 | + |
| 38 | +**Recommendation**: Use learned embedding with `action_embed_dim = min(32, action_space // 2)` to balance expressiveness and efficiency. |
| 39 | + |
| 40 | +### 2. Architecture Changes |
| 41 | + |
| 42 | +#### 2.1 Add Action Embedding Layer |
| 43 | +```python |
| 44 | +# In __init__: |
| 45 | +self.action_embed_dim = min(32, action_space // 2) # or configurable |
| 46 | +self.action_embedding = nn.Embedding(action_space, self.action_embed_dim) |
| 47 | +``` |
| 48 | + |
| 49 | +#### 2.2 Modify LSTM Input Dimension |
| 50 | +```python |
| 51 | +# Current: LSTM(hidden_size, hidden_size) |
| 52 | +# New: LSTM(hidden_size + action_embed_dim, hidden_size) |
| 53 | +# NOTE: IQN uses batch_first=False, PPO uses batch_first=True |
| 54 | +self.lstm = nn.LSTM( |
| 55 | + hidden_size + self.action_embed_dim, # Input size increased |
| 56 | + hidden_size, # Hidden size unchanged |
| 57 | + batch_first=False # IQN: False, PPO: True (keep existing setting) |
| 58 | +) |
| 59 | +``` |
| 60 | + |
| 61 | +#### 2.3 Track Last Action |
| 62 | +- Add `self._last_action: Optional[int] = None` to track previous action |
| 63 | +- Reset to `None` at episode start (when `done=True`) |
| 64 | + |
| 65 | +### 3. Code Changes by File |
| 66 | + |
| 67 | +#### 3.1 IQN: `recurrent_iqn_lstm_cpc_fixed.py` |
| 68 | + |
| 69 | +**Changes in `__init__`:** |
| 70 | +1. Add action embedding layer |
| 71 | +2. Increase LSTM input size |
| 72 | +3. Initialize `self._last_action = None` |
| 73 | + |
| 74 | +**Changes in `take_action()`:** |
| 75 | +1. Get last action from `self._last_action` (or None if first step) |
| 76 | +2. Embed last action (use zero embedding if None) |
| 77 | +3. Concatenate `z_t` with `a_emb` before LSTM |
| 78 | +4. **CRITICAL**: Store the action returned from `base_model.take_action()` as `self._last_action` for the NEXT step |
| 79 | + - The action is selected AFTER LSTM forward pass, so we store it for next call |
| 80 | + |
| 81 | +**Changes in `add_memory()`:** |
| 82 | +1. Reset `self._last_action = None` when `done=True` |
| 83 | + |
| 84 | +**Changes in `train_step()`:** |
| 85 | +1. **Action shifting logic**: |
| 86 | + - For burn-in: Use `actions_t[:, :burn_in]` (these are the actions taken at each step) |
| 87 | + - For unroll: Need PREVIOUS actions, so use `actions_t[:, burn_in-1:burn_in+unroll]` |
| 88 | + - First step of unroll uses last action from burn-in: `actions_t[:, burn_in-1]` |
| 89 | + - Handle first step of each sequence: Use zero embedding (action index 0 or zero vector) |
| 90 | +2. Embed actions: `prev_actions = actions_t[:, max(0, burn_in-1):burn_in+unroll]` with zeros prepended for first step |
| 91 | +3. Concatenate encoded states with action embeddings before LSTM |
| 92 | +4. **Important**: Actions at index `t` correspond to the action taken AFTER observing state `t`, so for LSTM input at step `t`, we need action from step `t-1` |
| 93 | + |
| 94 | +**Changes in `_recompute_sequence_with_gradients()`:** |
| 95 | +1. **Add actions parameter**: `raw_states: List[np.ndarray], actions: List[int]` |
| 96 | +2. **Action shifting**: For state at index `t`, use action from index `t-1` (previous action) |
| 97 | + - First step (t=0): Use zero embedding |
| 98 | + - Subsequent steps: Use `actions[t-1]` |
| 99 | +3. Embed actions (or zeros for first step) |
| 100 | +4. Concatenate with encoded features before LSTM |
| 101 | + |
| 102 | +**Changes in `_compute_cpc_loss()`:** |
| 103 | +1. Extract actions from episode: `episode['actions']` |
| 104 | +2. Pass actions to `_recompute_sequence_with_gradients()`: `_recompute_sequence_with_gradients(raw_states, actions)` |
| 105 | +3. Handle action shifting: For state at index `t`, use action from index `t-1` (first step uses zero) |
| 106 | + |
| 107 | +#### 3.2 PPO: `recurrent_ppo_lstm_generic.py` |
| 108 | + |
| 109 | +**Changes in `__init__`:** |
| 110 | +1. Add action embedding layer |
| 111 | +2. Increase LSTM input size |
| 112 | +3. Initialize `self._last_action = None` |
| 113 | + |
| 114 | +**Changes in `take_action()`:** |
| 115 | +1. Get last action from `self._last_action` (or None if first step) |
| 116 | +2. Embed last action (use zero embedding if None) |
| 117 | +3. Concatenate encoded features with `a_emb` before LSTM in `_forward_base()` |
| 118 | +4. **CRITICAL**: Store the action returned from policy sampling as `self._last_action` for the NEXT step |
| 119 | + - Action is selected AFTER LSTM forward pass, so store it for next call |
| 120 | + |
| 121 | +**Changes in `add_memory_ppo()` / `store_memory()`:** |
| 122 | +1. Reset `self._last_action = None` when `done=True` |
| 123 | + |
| 124 | +**Changes in `learn()`:** |
| 125 | +1. **CRITICAL**: PPO currently uses stored `h_states` from rollout. With action inputs, we must RECOMPUTE LSTM states during training (similar to how IQN does it) |
| 126 | +2. **Rollout Memory Structure**: |
| 127 | + - `rollout_memory["states"][i]`: State observed at step `i` |
| 128 | + - `rollout_memory["actions"][i]`: Action taken at step `i` (AFTER observing state `i`) |
| 129 | + - `rollout_memory["dones"][i]`: Whether episode ended at step `i` |
| 130 | + - For LSTM input at step `i`, we need action from step `i-1` (previous action) |
| 131 | +3. Extract previous actions from rollout memory: |
| 132 | + - Create `prev_actions` array: `prev_actions[0] = 0` (or None), `prev_actions[i] = actions[i-1]` for i > 0 |
| 133 | + - Handle episode boundaries: When `dones[i-1] == True`, set `prev_actions[i] = 0` (new episode) |
| 134 | +4. **Recompute LSTM states** (don't use stored `h_states`): |
| 135 | + - Process states through encoder + action embedding + LSTM sequentially |
| 136 | + - For each epoch in `K_epochs`, recompute from scratch (proper BPTT) |
| 137 | + - Initialize hidden state at start of sequence: `h0, c0 = zeros` |
| 138 | + - Process sequence: For each state `i`, use `prev_actions[i]` for LSTM input |
| 139 | +5. Update `_forward_base()` to accept optional action parameter |
| 140 | +6. In minibatch loop: Process states sequentially with their corresponding previous actions |
| 141 | + |
| 142 | +#### 3.3 PPO with CPC: `recurrent_ppo_lstm_cpc.py` |
| 143 | + |
| 144 | +**Same changes as `recurrent_ppo_lstm_generic.py` plus:** |
| 145 | +1. Update `_recompute_sequence_with_gradients()` to accept and handle actions parameter |
| 146 | +2. Update CPC loss computation to extract actions from rollout and pass to `_recompute_sequence_with_gradients()` |
| 147 | +3. Handle action shifting in CPC sequence recomputation (same as IQN) |
| 148 | + |
| 149 | +### 4. Edge Cases and Special Handling |
| 150 | + |
| 151 | +#### 4.1 First Step of Episode |
| 152 | +- **Problem**: No previous action exists |
| 153 | +- **Solution**: Use zero embedding or special "no-action" token |
| 154 | + - Option 1: `action_embedding(torch.zeros(..., dtype=torch.long))` (action 0) |
| 155 | + - Option 2: `torch.zeros(action_embed_dim)` (zero vector) |
| 156 | + - **Recommendation**: Use zero vector for simplicity |
| 157 | + |
| 158 | +#### 4.2 Episode Boundaries |
| 159 | +- **Problem**: Action from previous episode shouldn't influence new episode |
| 160 | +- **Solution**: Reset `self._last_action = None` when `done=True` |
| 161 | +- In training: Detect episode boundaries using `dones` tensor and reset action embeddings |
| 162 | + |
| 163 | +#### 4.3 Batch Processing |
| 164 | +- **Problem**: Different episodes in batch may have different lengths |
| 165 | +- **Solution**: |
| 166 | + - In burn-in: Use zero embeddings for first step of each sequence |
| 167 | + - In unroll: Shift actions by 1 timestep: `prev_actions = actions[:, burn_in-1:burn_in+unroll]` with zeros prepended for first step |
| 168 | + - **Episode boundaries in batches**: When `dones[i-1] == True`, the next step should use zero embedding (new episode started) |
| 169 | + - For IQN: Handle this in the sequence sampling (EpisodeBuffer should handle episode boundaries) |
| 170 | + - For PPO: Detect episode boundaries from `dones` tensor and reset action embeddings accordingly |
| 171 | + |
| 172 | +### 5. Implementation Details |
| 173 | + |
| 174 | +#### 5.1 Action Embedding Function |
| 175 | +```python |
| 176 | +def _embed_action(self, action: int | torch.Tensor | None, batch_size: int = 1) -> torch.Tensor: |
| 177 | + """Embed action index to vector representation. |
| 178 | + |
| 179 | + Args: |
| 180 | + action: Action index (int), tensor of action indices, or None (for first step) |
| 181 | + batch_size: Batch size for batched operations (default: 1 for single step) |
| 182 | + |
| 183 | + Returns: |
| 184 | + Action embedding tensor of shape (batch_size, action_embed_dim) |
| 185 | + """ |
| 186 | + # Handle None (first step) - use zero vector |
| 187 | + if action is None: |
| 188 | + return torch.zeros(batch_size, self.action_embed_dim, device=self.device) |
| 189 | + |
| 190 | + # Handle single integer |
| 191 | + if isinstance(action, int): |
| 192 | + action_tensor = torch.tensor([action], device=self.device, dtype=torch.long) |
| 193 | + return self.action_embedding(action_tensor) |
| 194 | + |
| 195 | + # Handle tensor (can be batched) |
| 196 | + action_tensor = action.to(self.device).long() |
| 197 | + # Clamp to valid range [0, action_space-1] to avoid index errors |
| 198 | + action_tensor = torch.clamp(action_tensor, 0, self.action_space - 1) |
| 199 | + return self.action_embedding(action_tensor) |
| 200 | +``` |
| 201 | + |
| 202 | +#### 5.2 Concatenation Pattern |
| 203 | + |
| 204 | +**IQN (batch_first=False):** |
| 205 | +```python |
| 206 | +# During acting: |
| 207 | +z_t = self.encoder(frame_t) # (1, hidden_size) |
| 208 | +a_emb = self._embed_action(self._last_action, batch_size=1) # (1, action_embed_dim) |
| 209 | +lstm_input = torch.cat([z_t, a_emb], dim=-1) # (1, hidden_size + action_embed_dim) |
| 210 | +lstm_input_seq = lstm_input.unsqueeze(0) # (1, 1, hidden_size + action_embed_dim) for LSTM |
| 211 | +lstm_out, hidden = self.lstm(lstm_input_seq, hidden) # batch_first=False |
| 212 | + |
| 213 | +# During training (batched, IQN): |
| 214 | +z_seq = self.encoder(states_flat) # (B*L, hidden_size) |
| 215 | +prev_actions = ... # (B*L,) - shifted actions (or zeros for first step) |
| 216 | +a_emb_seq = self._embed_action(prev_actions, batch_size=B*L) # (B*L, action_embed_dim) |
| 217 | +lstm_input_seq = torch.cat([z_seq, a_emb_seq], dim=-1) # (B*L, hidden_size + action_embed_dim) |
| 218 | +# Reshape for LSTM: (L, B, hidden_size + action_embed_dim) |
| 219 | +lstm_input_reshaped = lstm_input_seq.view(B, L, -1).permute(1, 0, 2) |
| 220 | +lstm_out, _ = self.lstm(lstm_input_reshaped, hidden) |
| 221 | +``` |
| 222 | + |
| 223 | +**PPO (batch_first=True):** |
| 224 | +```python |
| 225 | +# During acting: |
| 226 | +z_t = self.encoder(state_tensor) # (1, hidden_size) after encoder |
| 227 | +a_emb = self._embed_action(self._last_action, batch_size=1) # (1, action_embed_dim) |
| 228 | +lstm_input = torch.cat([z_t, a_emb], dim=-1) # (1, hidden_size + action_embed_dim) |
| 229 | +lstm_input_seq = lstm_input.unsqueeze(1) # (1, 1, hidden_size + action_embed_dim) for LSTM |
| 230 | +lstm_out, hidden = self.lstm(lstm_input_seq, hidden) # batch_first=True |
| 231 | + |
| 232 | +# During training (batched, PPO): |
| 233 | +# Process each minibatch sequentially or in parallel |
| 234 | +# For each state at index i, use action from index i-1 |
| 235 | +z_batch = self.encoder(mb_states) # (B, hidden_size) |
| 236 | +prev_actions = ... # (B,) - previous actions for each sample |
| 237 | +a_emb_batch = self._embed_action(prev_actions, batch_size=B) # (B, action_embed_dim) |
| 238 | +lstm_input_batch = torch.cat([z_batch, a_emb_batch], dim=-1) # (B, hidden_size + action_embed_dim) |
| 239 | +lstm_input_seq = lstm_input_batch.unsqueeze(1) # (B, 1, hidden_size + action_embed_dim) |
| 240 | +lstm_out, _ = self.lstm(lstm_input_seq, (mb_h, mb_c)) # batch_first=True |
| 241 | +``` |
| 242 | + |
| 243 | +### 6. Testing Considerations |
| 244 | + |
| 245 | +1. **Backward Compatibility**: Ensure existing code still works (make action embedding optional via flag) |
| 246 | +2. **Gradient Flow**: Verify gradients flow through action embedding to encoder and LSTM |
| 247 | +3. **Memory**: Check that action tracking doesn't leak memory |
| 248 | +4. **Episode Boundaries**: Test that actions reset correctly at episode boundaries (both in acting and training) |
| 249 | +5. **First Step**: Verify zero embedding works correctly for first step of episodes |
| 250 | +6. **Action Shifting**: Verify that actions are correctly shifted (action at step t-1 used for LSTM input at step t) |
| 251 | +7. **Batch Processing**: Test with batches containing multiple episodes with different lengths |
| 252 | +8. **PPO State Recomputation**: Verify that PPO correctly recomputes LSTM states during training (not using stored states) |
| 253 | +9. **CPC Integration**: Test that CPC loss computation works correctly with action-aware LSTM states |
| 254 | + |
| 255 | +### 7. Configuration Options |
| 256 | + |
| 257 | +Add optional parameter to enable/disable action input: |
| 258 | +```python |
| 259 | +def __init__( |
| 260 | + ... |
| 261 | + use_action_input: bool = True, # New parameter |
| 262 | + action_embed_dim: Optional[int] = None, # Auto if None |
| 263 | + ... |
| 264 | +): |
| 265 | + self.use_action_input = use_action_input |
| 266 | + if use_action_input: |
| 267 | + # Add action embedding and modify LSTM |
| 268 | + else: |
| 269 | + # Keep original architecture |
| 270 | +``` |
| 271 | + |
| 272 | +### 8. Migration Path |
| 273 | + |
| 274 | +1. **Phase 1**: Implement with `use_action_input=False` by default (backward compatible) |
| 275 | +2. **Phase 2**: Test with `use_action_input=True` on small experiments |
| 276 | +3. **Phase 3**: Enable by default after validation |
| 277 | + |
| 278 | +## Files to Modify |
| 279 | + |
| 280 | +1. `sorrel/models/pytorch/recurrent_iqn_lstm_cpc_fixed.py` |
| 281 | + - Add action embedding |
| 282 | + - Modify LSTM input size |
| 283 | + - Update `take_action()`, `train_step()`, `_recompute_sequence_with_gradients()` |
| 284 | + |
| 285 | +2. `sorrel/models/pytorch/recurrent_ppo_lstm_generic.py` |
| 286 | + - Add action embedding |
| 287 | + - Modify LSTM input size |
| 288 | + - Update `take_action()`, `learn()`, `_forward_base()` |
| 289 | + |
| 290 | +3. `sorrel/models/pytorch/recurrent_ppo_lstm_cpc.py` |
| 291 | + - Same as above plus CPC-specific updates |
| 292 | + |
| 293 | +## Summary |
| 294 | + |
| 295 | +This plan adds the last step action as input to the LSTM by: |
| 296 | +1. Creating an action embedding layer (learned embedding recommended) |
| 297 | +2. Concatenating action embeddings with encoded observations before LSTM |
| 298 | +3. Tracking last action during acting (store action AFTER selection for next step) |
| 299 | +4. Handling action shifting in training (action at step t-1 for LSTM input at step t) |
| 300 | +5. Handling edge cases (first step uses zero embedding, episode boundaries reset actions) |
| 301 | +6. **PPO-specific**: Recomputing LSTM states during training (not using stored states) |
| 302 | +7. Maintaining backward compatibility with optional flag |
| 303 | + |
| 304 | +## Critical Implementation Notes |
| 305 | + |
| 306 | +1. **Action Timing**: Actions are taken AFTER observing state, so for LSTM input at step `t`, we use action from step `t-1` |
| 307 | +2. **PPO State Recomputation**: PPO must recompute LSTM states during training when using action inputs (can't use stored `h_states`) |
| 308 | +3. **Batch First Difference**: IQN uses `batch_first=False`, PPO uses `batch_first=True` - account for this in reshaping |
| 309 | +4. **Episode Boundaries**: When `done=True`, next step should use zero embedding (new episode started) |
| 310 | +5. **First Step**: Always use zero embedding for the first step of each episode/sequence |
| 311 | + |
| 312 | +The changes are focused but require careful handling of action timing and state recomputation, especially for PPO. |
| 313 | + |
0 commit comments