use std::fmt::Debug;
use super::NASConfig;
use crate::error::{OptimError, Result};
use crate::numeric::{count_as, scalar_as};
use scirs2_core::numeric::Float;
use std::collections::HashMap;
use std::time::Duration;
pub const BEST_PER_PHASE_CAPACITY: usize = 3;
pub const TREND_WINDOW: usize = 10;
#[derive(Debug, Clone)]
pub struct ProgressiveNAS<T: Float + Debug + Send + Sync + 'static> {
pub current_phase: SearchPhase,
pub progression_strategy: ProgressionStrategy,
pub complexity_scheduler: ComplexityScheduler<T>,
pub architecture_progression: ArchitectureProgression<T>,
search_complete: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum SearchPhase {
Initial,
Intermediate,
Advanced,
Final,
}
impl SearchPhase {
pub const ORDER: [SearchPhase; 4] = [
SearchPhase::Initial,
SearchPhase::Intermediate,
SearchPhase::Advanced,
SearchPhase::Final,
];
pub fn index(self) -> usize {
match self {
SearchPhase::Initial => 0,
SearchPhase::Intermediate => 1,
SearchPhase::Advanced => 2,
SearchPhase::Final => 3,
}
}
pub fn at(index: usize) -> SearchPhase {
*Self::ORDER.get(index).unwrap_or(&SearchPhase::Final)
}
pub fn next(self) -> Option<SearchPhase> {
Self::ORDER.get(self.index() + 1).copied()
}
}
#[derive(Debug, Clone)]
pub enum ProgressionStrategy {
TimeBased(std::time::Duration),
PerformanceBased(f64),
BudgetBased(usize),
Adaptive,
}
#[derive(Debug, Clone)]
pub struct ComplexityScheduler<T: Float + Debug + Send + Sync + 'static> {
pub current_complexity: T,
pub max_complexity: T,
pub increase_rate: T,
pub strategy: SchedulingStrategy,
}
#[derive(Debug, Clone)]
pub enum SchedulingStrategy {
Linear,
Exponential,
StepWise,
Adaptive,
}
#[derive(Debug, Clone)]
pub struct ArchitectureProgression<T: Float + Debug + Send + Sync + 'static> {
pub history: Vec<ProgressionRecord<T>>,
pub best_per_phase: HashMap<SearchPhase, Vec<ScoredArchitecture<T>>>,
pub performance_trends: Vec<T>,
}
#[derive(Debug, Clone)]
pub struct ScoredArchitecture<T: Float + Debug + Send + Sync + 'static> {
pub architecture_id: String,
pub performance: T,
}
#[derive(Debug, Clone)]
pub struct ProgressionRecord<T: Float + Debug + Send + Sync + 'static> {
pub phase: SearchPhase,
pub complexity: T,
pub performance: T,
pub best_performance: T,
pub architectures_evaluated: usize,
pub timestamp: std::time::Instant,
}
impl<T: Float + Debug + Send + Sync + std::iter::Sum> ProgressiveNAS<T> {
pub fn new(config: &NASConfig<T>) -> Result<Self> {
let progression_strategy = match config.search_budget {
budget if budget < 50 => ProgressionStrategy::TimeBased(Duration::from_secs(300)),
budget if budget < 200 => ProgressionStrategy::BudgetBased((budget / 4).max(1)),
_ => ProgressionStrategy::Adaptive,
};
Ok(Self {
current_phase: SearchPhase::Initial,
progression_strategy,
complexity_scheduler: ComplexityScheduler::new()?,
architecture_progression: ArchitectureProgression::new(),
search_complete: false,
})
}
pub fn is_search_complete(&self) -> bool {
self.search_complete
}
pub fn update_search_phase(&mut self, generation: usize) -> Result<()> {
match self.progression_strategy {
ProgressionStrategy::BudgetBased(budget_per_phase) => {
if budget_per_phase == 0 {
return Err(OptimError::InvalidConfig(
"ProgressionStrategy::BudgetBased needs a non-zero budget per \
phase; zero would make every generation belong to every phase"
.to_string(),
));
}
self.advance_to_index(generation / budget_per_phase);
}
ProgressionStrategy::Adaptive => {
if let Some(trend) = self.analyze_performance_trend()? {
let epsilon: T = scalar_as(0.01, "adaptive progression threshold")?;
if trend < epsilon {
self.advance_phase();
}
}
}
ProgressionStrategy::TimeBased(duration_per_phase) => {
let phase_seconds = duration_per_phase.as_secs();
if phase_seconds == 0 {
return Err(OptimError::InvalidConfig(
"ProgressionStrategy::TimeBased needs a phase duration of at \
least one second; a sub-second duration divided by zero"
.to_string(),
));
}
let elapsed = self
.architecture_progression
.history
.first()
.map(|first| first.timestamp.elapsed())
.unwrap_or(Duration::from_secs(0));
let phases_elapsed = (elapsed.as_secs() / phase_seconds) as usize;
self.advance_to_index(phases_elapsed);
}
ProgressionStrategy::PerformanceBased(threshold) => {
if let Some(latest_performance) =
self.architecture_progression.performance_trends.last()
{
let threshold: T = scalar_as(threshold, "performance progression threshold")?;
if *latest_performance > threshold {
self.advance_phase();
}
}
}
}
Ok(())
}
fn advance_to_index(&mut self, index: usize) {
let phase = SearchPhase::at(index);
if phase > self.current_phase {
self.current_phase = phase;
}
if index >= SearchPhase::ORDER.len() {
self.search_complete = true;
}
}
fn advance_phase(&mut self) {
match self.current_phase.next() {
Some(next) => self.current_phase = next,
None => self.search_complete = true,
}
}
fn analyze_performance_trend(&self) -> Result<Option<T>> {
let phase = self.current_phase;
let phase_performances: Vec<T> = self
.architecture_progression
.history
.iter()
.filter(|record| record.phase == phase)
.map(|record| record.performance)
.collect();
if phase_performances.len() < TREND_WINDOW {
return Ok(None);
}
let recent_trends = &phase_performances[phase_performances.len() - TREND_WINDOW..];
let n: T = count_as(recent_trends.len(), "trend window size")?;
let two: T = scalar_as(2.0, "trend regression constant")?;
let sum_x = n * (n - T::one()) / two;
let sum_y = recent_trends.iter().cloned().sum::<T>();
let mut sum_xy = T::zero();
let mut sum_x2 = T::zero();
for (i, &y) in recent_trends.iter().enumerate() {
let x: T = count_as(i, "trend regression index")?;
sum_xy = sum_xy + x * y;
sum_x2 = sum_x2 + x * x;
}
let denominator = n * sum_x2 - sum_x * sum_x;
if denominator == T::zero() {
return Ok(None);
}
Ok(Some((n * sum_xy - sum_x * sum_y) / denominator))
}
pub fn get_current_search_config(&self) -> SearchPhaseConfig<T> {
match self.current_phase {
SearchPhase::Initial => SearchPhaseConfig {
complexity_limit: scirs2_core::numeric::NumCast::from(0.25)
.unwrap_or_else(|| T::zero()),
max_components: 2,
max_depth: 3,
exploration_factor: scirs2_core::numeric::NumCast::from(0.8)
.unwrap_or_else(|| T::zero()),
mutation_rate: scirs2_core::numeric::NumCast::from(0.3)
.unwrap_or_else(|| T::zero()),
population_diversity_weight: scirs2_core::numeric::NumCast::from(0.7)
.unwrap_or_else(|| T::zero()),
conservative_search: true,
},
SearchPhase::Intermediate => SearchPhaseConfig {
complexity_limit: scirs2_core::numeric::NumCast::from(0.5)
.unwrap_or_else(|| T::zero()),
max_components: 4,
max_depth: 5,
exploration_factor: scirs2_core::numeric::NumCast::from(0.6)
.unwrap_or_else(|| T::zero()),
mutation_rate: scirs2_core::numeric::NumCast::from(0.2)
.unwrap_or_else(|| T::zero()),
population_diversity_weight: scirs2_core::numeric::NumCast::from(0.5)
.unwrap_or_else(|| T::zero()),
conservative_search: false,
},
SearchPhase::Advanced => SearchPhaseConfig {
complexity_limit: scirs2_core::numeric::NumCast::from(0.75)
.unwrap_or_else(|| T::zero()),
max_components: 6,
max_depth: 7,
exploration_factor: scirs2_core::numeric::NumCast::from(0.4)
.unwrap_or_else(|| T::zero()),
mutation_rate: scirs2_core::numeric::NumCast::from(0.15)
.unwrap_or_else(|| T::zero()),
population_diversity_weight: scirs2_core::numeric::NumCast::from(0.3)
.unwrap_or_else(|| T::zero()),
conservative_search: false,
},
SearchPhase::Final => SearchPhaseConfig {
complexity_limit: T::one(),
max_components: 8,
max_depth: 10,
exploration_factor: scirs2_core::numeric::NumCast::from(0.2)
.unwrap_or_else(|| T::zero()),
mutation_rate: scirs2_core::numeric::NumCast::from(0.1)
.unwrap_or_else(|| T::zero()),
population_diversity_weight: scirs2_core::numeric::NumCast::from(0.2)
.unwrap_or_else(|| T::zero()),
conservative_search: false,
},
}
}
pub fn record_architecture_evaluation(
&mut self,
architecture_id: String,
performance: T,
complexity: T,
) -> Result<()> {
if architecture_id.is_empty() {
return Err(OptimError::InvalidParameter(
"a progression record needs the id of the architecture it is about; \
an empty id cannot be credited to anything"
.to_string(),
));
}
self.architecture_progression.record_evaluation(
self.current_phase,
architecture_id,
performance,
complexity,
);
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct SearchPhaseConfig<T: Float + Debug + Send + Sync + 'static> {
pub complexity_limit: T,
pub max_components: usize,
pub max_depth: usize,
pub exploration_factor: T,
pub mutation_rate: T,
pub population_diversity_weight: T,
pub conservative_search: bool,
}
impl<T: Float + Debug + Send + Sync + 'static> ComplexityScheduler<T> {
pub fn new() -> Result<Self> {
Ok(Self {
current_complexity: scalar_as(0.1, "initial complexity")?,
max_complexity: T::one(),
increase_rate: scalar_as(0.1, "complexity increase rate")?,
strategy: SchedulingStrategy::Linear,
})
}
fn phase_factor(phase: SearchPhase) -> Result<T> {
let steps: T = count_as(SearchPhase::ORDER.len(), "phase count")?;
let position: T = count_as(phase.index() + 1, "phase position")?;
Ok(position / steps)
}
pub fn update_complexity(&mut self, phase: SearchPhase) -> Result<T> {
match self.strategy {
SchedulingStrategy::Linear => {
self.current_complexity = self.max_complexity * Self::phase_factor(phase)?;
}
SchedulingStrategy::Exponential => {
self.current_complexity = self.current_complexity * (T::one() + self.increase_rate);
if self.current_complexity > self.max_complexity {
self.current_complexity = self.max_complexity;
}
}
SchedulingStrategy::StepWise => {
let steps: T = count_as(phase.index(), "completed phase count")?;
let base: T = scalar_as(0.1, "step-wise base complexity")?;
let level = base + self.increase_rate * steps;
self.current_complexity = if level > self.max_complexity {
self.max_complexity
} else {
level
};
}
SchedulingStrategy::Adaptive => {
let target = self.max_complexity * Self::phase_factor(phase)?;
let gap = target - self.current_complexity;
self.current_complexity = self.current_complexity + gap * self.increase_rate;
if self.current_complexity > self.max_complexity {
self.current_complexity = self.max_complexity;
}
}
}
Ok(self.current_complexity)
}
}
impl<T: Float + Debug + Send + Sync + 'static> Default for ArchitectureProgression<T> {
fn default() -> Self {
Self::new()
}
}
impl<T: Float + Debug + Send + Sync + 'static> ArchitectureProgression<T> {
pub fn new() -> Self {
Self {
history: Vec::new(),
best_per_phase: HashMap::new(),
performance_trends: Vec::new(),
}
}
pub fn record_evaluation(
&mut self,
phase: SearchPhase,
architecture_id: String,
performance: T,
complexity: T,
) {
let best_in_phase = self.best_in_phase(phase).map_or(performance, |best| {
if performance > best {
performance
} else {
best
}
});
self.performance_trends.push(performance);
self.history.push(ProgressionRecord {
phase,
complexity,
performance,
best_performance: best_in_phase,
architectures_evaluated: self.history.len() + 1,
timestamp: std::time::Instant::now(),
});
let ranked = self.best_per_phase.entry(phase).or_default();
ranked.push(ScoredArchitecture {
architecture_id,
performance,
});
ranked.sort_by(|left, right| {
right
.performance
.partial_cmp(&left.performance)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| left.architecture_id.cmp(&right.architecture_id))
});
ranked.truncate(BEST_PER_PHASE_CAPACITY);
}
pub fn record_step(&mut self, record: ProgressionRecord<T>) {
self.performance_trends.push(record.performance);
self.history.push(record);
}
pub fn best_in_phase(&self, phase: SearchPhase) -> Option<T> {
self.best_per_phase
.get(&phase)
.and_then(|ranked| ranked.first())
.map(|entry| entry.performance)
}
pub fn get_best_for_phase(&self, phase: SearchPhase) -> Vec<String> {
self.best_per_phase
.get(&phase)
.map(|ranked| {
ranked
.iter()
.map(|entry| entry.architecture_id.clone())
.collect()
})
.unwrap_or_default()
}
pub fn best_scored_for_phase(&self, phase: SearchPhase) -> &[ScoredArchitecture<T>] {
self.best_per_phase
.get(&phase)
.map(|ranked| ranked.as_slice())
.unwrap_or(&[])
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config_with_budget(budget: usize) -> NASConfig<f64> {
NASConfig::<f64> {
search_budget: budget,
..NASConfig::<f64>::default()
}
}
#[test]
fn test_progressive_nas_creation() {
let nas: ProgressiveNAS<f64> =
ProgressiveNAS::new(&NASConfig::<f64>::default()).expect("construction");
assert_eq!(nas.current_phase, SearchPhase::Initial);
assert!(!nas.is_search_complete());
}
#[test]
fn a_tiny_budget_does_not_produce_a_zero_length_phase() {
for budget in [0, 1, 2, 3, 50, 51, 199] {
let mut nas: ProgressiveNAS<f64> =
ProgressiveNAS::new(&config_with_budget(budget)).expect("construction");
if let ProgressionStrategy::BudgetBased(per_phase) = nas.progression_strategy {
assert!(per_phase >= 1, "budget {budget} gave a zero-length phase");
}
nas.update_search_phase(7)
.expect("updating a phase must not divide by zero");
}
}
#[test]
fn a_sub_second_time_budget_is_an_error_not_a_panic() {
let mut nas: ProgressiveNAS<f64> =
ProgressiveNAS::new(&config_with_budget(10)).expect("construction");
nas.progression_strategy =
ProgressionStrategy::TimeBased(std::time::Duration::from_millis(250));
nas.record_architecture_evaluation("a".to_string(), 0.5, 0.1)
.expect("record");
let err = nas
.update_search_phase(1)
.expect_err("a zero-second phase length cannot be honoured");
assert!(matches!(err, OptimError::InvalidConfig(_)));
nas.progression_strategy = ProgressionStrategy::BudgetBased(0);
assert!(matches!(
nas.update_search_phase(1),
Err(OptimError::InvalidConfig(_))
));
}
#[test]
fn budget_based_phases_advance_and_never_regress() {
let mut nas: ProgressiveNAS<f64> =
ProgressiveNAS::new(&config_with_budget(100)).expect("construction");
nas.progression_strategy = ProgressionStrategy::BudgetBased(10);
nas.update_search_phase(0).expect("update");
assert_eq!(nas.current_phase, SearchPhase::Initial);
nas.update_search_phase(10).expect("update");
assert_eq!(nas.current_phase, SearchPhase::Intermediate);
nas.update_search_phase(25).expect("update");
assert_eq!(nas.current_phase, SearchPhase::Advanced);
nas.update_search_phase(99).expect("update");
assert_eq!(nas.current_phase, SearchPhase::Final);
nas.update_search_phase(0).expect("update");
assert_eq!(
nas.current_phase,
SearchPhase::Final,
"phases must not regress"
);
assert!(
nas.is_search_complete(),
"staying in Final after the schedule is exhausted must be reported"
);
}
#[test]
fn performance_based_progression_advances_once_per_call_and_then_completes() {
let mut nas: ProgressiveNAS<f64> =
ProgressiveNAS::new(&config_with_budget(10)).expect("construction");
nas.progression_strategy = ProgressionStrategy::PerformanceBased(0.5);
nas.record_architecture_evaluation("a".to_string(), 0.9, 0.1)
.expect("record");
for expected in [
SearchPhase::Intermediate,
SearchPhase::Advanced,
SearchPhase::Final,
] {
nas.update_search_phase(0).expect("update");
assert_eq!(nas.current_phase, expected);
assert!(!nas.is_search_complete());
}
nas.update_search_phase(0).expect("update");
assert_eq!(nas.current_phase, SearchPhase::Final);
assert!(nas.is_search_complete());
}
#[test]
fn the_adaptive_trend_only_sees_the_current_phase() {
let mut nas: ProgressiveNAS<f64> =
ProgressiveNAS::new(&config_with_budget(10)).expect("construction");
nas.progression_strategy = ProgressionStrategy::Adaptive;
for step in 0..TREND_WINDOW {
nas.record_architecture_evaluation(format!("p0_{step}"), step as f64, 0.1)
.expect("record");
}
nas.update_search_phase(0).expect("update");
assert_eq!(
nas.current_phase,
SearchPhase::Initial,
"a strongly improving phase must not be abandoned"
);
nas.current_phase = SearchPhase::Intermediate;
for step in 0..TREND_WINDOW {
nas.record_architecture_evaluation(format!("p1_{step}"), 1.0, 0.1)
.expect("record");
}
nas.update_search_phase(0).expect("update");
assert_eq!(
nas.current_phase,
SearchPhase::Advanced,
"a flat current phase must trigger an advance"
);
}
#[test]
fn best_per_phase_keeps_the_best_not_the_most_recent() {
let mut nas: ProgressiveNAS<f64> =
ProgressiveNAS::new(&config_with_budget(10)).expect("construction");
for (id, score) in [
("great", 0.99),
("good", 0.80),
("ok", 0.60),
("bad", 0.10),
("awful", 0.01),
] {
nas.record_architecture_evaluation(id.to_string(), score, 0.1)
.expect("record");
}
let best = nas
.architecture_progression
.get_best_for_phase(SearchPhase::Initial);
assert_eq!(
best,
vec!["great".to_string(), "good".to_string(), "ok".to_string()],
"the FIFO used to return the three most recent ids instead"
);
assert_eq!(best.len(), BEST_PER_PHASE_CAPACITY);
assert_eq!(
nas.architecture_progression
.best_in_phase(SearchPhase::Initial),
Some(0.99)
);
nas.current_phase = SearchPhase::Advanced;
nas.record_architecture_evaluation("late".to_string(), 0.5, 0.2)
.expect("record");
assert_eq!(
nas.architecture_progression
.get_best_for_phase(SearchPhase::Advanced),
vec!["late".to_string()]
);
assert!(!nas
.architecture_progression
.get_best_for_phase(SearchPhase::Initial)
.contains(&"late".to_string()));
}
#[test]
fn the_record_high_water_mark_is_the_phase_best_not_the_latest_score() {
let mut nas: ProgressiveNAS<f64> =
ProgressiveNAS::new(&config_with_budget(10)).expect("construction");
nas.record_architecture_evaluation("first".to_string(), 0.9, 0.1)
.expect("record");
nas.record_architecture_evaluation("second".to_string(), 0.2, 0.1)
.expect("record");
let history = &nas.architecture_progression.history;
assert_eq!(history.len(), 2);
assert!((history[1].performance - 0.2).abs() < 1e-12);
assert!(
(history[1].best_performance - 0.9).abs() < 1e-12,
"a worse result must not lower the phase's high-water mark"
);
}
#[test]
fn an_unidentified_evaluation_is_rejected() {
let mut nas: ProgressiveNAS<f64> =
ProgressiveNAS::new(&config_with_budget(10)).expect("construction");
let err = nas
.record_architecture_evaluation(String::new(), 0.5, 0.1)
.expect_err("an empty architecture id cannot be credited");
assert!(matches!(err, OptimError::InvalidParameter(_)));
assert!(nas.architecture_progression.history.is_empty());
}
#[test]
fn test_complexity_scheduler_linear() {
let mut scheduler = ComplexityScheduler::<f64>::new().expect("construction");
let initial = scheduler
.update_complexity(SearchPhase::Initial)
.expect("phase factors are representable in f64");
let intermediate = scheduler
.update_complexity(SearchPhase::Intermediate)
.expect("phase factors are representable in f64");
let advanced = scheduler
.update_complexity(SearchPhase::Advanced)
.expect("phase factors are representable in f64");
let final_level = scheduler
.update_complexity(SearchPhase::Final)
.expect("phase factors are representable in f64");
assert!(initial < intermediate);
assert!(intermediate < advanced);
assert!(advanced < final_level);
assert!((final_level - 1.0).abs() < 1e-12);
}
#[test]
fn every_scheduling_strategy_actually_schedules() {
let mut stepwise = ComplexityScheduler::<f64>::new().expect("construction");
stepwise.strategy = SchedulingStrategy::StepWise;
let levels: Vec<f64> = SearchPhase::ORDER
.iter()
.map(|&phase| {
stepwise
.update_complexity(phase)
.expect("phase factors are representable in f64")
})
.collect();
for window in levels.windows(2) {
assert!(
window[1] > window[0],
"step-wise complexity did not grow: {levels:?}"
);
}
assert!(levels.iter().all(|level| *level <= 1.0));
let mut adaptive = ComplexityScheduler::<f64>::new().expect("construction");
adaptive.strategy = SchedulingStrategy::Adaptive;
let start = adaptive.current_complexity;
let after = adaptive
.update_complexity(SearchPhase::Final)
.expect("phase factors are representable in f64");
assert!(after > start, "adaptive complexity did not move");
assert!(after < 1.0);
for _ in 0..500 {
adaptive
.update_complexity(SearchPhase::Final)
.expect("phase factors are representable in f64");
}
assert!((adaptive.current_complexity - 1.0).abs() < 1e-6);
let mut exponential = ComplexityScheduler::<f64>::new().expect("construction");
exponential.strategy = SchedulingStrategy::Exponential;
for _ in 0..500 {
exponential
.update_complexity(SearchPhase::Initial)
.expect("phase factors are representable in f64");
}
assert!((exponential.current_complexity - 1.0).abs() < 1e-12);
}
#[test]
fn test_architecture_progression() {
let mut progression = ArchitectureProgression::<f64>::new();
progression.record_evaluation(SearchPhase::Initial, "a".to_string(), 0.8, 0.1);
assert_eq!(progression.history.len(), 1);
assert_eq!(progression.performance_trends.len(), 1);
assert_eq!(progression.history[0].architectures_evaluated, 1);
assert_eq!(
progression
.best_scored_for_phase(SearchPhase::Initial)
.len(),
1
);
assert!(progression
.best_scored_for_phase(SearchPhase::Advanced)
.is_empty());
}
}