Skip to main content

dataset_ml/
covtype.rs

1//! Forest Cover Type dataset.
2//!
3//! The Forest CoverType dataset for multi-class classification, identical to the
4//! one scikit-learn exposes through `fetch_covtype`. Each of the 581,012 samples
5//! describes a 30×30 metre cell of wilderness in the Roosevelt National Forest of
6//! northern Colorado, and the task is to predict which of seven forest cover types
7//! the cell belongs to from 54 cartographic features.
8//!
9//! **Features (54):** these encode 12 logical attributes (10 numeric + 2
10//! categorical), with the two categorical attributes already **one-hot expanded**.
11//! By 0-based column index:
12//! - cols `0..=9` — 10 distinct quantitative variables: `Elevation`, `Aspect`,
13//!   `Slope`, `Horizontal_Distance_To_Hydrology`, `Vertical_Distance_To_Hydrology`,
14//!   `Horizontal_Distance_To_Roadways`, `Hillshade_9am`, `Hillshade_Noon`,
15//!   `Hillshade_3pm`, `Horizontal_Distance_To_Fire_Points`
16//! - cols `10..=13` — `Wilderness_Area`: **one** categorical attribute (4 areas)
17//!   one-hot encoded, so exactly one of these columns is `1` and the rest are `0`
18//! - cols `14..=53` — `Soil_Type`: **one** categorical attribute (40 soil types)
19//!   one-hot encoded, so exactly one of these columns is `1` and the rest are `0`
20//!
21//! All 54 are stored as `f64` (the one-hot columns hold `0.0`/`1.0`), so each
22//! one-hot block sums to `1` per row. See the struct docs for a per-column table.
23//!
24//! **Target:** `cover_type` - the forest cover type, one of `1`–`7` (stored as
25//! `u8`): `1` = Spruce/Fir, `2` = Lodgepole Pine, `3` = Ponderosa Pine,
26//! `4` = Cottonwood/Willow, `5` = Aspen, `6` = Douglas-fir, `7` = Krummholz.
27//!
28//! **Samples:** 581,012 total
29//! **Application:** Multi-class classification / forest cover type prediction
30//!
31//! **Source:** UCI Machine Learning Repository, via the gzip-compressed
32//! `covtype.data.gz` mirror that scikit-learn's `fetch_covtype` downloads.
33//! <https://archive.ics.uci.edu/dataset/31/covertype>
34
35use csv::ReaderBuilder;
36use dataset_core::{Dataset, DatasetError, acquire_dataset, download_to, gunzip};
37use ndarray::{Array1, Array2};
38use std::fs::File;
39
40/// The URL for the Forest Cover Type dataset.
41///
42/// This is the gzip-compressed `covtype.data.gz` mirror used by scikit-learn's
43/// `fetch_covtype`. The URL has no filename segment, so the download is saved under
44/// an explicit name ([`COVTYPE_GZ_FILENAME`]).
45///
46/// # Citation
47///
48/// J. A. Blackard and D. J. Dean. "Covertype," UCI Machine Learning Repository,
49/// \[Online\]. Available: <https://doi.org/10.24432/C50K5N>
50const COVTYPE_DATA_URL: &str = "https://ndownloader.figshare.com/files/5976039";
51
52/// The name the downloaded gzip archive is saved under inside the temp directory.
53const COVTYPE_GZ_FILENAME: &str = "covtype.data.gz";
54
55/// The name of the final cached (decompressed) Cover Type dataset file.
56const COVTYPE_FILENAME: &str = "covtype.csv";
57
58/// The SHA256 hash of the **decompressed** Cover Type dataset file (`covtype.csv`).
59///
60/// Note this is the hash of the uncompressed comma-separated data, not of the
61/// downloaded `covtype.data.gz`, because the cached file is the decompressed one.
62const COVTYPE_SHA256: &str = "0a9371cef7c964b5475d6053cc3e0894a5aa6f65ad1ed3ecb01c45aa96217945";
63
64/// The name of the dataset
65const COVTYPE_DATASET_NAME: &str = "covtype";
66
67/// The number of cartographic features per sample.
68const N_FEATURES: usize = 54;
69
70/// The number of columns per CSV record (54 features + 1 cover-type label).
71const N_COLUMNS: usize = N_FEATURES + 1;
72
73/// The expected number of samples, used only to pre-allocate the parse buffers.
74const N_SAMPLES: usize = 581_012;
75
76/// Type alias for the Cover Type dataset: (features, labels).
77type CovtypeData = (Array2<f64>, Array1<u8>);
78
79/// A struct representing the Forest Cover Type dataset with lazy loading.
80///
81/// The dataset is not loaded until you call one of the data accessor methods.
82/// Once loaded, the data is cached for subsequent accesses.
83///
84/// # About Dataset
85///
86/// The Forest CoverType dataset contains 581,012 cartographic samples, each
87/// describing a 30×30 metre cell of the Roosevelt National Forest in northern
88/// Colorado. The 54 features combine 10 quantitative measurements (elevation,
89/// slope, distances to hydrology/roadways/fire points, hillshade indices) with two
90/// one-hot blocks: 4 `Wilderness_Area` columns and 40 `Soil_Type` columns. The
91/// target is the forest cover type (`1`–`7`).
92///
93/// This is the same data scikit-learn exposes through `fetch_covtype`.
94///
95/// # Feature columns
96///
97/// The 54 feature columns are **not** 54 independent variables: they encode 12
98/// logical attributes (10 numeric + 2 categorical), where the two categorical
99/// attributes are already **one-hot expanded** into many binary indicator columns.
100/// By 0-based column index in the feature matrix:
101///
102/// | Columns   | Attribute(s)                                  | Encoding                                   |
103/// |-----------|-----------------------------------------------|--------------------------------------------|
104/// | `0`       | `Elevation`                                   | quantitative (metres)                      |
105/// | `1`       | `Aspect`                                      | quantitative (azimuth degrees)             |
106/// | `2`       | `Slope`                                       | quantitative (degrees)                     |
107/// | `3`       | `Horizontal_Distance_To_Hydrology`            | quantitative                               |
108/// | `4`       | `Vertical_Distance_To_Hydrology`              | quantitative (may be negative)             |
109/// | `5`       | `Horizontal_Distance_To_Roadways`             | quantitative                               |
110/// | `6`       | `Hillshade_9am`                               | quantitative (`0..=255`)                   |
111/// | `7`       | `Hillshade_Noon`                              | quantitative (`0..=255`)                   |
112/// | `8`       | `Hillshade_3pm`                               | quantitative (`0..=255`)                   |
113/// | `9`       | `Horizontal_Distance_To_Fire_Points`          | quantitative                               |
114/// | `10..=13` | `Wilderness_Area` (one attribute, 4 areas)    | one-hot: exactly one column is `1`, rest `0` |
115/// | `14..=53` | `Soil_Type` (one attribute, 40 soil types)    | one-hot: exactly one column is `1`, rest `0` |
116///
117/// So columns `0..=9` are ten distinct numeric features, but columns `10..=13`
118/// jointly answer "which of 4 wilderness areas" and columns `14..=53` jointly
119/// answer "which of 40 soil types" — each block is a single categorical variable,
120/// with `1` marking the active category and `0` everywhere else (the block sums to
121/// `1`). All 54 columns are stored as `f64` (the one-hot columns hold `0.0`/`1.0`),
122/// matching scikit-learn's dense `fetch_covtype` matrix.
123///
124/// # Labels
125///
126/// - cover type (in `u8`): `1` = Spruce/Fir, `2` = Lodgepole Pine,
127///   `3` = Ponderosa Pine, `4` = Cottonwood/Willow, `5` = Aspen,
128///   `6` = Douglas-fir, `7` = Krummholz
129///
130/// See more information at
131/// <https://archive.ics.uci.edu/dataset/31/covertype>
132///
133/// # Citation
134///
135/// J. A. Blackard and D. J. Dean. "Covertype," UCI Machine Learning Repository,
136/// \[Online\]. Available: <https://doi.org/10.24432/C50K5N>
137///
138/// # Thread Safety
139///
140/// This struct automatically implements `Send` and `Sync` (All fields implement them), making it safe to share across threads.
141/// The internal [`Dataset`] ensures thread-safe lazy initialization.
142///
143/// # Example
144/// ```no_run
145/// use dataset_ml::covtype::Covtype;
146///
147/// let download_dir = "./covtype"; // the code will create the directory if it doesn't exist
148///
149/// let mut dataset = Covtype::new(download_dir);
150/// let features = dataset.features().unwrap();
151/// let labels = dataset.labels().unwrap();
152///
153/// let (features, labels) = dataset.data().unwrap(); // this is also a way to get features and labels
154/// assert_eq!(features.shape(), &[581012, 54]);
155/// assert_eq!(labels.len(), 581012);
156///
157/// // `get_data()` borrows the cached arrays without reloading; `get_data_mut()`
158/// // edits them in place — no clone, no reload, the change stays cached. Prefer
159/// // this over cloning with `.to_owned()` when you only need to tweak values.
160/// if let Some((features, labels)) = dataset.get_data_mut() {
161///     features[[0, 0]] = 2596.0;
162///     labels[0] = 5;
163/// }
164/// assert!(dataset.get_data().is_some());
165///
166/// // `take_data()` moves owned arrays out (no `to_owned()` clone) and leaves the
167/// // instance reusable — the next access reloads from the cached file.
168/// let (owned_features, owned_labels) = dataset.take_data().unwrap();
169/// assert_eq!(owned_features.shape(), &[581012, 54]);
170/// assert_eq!(owned_labels.len(), 581012);
171///
172/// // `into_data()` also returns owned arrays with no clone, but consumes the
173/// // instance (use it when you are done with the dataset).
174/// let (owned_features, owned_labels) = dataset.into_data().unwrap();
175/// assert_eq!(owned_features.shape(), &[581012, 54]);
176/// assert_eq!(owned_labels.len(), 581012);
177/// ```
178#[derive(Debug)]
179pub struct Covtype {
180    dataset: Dataset<CovtypeData, DatasetError>,
181}
182
183impl Covtype {
184    /// Create a new Covtype instance without loading data.
185    ///
186    /// The dataset will be loaded lazily when you first call any data accessor method.
187    /// This is a lightweight operation that only stores the storage directory.
188    ///
189    /// # Parameters
190    ///
191    /// - `storage_dir` - Directory where the dataset will be stored.
192    ///
193    /// # Returns
194    ///
195    /// - `Self` - `Covtype` instance ready for lazy loading.
196    pub fn new(storage_dir: &str) -> Self {
197        Covtype {
198            dataset: Dataset::new(storage_dir, Self::load_data),
199        }
200    }
201
202    /// Acquire and parse the Forest Cover Type dataset.
203    fn load_data(dir: &str) -> Result<CovtypeData, DatasetError> {
204        // Prepare the dataset file: download the gzip-compressed `covtype.data.gz`
205        // and decompress it into the plain comma-separated `covtype.csv`.
206        let file_path = acquire_dataset(
207            dir,
208            COVTYPE_FILENAME,
209            COVTYPE_DATASET_NAME,
210            Some(COVTYPE_SHA256),
211            |temp_path| {
212                download_to(COVTYPE_DATA_URL, temp_path, Some(COVTYPE_GZ_FILENAME))?;
213                let gz_path = temp_path.join(COVTYPE_GZ_FILENAME);
214                let csv_path = temp_path.join(COVTYPE_FILENAME);
215                gunzip(&gz_path, &csv_path)?;
216                Ok(csv_path)
217            },
218        )?;
219
220        // `covtype.data` is a headerless comma-separated file: every line is a
221        // record of 54 cartographic features followed by the cover-type label.
222        let file = File::open(&file_path)?;
223        let mut rdr = ReaderBuilder::new().has_headers(false).from_reader(file);
224
225        // Pre-allocate for the known sample count to avoid repeatedly growing a
226        // ~250 MB feature buffer; parsing still works for any actual row count.
227        let mut features = Vec::with_capacity(N_SAMPLES * N_FEATURES);
228        let mut labels = Vec::with_capacity(N_SAMPLES);
229
230        for (idx, result) in rdr.records().enumerate() {
231            let record =
232                result.map_err(|e| DatasetError::csv_read_error(COVTYPE_DATASET_NAME, e))?;
233            let line_num = idx + 1; // headerless file, lines are 1-indexed
234
235            if record.len() != N_COLUMNS {
236                return Err(DatasetError::invalid_column_count(
237                    COVTYPE_DATASET_NAME,
238                    N_COLUMNS,
239                    record.len(),
240                    line_num,
241                ));
242            }
243
244            for (col, field) in record.iter().take(N_FEATURES).enumerate() {
245                let value: f64 = field.trim().parse().map_err(|e| {
246                    DatasetError::parse_failed(
247                        COVTYPE_DATASET_NAME,
248                        &format!("feature_{}", col),
249                        line_num,
250                        e,
251                    )
252                })?;
253                features.push(value);
254            }
255
256            let raw_label = record[N_FEATURES].trim();
257            let label: u8 = raw_label.parse().map_err(|e| {
258                DatasetError::parse_failed(COVTYPE_DATASET_NAME, "cover_type", line_num, e)
259            })?;
260            if !(1..=7).contains(&label) {
261                return Err(DatasetError::invalid_value(
262                    COVTYPE_DATASET_NAME,
263                    "cover_type",
264                    raw_label,
265                    line_num,
266                ));
267            }
268            labels.push(label);
269        }
270
271        let n_samples = labels.len();
272        if n_samples == 0 {
273            return Err(DatasetError::empty_dataset(COVTYPE_DATASET_NAME));
274        }
275
276        // Cover Type has a fixed schema of 54 numeric features per sample.
277        let features_array = Array2::from_shape_vec((n_samples, N_FEATURES), features)
278            .map_err(|e| DatasetError::array_shape_error(COVTYPE_DATASET_NAME, "features", e))?;
279        let labels_array = Array1::from_vec(labels);
280
281        Ok((features_array, labels_array))
282    }
283
284    /// Get a reference to the feature matrix.
285    ///
286    /// This method triggers lazy loading on first call. Subsequent calls return
287    /// the cached data instantly.
288    ///
289    /// # Returns
290    ///
291    /// - `&Array2<f64>` - Reference to feature matrix with shape `(581012, 54)`
292    ///   containing the 10 quantitative variables followed by the 4 one-hot
293    ///   `Wilderness_Area` and 40 one-hot `Soil_Type` columns.
294    ///
295    /// # Errors
296    ///
297    /// Returns `DatasetError` if:
298    /// - Download fails due to network issues
299    /// - File decompression or I/O operations fail
300    /// - Data format is invalid (wrong number of columns, unparseable values, or invalid labels)
301    /// - Dataset size doesn't match expected dimensions (581012 samples, 54 features)
302    pub fn features(&self) -> Result<&Array2<f64>, DatasetError> {
303        Ok(&self.dataset.load()?.0)
304    }
305
306    /// Get a reference to the labels vector.
307    ///
308    /// This method triggers lazy loading on first call. Subsequent calls return
309    /// the cached data instantly.
310    ///
311    /// # Returns
312    ///
313    /// - `&Array1<u8>` - Reference to labels vector with shape `(581012,)` containing the cover-type classes (`1`–`7`).
314    ///
315    /// # Errors
316    ///
317    /// Returns `DatasetError` if:
318    /// - Download fails due to network issues
319    /// - File decompression or I/O operations fail
320    /// - Data format is invalid (wrong number of columns, unparseable values, or invalid labels)
321    /// - Dataset size doesn't match expected dimensions (581012 samples)
322    pub fn labels(&self) -> Result<&Array1<u8>, DatasetError> {
323        Ok(&self.dataset.load()?.1)
324    }
325
326    /// Get both features and labels as references.
327    ///
328    /// This method triggers lazy loading on first call. Subsequent calls return
329    /// the cached data instantly.
330    ///
331    /// # Returns
332    ///
333    /// - `&CovtypeData` - reference to the cached `(features, labels)` tuple: the
334    ///   feature matrix has shape `(581012, 54)` and the label vector has shape
335    ///   `(581012,)` containing the cover-type classes (`1`–`7`).
336    ///
337    /// # Errors
338    ///
339    /// Returns `DatasetError` if:
340    /// - Download fails due to network issues
341    /// - File decompression or I/O operations fail
342    /// - Data format is invalid (wrong number of columns, unparseable values, or invalid labels)
343    /// - Dataset size doesn't match expected dimensions (581012 samples, 54 features)
344    pub fn data(&self) -> Result<&CovtypeData, DatasetError> {
345        self.dataset.load()
346    }
347
348    /// Get both features and labels as references **without** triggering loading.
349    ///
350    /// Unlike [`Covtype::data`], which loads the dataset on first call, this never
351    /// runs the loader: if the data has not been loaded yet, it returns `None`
352    /// instead of downloading and parsing. Use it when you only want the data if
353    /// it is already cached and want to avoid paying the download/parse cost
354    /// otherwise.
355    ///
356    /// # Returns
357    ///
358    /// - `Some(&CovtypeData)` - reference to the cached `(features, labels)` tuple
359    ///   (feature matrix `(581012, 54)`, label vector `(581012,)`), if loaded.
360    /// - `None` - if the dataset has not been loaded yet.
361    pub fn get_data(&self) -> Option<&CovtypeData> {
362        self.dataset.get()
363    }
364
365    /// Get mutable references to features and labels for **in-place** editing.
366    ///
367    /// This lets you modify the cached arrays directly (e.g. normalize features,
368    /// replace label values) with no `to_owned()` clone and without removing them
369    /// from the cache: the changes persist, so later [`Covtype::features`],
370    /// [`Covtype::data`], or [`Covtype::get_data`] calls observe them.
371    ///
372    /// Like [`Covtype::get_data`], this does **not** trigger loading: it returns
373    /// `None` if the dataset has not been loaded. Call a loading accessor (e.g.
374    /// [`Covtype::data`]) first if you need to ensure the data is present.
375    ///
376    /// # Returns
377    ///
378    /// - `Some(&mut CovtypeData)` - mutable reference to the cached
379    ///   `(features, labels)` tuple (feature matrix `(581012, 54)`, label vector
380    ///   `(581012,)`), if loaded.
381    /// - `None` - if the dataset has not been loaded yet.
382    pub fn get_data_mut(&mut self) -> Option<&mut CovtypeData> {
383        self.dataset.get_mut()
384    }
385
386    /// Consume the dataset and return **owned** features and labels.
387    ///
388    /// Unlike [`Covtype::data`], which borrows the cached data, this moves it out and
389    /// returns owned arrays directly — no `to_owned()` clone needed. The dataset is
390    /// loaded on first access if it has not been loaded yet.
391    ///
392    /// This **consumes** `self`, so the instance cannot be used afterwards. If you
393    /// want owned data but need to keep using the instance, use [`Covtype::take_data`]
394    /// instead — it takes `&mut self` and leaves the instance reusable.
395    ///
396    /// # Returns
397    ///
398    /// - `(Array2<f64>, Array1<u8>)` - owned feature matrix with shape
399    ///   `(581012, 54)` and owned label vector with shape `(581012,)`.
400    ///
401    /// # Errors
402    ///
403    /// Returns `DatasetError` if loading fails (network, file I/O, parsing, invalid
404    /// labels, or a dimension mismatch).
405    pub fn into_data(self) -> Result<CovtypeData, DatasetError> {
406        self.dataset.load()?;
407        Ok(self
408            .dataset
409            .into_inner()
410            .expect("data is present after a successful load"))
411    }
412
413    /// Take **owned** features and labels out of the dataset, leaving it reusable.
414    ///
415    /// Like [`Covtype::into_data`], this returns owned arrays with no `to_owned()`
416    /// clone. But instead of consuming the instance, it takes `&mut self` and moves
417    /// the cached data out, resetting the instance to its unloaded state: the next
418    /// accessor call (e.g. [`Covtype::features`] or [`Covtype::data`]) loads the
419    /// dataset again.
420    ///
421    /// Use [`Covtype::into_data`] instead if you are done with the instance.
422    ///
423    /// # Returns
424    ///
425    /// - `(Array2<f64>, Array1<u8>)` - owned feature matrix with shape
426    ///   `(581012, 54)` and owned label vector with shape `(581012,)`.
427    ///
428    /// # Errors
429    ///
430    /// Returns `DatasetError` if loading fails (network, file I/O, parsing, invalid
431    /// labels, or a dimension mismatch).
432    pub fn take_data(&mut self) -> Result<CovtypeData, DatasetError> {
433        self.dataset.load()?;
434        Ok(self
435            .dataset
436            .take()
437            .expect("data is present after a successful load"))
438    }
439}