Skip to main content

dataset_ml/
banknote_authentication.rs

1//! Banknote Authentication dataset.
2//!
3//! The dataset holds features extracted from images of genuine and forged
4//! banknote-like specimens. Researchers digitized the images with an
5//! industrial camera normally used for print inspection. They then used a
6//! Wavelet Transform tool to derive four continuous statistics from each
7//! image. The task is to predict the class of a specimen from those four
8//! features.
9//!
10//! **Features (4, all numeric):** `variance`, `skewness`, `curtosis`, and
11//! `entropy` of the Wavelet-Transformed image, all continuous `f64` values.
12//!
13//! **Target:** `class`, the raw integer code from the source, `0` or `1`
14//!
15//! **Samples:** 1372 total (762 of class `0`, 610 of class `1`)
16//! **Application:** Binary classification / banknote authentication
17//!
18//! **Source:** UCI Machine Learning Repository
19//! <https://doi.org/10.24432/C55P57>
20
21use crate::DOWNLOAD_RETRIES;
22use crate::traits::impl_ml_dataset;
23use dataset_core::{Dataset, DatasetError, acquire_dataset, download_to_with_retries, unzip};
24use ndarray::{Array1, Array2};
25use std::fs::File;
26
27use csv::ReaderBuilder;
28
29/// The URL for the Banknote Authentication dataset.
30///
31/// This is the UCI static package. It is a ZIP archive that contains a single
32/// file, `data_banknote_authentication.txt`.
33///
34/// # Citation
35///
36/// V. Lohweg. "Banknote Authentication," UCI Machine Learning Repository,
37/// \[Online\]. Available: <https://doi.org/10.24432/C55P57>
38const BANKNOTE_AUTHENTICATION_DATA_URL: &str =
39    "https://archive.ics.uci.edu/static/public/267/banknote+authentication.zip";
40
41/// The name the downloaded ZIP archive is saved under inside the temp directory.
42const BANKNOTE_AUTHENTICATION_ZIP_FILENAME: &str = "banknote_authentication.zip";
43
44/// The name of the only file inside the archive, holding all 1372 records.
45const BANKNOTE_AUTHENTICATION_SOURCE_FILENAME: &str = "data_banknote_authentication.txt";
46
47/// The name of the final cached Banknote Authentication dataset file.
48const BANKNOTE_AUTHENTICATION_FILENAME: &str = "banknote_authentication.csv";
49
50/// The SHA256 hash of the Banknote Authentication dataset file
51/// (`data_banknote_authentication.txt`).
52const BANKNOTE_AUTHENTICATION_SHA256: &str =
53    "d0539aaed2139ba7a587b3e34fb345ce503ff7d5d33dbf9912d8e195ce425cb9";
54
55/// The name of the dataset.
56const BANKNOTE_AUTHENTICATION_DATASET_NAME: &str = "banknote_authentication";
57
58/// Number of samples.
59const N_SAMPLES: usize = 1372;
60
61/// The number of numeric features per sample.
62const N_FEATURES: usize = 4;
63
64/// The number of columns per CSV record (4 features + 1 label).
65const N_COLUMNS: usize = N_FEATURES + 1;
66
67/// The names of the four feature columns, in source order. `curtosis` keeps the
68/// (misspelled) UCI attribute name so the schema matches the source exactly.
69const FEATURE_NAMES: [&str; N_FEATURES] = ["variance", "skewness", "curtosis", "entropy"];
70
71/// Type alias for the Banknote Authentication dataset: (features, labels).
72type BanknoteAuthenticationData = (Array2<f64>, Array1<u8>);
73
74/// This struct represents the Banknote Authentication dataset and loads it lazily.
75///
76/// Nothing loads until you call a data accessor method. After loading, the
77/// data stays cached for later accesses.
78///
79/// # About Dataset
80///
81/// Researchers extracted the data from images of genuine and forged
82/// banknote-like specimens. They digitized the images with an industrial
83/// camera normally used for print inspection. This camera produced 400×400
84/// pixel grayscale images at a resolution of about 660 dpi. Researchers then
85/// used a Wavelet Transform tool to extract four continuous statistics from
86/// each image. These statistics are the variance, skewness, curtosis, and
87/// entropy of the transformed image. Together they form a compact,
88/// pure-numeric feature matrix over 1372 specimens.
89///
90/// # Feature columns
91///
92/// All 4 features are quantitative, stored in one `(1372, 4)` `Array2<f64>`
93/// matrix. By 0-based column index:
94///
95/// | Column | Attribute  | Description                                          |
96/// |--------|------------|------------------------------------------------------|
97/// | `0`    | `variance` | variance of the Wavelet-Transformed image            |
98/// | `1`    | `skewness` | skewness of the Wavelet-Transformed image            |
99/// | `2`    | `curtosis` | curtosis of the Wavelet-Transformed image            |
100/// | `3`    | `entropy`  | entropy of the image                                 |
101///
102/// `curtosis` keeps the source's spelling (UCI names the attribute that way)
103/// so the schema matches the source exactly.
104///
105/// # Labels
106///
107/// - `class` (shape `(1372,)`): the `Array1<u8>` holds the raw integer code from
108///   the source (`0` or `1`). UCI does not document which code corresponds to
109///   genuine vs forged notes, so the loader exposes it verbatim.
110///
111/// See more information at
112/// <https://archive.ics.uci.edu/dataset/267/banknote+authentication>.
113///
114/// # Citation
115///
116/// V. Lohweg. "Banknote Authentication," UCI Machine Learning Repository,
117/// \[Online\]. Available: <https://doi.org/10.24432/C55P57>
118///
119/// # Thread Safety
120///
121/// Every field implements `Send` and `Sync`, so this struct implements them too. It is safe
122/// to share across threads.
123/// The internal [`Dataset`] makes initialization thread-safe and lazy.
124///
125/// # Example
126/// ```no_run
127/// use dataset_ml::banknote_authentication::BanknoteAuthentication;
128///
129/// let download_dir = "./banknote_authentication"; // creates the directory if it is missing
130///
131/// let mut dataset = BanknoteAuthentication::new(download_dir);
132/// let features = dataset.features().unwrap();
133/// let labels = dataset.labels().unwrap();
134///
135/// let (features, labels) = dataset.data().unwrap(); // also a way to get features and labels
136/// assert_eq!(features.shape(), &[1372, 4]);
137/// assert_eq!(labels.len(), 1372);
138///
139/// // `get_data()` borrows the cached arrays without reloading. `get_data_mut()`
140/// // edits them in place. It needs no clone and no reload, and the change
141/// // stays cached. Prefer this method over cloning with `.to_owned()` when
142/// // you only need to change values.
143/// if let Some((features, labels)) = dataset.get_data_mut() {
144///     features[[0, 0]] = 0.5;
145///     labels[0] = 1;
146/// }
147/// assert!(dataset.get_data().is_some());
148///
149/// // `take_data()` moves owned arrays out (no `to_owned()` clone) and leaves the
150/// // instance reusable. The next access reloads from the cached file.
151/// let (owned_features, owned_labels) = dataset.take_data().unwrap();
152/// assert_eq!(owned_features.shape(), &[1372, 4]);
153/// assert_eq!(owned_labels.len(), 1372);
154///
155/// // `into_data()` also returns owned arrays with no clone, but consumes the
156/// // instance (use it when you are done with the dataset).
157/// let (owned_features, owned_labels) = dataset.into_data().unwrap();
158/// assert_eq!(owned_features.shape(), &[1372, 4]);
159/// assert_eq!(owned_labels.len(), 1372);
160/// ```
161#[derive(Debug)]
162pub struct BanknoteAuthentication {
163    dataset: Dataset<BanknoteAuthenticationData, DatasetError>,
164}
165
166impl BanknoteAuthentication {
167    /// Create a new BanknoteAuthentication instance without loading data.
168    ///
169    /// This does not load the dataset. The dataset loads on the first call to a
170    /// data accessor method. This is a lightweight operation: it only stores the
171    /// storage directory.
172    ///
173    /// # Parameters
174    ///
175    /// - `storage_dir` - Directory used to store the dataset.
176    ///
177    /// # Returns
178    ///
179    /// - `Self` - `BanknoteAuthentication` instance ready for lazy loading.
180    pub fn new(storage_dir: &str) -> Self {
181        BanknoteAuthentication {
182            dataset: Dataset::new(storage_dir, Self::load_data),
183        }
184    }
185
186    /// Get and parse the Banknote Authentication dataset.
187    fn load_data(dir: &str) -> Result<BanknoteAuthenticationData, DatasetError> {
188        // Prepare the dataset file: download the UCI ZIP package, extract it, and
189        // surface the single `data_banknote_authentication.txt` file it contains.
190        let file_path = acquire_dataset(
191            dir,
192            BANKNOTE_AUTHENTICATION_FILENAME,
193            BANKNOTE_AUTHENTICATION_DATASET_NAME,
194            Some(BANKNOTE_AUTHENTICATION_SHA256),
195            |temp_path| {
196                download_to_with_retries(
197                    BANKNOTE_AUTHENTICATION_DATA_URL,
198                    temp_path,
199                    Some(BANKNOTE_AUTHENTICATION_ZIP_FILENAME),
200                    DOWNLOAD_RETRIES,
201                )?;
202                unzip(
203                    &temp_path.join(BANKNOTE_AUTHENTICATION_ZIP_FILENAME),
204                    temp_path,
205                )?;
206                Ok(temp_path.join(BANKNOTE_AUTHENTICATION_SOURCE_FILENAME))
207            },
208        )?;
209
210        // The source is plain comma-separated with no header: every line is a
211        // record of 4 numeric features followed by the class code.
212        let file = File::open(&file_path)?;
213        let mut rdr = ReaderBuilder::new().has_headers(false).from_reader(file);
214
215        let mut features: Vec<f64> = Vec::with_capacity(N_SAMPLES * N_FEATURES);
216        let mut labels: Vec<u8> = Vec::with_capacity(N_SAMPLES);
217
218        for (idx, result) in rdr.records().enumerate() {
219            let record = result.map_err(|e| {
220                DatasetError::csv_read_error(BANKNOTE_AUTHENTICATION_DATASET_NAME, e)
221            })?;
222            let line_num = idx + 1; // headerless file, lines are 1-indexed
223
224            // Skip blank lines defensively (e.g. a trailing newline).
225            if record.iter().all(|f| f.is_empty()) {
226                continue;
227            }
228
229            if record.len() != N_COLUMNS {
230                return Err(DatasetError::invalid_column_count(
231                    BANKNOTE_AUTHENTICATION_DATASET_NAME,
232                    N_COLUMNS,
233                    record.len(),
234                    line_num,
235                ));
236            }
237
238            // 4 numeric features.
239            for (col, name) in FEATURE_NAMES.iter().enumerate() {
240                let value: f64 = record[col].trim().parse().map_err(|e| {
241                    DatasetError::parse_failed(
242                        BANKNOTE_AUTHENTICATION_DATASET_NAME,
243                        name,
244                        line_num,
245                        e,
246                    )
247                })?;
248                features.push(value);
249            }
250
251            // Label, kept as the raw `0`/`1` code the source records.
252            let raw_label = record[N_FEATURES].trim();
253            let label: u8 = raw_label.parse().map_err(|e| {
254                DatasetError::parse_failed(
255                    BANKNOTE_AUTHENTICATION_DATASET_NAME,
256                    "class",
257                    line_num,
258                    e,
259                )
260            })?;
261            if label > 1 {
262                return Err(DatasetError::invalid_value(
263                    BANKNOTE_AUTHENTICATION_DATASET_NAME,
264                    "class",
265                    raw_label,
266                    line_num,
267                ));
268            }
269            labels.push(label);
270        }
271
272        let n_samples = labels.len();
273        if n_samples == 0 {
274            return Err(DatasetError::empty_dataset(
275                BANKNOTE_AUTHENTICATION_DATASET_NAME,
276            ));
277        }
278
279        // Banknote Authentication has a fixed schema of 4 numeric features per sample.
280        let features_array =
281            Array2::from_shape_vec((n_samples, N_FEATURES), features).map_err(|e| {
282                DatasetError::array_shape_error(BANKNOTE_AUTHENTICATION_DATASET_NAME, "features", e)
283            })?;
284
285        let labels_array = Array1::from_vec(labels);
286
287        Ok((features_array, labels_array))
288    }
289
290    /// Get a reference to the feature matrix.
291    ///
292    /// This method triggers lazy loading on first call. Later calls return the
293    /// cached data instantly.
294    ///
295    /// # Returns
296    ///
297    /// - `&Array2<f64>` - Reference to the numeric feature matrix with shape
298    ///   `(1372, 4)`: the `variance`, `skewness`, `curtosis`, and `entropy` of
299    ///   each Wavelet-Transformed image.
300    ///
301    /// # Errors
302    ///
303    /// Returns `DatasetError` if:
304    /// - Download fails due to network issues
305    /// - File extraction or I/O operations fail
306    /// - Data format is invalid (wrong number of columns, unparseable values, or invalid labels)
307    /// - Dataset size does not match the expected dimensions (1372 samples, 4 features)
308    pub fn features(&self) -> Result<&Array2<f64>, DatasetError> {
309        Ok(&self.dataset.load()?.0)
310    }
311
312    /// Get a reference to the labels vector.
313    ///
314    /// This method triggers lazy loading on first call. Later calls return the
315    /// cached data instantly.
316    ///
317    /// # Returns
318    ///
319    /// - `&Array1<u8>` - Reference to labels vector with shape `(1372,)`
320    ///   containing the raw class codes (`0` or `1`).
321    ///
322    /// # Errors
323    ///
324    /// Returns `DatasetError` if:
325    /// - Download fails due to network issues
326    /// - File extraction or I/O operations fail
327    /// - Data format is invalid (wrong number of columns, unparseable values, or invalid labels)
328    /// - Dataset size does not match the expected dimensions (1372 samples)
329    pub fn labels(&self) -> Result<&Array1<u8>, DatasetError> {
330        Ok(&self.dataset.load()?.1)
331    }
332
333    /// Get both features and labels as references.
334    ///
335    /// This method triggers lazy loading on first call. Later calls return the
336    /// cached data instantly.
337    ///
338    /// # Returns
339    ///
340    /// - `&BanknoteAuthenticationData` - reference to the cached
341    ///   `(features, labels)` tuple: the feature matrix has shape `(1372, 4)` and
342    ///   the label vector has shape `(1372,)` containing the raw class codes
343    ///   (`0` or `1`).
344    ///
345    /// # Errors
346    ///
347    /// Returns `DatasetError` if:
348    /// - Download fails due to network issues
349    /// - File extraction or I/O operations fail
350    /// - Data format is invalid (wrong number of columns, unparseable values, or invalid labels)
351    /// - Dataset size does not match the expected dimensions (1372 samples, 4 features)
352    pub fn data(&self) -> Result<&BanknoteAuthenticationData, DatasetError> {
353        self.dataset.load()
354    }
355
356    /// Get both features and labels as references, without triggering loading.
357    ///
358    /// Unlike [`BanknoteAuthentication::data`], which loads the dataset on first
359    /// call, this never runs the loader. If the data has not been loaded yet, it
360    /// returns `None` instead of downloading and parsing.
361    ///
362    /// Use this method when you want the data only if it is already cached. This
363    /// avoids the download and parse cost when the data is not cached.
364    ///
365    /// # Returns
366    ///
367    /// - `Some(&BanknoteAuthenticationData)` - reference to the cached
368    ///   `(features, labels)` tuple (feature matrix `(1372, 4)`, label vector
369    ///   `(1372,)`), if loaded.
370    /// - `None` - if the dataset has not been loaded yet.
371    pub fn get_data(&self) -> Option<&BanknoteAuthenticationData> {
372        self.dataset.get()
373    }
374
375    /// Get mutable references to features and labels for **in-place** editing.
376    ///
377    /// This lets you change the cached arrays directly (e.g. normalize features,
378    /// replace label values). It needs no `to_owned()` clone, and the arrays
379    /// stay in the cache. The changes persist, so later calls to
380    /// [`BanknoteAuthentication::features`], [`BanknoteAuthentication::data`], or
381    /// [`BanknoteAuthentication::get_data`] see them.
382    ///
383    /// Like [`BanknoteAuthentication::get_data`], this does **not** trigger
384    /// loading. It returns `None` if the dataset has not been loaded. If you
385    /// need to make sure the data is present, call a loading accessor first
386    /// (e.g. [`BanknoteAuthentication::data`]).
387    ///
388    /// # Returns
389    ///
390    /// - `Some(&mut BanknoteAuthenticationData)` - mutable reference to the cached
391    ///   `(features, labels)` tuple (feature matrix `(1372, 4)`, label vector
392    ///   `(1372,)`), if loaded.
393    /// - `None` - if the dataset has not been loaded yet.
394    pub fn get_data_mut(&mut self) -> Option<&mut BanknoteAuthenticationData> {
395        self.dataset.get_mut()
396    }
397
398    /// Consume the dataset and return **owned** features and labels.
399    ///
400    /// Unlike [`BanknoteAuthentication::data`], which borrows the cached data,
401    /// this moves it out and returns owned arrays directly. It needs no
402    /// `to_owned()` clone. The dataset is loaded on first access if it has not
403    /// been loaded yet.
404    ///
405    /// This **consumes** `self`, so the instance cannot be used afterwards. If you
406    /// want owned data but need to keep using the instance, use
407    /// [`BanknoteAuthentication::take_data`] instead. It takes `&mut self` and
408    /// leaves the instance reusable.
409    ///
410    /// # Returns
411    ///
412    /// - `(Array2<f64>, Array1<u8>)` - owned feature matrix with shape
413    ///   `(1372, 4)` and owned label vector with shape `(1372,)`.
414    ///
415    /// # Errors
416    ///
417    /// Returns `DatasetError` if loading fails (network, file I/O, parsing, invalid
418    /// labels, or a dimension mismatch).
419    pub fn into_data(self) -> Result<BanknoteAuthenticationData, DatasetError> {
420        self.dataset.load()?;
421        Ok(self
422            .dataset
423            .into_inner()
424            .expect("data is present after a successful load"))
425    }
426
427    /// Take **owned** features and labels out of the dataset. The instance stays
428    /// reusable.
429    ///
430    /// Like [`BanknoteAuthentication::into_data`], this returns owned arrays with
431    /// no `to_owned()` clone. But instead of consuming the instance, it takes
432    /// `&mut self` and moves the cached data out. This resets the instance to
433    /// its unloaded state. The next accessor call (e.g.
434    /// [`BanknoteAuthentication::features`] or [`BanknoteAuthentication::data`])
435    /// loads the dataset again.
436    ///
437    /// If you are done with the instance, use
438    /// [`BanknoteAuthentication::into_data`] instead.
439    ///
440    /// # Returns
441    ///
442    /// - `(Array2<f64>, Array1<u8>)` - owned feature matrix with shape
443    ///   `(1372, 4)` and owned label vector with shape `(1372,)`.
444    ///
445    /// # Errors
446    ///
447    /// Returns `DatasetError` if loading fails (network, file I/O, parsing, invalid
448    /// labels, or a dimension mismatch).
449    pub fn take_data(&mut self) -> Result<BanknoteAuthenticationData, DatasetError> {
450        self.dataset.load()?;
451        Ok(self
452            .dataset
453            .take()
454            .expect("data is present after a successful load"))
455    }
456}
457
458impl_ml_dataset!(
459    BanknoteAuthentication,
460    BanknoteAuthenticationData,
461    "banknote_authentication"
462);