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}