Skip to main content

optirs_core/optimizers/
adadelta.rs

1// OptiRS - AdaDelta Optimizer
2// Adaptive learning rate method without manual learning rate tuning
3// Reference: "ADADELTA: An Adaptive Learning Rate Method" by Matthew D. Zeiler (2012)
4//
5// Algorithm:
6//   Accumulate gradients: E[g²]_t = ρ * E[g²]_{t-1} + (1 - ρ) * g_t²
7//   Compute update: Δθ_t = -RMS[Δθ]_{t-1}/RMS[g]_t * g_t
8//   Accumulate updates: E[Δθ²]_t = ρ * E[Δθ²]_{t-1} + (1 - ρ) * Δθ_t²
9//   Apply update: θ_{t+1} = θ_t + Δθ_t
10
11use crate::error::{OptimError, Result};
12use crate::optimizers::Optimizer;
13use scirs2_core::ndarray::{Ix1, ScalarOperand};
14use scirs2_core::ndarray_ext::{Array1, ArrayView1};
15use scirs2_core::numeric::Float;
16use serde::{Deserialize, Serialize};
17use std::fmt::Debug;
18
19/// AdaDelta optimizer configuration
20///
21/// AdaDelta adapts learning rates based on a moving window of gradient updates,
22/// instead of accumulating all past gradients. This eliminates the need for a
23/// manual learning rate parameter.
24///
25/// # Key Features
26/// - No learning rate parameter required (uses adaptive rates)
27/// - Uses exponentially decaying average of squared gradients
28/// - Uses exponentially decaying average of squared parameter updates
29/// - More robust to hyperparameter choice than AdaGrad
30///
31/// # Type Parameters
32/// - `T`: Floating-point type (f32 or f64)
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct AdaDelta<T: Float> {
35    /// Decay rate for moving averages (typically 0.9 or 0.95)
36    /// Controls the window size for gradient history
37    rho: T,
38
39    /// Small constant for numerical stability (typically 1e-6 to 1e-8)
40    /// Prevents division by zero
41    epsilon: T,
42
43    /// Exponentially decaying average of squared gradients E[g²]
44    /// Tracks the magnitude of recent gradients
45    accumulated_gradients: Option<Array1<T>>,
46
47    /// Exponentially decaying average of squared parameter updates E[Δθ²]
48    /// Tracks the magnitude of recent parameter updates
49    accumulated_updates: Option<Array1<T>>,
50
51    /// Number of optimization steps performed
52    step_count: usize,
53
54    /// Optional multiplier applied to the first `warmup_steps` updates
55    ///
56    /// Defaults to `1` (disabled), i.e. plain AdaDelta as published by Zeiler (2012).
57    /// When enabled the boost only scales the *applied* update; the accumulator
58    /// `E[Δθ²]` is still fed the unboosted update, so the adaptive rate is not
59    /// contaminated by the bootstrap factor.
60    warmup_boost: T,
61
62    /// Number of initial steps over which `warmup_boost` is applied
63    warmup_steps: usize,
64}
65
66impl<T: Float> Default for AdaDelta<T> {
67    fn default() -> Self {
68        Self::new(
69            T::from(0.95).expect("AdaDelta: default rho (0.95) must be representable in T"),
70            T::from(1e-6).expect("AdaDelta: default epsilon (1e-6) must be representable in T"),
71        )
72        .expect("AdaDelta: default (rho=0.95, epsilon=1e-6) always satisfies validation")
73    }
74}
75
76impl<T: Float> AdaDelta<T> {
77    /// Create a new AdaDelta optimizer
78    ///
79    /// # Arguments
80    /// - `rho`: Decay rate for moving averages (typically 0.9-0.99)
81    /// - `epsilon`: Small constant for numerical stability (typically 1e-6 to 1e-8)
82    ///
83    /// # Returns
84    /// Result containing the optimizer or validation error
85    ///
86    /// # Example
87    /// ```
88    /// use optirs_core::optimizers::AdaDelta;
89    ///
90    /// let optimizer = AdaDelta::<f32>::new(0.95, 1e-6).expect("AdaDelta::<f32>::new succeeds");
91    /// ```
92    pub fn new(rho: T, epsilon: T) -> Result<Self> {
93        let rho_f64 = crate::optimizers::scalar_to_f64(rho)?;
94        let epsilon_f64 = crate::optimizers::scalar_to_f64(epsilon)?;
95
96        if rho_f64 <= 0.0 || rho_f64 >= 1.0 {
97            return Err(OptimError::InvalidParameter(format!(
98                "rho must be in (0, 1), got {}",
99                rho_f64
100            )));
101        }
102
103        if epsilon_f64 <= 0.0 {
104            return Err(OptimError::InvalidParameter(format!(
105                "epsilon must be positive, got {}",
106                epsilon_f64
107            )));
108        }
109
110        Ok(Self {
111            rho,
112            epsilon,
113            accumulated_gradients: None,
114            accumulated_updates: None,
115            step_count: 0,
116            warmup_boost: T::one(),
117            warmup_steps: 0,
118        })
119    }
120
121    /// Enable an opt-in bootstrap multiplier for the first `steps` updates
122    ///
123    /// Plain AdaDelta starts with `E[Δθ²] = 0`, so the first updates are on the order
124    /// of `sqrt(epsilon)` and progress is slow until the update accumulator warms up.
125    /// Setting a boost trades strict fidelity to the paper for a faster start.
126    ///
127    /// The boost scales only the update that is *applied* to the parameters — the
128    /// value accumulated into `E[Δθ²]` remains the unboosted update, so the adaptive
129    /// learning rate stays a faithful estimate.
130    ///
131    /// # Errors
132    /// Returns an error if `boost` is not strictly positive.
133    pub fn with_warmup_boost(mut self, boost: T, steps: usize) -> Result<Self> {
134        let boost_f64 = boost.to_f64().ok_or_else(|| {
135            OptimError::InvalidParameter("boost is not representable".to_string())
136        })?;
137        if boost_f64 <= 0.0 {
138            return Err(OptimError::InvalidParameter(format!(
139                "warmup boost must be positive, got {}",
140                boost_f64
141            )));
142        }
143        self.warmup_boost = boost;
144        self.warmup_steps = steps;
145        Ok(self)
146    }
147
148    /// Returns the configured warmup boost multiplier (1 when disabled)
149    pub fn warmup_boost(&self) -> T {
150        self.warmup_boost
151    }
152
153    /// Perform a single optimization step
154    ///
155    /// # Arguments
156    /// - `params`: Current parameter values
157    /// - `grads`: Gradient values
158    ///
159    /// # Returns
160    /// Result containing updated parameters or error
161    ///
162    /// # Algorithm
163    /// 1. Initialize accumulators on first step
164    /// 2. Update exponentially decaying average of squared gradients
165    /// 3. Compute RMS of gradients and previous updates
166    /// 4. Compute parameter update using adaptive learning rate
167    /// 5. Update exponentially decaying average of squared updates
168    /// 6. Apply parameter update
169    ///
170    /// # Example
171    /// ```
172    /// use optirs_core::optimizers::AdaDelta;
173    /// use scirs2_core::ndarray_ext::array;
174    ///
175    /// let mut optimizer = AdaDelta::<f32>::new(0.95, 1e-6).expect("AdaDelta::<f32>::new succeeds");
176    /// let params = array![1.0, 2.0, 3.0];
177    /// let grads = array![0.1, 0.2, 0.3];
178    ///
179    /// let updated_params = optimizer.step(params.view(), grads.view()).expect("optimizer.step succeeds");
180    /// ```
181    pub fn step<'a, P, G>(&mut self, params: P, grads: G) -> Result<Array1<T>>
182    where
183        P: Into<ArrayView1<'a, T>>,
184        G: Into<ArrayView1<'a, T>>,
185        T: 'a,
186    {
187        self.step_view(params.into(), grads.into())
188    }
189
190    /// Perform a single optimization step on borrowed views
191    ///
192    /// This is the concrete implementation behind [`AdaDelta::step`].
193    pub fn step_view(&mut self, params: ArrayView1<T>, grads: ArrayView1<T>) -> Result<Array1<T>> {
194        let n = params.len();
195
196        if grads.len() != n {
197            return Err(OptimError::DimensionMismatch(format!(
198                "Expected gradient size {}, got {}",
199                n,
200                grads.len()
201            )));
202        }
203
204        // Initialize accumulators on first step
205        let acc_grad = self
206            .accumulated_gradients
207            .get_or_insert_with(|| Array1::zeros(n));
208        let acc_update = self
209            .accumulated_updates
210            .get_or_insert_with(|| Array1::zeros(n));
211
212        // Update exponentially decaying average of squared gradients
213        // E[g²]_t = ρ * E[g²]_{t-1} + (1 - ρ) * g_t²
214        let one = T::one();
215        let one_minus_rho = one - self.rho;
216
217        for i in 0..n {
218            let grad = grads[i];
219            acc_grad[i] = self.rho * acc_grad[i] + one_minus_rho * grad * grad;
220        }
221
222        // Compute RMS[g]_t = sqrt(E[g²]_t + ε)
223        // Compute RMS[Δθ]_{t-1} = sqrt(E[Δθ²]_{t-1} + ε)
224        // Compute update: Δθ_t = -RMS[Δθ]_{t-1}/RMS[g]_t * g_t
225        let mut delta_params = Array1::zeros(n);
226
227        for i in 0..n {
228            let rms_grad = (acc_grad[i] + self.epsilon).sqrt();
229            let rms_update = (acc_update[i] + self.epsilon).sqrt();
230
231            // Adaptive learning rate: RMS[Δθ]_{t-1}/RMS[g]_t
232            delta_params[i] = -(rms_update / rms_grad) * grads[i];
233        }
234
235        // Update exponentially decaying average of squared parameter updates using the
236        // *unboosted* update, so an opt-in bootstrap multiplier cannot contaminate the
237        // adaptive learning rate estimate.
238        // E[Δθ²]_t = ρ * E[Δθ²]_{t-1} + (1 - ρ) * Δθ_t²
239        for i in 0..n {
240            let delta = delta_params[i];
241            acc_update[i] = self.rho * acc_update[i] + one_minus_rho * delta * delta;
242        }
243
244        // Opt-in bootstrap multiplier for the first `warmup_steps` updates
245        let boost = if self.step_count < self.warmup_steps {
246            self.warmup_boost
247        } else {
248            T::one()
249        };
250
251        // Apply update: θ_{t+1} = θ_t + Δθ_t
252        let mut updated_params = params.to_owned();
253        for i in 0..n {
254            updated_params[i] = updated_params[i] + delta_params[i] * boost;
255        }
256
257        self.step_count += 1;
258
259        Ok(updated_params)
260    }
261
262    /// Get the number of optimization steps performed
263    pub fn step_count(&self) -> usize {
264        self.step_count
265    }
266
267    /// Reset the optimizer state
268    ///
269    /// Clears accumulated gradient and update history
270    pub fn reset(&mut self) {
271        self.accumulated_gradients = None;
272        self.accumulated_updates = None;
273        self.step_count = 0;
274    }
275
276    /// Get the current RMS of gradients for each parameter
277    ///
278    /// Returns None if no steps have been performed yet
279    pub fn rms_gradients(&self) -> Option<Array1<T>> {
280        self.accumulated_gradients
281            .as_ref()
282            .map(|acc_grad| acc_grad.mapv(|x| (x + self.epsilon).sqrt()))
283    }
284
285    /// Get the current RMS of parameter updates
286    ///
287    /// Returns None if no steps have been performed yet
288    pub fn rms_updates(&self) -> Option<Array1<T>> {
289        self.accumulated_updates
290            .as_ref()
291            .map(|acc_update| acc_update.mapv(|x| (x + self.epsilon).sqrt()))
292    }
293}
294
295impl<T> Optimizer<T, Ix1> for AdaDelta<T>
296where
297    T: Float + ScalarOperand + Debug + Send + Sync,
298{
299    fn step(&mut self, params: &Array1<T>, gradients: &Array1<T>) -> Result<Array1<T>> {
300        self.step_view(params.view(), gradients.view())
301    }
302
303    /// AdaDelta has no learning-rate hyperparameter; the effective per-parameter rate
304    /// is `RMS[Δθ]/RMS[g]`. This reports `1` as the nominal scale.
305    fn get_learning_rate(&self) -> T {
306        T::one()
307    }
308
309    /// AdaDelta derives its step size from its accumulators, so setting a learning
310    /// rate has no effect. The method exists to satisfy the [`Optimizer`] trait.
311    fn set_learning_rate(&mut self, _learning_rate: T) {}
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use approx::assert_relative_eq;
318    use scirs2_core::ndarray_ext::array;
319
320    #[test]
321    fn test_adadelta_creation() {
322        let optimizer = AdaDelta::<f32>::new(0.95, 1e-6)
323            .expect("AdaDelta::<f32>::new succeeds in test_adadelta_creation");
324        assert_eq!(optimizer.step_count(), 0);
325    }
326
327    #[test]
328    fn test_adadelta_invalid_rho() {
329        assert!(AdaDelta::<f32>::new(1.5, 1e-6).is_err());
330        assert!(AdaDelta::<f32>::new(-0.1, 1e-6).is_err());
331    }
332
333    #[test]
334    fn test_adadelta_invalid_epsilon() {
335        assert!(AdaDelta::<f32>::new(0.95, -1e-6).is_err());
336    }
337
338    #[test]
339    fn test_adadelta_single_step() {
340        let mut optimizer = AdaDelta::<f32>::new(0.9, 1e-6)
341            .expect("AdaDelta::<f32>::new succeeds in test_adadelta_single_step");
342        let params = array![1.0, 2.0, 3.0];
343        let grads = array![0.1, 0.2, 0.3];
344
345        let updated_params = optimizer
346            .step(params.view(), grads.view())
347            .expect("step succeeds in test_adadelta_single_step");
348
349        // First step should have small updates (RMS[Δθ]_{-1} = 0)
350        assert!(updated_params.len() == 3);
351        assert_eq!(optimizer.step_count(), 1);
352
353        // Parameters should change (even if slightly on first step)
354        for i in 0..3 {
355            assert_ne!(updated_params[i], params[i]);
356        }
357    }
358
359    #[test]
360    fn test_adadelta_multiple_steps() {
361        let mut optimizer = AdaDelta::<f32>::new(0.95, 1e-6)
362            .expect("AdaDelta::<f32>::new succeeds in test_adadelta_multiple_steps");
363        let mut params = array![1.0, 2.0, 3.0];
364
365        for _ in 0..10 {
366            let grads = array![0.1, 0.2, 0.3];
367            params = optimizer
368                .step(params.view(), grads.view())
369                .expect("step succeeds in test_adadelta_multiple_steps");
370        }
371
372        assert_eq!(optimizer.step_count(), 10);
373
374        // After multiple steps, parameters should have changed significantly
375        assert!(params[0] < 1.0);
376        assert!(params[1] < 2.0);
377        assert!(params[2] < 3.0);
378    }
379
380    #[test]
381    fn test_adadelta_shape_mismatch() {
382        let mut optimizer = AdaDelta::<f32>::new(0.95, 1e-6)
383            .expect("AdaDelta::<f32>::new succeeds in test_adadelta_shape_mismatch");
384        let params = array![1.0, 2.0, 3.0];
385        let grads = array![0.1, 0.2]; // Wrong shape
386
387        assert!(optimizer.step(params.view(), grads.view()).is_err());
388    }
389
390    #[test]
391    fn test_adadelta_reset() {
392        let mut optimizer = AdaDelta::<f32>::new(0.95, 1e-6)
393            .expect("AdaDelta::<f32>::new succeeds in test_adadelta_reset");
394        let params = array![1.0, 2.0, 3.0];
395        let grads = array![0.1, 0.2, 0.3];
396
397        optimizer
398            .step(params.view(), grads.view())
399            .expect("step succeeds in test_adadelta_reset");
400        assert_eq!(optimizer.step_count(), 1);
401        assert!(optimizer.accumulated_gradients.is_some());
402
403        optimizer.reset();
404        assert_eq!(optimizer.step_count(), 0);
405        assert!(optimizer.accumulated_gradients.is_none());
406        assert!(optimizer.accumulated_updates.is_none());
407    }
408
409    #[test]
410    fn test_adadelta_convergence() {
411        // Test convergence on a simple quadratic function: f(x) = x²
412        // Gradient: f'(x) = 2x
413        // Using higher rho (0.99) for better long-term memory.
414        //
415        // Plain AdaDelta bootstraps from E[Δθ²] = 0, so the first updates are on the
416        // order of sqrt(epsilon). It genuinely needs a few thousand steps on this toy
417        // problem; that is the published algorithm, not a defect.
418        let mut optimizer = AdaDelta::<f64>::new(0.99, 1e-6)
419            .expect("AdaDelta::<f64>::new succeeds in test_adadelta_convergence");
420        let mut params = array![10.0]; // Start far from optimum
421
422        for _ in 0..3000 {
423            let grads = params.mapv(|x| 2.0 * x); // Gradient of x²
424            params = optimizer
425                .step(params.view(), grads.view())
426                .expect("step succeeds in test_adadelta_convergence");
427        }
428
429        assert!(
430            params[0].abs() < 0.5,
431            "Failed to converge, got {}",
432            params[0]
433        );
434    }
435
436    /// Regression test: the first update must follow the published AdaDelta formula
437    /// exactly. The implementation used to multiply the first ten updates by a
438    /// hardcoded, undocumented factor of 10 and feed the boosted value back into the
439    /// update accumulator.
440    #[test]
441    fn test_adadelta_first_step_matches_published_formula() {
442        let rho = 0.95f64;
443        let epsilon = 1e-6f64;
444        let mut optimizer = AdaDelta::<f64>::new(rho, epsilon).expect("valid config");
445
446        let params = array![1.0f64];
447        let grads = array![0.5f64];
448
449        let updated = optimizer
450            .step(params.view(), grads.view())
451            .expect("step failed");
452
453        let acc_grad = (1.0 - rho) * 0.5 * 0.5;
454        let expected_delta = -((0.0f64 + epsilon).sqrt() / (acc_grad + epsilon).sqrt()) * 0.5;
455
456        assert_relative_eq!(updated[0], 1.0 + expected_delta, epsilon = 1e-12);
457        assert_relative_eq!(optimizer.warmup_boost(), 1.0, epsilon = 1e-12);
458    }
459
460    /// The opt-in bootstrap multiplier must not leak into `E[Δθ²]`.
461    #[test]
462    fn test_adadelta_warmup_boost_is_opt_in_and_uncontaminating() {
463        let rho = 0.95f64;
464        let epsilon = 1e-6f64;
465
466        let mut plain = AdaDelta::<f64>::new(rho, epsilon).expect("valid config");
467        let mut boosted = AdaDelta::<f64>::new(rho, epsilon)
468            .expect("valid config")
469            .with_warmup_boost(10.0, 1)
470            .expect("valid boost");
471
472        let params = array![1.0f64];
473        let grads = array![0.5f64];
474
475        let plain_out = plain.step(params.view(), grads.view()).expect("plain step");
476        let boosted_out = boosted
477            .step(params.view(), grads.view())
478            .expect("boosted step");
479
480        let plain_delta = plain_out[0] - 1.0;
481        let boosted_delta = boosted_out[0] - 1.0;
482
483        // The applied update is scaled...
484        assert_relative_eq!(boosted_delta, plain_delta * 10.0, epsilon = 1e-12);
485
486        // ...but the accumulator is identical, i.e. uncontaminated.
487        let plain_rms = plain.rms_updates().expect("rms after step");
488        let boosted_rms = boosted.rms_updates().expect("rms after step");
489        assert_relative_eq!(plain_rms[0], boosted_rms[0], epsilon = 1e-15);
490    }
491
492    /// AdaDelta must be usable through the generic `Optimizer` trait.
493    #[test]
494    fn test_adadelta_optimizer_trait() {
495        let mut optimizer = AdaDelta::<f64>::new(0.95, 1e-6).expect("valid config");
496        let params = array![1.0f64, 2.0, 3.0];
497        let grads = array![0.1f64, 0.2, 0.3];
498
499        let updated =
500            Optimizer::<f64, scirs2_core::ndarray::Ix1>::step(&mut optimizer, &params, &grads)
501                .expect("trait step failed");
502        assert_eq!(updated.len(), 3);
503
504        // The generic `step` also accepts plain references.
505        let again = optimizer.step(&params, &grads).expect("ref step failed");
506        assert_eq!(again.len(), 3);
507    }
508
509    #[test]
510    fn test_adadelta_rms_values() {
511        let mut optimizer = AdaDelta::<f32>::new(0.9, 1e-6)
512            .expect("AdaDelta::<f32>::new succeeds in test_adadelta_rms_values");
513
514        // No RMS values before first step
515        assert!(optimizer.rms_gradients().is_none());
516        assert!(optimizer.rms_updates().is_none());
517
518        let params = array![1.0, 2.0, 3.0];
519        let grads = array![0.1, 0.2, 0.3];
520
521        optimizer
522            .step(params.view(), grads.view())
523            .expect("step succeeds in test_adadelta_rms_values");
524
525        // RMS values should exist after first step
526        assert!(optimizer.rms_gradients().is_some());
527        assert!(optimizer.rms_updates().is_some());
528
529        let rms_grads = optimizer
530            .rms_gradients()
531            .expect("optimizer.rms_gradients succeeds in test_adadelta_rms_values");
532        assert_eq!(rms_grads.len(), 3);
533    }
534
535    #[test]
536    fn test_adadelta_f64() {
537        let mut optimizer = AdaDelta::<f64>::new(0.95, 1e-8)
538            .expect("AdaDelta::<f64>::new succeeds in test_adadelta_f64");
539        let params = array![1.0, 2.0, 3.0];
540        let grads = array![0.1, 0.2, 0.3];
541
542        let updated_params = optimizer
543            .step(params.view(), grads.view())
544            .expect("step succeeds in test_adadelta_f64");
545        assert_eq!(updated_params.len(), 3);
546    }
547}