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