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}