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