Skip to main content

thrust_rl/buffer/replay/
sampling.rs

1//! Uniform random sampling and tensor conversion for the replay buffer.
2//!
3//! `sample` draws `batch_size` independent uniform indices from the
4//! filled portion of a [`super::storage::ReplayBuffer`] and copies the
5//! transitions into flat CPU-side vectors. `ReplayBatch::to_burn_tensors`
6//! then stacks those vectors into Burn tensors on the requested device so
7//! the trainer can run a single Q-network forward pass.
8
9use burn::tensor::{Int, Tensor as BurnTensor, TensorData, backend::Backend};
10use rand::Rng;
11
12use super::storage::ReplayBuffer;
13
14/// One minibatch sampled from the replay buffer.
15///
16/// All fields are CPU-side primitive vectors; convert to Burn tensors
17/// via [`ReplayBatch::to_burn_tensors`] when handing them to the trainer.
18#[derive(Debug, Clone)]
19pub struct ReplayBatch {
20    /// Flattened current observations, shape `[batch_size * obs_dim]`.
21    pub observations: Vec<f32>,
22    /// Actions taken, length `batch_size`.
23    pub actions: Vec<i64>,
24    /// Rewards received, length `batch_size`.
25    pub rewards: Vec<f32>,
26    /// Flattened next observations, shape `[batch_size * obs_dim]`.
27    pub next_observations: Vec<f32>,
28    /// Episode-end mask, length `batch_size`. `true` means the transition
29    /// terminated the episode (so the TD target drops the bootstrap term).
30    pub dones: Vec<bool>,
31    /// Length of one observation slice (so `to_tensors` can reshape).
32    pub obs_dim: usize,
33}
34
35impl ReplayBatch {
36    /// Number of transitions in the batch.
37    pub fn len(&self) -> usize {
38        self.actions.len()
39    }
40
41    /// `true` if the batch is empty.
42    pub fn is_empty(&self) -> bool {
43        self.actions.is_empty()
44    }
45
46    /// Stack the batch into Burn tensors on `device`.
47    ///
48    /// Returns a named [`ReplayBurnTensors`] struct so trainers can
49    /// pattern-match named fields rather than positional tuple elements.
50    ///
51    /// Shapes (all on `device`):
52    /// - `observations`: `[batch, obs_dim]`, `f32`
53    /// - `actions`: `[batch]`, `i64` (Burn `Int` kind)
54    /// - `rewards`: `[batch]`, `f32`
55    /// - `next_observations`: `[batch, obs_dim]`, `f32`
56    /// - `dones`: `[batch]`, `f32` (0.0 / 1.0; the TD-target formula `(1 -
57    ///   done)` consumes it directly as a float mask).
58    pub fn to_burn_tensors<B: Backend>(&self, device: &B::Device) -> ReplayBurnTensors<B> {
59        let batch = self.len();
60        let obs_dim = self.obs_dim;
61
62        // Direct rank-2 construction (vs. rank-1 + reshape) to keep the
63        // empty-batch case panic-free; the reshape path trips an internal
64        // shape assertion in cubecl-zspace when both dims are zero.
65        let observations = BurnTensor::<B, 2>::from_data(
66            TensorData::new(self.observations.clone(), [batch, obs_dim]),
67            device,
68        );
69        let next_observations = BurnTensor::<B, 2>::from_data(
70            TensorData::new(self.next_observations.clone(), [batch, obs_dim]),
71            device,
72        );
73        let actions = BurnTensor::<B, 1, Int>::from_data(
74            TensorData::new(self.actions.clone(), [batch]),
75            device,
76        );
77        let rewards =
78            BurnTensor::<B, 1>::from_data(TensorData::new(self.rewards.clone(), [batch]), device);
79        let dones_f: Vec<f32> = self.dones.iter().map(|&d| if d { 1.0 } else { 0.0 }).collect();
80        let dones = BurnTensor::<B, 1>::from_data(TensorData::new(dones_f, [batch]), device);
81
82        ReplayBurnTensors { observations, actions, rewards, next_observations, dones }
83    }
84}
85
86/// Bundle of Burn tensors produced by [`ReplayBatch::to_burn_tensors`].
87///
88/// Fields are in the order DQN trainers consume them: state and action
89/// first, then the reward signal, then the bootstrap state and terminal
90/// mask used to build the TD target. Fields are named so downstream
91/// patches that add fields stay source-compatible.
92#[derive(Debug)]
93pub struct ReplayBurnTensors<B: Backend> {
94    /// Observations, shape `[batch, obs_dim]`, dtype `f32`.
95    pub observations: BurnTensor<B, 2>,
96    /// Discrete actions, shape `[batch]`, dtype `i64`.
97    pub actions: BurnTensor<B, 1, Int>,
98    /// Rewards, shape `[batch]`, dtype `f32`.
99    pub rewards: BurnTensor<B, 1>,
100    /// Bootstrap-state observations, shape `[batch, obs_dim]`, dtype `f32`.
101    pub next_observations: BurnTensor<B, 2>,
102    /// Episode-terminal mask (0.0 or 1.0), shape `[batch]`, dtype `f32`.
103    /// Kept as `f32` so the TD-target formula `(1 - done)` works directly.
104    pub dones: BurnTensor<B, 1>,
105}
106
107/// Sample `batch_size` transitions uniformly with replacement from the
108/// filled portion of `buffer`.
109///
110/// Uniform with-replacement matches the canonical DQN recipe and is
111/// what makes the sampler O(1) per draw. For small batches relative to
112/// buffer size the difference vs without-replacement is negligible.
113///
114/// # Panics
115/// Panics if `buffer.is_empty()` or `batch_size == 0`.
116pub fn sample<R: Rng>(buffer: &ReplayBuffer, batch_size: usize, rng: &mut R) -> ReplayBatch {
117    assert!(!buffer.is_empty(), "ReplayBuffer is empty; cannot sample");
118    assert!(batch_size > 0, "batch_size must be > 0");
119
120    let obs_dim = buffer.obs_dim();
121    let len = buffer.len();
122
123    let mut observations = vec![0.0f32; batch_size * obs_dim];
124    let mut next_observations = vec![0.0f32; batch_size * obs_dim];
125    let mut actions = Vec::with_capacity(batch_size);
126    let mut rewards = Vec::with_capacity(batch_size);
127    let mut dones = Vec::with_capacity(batch_size);
128
129    for k in 0..batch_size {
130        let idx = rng.random_range(0..len);
131        let obs_slice = &mut observations[k * obs_dim..(k + 1) * obs_dim];
132        let next_slice = &mut next_observations[k * obs_dim..(k + 1) * obs_dim];
133        let (a, r, d) = buffer.read_into(idx, obs_slice, next_slice);
134        actions.push(a);
135        rewards.push(r);
136        dones.push(d);
137    }
138
139    ReplayBatch { observations, actions, rewards, next_observations, dones, obs_dim }
140}
141
142#[cfg(test)]
143mod tests {
144    use rand::{SeedableRng, rngs::StdRng};
145
146    use super::*;
147
148    #[test]
149    fn test_sample_returns_correct_count() {
150        let mut buf = ReplayBuffer::new(16, 3);
151        for i in 0..10 {
152            buf.push(
153                &[i as f32, i as f32 + 0.1, i as f32 + 0.2],
154                (i % 2) as i64,
155                i as f32,
156                &[(i + 1) as f32, (i + 1) as f32 + 0.1, (i + 1) as f32 + 0.2],
157                false,
158            );
159        }
160        let mut rng = StdRng::seed_from_u64(42);
161        let batch = sample(&buf, 5, &mut rng);
162        assert_eq!(batch.len(), 5);
163        assert_eq!(batch.actions.len(), 5);
164        assert_eq!(batch.rewards.len(), 5);
165        assert_eq!(batch.dones.len(), 5);
166        assert_eq!(batch.observations.len(), 5 * 3);
167        assert_eq!(batch.next_observations.len(), 5 * 3);
168        assert_eq!(batch.obs_dim, 3);
169    }
170
171    #[test]
172    fn test_sampled_values_match_pushed_values() {
173        // Push a single transition; every sample must return it.
174        let mut buf = ReplayBuffer::new(8, 2);
175        buf.push(&[7.0, 8.0], 1, 42.0, &[9.0, 10.0], true);
176
177        let mut rng = StdRng::seed_from_u64(0);
178        let batch = sample(&buf, 4, &mut rng);
179        for k in 0..4 {
180            assert_eq!(batch.actions[k], 1);
181            assert_eq!(batch.rewards[k], 42.0);
182            assert!(batch.dones[k]);
183            assert_eq!(&batch.observations[k * 2..(k + 1) * 2], &[7.0, 8.0]);
184            assert_eq!(&batch.next_observations[k * 2..(k + 1) * 2], &[9.0, 10.0]);
185        }
186    }
187
188    mod burn_tests {
189        use burn::backend::NdArray;
190
191        use super::*;
192
193        type B = NdArray<f32>;
194
195        #[test]
196        fn test_to_burn_tensors_shapes_and_roundtrip() {
197            let mut buf = ReplayBuffer::new(8, 4);
198            for i in 0..6 {
199                buf.push(&[i as f32; 4], (i % 2) as i64, i as f32, &[i as f32 + 1.0; 4], i == 5);
200            }
201            let mut rng = StdRng::seed_from_u64(1);
202            let batch = sample(&buf, 3, &mut rng);
203            let device = crate::utils::cuda::default_burn_device::<B>();
204            let t = batch.to_burn_tensors::<B>(&device);
205
206            // Shapes.
207            assert_eq!(t.observations.dims(), [3, 4]);
208            assert_eq!(t.next_observations.dims(), [3, 4]);
209            assert_eq!(t.actions.dims(), [3]);
210            assert_eq!(t.rewards.dims(), [3]);
211            assert_eq!(t.dones.dims(), [3]);
212
213            // Round-trip: copying out the host data should match the
214            // CPU-side `Vec`s the batch was built from. Observations are
215            // reshaped to `[3, 4]` but the underlying buffer order is the
216            // same row-major flatten.
217            let obs_flat: Vec<f32> = t.observations.into_data().to_vec().unwrap();
218            assert_eq!(obs_flat, batch.observations);
219            let next_flat: Vec<f32> = t.next_observations.into_data().to_vec().unwrap();
220            assert_eq!(next_flat, batch.next_observations);
221            let acts: Vec<i64> = t.actions.into_data().to_vec().unwrap();
222            assert_eq!(acts, batch.actions);
223            let rews: Vec<f32> = t.rewards.into_data().to_vec().unwrap();
224            assert_eq!(rews, batch.rewards);
225            let dones_f: Vec<f32> = t.dones.into_data().to_vec().unwrap();
226            let expected_dones: Vec<f32> =
227                batch.dones.iter().map(|&d| if d { 1.0 } else { 0.0 }).collect();
228            assert_eq!(dones_f, expected_dones);
229        }
230
231        #[test]
232        fn test_to_burn_tensors_empty_batch_does_not_panic() {
233            // A 0-row batch must produce well-formed zero-element tensors.
234            // The buffer-side `sample` API doesn't accept batch_size == 0,
235            // so build the batch by hand to exercise the edge case.
236            let batch = ReplayBatch {
237                observations: vec![],
238                actions: vec![],
239                rewards: vec![],
240                next_observations: vec![],
241                dones: vec![],
242                obs_dim: 4,
243            };
244            let device = crate::utils::cuda::default_burn_device::<B>();
245            let t = batch.to_burn_tensors::<B>(&device);
246            assert_eq!(t.observations.dims(), [0, 4]);
247            assert_eq!(t.next_observations.dims(), [0, 4]);
248            assert_eq!(t.actions.dims(), [0]);
249            assert_eq!(t.rewards.dims(), [0]);
250            assert_eq!(t.dones.dims(), [0]);
251        }
252    }
253
254    #[test]
255    #[should_panic(expected = "ReplayBuffer is empty")]
256    fn test_sample_empty_panics() {
257        let buf = ReplayBuffer::new(4, 2);
258        let mut rng = StdRng::seed_from_u64(0);
259        let _ = sample(&buf, 2, &mut rng);
260    }
261
262    #[test]
263    #[should_panic(expected = "batch_size must be > 0")]
264    fn test_zero_batch_size_panics() {
265        let mut buf = ReplayBuffer::new(4, 2);
266        buf.push(&[0.0, 0.0], 0, 0.0, &[0.0, 0.0], false);
267        let mut rng = StdRng::seed_from_u64(0);
268        let _ = sample(&buf, 0, &mut rng);
269    }
270}