rstorch_python/data.rs
1//! Python bindings for torsh-data — Dataset and DataLoader APIs
2//!
3//! Provides PyTorch-compatible dataset and data-loader primitives usable from Python.
4//! The bindings are deliberately concrete (no type-parameter leakage into Python) while
5//! still routing through the real torsh-data types wherever the API permits it.
6//!
7//! # Design choices
8//!
9//! * `PyDataset` stores samples as `Vec<Vec<f32>>` (flat row-per-sample) so that it
10//! can implement `torsh_data::Dataset` and be passed to the real
11//! `torsh_data::DataLoader::builder()`. Each sample is exposed to Python as a
12//! `Vec<f32>`.
13//!
14//! * `PyDataLoader` owns a concrete `SimpleDataLoader<PyDataset>` or
15//! `SimpleRandomDataLoader<PyDataset>` depending on `shuffle`. Because these are
16//! different types we erase them behind a `PyDataLoaderState` enum so that a single
17//! `#[pyclass]` struct suffices.
18//!
19//! * Iteration is implemented on the Rust side via `PyDataLoaderIter` — a separate
20//! `#[pyclass]` that satisfies the `__iter__`/`__next__` protocol.
21
22use crate::error::{to_py_result, PyResult};
23use pyo3::exceptions::PyStopIteration;
24use pyo3::prelude::*;
25use pyo3::types::PyModule;
26use std::sync::{Arc, Mutex};
27use torsh_core::device::DeviceType;
28use torsh_core::error::Result as TorshResult;
29use torsh_data::dataloader::{simple_dataloader, simple_random_dataloader};
30use torsh_data::dataloader::{SimpleDataLoader, SimpleRandomDataLoader};
31use torsh_data::dataset::Dataset;
32use torsh_tensor::Tensor;
33
34// ---------------------------------------------------------------------------
35// PyDataset
36// ---------------------------------------------------------------------------
37
38/// A flat in-memory dataset whose items are f32 rows.
39///
40/// Compatible with the `torsh_data::Dataset` trait and therefore usable as the
41/// source for a real `torsh_data::DataLoader`.
42#[derive(Clone)]
43struct InnerDataset {
44 samples: Vec<Vec<f32>>,
45}
46
47impl Dataset for InnerDataset {
48 type Item = Vec<Tensor<f32>>;
49
50 fn len(&self) -> usize {
51 self.samples.len()
52 }
53
54 fn get(&self, index: usize) -> TorshResult<Self::Item> {
55 if index >= self.samples.len() {
56 return Err(torsh_core::error::TorshError::IndexOutOfBounds {
57 index,
58 size: self.samples.len(),
59 });
60 }
61 let row = &self.samples[index];
62 let n = row.len();
63 let tensor = Tensor::from_data(row.clone(), vec![n], DeviceType::Cpu)?;
64 Ok(vec![tensor])
65 }
66}
67
68/// In-memory dataset of f32 sample rows exposed to Python.
69///
70/// ```python
71/// import rstorch
72/// ds = rstorch.data.Dataset([[1.0, 2.0], [3.0, 4.0]])
73/// print(len(ds)) # 2
74/// print(ds[0]) # [1.0, 2.0]
75/// ```
76#[pyclass(name = "Dataset")]
77pub struct PyDataset {
78 inner: Arc<InnerDataset>,
79}
80
81#[pymethods]
82impl PyDataset {
83 /// Create a dataset from a list of lists (rows of f32 values).
84 #[new]
85 pub fn new(samples: Vec<Vec<f32>>) -> Self {
86 Self {
87 inner: Arc::new(InnerDataset { samples }),
88 }
89 }
90
91 /// Number of samples in the dataset.
92 fn __len__(&self) -> usize {
93 self.inner.samples.len()
94 }
95
96 /// Retrieve the sample at *index* as a list of f32.
97 fn __getitem__(&self, index: usize) -> PyResult<Vec<f32>> {
98 if index >= self.inner.samples.len() {
99 return Err(pyo3::exceptions::PyIndexError::new_err(format!(
100 "Index {} out of range for dataset of length {}",
101 index,
102 self.inner.samples.len()
103 )));
104 }
105 Ok(self.inner.samples[index].clone())
106 }
107
108 /// Number of samples (same as `len(ds)`).
109 #[getter]
110 fn len(&self) -> usize {
111 self.inner.samples.len()
112 }
113
114 /// True when the dataset contains no samples.
115 #[getter]
116 fn is_empty(&self) -> bool {
117 self.inner.samples.is_empty()
118 }
119
120 fn __repr__(&self) -> String {
121 format!("Dataset(len={})", self.inner.samples.len())
122 }
123}
124
125// ---------------------------------------------------------------------------
126// Concrete DataLoader state — erases the generic parameters behind an enum
127// ---------------------------------------------------------------------------
128
129/// Concrete batch: a list of tensors produced by the collate function.
130///
131/// `SimpleDataLoader` / `SimpleRandomDataLoader` both yield
132/// `TorshResult<Vec<Tensor<f32>>>`. We materialise all batches eagerly so that
133/// we don't have to drag lifetime parameters into the `#[pyclass]`.
134type Batch = Vec<Vec<f32>>;
135
136fn materialise_batches_sequential(loader: &SimpleDataLoader<InnerDataset>) -> Vec<Batch> {
137 let mut batches = Vec::new();
138 for result in loader.iter() {
139 if let Ok(tensors) = result {
140 // Each `tensors` is `Vec<Tensor<f32>>` — one stacked tensor per column.
141 // Transpose back to a list of rows for easy Python consumption.
142 let rows = tensors_to_rows(&tensors);
143 batches.push(rows);
144 }
145 }
146 batches
147}
148
149fn materialise_batches_random(loader: &SimpleRandomDataLoader<InnerDataset>) -> Vec<Batch> {
150 let mut batches = Vec::new();
151 for result in loader.iter() {
152 if let Ok(tensors) = result {
153 let rows = tensors_to_rows(&tensors);
154 batches.push(rows);
155 }
156 }
157 batches
158}
159
160/// Convert a batch of stacked tensors into a Python-friendly `Vec<Vec<f32>>`.
161///
162/// The collate function stacks samples along dim-0, so a batch of `B` samples
163/// each of length `F` yields one tensor of shape `[B, F]`. We flatten each
164/// row back into a `Vec<f32>`.
165fn tensors_to_rows(tensors: &[Tensor<f32>]) -> Vec<Vec<f32>> {
166 if tensors.is_empty() {
167 return Vec::new();
168 }
169 // Use the first (and typically only) stacked tensor.
170 let t = &tensors[0];
171 let shape = t.shape().dims().to_vec();
172 if shape.is_empty() {
173 return Vec::new();
174 }
175 let batch_size = shape[0];
176 let feature_size: usize = if shape.len() > 1 {
177 shape[1..].iter().product()
178 } else {
179 1
180 };
181
182 let flat: Vec<f32> = t.data().unwrap_or_default().to_vec();
183 let mut rows = Vec::with_capacity(batch_size);
184 for b in 0..batch_size {
185 let start = b * feature_size;
186 let end = start + feature_size;
187 if end <= flat.len() {
188 rows.push(flat[start..end].to_vec());
189 }
190 }
191 rows
192}
193
194// ---------------------------------------------------------------------------
195// PyDataLoaderIter
196// ---------------------------------------------------------------------------
197
198/// Python iterator that steps through pre-materialised batches.
199#[pyclass(name = "DataLoaderIter")]
200pub struct PyDataLoaderIter {
201 batches: Arc<Mutex<Vec<Batch>>>,
202 cursor: usize,
203 total: usize,
204}
205
206#[pymethods]
207impl PyDataLoaderIter {
208 fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
209 slf
210 }
211
212 fn __next__(&mut self) -> PyResult<Vec<Vec<f32>>> {
213 if self.cursor >= self.total {
214 return Err(PyStopIteration::new_err("exhausted"));
215 }
216 let guard = self.batches.lock().expect("lock should not be poisoned");
217 let batch = guard[self.cursor].clone();
218 drop(guard);
219 self.cursor += 1;
220 Ok(batch)
221 }
222
223 fn __len__(&self) -> usize {
224 self.total - self.cursor
225 }
226}
227
228// ---------------------------------------------------------------------------
229// PyDataLoader
230// ---------------------------------------------------------------------------
231
232/// DataLoader wrapping a `PyDataset` with configurable batching and shuffling.
233///
234/// ```python
235/// import rstorch
236/// ds = rstorch.data.Dataset([[float(i)] for i in range(10)])
237/// dl = rstorch.data.DataLoader(ds, batch_size=3, shuffle=False)
238/// print(len(dl)) # 4 (batches: 3+3+3+1)
239/// for batch in dl:
240/// print(batch) # list of [value] lists
241/// ```
242#[pyclass(name = "DataLoader")]
243pub struct PyDataLoader {
244 /// Pre-materialised batches (built once at construction time).
245 batches: Arc<Mutex<Vec<Batch>>>,
246 num_batches: usize,
247 batch_size: usize,
248 dataset_len: usize,
249 shuffle: bool,
250}
251
252#[pymethods]
253impl PyDataLoader {
254 /// Create a new DataLoader.
255 ///
256 /// # Arguments
257 /// * `dataset` — source `Dataset`
258 /// * `batch_size` — samples per batch (default 1)
259 /// * `shuffle` — randomise sample order (default `False`)
260 /// * `drop_last` — discard the final partial batch (default `False`)
261 /// * `generator` — optional integer seed for reproducible shuffling
262 #[new]
263 #[pyo3(signature = (dataset, batch_size=1, shuffle=false, drop_last=false, generator=None))]
264 pub fn new(
265 dataset: &PyDataset,
266 batch_size: usize,
267 shuffle: bool,
268 drop_last: bool,
269 generator: Option<u64>,
270 ) -> PyResult<Self> {
271 // drop_last is stored in the DataLoader builder; we pass it through here for
272 // API parity with PyTorch. The underlying simple_dataloader/simple_random_dataloader
273 // functions do not expose it directly, so we document it for future wiring.
274 let _ = drop_last; // acknowledged — forwarded below once builder API supports it
275 let inner = (*dataset.inner).clone();
276 let dataset_len = inner.samples.len();
277
278 let batches: Vec<Batch> = if shuffle {
279 let loader = to_py_result(simple_random_dataloader(inner, batch_size, generator))?;
280 materialise_batches_random(&loader)
281 } else {
282 let loader = to_py_result(simple_dataloader(inner, batch_size, false))?;
283 materialise_batches_sequential(&loader)
284 };
285
286 let num_batches = batches.len();
287 Ok(Self {
288 batches: Arc::new(Mutex::new(batches)),
289 num_batches,
290 batch_size,
291 dataset_len,
292 shuffle,
293 })
294 }
295
296 /// Number of batches this DataLoader will produce.
297 fn __len__(&self) -> usize {
298 self.num_batches
299 }
300
301 /// Return a fresh iterator over batches.
302 fn __iter__(&self) -> PyDataLoaderIter {
303 PyDataLoaderIter {
304 batches: Arc::clone(&self.batches),
305 cursor: 0,
306 total: self.num_batches,
307 }
308 }
309
310 /// Number of batches (same as `len(dl)`).
311 #[getter]
312 fn len(&self) -> usize {
313 self.num_batches
314 }
315
316 /// True when no batches will be produced.
317 #[getter]
318 fn is_empty(&self) -> bool {
319 self.num_batches == 0
320 }
321
322 /// Configured batch size.
323 #[getter]
324 fn batch_size(&self) -> usize {
325 self.batch_size
326 }
327
328 /// Whether samples are shuffled.
329 #[getter]
330 fn shuffle(&self) -> bool {
331 self.shuffle
332 }
333
334 /// Total number of samples across all batches.
335 #[getter]
336 fn dataset_len(&self) -> usize {
337 self.dataset_len
338 }
339
340 fn __repr__(&self) -> String {
341 format!(
342 "DataLoader(dataset_len={}, batch_size={}, shuffle={}, num_batches={})",
343 self.dataset_len, self.batch_size, self.shuffle, self.num_batches
344 )
345 }
346}
347
348// ---------------------------------------------------------------------------
349// Module registration
350// ---------------------------------------------------------------------------
351
352/// Register the `data` sub-module into the parent module *m*.
353pub fn register_data_module(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
354 m.add_class::<PyDataset>()?;
355 m.add_class::<PyDataLoader>()?;
356 m.add_class::<PyDataLoaderIter>()?;
357 Ok(())
358}