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