Skip to main content

dataset_ml/
iris.rs

1//! Iris flower dataset.
2//!
3//! The classic Fisher Iris dataset for multi-class classification.
4//! It contains measurements for three Iris species: `setosa`, `versicolor`, and
5//! `virginica`.
6//!
7//! **Features (4):**
8//! - `sepal_length` - sepal length in cm
9//! - `sepal_width` - sepal width in cm
10//! - `petal_length` - petal length in cm
11//! - `petal_width` - petal width in cm
12//!
13//! **Target:** `species` - one of `setosa`, `versicolor`, or `virginica`
14//!
15//! **Samples:** 150 total, with 50 samples per species
16//! **Application:** Multi-class classification / species recognition
17//!
18//! **Source:** UCI Machine Learning Repository
19//! <https://doi.org/10.24432/C56C76>
20
21use crate::DOWNLOAD_RETRIES;
22use crate::traits::impl_ml_dataset;
23use csv::ReaderBuilder;
24use dataset_core::{Dataset, DatasetError, acquire_dataset, download_to_with_retries};
25use ndarray::{Array1, Array2};
26use serde::Deserialize;
27use std::fs::File;
28
29/// The URL for the Iris dataset.
30///
31/// # Citation
32///
33/// R. A. Fisher. "Iris," UCI Machine Learning Repository, \[Online\].
34/// Available: <https://doi.org/10.24432/C56C76>
35const IRIS_DATA_URL: &str = "https://gist.githubusercontent.com/curran/a08a1080b88344b0c8a7/raw/0e7a9b0a5d22642a06d3d5b9bcbad9890c8ee534/iris.csv";
36
37/// The name of the Iris dataset file.
38const IRIS_FILENAME: &str = "iris.csv";
39
40/// The SHA256 hash of the Iris dataset file.
41const IRIS_SHA256: &str = "c52742e50315a99f956a383faedf7575552675f6409ef0f9a47076dd08479930";
42
43/// The name of the dataset
44const IRIS_DATASET_NAME: &str = "iris";
45
46/// Type alias for the Iris dataset: (features, labels).
47type IrisData = (Array2<f64>, Array1<&'static str>);
48
49/// One CSV record of the Iris dataset: four `f64` measurements followed by the
50/// species label.
51///
52/// Fields are declared in CSV column order and deserialized **positionally**
53/// (the loader disables csv's header handling), so this struct is independent
54/// of the exact header spelling and of any byte-order mark on the header row.
55#[derive(Deserialize)]
56struct IrisRecord {
57    sepal_length: f64,
58    sepal_width: f64,
59    petal_length: f64,
60    petal_width: f64,
61    species: String,
62}
63
64/// A struct that represents the Iris dataset with lazy loading.
65///
66/// The dataset loads only when you call a data accessor method. After the first
67/// load, the dataset caches the data for later accesses.
68///
69/// # About Dataset
70///
71/// The Iris dataset is a classic dataset for classification tasks. It includes
72/// three iris species, with 50 samples each, and properties of each flower. One
73/// flower species is linearly separable from the other two. The other two are
74/// not linearly separable from each other.
75///
76/// # Feature columns
77///
78/// | Columns | Attributes      | Unit |
79/// |---------|-----------------|------|
80/// | `0`     | `sepal_length`  | cm   |
81/// | `1`     | `sepal_width`   | cm   |
82/// | `2`     | `petal_length`  | cm   |
83/// | `3`     | `petal_width`   | cm   |
84///
85/// # Labels
86///
87/// - species name (in `&str`): `"setosa"`, `"versicolor"`, `"virginica"`
88///
89/// See more information at <https://archive.ics.uci.edu/dataset/53/iris>
90///
91/// # Citation
92///
93/// R. A. Fisher. "Iris," UCI Machine Learning Repository, \[Online\].
94/// Available: <https://doi.org/10.24432/C56C76>
95///
96/// # Thread Safety
97///
98/// This struct implements `Send` and `Sync` automatically, because all fields
99/// implement them. This makes the struct safe to share across threads. The
100/// internal [`Dataset`] makes lazy initialization thread-safe.
101///
102/// # Example
103/// ```no_run
104/// use dataset_ml::iris::Iris;
105///
106/// let download_dir = "./iris"; // the code creates the directory if it does not exist
107///
108/// let mut dataset = Iris::new(download_dir);
109/// let features = dataset.features().unwrap();
110/// let labels = dataset.labels().unwrap();
111///
112/// let (features, labels) = dataset.data().unwrap(); // this also returns features and labels
113/// assert_eq!(features.shape(), &[150, 4]);
114/// assert_eq!(labels.len(), 150);
115///
116/// // `get_data()` borrows the cached arrays without reloading. `get_data_mut()`
117/// // edits the arrays in place. This needs no clone and no reload. The change
118/// // stays cached. Prefer this method over `.to_owned()` when you only need to
119/// // change values.
120/// if let Some((features, labels)) = dataset.get_data_mut() {
121///     features[[0, 0]] = 5.5;
122///     labels[0] = "setosa-modified";
123/// }
124/// assert!(dataset.get_data().is_some());
125///
126/// // `take_data()` moves owned arrays out (no `to_owned()` clone). It leaves the
127/// // instance reusable. The next access reloads the data from the cached file.
128/// let (owned_features, owned_labels) = dataset.take_data().unwrap();
129/// assert_eq!(owned_features.shape(), &[150, 4]);
130/// assert_eq!(owned_labels.len(), 150);
131///
132/// // `into_data()` also returns owned arrays with no clone, but consumes the
133/// // instance (use it when you are done with the dataset).
134/// let (owned_features, owned_labels) = dataset.into_data().unwrap();
135/// assert_eq!(owned_features.shape(), &[150, 4]);
136/// assert_eq!(owned_labels.len(), 150);
137/// ```
138#[derive(Debug)]
139pub struct Iris {
140    dataset: Dataset<IrisData, DatasetError>,
141}
142
143impl Iris {
144    /// Create a new Iris instance without loading data.
145    ///
146    /// The dataset loads lazily, on your first call to a data accessor method.
147    /// This is a lightweight operation that only stores the storage directory.
148    ///
149    /// # Parameters
150    ///
151    /// - `storage_dir` - Directory where the dataset is stored.
152    ///
153    /// # Returns
154    ///
155    /// - `Self` - `Iris` instance ready for lazy loading.
156    pub fn new(storage_dir: &str) -> Self {
157        Iris {
158            dataset: Dataset::new(storage_dir, Self::load_data),
159        }
160    }
161
162    /// Get and parse the Iris dataset.
163    fn load_data(dir: &str) -> Result<IrisData, DatasetError> {
164        // Prepare the dataset file
165        let file_path = acquire_dataset(
166            dir,
167            IRIS_FILENAME,
168            IRIS_DATASET_NAME,
169            Some(IRIS_SHA256),
170            |temp_path| {
171                download_to_with_retries(IRIS_DATA_URL, temp_path, None, DOWNLOAD_RETRIES)?;
172                Ok(temp_path.join(IRIS_FILENAME))
173            },
174        )?;
175
176        // csv deserializes into the struct
177        let file = File::open(&file_path)?;
178        let mut rdr = ReaderBuilder::new().has_headers(false).from_reader(file);
179
180        let mut features = Vec::new();
181        let mut labels = Vec::new();
182
183        for (idx, result) in rdr.deserialize::<IrisRecord>().skip(1).enumerate() {
184            let IrisRecord {
185                sepal_length,
186                sepal_width,
187                petal_length,
188                petal_width,
189                species,
190            } = result.map_err(|e| DatasetError::csv_read_error(IRIS_DATASET_NAME, e))?;
191            let line_num = idx + 2; // +1 for 0-indexed, +1 for header
192
193            features.push(sepal_length);
194            features.push(sepal_width);
195            features.push(petal_length);
196            features.push(petal_width);
197
198            labels.push(match species.as_str() {
199                "setosa" => "setosa",
200                "versicolor" => "versicolor",
201                "virginica" => "virginica",
202                other => {
203                    return Err(DatasetError::invalid_value(
204                        IRIS_DATASET_NAME,
205                        "label",
206                        other,
207                        line_num,
208                    ));
209                }
210            });
211        }
212
213        let n_samples = labels.len();
214        if n_samples == 0 {
215            return Err(DatasetError::empty_dataset(IRIS_DATASET_NAME));
216        }
217
218        // Iris has a fixed schema of 4 numeric features per sample.
219        let features_array = Array2::from_shape_vec((n_samples, 4), features)
220            .map_err(|e| DatasetError::array_shape_error(IRIS_DATASET_NAME, "features", e))?;
221        let labels_array = Array1::from_vec(labels);
222
223        Ok((features_array, labels_array))
224    }
225
226    /// Get a reference to the feature matrix.
227    ///
228    /// This method triggers lazy loading on first call. Subsequent calls return
229    /// the cached data instantly.
230    ///
231    /// # Returns
232    ///
233    /// - `&Array2<f64>` - Reference to feature matrix with shape `(150, 4)` containing:
234    ///     - sepal length in cm
235    ///     - sepal width in cm
236    ///     - petal length in cm
237    ///     - petal width in cm
238    ///
239    /// # Errors
240    ///
241    /// Returns `DatasetError` if:
242    /// - Download fails due to network issues
243    /// - File extraction or I/O operations fail
244    /// - Data format is invalid (wrong number of columns, unparseable values, or invalid labels)
245    /// - Dataset size does not match expected dimensions (150 samples, 4 features)
246    pub fn features(&self) -> Result<&Array2<f64>, DatasetError> {
247        Ok(&self.dataset.load()?.0)
248    }
249
250    /// Get a reference to the labels vector.
251    ///
252    /// This method triggers lazy loading on first call. Subsequent calls return
253    /// the cached data instantly.
254    ///
255    /// # Returns
256    ///
257    /// - `&Array1<&'static str>` - Reference to labels vector with shape `(150,)` containing species names (`"setosa"`, `"versicolor"`, `"virginica"`)
258    ///
259    /// # Errors
260    ///
261    /// Returns `DatasetError` if:
262    /// - Download fails due to network issues
263    /// - File extraction or I/O operations fail
264    /// - Data format is invalid (wrong number of columns, unparseable values, or invalid labels)
265    /// - Dataset size does not match expected dimensions (150 samples)
266    pub fn labels(&self) -> Result<&Array1<&'static str>, DatasetError> {
267        Ok(&self.dataset.load()?.1)
268    }
269
270    /// Get both features and labels as references.
271    ///
272    /// This method triggers lazy loading on first call. Subsequent calls return
273    /// the cached data instantly.
274    ///
275    /// # Returns
276    ///
277    /// - `&IrisData` - reference to the cached `(features, labels)` tuple: the
278    ///   feature matrix has shape `(150, 4)` (sepal length/width, petal
279    ///   length/width, all in cm) and the label vector has shape `(150,)`
280    ///   containing species names (`"setosa"`, `"versicolor"`, `"virginica"`).
281    ///
282    /// # Errors
283    ///
284    /// Returns `DatasetError` if:
285    /// - Download fails due to network issues
286    /// - File extraction or I/O operations fail
287    /// - Data format is invalid (wrong number of columns, unparseable values, or invalid labels)
288    /// - Dataset size does not match expected dimensions (150 samples, 4 features)
289    pub fn data(&self) -> Result<&IrisData, DatasetError> {
290        self.dataset.load()
291    }
292
293    /// Get both features and labels as references **without** triggering loading.
294    ///
295    /// Unlike [`Iris::data`], which loads the dataset on first call, this method
296    /// never runs the loader. If the data has not loaded yet, this method returns
297    /// `None` instead of downloading and parsing it. Use this method when you want
298    /// the data only if it is already cached. This avoids the download and parse
299    /// cost when the data is not yet cached.
300    ///
301    /// # Returns
302    ///
303    /// - `Some(&IrisData)` - reference to the cached `(features, labels)` tuple
304    ///   (feature matrix `(150, 4)`, label vector `(150,)`), if loaded.
305    /// - `None` - if the dataset has not been loaded yet.
306    pub fn get_data(&self) -> Option<&IrisData> {
307        self.dataset.get()
308    }
309
310    /// Get mutable references to features and labels for **in-place** editing.
311    ///
312    /// This lets you change the cached arrays directly, for example to normalize
313    /// features or replace label values. It needs no `to_owned()` clone, and it
314    /// does not remove the arrays from the cache. The changes persist, so later
315    /// calls to [`Iris::features`], [`Iris::data`], or [`Iris::get_data`] observe
316    /// them.
317    ///
318    /// Like [`Iris::get_data`], this method does not trigger loading. It returns
319    /// `None` if the dataset has not loaded yet. If you need the data to be
320    /// present, call a loading accessor first, for example [`Iris::data`].
321    ///
322    /// # Returns
323    ///
324    /// - `Some(&mut IrisData)` - mutable reference to the cached
325    ///   `(features, labels)` tuple (feature matrix `(150, 4)`, label vector
326    ///   `(150,)`), if loaded.
327    /// - `None` - if the dataset has not been loaded yet.
328    pub fn get_data_mut(&mut self) -> Option<&mut IrisData> {
329        self.dataset.get_mut()
330    }
331
332    /// Consume the dataset and return **owned** features and labels.
333    ///
334    /// Unlike [`Iris::data`], which borrows the cached data, this method moves the
335    /// data out and returns owned arrays directly. It needs no `to_owned()` clone.
336    /// If the dataset has not loaded yet, it loads on first access.
337    ///
338    /// This method consumes `self`, so you cannot use the instance afterward. If
339    /// you want owned data but need to keep using the instance, use
340    /// [`Iris::take_data`] instead. It takes `&mut self` and leaves the instance
341    /// reusable.
342    ///
343    /// # Returns
344    ///
345    /// - `(Array2<f64>, Array1<&'static str>)` - owned feature matrix with shape
346    ///   `(150, 4)` and owned label vector with shape `(150,)`.
347    ///
348    /// # Errors
349    ///
350    /// Returns `DatasetError` if loading fails (network, file I/O, parsing, invalid
351    /// labels, or a dimension mismatch).
352    pub fn into_data(self) -> Result<IrisData, DatasetError> {
353        self.dataset.load()?;
354        Ok(self
355            .dataset
356            .into_inner()
357            .expect("data is present after a successful load"))
358    }
359
360    /// Take **owned** features and labels out of the dataset. This leaves the
361    /// instance reusable.
362    ///
363    /// Like [`Iris::into_data`], this method returns owned arrays with no
364    /// `to_owned()` clone. But instead of consuming the instance, it takes
365    /// `&mut self` and moves the cached data out. This resets the instance to its
366    /// unloaded state. The next accessor call, for example [`Iris::features`] or
367    /// [`Iris::data`], loads the dataset again.
368    ///
369    /// If you are done with the instance, use [`Iris::into_data`] instead.
370    ///
371    /// # Returns
372    ///
373    /// - `(Array2<f64>, Array1<&'static str>)` - owned feature matrix with shape
374    ///   `(150, 4)` and owned label vector with shape `(150,)`.
375    ///
376    /// # Errors
377    ///
378    /// Returns `DatasetError` if loading fails (network, file I/O, parsing, invalid
379    /// labels, or a dimension mismatch).
380    pub fn take_data(&mut self) -> Result<IrisData, DatasetError> {
381        self.dataset.load()?;
382        Ok(self
383            .dataset
384            .take()
385            .expect("data is present after a successful load"))
386    }
387}
388
389impl_ml_dataset!(Iris, IrisData, "iris");