Skip to main content

dataset_ml/
digits.rs

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