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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ 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.5.3]
### Changed
- Classification metrics refactored: `Precision`, `Recall`, and `F1` now derive per-class scores from a single shared `ConfusionCounts` helper (`src/metrics/confusion.rs`) instead of each re-implementing the per-class tp/predicted/support bookkeeping. `Precision` and `Recall` expose a crate-private `per_class_scores_from_counts` used by `F1`'s multiclass path.
- `Precision` and `Recall` now early-return `0.0` on empty input and drop the unreachable `classes == 0` / `support.is_empty()` branches.
- Multiclass macro `F1` (landed in #382, cleaned up in #383) is unchanged behaviourally; it now consumes `Precision`/`Recall::per_class_scores_from_counts` instead of its own `HashMap` bookkeeping.

## [0.4.8] - 2025-11-29
- WARNING: Breaking changes!
- `LassoParameters` and `LassoSearchParameters` have a new field `fit_intercept`. When it is set to false, the `beta_0` term in the formula will be forced to zero, and `intercept` field in `Lasso` will be set to `None`.
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.org"
version = "0.5.2"
version = "0.5.3"
authors = ["smartcore Developers"]
edition = "2021"
license = "Apache-2.0"
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Add to Cargo.toml:

```toml
[dependencies]
smartcore = "^0.4.3"
smartcore = "^0.5.3"
```

For the latest development branch:
Expand Down Expand Up @@ -124,6 +124,10 @@ A curated set of Jupyter notebooks is available via the companion repository to
- Tree and forest components refactored for reuse; Extra Trees added.
- SVM multiclass support; SVR kernel enum and related improvements.
- XGBoost-style regression introduced; single-linkage clustering implemented.
- Classification metrics hardened: multiclass macro F1 now averages per-class
F-measures (matching sklearn), and Precision/Recall/F1 share a single
per-class confusion-counts helper so the per-class bookkeeping lives in one
place.

See CHANGELOG.md for precise details, deprecations, and breaking changes. Some features like nalgebra-bindings have been dropped in favor of ndarray-only paths. Default features are tuned for WASM/WASI builds; enable serde/datasets as needed.

Expand Down
184 changes: 184 additions & 0 deletions src/metrics/confusion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//! Shared per-class confusion-count helpers for classification metrics.
//!
//! [`ConfusionCounts`] computes, in a single pass over `(y_true, y_pred)`,
//! the per-class true-positive, predicted, and support counts used by
//! [`Precision`](crate::metrics::precision::Precision),
//! [`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.

use std::collections::{HashMap, HashSet};

use crate::linalg::basic::arrays::ArrayView1;
use crate::numbers::realnum::RealNumber;

/// Per-class confusion counts for a classification result.
///
/// Built in a single pass over `(y_true, y_pred)`. Exposes per-class
/// true-positive, predicted, and support counts so that `Precision`,
/// `Recall`, and `F1` can derive their per-class scores from a single
/// source of truth instead of each re-implementing the bookkeeping.
pub(crate) struct ConfusionCounts {
classes_set: HashSet<u64>,
predicted: HashMap<u64, usize>,
support: HashMap<u64, usize>,
tp_map: HashMap<u64, usize>,
}

impl ConfusionCounts {
/// Compute per-class confusion counts in a single pass over
/// `(y_true, y_pred)`.
///
/// Named `new` rather than `from` to avoid shadowing the conventional
/// `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 {
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();
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;
if *y_true.get(i) == *y_pred.get(i) {
*tp_map.entry(t_bits).or_insert(0) += 1;
}
}
Self {
classes_set,
predicted,
support,
tp_map,
}
}

/// The set of label bit patterns observed in `y_true`.
pub(crate) fn classes_set(&self) -> &HashSet<u64> {
&self.classes_set
}

/// Number of predictions equal to the label with the given bit pattern.
pub(crate) fn predicted(&self, bits: u64) -> usize {
*self.predicted.get(&bits).unwrap_or(&0)
}

/// Number of `y_true` entries equal to the label with the given bit
/// pattern (the class support).
pub(crate) fn support(&self, bits: u64) -> usize {
*self.support.get(&bits).unwrap_or(&0)
}

