Skip to main content

dataset_ml/dataset/
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//! **Columns (65):** the 64 pixels of the image, flattened in row-major order,
14//! then the digit.
15//!
16//! | Name                        | Type      | Description                        |
17//! |-----------------------------|-----------|-------------------------------------|
18//! | `pixel_0_0` … `pixel_0_7`   | `Numeric` | row 0 pixel intensities (`0..=16`) |
19//! | `pixel_1_0` … `pixel_1_7`   | `Numeric` | row 1 pixel intensities (`0..=16`) |
20//! | `pixel_2_0` … `pixel_2_7`   | `Numeric` | row 2 pixel intensities (`0..=16`) |
21//! | `pixel_3_0` … `pixel_3_7`   | `Numeric` | row 3 pixel intensities (`0..=16`) |
22//! | `pixel_4_0` … `pixel_4_7`   | `Numeric` | row 4 pixel intensities (`0..=16`) |
23//! | `pixel_5_0` … `pixel_5_7`   | `Numeric` | row 5 pixel intensities (`0..=16`) |
24//! | `pixel_6_0` … `pixel_6_7`   | `Numeric` | row 6 pixel intensities (`0..=16`) |
25//! | `pixel_7_0` … `pixel_7_7`   | `Numeric` | row 7 pixel intensities (`0..=16`) |
26//! | `digit`                     | `Integer` | the handwritten digit, `0`–`9`     |
27//!
28//! The source designates the 64 pixel columns as the inputs
29//! ([`Digits::FEATURE_NAMES`](crate::Digits::FEATURE_NAMES)) and `digit` as the label ([`Digits::TARGET`](crate::Digits::TARGET)).
30//!
31//! **Samples:** 1797 total (roughly 180 per digit class)
32//! **Application:** Multi-class classification / handwritten digit recognition
33//!
34//! **Missing values:** none.
35//!
36//! **Source:** UCI Machine Learning Repository
37//! <https://doi.org/10.24432/C50P49>
38
39use crate::DOWNLOAD_RETRIES;
40use crate::table::{Column, ColumnData, Table};
41use crate::traits::impl_ml_dataset;
42use csv::ReaderBuilder;
43use dataset_core::{Dataset, DatasetError, acquire_dataset, download_to_with_retries, unzip};
44use ndarray::Array1;
45use std::fs::File;
46
47/// The URL for the Optical Recognition of Handwritten Digits dataset.
48///
49/// This is the UCI static package. It is a ZIP archive with several files. The
50/// loader uses only the `optdigits.tes` test partition, which matches
51/// scikit-learn.
52///
53/// # Citation
54///
55/// E. Alpaydin and C. Kaynak. "Optical Recognition of Handwritten Digits," UCI
56/// Machine Learning Repository, \[Online\].
57/// Available: <https://doi.org/10.24432/C50P49>
58const DIGITS_DATA_URL: &str =
59    "https://archive.ics.uci.edu/static/public/80/optical+recognition+of+handwritten+digits.zip";
60
61/// The loader saves the downloaded ZIP archive under this name inside the temp
62/// directory.
63const DIGITS_ZIP_FILENAME: &str = "optdigits.zip";
64
65/// The name of the file inside the archive that scikit-learn's `load_digits` uses
66/// (the test partition, 1797 samples).
67const DIGITS_SOURCE_FILENAME: &str = "optdigits.tes";
68
69/// The name of the final cached Digits dataset file.
70const DIGITS_FILENAME: &str = "digits.csv";
71
72/// The SHA256 hash of the Digits dataset file (`optdigits.tes`).
73const DIGITS_SHA256: &str = "6ebb3d2fee246a4e99363262ddf8a00a3c41bee6014c373ed9d9216ba7f651b8";
74
75/// The name of the dataset
76const DIGITS_DATASET_NAME: &str = "digits";
77
78/// The number of pixel features per sample (an 8×8 image flattened to 64 values).
79const N_FEATURES: usize = 64;
80
81/// The number of columns per CSV record (64 pixels + 1 label).
82const N_COLUMNS: usize = N_FEATURES + 1;
83
84/// A struct that represents the Digits dataset with lazy loading.
85///
86/// The dataset loads only when you call a data accessor method. After the first
87/// load, the dataset caches the data for later accesses.
88///
89/// # About Dataset
90///
91/// The Optical Recognition of Handwritten Digits dataset contains 8×8 grayscale
92/// images of handwritten digits. The source flattens each image into 64 pixel
93/// intensities in the range `0..=16`. The target is the digit (`0`–`9`) that the
94/// image shows.
95///
96/// This is the same data scikit-learn exposes through `load_digits`: it uses the
97/// test partition (`optdigits.tes`) of the UCI archive, with 1797 samples.
98///
99/// # Columns
100///
101/// The 64 pixel columns hold the 8×8 image, flattened in row-major order. The
102/// name of the pixel of row `r` and column `c` is `pixel_r_c`.
103///
104/// | Name                        | Type      | Description                        |
105/// |-----------------------------|-----------|-------------------------------------|
106/// | `pixel_0_0` … `pixel_0_7`   | `Numeric` | row 0 pixel intensities (`0..=16`) |
107/// | `pixel_1_0` … `pixel_1_7`   | `Numeric` | row 1 pixel intensities (`0..=16`) |
108/// | `pixel_2_0` … `pixel_2_7`   | `Numeric` | row 2 pixel intensities (`0..=16`) |
109/// | `pixel_3_0` … `pixel_3_7`   | `Numeric` | row 3 pixel intensities (`0..=16`) |
110/// | `pixel_4_0` … `pixel_4_7`   | `Numeric` | row 4 pixel intensities (`0..=16`) |
111/// | `pixel_5_0` … `pixel_5_7`   | `Numeric` | row 5 pixel intensities (`0..=16`) |
112/// | `pixel_6_0` … `pixel_6_7`   | `Numeric` | row 6 pixel intensities (`0..=16`) |
113/// | `pixel_7_0` … `pixel_7_7`   | `Numeric` | row 7 pixel intensities (`0..=16`) |
114/// | `digit`                     | `Integer` | the handwritten digit, `0`–`9`     |
115///
116/// The source designates the 64 pixel columns as the inputs
117/// ([`Digits::FEATURE_NAMES`]) and `digit` as the label ([`Digits::TARGET`]).
118///
119/// Missing values: none.
120///
121/// See more information at
122/// <https://archive.ics.uci.edu/dataset/80/optical+recognition+of+handwritten+digits>
123///
124/// # Citation
125///
126/// E. Alpaydin and C. Kaynak. "Optical Recognition of Handwritten Digits," UCI
127/// Machine Learning Repository, \[Online\].
128/// Available: <https://doi.org/10.24432/C50P49>
129///
130/// # Thread Safety
131///
132/// This struct implements `Send` and `Sync` automatically, because all fields
133/// implement them. This makes the struct safe to share across threads. The
134/// internal [`Dataset`] makes lazy initialization thread-safe.
135///
136/// # Example
137/// ```no_run
138/// use dataset_ml::Digits;
139///
140/// let download_dir = "./digits"; // the loader creates the directory if it does not exist
141///
142/// let mut dataset = Digits::new(download_dir);
143/// let table = dataset.data().unwrap();
144///
145/// assert_eq!(table.n_samples(), 1797);
146/// assert_eq!(table.n_columns(), 65);
147///
148/// // Ask for the feature matrix when you want it.
149/// let features = table.numeric_matrix(&Digits::FEATURE_NAMES).unwrap();
150/// assert_eq!(features.shape(), &[1797, 64]);
151///
152/// // Reach one column by name.
153/// let digit = table.column(Digits::TARGET).unwrap().as_integer().unwrap();
154/// assert_eq!(digit.len(), 1797);
155///
156/// // `get_data_mut()` edits the table in place. This needs no clone and no
157/// // reload. The change stays cached.
158/// if let Some(table) = dataset.get_data_mut() {
159///     if let Some(column) = table.column_mut("pixel_0_0") {
160///         if let dataset_ml::ColumnData::Numeric(values) = column.data_mut() {
161///             values[0] = 5.0;
162///         }
163///     }
164/// }
165/// assert!(dataset.get_data().is_some());
166///
167/// // `take_data()` moves the owned table out with no clone. This leaves the
168/// // instance reusable.
169/// let owned = dataset.take_data().unwrap();
170/// assert_eq!(owned.n_samples(), 1797);
171///
172/// // `into_data()` also returns the owned table with no clone, but it consumes
173/// // the instance.
174/// let owned = dataset.into_data().unwrap();
175/// assert_eq!(owned.n_samples(), 1797);
176/// ```
177#[derive(Debug)]
178pub struct Digits {
179    dataset: Dataset<Table, DatasetError>,
180}
181
182impl Digits {
183    /// The columns the source designates as the model inputs, in source order.
184    /// The name of the pixel of row `r` and column `c` of the 8×8 image is
185    /// `pixel_r_c`.
186    pub const FEATURE_NAMES: [&'static str; N_FEATURES] = [
187        "pixel_0_0",
188        "pixel_0_1",
189        "pixel_0_2",
190        "pixel_0_3",
191        "pixel_0_4",
192        "pixel_0_5",
193        "pixel_0_6",
194        "pixel_0_7",
195        "pixel_1_0",
196        "pixel_1_1",
197        "pixel_1_2",
198        "pixel_1_3",
199        "pixel_1_4",
200        "pixel_1_5",
201        "pixel_1_6",
202        "pixel_1_7",
203        "pixel_2_0",
204        "pixel_2_1",
205        "pixel_2_2",
206        "pixel_2_3",
207        "pixel_2_4",
208        "pixel_2_5",
209        "pixel_2_6",
210        "pixel_2_7",
211        "pixel_3_0",
212        "pixel_3_1",
213        "pixel_3_2",
214        "pixel_3_3",
215        "pixel_3_4",
216        "pixel_3_5",
217        "pixel_3_6",
218        "pixel_3_7",
219        "pixel_4_0",
220        "pixel_4_1",
221        "pixel_4_2",
222        "pixel_4_3",
223        "pixel_4_4",
224        "pixel_4_5",
225        "pixel_4_6",
226        "pixel_4_7",
227        "pixel_5_0",
228        "pixel_5_1",
229        "pixel_5_2",
230        "pixel_5_3",
231        "pixel_5_4",
232        "pixel_5_5",
233        "pixel_5_6",
234        "pixel_5_7",
235        "pixel_6_0",
236        "pixel_6_1",
237        "pixel_6_2",
238        "pixel_6_3",
239        "pixel_6_4",
240        "pixel_6_5",
241        "pixel_6_6",
242        "pixel_6_7",
243        "pixel_7_0",
244        "pixel_7_1",
245        "pixel_7_2",
246        "pixel_7_3",
247        "pixel_7_4",
248        "pixel_7_5",
249        "pixel_7_6",
250        "pixel_7_7",
251    ];
252
253    /// The column the source designates as the label.
254    pub const TARGET: &'static str = "digit";
255
256    /// Create a new Digits instance without loading data.
257    ///
258    /// The dataset loads lazily, on your first call to a data accessor method.
259    /// This is a lightweight operation that only stores the storage directory.
260    ///
261    /// # Parameters
262    ///
263    /// - `storage_dir` - The directory that stores the dataset.
264    ///
265    /// # Returns
266    ///
267    /// - `Self` - a `Digits` instance ready for lazy loading.
268    pub fn new(storage_dir: &str) -> Self {
269        Digits {
270            dataset: Dataset::new(storage_dir, Self::load_data),
271        }
272    }
273
274    /// Get and parse the Digits dataset.
275    fn load_data(dir: &str) -> Result<Table, DatasetError> {
276        // Prepare the dataset file: download the UCI ZIP package, extract it, and
277        // return the `optdigits.tes` test partition (which scikit-learn uses).
278        let file_path = acquire_dataset(
279            dir,
280            DIGITS_FILENAME,
281            DIGITS_DATASET_NAME,
282            Some(DIGITS_SHA256),
283            |temp_path| {
284                download_to_with_retries(
285                    DIGITS_DATA_URL,
286                    temp_path,
287                    Some(DIGITS_ZIP_FILENAME),
288                    DOWNLOAD_RETRIES,
289                )?;
290                unzip(&temp_path.join(DIGITS_ZIP_FILENAME), temp_path)?;
291                Ok(temp_path.join(DIGITS_SOURCE_FILENAME))
292            },
293        )?;
294
295        // `optdigits.tes` is a headerless comma-separated file: every line is a
296        // record of 64 pixel values followed by the digit label.
297        let file = File::open(&file_path)?;
298        let mut rdr = ReaderBuilder::new().has_headers(false).from_reader(file);
299
300        let mut features: Vec<Vec<f64>> = vec![Vec::new(); N_FEATURES];
301        let mut labels: Vec<i64> = Vec::new();
302
303        for (idx, result) in rdr.records().enumerate() {
304            let record =
305                result.map_err(|e| DatasetError::csv_read_error(DIGITS_DATASET_NAME, e))?;
306            let line_num = idx + 1; // headerless file, lines are 1-indexed
307
308            if record.len() != N_COLUMNS {
309                return Err(DatasetError::invalid_column_count(
310                    DIGITS_DATASET_NAME,
311                    N_COLUMNS,
312                    record.len(),
313                    line_num,
314                ));
315            }
316
317            for (col, field) in record.iter().take(N_FEATURES).enumerate() {
318                let value: f64 = field.trim().parse().map_err(|e| {
319                    DatasetError::parse_failed(
320                        DIGITS_DATASET_NAME,
321                        &format!("pixel_{}_{}", col / 8, col % 8),
322                        line_num,
323                        e,
324                    )
325                })?;
326                features[col].push(value);
327            }
328
329            let raw_label = record[N_FEATURES].trim();
330            let label: u8 = raw_label.parse().map_err(|e| {
331                DatasetError::parse_failed(DIGITS_DATASET_NAME, "digit", line_num, e)
332            })?;
333            if label > 9 {
334                return Err(DatasetError::invalid_value(
335                    DIGITS_DATASET_NAME,
336                    "digit",
337                    raw_label,
338                    line_num,
339                ));
340            }
341            labels.push(i64::from(label));
342        }
343
344        let mut columns: Vec<Column> = Vec::with_capacity(N_COLUMNS);
345        for (name, values) in Self::FEATURE_NAMES.into_iter().zip(features) {
346            columns.push(Column::new(
347                name,
348                ColumnData::Numeric(Array1::from_vec(values)),
349            ));
350        }
351        columns.push(Column::new(
352            Self::TARGET,
353            ColumnData::Integer(Array1::from_vec(labels)),
354        ));
355
356        Table::new(DIGITS_DATASET_NAME, columns)
357    }
358
359    /// Get a reference to the parsed table.
360    ///
361    /// This method triggers lazy loading on the first call. Later calls return
362    /// the cached data.
363    ///
364    /// # Returns
365    ///
366    /// - `&Table` - reference to the cached table of 1797 samples and 65
367    ///   columns.
368    ///
369    /// # Errors
370    ///
371    /// Returns `DatasetError` if:
372    /// - Download fails due to network issues
373    /// - File extraction or I/O operations fail
374    /// - Data format is invalid (wrong number of columns, unparseable values, or invalid labels)
375    pub fn data(&self) -> Result<&Table, DatasetError> {
376        self.dataset.load()
377    }
378
379    /// Get a reference to the parsed table **without** triggering loading.
380    ///
381    /// Unlike [`Digits::data`], this method never runs the loader. If the data
382    /// has not loaded yet, it returns `None` instead of downloading and parsing
383    /// it.
384    ///
385    /// # Returns
386    ///
387    /// - `Some(&Table)` - reference to the cached table, if loaded.
388    /// - `None` - if the dataset has not loaded yet.
389    pub fn get_data(&self) -> Option<&Table> {
390        self.dataset.get()
391    }
392
393    /// Get a mutable reference to the parsed table for **in-place** editing.
394    ///
395    /// This needs no clone, and it does not remove the data from the cache. The
396    /// changes stay in the cache. Later calls to [`Digits::data`] or
397    /// [`Digits::get_data`] see them.
398    ///
399    /// Like [`Digits::get_data`], this does **not** trigger loading.
400    ///
401    /// # Returns
402    ///
403    /// - `Some(&mut Table)` - mutable reference to the cached table, if loaded.
404    /// - `None` - if the dataset has not loaded yet.
405    pub fn get_data_mut(&mut self) -> Option<&mut Table> {
406        self.dataset.get_mut()
407    }
408
409    /// Consume the dataset and return the **owned** table.
410    ///
411    /// This **consumes** `self`. If you want owned data but need to keep using
412    /// the instance, use [`Digits::take_data`] instead.
413    ///
414    /// # Returns
415    ///
416    /// - `Table` - the owned table of 1797 samples and 65 columns.
417    ///
418    /// # Errors
419    ///
420    /// Returns `DatasetError` if loading fails (network, file I/O, or parsing).
421    pub fn into_data(self) -> Result<Table, DatasetError> {
422        self.dataset.load()?;
423        Ok(self
424            .dataset
425            .into_inner()
426            .expect("data is present after a successful load"))
427    }
428
429    /// Take the **owned** table out of the dataset. This leaves the instance
430    /// reusable.
431    ///
432    /// This resets the instance to its unloaded state. The next accessor call
433    /// loads the dataset again.
434    ///
435    /// # Returns
436    ///
437    /// - `Table` - the owned table of 1797 samples and 65 columns.
438    ///
439    /// # Errors
440    ///
441    /// Returns `DatasetError` if loading fails (network, file I/O, or parsing).
442    pub fn take_data(&mut self) -> Result<Table, DatasetError> {
443        self.dataset.load()?;
444        Ok(self
445            .dataset
446            .take()
447            .expect("data is present after a successful load"))
448    }
449}
450
451impl_ml_dataset!(Digits, "digits");