dataset_ml/digits.rs
1//! Optical Recognition of Handwritten Digits dataset.
2//!
3//! The classic digits dataset for multi-class classification, identical to the
4//! one bundled with scikit-learn as `load_digits`. Each sample is an 8×8 image of
5//! a handwritten digit, flattened into 64 integer pixel intensities in the range
6//! `0..=16`. The task is to recognise which digit (`0`–`9`) the image shows.
7//!
8//! This reproduces scikit-learn's `load_digits` output: scikit-learn uses the
9//! **test** partition (`optdigits.tes`) of the UCI archive, which holds exactly
10//! 1797 samples.
11//!
12//! **Features (64):** `pixel_0_0` … `pixel_7_7` - the 8×8 image flattened in
13//! row-major order, each an integer pixel intensity in `0..=16` (stored as `f64`).
14//!
15//! **Target:** `digit` - the handwritten digit, one of `0`–`9` (stored as `u8`).
16//!
17//! **Samples:** 1797 total (roughly 180 per digit class)
18//! **Application:** Multi-class classification / handwritten digit recognition
19//!
20//! **Source:** UCI Machine Learning Repository
21//! <https://doi.org/10.24432/C50P49>
22
23use csv::ReaderBuilder;
24use dataset_core::{Dataset, DatasetError, acquire_dataset, download_to, unzip};
25use ndarray::{Array1, Array2};
26use std::fs::File;
27
28/// The URL for the Optical Recognition of Handwritten Digits dataset.
29///
30/// This is the UCI static package; it is a ZIP archive containing several files,
31/// of which only the `optdigits.tes` test partition is used (matching scikit-learn).
32///
33/// # Citation
34///
35/// E. Alpaydin and C. Kaynak. "Optical Recognition of Handwritten Digits," UCI
36/// Machine Learning Repository, \[Online\].
37/// Available: <https://doi.org/10.24432/C50P49>
38const DIGITS_DATA_URL: &str =
39 "https://archive.ics.uci.edu/static/public/80/optical+recognition+of+handwritten+digits.zip";
40
41/// The name the downloaded ZIP archive is saved under inside the temp directory.
42const DIGITS_ZIP_FILENAME: &str = "optdigits.zip";
43
44/// The name of the file inside the archive that scikit-learn's `load_digits` uses
45/// (the test partition, 1797 samples).
46const DIGITS_SOURCE_FILENAME: &str = "optdigits.tes";
47
48/// The name of the final cached Digits dataset file.
49const DIGITS_FILENAME: &str = "digits.csv";
50
51/// The SHA256 hash of the Digits dataset file (`optdigits.tes`).
52const DIGITS_SHA256: &str = "6ebb3d2fee246a4e99363262ddf8a00a3c41bee6014c373ed9d9216ba7f651b8";
53
54/// The name of the dataset
55const DIGITS_DATASET_NAME: &str = "digits";
56
57/// The number of pixel features per sample (an 8×8 image flattened to 64 values).
58const N_FEATURES: usize = 64;
59
60/// The number of columns per CSV record (64 pixels + 1 label).
61const N_COLUMNS: usize = N_FEATURES + 1;
62
63/// Type alias for the Digits dataset: (features, labels).
64type DigitsData = (Array2<f64>, Array1<u8>);
65
66/// A struct representing the Digits dataset with lazy loading.
67///
68/// The dataset is not loaded until you call one of the data accessor methods.
69/// Once loaded, the data is cached for subsequent accesses.
70///
71/// # About Dataset
72///
73/// The Optical Recognition of Handwritten Digits dataset contains 8×8 grayscale
74/// images of handwritten digits. Each image is flattened into 64 pixel intensities
75/// in the range `0..=16`, and the target is the digit (`0`–`9`) the image depicts.
76///
77/// This is the same data scikit-learn exposes through `load_digits`: it uses the
78/// test partition (`optdigits.tes`) of the UCI archive, with 1797 samples.
79///
80/// # Feature columns
81///
82/// The 64 features are the pixels of an 8×8 grayscale image, flattened in
83/// row-major order. Each pixel holds an integer intensity in `0..=16` stored as
84/// `f64`. By 0-based column index:
85///
86/// | Columns | Attributes | Unit |
87/// |-----------|---------------------------------------------|----------------------|
88/// | `0..=7` | row 0 pixels (`pixel_0_0` .. `pixel_0_7`) | intensity (`0..=16`) |
89/// | `8..=15` | row 1 pixels (`pixel_1_0` .. `pixel_1_7`) | intensity (`0..=16`) |
90/// | `16..=23` | row 2 pixels (`pixel_2_0` .. `pixel_2_7`) | intensity (`0..=16`) |
91/// | `24..=31` | row 3 pixels (`pixel_3_0` .. `pixel_3_7`) | intensity (`0..=16`) |
92/// | `32..=39` | row 4 pixels (`pixel_4_0` .. `pixel_4_7`) | intensity (`0..=16`) |
93/// | `40..=47` | row 5 pixels (`pixel_5_0` .. `pixel_5_7`) | intensity (`0..=16`) |
94/// | `48..=55` | row 6 pixels (`pixel_6_0` .. `pixel_6_7`) | intensity (`0..=16`) |
95/// | `56..=63` | row 7 pixels (`pixel_7_0` .. `pixel_7_7`) | intensity (`0..=16`) |
96///
97/// # Labels
98///
99/// - digit (in `u8`): `0`, `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`
100///
101/// See more information at
102/// <https://archive.ics.uci.edu/dataset/80/optical+recognition+of+handwritten+digits>
103///
104/// # Citation
105///
106/// E. Alpaydin and C. Kaynak. "Optical Recognition of Handwritten Digits," UCI
107/// Machine Learning Repository, \[Online\].
108/// Available: <https://doi.org/10.24432/C50P49>
109///
110/// # Thread Safety
111///
112/// This struct automatically implements `Send` and `Sync` (All fields implement them), making it safe to share across threads.
113/// The internal [`Dataset`] ensures thread-safe lazy initialization.
114///
115/// # Example
116/// ```no_run
117/// use dataset_ml::digits::Digits;
118///
119/// let download_dir = "./digits"; // the code will create the directory if it doesn't exist
120///
121/// let mut dataset = Digits::new(download_dir);
122/// let features = dataset.features().unwrap();
123/// let labels = dataset.labels().unwrap();
124///
125/// let (features, labels) = dataset.data().unwrap(); // this is also a way to get features and labels
126/// assert_eq!(features.shape(), &[1797, 64]);
127/// assert_eq!(labels.len(), 1797);
128///
129/// // `get_data()` borrows the cached arrays without reloading; `get_data_mut()`
130/// // edits them in place — no clone, no reload, the change stays cached. Prefer
131/// // this over cloning with `.to_owned()` when you only need to tweak values.
132/// if let Some((features, labels)) = dataset.get_data_mut() {
133/// features[[0, 0]] = 5.0;
134/// labels[0] = 7;
135/// }
136/// assert!(dataset.get_data().is_some());
137///
138/// // `take_data()` moves owned arrays out (no `to_owned()` clone) and leaves the
139/// // instance reusable — the next access reloads from the cached file.
140/// let (owned_features, owned_labels) = dataset.take_data().unwrap();
141/// assert_eq!(owned_features.shape(), &[1797, 64]);
142/// assert_eq!(owned_labels.len(), 1797);
143///
144/// // `into_data()` also returns owned arrays with no clone, but consumes the
145/// // instance (use it when you are done with the dataset).
146/// let (owned_features, owned_labels) = dataset.into_data().unwrap();
147/// assert_eq!(owned_features.shape(), &[1797, 64]);
148/// assert_eq!(owned_labels.len(), 1797);
149/// ```
150#[derive(Debug)]
151pub struct Digits {
152 dataset: Dataset<DigitsData, DatasetError>,
153}
154
155impl Digits {
156 /// Create a new Digits instance without loading data.
157 ///
158 /// The dataset will be loaded lazily when you first call any data accessor method.
159 /// This is a lightweight operation that only stores the storage directory.
160 ///
161 /// # Parameters
162 ///
163 /// - `storage_dir` - Directory where the dataset will be stored.
164 ///
165 /// # Returns
166 ///
167 /// - `Self` - `Digits` instance ready for lazy loading.
168 pub fn new(storage_dir: &str) -> Self {
169 Digits {
170 dataset: Dataset::new(storage_dir, Self::load_data),
171 }
172 }
173
174 /// Acquire and parse the Digits dataset.
175 fn load_data(dir: &str) -> Result<DigitsData, DatasetError> {
176 // Prepare the dataset file: download the UCI ZIP package, extract it, and
177 // surface the `optdigits.tes` test partition (which scikit-learn uses).
178 let file_path = acquire_dataset(
179 dir,
180 DIGITS_FILENAME,
181 DIGITS_DATASET_NAME,
182 Some(DIGITS_SHA256),
183 |temp_path| {
184 download_to(DIGITS_DATA_URL, temp_path, Some(DIGITS_ZIP_FILENAME))?;
185 unzip(&temp_path.join(DIGITS_ZIP_FILENAME), temp_path)?;
186 Ok(temp_path.join(DIGITS_SOURCE_FILENAME))
187 },
188 )?;
189
190 // `optdigits.tes` is a headerless comma-separated file: every line is a
191 // record of 64 pixel values followed by the digit label.
192 let file = File::open(&file_path)?;
193 let mut rdr = ReaderBuilder::new().has_headers(false).from_reader(file);
194
195 let mut features = Vec::new();
196 let mut labels = Vec::new();
197
198 for (idx, result) in rdr.records().enumerate() {
199 let record =
200 result.map_err(|e| DatasetError::csv_read_error(DIGITS_DATASET_NAME, e))?;
201 let line_num = idx + 1; // headerless file, lines are 1-indexed
202
203 if record.len() != N_COLUMNS {
204 return Err(DatasetError::invalid_column_count(
205 DIGITS_DATASET_NAME,
206 N_COLUMNS,
207 record.len(),
208 line_num,
209 ));
210 }
211
212 for (col, field) in record.iter().take(N_FEATURES).enumerate() {
213 let value: f64 = field.trim().parse().map_err(|e| {
214 DatasetError::parse_failed(
215 DIGITS_DATASET_NAME,
216 &format!("pixel_{}_{}", col / 8, col % 8),
217 line_num,
218 e,
219 )
220 })?;
221 features.push(value);
222 }
223
224 let raw_label = record[N_FEATURES].trim();
225 let label: u8 = raw_label.parse().map_err(|e| {
226 DatasetError::parse_failed(DIGITS_DATASET_NAME, "digit", line_num, e)
227 })?;
228 if label > 9 {
229 return Err(DatasetError::invalid_value(
230 DIGITS_DATASET_NAME,
231 "digit",
232 raw_label,
233 line_num,
234 ));
235 }
236 labels.push(label);
237 }
238
239 let n_samples = labels.len();
240 if n_samples == 0 {
241 return Err(DatasetError::empty_dataset(DIGITS_DATASET_NAME));
242 }
243
244 // Digits has a fixed schema of 64 numeric pixel features per sample.
245 let features_array = Array2::from_shape_vec((n_samples, N_FEATURES), features)
246 .map_err(|e| DatasetError::array_shape_error(DIGITS_DATASET_NAME, "features", e))?;
247 let labels_array = Array1::from_vec(labels);
248
249 Ok((features_array, labels_array))
250 }
251
252 /// Get a reference to the feature matrix.
253 ///
254 /// This method triggers lazy loading on first call. Subsequent calls return
255 /// the cached data instantly.
256 ///
257 /// # Returns
258 ///
259 /// - `&Array2<f64>` - Reference to feature matrix with shape `(1797, 64)`
260 /// containing the 64 pixel intensities (`pixel_0_0` … `pixel_7_7`, each in
261 /// `0..=16`) of each flattened 8×8 image.
262 ///
263 /// # Errors
264 ///
265 /// Returns `DatasetError` if:
266 /// - Download fails due to network issues
267 /// - File extraction or I/O operations fail
268 /// - Data format is invalid (wrong number of columns, unparseable values, or invalid labels)
269 /// - Dataset size doesn't match expected dimensions (1797 samples, 64 features)
270 pub fn features(&self) -> Result<&Array2<f64>, DatasetError> {
271 Ok(&self.dataset.load()?.0)
272 }
273
274 /// Get a reference to the labels vector.
275 ///
276 /// This method triggers lazy loading on first call. Subsequent calls return
277 /// the cached data instantly.
278 ///
279 /// # Returns
280 ///
281 /// - `&Array1<u8>` - Reference to labels vector with shape `(1797,)` containing the digit classes (`0`–`9`).
282 ///
283 /// # Errors
284 ///
285 /// Returns `DatasetError` if:
286 /// - Download fails due to network issues
287 /// - File extraction or I/O operations fail
288 /// - Data format is invalid (wrong number of columns, unparseable values, or invalid labels)
289 /// - Dataset size doesn't match expected dimensions (1797 samples)
290 pub fn labels(&self) -> Result<&Array1<u8>, DatasetError> {
291 Ok(&self.dataset.load()?.1)
292 }
293
294 /// Get both features and labels as references.
295 ///
296 /// This method triggers lazy loading on first call. Subsequent calls return
297 /// the cached data instantly.
298 ///
299 /// # Returns
300 ///
301 /// - `&DigitsData` - reference to the cached `(features, labels)` tuple: the
302 /// feature matrix has shape `(1797, 64)` and the label vector has shape
303 /// `(1797,)` containing the digit classes (`0`–`9`).
304 ///
305 /// # Errors
306 ///
307 /// Returns `DatasetError` if:
308 /// - Download fails due to network issues
309 /// - File extraction or I/O operations fail
310 /// - Data format is invalid (wrong number of columns, unparseable values, or invalid labels)
311 /// - Dataset size doesn't match expected dimensions (1797 samples, 64 features)
312 pub fn data(&self) -> Result<&DigitsData, DatasetError> {
313 self.dataset.load()
314 }
315
316 /// Get both features and labels as references **without** triggering loading.
317 ///
318 /// Unlike [`Digits::data`], which loads the dataset on first call, this never
319 /// runs the loader: if the data has not been loaded yet, it returns `None`
320 /// instead of downloading and parsing. Use it when you only want the data if
321 /// it is already cached and want to avoid paying the download/parse cost
322 /// otherwise.
323 ///
324 /// # Returns
325 ///
326 /// - `Some(&DigitsData)` - reference to the cached `(features, labels)` tuple
327 /// (feature matrix `(1797, 64)`, label vector `(1797,)`), if loaded.
328 /// - `None` - if the dataset has not been loaded yet.
329 pub fn get_data(&self) -> Option<&DigitsData> {
330 self.dataset.get()
331 }
332
333 /// Get mutable references to features and labels for **in-place** editing.
334 ///
335 /// This lets you modify the cached arrays directly (e.g. normalize features,
336 /// replace label values) with no `to_owned()` clone and without removing them
337 /// from the cache: the changes persist, so later [`Digits::features`],
338 /// [`Digits::data`], or [`Digits::get_data`] calls observe them.
339 ///
340 /// Like [`Digits::get_data`], this does **not** trigger loading: it returns
341 /// `None` if the dataset has not been loaded. Call a loading accessor (e.g.
342 /// [`Digits::data`]) first if you need to ensure the data is present.
343 ///
344 /// # Returns
345 ///
346 /// - `Some(&mut DigitsData)` - mutable reference to the cached
347 /// `(features, labels)` tuple (feature matrix `(1797, 64)`, label vector
348 /// `(1797,)`), if loaded.
349 /// - `None` - if the dataset has not been loaded yet.
350 pub fn get_data_mut(&mut self) -> Option<&mut DigitsData> {
351 self.dataset.get_mut()
352 }
353
354 /// Consume the dataset and return **owned** features and labels.
355 ///
356 /// Unlike [`Digits::data`], which borrows the cached data, this moves it out and
357 /// returns owned arrays directly — no `to_owned()` clone needed. The dataset is
358 /// loaded on first access if it has not been loaded yet.
359 ///
360 /// This **consumes** `self`, so the instance cannot be used afterwards. If you
361 /// want owned data but need to keep using the instance, use [`Digits::take_data`]
362 /// instead — it takes `&mut self` and leaves the instance reusable.
363 ///
364 /// # Returns
365 ///
366 /// - `(Array2<f64>, Array1<u8>)` - owned feature matrix with shape
367 /// `(1797, 64)` and owned label vector with shape `(1797,)`.
368 ///
369 /// # Errors
370 ///
371 /// Returns `DatasetError` if loading fails (network, file I/O, parsing, invalid
372 /// labels, or a dimension mismatch).
373 pub fn into_data(self) -> Result<DigitsData, DatasetError> {
374 self.dataset.load()?;
375 Ok(self
376 .dataset
377 .into_inner()
378 .expect("data is present after a successful load"))
379 }
380
381 /// Take **owned** features and labels out of the dataset, leaving it reusable.
382 ///
383 /// Like [`Digits::into_data`], this returns owned arrays with no `to_owned()`
384 /// clone. But instead of consuming the instance, it takes `&mut self` and moves
385 /// the cached data out, resetting the instance to its unloaded state: the next
386 /// accessor call (e.g. [`Digits::features`] or [`Digits::data`]) loads the
387 /// dataset again.
388 ///
389 /// Use [`Digits::into_data`] instead if you are done with the instance.
390 ///
391 /// # Returns
392 ///
393 /// - `(Array2<f64>, Array1<u8>)` - owned feature matrix with shape
394 /// `(1797, 64)` and owned label vector with shape `(1797,)`.
395 ///
396 /// # Errors
397 ///
398 /// Returns `DatasetError` if loading fails (network, file I/O, parsing, invalid
399 /// labels, or a dimension mismatch).
400 pub fn take_data(&mut self) -> Result<DigitsData, DatasetError> {
401 self.dataset.load()?;
402 Ok(self
403 .dataset
404 .take()
405 .expect("data is present after a successful load"))
406 }
407}