/// Number of true positives for the label with the given bit pattern.
pub(crate) fn tp(&self, bits: u64) -> usize {
*self.tp_map.get(&bits).unwrap_or(&0)
}
}

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

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

#[test]
fn confusion_counts_binary_basic() {
// y_true = [0, 1, 1, 0], y_pred = [0, 0, 1, 1]
let y_true: Vec<f64> = vec![0., 1., 1., 0.];
let y_pred: Vec<f64> = vec![0., 0., 1., 1.];
let counts = ConfusionCounts::new(&y_true, &y_pred);

assert_eq!(counts.classes_set().len(), 2);
assert!(counts.classes_set().contains(&bits_of(0.0)));
assert!(counts.classes_set().contains(&bits_of(1.0)));

// Class 0: support=2, predicted=2 (y_pred has two 0s), tp=1
assert_eq!(counts.support(bits_of(0.0)), 2);
assert_eq!(counts.predicted(bits_of(0.0)), 2);
assert_eq!(counts.tp(bits_of(0.0)), 1);

// Class 1: support=2, predicted=2 (y_pred has two 1s), tp=1
assert_eq!(counts.support(bits_of(1.0)), 2);
assert_eq!(counts.predicted(bits_of(1.0)), 2);
assert_eq!(counts.tp(bits_of(1.0)), 1);
}

#[test]
fn confusion_counts_multiclass() {
// y_true = [0, 0, 1, 2, 2, 2], y_pred = [0, 1, 1, 2, 0, 2]
let y_true: Vec<f64> = vec![0., 0., 1., 2., 2., 2.];
let y_pred: Vec<f64> = vec![0., 1., 1., 2., 0., 2.];
let counts = ConfusionCounts::new(&y_true, &y_pred);

assert_eq!(counts.classes_set().len(), 3);

// Class 0: support=2, predicted=2, tp=1
assert_eq!(counts.support(bits_of(0.0)), 2);
assert_eq!(counts.predicted(bits_of(0.0)), 2);
assert_eq!(counts.tp(bits_of(0.0)), 1);

// Class 1: support=1, predicted=2, tp=1
assert_eq!(counts.support(bits_of(1.0)), 1);
assert_eq!(counts.predicted(bits_of(1.0)), 2);
assert_eq!(counts.tp(bits_of(1.0)), 1);

// Class 2: support=3, predicted=2, tp=2
assert_eq!(counts.support(bits_of(2.0)), 3);
assert_eq!(counts.predicted(bits_of(2.0)), 2);
assert_eq!(counts.tp(bits_of(2.0)), 2);
}

#[test]
fn confusion_counts_spurious_predicted_label() {
// y_pred contains label 2 which never appears in y_true. The
// `predicted` map records it, but `classes_set` (sourced from
// y_true) does not include it, so it is silently ignored by
// per-class metrics that iterate `classes_set`.
let y_true: Vec<f64> = vec![0., 0., 1., 1.];
let y_pred: Vec<f64> = vec![0., 2., 1., 1.];
let counts = ConfusionCounts::new(&y_true, &y_pred);

assert_eq!(counts.classes_set().len(), 2);
assert!(!counts.classes_set().contains(&bits_of(2.0)));

// The spurious label is tracked in `predicted`...
assert_eq!(counts.predicted(bits_of(2.0)), 1);
// ...but has no support or tp entry.
assert_eq!(counts.support(bits_of(2.0)), 0);
assert_eq!(counts.tp(bits_of(2.0)), 0);
}

#[test]
fn confusion_counts_empty_input() {
let y_true: Vec<f64> = vec![];
let y_pred: Vec<f64> = vec![];
let counts = ConfusionCounts::new(&y_true, &y_pred);

assert!(counts.classes_set().is_empty());
assert_eq!(counts.predicted(bits_of(0.0)), 0);
assert_eq!(counts.support(bits_of(0.0)), 0);
assert_eq!(counts.tp(bits_of(0.0)), 0);
}

#[test]
fn confusion_counts_perfect_predictions() {
let y_true: Vec<f64> = vec![0., 1., 2., 0., 1.];
let counts = ConfusionCounts::new(&y_true, &y_true);

for &bits in counts.classes_set() {
assert_eq!(counts.tp(bits), counts.support(bits));
assert_eq!(counts.tp(bits), counts.predicted(bits));
}
}
}
72 changes: 26 additions & 46 deletions src/metrics/f1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@
//!
//! <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::collections::{HashMap, HashSet};
use std::marker::PhantomData;

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

