Skip to main content

sklears_multioutput/
mlp.rs

1//! Multi-Layer Perceptron for Multi-Output Learning
2//!
3//! This module provides a flexible Multi-Layer Perceptron implementation that can handle
4//! both regression and classification tasks with multiple outputs. It supports configurable
5//! architecture, activation functions, and training parameters.
6#![allow(non_snake_case)] // Standard ML notation: X for feature matrices, K for kernels
7
8// Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
9use scirs2_core::ndarray::{Array1, Array2, ArrayView2, Axis};
10use scirs2_core::random::RandNormal;
11use sklears_core::{
12    error::{Result as SklResult, SklearsError},
13    traits::{Estimator, Fit, Predict, Untrained},
14    types::Float,
15};
16
17use crate::activation::ActivationFunction;
18use crate::loss::LossFunction;
19
20/// Multi-Layer Perceptron for Multi-Output Learning
21///
22/// This neural network can handle both regression and classification tasks with multiple outputs.
23/// It supports configurable architecture, activation functions, and training parameters.
24///
25/// # Examples
26///
27/// ```
28/// use sklears_multioutput::mlp::{MultiOutputMLP};
29/// use sklears_multioutput::activation::ActivationFunction;
30/// use sklears_multioutput::loss::LossFunction;
31/// use sklears_core::traits::{Predict, Fit};
32/// // Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
33/// use scirs2_core::ndarray::array;
34///
35/// let X = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0], [4.0, 4.0]];
36/// let y = array![[0.5, 1.2], [1.0, 2.1], [1.5, 0.8], [2.0, 2.5]]; // Multi-output regression
37///
38/// let mlp = MultiOutputMLP::new()
39///     .hidden_layer_sizes(vec![10, 5])
40///     .activation(ActivationFunction::ReLU)
41///     .output_activation(ActivationFunction::Linear)
42///     .loss_function(LossFunction::MeanSquaredError)
43///     .learning_rate(0.01)
44///     .max_iter(1000)
45///     .random_state(Some(42));
46///
47/// let trained_mlp = mlp.fit(&X.view(), &y).unwrap();
48/// let predictions = trained_mlp.predict(&X.view()).unwrap();
49/// ```
50#[derive(Debug, Clone)]
51pub struct MultiOutputMLP<S = Untrained> {
52    state: S,
53    hidden_layer_sizes: Vec<usize>,
54    activation: ActivationFunction,
55    output_activation: ActivationFunction,
56    loss_function: LossFunction,
57    learning_rate: Float,
58    max_iter: usize,
59    tolerance: Float,
60    random_state: Option<u64>,
61    alpha: Float, // L2 regularization
62    batch_size: Option<usize>,
63    early_stopping: bool,
64    validation_fraction: Float,
65}
66
67/// Trained state for MultiOutputMLP
68#[derive(Debug, Clone)]
69#[allow(dead_code)] // fields retained for model introspection and serialization
70pub struct MultiOutputMLPTrained {
71    /// Weights for each layer
72    weights: Vec<Array2<Float>>,
73    /// Biases for each layer
74    biases: Vec<Array1<Float>>,
75    /// Number of input features
76    n_features: usize,
77    /// Number of outputs
78    n_outputs: usize,
79    /// Training configuration
80    hidden_layer_sizes: Vec<usize>,
81    activation: ActivationFunction,
82    output_activation: ActivationFunction,
83    /// Training history
84    loss_curve: Vec<Float>,
85    /// Number of iterations performed
86    n_iter: usize,
87}
88
89impl MultiOutputMLP<Untrained> {
90    /// Create a new MultiOutputMLP instance
91    pub fn new() -> Self {
92        Self {
93            state: Untrained,
94            hidden_layer_sizes: vec![100],
95            activation: ActivationFunction::ReLU,
96            output_activation: ActivationFunction::Linear,
97            loss_function: LossFunction::MeanSquaredError,
98            learning_rate: 0.001,
99            max_iter: 200,
100            tolerance: 1e-4,
101            random_state: None,
102            alpha: 0.0001,
103            batch_size: None,
104            early_stopping: false,
105            validation_fraction: 0.1,
106        }
107    }
108
109    /// Set hidden layer sizes
110    pub fn hidden_layer_sizes(mut self, sizes: Vec<usize>) -> Self {
111        self.hidden_layer_sizes = sizes;
112        self
113    }
114
115    /// Set activation function for hidden layers
116    pub fn activation(mut self, activation: ActivationFunction) -> Self {
117        self.activation = activation;
118        self
119    }
120
121    /// Set activation function for output layer
122    pub fn output_activation(mut self, activation: ActivationFunction) -> Self {
123        self.output_activation = activation;
124        self
125    }
126
127    /// Set loss function
128    pub fn loss_function(mut self, loss_function: LossFunction) -> Self {
129        self.loss_function = loss_function;
130        self
131    }
132
133    /// Set learning rate
134    pub fn learning_rate(mut self, learning_rate: Float) -> Self {
135        self.learning_rate = learning_rate;
136        self
137    }
138
139    /// Set maximum number of iterations
140    pub fn max_iter(mut self, max_iter: usize) -> Self {
141        self.max_iter = max_iter;
142        self
143    }
144
145    /// Set convergence tolerance
146    pub fn tolerance(mut self, tolerance: Float) -> Self {
147        self.tolerance = tolerance;
148        self
149    }
150
151    /// Set random state for reproducibility
152    pub fn random_state(mut self, random_state: Option<u64>) -> Self {
153        self.random_state = random_state;
154        self
155    }
156
157    /// Set L2 regularization parameter
158    pub fn alpha(mut self, alpha: Float) -> Self {
159        self.alpha = alpha;
160        self
161    }
162
163    /// Set batch size for mini-batch gradient descent
164    pub fn batch_size(mut self, batch_size: Option<usize>) -> Self {
165        self.batch_size = batch_size;
166        self
167    }
168
169    /// Enable early stopping
170    pub fn early_stopping(mut self, early_stopping: bool) -> Self {
171        self.early_stopping = early_stopping;
172        self
173    }
174
175    /// Set validation fraction for early stopping
176    pub fn validation_fraction(mut self, validation_fraction: Float) -> Self {
177        self.validation_fraction = validation_fraction;
178        self
179    }
180}
181
182impl Default for MultiOutputMLP<Untrained> {
183    fn default() -> Self {
184        Self::new()
185    }
186}
187
188impl Estimator for MultiOutputMLP<Untrained> {
189    type Config = ();
190    type Error = SklearsError;
191    type Float = Float;
192
193    fn config(&self) -> &Self::Config {
194        &()
195    }
196}
197
198impl Fit<ArrayView2<'_, Float>, Array2<Float>> for MultiOutputMLP<Untrained> {
199    type Fitted = MultiOutputMLP<MultiOutputMLPTrained>;
200
201    #[allow(non_snake_case)] // standard ML notation
202    fn fit(self, X: &ArrayView2<'_, Float>, y: &Array2<Float>) -> SklResult<Self::Fitted> {
203        let (n_samples, n_features) = X.dim();
204        let (n_samples_y, n_outputs) = y.dim();
205
206        if n_samples != n_samples_y {
207            return Err(SklearsError::InvalidInput(
208                "X and y must have the same number of samples".to_string(),
209            ));
210        }
211
212        if n_samples == 0 {
213            return Err(SklearsError::InvalidInput(
214                "Cannot fit with zero samples".to_string(),
215            ));
216        }
217
218        // Initialize random number generator
219        let mut rng = match self.random_state {
220            Some(seed) => scirs2_core::random::seeded_rng(seed),
221            None => scirs2_core::random::seeded_rng(42),
222        };
223
224        // Build network architecture
225        let mut layer_sizes = vec![n_features];
226        layer_sizes.extend(&self.hidden_layer_sizes);
227        layer_sizes.push(n_outputs);
228
229        // Initialize weights and biases
230        let mut weights = Vec::new();
231        let mut biases = Vec::new();
232
233        for i in 0..layer_sizes.len() - 1 {
234            let input_size = layer_sizes[i];
235            let output_size = layer_sizes[i + 1];
236
237            // Xavier/Glorot initialization
238            let scale = (2.0 / (input_size + output_size) as Float).sqrt();
239            let normal_dist = RandNormal::new(0.0, scale).expect("operation should succeed");
240            let mut weight_matrix = Array2::<Float>::zeros((output_size, input_size));
241            for i in 0..output_size {
242                for j in 0..input_size {
243                    weight_matrix[[i, j]] = rng.sample(normal_dist);
244                }
245            }
246            let bias_vector = Array1::<Float>::zeros(output_size);
247
248            weights.push(weight_matrix);
249            biases.push(bias_vector);
250        }
251
252        // Training loop
253        let mut loss_curve = Vec::new();
254        let X_owned = X.to_owned();
255        let y_owned = y.to_owned();
256
257        for epoch in 0..self.max_iter {
258            // Forward pass
259            let (activations, _) = self.forward_pass(&X_owned, &weights, &biases)?;
260            let predictions = activations.last().expect("collection should not be empty");
261
262            // Compute loss
263            let loss = self.loss_function.compute_loss(predictions, &y_owned);
264            loss_curve.push(loss);
265
266            // Check convergence
267            if epoch > 0 && (loss_curve[epoch - 1] - loss).abs() < self.tolerance {
268                break;
269            }
270
271            // Backward pass
272            self.backward_pass(&X_owned, &y_owned, &mut weights, &mut biases)?;
273        }
274
275        let trained_state = MultiOutputMLPTrained {
276            weights,
277            biases,
278            n_features,
279            n_outputs,
280            hidden_layer_sizes: self.hidden_layer_sizes.clone(),
281            activation: self.activation,
282            output_activation: self.output_activation,
283            loss_curve,
284            n_iter: self.max_iter,
285        };
286
287        Ok(MultiOutputMLP {
288            state: trained_state,
289            hidden_layer_sizes: self.hidden_layer_sizes,
290            activation: self.activation,
291            output_activation: self.output_activation,
292            loss_function: self.loss_function,
293            learning_rate: self.learning_rate,
294            max_iter: self.max_iter,
295            tolerance: self.tolerance,
296            random_state: self.random_state,
297            alpha: self.alpha,
298            batch_size: self.batch_size,
299            early_stopping: self.early_stopping,
300            validation_fraction: self.validation_fraction,
301        })
302    }
303}
304
305impl MultiOutputMLP<Untrained> {
306    /// Forward pass through the network
307    #[allow(clippy::type_complexity)]
308    #[allow(non_snake_case)] // standard ML notation
309    fn forward_pass(
310        &self,
311        X: &Array2<Float>,
312        weights: &[Array2<Float>],
313        biases: &[Array1<Float>],
314    ) -> SklResult<(Vec<Array2<Float>>, Vec<Array2<Float>>)> {
315        let mut activations = vec![X.clone()];
316        let mut z_values = Vec::new();
317
318        for (i, (weight, bias)) in weights.iter().zip(biases.iter()).enumerate() {
319            let current_input = activations.last().expect("collection should not be empty");
320
321            // Linear transformation: z = X * W^T + b
322            let z = current_input.dot(&weight.t()) + bias.view().insert_axis(Axis(0));
323            z_values.push(z.clone());
324
325            // Apply activation function
326            let activation_fn = if i == weights.len() - 1 {
327                self.output_activation
328            } else {
329                self.activation
330            };
331
332            let activated = activation_fn.apply_2d(&z);
333            activations.push(activated);
334        }
335
336        Ok((activations, z_values))
337    }
338
339    /// Backward pass with gradient computation
340    fn backward_pass(
341        &self,
342        X: &Array2<Float>,
343        y: &Array2<Float>,
344        weights: &mut [Array2<Float>],
345        biases: &mut [Array1<Float>],
346    ) -> SklResult<()> {
347        let (activations, z_values) = self.forward_pass(X, weights, biases)?;
348        let n_samples = X.nrows() as Float;
349
350        // Compute output layer error
351        let output_predictions = activations.last().expect("collection should not be empty");
352        let mut delta = output_predictions - y;
353
354        // Backpropagate errors
355        for i in (0..weights.len()).rev() {
356            let current_activation = &activations[i];
357
358            // Compute gradients
359            let weight_gradient = delta.t().dot(current_activation) / n_samples;
360            let bias_gradient = delta
361                .mean_axis(Axis(0))
362                .expect("array should have elements for mean computation");
363
364            // Add L2 regularization to weight gradient
365            let regularized_weight_gradient = weight_gradient + self.alpha * &weights[i];
366
367            // Update weights and biases
368            weights[i] = &weights[i] - self.learning_rate * regularized_weight_gradient;
369            biases[i] = &biases[i] - self.learning_rate * bias_gradient;
370
371            // Compute delta for next layer (if not the first layer)
372            if i > 0 {
373                let activation_fn = if i == weights.len() - 1 {
374                    self.output_activation
375                } else {
376                    self.activation
377                };
378
379                // For simplicity, we'll use a basic derivative approximation
380                let derivative_approx = match activation_fn {
381                    ActivationFunction::ReLU => {
382                        z_values[i - 1].map(|&val| if val > 0.0 { 1.0 } else { 0.0 })
383                    }
384                    ActivationFunction::Sigmoid => {
385                        let sigmoid_vals = &activations[i];
386                        sigmoid_vals.map(|&val| val * (1.0 - val))
387                    }
388                    ActivationFunction::Tanh => {
389                        let tanh_vals = &activations[i];
390                        tanh_vals.map(|&val| 1.0 - val * val)
391                    }
392                    _ => Array2::ones(z_values[i - 1].dim()),
393                };
394
395                delta = delta.dot(&weights[i]) * derivative_approx;
396            }
397        }
398
399        Ok(())
400    }
401}
402
403impl Predict<ArrayView2<'_, Float>, Array2<Float>> for MultiOutputMLP<MultiOutputMLPTrained> {
404    #[allow(non_snake_case)] // standard ML notation
405    fn predict(&self, X: &ArrayView2<'_, Float>) -> SklResult<Array2<Float>> {
406        let (_n_samples, n_features) = X.dim();
407
408        if n_features != self.state.n_features {
409            return Err(SklearsError::InvalidInput(
410                "X has different number of features than training data".to_string(),
411            ));
412        }
413
414        let X_owned = X.to_owned();
415        let (activations, _) = self.forward_pass_trained(&X_owned)?;
416        let predictions = activations
417            .last()
418            .expect("collection should not be empty")
419            .clone();
420
421        Ok(predictions)
422    }
423}
424
425impl MultiOutputMLP<MultiOutputMLPTrained> {
426    /// Forward pass for trained model
427    #[allow(clippy::type_complexity)]
428    #[allow(non_snake_case)] // standard ML notation
429    fn forward_pass_trained(
430        &self,
431        X: &Array2<Float>,
432    ) -> SklResult<(Vec<Array2<Float>>, Vec<Array2<Float>>)> {
433        let mut activations = vec![X.clone()];
434        let mut z_values = Vec::new();
435
436        for (i, (weight, bias)) in self
437            .state
438            .weights
439            .iter()
440            .zip(self.state.biases.iter())
441            .enumerate()
442        {
443            let current_input = activations.last().expect("collection should not be empty");
444
445            // Linear transformation: z = X * W^T + b
446            let z = current_input.dot(&weight.t()) + bias.view().insert_axis(Axis(0));
447            z_values.push(z.clone());
448
449            // Apply activation function
450            let activation_fn = if i == self.state.weights.len() - 1 {
451                self.state.output_activation
452            } else {
453                self.state.activation
454            };
455
456            let activated = activation_fn.apply_2d(&z);
457            activations.push(activated);
458        }
459
460        Ok((activations, z_values))
461    }
462
463    /// Get the loss curve from training
464    pub fn loss_curve(&self) -> &[Float] {
465        &self.state.loss_curve
466    }
467
468    /// Get the number of iterations performed during training
469    pub fn n_iter(&self) -> usize {
470        self.state.n_iter
471    }
472
473    /// Get the network weights
474    pub fn weights(&self) -> &[Array2<Float>] {
475        &self.state.weights
476    }
477
478    /// Get the network biases
479    pub fn biases(&self) -> &[Array1<Float>] {
480        &self.state.biases
481    }
482}
483
484/// Multi-Output MLP Classifier
485///
486/// This is a specialized version of MultiOutputMLP for classification tasks.
487/// It automatically configures the network for multi-class or multi-label classification.
488pub type MultiOutputMLPClassifier<S = Untrained> = MultiOutputMLP<S>;
489
490impl MultiOutputMLPClassifier<Untrained> {
491    /// Create a new classifier with appropriate defaults
492    pub fn new_classifier() -> Self {
493        Self::new()
494            .output_activation(ActivationFunction::Sigmoid)
495            .loss_function(LossFunction::BinaryCrossEntropy)
496    }
497}
498
499/// Multi-Output MLP Regressor
500///
501/// This is a specialized version of MultiOutputMLP for regression tasks.
502/// It automatically configures the network for multi-output regression.
503pub type MultiOutputMLPRegressor<S = Untrained> = MultiOutputMLP<S>;
504
505impl MultiOutputMLPRegressor<Untrained> {
506    /// Create a new regressor with appropriate defaults
507    pub fn new_regressor() -> Self {
508        Self::new()
509            .output_activation(ActivationFunction::Linear)
510            .loss_function(LossFunction::MeanSquaredError)
511    }
512}