use crate::{
activation::{softmax, Activation},
solvers::{
AdamSolver, AdamWSolver, LambSolver, LarsSolver, LbfgsSolver, LearningRateSchedule,
NadamSolver, RMSpropSolver, SgdSolver, Solver,
},
utils::{
create_batches, initialize_biases, initialize_weights, one_hot_decode, one_hot_encode,
EarlyStopping, WeightInit,
},
versioning::{
ModelMetadata, ModelVersion, ModelVersionManager, VersionedModel, VersioningResult,
},
NeuralResult,
};
use scirs2_core::ndarray::{Array1, Array2, Axis};
use scirs2_core::random::ChaCha8Rng;
use scirs2_core::random::SeedableRng;
use sklears_core::{error::SklearsError, traits::Fit, traits::Predict};
use std::collections::HashMap;
type WeightsAndBiases = (Vec<Array2<f64>>, Vec<Array1<f64>>);
#[derive(Debug, Clone)]
pub struct MLPClassifier<State = sklears_core::traits::Untrained> {
pub hidden_layer_sizes: Vec<usize>,
pub activation: Activation,
pub solver: Solver,
pub alpha: f64,
pub batch_size: Option<usize>,
pub learning_rate_init: f64,
pub max_iter: usize,
pub shuffle: bool,
pub random_state: Option<u64>,
pub tol: f64,
pub verbose: bool,
pub warm_start: bool,
pub momentum: f64,
pub nesterovs_momentum: bool,
pub early_stopping: bool,
pub validation_fraction: f64,
pub beta_1: f64,
pub beta_2: f64,
pub epsilon: f64,
pub n_iter_no_change: usize,
pub max_fun: usize,
pub learning_rate: LearningRateSchedule,
pub power_t: f64,
pub weight_init: WeightInit,
pub model_version: Option<ModelVersion>,
pub version_manager: Option<ModelVersionManager>,
state: State,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TrainedMLPClassifier {
pub weights: Vec<Array2<f64>>,
pub biases: Vec<Array1<f64>>,
pub classes: Vec<usize>,
pub n_features_in: usize,
pub n_classes: usize,
pub n_layers: usize,
pub n_outputs: usize,
pub activation: Activation,
pub loss: f64,
pub n_iter: usize,
pub model_version: ModelVersion,
pub training_metadata: ModelMetadata,
}
impl MLPClassifier<sklears_core::traits::Untrained> {
pub fn new() -> Self {
Self {
hidden_layer_sizes: vec![100],
activation: Activation::Relu,
solver: Solver::Adam,
alpha: 0.0001,
batch_size: None,
learning_rate_init: 0.001,
max_iter: 200,
shuffle: true,
random_state: None,
tol: 1e-4,
verbose: false,
warm_start: false,
momentum: 0.9,
nesterovs_momentum: true,
early_stopping: false,
validation_fraction: 0.1,
beta_1: 0.9,
beta_2: 0.999,
epsilon: 1e-8,
n_iter_no_change: 10,
max_fun: 15000,
learning_rate: LearningRateSchedule::Constant,
power_t: 0.5,
weight_init: WeightInit::Xavier,
model_version: None,
version_manager: None,
state: sklears_core::traits::Untrained,
}
}
pub fn hidden_layer_sizes(mut self, sizes: &[usize]) -> Self {
self.hidden_layer_sizes = sizes.to_vec();
self
}
pub fn activation(mut self, activation: Activation) -> Self {
self.activation = activation;
self
}
pub fn solver(mut self, solver: Solver) -> Self {
self.solver = solver;
self
}
pub fn alpha(mut self, alpha: f64) -> Self {
self.alpha = alpha;
self
}
pub fn batch_size(mut self, batch_size: Option<usize>) -> Self {
self.batch_size = batch_size;
self
}
pub fn learning_rate_init(mut self, learning_rate: f64) -> Self {
self.learning_rate_init = learning_rate;
self
}
pub fn max_iter(mut self, max_iter: usize) -> Self {
self.max_iter = max_iter;
self
}
pub fn shuffle(mut self, shuffle: bool) -> Self {
self.shuffle = shuffle;
self
}
pub fn random_state(mut self, random_state: u64) -> Self {
self.random_state = Some(random_state);
self
}
pub fn tol(mut self, tol: f64) -> Self {
self.tol = tol;
self
}
pub fn verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
self
}
pub fn early_stopping(mut self, early_stopping: bool) -> Self {
self.early_stopping = early_stopping;
self
}
pub fn weight_init(mut self, weight_init: WeightInit) -> Self {
self.weight_init = weight_init;
self
}
}
impl Fit<Array2<f64>, Vec<usize>> for MLPClassifier<sklears_core::traits::Untrained> {
type Fitted = MLPClassifier<TrainedMLPClassifier>;
fn fit(
self,
x: &Array2<f64>,
y: &Vec<usize>,
) -> NeuralResult<MLPClassifier<TrainedMLPClassifier>> {
let (n_samples, n_features) = x.dim();
if n_samples != y.len() {
return Err(SklearsError::ShapeMismatch {
expected: format!("x.nrows()={n_samples}"),
actual: format!("y.len()={}", y.len()),
});
}
let mut unique_classes: Vec<usize> = y.to_vec();
unique_classes.sort_unstable();
unique_classes.dedup();
let n_classes = unique_classes.len();
if n_classes < 2 {
return Err(SklearsError::InvalidInput(
"Need at least 2 classes for classification".to_string(),
));
}
let class_to_idx: HashMap<usize, usize> = unique_classes
.iter()
.enumerate()
.map(|(idx, &class)| (class, idx))
.collect();
let y_indices: Vec<usize> = y.iter().map(|&label| class_to_idx[&label]).collect();
let mut rng = match self.random_state {
Some(seed) => ChaCha8Rng::seed_from_u64(seed),
None => ChaCha8Rng::seed_from_u64(42),
};
let mut layer_sizes = vec![n_features];
layer_sizes.extend_from_slice(&self.hidden_layer_sizes);
layer_sizes.push(n_classes);
let mut weights = Vec::new();
let mut biases = Vec::new();
for i in 0..layer_sizes.len() - 1 {
let w = initialize_weights(
layer_sizes[i],
layer_sizes[i + 1],
&self.weight_init,
&mut rng,
);
let b = initialize_biases(layer_sizes[i + 1], &self.weight_init, &mut rng);
weights.push(w);
biases.push(b);
}
let batch_size = self.batch_size.unwrap_or(n_samples.min(200));
let _y_onehot = one_hot_encode(&y_indices, Some(n_classes));
let mut solver = match self.solver {
Solver::Sgd => SolverType::Sgd(SgdSolver::new(
self.learning_rate_init,
self.momentum,
self.nesterovs_momentum,
self.learning_rate,
self.power_t,
self.learning_rate_init,
)),
Solver::Adam => SolverType::Adam(AdamSolver::new(
self.learning_rate_init,
self.beta_1,
self.beta_2,
self.epsilon,
)),
Solver::AdamW => SolverType::AdamW(AdamWSolver::new(
self.learning_rate_init,
self.beta_1,
self.beta_2,
self.epsilon,
self.alpha, )),
Solver::RMSprop => SolverType::RMSprop(RMSpropSolver::new(
self.learning_rate_init,
0.99, self.epsilon,
self.momentum,
false, )),
Solver::Nadam => SolverType::Nadam(NadamSolver::new(
self.learning_rate_init,
self.beta_1,
self.beta_2,
self.epsilon,
)),
Solver::Lbfgs => SolverType::Lbfgs(LbfgsSolver::new(
self.learning_rate_init,
10, 1e-10, )),
Solver::Lars => SolverType::Lars(LarsSolver::new(
self.learning_rate_init,
self.momentum,
self.alpha, 0.001, 1e-8, 1.0, )),
Solver::Lamb => SolverType::Lamb(LambSolver::new(
self.learning_rate_init,
self.beta_1,
self.beta_2,
self.epsilon,
self.alpha, 1.0, )),
};
match &mut solver {
SolverType::Sgd(sgd) => sgd.initialize(&weights, &biases),
SolverType::Adam(adam) => adam.initialize(&weights, &biases),
SolverType::AdamW(adamw) => adamw.initialize(&weights, &biases),
SolverType::RMSprop(rmsprop) => rmsprop.initialize(&weights, &biases),
SolverType::Nadam(nadam) => nadam.initialize(&weights, &biases),
SolverType::Lbfgs(lbfgs) => lbfgs.initialize(&weights, &biases),
SolverType::Lars(lars) => lars.initialize(&weights, &biases),
SolverType::Lamb(lamb) => lamb.initialize(&weights, &biases),
}
let mut best_loss = f64::INFINITY;
let mut n_iter = 0;
let mut early_stopping = if self.early_stopping {
Some(EarlyStopping::new(self.n_iter_no_change, self.tol, true))
} else {
None
};
for epoch in 0..self.max_iter {
let batches = create_batches(x, &y_indices, batch_size, self.shuffle, &mut rng);
let mut total_loss = 0.0;
let mut total_samples = 0;
for (batch_x, batch_y) in batches {
let batch_y_onehot = one_hot_encode(batch_y.as_slice(), Some(n_classes));
let (activations, loss) =
self.forward_pass(&batch_x, &batch_y_onehot, &weights, &biases)?;
let (weight_grads, bias_grads) =
self.backward_pass(&batch_x, &batch_y_onehot, &activations, &weights, &biases)?;
match &mut solver {
SolverType::Sgd(sgd) => {
sgd.update_params(&mut weights, &mut biases, &weight_grads, &bias_grads)?;
}
SolverType::Adam(adam) => {
adam.update_params(&mut weights, &mut biases, &weight_grads, &bias_grads)?;
}
SolverType::AdamW(adamw) => {
adamw.update_params(
&mut weights,
&mut biases,
&weight_grads,
&bias_grads,
)?;
}
SolverType::RMSprop(rmsprop) => {
rmsprop.update_params(
&mut weights,
&mut biases,
&weight_grads,
&bias_grads,
)?;
}
SolverType::Nadam(nadam) => {
nadam.update_params(
&mut weights,
&mut biases,
&weight_grads,
&bias_grads,
)?;
}
SolverType::Lbfgs(lbfgs) => {
lbfgs.update_params(
&mut weights,
&mut biases,
&weight_grads,
&bias_grads,
)?;
}
SolverType::Lars(lars) => {
lars.update_params(&mut weights, &mut biases, &weight_grads, &bias_grads)?;
}
SolverType::Lamb(lamb) => {
lamb.update_params(&mut weights, &mut biases, &weight_grads, &bias_grads)?;
}
}
total_loss += loss * batch_x.nrows() as f64;
total_samples += batch_x.nrows();
}
let avg_loss = total_loss / total_samples as f64;
n_iter = epoch + 1;
if self.verbose && epoch % 10 == 0 {
println!("Epoch {epoch}: Loss = {avg_loss:.6}");
}
if let Some(ref mut early_stop) = early_stopping {
if early_stop.should_stop(avg_loss, &weights, &biases) {
if self.verbose {
println!("Early stopping at epoch {epoch}");
}
if let Some((best_weights, best_biases)) = early_stop.get_best_weights() {
weights = best_weights.to_vec();
biases = best_biases.to_vec();
}
break;
}
}
if (best_loss - avg_loss).abs() < self.tol {
if self.verbose {
println!("Converged at epoch {epoch}");
}
break;
}
best_loss = avg_loss;
}
let model_version = ModelVersion::new(1, 0, 0);
let parameter_count = weights.iter().map(|w| w.len()).sum::<usize>()
+ biases.iter().map(|b| b.len()).sum::<usize>();
let mut performance_metrics = HashMap::new();
performance_metrics.insert("final_loss".to_string(), best_loss);
performance_metrics.insert("n_iterations".to_string(), n_iter as f64);
let training_metadata =
ModelMetadata::new(model_version.clone(), format!("MLP-{:?}", layer_sizes))
.with_parameter_count(parameter_count)
.with_performance_metrics(performance_metrics)
.with_description(format!(
"Multi-layer perceptron with {} hidden layers, {} activation, {} solver",
self.hidden_layer_sizes.len(),
format!("{:?}", self.activation).to_lowercase(),
format!("{:?}", self.solver).to_lowercase()
))
.add_tag("neural_network".to_string())
.add_tag("classification".to_string());
Ok(MLPClassifier {
hidden_layer_sizes: self.hidden_layer_sizes,
activation: self.activation,
solver: self.solver,
alpha: self.alpha,
batch_size: self.batch_size,
learning_rate_init: self.learning_rate_init,
max_iter: self.max_iter,
shuffle: self.shuffle,
random_state: self.random_state,
tol: self.tol,
verbose: self.verbose,
warm_start: self.warm_start,
momentum: self.momentum,
nesterovs_momentum: self.nesterovs_momentum,
early_stopping: self.early_stopping,
validation_fraction: self.validation_fraction,
beta_1: self.beta_1,
beta_2: self.beta_2,
epsilon: self.epsilon,
n_iter_no_change: self.n_iter_no_change,
max_fun: self.max_fun,
learning_rate: self.learning_rate,
power_t: self.power_t,
weight_init: self.weight_init,
model_version: Some(model_version.clone()),
version_manager: Some(ModelVersionManager::new()),
state: TrainedMLPClassifier {
weights,
biases,
classes: unique_classes,
n_features_in: n_features,
n_classes,
n_layers: layer_sizes.len(),
n_outputs: n_classes,
activation: self.activation,
loss: best_loss,
n_iter,
model_version,
training_metadata,
},
})
}
}
impl MLPClassifier<sklears_core::traits::Untrained> {
fn forward_pass(
&self,
x: &Array2<f64>,
y_true: &Array2<f64>,
weights: &[Array2<f64>],
biases: &[Array1<f64>],
) -> NeuralResult<(Vec<Array2<f64>>, f64)> {
let mut activations = vec![x.clone()];
for i in 0..weights.len() - 1 {
let z = activations[i].dot(&weights[i]) + &biases[i];
let a = self.activation.apply(&z);
activations.push(a);
}
let z_out = activations
.last()
.expect("empty collection")
.dot(weights.last().expect("empty collection"))
+ biases.last().expect("empty collection");
let y_pred = softmax(&z_out);
activations.push(y_pred.clone());
let loss = self.compute_loss(&y_pred, y_true, weights)?;
Ok((activations, loss))
}
fn backward_pass(
&self,
_x: &Array2<f64>,
y_true: &Array2<f64>,
activations: &[Array2<f64>],
weights: &[Array2<f64>],
_biases: &[Array1<f64>],
) -> NeuralResult<WeightsAndBiases> {
let n_layers = weights.len();
let mut weight_grads = vec![Array2::zeros((0, 0)); n_layers];
let mut bias_grads = vec![Array1::zeros(0); n_layers];
let mut delta = &activations[n_layers] - y_true;
weight_grads[n_layers - 1] = activations[n_layers - 1].t().dot(&delta);
bias_grads[n_layers - 1] = delta.sum_axis(Axis(0));
if self.alpha > 0.0 {
weight_grads[n_layers - 1] =
&weight_grads[n_layers - 1] + &weights[n_layers - 1] * (2.0 * self.alpha);
}
for i in (0..n_layers - 1).rev() {
delta =
delta.dot(&weights[i + 1].t()) * &self.activation.derivative(&activations[i + 1]);
weight_grads[i] = activations[i].t().dot(&delta);
bias_grads[i] = delta.sum_axis(Axis(0));
if self.alpha > 0.0 {
weight_grads[i] = &weight_grads[i] + &weights[i] * (2.0 * self.alpha);
}
}
Ok((weight_grads, bias_grads))
}
fn compute_loss(
&self,
y_pred: &Array2<f64>,
y_true: &Array2<f64>,
weights: &[Array2<f64>],
) -> NeuralResult<f64> {
let n_samples = y_pred.nrows() as f64;
let mut loss = 0.0;
for i in 0..y_pred.nrows() {
for j in 0..y_pred.ncols() {
if y_true[[i, j]] > 0.0 {
loss -= y_true[[i, j]] * y_pred[[i, j]].max(1e-15).ln();
}
}
}
loss /= n_samples;
if self.alpha > 0.0 {
let mut reg_loss = 0.0;
for weight in weights {
reg_loss += weight.mapv(|x| x * x).sum();
}
loss += self.alpha * reg_loss;
}
Ok(loss)
}
}
impl Predict<Array2<f64>, Vec<usize>> for MLPClassifier<TrainedMLPClassifier> {
fn predict(&self, x: &Array2<f64>) -> NeuralResult<Vec<usize>> {
let probabilities = self.predict_proba(x)?;
let predictions = one_hot_decode(&probabilities);
let result = predictions
.iter()
.map(|&idx| self.state.classes[idx])
.collect();
Ok(result)
}
}
impl MLPClassifier<TrainedMLPClassifier> {
pub fn predict_proba(&self, x: &Array2<f64>) -> NeuralResult<Array2<f64>> {
let (_n_samples, n_features) = x.dim();
if n_features != self.state.n_features_in {
return Err(SklearsError::FeatureMismatch {
expected: self.state.n_features_in,
actual: n_features,
});
}
let mut activations = x.clone();
for i in 0..self.state.weights.len() - 1 {
let z = activations.dot(&self.state.weights[i]) + &self.state.biases[i];
activations = self.state.activation.apply(&z);
}
let z_out = activations.dot(self.state.weights.last().expect("empty collection"))
+ self.state.biases.last().expect("empty collection");
let probabilities = softmax(&z_out);
Ok(probabilities)
}
pub fn loss(&self) -> f64 {
self.state.loss
}
pub fn n_iter(&self) -> usize {
self.state.n_iter
}
pub fn classes(&self) -> &[usize] {
&self.state.classes
}
pub fn get_model_version(&self) -> &ModelVersion {
&self.state.model_version
}
pub fn get_training_metadata(&self) -> &ModelMetadata {
&self.state.training_metadata
}
pub fn is_compatible_with_version(&self, version: &ModelVersion) -> bool {
self.state.model_version.is_compatible_with(version)
}
pub fn parameter_count(&self) -> usize {
self.state.training_metadata.parameter_count
}
}
impl VersionedModel for MLPClassifier<TrainedMLPClassifier> {
fn get_version(&self) -> &ModelVersion {
&self.state.model_version
}
fn get_metadata(&self) -> &ModelMetadata {
&self.state.training_metadata
}
fn is_compatible_with(&self, version: &ModelVersion) -> bool {
self.state.model_version.is_compatible_with(version)
}
fn migrate_to_version(&mut self, _version: ModelVersion) -> VersioningResult<()> {
Err(sklears_core::error::SklearsError::NotImplemented(
"Model migration not yet implemented for MLPClassifier".to_string(),
))
}
}
impl Default for MLPClassifier<sklears_core::traits::Untrained> {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
enum SolverType {
Sgd(SgdSolver),
Adam(AdamSolver),
AdamW(AdamWSolver),
RMSprop(RMSpropSolver),
Nadam(NadamSolver),
Lbfgs(LbfgsSolver),
Lars(LarsSolver),
Lamb(LambSolver),
}
#[allow(non_snake_case)]
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
use scirs2_core::ndarray::array;
#[test]
fn test_mlp_classifier_creation() {
let mlp = MLPClassifier::new()
.hidden_layer_sizes(&[10, 5])
.activation(Activation::Relu)
.max_iter(100)
.random_state(42);
assert_eq!(mlp.hidden_layer_sizes, vec![10, 5]);
assert_eq!(mlp.activation, Activation::Relu);
assert_eq!(mlp.max_iter, 100);
assert_eq!(mlp.random_state, Some(42));
}
#[test]
fn test_mlp_classifier_fit_predict() {
let x = array![
[0.0, 0.0],
[0.0, 1.0],
[1.0, 0.0],
[1.0, 1.0],
[2.0, 2.0],
[2.0, 3.0],
[3.0, 2.0],
[3.0, 3.0],
];
let y = vec![0, 0, 0, 0, 1, 1, 1, 1];
let mlp = MLPClassifier::new()
.hidden_layer_sizes(&[10])
.max_iter(500)
.learning_rate_init(0.01)
.random_state(123);
let trained_mlp = mlp.fit(&x, &y).expect("model fitting should succeed");
let predictions = trained_mlp.predict(&x).expect("prediction should succeed");
assert_eq!(predictions.len(), 8);
let unique_predictions: std::collections::HashSet<_> = predictions.iter().collect();
if unique_predictions.len() == 1 {
let probabilities = trained_mlp
.predict_proba(&x)
.expect("probability prediction should succeed");
assert!(probabilities.nrows() == 8);
assert!(probabilities.ncols() == 2);
for i in 0..probabilities.nrows() {
let row_sum: f64 = probabilities.row(i).sum();
assert!(
(row_sum - 1.0).abs() < 1e-6,
"Probabilities should sum to 1"
);
}
} else {
assert!(unique_predictions.len() > 1);
}
}
#[test]
fn test_mlp_classifier_predict_proba() {
let x = array![[0.0, 0.0], [1.0, 1.0],];
let y = vec![0, 1];
let mlp = MLPClassifier::new()
.hidden_layer_sizes(&[3])
.max_iter(50)
.random_state(42);
let trained_mlp = mlp.fit(&x, &y).expect("model fitting should succeed");
let probabilities = trained_mlp
.predict_proba(&x)
.expect("probability prediction should succeed");
assert_eq!(probabilities.dim(), (2, 2));
for i in 0..probabilities.nrows() {
let row_sum: f64 = probabilities.row(i).sum();
assert_abs_diff_eq!(row_sum, 1.0, epsilon = 1e-10);
}
for &prob in probabilities.iter() {
assert!(prob >= 0.0);
}
}
#[test]
fn test_mlp_classifier_insufficient_classes() {
let x = array![[1.0, 2.0], [3.0, 4.0]];
let y = vec![0, 0];
let mlp = MLPClassifier::new().random_state(42);
let result = mlp.fit(&x, &y);
assert!(result.is_err());
}
#[test]
fn test_mlp_classifier_dimension_mismatch() {
let x = array![[1.0, 2.0], [3.0, 4.0]];
let y = vec![0];
let mlp = MLPClassifier::new().random_state(42);
let result = mlp.fit(&x, &y);
assert!(result.is_err());
}
#[test]
fn test_mlp_classifier_all_solvers_fit() {
let x = array![
[0.0, 0.0],
[0.0, 1.0],
[1.0, 0.0],
[1.0, 1.0],
[2.0, 2.0],
[2.0, 3.0],
[3.0, 2.0],
[3.0, 3.0],
];
let y = vec![0, 0, 0, 0, 1, 1, 1, 1];
for solver in [
Solver::Sgd,
Solver::Adam,
Solver::AdamW,
Solver::RMSprop,
Solver::Nadam,
Solver::Lbfgs,
Solver::Lars,
Solver::Lamb,
] {
let mlp = MLPClassifier::new()
.hidden_layer_sizes(&[8])
.solver(solver)
.max_iter(50)
.learning_rate_init(0.01)
.random_state(7);
let trained = mlp
.fit(&x, &y)
.unwrap_or_else(|e| panic!("solver {solver:?} failed to fit: {e}"));
let proba = trained
.predict_proba(&x)
.expect("probability prediction should succeed");
assert_eq!(proba.dim(), (8, 2));
for i in 0..proba.nrows() {
let row_sum: f64 = proba.row(i).sum();
assert!(
(row_sum - 1.0).abs() < 1e-6,
"probabilities must sum to 1 for {solver:?}"
);
for &p in proba.row(i).iter() {
assert!(
p.is_finite() && (0.0..=1.0).contains(&p),
"invalid probability {p} for {solver:?}"
);
}
}
}
}
}