Skip to main content

thrust_rl/train/bc/
dataset.rs

1//! Fixed dataset of expert demonstrations for Behavioral Cloning.
2//!
3//! [`Demonstrations`] is a small, flat supervised dataset of discrete-action
4//! demonstrations: a contiguous block of observations paired with the expert
5//! action taken at each. It is intentionally NOT a reuse of
6//! [`crate::buffer::rollout::RolloutBuffer`] or
7//! [`crate::buffer::replay::ReplayBuffer`], which carry rewards, dones,
8//! advantages, and priorities that supervised imitation has no use for (BC
9//! epic #161, decision 2).
10//!
11//! Seeded minibatching reuses PPO's
12//! [`generate_minibatch_indices_with_rng`](crate::train::ppo::loss::generate_minibatch_indices_with_rng)
13//! so the BC trainer (#167) can produce bit-reproducible shuffles from a
14//! [`BcConfig`](crate::train::bc::BcConfig) seed.
15//!
16//! This is the dataset half of PR A of the BC decomposition (#164). A
17//! `from_file` constructor can be layered on later without disturbing the
18//! in-memory API.
19
20use anyhow::{Result, anyhow};
21use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
22
23/// A fixed dataset of expert `(observation, action)` pairs for supervised
24/// imitation learning.
25///
26/// Observations are stored as a single flat row-major `Vec<f32>` of shape
27/// `[len, obs_dim]`; the discrete expert action for example `i` lives at
28/// `actions[i]`. The dataset is immutable once constructed and serves
29/// host-side minibatches by index via [`Demonstrations::batch`].
30#[derive(Debug, Clone)]
31pub struct Demonstrations {
32    /// Flat row-major observation buffer of shape `[len, obs_dim]`.
33    obs: Vec<f32>,
34    /// Discrete expert action per example, length `len`.
35    actions: Vec<i64>,
36    /// Dimensionality of a single observation.
37    obs_dim: usize,
38    /// Number of `(observation, action)` examples.
39    len: usize,
40}
41
42impl Demonstrations {
43    /// Build a dataset from a flat observation buffer and a parallel action
44    /// vector.
45    ///
46    /// `obs` must be row-major of length `len * obs_dim` and `actions` must
47    /// have length `len`, where `len` is inferred from `actions`. Returns an
48    /// `Err` if the lengths are inconsistent or `obs_dim` is zero.
49    ///
50    /// # Arguments
51    ///
52    /// * `obs` - Flat row-major observations of shape `[len, obs_dim]`.
53    /// * `actions` - Expert action index per example.
54    /// * `obs_dim` - Dimensionality of a single observation.
55    pub fn new(obs: Vec<f32>, actions: Vec<i64>, obs_dim: usize) -> Result<Self> {
56        if obs_dim == 0 {
57            return Err(anyhow!("obs_dim must be positive"));
58        }
59        let len = actions.len();
60        if obs.len() != len * obs_dim {
61            return Err(anyhow!(
62                "obs length {} does not match actions.len() ({}) * obs_dim ({}) = {}",
63                obs.len(),
64                len,
65                obs_dim,
66                len * obs_dim
67            ));
68        }
69        Ok(Self { obs, actions, obs_dim, len })
70    }
71
72    /// Number of `(observation, action)` examples in the dataset.
73    pub fn len(&self) -> usize {
74        self.len
75    }
76
77    /// Whether the dataset contains no examples.
78    pub fn is_empty(&self) -> bool {
79        self.len == 0
80    }
81
82    /// Dimensionality of a single observation.
83    pub fn obs_dim(&self) -> usize {
84        self.obs_dim
85    }
86
87    /// Gather the given example indices into a minibatch of tensors.
88    ///
89    /// Returns `(obs, actions)` where `obs` has shape `[k, obs_dim]` and
90    /// `actions` has shape `[k]`, with `k = indices.len()`. Gathering is done
91    /// host-side; the resulting tensors are plain inputs/labels with no
92    /// gradient tracking concerns.
93    ///
94    /// # Panics
95    ///
96    /// Panics if any index is out of bounds (`>= len`).
97    ///
98    /// # Arguments
99    ///
100    /// * `indices` - Example indices to gather into the minibatch.
101    /// * `device` - Backend device the tensors are allocated on.
102    pub fn batch<B: Backend>(
103        &self,
104        indices: &[usize],
105        device: &B::Device,
106    ) -> (Tensor<B, 2>, Tensor<B, 1, Int>) {
107        let k = indices.len();
108        let mut obs_data = Vec::with_capacity(k * self.obs_dim);
109        let mut action_data = Vec::with_capacity(k);
110
111        for &i in indices {
112            assert!(i < self.len, "index {} out of bounds for dataset of len {}", i, self.len);
113            let start = i * self.obs_dim;
114            obs_data.extend_from_slice(&self.obs[start..start + self.obs_dim]);
115            action_data.push(self.actions[i]);
116        }
117
118        let obs = Tensor::<B, 2>::from_data(TensorData::new(obs_data, [k, self.obs_dim]), device);
119        let actions = Tensor::<B, 1, Int>::from_data(TensorData::new(action_data, [k]), device);
120        (obs, actions)
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use burn::backend::{Autodiff, NdArray};
127
128    use super::*;
129    use crate::train::ppo::loss::generate_minibatch_indices_with_rng;
130
131    type B = Autodiff<NdArray<f32>>;
132
133    fn sample_dataset() -> Demonstrations {
134        // 4 examples, obs_dim = 2.
135        let obs = vec![
136            0.0, 0.1, // ex 0
137            1.0, 1.1, // ex 1
138            2.0, 2.1, // ex 2
139            3.0, 3.1, // ex 3
140        ];
141        let actions = vec![0i64, 1, 0, 1];
142        Demonstrations::new(obs, actions, 2).unwrap()
143    }
144
145    #[test]
146    fn test_new_validates_dimensions() {
147        // Valid: 4 actions * obs_dim 2 == 8 floats.
148        assert!(Demonstrations::new(vec![0.0; 8], vec![0i64; 4], 2).is_ok());
149
150        // obs too short.
151        assert!(Demonstrations::new(vec![0.0; 6], vec![0i64; 4], 2).is_err());
152
153        // obs too long.
154        assert!(Demonstrations::new(vec![0.0; 10], vec![0i64; 4], 2).is_err());
155
156        // obs_dim zero rejected.
157        assert!(Demonstrations::new(vec![], vec![], 0).is_err());
158    }
159
160    #[test]
161    fn test_len_and_is_empty() {
162        let ds = sample_dataset();
163        assert_eq!(ds.len(), 4);
164        assert_eq!(ds.obs_dim(), 2);
165        assert!(!ds.is_empty());
166
167        let empty = Demonstrations::new(vec![], vec![], 2).unwrap();
168        assert_eq!(empty.len(), 0);
169        assert!(empty.is_empty());
170    }
171
172    #[test]
173    fn test_batch_shapes_and_contents() {
174        let ds = sample_dataset();
175        let device = Default::default();
176
177        let indices = [2usize, 1];
178        let (obs, actions) = ds.batch::<B>(&indices, &device);
179
180        assert_eq!(obs.dims(), [2, 2]);
181        assert_eq!(actions.dims(), [2]);
182
183        let obs_vals: Vec<f32> = obs.into_data().to_vec().unwrap();
184        // Row 0 -> example 2, row 1 -> example 1.
185        assert_eq!(obs_vals, vec![2.0, 2.1, 1.0, 1.1]);
186
187        let action_vals: Vec<i64> = actions.into_data().to_vec().unwrap();
188        // actions[2] == 0, actions[1] == 1.
189        assert_eq!(action_vals, vec![0, 1]);
190    }
191
192    #[test]
193    fn test_seeded_minibatch_shuffle_is_reproducible() {
194        use rand::{SeedableRng, rngs::StdRng};
195
196        let ds = sample_dataset();
197
198        let mut rng_a = StdRng::seed_from_u64(7);
199        let batches_a = generate_minibatch_indices_with_rng(ds.len(), 2, &mut rng_a);
200
201        let mut rng_b = StdRng::seed_from_u64(7);
202        let batches_b = generate_minibatch_indices_with_rng(ds.len(), 2, &mut rng_b);
203
204        // Same seed -> identical minibatch partition.
205        assert_eq!(batches_a, batches_b);
206
207        // Every example index appears exactly once across the partition.
208        let mut seen: Vec<usize> = batches_a.iter().flatten().copied().collect();
209        seen.sort_unstable();
210        assert_eq!(seen, vec![0, 1, 2, 3]);
211    }
212}