Skip to main content

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}