Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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
- `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.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
60 changes: 60 additions & 0 deletions src/ensemble/random_forest_classifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,66 @@ 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};

// Feature matrix is `f32`, mirroring the report in #322.
let x: DenseMatrix<f32> = 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<u16> = 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<f64> = DenseMatrix::<f64>::rand(21, 200);
Expand Down
34 changes: 23 additions & 11 deletions src/metrics/confusion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,29 @@
//! [`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<T: Number>(v: T) -> u64 {
v.to_f64()
.expect("class label must convert to f64")
.to_bits()
}

/// Per-class confusion counts for a classification result.
///
Expand All @@ -36,20 +51,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<T: RealNumber>(
y_true: &dyn ArrayView1<T>,
y_pred: &dyn ArrayView1<T>,
) -> Self {
pub(crate) fn new<T: Number>(y_true: &dyn ArrayView1<T>, y_pred: &dyn ArrayView1<T>) -> Self {
let n = y_true.shape();
let mut classes_set: HashSet<u64> = HashSet::new();
let mut predicted: HashMap<u64, usize> = HashMap::new();
let mut support: HashMap<u64, usize> = HashMap::new();
let mut tp_map: HashMap<u64, usize> = 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;
}
Expand Down Expand Up @@ -89,7 +101,7 @@ mod tests {
use super::*;

fn bits_of(v: f64) -> u64 {
v.to_f64_bits()
v.to_bits()
}

#[test]
Expand Down
41 changes: 38 additions & 3 deletions src/metrics/f1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u16> = vec![0, 0, 1, 1, 1, 1];
//! let y_true: Vec<u16> = vec![0, 1, 1, 0, 1, 0];
//!
//! let score: f64 = F1::new_with(1.0).get_score(&y_true, &y_pred);
//! ```
//!
//! <script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
//! <script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
use std::marker::PhantomData;
Expand All @@ -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;

Expand All @@ -47,7 +57,7 @@ pub struct F1<T> {
_phantom: PhantomData<T>,
}

impl<T: Number + RealNumber + FloatNumber> Metrics<T> for F1<T> {
impl<T: Number> Metrics<T> for F1<T> {
fn new() -> Self {
let beta: f64 = 1f64;
Self {
Expand Down Expand Up @@ -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<u16> = vec![0, 1, 1, 0, 1, 0];
let y_pred: Vec<u16> = 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<i64> = vec![0, 0, 1, 1, 2, 2];
let y_pred: Vec<i64> = 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);
}
}
70 changes: 53 additions & 17 deletions src/metrics/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,22 +119,33 @@ pub struct ClusterMetrics<T> {
phantom: PhantomData<T>,
}

impl<T: Number + RealNumber + FloatNumber> ClassificationMetrics<T> {
impl<T: Number> ClassificationMetrics<T> {
/// 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<T> {
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<T> {
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<T> {
f1::F1::new_with(beta)
}
}

impl<T: Number + FloatNumber + PartialOrd> ClassificationMetrics<T> {
/// Area Under the Receiver Operating Characteristic Curve (ROC AUC), see [AUC](auc/index.html).
pub fn roc_auc_score() -> auc::AUC<T> {
auc::AUC::<T>::new()
Expand Down Expand Up @@ -183,34 +194,33 @@ pub fn accuracy<T: Number + Ord, V: ArrayView1<T>>(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<T: Number + RealNumber + FloatNumber, V: ArrayView1<T>>(
y_true: &V,
y_pred: &V,
) -> f64 {
let obj = ClassificationMetrics::<T>::recall();
///
/// Works with float and integer labels, e.g. the ordered integer labels
/// accepted by `RandomForestClassifier::fit`.
pub fn recall<T: Number, V: ArrayView1<T>>(y_true: &V, y_pred: &V) -> f64 {
let obj = recall::Recall::<T>::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<T: Number + RealNumber + FloatNumber, V: ArrayView1<T>>(
y_true: &V,
y_pred: &V,
) -> f64 {
let obj = ClassificationMetrics::<T>::precision();
///
/// Works with float and integer labels, e.g. the ordered integer labels
/// accepted by `RandomForestClassifier::fit`.
pub fn precision<T: Number, V: ArrayView1<T>>(y_true: &V, y_pred: &V) -> f64 {
let obj = precision::Precision::<T>::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<T: Number + RealNumber + FloatNumber, V: ArrayView1<T>>(
y_true: &V,
y_pred: &V,
beta: f64,
) -> f64 {
let obj = ClassificationMetrics::<T>::f1(beta);
///
/// Works with float and integer labels, e.g. the ordered integer labels
/// accepted by `RandomForestClassifier::fit`.
pub fn f1<T: Number, V: ArrayView1<T>>(y_true: &V, y_pred: &V, beta: f64) -> f64 {
let obj = f1::F1::<T>::new_with(beta);
obj.get_score(y_true, y_pred)
}

Expand Down Expand Up @@ -298,3 +308,29 @@ pub fn v_measure_score<T: Number + FloatNumber + RealNumber + Ord, V: ArrayView1
obj.compute(y_true, y_pred);
obj.v_measure().unwrap()
}

#[cfg(test)]
mod tests {
use super::*;

#[cfg_attr(
all(target_arch = "wasm32", not(target_os = "wasi")),
wasm_bindgen_test::wasm_bindgen_test
)]
#[test]
fn classification_metrics_integer_labels() {
// #322: precision/recall/f1 through the ClassificationMetrics entry
// point must accept ordered integer labels, not only floats.
let y_true: Vec<u16> = vec![0, 1, 1, 0, 1, 0];
let y_pred: Vec<u16> = vec![0, 0, 1, 1, 1, 1];

let p = ClassificationMetrics::<u16>::precision().get_score(&y_true, &y_pred);
assert!((p - 0.5).abs() < 1e-8);

let r = ClassificationMetrics::<u16>::recall().get_score(&y_true, &y_pred);
assert!((r - 2.0 / 3.0).abs() < 1e-8);

let f = ClassificationMetrics::<u16>::f1(1.0).get_score(&y_true, &y_pred);
assert!((f - 4.0 / 7.0).abs() < 1e-8);
}
}
Loading
Loading