use crate::error::{NeuralError, Result};
use scirs2_core::ndarray::{Array1, Array2, ArrayView1, Axis, ScalarOperand};
use scirs2_core::numeric::{Float, FromPrimitive, NumAssign, ToPrimitive};
use scirs2_core::random::{Rng, SeedableRng};
use scirs2_core::random::rngs::SmallRng;
use std::fmt::{self, Debug};
#[derive(Debug, Clone, PartialEq)]
pub enum EnsembleMethod {
Voting,
Averaging {
weights: Option<Vec<f64>>,
},
Stacking,
BoostedEnsemble,
Bagging,
}
impl fmt::Display for EnsembleMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Voting => write!(f, "SoftVoting"),
Self::Averaging { weights: None } => write!(f, "Averaging(uniform)"),
Self::Averaging { weights: Some(_) } => write!(f, "Averaging(weighted)"),
Self::Stacking => write!(f, "Stacking"),
Self::BoostedEnsemble => write!(f, "BoostedEnsemble"),
Self::Bagging => write!(f, "Bagging"),
}
}
}
#[derive(Debug, Clone)]
pub struct ModelWeights {
pub name: String,
pub params: Vec<(String, Vec<usize>, Vec<f64>)>,
pub validation_score: Option<f64>,
pub epoch: usize,
}
impl ModelWeights {
pub fn new(name: impl Into<String>, epoch: usize) -> Self {
Self {
name: name.into(),
params: Vec::new(),
validation_score: None,
epoch,
}
}
pub fn add_param(
&mut self,
param_name: impl Into<String>,
shape: Vec<usize>,
values: Vec<f64>,
) -> Result<()> {
let expected: usize = shape.iter().product();
if values.len() != expected {
return Err(NeuralError::ShapeMismatch(format!(
"expected {} values for shape {:?} but got {}",
expected,
shape,
values.len()
)));
}
self.params.push((param_name.into(), shape, values));
Ok(())
}
pub fn param_count(&self) -> usize {
self.params.iter().map(|(_, shape, _)| shape.iter().product::<usize>()).sum()
}
}
#[derive(Debug, Clone)]
pub struct EnsemblePolicy {
pub selected_indices: Vec<usize>,
pub weights: Vec<f64>,
pub method: EnsembleMethod,
pub best_score: Option<f64>,
}
pub fn soft_voting(predictions: &[Array1<f64>]) -> Array1<f64> {
if predictions.is_empty() {
return Array1::zeros(0);
}
let n = predictions[0].len();
let mut sum = Array1::<f64>::zeros(n);
for pred in predictions {
sum = sum + pred;
}
let count = predictions.len() as f64;
sum.mapv(|v| v / count)
}
pub fn hard_voting(predictions: &[Vec<usize>]) -> Vec<usize> {
if predictions.is_empty() {
return Vec::new();
}
let n_samples = predictions[0].len();
let mut result = Vec::with_capacity(n_samples);
for i in 0..n_samples {
let mut vote_counts: std::collections::HashMap<usize, usize> =
std::collections::HashMap::new();
for model_preds in predictions {
if let Some(&cls) = model_preds.get(i) {
*vote_counts.entry(cls).or_insert(0) += 1;
}
}
let winner = vote_counts
.iter()
.max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))
.map(|(&cls, _)| cls)
.unwrap_or(0);
result.push(winner);
}
result
}
pub fn predict_ensemble(
model_outputs: &[Array1<f64>],
method: &EnsembleMethod,
) -> Result<Array1<f64>> {
if model_outputs.is_empty() {
return Err(NeuralError::InvalidArgument(
"predict_ensemble requires at least one model output".into(),
));
}
let n = model_outputs[0].len();
for (i, out) in model_outputs.iter().enumerate() {
if out.len() != n {
return Err(NeuralError::ShapeMismatch(format!(
"model {} output length {} does not match expected {}",
i,
out.len(),
n
)));
}
}
match method {
EnsembleMethod::Voting | EnsembleMethod::Averaging { weights: None } => {
Ok(soft_voting(model_outputs))
}
EnsembleMethod::Averaging {
weights: Some(ws),
} => {
if ws.len() != model_outputs.len() {
return Err(NeuralError::InvalidArgument(format!(
"weights length {} does not match number of models {}",
ws.len(),
model_outputs.len()
)));
}
let weight_sum: f64 = ws.iter().sum();
if weight_sum.abs() < 1e-12 {
return Err(NeuralError::InvalidArgument(
"ensemble weights must not sum to zero".into(),
));
}
let mut acc = Array1::<f64>::zeros(n);
for (out, &w) in model_outputs.iter().zip(ws.iter()) {
acc = acc + out.mapv(|v| v * w);
}
Ok(acc.mapv(|v| v / weight_sum))
}
EnsembleMethod::Stacking
| EnsembleMethod::BoostedEnsemble
| EnsembleMethod::Bagging => {
Ok(soft_voting(model_outputs))
}
}
}
#[derive(Debug, Clone)]
pub struct ModelEnsemble {
members: Vec<ModelWeights>,
pub method: EnsembleMethod,
}
impl ModelEnsemble {
pub fn new(method: EnsembleMethod) -> Self {
Self {
members: Vec::new(),
method,
}
}
pub fn add_member(&mut self, weights: ModelWeights) {
self.members.push(weights);
}
pub fn size(&self) -> usize {
self.members.len()
}
pub fn members(&self) -> &[ModelWeights] {
&self.members
}
pub fn mean_validation_score(&self) -> Option<f64> {
let scores: Vec<f64> = self
.members
.iter()
.filter_map(|m| m.validation_score)
.collect();
if scores.is_empty() {
None
} else {
Some(scores.iter().sum::<f64>() / scores.len() as f64)
}
}
pub fn aggregate(&self, model_outputs: &[Array1<f64>]) -> Result<Array1<f64>> {
predict_ensemble(model_outputs, &self.method)
}
}
#[derive(Debug, Clone)]
pub struct StackingEnsemble {
pub n_models: usize,
pub n_classes: usize,
meta_weights: Array2<f64>,
meta_bias: Array1<f64>,
pub fitted: bool,
pub learning_rate: f64,
pub epochs: usize,
}
impl StackingEnsemble {
pub fn new(n_models: usize, n_classes: usize) -> Self {
let input_dim = n_models * n_classes;
Self {
n_models,
n_classes,
meta_weights: Array2::zeros((input_dim, n_classes)),
meta_bias: Array1::zeros(n_classes),
fitted: false,
learning_rate: 0.01,
epochs: 100,
}
}
pub fn with_hypers(mut self, learning_rate: f64, epochs: usize) -> Self {
self.learning_rate = learning_rate;
self.epochs = epochs;
self
}
fn stack_features(model_outputs: &[Array1<f64>]) -> Array1<f64> {
let total: usize = model_outputs.iter().map(|v| v.len()).sum();
let mut stacked = Array1::zeros(total);
let mut offset = 0;
for out in model_outputs {
for (j, &v) in out.iter().enumerate() {
stacked[offset + j] = v;
}
offset += out.len();
}
stacked
}
fn softmax(logits: &Array1<f64>) -> Array1<f64> {
let max = logits.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let exp: Array1<f64> = logits.mapv(|v| (v - max).exp());
let sum = exp.sum();
if sum < 1e-300 {
Array1::from_elem(logits.len(), 1.0 / logits.len() as f64)
} else {
exp.mapv(|v| v / sum)
}
}
pub fn fit(
&mut self,
stacked_inputs: &[Vec<Array1<f64>>],
targets: &[usize],
) -> Result<()> {
if stacked_inputs.len() != targets.len() {
return Err(NeuralError::InvalidArgument(
"stacked_inputs and targets must have equal length".into(),
));
}
if stacked_inputs.is_empty() {
return Err(NeuralError::InvalidArgument(
"no training data provided to StackingEnsemble".into(),
));
}
for &t in targets {
if t >= self.n_classes {
return Err(NeuralError::InvalidArgument(format!(
"target {} out of range for n_classes={}",
t, self.n_classes
)));
}
}
let n = stacked_inputs.len();
let input_dim = self.n_models * self.n_classes;
for _epoch in 0..self.epochs {
let mut dw = Array2::<f64>::zeros((input_dim, self.n_classes));
let mut db = Array1::<f64>::zeros(self.n_classes);
for (sample_models, &label) in stacked_inputs.iter().zip(targets.iter()) {
let feat = Self::stack_features(sample_models);
if feat.len() != input_dim {
return Err(NeuralError::ShapeMismatch(format!(
"expected feature length {} but got {}",
input_dim,
feat.len()
)));
}
let mut logits = Array1::<f64>::zeros(self.n_classes);
for c in 0..self.n_classes {
let mut s = self.meta_bias[c];
for d in 0..input_dim {
s += self.meta_weights[[d, c]] * feat[d];
}
logits[c] = s;
}
let probs = Self::softmax(&logits);
let mut delta = probs;
delta[label] -= 1.0;
for c in 0..self.n_classes {
db[c] += delta[c];
for d in 0..input_dim {
dw[[d, c]] += feat[d] * delta[c];
}
}
}
let lr = self.learning_rate / n as f64;
for c in 0..self.n_classes {
self.meta_bias[c] -= lr * db[c];
for d in 0..input_dim {
self.meta_weights[[d, c]] -= lr * dw[[d, c]];
}
}
}
self.fitted = true;
Ok(())
}
pub fn predict(&self, model_outputs: &[Array1<f64>]) -> Result<Array1<f64>> {
if !self.fitted {
return Err(NeuralError::InvalidState(
"StackingEnsemble must be fitted before predicting".into(),
));
}
let feat = Self::stack_features(model_outputs);
let input_dim = self.n_models * self.n_classes;
if feat.len() != input_dim {
return Err(NeuralError::ShapeMismatch(format!(
"expected feature length {} but got {}",
input_dim,
feat.len()
)));
}
let mut logits = Array1::<f64>::zeros(self.n_classes);
for c in 0..self.n_classes {
let mut s = self.meta_bias[c];
for d in 0..input_dim {
s += self.meta_weights[[d, c]] * feat[d];
}
logits[c] = s;
}
Ok(Self::softmax(&logits))
}
}
#[derive(Debug, Clone)]
pub struct BaggingEnsemble {
pub n_estimators: usize,
pub sample_fraction: f64,
pub feature_subsampling: bool,
pub feature_fraction: f64,
pub seed: u64,
}
impl BaggingEnsemble {
pub fn new(n_estimators: usize) -> Self {
Self {
n_estimators,
sample_fraction: 1.0,
feature_subsampling: false,
feature_fraction: 0.7,
seed: 42,
}
}
pub fn bootstrap_indices(&self, dataset_size: usize) -> Vec<Vec<usize>> {
let sample_size =
((self.sample_fraction * dataset_size as f64).ceil() as usize).max(1);
let mut rng = SmallRng::seed_from_u64(self.seed);
(0..self.n_estimators)
.map(|_| {
(0..sample_size)
.map(|_| rng.random_range(0..dataset_size))
.collect()
})
.collect()
}
pub fn aggregate(&self, predictions: &[Array1<f64>]) -> Result<Array1<f64>> {
if predictions.is_empty() {
return Err(NeuralError::InvalidArgument(
"no predictions provided to BaggingEnsemble::aggregate".into(),
));
}
Ok(soft_voting(predictions))
}
}
pub fn snapshot_ensemble(checkpoints: &[ModelWeights]) -> EnsemblePolicy {
if checkpoints.is_empty() {
return EnsemblePolicy {
selected_indices: Vec::new(),
weights: Vec::new(),
method: EnsembleMethod::Averaging { weights: None },
best_score: None,
};
}
let scored: Vec<usize> = checkpoints
.iter()
.enumerate()
.filter(|(_, c)| c.validation_score.is_some())
.map(|(i, _)| i)
.collect();
let selected_indices: Vec<usize> = if scored.is_empty() {
let take = checkpoints.len().min(5);
(checkpoints.len() - take..checkpoints.len()).collect()
} else {
scored
};
let raw_scores: Vec<f64> = selected_indices
.iter()
.map(|&i| checkpoints[i].validation_score.unwrap_or(0.0))
.collect();
let max_score = raw_scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let exp_scores: Vec<f64> = raw_scores.iter().map(|&s| (s - max_score).exp()).collect();
let sum_exp: f64 = exp_scores.iter().sum();
let weights: Vec<f64> = if sum_exp < 1e-300 {
vec![1.0 / selected_indices.len() as f64; selected_indices.len()]
} else {
exp_scores.iter().map(|&e| e / sum_exp).collect()
};
let best_score = selected_indices
.iter()
.filter_map(|&i| checkpoints[i].validation_score)
.reduce(f64::max);
EnsemblePolicy {
selected_indices,
weights: weights.clone(),
method: EnsembleMethod::Averaging {
weights: Some(weights),
},
best_score,
}
}
pub fn cyclic_lr_ensemble(total_epochs: usize, cycle_length: usize) -> Vec<usize> {
if cycle_length == 0 || total_epochs == 0 {
return Vec::new();
}
let mut epochs = Vec::new();
let mut end = cycle_length;
while end <= total_epochs {
epochs.push(end - 1);
end += cycle_length;
}
epochs
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::array;
#[test]
fn test_soft_voting_uniform() {
let p1 = array![0.8_f64, 0.1, 0.1];
let p2 = array![0.6_f64, 0.3, 0.1];
let p3 = array![0.7_f64, 0.2, 0.1];
let result = soft_voting(&[p1, p2, p3]);
assert!((result[0] - 0.7).abs() < 1e-10);
assert!((result[1] - 0.2).abs() < 1e-10);
assert!((result[2] - 0.1).abs() < 1e-10);
}
#[test]
fn test_hard_voting() {
let p1 = vec![0usize, 1, 1];
let p2 = vec![0usize, 0, 1];
let p3 = vec![1usize, 1, 1];
let result = hard_voting(&[p1, p2, p3]);
assert_eq!(result, vec![0, 1, 1]);
}
#[test]
fn test_predict_ensemble_weighted() {
let p1 = array![0.9_f64, 0.1];
let p2 = array![0.4_f64, 0.6];
let weights = vec![0.7, 0.3];
let result = predict_ensemble(
&[p1, p2],
&EnsembleMethod::Averaging {
weights: Some(weights),
},
)
.expect("ensemble predict");
assert!((result[0] - 0.75).abs() < 1e-10);
}
#[test]
fn test_cyclic_lr_ensemble() {
let epochs = cyclic_lr_ensemble(100, 20);
assert_eq!(epochs, vec![19, 39, 59, 79, 99]);
}
#[test]
fn test_cyclic_lr_ensemble_non_divisible() {
let epochs = cyclic_lr_ensemble(50, 15);
assert_eq!(epochs, vec![14, 29, 44]);
}
#[test]
fn test_snapshot_ensemble_empty() {
let policy = snapshot_ensemble(&[]);
assert!(policy.selected_indices.is_empty());
}
#[test]
fn test_snapshot_ensemble_with_scores() {
let mut c1 = ModelWeights::new("snap_0", 19);
c1.validation_score = Some(0.85);
let mut c2 = ModelWeights::new("snap_1", 39);
c2.validation_score = Some(0.90);
let mut c3 = ModelWeights::new("snap_2", 59);
c3.validation_score = Some(0.88);
let policy = snapshot_ensemble(&[c1, c2, c3]);
assert_eq!(policy.selected_indices.len(), 3);
assert_eq!(policy.best_score, Some(0.90));
}
#[test]
fn test_bagging_bootstrap_indices() {
let bagging = BaggingEnsemble::new(5);
let indices = bagging.bootstrap_indices(100);
assert_eq!(indices.len(), 5);
for sample in &indices {
assert_eq!(sample.len(), 100);
for &idx in sample {
assert!(idx < 100);
}
}
}
#[test]
fn test_stacking_ensemble_fit_predict() {
let mut stacking = StackingEnsemble::new(3, 2);
stacking.learning_rate = 0.1;
stacking.epochs = 50;
let data: Vec<Vec<Array1<f64>>> = (0..10)
.map(|i| {
vec![
array![0.8_f64 - i as f64 * 0.05, 0.2 + i as f64 * 0.05],
array![0.7_f64 - i as f64 * 0.04, 0.3 + i as f64 * 0.04],
array![0.6_f64 - i as f64 * 0.03, 0.4 + i as f64 * 0.03],
]
})
.collect();
let targets: Vec<usize> = (0..10).map(|i| if i < 5 { 0 } else { 1 }).collect();
stacking.fit(&data, &targets).expect("fit");
assert!(stacking.fitted);
let sample = vec![
array![0.9_f64, 0.1],
array![0.8_f64, 0.2],
array![0.7_f64, 0.3],
];
let probs = stacking.predict(&sample).expect("predict");
assert_eq!(probs.len(), 2);
let sum: f64 = probs.sum();
assert!((sum - 1.0).abs() < 1e-9);
}
#[test]
fn test_model_weights_add_param() {
let mut w = ModelWeights::new("test", 0);
w.add_param("weight", vec![2, 3], vec![1.0; 6]).expect("ok");
assert_eq!(w.param_count(), 6);
}
#[test]
fn test_model_weights_bad_shape() {
let mut w = ModelWeights::new("bad", 0);
let result = w.add_param("weight", vec![2, 3], vec![1.0; 5]);
assert!(result.is_err());
}
}