use crate::NeuralResult;
use scirs2_core::ndarray::{s, Array1, Array2, Axis};
use sklears_core::error::SklearsError;
#[derive(Debug, Clone, PartialEq)]
pub enum DifficultyStrategy {
PredictionConfidence,
LossValue,
GradientMagnitude,
Custom(Array1<f64>),
Random,
SelfPaced {
initial_threshold: f64,
growth_rate: f64,
max_threshold: f64,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum PacingStrategy {
Linear {
initial_size: usize,
growth_rate: usize,
},
Exponential {
initial_size: usize,
growth_factor: f64,
},
Stepwise {
step_size: usize,
step_epochs: Vec<usize>,
},
Polynomial {
initial_size: usize,
power: f64,
},
Sigmoid {
max_size: usize,
steepness: f64,
midpoint: f64,
},
Custom(Vec<usize>),
}
#[derive(Debug, Clone)]
pub struct CurriculumConfig {
pub difficulty_strategy: DifficultyStrategy,
pub pacing_strategy: PacingStrategy,
pub update_frequency: usize,
pub shuffle_within_bucket: bool,
pub min_batch_size: usize,
pub max_examples: Option<usize>,
pub adaptive_threshold: bool,
}
impl Default for CurriculumConfig {
fn default() -> Self {
Self {
difficulty_strategy: DifficultyStrategy::PredictionConfidence,
pacing_strategy: PacingStrategy::Linear {
initial_size: 100,
growth_rate: 50,
},
update_frequency: 5,
shuffle_within_bucket: true,
min_batch_size: 32,
max_examples: None,
adaptive_threshold: false,
}
}
}
pub struct CurriculumScheduler {
config: CurriculumConfig,
current_epoch: usize,
difficulty_scores: Option<Array1<f64>>,
sorted_indices: Vec<usize>,
current_threshold: f64,
training_history: Vec<f64>,
}
impl CurriculumScheduler {
pub fn new(config: CurriculumConfig) -> Self {
let current_threshold = match &config.difficulty_strategy {
DifficultyStrategy::SelfPaced {
initial_threshold, ..
} => *initial_threshold,
_ => 0.0,
};
Self {
config,
current_epoch: 0,
difficulty_scores: None,
sorted_indices: Vec::new(),
current_threshold,
training_history: Vec::new(),
}
}
pub fn update_difficulty_scores<M>(
&mut self,
model: &M,
inputs: &Array2<f64>,
targets: &Array1<usize>,
losses: Option<&Array1<f64>>,
) -> NeuralResult<()>
where
M: crate::interpretation::InterpretableModel,
{
let n_samples = inputs.nrows();
let scores = match &self.config.difficulty_strategy {
DifficultyStrategy::PredictionConfidence => {
let (predictions, _) = model.forward_with_activations(inputs)?;
self.compute_confidence_scores(&predictions, targets)?
}
DifficultyStrategy::LossValue => losses
.ok_or_else(|| SklearsError::InvalidParameter {
name: "losses".to_string(),
reason: "Loss values required for LossValue difficulty strategy".to_string(),
})?
.to_owned(),
DifficultyStrategy::GradientMagnitude => {
let mut total_gradient_magnitude = Array1::zeros(n_samples);
for i in 0..n_samples {
let sample = inputs.slice(s![i..i + 1, ..]).to_owned();
let gradients = model.compute_gradients(&sample, Some(targets[i]))?;
total_gradient_magnitude[i] = gradients.iter().map(|&x| x.abs()).sum();
}
total_gradient_magnitude
}
DifficultyStrategy::Custom(scores) => {
if scores.len() != n_samples {
return Err(SklearsError::ShapeMismatch {
expected: format!("n_samples={}", n_samples),
actual: format!("custom_scores.len()={}", scores.len()),
});
}
scores.clone()
}
DifficultyStrategy::Random => {
use scirs2_core::random::prelude::*;
let mut rng = thread_rng();
Array1::from_shape_fn(n_samples, |_| rng.random())
}
DifficultyStrategy::SelfPaced { .. } => losses
.ok_or_else(|| SklearsError::InvalidParameter {
name: "losses".to_string(),
reason: "Loss values required for SelfPaced difficulty strategy".to_string(),
})?
.to_owned(),
};
let mut indexed_scores: Vec<(usize, f64)> = scores
.iter()
.enumerate()
.map(|(i, &score)| (i, score))
.collect();
indexed_scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
self.sorted_indices = indexed_scores.into_iter().map(|(i, _)| i).collect();
self.difficulty_scores = Some(scores);
Ok(())
}
pub fn get_current_examples(&mut self) -> NeuralResult<Vec<usize>> {
let total_examples = self.sorted_indices.len();
if total_examples == 0 {
return Err(SklearsError::InvalidParameter {
name: "sorted_indices".to_string(),
reason: "No examples available. Call update_difficulty_scores first.".to_string(),
});
}
let max_examples = self.config.max_examples.unwrap_or(total_examples);
let available_examples = total_examples.min(max_examples);
let current_size = match &self.config.pacing_strategy {
PacingStrategy::Linear {
initial_size,
growth_rate,
} => {
let size = initial_size + growth_rate * self.current_epoch;
size.min(available_examples).max(self.config.min_batch_size)
}
PacingStrategy::Exponential {
initial_size,
growth_factor,
} => {
let size =
(*initial_size as f64 * growth_factor.powf(self.current_epoch as f64)) as usize;
size.min(available_examples).max(self.config.min_batch_size)
}
PacingStrategy::Stepwise {
step_size,
step_epochs,
} => {
let steps_completed = step_epochs
.iter()
.filter(|&&epoch| epoch <= self.current_epoch)
.count();
let size = self.config.min_batch_size + step_size * steps_completed;
size.min(available_examples)
}
PacingStrategy::Polynomial {
initial_size,
power,
} => {
let progress = (self.current_epoch as f64 + 1.0) / 100.0; let size = *initial_size
+ ((available_examples - initial_size) as f64 * progress.powf(*power)) as usize;
size.min(available_examples).max(self.config.min_batch_size)
}
PacingStrategy::Sigmoid {
max_size,
steepness,
midpoint,
} => {
let x = self.current_epoch as f64;
let sigmoid = 1.0 / (1.0 + (-steepness * (x - midpoint)).exp());
let size = self.config.min_batch_size
+ ((max_size - self.config.min_batch_size) as f64 * sigmoid) as usize;
size.min(available_examples)
}
PacingStrategy::Custom(sizes) => {
if self.current_epoch < sizes.len() {
sizes[self.current_epoch]
.min(available_examples)
.max(self.config.min_batch_size)
} else {
available_examples
}
}
};
let mut selected_indices: Vec<usize> =
if let DifficultyStrategy::SelfPaced { .. } = &self.config.difficulty_strategy {
if let Some(ref scores) = self.difficulty_scores {
self.sorted_indices
.iter()
.filter(|&&idx| scores[idx] <= self.current_threshold)
.take(current_size)
.cloned()
.collect()
} else {
self.sorted_indices
.iter()
.take(current_size)
.cloned()
.collect()
}
} else {
self.sorted_indices
.iter()
.take(current_size)
.cloned()
.collect()
};
if self.config.shuffle_within_bucket {
use scirs2_core::random::prelude::*;
selected_indices.shuffle(&mut thread_rng());
}
Ok(selected_indices)
}
pub fn step_epoch(&mut self, average_loss: Option<f64>) {
self.current_epoch += 1;
if let Some(loss) = average_loss {
self.training_history.push(loss);
}
if let DifficultyStrategy::SelfPaced {
growth_rate,
max_threshold,
..
} = &self.config.difficulty_strategy
{
if self.config.adaptive_threshold && !self.training_history.is_empty() {
let recent_losses =
&self.training_history[self.training_history.len().saturating_sub(5)..];
let avg_recent_loss =
recent_losses.iter().sum::<f64>() / recent_losses.len() as f64;
if self.training_history.len() > 1 {
let prev_loss = self.training_history[self.training_history.len() - 2];
if avg_recent_loss < prev_loss * 0.95 {
self.current_threshold += growth_rate;
}
}
} else {
self.current_threshold += growth_rate;
}
self.current_threshold = self.current_threshold.min(*max_threshold);
}
}
pub fn should_update_difficulty(&self) -> bool {
self.current_epoch
.is_multiple_of(self.config.update_frequency)
}
pub fn get_statistics(&mut self) -> CurriculumStatistics {
let current_size = if let Ok(indices) = self.get_current_examples() {
indices.len()
} else {
0
};
CurriculumStatistics {
current_epoch: self.current_epoch,
current_subset_size: current_size,
total_examples: self.sorted_indices.len(),
current_threshold: self.current_threshold,
average_difficulty: self
.difficulty_scores
.as_ref()
.map(|scores| scores.mean().unwrap_or(0.0)),
training_loss_trend: if self.training_history.len() >= 2 {
let recent = self.training_history[self.training_history.len() - 1];
let previous = self.training_history[self.training_history.len() - 2];
Some(recent - previous)
} else {
None
},
}
}
pub fn reset(&mut self) {
self.current_epoch = 0;
self.difficulty_scores = None;
self.sorted_indices.clear();
self.training_history.clear();
self.current_threshold = match &self.config.difficulty_strategy {
DifficultyStrategy::SelfPaced {
initial_threshold, ..
} => *initial_threshold,
_ => 0.0,
};
}
fn compute_confidence_scores(
&self,
predictions: &Array2<f64>,
targets: &Array1<usize>,
) -> NeuralResult<Array1<f64>> {
let mut confidence_scores = Array1::zeros(predictions.nrows());
for (i, (&target, pred_row)) in targets
.iter()
.zip(predictions.axis_iter(Axis(0)))
.enumerate()
{
if target >= pred_row.len() {
return Err(SklearsError::InvalidParameter {
name: "target".to_string(),
reason: format!(
"Target class {} is out of bounds for {} classes",
target,
pred_row.len()
),
});
}
confidence_scores[i] = pred_row[target];
}
Ok(confidence_scores.mapv(|x| 1.0 - x))
}
}
#[derive(Debug, Clone)]
pub struct CurriculumStatistics {
pub current_epoch: usize,
pub current_subset_size: usize,
pub total_examples: usize,
pub current_threshold: f64,
pub average_difficulty: Option<f64>,
pub training_loss_trend: Option<f64>,
}
impl CurriculumStatistics {
pub fn usage_percentage(&self) -> f64 {
if self.total_examples == 0 {
0.0
} else {
(self.current_subset_size as f64 / self.total_examples as f64) * 100.0
}
}
pub fn is_complete(&self) -> bool {
self.current_subset_size >= self.total_examples
}
}
pub struct AntiCurriculumScheduler {
base_scheduler: CurriculumScheduler,
}
impl AntiCurriculumScheduler {
pub fn new(config: CurriculumConfig) -> Self {
Self {
base_scheduler: CurriculumScheduler::new(config),
}
}
pub fn update_difficulty_scores<M>(
&mut self,
model: &M,
inputs: &Array2<f64>,
targets: &Array1<usize>,
losses: Option<&Array1<f64>>,
) -> NeuralResult<()>
where
M: crate::interpretation::InterpretableModel,
{
self.base_scheduler
.update_difficulty_scores(model, inputs, targets, losses)?;
self.base_scheduler.sorted_indices.reverse();
Ok(())
}
pub fn get_current_examples(&mut self) -> NeuralResult<Vec<usize>> {
self.base_scheduler.get_current_examples()
}
pub fn step_epoch(&mut self, average_loss: Option<f64>) {
self.base_scheduler.step_epoch(average_loss);
}
pub fn should_update_difficulty(&self) -> bool {
self.base_scheduler.should_update_difficulty()
}
pub fn get_statistics(&mut self) -> CurriculumStatistics {
self.base_scheduler.get_statistics()
}
pub fn reset(&mut self) {
self.base_scheduler.reset();
}
}
#[allow(non_snake_case)]
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
struct MockModel;
impl crate::interpretation::InterpretableModel for MockModel {
fn compute_gradients(
&self,
input: &Array2<f64>,
_target_class: Option<usize>,
) -> NeuralResult<Array2<f64>> {
Ok(input.mapv(|x| x * 0.1))
}
fn forward_with_activations(
&self,
input: &Array2<f64>,
) -> NeuralResult<(Array2<f64>, Vec<Array2<f64>>)> {
let predictions = Array2::from_shape_fn((input.nrows(), 3), |(i, j)| match j {
0 => 0.7 - (i as f64 * 0.1),
1 => 0.2 + (i as f64 * 0.05),
2 => 0.1 + (i as f64 * 0.05),
_ => 0.0,
});
Ok((predictions, vec![input.clone()]))
}
fn num_classes(&self) -> usize {
3
}
}
#[test]
fn test_curriculum_scheduler_creation() {
let config = CurriculumConfig::default();
let scheduler = CurriculumScheduler::new(config);
assert_eq!(scheduler.current_epoch, 0);
assert!(scheduler.difficulty_scores.is_none());
assert!(scheduler.sorted_indices.is_empty());
}
#[test]
fn test_difficulty_score_update() {
let config = CurriculumConfig {
difficulty_strategy: DifficultyStrategy::PredictionConfidence,
..Default::default()
};
let mut scheduler = CurriculumScheduler::new(config);
let model = MockModel;
let inputs = Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0])
.expect("array shape mismatch");
let targets = Array1::from_vec(vec![0, 0, 1, 2]);
scheduler
.update_difficulty_scores(&model, &inputs, &targets, None)
.expect("operation should succeed");
assert!(scheduler.difficulty_scores.is_some());
assert_eq!(scheduler.sorted_indices.len(), 4);
}
#[test]
fn test_linear_pacing() {
let config = CurriculumConfig {
pacing_strategy: PacingStrategy::Linear {
initial_size: 2,
growth_rate: 1,
},
min_batch_size: 1,
..Default::default()
};
let mut scheduler = CurriculumScheduler::new(config);
scheduler.sorted_indices = vec![0, 1, 2, 3, 4];
let indices = scheduler
.get_current_examples()
.expect("operation should succeed");
assert_eq!(indices.len(), 2);
scheduler.step_epoch(None);
let indices = scheduler
.get_current_examples()
.expect("operation should succeed");
assert_eq!(indices.len(), 3);
}
#[test]
fn test_exponential_pacing() {
let config = CurriculumConfig {
pacing_strategy: PacingStrategy::Exponential {
initial_size: 2,
growth_factor: 1.5,
},
min_batch_size: 1,
..Default::default()
};
let mut scheduler = CurriculumScheduler::new(config);
scheduler.sorted_indices = vec![0, 1, 2, 3, 4, 5, 6, 7];
let indices = scheduler
.get_current_examples()
.expect("operation should succeed");
assert_eq!(indices.len(), 2);
scheduler.step_epoch(None);
let indices = scheduler
.get_current_examples()
.expect("operation should succeed");
assert_eq!(indices.len(), 3);
}
#[test]
fn test_curriculum_statistics() {
let config = CurriculumConfig::default();
let mut scheduler = CurriculumScheduler::new(config);
scheduler.sorted_indices = vec![0, 1, 2, 3, 4];
scheduler.training_history = vec![1.0, 0.8, 0.6];
let stats = scheduler.get_statistics();
assert_eq!(stats.total_examples, 5);
assert_eq!(stats.current_epoch, 0);
assert!(stats.training_loss_trend.is_some());
assert_abs_diff_eq!(
stats.training_loss_trend.expect("operation should succeed"),
-0.2,
epsilon = 1e-10
);
}
#[test]
fn test_anti_curriculum() {
let config = CurriculumConfig {
difficulty_strategy: DifficultyStrategy::PredictionConfidence,
..Default::default()
};
let mut anti_scheduler = AntiCurriculumScheduler::new(config);
let model = MockModel;
let inputs = Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0])
.expect("array shape mismatch");
let targets = Array1::from_vec(vec![0, 0, 1, 2]);
anti_scheduler
.update_difficulty_scores(&model, &inputs, &targets, None)
.expect("operation should succeed");
assert_eq!(anti_scheduler.base_scheduler.sorted_indices.len(), 4);
}
#[test]
fn test_self_paced_learning() {
let config = CurriculumConfig {
difficulty_strategy: DifficultyStrategy::SelfPaced {
initial_threshold: 0.5,
growth_rate: 0.1,
max_threshold: 2.0,
},
..Default::default()
};
let mut scheduler = CurriculumScheduler::new(config);
assert_abs_diff_eq!(scheduler.current_threshold, 0.5, epsilon = 1e-10);
scheduler.step_epoch(Some(1.0));
assert_abs_diff_eq!(scheduler.current_threshold, 0.6, epsilon = 1e-10);
for _ in 0..20 {
scheduler.step_epoch(Some(0.5));
}
assert_abs_diff_eq!(scheduler.current_threshold, 2.0, epsilon = 1e-10);
}
}