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");