From 21031950d3b102e8efd0b6d1db11f307e3c31fd3 Mon Sep 17 00:00:00 2001 From: "Lorenzo (Mec-iS)" Date: Fri, 21 Aug 2026 15:44:04 +0100 Subject: [PATCH 1/5] fix(metrics): accept integer labels in precision/recall/f1 (#322) Relax Precision, Recall and F1 (structs and free functions) from Number + RealNumber + FloatNumber to Number. Classification metrics need only equality and a canonical class key, so ordered integer labels (u16, i32, ...) now work. This lets the same y feed RandomForestClassifier::fit (Number + Ord) and cross_validate with &precision / &recall / &f1. Class keys are derived through a shared label_bits helper (to_f64 widening) instead of RealNumber::to_f64_bits raw transmutation; float-input scores are unchanged. - add integer-label unit tests to precision/recall/f1 - add regression test: cross_validate(RandomForestClassifier, ..., &precision) with Vec labels (#322) - bump patch 0.6.8 -> 0.6.9 --- CHANGELOG.md | 4 ++ Cargo.toml | 2 +- src/ensemble/random_forest_classifier.rs | 59 ++++++++++++++++++++++++ src/metrics/confusion.rs | 32 ++++++++----- src/metrics/f1.rs | 41 ++++++++++++++-- src/metrics/mod.rs | 31 ++++++------- src/metrics/precision.rs | 45 ++++++++++++++++-- src/metrics/recall.rs | 45 ++++++++++++++++-- 8 files changed, 218 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a25ad096..d5ae789a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.9] +### Fixed +- `metrics`: `precision`, `recall`, and `f1` (the free functions and the `Precision` / `Recall` / `F1` metric structs) now accept any label type that implements `Number`, including ordered integers such as `u16` or `i32`; labels no longer need to implement `RealNumber` (#322). The same integer labels can now feed `RandomForestClassifier::fit` and classification metrics inside `model_selection::cross_validate`. Class keys are derived through a shared `f64` conversion instead of raw float bit transmutation; scores for float inputs are unchanged. + ## [0.6.8] ### Fixed - `linear/linear_regression.rs`: `LinearRegression::fit` / `fit_matrix` now return `Err(Failed::fit(...))` instead of panicking when the intercept-augmented system is underdetermined, i.e. `n_features + 1 > n_samples` (#435). Both the default SVD solver and the QR solver are covered. diff --git a/Cargo.toml b/Cargo.toml index 1e3b06b3..7356044b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "smartcore" description = "Machine Learning in Rust." homepage = "https://smartcorelib.github.io/" -version = "0.6.8" +version = "0.6.9" authors = ["smartcore Developers"] edition = "2024" rust-version = "1.85" diff --git a/src/ensemble/random_forest_classifier.rs b/src/ensemble/random_forest_classifier.rs index 740182c4..09df380e 100644 --- a/src/ensemble/random_forest_classifier.rs +++ b/src/ensemble/random_forest_classifier.rs @@ -693,6 +693,65 @@ mod tests { assert!(accuracy(&y, &classifier.predict(&x).unwrap()) >= 0.95); } + // Regression test for #322: `cross_validate` with a `RandomForestClassifier` + // and the `precision` metric must accept the same ordered integer labels + // (`Number + Ord`) that `fit` accepts. + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn cross_validate_with_precision_and_integer_labels() { + use crate::model_selection::{KFold, cross_validate}; + + let x = DenseMatrix::from_2d_array(&[ + &[5.1, 3.5, 1.4, 0.2], + &[4.9, 3.0, 1.4, 0.2], + &[4.7, 3.2, 1.3, 0.2], + &[4.6, 3.1, 1.5, 0.2], + &[5.0, 3.6, 1.4, 0.2], + &[5.4, 3.9, 1.7, 0.4], + &[4.6, 3.4, 1.4, 0.3], + &[5.0, 3.4, 1.5, 0.2], + &[4.4, 2.9, 1.4, 0.2], + &[4.9, 3.1, 1.5, 0.1], + &[7.0, 3.2, 4.7, 1.4], + &[6.4, 3.2, 4.5, 1.5], + &[6.9, 3.1, 4.9, 1.5], + &[5.5, 2.3, 4.0, 1.3], + &[6.5, 2.8, 4.6, 1.5], + &[5.7, 2.8, 4.5, 1.3], + &[6.3, 3.3, 4.7, 1.6], + &[4.9, 2.4, 3.3, 1.0], + &[6.6, 2.9, 4.6, 1.3], + &[5.2, 2.7, 3.9, 1.4], + ]) + .unwrap(); + let y: Vec = vec![0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]; + + let results = cross_validate( + RandomForestClassifier::new(), + &x, + &y, + RandomForestClassifierParameters { + criterion: SplitCriterion::Gini, + max_depth: Option::None, + min_samples_leaf: 1, + min_samples_split: 2, + n_trees: 10, + m: Option::None, + keep_samples: false, + seed: 87, + }, + &KFold::default().with_n_splits(5), + &precision, + ) + .unwrap(); + + assert_eq!(results.test_score.len(), 5); + assert!(results.test_score.iter().all(|s| *s >= 0.0 && *s <= 1.0)); + } + #[test] fn test_random_matrix_with_wrong_rownum() { let x_rand: DenseMatrix = DenseMatrix::::rand(21, 200); diff --git a/src/metrics/confusion.rs b/src/metrics/confusion.rs index cd0fb977..872827fa 100644 --- a/src/metrics/confusion.rs +++ b/src/metrics/confusion.rs @@ -6,14 +6,27 @@ //! [`Recall`](crate::metrics::recall::Recall), and //! [`F1`](crate::metrics::f1::F1). //! -//! Labels are keyed by their `f64` bit pattern; note that `-0.0` and `+0.0` -//! have distinct bit patterns and would be counted as separate classes. This -//! convention is shared across the classification metrics. +//! Labels are keyed by their `f64` representation: each label is converted +//! with [`label_bits`] and the resulting bit pattern is used as the class +//! key. Note that `-0.0` and `+0.0` have distinct bit patterns and would be +//! counted as separate classes. This convention is shared across the +//! classification metrics. Integer labels are supported: they convert to +//! their exact `f64` value when their magnitude is at most 2^53. use std::collections::{HashMap, HashSet}; use crate::linalg::basic::arrays::ArrayView1; -use crate::numbers::realnum::RealNumber; +use crate::numbers::basenum::Number; + +/// Convert a class label to its canonical `u64` key. +/// +/// The label is widened to `f64` and stored as its bit pattern, so integer +/// labels (`u16`, `i32`, ...) and float labels produce consistent keys. +/// All types implementing [`Number`] convert to `f64` without failure; +/// integer values beyond 2^53 lose precision and may collide. +pub(crate) fn label_bits(v: T) -> u64 { + v.to_f64().unwrap().to_bits() +} /// Per-class confusion counts for a classification result. /// @@ -36,20 +49,17 @@ impl ConfusionCounts { /// `std::convert::From` trait method (the two-argument signature does /// not collide with `From::from`'s single-argument form, but the /// shadowing is still confusing for readers). - pub(crate) fn new( - y_true: &dyn ArrayView1, - y_pred: &dyn ArrayView1, - ) -> Self { + pub(crate) fn new(y_true: &dyn ArrayView1, y_pred: &dyn ArrayView1) -> Self { let n = y_true.shape(); let mut classes_set: HashSet = HashSet::new(); let mut predicted: HashMap = HashMap::new(); let mut support: HashMap = HashMap::new(); let mut tp_map: HashMap = HashMap::new(); for i in 0..n { - let t_bits = y_true.get(i).to_f64_bits(); + let t_bits = label_bits(*y_true.get(i)); classes_set.insert(t_bits); *support.entry(t_bits).or_insert(0) += 1; - *predicted.entry(y_pred.get(i).to_f64_bits()).or_insert(0) += 1; + *predicted.entry(label_bits(*y_pred.get(i))).or_insert(0) += 1; if *y_true.get(i) == *y_pred.get(i) { *tp_map.entry(t_bits).or_insert(0) += 1; } @@ -89,7 +99,7 @@ mod tests { use super::*; fn bits_of(v: f64) -> u64 { - v.to_f64_bits() + v.to_bits() } #[test] diff --git a/src/metrics/f1.rs b/src/metrics/f1.rs index 63bc3ecd..2fc32d9c 100644 --- a/src/metrics/f1.rs +++ b/src/metrics/f1.rs @@ -21,6 +21,18 @@ //! let score: f64 = F1::new_with(beta).get_score( &y_true, &y_pred); //! ``` //! +//! Integer labels work too, so these metrics pair with classifiers like +//! `RandomForestClassifier`, whose `fit` takes ordered integer labels: +//! +//! ``` +//! use smartcore::metrics::f1::F1; +//! use smartcore::metrics::Metrics; +//! let y_pred: Vec = vec![0, 0, 1, 1, 1, 1]; +//! let y_true: Vec = vec![0, 1, 1, 0, 1, 0]; +//! +//! let score: f64 = F1::new_with(1.0).get_score(&y_true, &y_pred); +//! ``` +//! //! //! use std::marker::PhantomData; @@ -33,8 +45,6 @@ use crate::metrics::confusion::ConfusionCounts; use crate::metrics::precision::Precision; use crate::metrics::recall::Recall; use crate::numbers::basenum::Number; -use crate::numbers::floatnum::FloatNumber; -use crate::numbers::realnum::RealNumber; use crate::metrics::Metrics; @@ -47,7 +57,7 @@ pub struct F1 { _phantom: PhantomData, } -impl Metrics for F1 { +impl Metrics for F1 { fn new() -> Self { let beta: f64 = 1f64; Self { @@ -224,4 +234,29 @@ mod tests { let perfect: f64 = F1::new_with(1.0).get_score(&y_true, &y_true); assert!((perfect - 1.0).abs() < 1e-8); } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn f1_integer_labels() { + // Binary case with ordered integer labels (#322). + // Mirrors the float case above: P=0.5, R=2/3 -> F1 = 4/7. + let y_true: Vec = vec![0, 1, 1, 0, 1, 0]; + let y_pred: Vec = vec![0, 0, 1, 1, 1, 1]; + let score: f64 = F1::new_with(1.0).get_score(&y_true, &y_pred); + assert!((score - 0.57142857).abs() < 1e-8); + + // Perfect predictions score 1.0. + let score: f64 = F1::new_with(1.0).get_score(&y_true, &y_true); + assert!((score - 1.0).abs() < 1e-8); + + // Multiclass macro-average with i64 labels. + let y_true: Vec = vec![0, 0, 1, 1, 2, 2]; + let y_pred: Vec = vec![0, 1, 1, 1, 2, 2]; + let score: f64 = F1::new_with(1.0).get_score(&y_true, &y_pred); + let expected = (2.0 / 3.0 + 0.8 + 1.0) / 3.0; + assert!((score - expected).abs() < 1e-8); + } } diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs index 12a32357..77be71e6 100644 --- a/src/metrics/mod.rs +++ b/src/metrics/mod.rs @@ -183,34 +183,33 @@ pub fn accuracy>(y_true: &V, y_pred: &V) -> f6 /// Calculated recall score, see [recall](recall/index.html) /// * `y_true` - cround truth (correct) labels. /// * `y_pred` - predicted labels, as returned by a classifier. -pub fn recall>( - y_true: &V, - y_pred: &V, -) -> f64 { - let obj = ClassificationMetrics::::recall(); +/// +/// Works with float and integer labels, e.g. the ordered integer labels +/// accepted by `RandomForestClassifier::fit`. +pub fn recall>(y_true: &V, y_pred: &V) -> f64 { + let obj = recall::Recall::::new(); obj.get_score(y_true, y_pred) } /// Calculated precision score, see [precision](precision/index.html). /// * `y_true` - cround truth (correct) labels. /// * `y_pred` - predicted labels, as returned by a classifier. -pub fn precision>( - y_true: &V, - y_pred: &V, -) -> f64 { - let obj = ClassificationMetrics::::precision(); +/// +/// Works with float and integer labels, e.g. the ordered integer labels +/// accepted by `RandomForestClassifier::fit`. +pub fn precision>(y_true: &V, y_pred: &V) -> f64 { + let obj = precision::Precision::::new(); obj.get_score(y_true, y_pred) } /// Computes F1 score, see [F1](f1/index.html). /// * `y_true` - cround truth (correct) labels. /// * `y_pred` - predicted labels, as returned by a classifier. -pub fn f1>( - y_true: &V, - y_pred: &V, - beta: f64, -) -> f64 { - let obj = ClassificationMetrics::::f1(beta); +/// +/// Works with float and integer labels, e.g. the ordered integer labels +/// accepted by `RandomForestClassifier::fit`. +pub fn f1>(y_true: &V, y_pred: &V, beta: f64) -> f64 { + let obj = f1::F1::::new_with(beta); obj.get_score(y_true, y_pred) } diff --git a/src/metrics/precision.rs b/src/metrics/precision.rs index d3ed9d3c..21cd4105 100644 --- a/src/metrics/precision.rs +++ b/src/metrics/precision.rs @@ -19,6 +19,18 @@ //! let score: f64 = Precision::new().get_score(&y_true, &y_pred); //! ``` //! +//! Integer labels work too, so these metrics pair with classifiers like +//! `RandomForestClassifier`, whose `fit` takes ordered integer labels: +//! +//! ``` +//! use smartcore::metrics::precision::Precision; +//! use smartcore::metrics::Metrics; +//! let y_pred: Vec = vec![0, 1, 1, 0]; +//! let y_true: Vec = vec![0, 0, 1, 1]; +//! +//! let score: f64 = Precision::new().get_score(&y_true, &y_pred); +//! ``` +//! //! //! @@ -29,8 +41,8 @@ use std::marker::PhantomData; use serde::{Deserialize, Serialize}; use crate::linalg::basic::arrays::ArrayView1; -use crate::metrics::confusion::ConfusionCounts; -use crate::numbers::realnum::RealNumber; +use crate::metrics::confusion::{ConfusionCounts, label_bits}; +use crate::numbers::basenum::Number; use crate::metrics::Metrics; @@ -41,7 +53,7 @@ pub struct Precision { _phantom: PhantomData, } -impl Precision { +impl Precision { /// Per-class precision scores derived from shared confusion counts. /// /// Returns a map from label bit pattern to that class's precision @@ -72,7 +84,7 @@ impl Precision { } } -impl Metrics for Precision { +impl Metrics for Precision { /// create a typed object to call Precision functions fn new() -> Self { Self { @@ -113,7 +125,7 @@ impl Metrics for Precision { // the positive label — so a spurious predicted label not present // in y_true does not affect the score. If the positive label is // not present in y_true the score is 0.0. - let positive_bits = T::one().to_f64_bits(); + let positive_bits = label_bits(T::one()); *scores.get(&positive_bits).unwrap_or(&0.0) } else { // Multiclass case: macro-averaged precision. classes >= 1 is @@ -222,4 +234,27 @@ mod tests { let score: f64 = Precision::new().get_score(&y_true, &y_pred); assert!((score - 1.0).abs() < 1e-8); } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn precision_integer_labels() { + // Binary case with ordered integer labels (#322). + let y_true: Vec = vec![0, 1, 1, 0]; + let y_pred: Vec = vec![0, 0, 1, 1]; + let score: f64 = Precision::new().get_score(&y_true, &y_pred); + assert!((score - 0.5).abs() < 1e-8); + + // Multiclass macro-average with u16 labels. + let y_true: Vec = vec![0, 0, 0, 1, 1, 1, 2, 2, 2]; + let y_pred: Vec = vec![0, 1, 2, 0, 1, 2, 0, 1, 2]; + let score: f64 = Precision::new().get_score(&y_true, &y_pred); + assert!((score - 0.333333333).abs() < 1e-8); + + // Perfect predictions score 1.0. + let score: f64 = Precision::new().get_score(&y_true, &y_true); + assert!((score - 1.0).abs() < 1e-8); + } } diff --git a/src/metrics/recall.rs b/src/metrics/recall.rs index 6031c6ad..b110cdef 100644 --- a/src/metrics/recall.rs +++ b/src/metrics/recall.rs @@ -19,6 +19,18 @@ //! let score: f64 = Recall::new().get_score( &y_true, &y_pred); //! ``` //! +//! Integer labels work too, so these metrics pair with classifiers like +//! `RandomForestClassifier`, whose `fit` takes ordered integer labels: +//! +//! ``` +//! use smartcore::metrics::recall::Recall; +//! use smartcore::metrics::Metrics; +//! let y_pred: Vec = vec![0, 1, 1, 0]; +//! let y_true: Vec = vec![0, 0, 1, 1]; +//! +//! let score: f64 = Recall::new().get_score(&y_true, &y_pred); +//! ``` +//! //! //! @@ -29,8 +41,8 @@ use std::marker::PhantomData; use serde::{Deserialize, Serialize}; use crate::linalg::basic::arrays::ArrayView1; -use crate::metrics::confusion::ConfusionCounts; -use crate::numbers::realnum::RealNumber; +use crate::metrics::confusion::{ConfusionCounts, label_bits}; +use crate::numbers::basenum::Number; use crate::metrics::Metrics; @@ -41,7 +53,7 @@ pub struct Recall { _phantom: PhantomData, } -impl Recall { +impl Recall { /// Per-class recall scores derived from shared confusion counts. /// /// Returns a map from label bit pattern to that class's recall @@ -71,7 +83,7 @@ impl Recall { } } -impl Metrics for Recall { +impl Metrics for Recall { /// create a typed object to call Recall functions fn new() -> Self { Self { @@ -109,7 +121,7 @@ impl Metrics for Recall { // Binary case: recall for the positive class, assumed to be // T::one() (i.e. 1.0 when labels are 0.0/1.0). If the positive // label is not present in y_true the score is 0.0. - let positive_bits = T::one().to_f64_bits(); + let positive_bits = label_bits(T::one()); *scores.get(&positive_bits).unwrap_or(&0.0) } else { // Multiclass case: macro-averaged recall. classes >= 1 is @@ -177,4 +189,27 @@ mod tests { let expected = (0.5 + 1.0 + (2.0 / 3.0)) / 3.0; assert!((score - expected).abs() < 1e-8); } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn recall_integer_labels() { + // Binary case with ordered integer labels (#322). + let y_true: Vec = vec![0, 1, 1, 0]; + let y_pred: Vec = vec![0, 0, 1, 1]; + let score: f64 = Recall::new().get_score(&y_true, &y_pred); + assert!((score - 0.5).abs() < 1e-8); + + // Multiclass macro-average with u16 labels. + let y_true: Vec = vec![0, 0, 0, 1, 1, 1, 2, 2, 2]; + let y_pred: Vec = vec![0, 1, 2, 0, 1, 2, 0, 1, 2]; + let score: f64 = Recall::new().get_score(&y_true, &y_pred); + assert!((score - 0.333333333).abs() < 1e-8); + + // Perfect predictions score 1.0. + let score: f64 = Recall::new().get_score(&y_true, &y_true); + assert!((score - 1.0).abs() < 1e-8); + } } From ec64a05260c1b761f279ee56c144e60efc12b3c7 Mon Sep 17 00:00:00 2001 From: "Lorenzo (Mec-iS)" Date: Fri, 21 Aug 2026 15:58:57 +0100 Subject: [PATCH 2/5] fix(metrics): address review notes on #438 - label_bits: replace unwrap with expect for a clear panic message when a custom Number impl fails to_f64 conversion - regression test: use DenseMatrix features, mirroring the exact report in #322 --- src/ensemble/random_forest_classifier.rs | 3 ++- src/metrics/confusion.rs | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ensemble/random_forest_classifier.rs b/src/ensemble/random_forest_classifier.rs index 09df380e..31e9adb2 100644 --- a/src/ensemble/random_forest_classifier.rs +++ b/src/ensemble/random_forest_classifier.rs @@ -704,7 +704,8 @@ mod tests { fn cross_validate_with_precision_and_integer_labels() { use crate::model_selection::{KFold, cross_validate}; - let x = DenseMatrix::from_2d_array(&[ + // Feature matrix is `f32`, mirroring the report in #322. + let x: DenseMatrix = DenseMatrix::from_2d_array(&[ &[5.1, 3.5, 1.4, 0.2], &[4.9, 3.0, 1.4, 0.2], &[4.7, 3.2, 1.3, 0.2], diff --git a/src/metrics/confusion.rs b/src/metrics/confusion.rs index 872827fa..cf595376 100644 --- a/src/metrics/confusion.rs +++ b/src/metrics/confusion.rs @@ -25,7 +25,9 @@ use crate::numbers::basenum::Number; /// All types implementing [`Number`] convert to `f64` without failure; /// integer values beyond 2^53 lose precision and may collide. pub(crate) fn label_bits(v: T) -> u64 { - v.to_f64().unwrap().to_bits() + v.to_f64() + .expect("class label must convert to f64") + .to_bits() } /// Per-class confusion counts for a classification result. From 05705016723c41a23a08a9091862c835971a09b4 Mon Sep 17 00:00:00 2001 From: "Lorenzo (Mec-iS)" Date: Fri, 21 Aug 2026 16:29:15 +0100 Subject: [PATCH 3/5] fix(metrics): relax ClassificationMetrics bounds for integer labels precision/recall/f1 on ClassificationMetrics still required Number + RealNumber + FloatNumber, so the struct-based entry point could not be used with integer labels even though Precision/Recall/F1 and the free functions accept any Number (#322). Split roc_auc_score into its own impl block with the bounds AUC itself needs. Also complete the CHANGELOG wording: FloatNumber was dropped from the bounds too. --- CHANGELOG.md | 2 +- src/metrics/mod.rs | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5ae789a..09dcfbc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.6.9] ### Fixed -- `metrics`: `precision`, `recall`, and `f1` (the free functions and the `Precision` / `Recall` / `F1` metric structs) now accept any label type that implements `Number`, including ordered integers such as `u16` or `i32`; labels no longer need to implement `RealNumber` (#322). The same integer labels can now feed `RandomForestClassifier::fit` and classification metrics inside `model_selection::cross_validate`. Class keys are derived through a shared `f64` conversion instead of raw float bit transmutation; scores for float inputs are unchanged. +- `metrics`: `precision`, `recall`, and `f1` (the free functions, the `Precision` / `Recall` / `F1` metric structs, and the matching `ClassificationMetrics` entry points) now accept any label type that implements `Number`, including ordered integers such as `u16` or `i32`; labels no longer need to implement `RealNumber` or `FloatNumber` (#322). The same integer labels can now feed `RandomForestClassifier::fit` and classification metrics inside `model_selection::cross_validate`. Class keys are derived through a shared `f64` conversion instead of raw float bit transmutation; scores for float inputs are unchanged. ## [0.6.8] ### Fixed diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs index 77be71e6..f4cda45a 100644 --- a/src/metrics/mod.rs +++ b/src/metrics/mod.rs @@ -119,22 +119,33 @@ pub struct ClusterMetrics { phantom: PhantomData, } -impl ClassificationMetrics { +impl ClassificationMetrics { /// Recall, see [recall](recall/index.html). + /// + /// Works with float and integer labels, e.g. the ordered integer labels + /// accepted by `RandomForestClassifier::fit`. pub fn recall() -> recall::Recall { recall::Recall::new() } /// Precision, see [precision](precision/index.html). + /// + /// Works with float and integer labels, e.g. the ordered integer labels + /// accepted by `RandomForestClassifier::fit`. pub fn precision() -> precision::Precision { precision::Precision::new() } /// F1 score, also known as balanced F-score or F-measure, see [F1](f1/index.html). + /// + /// Works with float and integer labels, e.g. the ordered integer labels + /// accepted by `RandomForestClassifier::fit`. pub fn f1(beta: f64) -> f1::F1 { f1::F1::new_with(beta) } +} +impl ClassificationMetrics { /// Area Under the Receiver Operating Characteristic Curve (ROC AUC), see [AUC](auc/index.html). pub fn roc_auc_score() -> auc::AUC { auc::AUC::::new() @@ -297,3 +308,29 @@ pub fn v_measure_score = vec![0, 1, 1, 0, 1, 0]; + let y_pred: Vec = vec![0, 0, 1, 1, 1, 1]; + + let p = ClassificationMetrics::::precision().get_score(&y_true, &y_pred); + assert!((p - 0.5).abs() < 1e-8); + + let r = ClassificationMetrics::::recall().get_score(&y_true, &y_pred); + assert!((r - 2.0 / 3.0).abs() < 1e-8); + + let f = ClassificationMetrics::::f1(1.0).get_score(&y_true, &y_pred); + assert!((f - 4.0 / 7.0).abs() < 1e-8); + } +} From 631802f4cbd9b89961b0f0a381990ae7cbf159f2 Mon Sep 17 00:00:00 2001 From: "Lorenzo (Mec-iS)" Date: Fri, 21 Aug 2026 16:43:13 +0100 Subject: [PATCH 4/5] fix(model_selection): pin KFold seed in knn cross-validation tests Under --all-features the std_rand feature makes an unseeded KFold draw OS entropy, so every CI run shuffled the folds differently. A sweep of 20k seeds showed 0.17% of shuffles violate the MAE < 10.0 assertion in test_cross_val_predict_knn (worst 12.81) and 0.01% violate train_score < test_score in test_cross_validate_knn. Pin seed Some(11) in both tests; margins are comfortable (predict MAE 2.84 vs 10, validate test 2.71 vs 15). The entropy path stays covered by the rand_custom tests. --- src/model_selection/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/model_selection/mod.rs b/src/model_selection/mod.rs index fe74fce4..c57ebc4c 100644 --- a/src/model_selection/mod.rs +++ b/src/model_selection/mod.rs @@ -451,6 +451,9 @@ mod tests { let cv = KFold { n_splits: 5, + // Pinned seed: under std_rand an unseeded KFold draws OS entropy, + // which makes these assertions flaky across CI runs. + seed: Some(11), ..KFold::default() }; @@ -500,6 +503,9 @@ mod tests { let cv: KFold = KFold { n_splits: 2, + // Pinned seed: under std_rand an unseeded KFold draws OS entropy, + // which makes these assertions flaky across CI runs. + seed: Some(11), ..KFold::default() }; From fcc6630090d3bf870dc2cf48de9a4d33cd6dd65e Mon Sep 17 00:00:00 2001 From: "Lorenzo (Mec-iS)" Date: Fri, 21 Aug 2026 16:46:28 +0100 Subject: [PATCH 5/5] chore: bump patch 0.6.9 -> 0.6.10 --- CHANGELOG.md | 4 ++++ Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09dcfbc5..054d6833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.10] +### Fixed +- `model_selection`: pinned the `KFold` seed in the `test_cross_val_predict_knn` and `test_cross_validate_knn` unit tests. Under `--all-features` (`std_rand`) an unseeded `KFold` draws OS entropy, so each CI run shuffled the folds differently; a sweep of 20 000 seeds showed 0.17% of shuffles violate the `MAE < 10.0` assertion (worst 12.81) and 0.01% violate `train_score < test_score`, making CI flaky. Library behaviour is unchanged; the entropy-seeded path stays covered by the `rand_custom` tests. + ## [0.6.9] ### Fixed - `metrics`: `precision`, `recall`, and `f1` (the free functions, the `Precision` / `Recall` / `F1` metric structs, and the matching `ClassificationMetrics` entry points) now accept any label type that implements `Number`, including ordered integers such as `u16` or `i32`; labels no longer need to implement `RealNumber` or `FloatNumber` (#322). The same integer labels can now feed `RandomForestClassifier::fit` and classification metrics inside `model_selection::cross_validate`. Class keys are derived through a shared `f64` conversion instead of raw float bit transmutation; scores for float inputs are unchanged. diff --git a/Cargo.toml b/Cargo.toml index 7356044b..ef3cde97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "smartcore" description = "Machine Learning in Rust." homepage = "https://smartcorelib.github.io/" -version = "0.6.9" +version = "0.6.10" authors = ["smartcore Developers"] edition = "2024" rust-version = "1.85"