use crate::linalg::basic::arrays::ArrayView1;
use crate::metrics::confusion::ConfusionCounts;
use crate::metrics::precision::Precision;
use crate::metrics::recall::Recall;
use crate::numbers::basenum::Number;
Expand Down Expand Up @@ -81,63 +81,43 @@ impl<T: Number + RealNumber + FloatNumber> Metrics<T> for F1<T> {
}
let beta2 = self.beta * self.beta;

// Single pass over y_true/y_pred: collect the class set (needed to pick
// the binary vs. multiclass path) and the per-class tp / support /
// predicted counts used by the multiclass path. 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 — an existing
// convention shared with Precision and Recall.
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();
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;
if *y_true.get(i) == *y_pred.get(i) {
*tp_map.entry(t_bits).or_insert(0) += 1;
}
}
let classes = classes_set.len();
// Build the per-class confusion counts once and delegate per-class
// precision/recall to `Precision` and `Recall`, so this metric no
// longer re-implements the tp/predicted/support bookkeeping. 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 —
// an existing convention shared with Precision and Recall.
let counts = ConfusionCounts::new(y_true, y_pred);
let classes = counts.classes_set().len();

if classes == 2 {
// Binary case: F-measure of the positive class. The positive class
// is assumed to be the label with the higher bit representation
// (i.e. 1.0 when labels are 0.0/1.0) — the convention baked into
// Precision and Recall, which already return the positive-class
// scores.
// Binary case: F-measure of the positive class. The positive
// class is assumed to be T::one() (i.e. 1.0 when labels are
// 0.0/1.0) — the convention baked into Precision and Recall,
// which already return the positive-class scores.
let p = Precision::new().get_score(y_true, y_pred);
let r = Recall::new().get_score(y_true, y_pred);
(1f64 + beta2) * (p * r) / ((beta2 * p) + r)
} else {
// Multiclass case (including classes == 1, where the macro F-beta
// is just the single class's F-beta): macro F-measure is the mean
// of the per-class F-measures, not the F-measure of the
// macro-averaged precision and recall.
// Multiclass case (including classes == 1, where the macro
// F-beta is just the single class's F-beta): macro F-measure is
// the mean of the per-class F-measures, not the F-measure of the
// macro-averaged precision and recall. Per-class precision and
// recall are sourced from `Precision` and `Recall` to keep a
// single source of truth for the per-class scores.
let p_scores = Precision::<T>::new().per_class_scores_from_counts(&counts);
let r_scores = Recall::<T>::new().per_class_scores_from_counts(&counts);
let mut fbeta_sum = 0.0;
for &bits in &classes_set {
let tp = *tp_map.get(&bits).unwrap_or(&0);
let pred_count = *predicted.get(&bits).unwrap_or(&0);
let support_count = *support.get(&bits).unwrap_or(&0);
let p_c = if pred_count > 0 {
tp as f64 / pred_count as f64
} else {
0.0
};
let r_c = if support_count > 0 {
tp as f64 / support_count as f64
} else {
0.0
};
for &bits in counts.classes_set() {
let p_c = *p_scores.get(&bits).unwrap_or(&0.0);
let r_c = *r_scores.get(&bits).unwrap_or(&0.0);
let denom = beta2 * p_c + r_c;
if denom > 0.0 {
fbeta_sum += (1f64 + beta2) * p_c * r_c / denom;
}
}
// classes >= 1 is guaranteed here: n > 0 (early return above) means
// classes_set is non-empty.
// classes >= 1 is guaranteed here: n > 0 (early return above)
// means classes_set is non-empty.
fbeta_sum / classes as f64
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/metrics/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ pub mod auc;
/// Compute the homogeneity, completeness and V-Measure scores.
pub mod cluster_hcv;
pub(crate) mod cluster_helpers;
/// Per-class confusion-count helpers shared by classification metrics.
pub(crate) mod confusion;
/// Multitude of distance metrics are defined here
pub mod distance;
/// F1 score, also known as balanced F-score or F-measure.
Expand Down
Loading
Loading