use crate::optim::{ConvergenceStatus, LbfgsF64};
use crate::primitives::Vector;
use serde::{Deserialize, Serialize};
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::fmt;
pub const DEFAULT_MAX_ITER: usize = 100;
pub const DEFAULT_TOL: f64 = 1e-4;
pub const DEFAULT_HISTORY_SIZE: usize = 10;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum Regularization {
Lambda(f64),
SklearnEquivalentC {
c: f64,
},
}
impl Regularization {
#[must_use]
pub fn resolve_lambda(self, n_rows: usize) -> f64 {
match self {
Self::Lambda(lambda) => lambda,
Self::SklearnEquivalentC { c } => 1.0 / (2.0 * c * n_rows as f64),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum HeadInputError {
TooFewClasses {
k: usize,
},
LabelCountMismatch {
labels: usize,
k: usize,
},
EmptyLabel {
index: usize,
},
DuplicateLabel {
first: usize,
second: usize,
label: String,
},
EmptyDataset,
RowCountMismatch {
rows: usize,
class_indices: usize,
},
ZeroFeatureDimension,
RaggedRow {
row: usize,
expected: usize,
found: usize,
},
NanFeature {
row: usize,
col: usize,
},
InfiniteFeature {
row: usize,
col: usize,
value: f32,
},
LabelIndexOutOfRange {
row: usize,
index: usize,
k: usize,
},
UnrepresentedClass {
class: usize,
},
NegativeLambda {
lambda: f64,
},
NonFiniteLambda {
lambda: f64,
},
NonPositiveC {
c: f64,
},
NonFiniteC {
c: f64,
},
FeatureDimMismatch {
row: usize,
expected: usize,
found: usize,
},
CoefficientCountMismatch {
array: &'static str,
expected: usize,
found: usize,
},
NonFiniteCoefficient {
array: &'static str,
index: usize,
value: f32,
},
}
impl fmt::Display for HeadInputError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::TooFewClasses { k } => {
write!(f, "n_classes = {k}, but a multinomial head requires K >= 2")
}
Self::LabelCountMismatch { labels, k } => {
write!(f, "ordered_labels has {labels} entries but n_classes = {k}")
}
Self::EmptyLabel { index } => {
write!(f, "ordered_labels[{index}] is the empty string")
}
Self::DuplicateLabel {
first,
second,
label,
} => write!(
f,
"ordered_labels[{first}] and ordered_labels[{second}] are both {label:?}"
),
Self::EmptyDataset => write!(f, "no feature rows were supplied"),
Self::RowCountMismatch {
rows,
class_indices,
} => write!(f, "{rows} feature rows but {class_indices} class indices"),
Self::ZeroFeatureDimension => write!(f, "the feature dimension is 0"),
Self::RaggedRow {
row,
expected,
found,
} => write!(
f,
"features[{row}] has dimension {found}, expected {expected} (ragged input)"
),
Self::NanFeature { row, col } => {
write!(f, "features[{row}][{col}] is NaN")
}
Self::InfiniteFeature { row, col, value } => {
write!(f, "features[{row}][{col}] is {value} (not finite)")
}
Self::LabelIndexOutOfRange { row, index, k } => {
write!(f, "class_indices[{row}] = {index}, not in 0..{k}")
}
Self::UnrepresentedClass { class } => write!(
f,
"class {class} has no rows; every class in 0..K must be represented"
),
Self::NegativeLambda { lambda } => {
write!(f, "lambda = {lambda} is negative")
}
Self::NonFiniteLambda { lambda } => {
write!(f, "lambda = {lambda} is not finite")
}
Self::NonPositiveC { c } => write!(f, "C = {c} must be strictly positive"),
Self::NonFiniteC { c } => write!(f, "C = {c} is not finite"),
Self::FeatureDimMismatch {
row,
expected,
found,
} => write!(
f,
"prediction row {row} has dimension {found}, but the head was fitted on {expected}"
),
Self::CoefficientCountMismatch {
array,
expected,
found,
} => write!(
f,
"stored {array} has {found} values but the label map and feature dimension \
imply {expected}"
),
Self::NonFiniteCoefficient {
array,
index,
value,
} => write!(f, "stored {array}[{index}] is {value} (not finite)"),
}
}
}
impl std::error::Error for HeadInputError {}
#[derive(Debug, Clone, PartialEq)]
pub enum HeadFitError {
InvalidInput(HeadInputError),
NotConverged {
iterations: usize,
gradient_norm: f64,
tol: f64,
},
Stalled {
iterations: usize,
gradient_norm: f64,
},
NumericalError {
iterations: usize,
},
Internal {
status: ConvergenceStatus,
},
NonFiniteLogit {
row: usize,
class: usize,
},
NotFitted,
}
impl fmt::Display for HeadFitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidInput(e) => write!(f, "invalid input: {e}"),
Self::NotConverged {
iterations,
gradient_norm,
tol,
} => write!(
f,
"L-BFGS did not converge: {iterations} iterations, gradient norm {gradient_norm:e} > tol {tol:e}"
),
Self::Stalled {
iterations,
gradient_norm,
} => write!(
f,
"L-BFGS stalled after {iterations} iterations at gradient norm {gradient_norm:e}"
),
Self::NumericalError { iterations } => write!(
f,
"L-BFGS hit a non-finite value after {iterations} iterations"
),
Self::Internal { status } => write!(
f,
"L-BFGS returned {status:?}, which is unreachable from a completed minimize()"
),
Self::NonFiniteLogit { row, class } => write!(
f,
"logit for row {row}, class {class} is not finite"
),
Self::NotFitted => write!(f, "the head has not been fitted"),
}
}
}
impl std::error::Error for HeadFitError {}
impl From<HeadInputError> for HeadFitError {
fn from(e: HeadInputError) -> Self {
Self::InvalidInput(e)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HeadFitReport {
pub status: ConvergenceStatus,
pub iterations: usize,
pub final_grad_norm: f64,
pub objective: f64,
}
pub(crate) fn log_sum_exp(logits: &[f64]) -> f64 {
let mut max = f64::NEG_INFINITY;
for &z in logits {
if z > max {
max = z;
}
}
let mut sum = 0.0;
for &z in logits {
sum += (z - max).exp();
}
max + sum.ln()
}
pub(crate) fn softmax_into(logits: &[f64], out: &mut [f64]) {
let mut max = f64::NEG_INFINITY;
for &z in logits {
if z > max {
max = z;
}
}
let mut sum = 0.0;
for (o, &z) in out.iter_mut().zip(logits.iter()) {
let e = (z - max).exp();
*o = e;
sum += e;
}
for o in out.iter_mut() {
*o /= sum;
}
}
pub(crate) fn argmax_lowest_index(values: &[f64]) -> usize {
let mut best = 0;
let mut best_value = f64::NEG_INFINITY;
for (i, &v) in values.iter().enumerate() {
if v > best_value {
best_value = v;
best = i;
}
}
best
}
pub(crate) struct SoftmaxNllProblem<'a> {
pub(crate) features: &'a [Vec<f32>],
pub(crate) class_indices: &'a [usize],
pub(crate) n_classes: usize,
pub(crate) n_features: usize,
pub(crate) lambda: f64,
}
impl SoftmaxNllProblem<'_> {
pub(crate) fn intercept_offset(&self) -> usize {
self.n_classes * self.n_features
}
pub(crate) fn n_params(&self) -> usize {
self.n_classes * self.n_features + self.n_classes
}
fn logits_for_row(&self, x: &Vector<f64>, row: usize, row_f64: &mut [f64], logits: &mut [f64]) {
let d = self.n_features;
let off = self.intercept_offset();
let features = &self.features[row];
for j in 0..d {
row_f64[j] = f64::from(features[j]);
}
for c in 0..self.n_classes {
let mut z = x[off + c];
for j in 0..d {
z += x[c * d + j] * row_f64[j];
}
logits[c] = z;
}
}
#[provable_contracts_macros::contract(
"multinomial-head-v1",
equation = "softmax_nll_objective"
)]
pub(crate) fn objective(&self, x: &Vector<f64>) -> f64 {
let k = self.n_classes;
let n = self.features.len();
let mut logits = vec![0.0_f64; k];
let mut row_f64 = vec![0.0_f64; self.n_features];
let mut nll = 0.0_f64;
for i in 0..n {
self.logits_for_row(x, i, &mut row_f64, &mut logits);
nll += log_sum_exp(&logits) - logits[self.class_indices[i]];
}
let mut penalty = 0.0_f64;
for p in 0..self.intercept_offset() {
penalty += x[p] * x[p];
}
nll / n as f64 + self.lambda * penalty
}
#[provable_contracts_macros::contract("multinomial-head-v1", equation = "analytic_gradient")]
pub(crate) fn gradient(&self, x: &Vector<f64>) -> Vector<f64> {
let k = self.n_classes;
let d = self.n_features;
let n = self.features.len();
let off = self.intercept_offset();
let inv_n = 1.0 / n as f64;
let mut g = vec![0.0_f64; self.n_params()];
let mut logits = vec![0.0_f64; k];
let mut probs = vec![0.0_f64; k];
let mut row_f64 = vec![0.0_f64; self.n_features];
for i in 0..n {
self.logits_for_row(x, i, &mut row_f64, &mut logits);
softmax_into(&logits, &mut probs);
let y_i = self.class_indices[i];
let row = &self.features[i];
for c in 0..k {
let indicator = if c == y_i { 1.0 } else { 0.0 };
let residual = (probs[c] - indicator) * inv_n;
for j in 0..d {
g[c * d + j] += residual * f64::from(row[j]);
}
g[off + c] += residual;
}
}
for p in 0..off {
g[p] += 2.0 * self.lambda * x[p];
}
Vector::from_vec(g)
}
}
struct ValidatedFit {
n_features: usize,
lambda: f64,
}
fn validate_label_set(n_classes: usize, ordered_labels: &[String]) -> Result<(), HeadInputError> {
if n_classes < 2 {
return Err(HeadInputError::TooFewClasses { k: n_classes });
}
if ordered_labels.len() != n_classes {
return Err(HeadInputError::LabelCountMismatch {
labels: ordered_labels.len(),
k: n_classes,
});
}
for (index, label) in ordered_labels.iter().enumerate() {
if label.is_empty() {
return Err(HeadInputError::EmptyLabel { index });
}
}
let mut first_seen: HashMap<&str, usize> = HashMap::with_capacity(ordered_labels.len());
let mut duplicate: Option<(usize, usize)> = None;
for (index, label) in ordered_labels.iter().enumerate() {
match first_seen.entry(label.as_str()) {
Entry::Vacant(slot) => {
slot.insert(index);
}
Entry::Occupied(slot) => {
let first = *slot.get();
if duplicate.is_none_or(|(best, _)| first < best) {
duplicate = Some((first, index));
}
}
}
}
if let Some((first, second)) = duplicate {
return Err(HeadInputError::DuplicateLabel {
first,
second,
label: ordered_labels[first].clone(),
});
}
Ok(())
}
fn validate_fit_shape(
features: &[Vec<f32>],
class_indices: &[usize],
) -> Result<usize, HeadInputError> {
if features.is_empty() {
return Err(HeadInputError::EmptyDataset);
}
if features.len() != class_indices.len() {
return Err(HeadInputError::RowCountMismatch {
rows: features.len(),
class_indices: class_indices.len(),
});
}
let n_features = features[0].len();
if n_features == 0 {
return Err(HeadInputError::ZeroFeatureDimension);
}
for (row, values) in features.iter().enumerate() {
if values.len() != n_features {
return Err(HeadInputError::RaggedRow {
row,
expected: n_features,
found: values.len(),
});
}
}
Ok(n_features)
}
fn validate_fit_feature_values(features: &[Vec<f32>]) -> Result<(), HeadInputError> {
for (row, values) in features.iter().enumerate() {
for (col, &v) in values.iter().enumerate() {
if v.is_nan() {
return Err(HeadInputError::NanFeature { row, col });
}
if !v.is_finite() {
return Err(HeadInputError::InfiniteFeature { row, col, value: v });
}
}
}
Ok(())
}
fn validate_fit_class_indices(
n_classes: usize,
class_indices: &[usize],
) -> Result<(), HeadInputError> {
let mut represented = vec![false; n_classes];
for (row, &index) in class_indices.iter().enumerate() {
if index >= n_classes {
return Err(HeadInputError::LabelIndexOutOfRange {
row,
index,
k: n_classes,
});
}
represented[index] = true;
}
for (class, seen) in represented.iter().enumerate() {
if !seen {
return Err(HeadInputError::UnrepresentedClass { class });
}
}
Ok(())
}
fn validate_fit_regularization(regularization: Regularization) -> Result<(), HeadInputError> {
match regularization {
Regularization::Lambda(lambda) => {
if !lambda.is_finite() {
return Err(HeadInputError::NonFiniteLambda { lambda });
}
if lambda < 0.0 {
return Err(HeadInputError::NegativeLambda { lambda });
}
}
Regularization::SklearnEquivalentC { c } => {
if !c.is_finite() {
return Err(HeadInputError::NonFiniteC { c });
}
if c <= 0.0 {
return Err(HeadInputError::NonPositiveC { c });
}
}
}
Ok(())
}
fn validate_fit_inputs(
n_classes: usize,
features: &[Vec<f32>],
class_indices: &[usize],
ordered_labels: &[String],
regularization: Regularization,
) -> Result<ValidatedFit, HeadInputError> {
validate_label_set(n_classes, ordered_labels)?;
let n_features = validate_fit_shape(features, class_indices)?;
validate_fit_feature_values(features)?;
validate_fit_class_indices(n_classes, class_indices)?;
validate_fit_regularization(regularization)?;
Ok(ValidatedFit {
n_features,
lambda: regularization.resolve_lambda(features.len()),
})
}
#[derive(Debug, Clone)]
pub struct MultinomialLogisticRegression {
n_classes: usize,
max_iter: usize,
tol: f64,
history_size: usize,
n_features: Option<usize>,
weights: Vec<f32>,
intercepts: Vec<f32>,
intercepts_f64: Vec<f64>,
labels: Vec<String>,
report: Option<HeadFitReport>,
}
impl MultinomialLogisticRegression {
#[must_use]
pub fn new(n_classes: usize) -> Self {
Self {
n_classes,
max_iter: DEFAULT_MAX_ITER,
tol: DEFAULT_TOL,
history_size: DEFAULT_HISTORY_SIZE,
n_features: None,
weights: Vec::new(),
intercepts: Vec::new(),
intercepts_f64: Vec::new(),
labels: Vec::new(),
report: None,
}
}
pub fn from_stored_coefficients(
ordered_labels: Vec<String>,
n_features: usize,
weights: Vec<f32>,
intercepts: Vec<f32>,
) -> Result<Self, HeadFitError> {
let n_classes = ordered_labels.len();
validate_label_set(n_classes, &ordered_labels)?;
if n_features == 0 {
return Err(HeadInputError::ZeroFeatureDimension.into());
}
let expected_weights = n_classes.saturating_mul(n_features);
if weights.len() != expected_weights {
return Err(HeadInputError::CoefficientCountMismatch {
array: "weights",
expected: expected_weights,
found: weights.len(),
}
.into());
}
if intercepts.len() != n_classes {
return Err(HeadInputError::CoefficientCountMismatch {
array: "intercepts",
expected: n_classes,
found: intercepts.len(),
}
.into());
}
for (array, values) in [("weights", &weights), ("intercepts", &intercepts)] {
for (index, value) in values.iter().enumerate() {
if !value.is_finite() {
return Err(HeadInputError::NonFiniteCoefficient {
array,
index,
value: *value,
}
.into());
}
}
}
Ok(Self {
n_classes,
max_iter: DEFAULT_MAX_ITER,
tol: DEFAULT_TOL,
history_size: DEFAULT_HISTORY_SIZE,
n_features: Some(n_features),
intercepts_f64: intercepts.iter().map(|&b| f64::from(b)).collect(),
weights,
intercepts,
labels: ordered_labels,
report: None,
})
}
#[must_use]
pub fn with_max_iter(mut self, max_iter: usize) -> Self {
self.max_iter = max_iter;
self
}
#[must_use]
pub fn with_tol(mut self, tol: f64) -> Self {
self.tol = tol;
self
}
#[must_use]
pub fn with_history_size(mut self, history_size: usize) -> Self {
self.history_size = history_size;
self
}
#[must_use]
pub fn n_classes(&self) -> usize {
self.n_classes
}
#[must_use]
pub fn n_features(&self) -> Option<usize> {
self.n_features
}
#[must_use]
pub fn weights(&self) -> &[f32] {
&self.weights
}
#[must_use]
pub fn intercepts(&self) -> &[f32] {
&self.intercepts
}
#[must_use]
pub fn labels(&self) -> &[String] {
&self.labels
}
#[must_use]
pub fn report(&self) -> Option<&HeadFitReport> {
self.report.as_ref()
}
#[cfg(test)]
pub(crate) fn intercepts_f64(&self) -> &[f64] {
&self.intercepts_f64
}
pub fn fit(
&mut self,
features: &[Vec<f32>],
class_indices: &[usize],
ordered_labels: &[String],
regularization: Regularization,
) -> Result<HeadFitReport, HeadFitError> {
self.n_features = None;
self.weights.clear();
self.intercepts.clear();
self.intercepts_f64.clear();
self.labels.clear();
self.report = None;
let validated = validate_fit_inputs(
self.n_classes,
features,
class_indices,
ordered_labels,
regularization,
)?;
let d = validated.n_features;
let problem = SoftmaxNllProblem {
features,
class_indices,
n_classes: self.n_classes,
n_features: d,
lambda: validated.lambda,
};
let x0 = Vector::from_vec(vec![0.0_f64; problem.n_params()]);
let mut solver = LbfgsF64::new(self.max_iter, self.tol, self.history_size);
let result = solver.minimize(
|x: &Vector<f64>| problem.objective(x),
|x: &Vector<f64>| problem.gradient(x),
&x0,
);
match result.status {
ConvergenceStatus::Converged => {}
ConvergenceStatus::MaxIterations => {
return Err(HeadFitError::NotConverged {
iterations: result.iterations,
gradient_norm: result.gradient_norm,
tol: self.tol,
})
}
ConvergenceStatus::Stalled => {
return Err(HeadFitError::Stalled {
iterations: result.iterations,
gradient_norm: result.gradient_norm,
})
}
ConvergenceStatus::NumericalError => {
return Err(HeadFitError::NumericalError {
iterations: result.iterations,
})
}
status @ (ConvergenceStatus::Running | ConvergenceStatus::UserTerminated) => {
return Err(HeadFitError::Internal { status })
}
}
let off = problem.intercept_offset();
let solution = result.solution.as_slice();
self.intercepts_f64 = solution[off..].to_vec();
self.weights = solution[..off].iter().map(|&w| w as f32).collect();
self.intercepts = self.intercepts_f64.iter().map(|&b| b as f32).collect();
self.labels = ordered_labels.to_vec();
self.n_features = Some(d);
let report = HeadFitReport {
status: result.status,
iterations: result.iterations,
final_grad_norm: result.gradient_norm,
objective: result.objective_value,
};
self.report = Some(report.clone());
Ok(report)
}
pub fn predict_logits(&self, features: &[Vec<f32>]) -> Result<Vec<Vec<f64>>, HeadFitError> {
let d = self.n_features.ok_or(HeadFitError::NotFitted)?;
let k = self.n_classes;
let mut out = Vec::with_capacity(features.len());
for (row, values) in features.iter().enumerate() {
if values.len() != d {
return Err(HeadFitError::InvalidInput(
HeadInputError::FeatureDimMismatch {
row,
expected: d,
found: values.len(),
},
));
}
let mut logits = vec![0.0_f64; k];
for c in 0..k {
let mut z = f64::from(self.intercepts[c]);
for j in 0..d {
z += f64::from(self.weights[c * d + j]) * f64::from(values[j]);
}
if !z.is_finite() {
return Err(HeadFitError::NonFiniteLogit { row, class: c });
}
logits[c] = z;
}
out.push(logits);
}
Ok(out)
}
pub fn predict_proba(&self, features: &[Vec<f32>]) -> Result<Vec<Vec<f64>>, HeadFitError> {
let rows = self.predict_logits(features)?;
let mut out = Vec::with_capacity(rows.len());
for logits in &rows {
let mut probs = vec![0.0_f64; logits.len()];
softmax_into(logits, &mut probs);
out.push(probs);
}
Ok(out)
}
pub fn predict_indices(&self, features: &[Vec<f32>]) -> Result<Vec<usize>, HeadFitError> {
let probs = self.predict_proba(features)?;
Ok(probs.iter().map(|p| argmax_lowest_index(p)).collect())
}
pub fn predict(&self, features: &[Vec<f32>]) -> Result<Vec<String>, HeadFitError> {
let indices = self.predict_indices(features)?;
Ok(indices
.into_iter()
.map(|i| self.labels[i].clone())
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn labels(names: &[&str]) -> Vec<String> {
names.iter().map(|s| (*s).to_string()).collect()
}
fn k3_separable() -> (Vec<Vec<f32>>, Vec<usize>, Vec<String>) {
let features = vec![
vec![-2.0, 0.0],
vec![-2.2, 0.3],
vec![-1.8, -0.3],
vec![0.0, 2.0],
vec![0.3, 2.2],
vec![-0.3, 1.8],
vec![2.0, 0.0],
vec![2.2, -0.3],
vec![1.8, 0.3],
];
let class_indices = vec![0, 0, 0, 1, 1, 1, 2, 2, 2];
(
features,
class_indices,
labels(&["against", "favor", "none"]),
)
}
fn k2_separable() -> (Vec<Vec<f32>>, Vec<usize>, Vec<String>) {
let features = vec![
vec![-1.5, -1.0],
vec![-1.2, -1.4],
vec![-1.8, -0.7],
vec![1.5, 1.0],
vec![1.2, 1.4],
vec![1.8, 0.7],
];
let class_indices = vec![0, 0, 0, 1, 1, 1];
(features, class_indices, labels(&["no", "yes"]))
}
fn duplicate_row_tie(k: usize) -> (Vec<Vec<f32>>, Vec<usize>, Vec<String>) {
let base = [vec![0.7_f32, -1.3], vec![-0.4, 0.9]];
let mut features = Vec::new();
let mut class_indices = Vec::new();
for row in &base {
for c in 0..k {
features.push(row.clone());
class_indices.push(c);
}
}
let names: Vec<String> = (0..k).map(|c| format!("class{c}")).collect();
(features, class_indices, names)
}
fn fit_k3() -> MultinomialLogisticRegression {
let (x, y, l) = k3_separable();
let mut head = MultinomialLogisticRegression::new(3);
head.fit(&x, &y, &l, Regularization::Lambda(0.01))
.expect("k3 separable fit converges");
head
}
#[test]
fn log_sum_exp_is_finite_for_logits_near_1000() {
assert!(
1000.0_f64.exp().is_infinite(),
"precondition: naive exp(1000) must overflow f64, else this test proves nothing"
);
let logits = [1000.0, 1001.0, 999.0];
let lse = log_sum_exp(&logits);
assert!(lse.is_finite(), "log_sum_exp({logits:?}) = {lse}");
let expected = 1001.0 + ((-1.0_f64).exp() + 1.0 + (-2.0_f64).exp()).ln();
assert!((lse - expected).abs() < 1e-9, "lse {lse} != {expected}");
}
#[test]
fn softmax_into_is_finite_and_sums_to_one_for_logits_near_1000() {
let logits = [1000.0, 1001.0, 999.0];
let mut out = [0.0; 3];
softmax_into(&logits, &mut out);
let sum: f64 = out.iter().sum();
for (c, p) in out.iter().enumerate() {
assert!(p.is_finite(), "probability[{c}] = {p} is not finite");
assert!(
(0.0..=1.0).contains(p),
"probability[{c}] = {p} out of [0,1]"
);
}
assert!((sum - 1.0).abs() < 1e-12, "probabilities sum to {sum}");
assert_eq!(argmax_lowest_index(&out), 1, "largest logit is index 1");
}
#[test]
fn argmax_lowest_index_breaks_exact_ties_to_lowest_index() {
assert_eq!(argmax_lowest_index(&[0.5, 0.5, 0.5]), 0);
assert_eq!(argmax_lowest_index(&[0.1, 0.6, 0.6]), 1);
assert_eq!(argmax_lowest_index(&[0.6, 0.1, 0.6]), 0);
assert_eq!(argmax_lowest_index(&[0.1, 0.2, 0.7]), 2);
}
#[test]
fn sklearn_equivalent_c_resolves_to_one_over_two_c_n_rows() {
let r = Regularization::SklearnEquivalentC { c: 1.0 };
assert!((r.resolve_lambda(24) - 1.0 / 48.0).abs() < 1e-15);
assert!(
(r.resolve_lambda(24) - 1.0 / 24.0).abs() > 1e-3,
"the factor-2 error must be separable at n=24"
);
assert!((Regularization::Lambda(0.07).resolve_lambda(24) - 0.07).abs() < 1e-15);
}
#[test]
fn k3_separable_fit_returns_ok_with_finite_probabilities_summing_to_one() {
let head = fit_k3();
let report = head.report().expect("report recorded");
assert_eq!(report.status, ConvergenceStatus::Converged);
assert_eq!(head.n_features(), Some(2));
assert_eq!(head.weights().len(), 3 * 2);
assert_eq!(head.intercepts().len(), 3);
let (x, y, l) = k3_separable();
let probs = head.predict_proba(&x).expect("predict_proba");
assert_eq!(probs.len(), x.len());
for (i, row) in probs.iter().enumerate() {
assert_eq!(row.len(), 3);
let mut sum = 0.0;
for (c, p) in row.iter().enumerate() {
assert!(p.is_finite(), "p[{i}][{c}] = {p} is not finite");
sum += p;
}
assert!((sum - 1.0).abs() < 1e-6, "row {i} sums to {sum}");
}
let predicted = head.predict(&x).expect("predict");
for (i, label) in predicted.iter().enumerate() {
assert_eq!(label, &l[y[i]], "row {i} misclassified on separable data");
}
}
#[test]
fn k2_binary_boundary_fit_predict_proba_and_labels() {
let (x, y, l) = k2_separable();
let mut head = MultinomialLogisticRegression::new(2);
let report = head
.fit(&x, &y, &l, Regularization::Lambda(0.01))
.expect("k2 separable fit converges");
assert_eq!(report.status, ConvergenceStatus::Converged);
assert_eq!(head.weights().len(), 2 * 2);
assert_eq!(head.intercepts().len(), 2);
let probs = head.predict_proba(&x).expect("predict_proba");
for (i, row) in probs.iter().enumerate() {
assert_eq!(row.len(), 2);
let sum: f64 = row.iter().sum();
assert!(row.iter().all(|p| p.is_finite()), "row {i} not finite");
assert!((sum - 1.0).abs() < 1e-6, "row {i} sums to {sum}");
}
let predicted = head.predict(&x).expect("predict");
for (i, label) in predicted.iter().enumerate() {
assert!(
l.contains(label),
"row {i} predicted {label:?}, not in {l:?}"
);
assert_eq!(label, &l[y[i]], "row {i} misclassified on separable data");
}
}
#[test]
fn predict_breaks_exact_probability_tie_to_lowest_label_index() {
for k in [2_usize, 3] {
let (x, y, l) = duplicate_row_tie(k);
let mut head = MultinomialLogisticRegression::new(k);
head.fit(&x, &y, &l, Regularization::Lambda(0.0))
.expect("degenerate duplicate-row fit converges at iteration 0");
assert!(
head.weights().iter().all(|w| *w == 0.0),
"K={k}: weights are not exactly zero: {:?}",
head.weights()
);
assert!(
head.intercepts().iter().all(|b| *b == 0.0),
"K={k}: intercepts are not exactly zero: {:?}",
head.intercepts()
);
let probe = vec![vec![0.7_f32, -1.3]];
let probs = head.predict_proba(&probe).expect("predict_proba");
for c in 1..k {
assert_eq!(
probs[0][c], probs[0][0],
"K={k}: probabilities must be EXACTLY tied, got {:?}",
probs[0]
);
}
let predicted = head.predict(&probe).expect("predict");
assert_eq!(
predicted[0], l[0],
"K={k}: an exact tie must resolve to the lowest label index"
);
}
}
#[test]
fn predict_near_f32_max_row_accumulates_in_f64_where_f32_would_overflow() {
let head = fit_k3();
let d = head.n_features().expect("fitted");
let w = head.weights();
let mut best_class = 0;
let mut best_l1 = 0.0_f32;
for c in 0..head.n_classes() {
let l1: f32 = (0..d).map(|j| w[c * d + j].abs()).sum();
if l1 > best_l1 {
best_l1 = l1;
best_class = c;
}
}
assert!(
best_l1 > 1.0,
"precondition: sum |w| = {best_l1} must exceed 1 for the f32 accumulation \
to overflow at f32::MAX inputs"
);
let row: Vec<f32> = (0..d)
.map(|j| {
if w[best_class * d + j] >= 0.0 {
f32::MAX
} else {
-f32::MAX
}
})
.collect();
assert!(
row.iter().all(|v| v.is_finite()),
"every individual feature value must be finite"
);
let mut z32 = head.intercepts()[best_class];
for j in 0..d {
z32 += w[best_class * d + j] * row[j];
}
assert!(
!z32.is_finite(),
"witness failed: the f32 accumulation produced {z32}, so this row does not \
exercise the overflow this test exists for"
);
let probs = head
.predict_proba(&[row])
.expect("f64 accumulation keeps the logits finite");
let sum: f64 = probs[0].iter().sum();
assert!(
probs[0].iter().all(|p| p.is_finite()),
"probabilities must be finite, got {:?}",
probs[0]
);
assert!((sum - 1.0).abs() < 1e-6, "probabilities sum to {sum}");
assert_eq!(
argmax_lowest_index(&probs[0]),
best_class,
"the aligned class must dominate"
);
}
#[test]
fn predict_row_with_infinite_feature_yields_nonfinite_logit_error() {
let head = fit_k3();
let err = head
.predict_proba(&[vec![1.0, f32::INFINITY]])
.expect_err("an infinite feature must not produce a probability");
match err {
HeadFitError::NonFiniteLogit { row, class } => {
assert_eq!(row, 0);
assert!(class < 3, "class {class} out of range");
}
other => panic!("expected NonFiniteLogit, got {other:?}"),
}
}
#[test]
fn max_iter_one_on_nontrivial_data_returns_not_converged() {
let (x, y, l) = k3_separable();
let mut head = MultinomialLogisticRegression::new(3).with_max_iter(1);
let err = head
.fit(&x, &y, &l, Regularization::Lambda(0.01))
.expect_err("one iteration cannot converge on this data");
match err {
HeadFitError::NotConverged {
iterations,
gradient_norm,
tol,
} => {
assert_eq!(iterations, 1, "the budget was 1 iteration");
assert!(gradient_norm > tol, "grad {gradient_norm} vs tol {tol}");
assert!((tol - DEFAULT_TOL).abs() < 1e-18);
}
other => panic!("expected NotConverged, got {other:?}"),
}
assert!(head.report().is_none(), "a failed fit records no report");
}
#[test]
fn fitted_intercept_mean_is_zero_within_1e_9() {
for (x, y, l, k) in [
{
let (x, y, l) = k3_separable();
(x, y, l, 3)
},
{
let (x, y, l) = k2_separable();
(x, y, l, 2)
},
] {
let mut head = MultinomialLogisticRegression::new(k);
head.fit(&x, &y, &l, Regularization::Lambda(0.01))
.expect("fit converges");
let b = head.intercepts_f64();
assert_eq!(b.len(), k);
let mean: f64 = b.iter().sum::<f64>() / k as f64;
assert!(
mean.abs() < 1e-9,
"K={k}: intercept mean {mean:e} left the centered gauge (b = {b:?})"
);
}
}
#[test]
fn two_identical_fits_produce_bitwise_identical_f32_weights() {
let (x, y, l) = k3_separable();
let mut a = MultinomialLogisticRegression::new(3);
let mut b = MultinomialLogisticRegression::new(3);
let ra = a
.fit(&x, &y, &l, Regularization::Lambda(0.01))
.expect("fit a");
let rb = b
.fit(&x, &y, &l, Regularization::Lambda(0.01))
.expect("fit b");
assert_eq!(ra, rb, "identical fits must produce identical reports");
let bits_a: Vec<u32> = a.weights().iter().map(|w| w.to_bits()).collect();
let bits_b: Vec<u32> = b.weights().iter().map(|w| w.to_bits()).collect();
assert_eq!(
bits_a, bits_b,
"stored f32 weights are not bitwise identical"
);
let ib_a: Vec<u32> = a.intercepts().iter().map(|v| v.to_bits()).collect();
let ib_b: Vec<u32> = b.intercepts().iter().map(|v| v.to_bits()).collect();
assert_eq!(
ib_a, ib_b,
"stored f32 intercepts are not bitwise identical"
);
}
#[test]
fn head_fit_report_serializes_stably_across_two_runs() {
let (x, y, l) = k3_separable();
let mut a = MultinomialLogisticRegression::new(3);
let mut b = MultinomialLogisticRegression::new(3);
let ra = a
.fit(&x, &y, &l, Regularization::Lambda(0.01))
.expect("fit a");
let rb = b
.fit(&x, &y, &l, Regularization::Lambda(0.01))
.expect("fit b");
let ja = serde_json::to_string(&ra).expect("serialize a");
let jb = serde_json::to_string(&rb).expect("serialize b");
assert_eq!(ja, jb, "report JSON is not stable across runs");
assert!(
ja.contains("final_grad_norm") && ja.contains("objective"),
"report JSON missing expected fields: {ja}"
);
let round: HeadFitReport = serde_json::from_str(&ja).expect("deserialize");
assert_eq!(round.status, ra.status);
assert_eq!(round.iterations, ra.iterations);
assert_eq!(
round.objective.to_bits(),
ra.objective.to_bits(),
"objective must survive the round trip bit for bit"
);
assert_eq!(
round.final_grad_norm.to_bits(),
ra.final_grad_norm.to_bits(),
"final_grad_norm must survive the round trip bit for bit"
);
assert_eq!(
serde_json::to_string(&round).expect("re-serialize"),
ja,
"re-serializing a parsed report must reproduce its own bytes"
);
}
#[test]
fn json_roundtrip_of_an_f64_is_bit_exact() {
let clean = 0.11346603265462092_f64;
let s_clean = serde_json::to_string(&clean).expect("serialize");
let back_clean: f64 = serde_json::from_str(&s_clean).expect("deserialize");
assert_eq!(
back_clean.to_bits(),
clean.to_bits(),
"{s_clean} should round-trip"
);
let measured = 2.1531120041346774e-5_f64;
assert_eq!(measured.to_bits(), 0x3ef6_93b7_4d83_1429);
let serialized = serde_json::to_string(&measured).expect("serialize");
assert_eq!(
serialized, "0.000021531120041346774",
"the SERIALIZED form is the stable, exact one; if this changed, re-measure the \
whole observation below rather than patching the constant"
);
let back: f64 = serde_json::from_str(&serialized).expect("deserialize");
assert_eq!(
back.to_bits(),
measured.to_bits(),
"`{serialized}` parsed back to bits {:#018x} instead of {:#018x}. serde_json's \
`float_roundtrip` feature is not engaged for this build, and every artifact \
whose reload is verified by comparing re-serialized bytes will report a \
spurious mismatch.",
back.to_bits(),
measured.to_bits(),
);
}
#[test]
fn predict_before_fit_returns_not_fitted() {
let head = MultinomialLogisticRegression::new(3);
assert_eq!(
head.predict_proba(&[vec![0.0, 0.0]]),
Err(HeadFitError::NotFitted)
);
assert_eq!(
head.predict_indices(&[vec![0.0, 0.0]]),
Err(HeadFitError::NotFitted)
);
}
fn expect_input_error(
head: &mut MultinomialLogisticRegression,
x: &[Vec<f32>],
y: &[usize],
l: &[String],
r: Regularization,
) -> HeadInputError {
match head.fit(x, y, l, r) {
Err(HeadFitError::InvalidInput(e)) => e,
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[test]
fn invalid_k_less_than_two() {
let (x, y, _) = k3_separable();
let mut head = MultinomialLogisticRegression::new(1);
let e = expect_input_error(
&mut head,
&x,
&y,
&labels(&["only"]),
Regularization::Lambda(0.0),
);
assert_eq!(e, HeadInputError::TooFewClasses { k: 1 });
}
#[test]
fn invalid_ordered_label_count_mismatch() {
let (x, y, _) = k3_separable();
let mut head = MultinomialLogisticRegression::new(3);
let e = expect_input_error(
&mut head,
&x,
&y,
&labels(&["a", "b"]),
Regularization::Lambda(0.0),
);
assert_eq!(e, HeadInputError::LabelCountMismatch { labels: 2, k: 3 });
}
#[test]
fn invalid_empty_ordered_label() {
let (x, y, _) = k3_separable();
let mut head = MultinomialLogisticRegression::new(3);
let e = expect_input_error(
&mut head,
&x,
&y,
&labels(&["a", "", "c"]),
Regularization::Lambda(0.0),
);
assert_eq!(e, HeadInputError::EmptyLabel { index: 1 });
}
#[test]
fn invalid_duplicate_ordered_labels() {
let (x, y, _) = k3_separable();
let mut head = MultinomialLogisticRegression::new(3);
let e = expect_input_error(
&mut head,
&x,
&y,
&labels(&["a", "b", "a"]),
Regularization::Lambda(0.0),
);
assert_eq!(
e,
HeadInputError::DuplicateLabel {
first: 0,
second: 2,
label: "a".to_string()
}
);
}
#[test]
fn invalid_empty_dataset() {
let mut head = MultinomialLogisticRegression::new(3);
let e = expect_input_error(
&mut head,
&[],
&[],
&labels(&["a", "b", "c"]),
Regularization::Lambda(0.0),
);
assert_eq!(e, HeadInputError::EmptyDataset);
}
#[test]
fn invalid_row_count_mismatch() {
let (x, _, l) = k3_separable();
let mut head = MultinomialLogisticRegression::new(3);
let e = expect_input_error(&mut head, &x, &[0, 1, 2], &l, Regularization::Lambda(0.0));
assert_eq!(
e,
HeadInputError::RowCountMismatch {
rows: 9,
class_indices: 3
}
);
}
#[test]
fn invalid_zero_feature_dimension() {
let mut head = MultinomialLogisticRegression::new(3);
let x = vec![Vec::<f32>::new(), Vec::new(), Vec::new()];
let e = expect_input_error(
&mut head,
&x,
&[0, 1, 2],
&labels(&["a", "b", "c"]),
Regularization::Lambda(0.0),
);
assert_eq!(e, HeadInputError::ZeroFeatureDimension);
}
#[test]
fn invalid_ragged_rows() {
let mut head = MultinomialLogisticRegression::new(3);
let x = vec![vec![0.0, 1.0], vec![1.0, 0.0, 2.0], vec![2.0, 2.0]];
let e = expect_input_error(
&mut head,
&x,
&[0, 1, 2],
&labels(&["a", "b", "c"]),
Regularization::Lambda(0.0),
);
assert_eq!(
e,
HeadInputError::RaggedRow {
row: 1,
expected: 2,
found: 3
}
);
}
#[test]
fn invalid_nan_feature() {
let (mut x, y, l) = k3_separable();
x[4][1] = f32::NAN;
let mut head = MultinomialLogisticRegression::new(3);
let e = expect_input_error(&mut head, &x, &y, &l, Regularization::Lambda(0.0));
assert_eq!(e, HeadInputError::NanFeature { row: 4, col: 1 });
}
#[test]
fn invalid_infinite_feature() {
let (mut x, y, l) = k3_separable();
x[7][0] = f32::INFINITY;
let mut head = MultinomialLogisticRegression::new(3);
let e = expect_input_error(&mut head, &x, &y, &l, Regularization::Lambda(0.0));
assert_eq!(
e,
HeadInputError::InfiniteFeature {
row: 7,
col: 0,
value: f32::INFINITY
}
);
}
#[test]
fn invalid_label_index_out_of_range() {
let (x, mut y, l) = k3_separable();
y[5] = 3;
let mut head = MultinomialLogisticRegression::new(3);
let e = expect_input_error(&mut head, &x, &y, &l, Regularization::Lambda(0.0));
assert_eq!(
e,
HeadInputError::LabelIndexOutOfRange {
row: 5,
index: 3,
k: 3
}
);
}
#[test]
fn invalid_unrepresented_class() {
let (x, mut y, l) = k3_separable();
for v in &mut y {
if *v == 1 {
*v = 0;
}
}
let mut head = MultinomialLogisticRegression::new(3);
let e = expect_input_error(&mut head, &x, &y, &l, Regularization::Lambda(0.0));
assert_eq!(e, HeadInputError::UnrepresentedClass { class: 1 });
}
#[test]
fn invalid_negative_lambda() {
let (x, y, l) = k3_separable();
let mut head = MultinomialLogisticRegression::new(3);
let e = expect_input_error(&mut head, &x, &y, &l, Regularization::Lambda(-1e-6));
assert_eq!(e, HeadInputError::NegativeLambda { lambda: -1e-6 });
}
#[test]
fn invalid_nan_lambda() {
let (x, y, l) = k3_separable();
let mut head = MultinomialLogisticRegression::new(3);
let e = expect_input_error(&mut head, &x, &y, &l, Regularization::Lambda(f64::NAN));
match e {
HeadInputError::NonFiniteLambda { lambda } => assert!(lambda.is_nan()),
other => panic!("expected NonFiniteLambda, got {other:?}"),
}
}
#[test]
fn invalid_sklearn_c_non_positive() {
let (x, y, l) = k3_separable();
let mut head = MultinomialLogisticRegression::new(3);
let e = expect_input_error(
&mut head,
&x,
&y,
&l,
Regularization::SklearnEquivalentC { c: 0.0 },
);
assert_eq!(e, HeadInputError::NonPositiveC { c: 0.0 });
}
#[test]
fn invalid_sklearn_c_non_finite() {
let (x, y, l) = k3_separable();
let mut head = MultinomialLogisticRegression::new(3);
let e = expect_input_error(
&mut head,
&x,
&y,
&l,
Regularization::SklearnEquivalentC { c: f64::INFINITY },
);
assert_eq!(e, HeadInputError::NonFiniteC { c: f64::INFINITY });
}
#[test]
fn invalid_predict_feature_dim_mismatch() {
let head = fit_k3();
for probe in [vec![vec![1.0_f32]], vec![vec![1.0_f32, 2.0, 3.0]]] {
let found = probe[0].len();
let err = head
.predict_proba(&probe)
.expect_err("dimension mismatch must be rejected");
assert_eq!(
err,
HeadFitError::InvalidInput(HeadInputError::FeatureDimMismatch {
row: 0,
expected: 2,
found
})
);
let err = head
.predict(&probe)
.expect_err("dimension mismatch must be rejected by predict too");
assert_eq!(
err,
HeadFitError::InvalidInput(HeadInputError::FeatureDimMismatch {
row: 0,
expected: 2,
found
})
);
}
}
#[test]
fn typed_errors_render_informative_messages() {
let cases: Vec<(HeadInputError, &str)> = vec![
(HeadInputError::TooFewClasses { k: 1 }, "K >= 2"),
(HeadInputError::EmptyDataset, "no feature rows"),
(HeadInputError::ZeroFeatureDimension, "dimension is 0"),
(HeadInputError::NonPositiveC { c: 0.0 }, "strictly positive"),
];
for (e, needle) in cases {
let rendered = e.to_string();
assert!(
rendered.contains(needle),
"{rendered:?} does not mention {needle:?}"
);
}
let wrapped = HeadFitError::from(HeadInputError::EmptyDataset);
assert!(wrapped.to_string().starts_with("invalid input:"));
assert!(HeadFitError::NotFitted
.to_string()
.contains("not been fitted"));
assert!(HeadFitError::NonFiniteLogit { row: 2, class: 1 }
.to_string()
.contains("row 2, class 1"));
assert!(HeadFitError::Internal {
status: ConvergenceStatus::Running
}
.to_string()
.contains("Running"));
}
#[test]
fn problem_layout_places_intercepts_after_the_weight_block() {
let (x, y, _) = k3_separable();
let problem = SoftmaxNllProblem {
features: &x,
class_indices: &y,
n_classes: 3,
n_features: 2,
lambda: 0.0,
};
assert_eq!(problem.intercept_offset(), 6);
assert_eq!(problem.n_params(), 9);
}
#[test]
fn objective_at_zero_is_log_k() {
let (x, y, _) = k3_separable();
let problem = SoftmaxNllProblem {
features: &x,
class_indices: &y,
n_classes: 3,
n_features: 2,
lambda: 0.07,
};
let x0 = Vector::from_vec(vec![0.0; problem.n_params()]);
let j = problem.objective(&x0);
assert!(
(j - 3.0_f64.ln()).abs() < 1e-12,
"objective at zero = {j}, expected ln(3) = {}",
3.0_f64.ln()
);
}
#[test]
fn gradient_intercept_block_sums_to_zero_at_every_point() {
let (x, y, _) = k3_separable();
let problem = SoftmaxNllProblem {
features: &x,
class_indices: &y,
n_classes: 3,
n_features: 2,
lambda: 0.07,
};
for scale in [0.0_f64, 0.3, -1.7] {
let params: Vec<f64> = (0..problem.n_params())
.map(|i| scale * ((i % 5) as f64 - 2.0))
.collect();
let g = problem.gradient(&Vector::from_vec(params));
let off = problem.intercept_offset();
let sum: f64 = (0..3).map(|c| g[off + c]).sum();
assert!(
sum.abs() < 1e-12,
"scale {scale}: intercept gradient block sums to {sum:e}, not 0"
);
}
}
}