1use crate::domain_optimizers::{l2_norm, AdvancedOptimizer, OptimizerStateInfo};
73use crate::error::{OptimError, Result};
74use scirs2_core::ndarray::Array1;
75use scirs2_core::numeric::Float;
76use scirs2_core::random::Random;
77use std::fmt::Debug;
78use std::sync::Arc;
79
80fn cast<T: Float>(value: f64) -> T {
84 T::from(value).unwrap_or_else(T::zero)
85}
86
87fn to_scalar<T: Float>(value: f64) -> Result<T> {
90 T::from(value).ok_or_else(|| {
91 OptimError::ComputationError(format!(
92 "cannot represent {value} in the target floating-point type"
93 ))
94 })
95}
96
97pub(crate) fn softmax<T: Float>(logits: &Array1<T>) -> Array1<T> {
104 if logits.is_empty() {
105 return Array1::zeros(0);
106 }
107 let max_logit = logits
108 .iter()
109 .fold(T::neg_infinity(), |acc, &x| if x > acc { x } else { acc });
110 let exps = logits.mapv(|x| (x - max_logit).exp());
111 let sum = exps.iter().fold(T::zero(), |acc, &x| acc + x);
112 exps.mapv(|x| x / sum)
113}
114
115fn mixed_update<T: Float>(weights: &Array1<T>, ops: &[Array1<T>]) -> Array1<T> {
117 let dim = ops.first().map_or(0, Array1::len);
118 let mut acc = Array1::<T>::zeros(dim);
119 for (&weight, op) in weights.iter().zip(ops.iter()) {
120 for (slot, &value) in acc.iter_mut().zip(op.iter()) {
121 *slot = *slot + value * weight;
122 }
123 }
124 acc
125}
126
127fn architecture_gradient<T: Float>(
132 weights: &Array1<T>,
133 ops: &[Array1<T>],
134 mixed: &Array1<T>,
135 g_val: &Array1<T>,
136 lr: T,
137) -> Array1<T> {
138 let grads: Vec<T> = ops
139 .iter()
140 .zip(weights.iter())
141 .map(|(op, &weight)| {
142 let directional = op
143 .iter()
144 .zip(mixed.iter())
145 .zip(g_val.iter())
146 .fold(T::zero(), |acc, ((&u, &m), &g)| acc + g * (u - m));
147 -lr * weight * directional
148 })
149 .collect();
150 Array1::from_vec(grads)
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
157pub enum UpdatePrimitive {
158 Grad,
160 Momentum,
163 RmsProp,
166 Sign,
168 AdamLike,
170 WeightDecay,
172}
173
174impl UpdatePrimitive {
175 pub fn name(&self) -> &'static str {
177 match self {
178 UpdatePrimitive::Grad => "Grad",
179 UpdatePrimitive::Momentum => "Momentum",
180 UpdatePrimitive::RmsProp => "RmsProp",
181 UpdatePrimitive::Sign => "Sign",
182 UpdatePrimitive::AdamLike => "AdamLike",
183 UpdatePrimitive::WeightDecay => "WeightDecay",
184 }
185 }
186
187 pub fn default_set() -> Vec<UpdatePrimitive> {
189 vec![
190 UpdatePrimitive::Grad,
191 UpdatePrimitive::Momentum,
192 UpdatePrimitive::RmsProp,
193 UpdatePrimitive::Sign,
194 UpdatePrimitive::AdamLike,
195 UpdatePrimitive::WeightDecay,
196 ]
197 }
198
199 pub fn is_stateful(&self) -> bool {
201 matches!(
202 self,
203 UpdatePrimitive::Momentum | UpdatePrimitive::RmsProp | UpdatePrimitive::AdamLike
204 )
205 }
206
207 pub fn apply<T>(
212 &self,
213 grad: &Array1<T>,
214 params: &Array1<T>,
215 state: &mut PrimitiveState<T>,
216 hp: &PrimitiveHyperparams<T>,
217 ) -> Array1<T>
218 where
219 T: Float + Debug + Send + Sync + 'static,
220 {
221 match self {
222 UpdatePrimitive::Grad => grad.clone(),
223 UpdatePrimitive::Sign => grad.mapv(|g| {
224 if g > T::zero() {
227 T::one()
228 } else if g < T::zero() {
229 -T::one()
230 } else {
231 T::zero()
232 }
233 }),
234 UpdatePrimitive::Momentum => {
235 let beta = hp.momentum_beta;
236 let one_minus = T::one() - beta;
237 state
238 .momentum
239 .zip_mut_with(grad, |m, &g| *m = beta * *m + one_minus * g);
240 state.momentum.clone()
241 }
242 UpdatePrimitive::RmsProp => {
243 let decay = hp.rms_decay;
244 let one_minus = T::one() - decay;
245 let eps = hp.epsilon;
246 state
247 .second_moment
248 .zip_mut_with(grad, |v, &g| *v = decay * *v + one_minus * g * g);
249 let mut out = grad.clone();
250 out.zip_mut_with(&state.second_moment, |o, &v| *o = *o / (v.sqrt() + eps));
251 out
252 }
253 UpdatePrimitive::AdamLike => {
254 state.step += 1;
255 let beta1 = hp.adam_beta1;
256 let beta2 = hp.adam_beta2;
257 let eps = hp.epsilon;
258 let one_minus1 = T::one() - beta1;
259 let one_minus2 = T::one() - beta2;
260 state
261 .momentum
262 .zip_mut_with(grad, |m, &g| *m = beta1 * *m + one_minus1 * g);
263 state
264 .second_moment
265 .zip_mut_with(grad, |v, &g| *v = beta2 * *v + one_minus2 * g * g);
266 let step_exp = i32::try_from(state.step).unwrap_or(i32::MAX);
267 let bias1 = T::one() - beta1.powi(step_exp);
268 let bias2 = T::one() - beta2.powi(step_exp);
269 let mut out = Array1::<T>::zeros(grad.len());
270 out.zip_mut_with(&state.momentum, |o, &m| *o = m / bias1);
271 out.zip_mut_with(&state.second_moment, |o, &v| {
272 let v_hat = (v / bias2).sqrt();
273 *o = *o / (v_hat + eps);
274 });
275 out
276 }
277 UpdatePrimitive::WeightDecay => params.mapv(|p| p * hp.weight_decay_strength),
278 }
279 }
280}
281
282#[derive(Debug, Clone)]
288pub struct PrimitiveState<T: Float + Debug + Send + Sync + 'static> {
289 momentum: Array1<T>,
291 second_moment: Array1<T>,
293 step: usize,
295}
296
297impl<T: Float + Debug + Send + Sync + 'static> PrimitiveState<T> {
298 pub fn new(dim: usize) -> Self {
300 Self {
301 momentum: Array1::zeros(dim),
302 second_moment: Array1::zeros(dim),
303 step: 0,
304 }
305 }
306}
307
308#[derive(Debug, Clone)]
311pub struct PrimitiveHyperparams<T: Float + Debug + Send + Sync + 'static> {
312 pub momentum_beta: T,
314 pub adam_beta1: T,
316 pub adam_beta2: T,
318 pub rms_decay: T,
320 pub epsilon: T,
322 pub weight_decay_strength: T,
324}
325
326impl<T: Float + Debug + Send + Sync + 'static> PrimitiveHyperparams<T> {
327 fn from_config(config: &DartsConfig) -> Result<Self> {
329 Ok(Self {
330 momentum_beta: to_scalar(config.momentum_beta)?,
331 adam_beta1: to_scalar(config.adam_beta1)?,
332 adam_beta2: to_scalar(config.adam_beta2)?,
333 rms_decay: to_scalar(config.rms_decay)?,
334 epsilon: to_scalar(config.epsilon)?,
335 weight_decay_strength: to_scalar(config.weight_decay_strength)?,
336 })
337 }
338}
339
340type LossGradFn<T> = Arc<dyn Fn(&Array1<T>) -> Result<(T, Array1<T>)> + Send + Sync>;
342
343pub trait DifferentiableObjective<T: Float + Debug + Send + Sync + 'static> {
349 fn train_loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)>;
351 fn validation_loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)>;
353 fn dim(&self) -> usize;
355}
356
357#[derive(Debug, Clone)]
363pub struct QuadraticBowl<T: Float + Debug + Send + Sync + 'static> {
364 train_curvature: Array1<T>,
365 train_center: Array1<T>,
366 val_curvature: Array1<T>,
367 val_center: Array1<T>,
368}
369
370impl<T: Float + Debug + Send + Sync + 'static> QuadraticBowl<T> {
371 pub fn new(curvature: Array1<T>) -> Self {
374 let center = Array1::zeros(curvature.len());
375 Self {
376 train_curvature: curvature.clone(),
377 train_center: center.clone(),
378 val_curvature: curvature,
379 val_center: center,
380 }
381 }
382
383 pub fn isotropic(dim: usize, curvature: T) -> Result<Self> {
385 if dim == 0 {
386 return Err(OptimError::InvalidConfig(
387 "QuadraticBowl dimension must be positive".to_string(),
388 ));
389 }
390 Ok(Self::new(Array1::from_elem(dim, curvature)))
391 }
392
393 pub fn with_validation(mut self, curvature: Array1<T>, center: Array1<T>) -> Result<Self> {
395 if curvature.len() != self.train_curvature.len() || center.len() != self.train_center.len()
396 {
397 return Err(OptimError::InvalidConfig(
398 "validation curvature/center dimension must match the training half".to_string(),
399 ));
400 }
401 self.val_curvature = curvature;
402 self.val_center = center;
403 Ok(self)
404 }
405
406 fn loss_grad(
407 curvature: &Array1<T>,
408 center: &Array1<T>,
409 params: &Array1<T>,
410 ) -> Result<(T, Array1<T>)> {
411 if params.len() != curvature.len() {
412 return Err(OptimError::InvalidConfig(format!(
413 "parameter length {} != objective dimension {}",
414 params.len(),
415 curvature.len()
416 )));
417 }
418 let half = cast::<T>(0.5);
419 let mut loss = T::zero();
420 let mut grad = Array1::<T>::zeros(params.len());
421 for (slot, ((&c, &p), &ctr)) in grad
422 .iter_mut()
423 .zip(curvature.iter().zip(params.iter()).zip(center.iter()))
424 {
425 let diff = p - ctr;
426 *slot = c * diff;
427 loss = loss + half * c * diff * diff;
428 }
429 Ok((loss, grad))
430 }
431}
432
433impl<T: Float + Debug + Send + Sync + 'static> DifferentiableObjective<T> for QuadraticBowl<T> {
434 fn train_loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)> {
435 Self::loss_grad(&self.train_curvature, &self.train_center, params)
436 }
437
438 fn validation_loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)> {
439 Self::loss_grad(&self.val_curvature, &self.val_center, params)
440 }
441
442 fn dim(&self) -> usize {
443 self.train_curvature.len()
444 }
445}
446
447#[derive(Debug, Clone)]
453pub struct Rosenbrock<T: Float + Debug + Send + Sync + 'static> {
454 dim: usize,
455 a: T,
456 b: T,
457}
458
459impl<T: Float + Debug + Send + Sync + 'static> Rosenbrock<T> {
460 pub fn new(dim: usize) -> Result<Self> {
463 if dim < 2 {
464 return Err(OptimError::InvalidConfig(
465 "Rosenbrock requires at least two dimensions".to_string(),
466 ));
467 }
468 Ok(Self {
469 dim,
470 a: T::one(),
471 b: cast::<T>(100.0),
472 })
473 }
474
475 pub fn with_constants(mut self, a: T, b: T) -> Self {
477 self.a = a;
478 self.b = b;
479 self
480 }
481
482 fn loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)> {
483 if params.len() != self.dim {
484 return Err(OptimError::InvalidConfig(format!(
485 "parameter length {} != objective dimension {}",
486 params.len(),
487 self.dim
488 )));
489 }
490 let two = cast::<T>(2.0);
491 let mut loss = T::zero();
492 let mut grad = Array1::<T>::zeros(self.dim);
493 for i in 0..self.dim - 1 {
494 let xi = params[i];
495 let xi1 = params[i + 1];
496 let residual = xi1 - xi * xi;
497 let offset = self.a - xi;
498 loss = loss + self.b * residual * residual + offset * offset;
499 grad[i] = grad[i] + self.b * two * residual * (-two * xi) - two * offset;
500 grad[i + 1] = grad[i + 1] + self.b * two * residual;
501 }
502 Ok((loss, grad))
503 }
504}
505
506impl<T: Float + Debug + Send + Sync + 'static> DifferentiableObjective<T> for Rosenbrock<T> {
507 fn train_loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)> {
508 self.loss_grad(params)
509 }
510
511 fn validation_loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)> {
512 self.loss_grad(params)
513 }
514
515 fn dim(&self) -> usize {
516 self.dim
517 }
518}
519
520#[derive(Clone)]
522pub struct ClosureObjective<T: Float + Debug + Send + Sync + 'static> {
523 dim: usize,
524 train: LossGradFn<T>,
525 validation: LossGradFn<T>,
526}
527
528impl<T: Float + Debug + Send + Sync + 'static> ClosureObjective<T> {
529 pub fn new<F, G>(dim: usize, train: F, validation: G) -> Result<Self>
531 where
532 F: Fn(&Array1<T>) -> Result<(T, Array1<T>)> + Send + Sync + 'static,
533 G: Fn(&Array1<T>) -> Result<(T, Array1<T>)> + Send + Sync + 'static,
534 {
535 if dim == 0 {
536 return Err(OptimError::InvalidConfig(
537 "ClosureObjective dimension must be positive".to_string(),
538 ));
539 }
540 Ok(Self {
541 dim,
542 train: Arc::new(train),
543 validation: Arc::new(validation),
544 })
545 }
546
547 pub fn from_single<F>(dim: usize, objective: F) -> Result<Self>
549 where
550 F: Fn(&Array1<T>) -> Result<(T, Array1<T>)> + Send + Sync + 'static,
551 {
552 if dim == 0 {
553 return Err(OptimError::InvalidConfig(
554 "ClosureObjective dimension must be positive".to_string(),
555 ));
556 }
557 let shared: LossGradFn<T> = Arc::new(objective);
558 Ok(Self {
559 dim,
560 train: Arc::clone(&shared),
561 validation: shared,
562 })
563 }
564}
565
566impl<T: Float + Debug + Send + Sync + 'static> DifferentiableObjective<T> for ClosureObjective<T> {
567 fn train_loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)> {
568 (self.train)(params)
569 }
570
571 fn validation_loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)> {
572 (self.validation)(params)
573 }
574
575 fn dim(&self) -> usize {
576 self.dim
577 }
578}
579
580#[derive(Debug, Clone)]
582pub struct DartsConfig {
583 pub primitives: Vec<UpdatePrimitive>,
585 pub learning_rate: f64,
587 pub arch_learning_rate: f64,
589 pub epochs: usize,
591 pub inner_steps: usize,
593 pub top_k: usize,
595 pub reset_each_epoch: bool,
599 pub init_params_scale: f64,
601 pub momentum_beta: f64,
603 pub adam_beta1: f64,
605 pub adam_beta2: f64,
607 pub rms_decay: f64,
609 pub epsilon: f64,
611 pub weight_decay_strength: f64,
613 pub seed: u64,
615}
616
617impl Default for DartsConfig {
618 fn default() -> Self {
619 Self {
620 primitives: UpdatePrimitive::default_set(),
621 learning_rate: 0.1,
622 arch_learning_rate: 0.3,
623 epochs: 60,
624 inner_steps: 5,
625 top_k: 1,
626 reset_each_epoch: true,
627 init_params_scale: 2.0,
628 momentum_beta: 0.9,
629 adam_beta1: 0.9,
630 adam_beta2: 0.999,
631 rms_decay: 0.9,
632 epsilon: 1e-8,
633 weight_decay_strength: 0.01,
634 seed: 0x0DA2_750F_C0FF_EE11,
635 }
636 }
637}
638
639impl DartsConfig {
640 pub fn validate(&self) -> Result<()> {
642 if self.primitives.is_empty() {
643 return Err(OptimError::InvalidConfig(
644 "the primitive search set must not be empty".to_string(),
645 ));
646 }
647 if self.top_k == 0 || self.top_k > self.primitives.len() {
648 return Err(OptimError::InvalidConfig(format!(
649 "top_k must be in 1..={}, got {}",
650 self.primitives.len(),
651 self.top_k
652 )));
653 }
654 if self.epochs == 0 {
655 return Err(OptimError::InvalidConfig(
656 "epochs must be at least 1".to_string(),
657 ));
658 }
659 if self.inner_steps == 0 {
660 return Err(OptimError::InvalidConfig(
661 "inner_steps must be at least 1".to_string(),
662 ));
663 }
664 Self::require_positive_finite("learning_rate", self.learning_rate)?;
665 Self::require_positive_finite("arch_learning_rate", self.arch_learning_rate)?;
666 Self::require_positive_finite("init_params_scale", self.init_params_scale)?;
667 Self::require_positive_finite("epsilon", self.epsilon)?;
668 Self::require_unit_open("momentum_beta", self.momentum_beta)?;
669 Self::require_unit_open("adam_beta1", self.adam_beta1)?;
670 Self::require_unit_open("adam_beta2", self.adam_beta2)?;
671 Self::require_unit_open("rms_decay", self.rms_decay)?;
672 if !self.weight_decay_strength.is_finite() || self.weight_decay_strength < 0.0 {
673 return Err(OptimError::InvalidConfig(
674 "weight_decay_strength must be finite and non-negative".to_string(),
675 ));
676 }
677 Ok(())
678 }
679
680 fn require_positive_finite(field: &str, value: f64) -> Result<()> {
681 if !value.is_finite() || value <= 0.0 {
682 return Err(OptimError::InvalidConfig(format!(
683 "{field} must be finite and positive, got {value}"
684 )));
685 }
686 Ok(())
687 }
688
689 fn require_unit_open(field: &str, value: f64) -> Result<()> {
690 if !value.is_finite() || value <= 0.0 || value >= 1.0 {
691 return Err(OptimError::InvalidConfig(format!(
692 "{field} must lie in the open interval (0, 1), got {value}"
693 )));
694 }
695 Ok(())
696 }
697}
698
699#[derive(Debug, Clone)]
701pub struct SearchOutcome<T: Float + Debug + Send + Sync + 'static> {
702 pub alpha: Array1<T>,
704 pub softmax_weights: Array1<T>,
706 pub primitives: Vec<UpdatePrimitive>,
708 pub validation_trajectory: Vec<T>,
710 pub discovered: DiscoveredOptimizer<T>,
712}
713
714impl<T: Float + Debug + Send + Sync + 'static> SearchOutcome<T> {
715 pub fn best_primitive(&self) -> UpdatePrimitive {
717 let mut best_index = 0usize;
718 let mut best_weight = T::neg_infinity();
719 for (index, &weight) in self.softmax_weights.iter().enumerate() {
720 if weight > best_weight {
721 best_weight = weight;
722 best_index = index;
723 }
724 }
725 self.primitives[best_index]
726 }
727
728 pub fn best_weight(&self) -> T {
730 self.softmax_weights
731 .iter()
732 .fold(T::neg_infinity(), |acc, &w| if w > acc { w } else { acc })
733 }
734}
735
736pub struct DartsOptimizerSearch<T: Float + Debug + Send + Sync + 'static> {
738 config: DartsConfig,
739 primitives: Vec<UpdatePrimitive>,
740 hyperparams: PrimitiveHyperparams<T>,
741 learning_rate: T,
742 arch_learning_rate: T,
743 alpha: Array1<T>,
744}
745
746impl<T: Float + Debug + Send + Sync + 'static> DartsOptimizerSearch<T> {
747 pub fn new(config: DartsConfig) -> Result<Self> {
752 config.validate()?;
753 let hyperparams = PrimitiveHyperparams::from_config(&config)?;
754 let learning_rate = to_scalar(config.learning_rate)?;
755 let arch_learning_rate = to_scalar(config.arch_learning_rate)?;
756 let primitives = config.primitives.clone();
757 let alpha = Array1::zeros(primitives.len());
758 Ok(Self {
759 config,
760 primitives,
761 hyperparams,
762 learning_rate,
763 arch_learning_rate,
764 alpha,
765 })
766 }
767
768 pub fn alpha(&self) -> &Array1<T> {
770 &self.alpha
771 }
772
773 pub fn architecture_weights(&self) -> Array1<T> {
775 softmax(&self.alpha)
776 }
777
778 pub fn primitives(&self) -> &[UpdatePrimitive] {
780 &self.primitives
781 }
782
783 fn fresh_states(&self, dim: usize) -> Vec<PrimitiveState<T>> {
785 self.primitives
786 .iter()
787 .map(|_| PrimitiveState::new(dim))
788 .collect()
789 }
790
791 pub fn search<O: DifferentiableObjective<T>>(
793 &mut self,
794 objective: &O,
795 ) -> Result<SearchOutcome<T>> {
796 let dim = objective.dim();
797 if dim == 0 {
798 return Err(OptimError::InsufficientData(
799 "objective dimensionality must be positive".to_string(),
800 ));
801 }
802
803 self.alpha = Array1::zeros(self.primitives.len());
805
806 let mut rng = Random::seed(self.config.seed);
808 let scale = self.config.init_params_scale;
809 let initial_params = Array1::from_shape_fn(dim, |_| {
810 let value: f64 = rng.random_range(-scale..scale);
811 cast::<T>(value)
812 });
813
814 let lr = self.learning_rate;
815 let arch_lr = self.arch_learning_rate;
816 let num_primitives = self.primitives.len();
817 let inner_steps = self.config.inner_steps;
818 let inner_scale = cast::<T>(1.0 / inner_steps as f64);
819
820 let mut params = initial_params.clone();
821 let mut states = self.fresh_states(dim);
822 let mut trajectory = Vec::with_capacity(self.config.epochs);
823
824 for _epoch in 0..self.config.epochs {
825 if self.config.reset_each_epoch {
826 params = initial_params.clone();
827 states = self.fresh_states(dim);
828 }
829
830 let mut alpha_grad = Array1::<T>::zeros(num_primitives);
831 let mut epoch_val_loss = T::zero();
832
833 for _inner in 0..inner_steps {
834 let weights = softmax(&self.alpha);
835 let (_, grad_train) = objective.train_loss_grad(¶ms)?;
836
837 let ops: Vec<Array1<T>> = self
839 .primitives
840 .iter()
841 .zip(states.iter_mut())
842 .map(|(primitive, state)| {
843 primitive.apply(&grad_train, ¶ms, state, &self.hyperparams)
844 })
845 .collect();
846 let mixed = mixed_update(&weights, &ops);
847
848 let stepped = ¶ms - &mixed.mapv(|m| m * lr);
850
851 let (val_loss, grad_val) = objective.validation_loss_grad(&stepped)?;
853 let step_grad = architecture_gradient(&weights, &ops, &mixed, &grad_val, lr);
854 alpha_grad = alpha_grad + &step_grad;
855
856 params = stepped;
857 epoch_val_loss = val_loss;
858 }
859
860 let scaled = alpha_grad.mapv(|g| g * inner_scale * arch_lr);
862 self.alpha = &self.alpha - &scaled;
863
864 trajectory.push(epoch_val_loss);
865 }
866
867 let discovered = self.discretize()?;
868 Ok(SearchOutcome {
869 alpha: self.alpha.clone(),
870 softmax_weights: softmax(&self.alpha),
871 primitives: self.primitives.clone(),
872 validation_trajectory: trajectory,
873 discovered,
874 })
875 }
876
877 fn discretize(&self) -> Result<DiscoveredOptimizer<T>> {
879 let weights = softmax(&self.alpha);
880 let mut order: Vec<usize> = (0..weights.len()).collect();
881 order.sort_by(|&a, &b| {
882 weights[b]
883 .partial_cmp(&weights[a])
884 .unwrap_or(std::cmp::Ordering::Equal)
885 });
886
887 let k = self.config.top_k.clamp(1, weights.len());
888 let selected_indices = &order[..k];
889
890 let selected_primitives: Vec<UpdatePrimitive> = selected_indices
891 .iter()
892 .map(|&i| self.primitives[i])
893 .collect();
894 let raw_weights: Vec<T> = selected_indices.iter().map(|&i| weights[i]).collect();
895 let weight_sum = raw_weights.iter().fold(T::zero(), |acc, &w| acc + w);
896
897 let normalized: Array1<T> = if weight_sum > T::zero() {
900 Array1::from_vec(raw_weights).mapv(|w| w / weight_sum)
901 } else {
902 let uniform = cast::<T>(1.0 / k as f64);
903 Array1::from_elem(k, uniform)
904 };
905
906 DiscoveredOptimizer::new(
907 selected_primitives,
908 normalized,
909 self.hyperparams.clone(),
910 lr_of(&self.config)?,
911 )
912 }
913
914 #[cfg(test)]
922 fn lookahead_validation_loss<O: DifferentiableObjective<T>>(
923 &self,
924 alpha: &Array1<T>,
925 ops: &[Array1<T>],
926 params: &Array1<T>,
927 objective: &O,
928 ) -> Result<T> {
929 let weights = softmax(alpha);
930 let mixed = mixed_update(&weights, ops);
931 let stepped = params - &mixed.mapv(|m| m * self.learning_rate);
932 let (loss, _) = objective.validation_loss_grad(&stepped)?;
933 Ok(loss)
934 }
935
936 #[cfg(test)]
942 fn analytic_architecture_gradient<O: DifferentiableObjective<T>>(
943 &self,
944 alpha: &Array1<T>,
945 ops: &[Array1<T>],
946 params: &Array1<T>,
947 objective: &O,
948 ) -> Result<Array1<T>> {
949 let weights = softmax(alpha);
950 let mixed = mixed_update(&weights, ops);
951 let stepped = params - &mixed.mapv(|m| m * self.learning_rate);
952 let (_, grad_val) = objective.validation_loss_grad(&stepped)?;
953 Ok(architecture_gradient(
954 &weights,
955 ops,
956 &mixed,
957 &grad_val,
958 self.learning_rate,
959 ))
960 }
961}
962
963fn lr_of<T: Float>(config: &DartsConfig) -> Result<T> {
965 to_scalar(config.learning_rate)
966}
967
968#[derive(Debug, Clone)]
973pub struct DiscoveredOptimizer<T: Float + Debug + Send + Sync + 'static> {
974 primitives: Vec<UpdatePrimitive>,
975 weights: Array1<T>,
976 hyperparams: PrimitiveHyperparams<T>,
977 states: Vec<PrimitiveState<T>>,
978 learning_rate: T,
979 param_len: usize,
980 step_count: usize,
981 grad_norm_ema: T,
982 norm_ema_decay: T,
983}
984
985impl<T: Float + Debug + Send + Sync + 'static> DiscoveredOptimizer<T> {
986 fn new(
989 primitives: Vec<UpdatePrimitive>,
990 weights: Array1<T>,
991 hyperparams: PrimitiveHyperparams<T>,
992 learning_rate: T,
993 ) -> Result<Self> {
994 if primitives.is_empty() || primitives.len() != weights.len() {
995 return Err(OptimError::InvalidConfig(
996 "discovered optimizer requires a non-empty, weight-aligned primitive set"
997 .to_string(),
998 ));
999 }
1000 Ok(Self {
1001 primitives,
1002 weights,
1003 hyperparams,
1004 states: Vec::new(),
1005 learning_rate,
1006 param_len: 0,
1007 step_count: 0,
1008 grad_norm_ema: T::zero(),
1009 norm_ema_decay: cast::<T>(0.999),
1010 })
1011 }
1012
1013 pub fn selected_primitives(&self) -> &[UpdatePrimitive] {
1015 &self.primitives
1016 }
1017
1018 pub fn selected_weights(&self) -> &Array1<T> {
1020 &self.weights
1021 }
1022
1023 pub fn step_count(&self) -> usize {
1025 self.step_count
1026 }
1027}
1028
1029impl<T: Float + Debug + Send + Sync + 'static> AdvancedOptimizer<T> for DiscoveredOptimizer<T> {
1030 fn step(&mut self, params: &Array1<T>, gradients: &Array1<T>) -> Result<Array1<T>> {
1031 if params.len() != gradients.len() {
1032 return Err(OptimError::InvalidConfig(format!(
1033 "Parameter length {} != gradient length {}",
1034 params.len(),
1035 gradients.len()
1036 )));
1037 }
1038 if params.is_empty() {
1039 return Err(OptimError::InsufficientData(
1040 "Empty parameter array".to_string(),
1041 ));
1042 }
1043
1044 if self.param_len != params.len() {
1045 self.states = self
1046 .primitives
1047 .iter()
1048 .map(|_| PrimitiveState::new(params.len()))
1049 .collect();
1050 self.param_len = params.len();
1051 }
1052
1053 let ops: Vec<Array1<T>> = self
1054 .primitives
1055 .iter()
1056 .zip(self.states.iter_mut())
1057 .map(|(primitive, state)| primitive.apply(gradients, params, state, &self.hyperparams))
1058 .collect();
1059 let mixed = mixed_update(&self.weights, &ops);
1060 let updated = params - &mixed.mapv(|m| m * self.learning_rate);
1061
1062 let grad_norm = l2_norm(gradients);
1063 self.grad_norm_ema =
1064 self.norm_ema_decay * self.grad_norm_ema + (T::one() - self.norm_ema_decay) * grad_norm;
1065 self.step_count += 1;
1066
1067 Ok(updated)
1068 }
1069
1070 fn get_learning_rate(&self) -> T {
1071 self.learning_rate
1072 }
1073
1074 fn set_learning_rate(&mut self, lr: T) {
1075 self.learning_rate = lr;
1076 }
1077
1078 fn name(&self) -> &str {
1079 "DartsDiscovered"
1080 }
1081
1082 fn get_state(&self) -> OptimizerStateInfo<T> {
1083 OptimizerStateInfo {
1084 step_count: self.step_count,
1085 current_lr: self.learning_rate,
1086 grad_norm_ema: self.grad_norm_ema,
1087 }
1088 }
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093 use super::*;
1094
1095 fn l2(arr: &Array1<f64>) -> f64 {
1096 arr.iter().fold(0.0, |acc, &x| acc + x * x).sqrt()
1097 }
1098
1099 #[test]
1100 fn test_softmax_positive_and_sums_to_one() {
1101 let logits = Array1::from_vec(vec![-2.0_f64, 0.5, 3.0, -0.1, 1.2]);
1102 let weights = softmax(&logits);
1103 let sum: f64 = weights.iter().sum();
1104 assert!(
1105 (sum - 1.0).abs() < 1e-12,
1106 "softmax must sum to 1, got {sum}"
1107 );
1108 for &w in weights.iter() {
1109 assert!(
1110 w > 0.0,
1111 "softmax weights must be strictly positive, got {w}"
1112 );
1113 }
1114 let argmax = weights
1116 .iter()
1117 .enumerate()
1118 .fold((0usize, f64::NEG_INFINITY), |best, (i, &w)| {
1119 if w > best.1 {
1120 (i, w)
1121 } else {
1122 best
1123 }
1124 })
1125 .0;
1126 assert_eq!(argmax, 2, "largest logit should map to largest weight");
1127 }
1128
1129 #[test]
1130 fn test_softmax_numerically_stable_large_logits() {
1131 let logits = Array1::from_vec(vec![1000.0_f64, 1001.0, 999.0]);
1132 let weights = softmax(&logits);
1133 let sum: f64 = weights.iter().sum();
1134 assert!((sum - 1.0).abs() < 1e-12);
1135 for &w in weights.iter() {
1136 assert!(w.is_finite() && w > 0.0);
1137 }
1138 }
1139
1140 #[test]
1141 fn test_update_primitive_apply_directions() {
1142 let hp = PrimitiveHyperparams::<f64> {
1143 momentum_beta: 0.9,
1144 adam_beta1: 0.9,
1145 adam_beta2: 0.999,
1146 rms_decay: 0.9,
1147 epsilon: 1e-8,
1148 weight_decay_strength: 0.5,
1149 };
1150 let grad = Array1::from_vec(vec![2.0, -4.0, 0.0]);
1151 let params = Array1::from_vec(vec![1.0, -1.0, 3.0]);
1152
1153 let mut state = PrimitiveState::new(3);
1154 let grad_dir = UpdatePrimitive::Grad.apply(&grad, ¶ms, &mut state, &hp);
1155 assert_eq!(grad_dir, grad, "Grad must return the gradient unchanged");
1156
1157 let mut state = PrimitiveState::new(3);
1158 let sign_dir = UpdatePrimitive::Sign.apply(&grad, ¶ms, &mut state, &hp);
1159 assert_eq!(sign_dir, Array1::from_vec(vec![1.0, -1.0, 0.0]));
1160
1161 let mut state = PrimitiveState::new(3);
1162 let mom_dir = UpdatePrimitive::Momentum.apply(&grad, ¶ms, &mut state, &hp);
1163 for (m, g) in mom_dir.iter().zip(grad.iter()) {
1165 assert!((m - 0.1 * g).abs() < 1e-12);
1166 }
1167
1168 let mut state = PrimitiveState::new(3);
1169 let wd_dir = UpdatePrimitive::WeightDecay.apply(&grad, ¶ms, &mut state, &hp);
1170 for (w, p) in wd_dir.iter().zip(params.iter()) {
1171 assert!((w - 0.5 * p).abs() < 1e-12);
1172 }
1173
1174 let mut state = PrimitiveState::new(3);
1176 let rms_dir = UpdatePrimitive::RmsProp.apply(&grad, ¶ms, &mut state, &hp);
1177 assert!(rms_dir.iter().all(|x| x.is_finite()));
1178 let mut state = PrimitiveState::new(3);
1179 let adam_dir = UpdatePrimitive::AdamLike.apply(&grad, ¶ms, &mut state, &hp);
1180 assert!(adam_dir.iter().all(|x| x.is_finite()));
1181 }
1182
1183 #[test]
1184 fn test_config_validation_rejects_bad_inputs() {
1185 let bad_top_k = DartsConfig {
1186 top_k: 99,
1187 ..DartsConfig::default()
1188 };
1189 assert!(bad_top_k.validate().is_err(), "top_k > num primitives");
1190
1191 let bad_epochs = DartsConfig {
1192 epochs: 0,
1193 ..DartsConfig::default()
1194 };
1195 assert!(bad_epochs.validate().is_err(), "zero epochs");
1196
1197 let bad_lr = DartsConfig {
1198 learning_rate: -1.0,
1199 ..DartsConfig::default()
1200 };
1201 assert!(bad_lr.validate().is_err(), "negative lr");
1202
1203 let empty_set = DartsConfig {
1204 primitives: Vec::new(),
1205 ..DartsConfig::default()
1206 };
1207 assert!(empty_set.validate().is_err(), "empty primitive set");
1208
1209 let bad_beta = DartsConfig {
1210 adam_beta2: 1.0,
1211 ..DartsConfig::default()
1212 };
1213 assert!(bad_beta.validate().is_err(), "beta out of (0,1)");
1214
1215 assert!(DartsConfig::default().validate().is_ok());
1216 }
1217
1218 #[test]
1219 fn test_search_drives_validation_loss_down() {
1220 let objective = QuadraticBowl::<f64>::isotropic(6, 1.0).expect("bowl");
1221 let config = DartsConfig {
1222 epochs: 60,
1223 inner_steps: 5,
1224 learning_rate: 0.15,
1225 arch_learning_rate: 0.4,
1226 seed: 11,
1227 ..DartsConfig::default()
1228 };
1229 let mut search = DartsOptimizerSearch::<f64>::new(config).expect("search");
1230 let outcome = search.search(&objective).expect("search runs");
1231
1232 assert_eq!(outcome.validation_trajectory.len(), 60);
1233 let initial = outcome.validation_trajectory[0];
1234 let final_loss = *outcome
1235 .validation_trajectory
1236 .last()
1237 .expect("non-empty trajectory");
1238 assert!(
1239 final_loss < initial,
1240 "validation loss should fall: initial={initial}, final={final_loss}"
1241 );
1242 assert!(final_loss.is_finite());
1243 }
1244
1245 #[test]
1246 fn test_softmax_weights_in_outcome_valid() {
1247 let objective = QuadraticBowl::<f64>::isotropic(4, 1.0).expect("bowl");
1248 let config = DartsConfig {
1249 epochs: 20,
1250 seed: 5,
1251 ..DartsConfig::default()
1252 };
1253 let mut search = DartsOptimizerSearch::<f64>::new(config).expect("search");
1254 let outcome = search.search(&objective).expect("search runs");
1255
1256 let sum: f64 = outcome.softmax_weights.iter().sum();
1257 assert!((sum - 1.0).abs() < 1e-12);
1258 assert!(outcome.softmax_weights.iter().all(|&w| w > 0.0));
1259 assert_eq!(outcome.softmax_weights.len(), outcome.primitives.len());
1260 }
1261
1262 #[test]
1263 fn test_grad_primitive_wins_on_well_conditioned_quadratic() {
1264 let objective = QuadraticBowl::<f64>::isotropic(5, 6.0).expect("bowl");
1269 let config = DartsConfig {
1270 primitives: UpdatePrimitive::default_set(),
1271 epochs: 120,
1272 inner_steps: 10,
1273 learning_rate: 0.06,
1274 arch_learning_rate: 0.6,
1275 init_params_scale: 2.0,
1276 seed: 7,
1277 ..DartsConfig::default()
1278 };
1279 let mut search = DartsOptimizerSearch::<f64>::new(config).expect("search");
1280 let outcome = search.search(&objective).expect("search runs");
1281
1282 assert_eq!(
1283 outcome.best_primitive(),
1284 UpdatePrimitive::Grad,
1285 "Grad should win on a well-conditioned quadratic; weights = {:?}",
1286 outcome.softmax_weights
1287 );
1288 assert_eq!(
1289 outcome.discovered.selected_primitives()[0],
1290 UpdatePrimitive::Grad
1291 );
1292 }
1293
1294 #[test]
1295 fn test_discovered_optimizer_converges_on_holdout() {
1296 let train_objective = QuadraticBowl::<f64>::isotropic(5, 1.0).expect("bowl");
1298 let config = DartsConfig {
1299 epochs: 100,
1300 inner_steps: 12,
1301 learning_rate: 0.25,
1302 arch_learning_rate: 0.5,
1303 seed: 7,
1304 ..DartsConfig::default()
1305 };
1306 let mut search = DartsOptimizerSearch::<f64>::new(config).expect("search");
1307 let outcome = search.search(&train_objective).expect("search runs");
1308
1309 let mut optimizer = outcome.discovered;
1310 let curvature = Array1::from_vec(vec![1.0, 0.8, 1.2, 0.9, 1.1]);
1312 let holdout = QuadraticBowl::<f64>::new(curvature);
1313 let mut params = Array1::from_vec(vec![3.0, -2.5, 4.0, -3.5, 2.0]);
1314 let initial_norm = l2(¶ms);
1315
1316 for _ in 0..200 {
1317 let (_, grad) = holdout.train_loss_grad(¶ms).expect("grad");
1318 params = optimizer.step(¶ms, &grad).expect("step");
1319 }
1320 let final_norm = l2(¶ms);
1321 assert!(
1322 final_norm < initial_norm,
1323 "discovered optimizer should converge: initial={initial_norm}, final={final_norm}"
1324 );
1325 assert!(
1326 final_norm < 0.5 * initial_norm,
1327 "should make substantial progress"
1328 );
1329 assert_eq!(optimizer.step_count(), 200);
1330 }
1331
1332 #[test]
1333 fn test_deterministic_given_seed() {
1334 let objective = QuadraticBowl::<f64>::isotropic(5, 1.0).expect("bowl");
1335 let config = DartsConfig {
1336 epochs: 30,
1337 inner_steps: 4,
1338 seed: 4242,
1339 ..DartsConfig::default()
1340 };
1341
1342 let mut search_a =
1343 DartsOptimizerSearch::<f64>::new(config.clone()).expect("first search constructs");
1344 let outcome_a = search_a.search(&objective).expect("a runs");
1345 let mut search_b =
1346 DartsOptimizerSearch::<f64>::new(config).expect("second search constructs");
1347 let outcome_b = search_b.search(&objective).expect("b runs");
1348
1349 for (a, b) in outcome_a.alpha.iter().zip(outcome_b.alpha.iter()) {
1350 assert!((a - b).abs() < 1e-15, "alpha must be identical: {a} vs {b}");
1351 }
1352 for (a, b) in outcome_a
1353 .validation_trajectory
1354 .iter()
1355 .zip(outcome_b.validation_trajectory.iter())
1356 {
1357 assert!((a - b).abs() < 1e-15, "trajectory must be identical");
1358 }
1359 }
1360
1361 #[test]
1362 fn test_analytic_alpha_gradient_matches_finite_difference() {
1363 let objective = QuadraticBowl::<f64>::isotropic(4, 1.3).expect("bowl");
1364 let config = DartsConfig {
1365 learning_rate: 0.2,
1366 seed: 1,
1367 ..DartsConfig::default()
1368 };
1369 let search = DartsOptimizerSearch::<f64>::new(config).expect("search");
1370
1371 let params = Array1::from_vec(vec![0.7, -1.1, 0.3, 0.9]);
1374 let alpha = Array1::from_vec(vec![0.2, -0.5, 0.8, 0.1, -0.3, 0.4]);
1375 let (_, grad_train) = objective.train_loss_grad(¶ms).expect("grad");
1376 let mut states = search.fresh_states(4);
1377 let ops: Vec<Array1<f64>> = search
1378 .primitives
1379 .iter()
1380 .zip(states.iter_mut())
1381 .map(|(p, s)| p.apply(&grad_train, ¶ms, s, &search.hyperparams))
1382 .collect();
1383
1384 let analytic = search
1385 .analytic_architecture_gradient(&alpha, &ops, ¶ms, &objective)
1386 .expect("analytic");
1387
1388 let eps = 1e-6;
1389 for j in 0..alpha.len() {
1390 let mut plus = alpha.clone();
1391 let mut minus = alpha.clone();
1392 plus[j] += eps;
1393 minus[j] -= eps;
1394 let loss_plus = search
1395 .lookahead_validation_loss(&plus, &ops, ¶ms, &objective)
1396 .expect("loss+");
1397 let loss_minus = search
1398 .lookahead_validation_loss(&minus, &ops, ¶ms, &objective)
1399 .expect("loss-");
1400 let numeric = (loss_plus - loss_minus) / (2.0 * eps);
1401 assert!(
1402 (analytic[j] - numeric).abs() < 1e-5,
1403 "analytic vs FD mismatch at {j}: {} vs {numeric}",
1404 analytic[j]
1405 );
1406 }
1407 }
1408
1409 #[test]
1410 fn test_discovered_optimizer_trait_surface() {
1411 let objective = QuadraticBowl::<f64>::isotropic(3, 1.0).expect("bowl");
1412 let mut search = DartsOptimizerSearch::<f64>::new(DartsConfig::default()).expect("search");
1413 let outcome = search.search(&objective).expect("search runs");
1414 let mut optimizer = outcome.discovered;
1415
1416 assert_eq!(optimizer.name(), "DartsDiscovered");
1417 let lr0 = optimizer.get_learning_rate();
1418 assert!(lr0 > 0.0);
1419 optimizer.set_learning_rate(0.05);
1420 assert!((optimizer.get_learning_rate() - 0.05).abs() < 1e-12);
1421
1422 let params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1423 let grad = Array1::from_vec(vec![0.1, 0.2, 0.3]);
1424 let _ = optimizer.step(¶ms, &grad).expect("step");
1425 let state = optimizer.get_state();
1426 assert_eq!(state.step_count, 1);
1427 assert!((state.current_lr - 0.05).abs() < 1e-12);
1428 assert!(state.grad_norm_ema > 0.0);
1429
1430 let weight_sum: f64 = optimizer.selected_weights().iter().sum();
1432 assert!((weight_sum - 1.0).abs() < 1e-12);
1433 }
1434
1435 #[test]
1436 fn test_top_k_discretization_keeps_two() {
1437 let objective = QuadraticBowl::<f64>::isotropic(4, 1.0).expect("bowl");
1438 let config = DartsConfig {
1439 top_k: 2,
1440 epochs: 25,
1441 seed: 99,
1442 ..DartsConfig::default()
1443 };
1444 let mut search = DartsOptimizerSearch::<f64>::new(config).expect("search");
1445 let outcome = search.search(&objective).expect("search runs");
1446 assert_eq!(outcome.discovered.selected_primitives().len(), 2);
1447 let weight_sum: f64 = outcome.discovered.selected_weights().iter().sum();
1448 assert!((weight_sum - 1.0).abs() < 1e-12);
1449 }
1450
1451 #[test]
1452 fn test_closure_objective_search_runs() {
1453 let objective = ClosureObjective::<f64>::from_single(3, |p| {
1455 let loss = 0.5 * p.iter().fold(0.0, |acc, &x| acc + x * x);
1456 let grad = p.clone();
1457 Ok((loss, grad))
1458 })
1459 .expect("closure objective");
1460 let config = DartsConfig {
1461 epochs: 30,
1462 inner_steps: 6,
1463 learning_rate: 0.2,
1464 seed: 3,
1465 ..DartsConfig::default()
1466 };
1467 let mut search = DartsOptimizerSearch::<f64>::new(config).expect("search");
1468 let outcome = search.search(&objective).expect("search runs");
1469 let initial = outcome.validation_trajectory[0];
1470 let final_loss = *outcome.validation_trajectory.last().expect("trajectory");
1471 assert!(final_loss < initial);
1472 }
1473
1474 #[test]
1475 fn test_rosenbrock_objective_evaluates() {
1476 let objective = Rosenbrock::<f64>::new(3).expect("rosenbrock");
1477 let minimum = Array1::from_vec(vec![1.0, 1.0, 1.0]);
1479 let (loss, grad) = objective.train_loss_grad(&minimum).expect("grad");
1480 assert!(loss.abs() < 1e-12);
1481 assert!(grad.iter().all(|&g| g.abs() < 1e-9));
1482
1483 let config = DartsConfig {
1485 epochs: 15,
1486 inner_steps: 4,
1487 learning_rate: 0.001,
1488 init_params_scale: 0.5,
1489 seed: 2,
1490 ..DartsConfig::default()
1491 };
1492 let mut search = DartsOptimizerSearch::<f64>::new(config).expect("search");
1493 let outcome = search.search(&objective).expect("search runs");
1494 assert!(outcome.validation_trajectory.iter().all(|x| x.is_finite()));
1495 }
1496}