Skip to main content

optirs_core/gradient_processing/
mod.rs

1// Gradient processing utilities for machine learning optimization
2//
3// This module provides comprehensive gradient manipulation utilities including
4// various clipping strategies, normalization, and other processing techniques.
5
6use scirs2_core::ndarray::{Array, Dimension, ScalarOperand};
7use scirs2_core::numeric::Float;
8use scirs2_core::random::thread_rng;
9use std::fmt::Debug;
10
11use crate::error::{OptimError, Result};
12
13/// Gradient clipping configuration
14#[derive(Debug, Clone)]
15pub struct GradientClipConfig<A: Float> {
16    /// Maximum allowed value for individual gradient elements
17    pub max_value: Option<A>,
18    /// Minimum allowed value for individual gradient elements  
19    pub min_value: Option<A>,
20    /// Maximum allowed L2 norm for the entire gradient vector
21    pub maxnorm: Option<A>,
22    /// Maximum allowed L1 norm
23    pub max_l1norm: Option<A>,
24    /// Whether to apply gradient centralization
25    pub centralization: bool,
26    /// Threshold for zeroing small gradients
27    pub zero_threshold: Option<A>,
28}
29
30impl<A: Float + Send + Sync> Default for GradientClipConfig<A> {
31    fn default() -> Self {
32        Self {
33            max_value: None,
34            min_value: None,
35            maxnorm: None,
36            max_l1norm: None,
37            centralization: false,
38            zero_threshold: None,
39        }
40    }
41}
42
43/// Gradient clipping processor
44pub struct GradientProcessor<A: Float> {
45    config: GradientClipConfig<A>,
46}
47
48impl<A: Float + ScalarOperand + Debug + Send + Sync> Default for GradientProcessor<A> {
49    fn default() -> Self {
50        Self {
51            config: GradientClipConfig::default(),
52        }
53    }
54}
55
56impl<A: Float + ScalarOperand + Debug + Send + Sync> GradientProcessor<A> {
57    /// Create a new gradient processor with default configuration
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Create a new gradient processor with a specific configuration
63    pub fn with_config(config: GradientClipConfig<A>) -> Self {
64        Self { config }
65    }
66
67    /// Set max value clipping
68    pub fn set_max_value(&mut self, value: A) -> &mut Self {
69        self.config.max_value = Some(value);
70        self
71    }
72
73    /// Set min value clipping
74    pub fn set_min_value(&mut self, value: A) -> &mut Self {
75        self.config.min_value = Some(value);
76        self
77    }
78
79    /// Set max L2 norm clipping
80    pub fn set_max_norm(&mut self, value: A) -> &mut Self {
81        self.config.maxnorm = Some(value);
82        self
83    }
84
85    /// Set max L1 norm clipping
86    pub fn set_max_l1_norm(&mut self, value: A) -> &mut Self {
87        self.config.max_l1norm = Some(value);
88        self
89    }
90
91    /// Enable gradient centralization
92    pub fn set_centralization(&mut self, enabled: bool) -> &mut Self {
93        self.config.centralization = enabled;
94        self
95    }
96
97    /// Set threshold for zeroing small gradients
98    pub fn set_zero_threshold(&mut self, value: A) -> &mut Self {
99        self.config.zero_threshold = Some(value);
100        self
101    }
102
103    /// Set value clipping range
104    pub fn set_value_clip(&mut self, min: A, max: A) -> &mut Self {
105        self.config.min_value = Some(min);
106        self.config.max_value = Some(max);
107        self
108    }
109
110    /// Set norm clipping
111    pub fn set_norm_clip(&mut self, maxnorm: A) -> &mut Self {
112        self.config.maxnorm = Some(maxnorm);
113        self
114    }
115
116    /// Set L1 norm clipping
117    pub fn set_l1_norm_clip(&mut self, max_l1norm: A) -> &mut Self {
118        self.config.max_l1norm = Some(max_l1norm);
119        self
120    }
121
122    /// Enable gradient centralization
123    pub fn enable_centralization(&mut self) -> &mut Self {
124        self.config.centralization = true;
125        self
126    }
127
128    /// Process gradients according to configuration
129    pub fn process<D: Dimension>(&self, gradients: &mut Array<A, D>) -> Result<()> {
130        // Apply value clipping if configured
131        if let (Some(min), Some(max)) = (self.config.min_value, self.config.max_value) {
132            clip_gradients_by_value(gradients, min, max);
133        }
134
135        // Apply L2 norm clipping if configured
136        if let Some(maxnorm) = self.config.maxnorm {
137            clip_gradient_norm(gradients, maxnorm)?;
138        }
139
140        // Apply L1 norm clipping if configured
141        if let Some(max_l1norm) = self.config.max_l1norm {
142            clip_gradient_l1_norm(gradients, max_l1norm)?;
143        }
144
145        // Apply gradient centralization if enabled
146        if self.config.centralization {
147            gradient_centralization(gradients);
148        }
149
150        // Zero small gradients if threshold is set
151        if let Some(threshold) = self.config.zero_threshold {
152            zero_small_gradients(gradients, threshold);
153        }
154
155        Ok(())
156    }
157}
158
159/// Clip gradient values to a specified range
160pub fn clip_gradients_by_value<A, D>(
161    gradients: &mut Array<A, D>,
162    min_value: A,
163    max_value: A,
164) -> &mut Array<A, D>
165where
166    A: Float + ScalarOperand,
167    D: Dimension,
168{
169    gradients.mapv_inplace(|x| {
170        if x < min_value {
171            min_value
172        } else if x > max_value {
173            max_value
174        } else {
175            x
176        }
177    });
178    gradients
179}
180
181/// Clip gradient L2 norm (global gradient clipping)
182pub fn clip_gradient_norm<A, D>(gradients: &mut Array<A, D>, maxnorm: A) -> Result<&mut Array<A, D>>
183where
184    A: Float + ScalarOperand,
185    D: Dimension,
186{
187    if maxnorm <= A::zero() {
188        return Err(OptimError::InvalidConfig(
189            "maxnorm must be positive".to_string(),
190        ));
191    }
192
193    // Calculate current L2 _norm
194    let _norm = gradients
195        .iter()
196        .fold(A::zero(), |acc, &x| acc + x * x)
197        .sqrt();
198
199    // If _norm exceeds maxnorm, scale gradients
200    if _norm > maxnorm {
201        let scale = maxnorm / _norm;
202        gradients.mapv_inplace(|x| x * scale);
203    }
204
205    Ok(gradients)
206}
207
208/// Clip gradient L1 norm
209pub fn clip_gradient_l1_norm<A, D>(
210    gradients: &mut Array<A, D>,
211    max_l1norm: A,
212) -> Result<&mut Array<A, D>>
213where
214    A: Float + ScalarOperand,
215    D: Dimension,
216{
217    if max_l1norm <= A::zero() {
218        return Err(OptimError::InvalidConfig(
219            "max_l1norm must be positive".to_string(),
220        ));
221    }
222
223    // Calculate current L1 _norm
224    let l1_norm = gradients.iter().fold(A::zero(), |acc, &x| acc + x.abs());
225
226    // If _norm exceeds max_l1norm, scale gradients
227    if l1_norm > max_l1norm {
228        let scale = max_l1norm / l1_norm;
229        gradients.mapv_inplace(|x| x * scale);
230    }
231
232    Ok(gradients)
233}
234
235/// Compute gradient centralization
236pub fn gradient_centralization<A, D>(gradients: &mut Array<A, D>) -> &mut Array<A, D>
237where
238    A: Float + ScalarOperand,
239    D: Dimension,
240{
241    // Calculate mean
242    let sum = gradients.iter().fold(A::zero(), |acc, &x| acc + x);
243    let mean = sum / A::from(gradients.len()).unwrap_or(A::one());
244
245    // Subtract mean from each element
246    gradients.mapv_inplace(|x| x - mean);
247
248    gradients
249}
250
251/// Zero out small gradient values
252pub fn zero_small_gradients<A, D>(gradients: &mut Array<A, D>, threshold: A) -> &mut Array<A, D>
253where
254    A: Float + ScalarOperand,
255    D: Dimension,
256{
257    let abs_threshold = threshold.abs();
258
259    gradients.mapv_inplace(|x| {
260        if x.abs() < abs_threshold {
261            A::zero()
262        } else {
263            x
264        }
265    });
266
267    gradients
268}
269
270/// Gradient accumulation utility
271#[derive(Debug, Clone)]
272pub struct GradientAccumulator<A: Float, D: Dimension> {
273    /// Accumulated gradients
274    accumulated_gradients: Option<Array<A, D>>,
275    /// Number of accumulated micro-batches
276    num_accumulated: usize,
277    /// Target number of micro-batches before step
278    accumulation_steps: usize,
279    /// Whether to average gradients (vs sum)
280    averagegradients: bool,
281}
282
283impl<A: Float + ScalarOperand + Debug, D: Dimension + Send + Sync> GradientAccumulator<A, D> {
284    /// Create a new gradient accumulator
285    ///
286    /// # Arguments
287    ///
288    /// * `accumulation_steps` - Number of micro-batches to accumulate before stepping
289    /// * `averagegradients` - Whether to average gradients (true) or sum them (false)
290    pub fn new(_accumulation_steps: usize, averagegradients: bool) -> Self {
291        Self {
292            accumulated_gradients: None,
293            num_accumulated: 0,
294            accumulation_steps: _accumulation_steps,
295            averagegradients,
296        }
297    }
298
299    /// Add gradients from a micro-batch
300    ///
301    /// # Arguments
302    ///
303    /// * `gradients` - Gradients from the current micro-batch
304    ///
305    /// # Returns
306    ///
307    /// `true` if enough gradients have been accumulated and it's time to step
308    pub fn accumulate(&mut self, gradients: &Array<A, D>) -> bool {
309        if let Some(acc) = &mut self.accumulated_gradients {
310            for (acc_val, &grad_val) in acc.iter_mut().zip(gradients.iter()) {
311                *acc_val = *acc_val + grad_val;
312            }
313        } else {
314            self.accumulated_gradients = Some(gradients.clone());
315        }
316
317        self.num_accumulated += 1;
318        self.num_accumulated >= self.accumulation_steps
319    }
320
321    /// Get the accumulated gradients and reset the accumulator
322    ///
323    /// # Returns
324    ///
325    /// The accumulated gradients, ready for optimization step
326    pub fn get_and_reset(&mut self) -> Option<Array<A, D>> {
327        if let Some(mut gradients) = self.accumulated_gradients.take() {
328            if self.averagegradients && self.num_accumulated > 0 {
329                let scale = A::one() / A::from(self.num_accumulated).unwrap_or(A::one());
330                gradients.mapv_inplace(|x| x * scale);
331            }
332            self.num_accumulated = 0;
333            Some(gradients)
334        } else {
335            None
336        }
337    }
338
339    /// Get current accumulation progress
340    pub fn progress(&self) -> (usize, usize) {
341        (self.num_accumulated, self.accumulation_steps)
342    }
343
344    /// Check if ready for optimization step
345    pub fn is_ready(&self) -> bool {
346        self.num_accumulated >= self.accumulation_steps
347    }
348
349    /// Reset the accumulator
350    pub fn reset(&mut self) {
351        self.accumulated_gradients = None;
352        self.num_accumulated = 0;
353    }
354
355    /// Change accumulation steps
356    pub fn set_accumulation_steps(&mut self, steps: usize) {
357        self.accumulation_steps = steps;
358    }
359}
360
361/// Adaptive gradient clipping
362///
363/// Clips gradients based on the ratio of gradient norm to parameter norm.
364/// This is particularly useful for transformer models.
365pub fn adaptive_gradient_clipping<'a, A, D>(
366    gradients: &'a mut Array<A, D>,
367    parameters: &Array<A, D>,
368    max_ratio: A,
369) -> Result<&'a mut Array<A, D>>
370where
371    A: Float + ScalarOperand,
372    D: Dimension,
373{
374    if max_ratio <= A::zero() {
375        return Err(OptimError::InvalidConfig(
376            "max_ratio must be positive".to_string(),
377        ));
378    }
379
380    let grad_norm = gradients
381        .iter()
382        .fold(A::zero(), |acc, &x| acc + x * x)
383        .sqrt();
384
385    let param_norm = parameters
386        .iter()
387        .fold(A::zero(), |acc, &x| acc + x * x)
388        .sqrt();
389
390    if param_norm > A::zero() && grad_norm > A::zero() {
391        let _ratio = grad_norm / param_norm;
392        if _ratio > max_ratio {
393            let scale = max_ratio / _ratio;
394            gradients.mapv_inplace(|x| x * scale);
395        }
396    }
397
398    Ok(gradients)
399}
400
401/// Add noise to gradients for regularization
402///
403/// # Arguments
404///
405/// * `gradients` - Gradients to add noise to
406/// * `noise_std` - Standard deviation of Gaussian noise to add
407/// * `seed` - Optional seed for reproducible results. `Some(seed)` draws from a
408///   deterministically seeded generator, so the same inputs always produce the
409///   same noise; `None` draws from the thread-local generator.
410pub fn add_gradient_noise<A, D>(
411    gradients: &mut Array<A, D>,
412    noise_std: A,
413    seed: Option<u64>,
414) -> &mut Array<A, D>
415where
416    A: Float + ScalarOperand,
417    D: Dimension,
418{
419    use scirs2_core::random::{seeded_rng, RandNormal};
420
421    if noise_std <= A::zero() {
422        return gradients;
423    }
424
425    let Some(std_f64) = noise_std.to_f64() else {
426        // The requested deviation is not representable in `f64`, so no honest
427        // noise distribution can be built. Leave the gradients untouched
428        // rather than panicking or silently substituting a different scale.
429        return gradients;
430    };
431    let Ok(normal) = RandNormal::new(0.0, std_f64) else {
432        return gradients;
433    };
434
435    // `seed` is honoured here: a fixed seed makes the perturbation
436    // reproducible, which is the whole point of the parameter (it used to be
437    // accepted and then ignored, so "reproducible results" was never true).
438    let count = gradients.len();
439    let samples: Vec<f64> = match seed {
440        Some(seed) => seeded_rng(seed).sample_vec(normal, count),
441        None => thread_rng().sample_vec(normal, count),
442    };
443
444    for (g, &n) in gradients.iter_mut().zip(samples.iter()) {
445        *g = *g + A::from(n).unwrap_or(A::zero());
446    }
447
448    gradients
449}
450
451/// Gradient masking and freezing utilities
452///
453/// Allows selective gradient updates by masking certain parameters
454#[derive(Debug, Clone)]
455pub struct GradientMask<A: Float, D: Dimension> {
456    /// Mask indicating which parameters to update (true = update, false = freeze)
457    mask: Array<bool, D>,
458    /// Optional learning rate multipliers for each parameter
459    lr_multipliers: Option<Array<A, D>>,
460}
461
462impl<A: Float + ScalarOperand + Debug, D: Dimension + Send + Sync> GradientMask<A, D> {
463    /// Create a new gradient mask
464    ///
465    /// # Arguments
466    ///
467    /// * `mask` - Boolean mask indicating which parameters to update
468    pub fn new(mask: Array<bool, D>) -> Self {
469        Self {
470            mask,
471            lr_multipliers: None,
472        }
473    }
474
475    /// Create a mask that freezes all parameters
476    pub fn freeze_all(shape: D) -> Self {
477        Self {
478            mask: Array::from_elem(shape, false),
479            lr_multipliers: None,
480        }
481    }
482
483    /// Create a mask that updates all parameters
484    pub fn update_all(shape: D) -> Self {
485        Self {
486            mask: Array::from_elem(shape, true),
487            lr_multipliers: None,
488        }
489    }
490
491    /// Set learning rate multipliers for different parameters
492    pub fn with_lr_multipliers(mut self, multipliers: Array<A, D>) -> Self {
493        self.lr_multipliers = Some(multipliers);
494        self
495    }
496
497    /// Apply the mask to gradients
498    ///
499    /// # Arguments
500    ///
501    /// * `gradients` - Gradients to mask
502    ///
503    /// # Returns
504    ///
505    /// Masked gradients where frozen parameters have zero gradients
506    pub fn apply_mask<'a>(&self, gradients: &'a mut Array<A, D>) -> &'a mut Array<A, D> {
507        gradients.zip_mut_with(&self.mask, |grad, &should_update| {
508            if !should_update {
509                *grad = A::zero();
510            }
511        });
512
513        // Apply learning rate multipliers if present
514        if let Some(multipliers) = &self.lr_multipliers {
515            gradients.zip_mut_with(multipliers, |grad, &mult| {
516                *grad = *grad * mult;
517            });
518        }
519
520        gradients
521    }
522
523    /// Freeze specific parameters by indices
524    pub fn freeze_indices(&mut self, indices: &[usize]) -> Result<()> {
525        let flat_mask = self.mask.as_slice_mut().ok_or_else(|| {
526            OptimError::InvalidConfig("Cannot access mask as flat slice".to_string())
527        })?;
528
529        for &idx in indices {
530            if idx < flat_mask.len() {
531                flat_mask[idx] = false;
532            } else {
533                return Err(OptimError::InvalidConfig(format!(
534                    "Index {} out of bounds for mask of size {}",
535                    idx,
536                    flat_mask.len()
537                )));
538            }
539        }
540        Ok(())
541    }
542
543    /// Unfreeze specific parameters by indices
544    pub fn unfreeze_indices(&mut self, indices: &[usize]) -> Result<()> {
545        let flat_mask = self.mask.as_slice_mut().ok_or_else(|| {
546            OptimError::InvalidConfig("Cannot access mask as flat slice".to_string())
547        })?;
548
549        for &idx in indices {
550            if idx < flat_mask.len() {
551                flat_mask[idx] = true;
552            } else {
553                return Err(OptimError::InvalidConfig(format!(
554                    "Index {} out of bounds for mask of size {}",
555                    idx,
556                    flat_mask.len()
557                )));
558            }
559        }
560        Ok(())
561    }
562
563    /// Get the number of frozen parameters
564    pub fn num_frozen(&self) -> usize {
565        self.mask.iter().filter(|&&x| !x).count()
566    }
567
568    /// Get the number of active (unfrozen) parameters
569    pub fn num_active(&self) -> usize {
570        self.mask.iter().filter(|&&x| x).count()
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use approx::assert_relative_eq;
578    use scirs2_core::ndarray::Array1;
579
580    #[test]
581    fn test_gradient_processor() {
582        let config = GradientClipConfig::<f64> {
583            max_value: Some(5.0),
584            min_value: Some(-5.0),
585            maxnorm: Some(10.0),
586            ..Default::default()
587        };
588
589        let processor = GradientProcessor::with_config(config);
590
591        let mut gradients = Array1::from_vec(vec![-8.0, 3.0, 7.0, -2.0, 6.0]);
592        processor.process(&mut gradients).expect("unwrap failed");
593
594        // Check value clipping
595        assert_eq!(gradients[0], -5.0);
596        assert_eq!(gradients[2], 5.0);
597        assert_eq!(gradients[4], 5.0);
598    }
599
600    #[test]
601    fn test_adaptive_clipping() {
602        let mut gradients = Array1::from_vec(vec![3.0, 4.0]); // norm = 5
603        let parameters = Array1::from_vec(vec![1.0, 0.0]); // norm = 1
604
605        // Gradient/parameter ratio = 5/1 = 5, max_ratio = 2
606        adaptive_gradient_clipping(&mut gradients, &parameters, 2.0).expect("unwrap failed");
607
608        // After clipping, ratio should be 2
609        let new_grad_norm = gradients.iter().fold(0.0, |acc, &x| acc + x * x).sqrt();
610        assert!((new_grad_norm - 2.0).abs() < 1e-6);
611    }
612
613    #[test]
614    fn test_gradient_accumulator() {
615        let mut accumulator = GradientAccumulator::new(3, true);
616
617        // First micro-batch
618        let grad1 = Array1::from_vec(vec![1.0, 2.0, 3.0]);
619        assert!(!accumulator.accumulate(&grad1));
620        assert_eq!(accumulator.progress(), (1, 3));
621
622        // Second micro-batch
623        let grad2 = Array1::from_vec(vec![2.0, 3.0, 4.0]);
624        assert!(!accumulator.accumulate(&grad2));
625        assert_eq!(accumulator.progress(), (2, 3));
626
627        // Third micro-batch - should trigger ready
628        let grad3 = Array1::from_vec(vec![3.0, 4.0, 5.0]);
629        assert!(accumulator.accumulate(&grad3));
630        assert!(accumulator.is_ready());
631
632        // Get accumulated gradients (should be averaged)
633        let final_grads = accumulator.get_and_reset().expect("unwrap failed");
634        assert_relative_eq!(final_grads[0], 2.0, epsilon = 1e-6); // (1+2+3)/3
635        assert_relative_eq!(final_grads[1], 3.0, epsilon = 1e-6); // (2+3+4)/3
636        assert_relative_eq!(final_grads[2], 4.0, epsilon = 1e-6); // (3+4+5)/3
637
638        // Should be reset now
639        assert_eq!(accumulator.progress(), (0, 3));
640        assert!(!accumulator.is_ready());
641    }
642
643    #[test]
644    fn test_gradient_accumulator_sum_mode() {
645        let mut accumulator = GradientAccumulator::new(2, false); // sum mode
646
647        let grad1 = Array1::from_vec(vec![1.0, 2.0]);
648        let grad2 = Array1::from_vec(vec![3.0, 4.0]);
649
650        accumulator.accumulate(&grad1);
651        accumulator.accumulate(&grad2);
652
653        let final_grads = accumulator.get_and_reset().expect("unwrap failed");
654        assert_relative_eq!(final_grads[0], 4.0, epsilon = 1e-6); // 1+3
655        assert_relative_eq!(final_grads[1], 6.0, epsilon = 1e-6); // 2+4
656    }
657
658    #[test]
659    fn test_gradient_noise() {
660        let mut gradients = Array1::from_vec(vec![1.0, 2.0, 3.0]);
661        let original = gradients.clone();
662
663        // Add noise with fixed seed for reproducibility
664        add_gradient_noise(&mut gradients, 0.1, Some(42));
665
666        // Gradients should be different but close to original
667        for (i, (&orig, &noisy)) in original.iter().zip(gradients.iter()).enumerate() {
668            assert!(
669                (orig - noisy).abs() < 1.0,
670                "Index {}: {} vs {}",
671                i,
672                orig,
673                noisy
674            );
675        }
676    }
677
678    #[test]
679    fn test_gradient_noise_zero_std() {
680        let mut gradients = Array1::from_vec(vec![1.0, 2.0, 3.0]);
681        let original = gradients.clone();
682
683        // Zero noise should leave gradients unchanged
684        add_gradient_noise(&mut gradients, 0.0, Some(42));
685
686        for (orig, noisy) in original.iter().zip(gradients.iter()) {
687            assert_relative_eq!(*orig, *noisy, epsilon = 1e-10);
688        }
689    }
690
691    #[test]
692    fn test_gradient_mask_creation() {
693        let mask = Array1::from_vec(vec![true, false, true]);
694        let grad_mask: GradientMask<f64, scirs2_core::ndarray::Ix1> = GradientMask::new(mask);
695
696        assert_eq!(grad_mask.num_active(), 2);
697        assert_eq!(grad_mask.num_frozen(), 1);
698    }
699
700    #[test]
701    fn test_gradient_mask_apply() {
702        let mask = Array1::from_vec(vec![true, false, true]);
703        let grad_mask: GradientMask<f64, scirs2_core::ndarray::Ix1> = GradientMask::new(mask);
704        let mut gradients = Array1::from_vec(vec![1.0, 2.0, 3.0]);
705
706        grad_mask.apply_mask(&mut gradients);
707
708        assert_eq!(
709            gradients.as_slice().expect("unwrap failed"),
710            &[1.0, 0.0, 3.0]
711        );
712    }
713
714    #[test]
715    fn test_gradient_mask_freeze_unfreeze() {
716        let mask = Array1::from_vec(vec![true, true, true]);
717        let mut grad_mask: GradientMask<f64, scirs2_core::ndarray::Ix1> = GradientMask::new(mask);
718
719        // Freeze some indices
720        grad_mask.freeze_indices(&[0, 2]).expect("unwrap failed");
721        assert_eq!(grad_mask.num_frozen(), 2);
722        assert_eq!(grad_mask.num_active(), 1);
723
724        // Unfreeze one index
725        grad_mask.unfreeze_indices(&[0]).expect("unwrap failed");
726        assert_eq!(grad_mask.num_frozen(), 1);
727        assert_eq!(grad_mask.num_active(), 2);
728    }
729
730    #[test]
731    fn test_gradient_mask_with_lr_multipliers() {
732        let mask = Array1::from_vec(vec![true, true, true]);
733        let multipliers = Array1::from_vec(vec![1.0, 0.5, 2.0]);
734        let grad_mask: GradientMask<f64, scirs2_core::ndarray::Ix1> =
735            GradientMask::new(mask).with_lr_multipliers(multipliers);
736        let mut gradients = Array1::from_vec(vec![1.0, 2.0, 3.0]);
737
738        grad_mask.apply_mask(&mut gradients);
739
740        assert_relative_eq!(gradients[0], 1.0, epsilon = 1e-6);
741        assert_relative_eq!(gradients[1], 1.0, epsilon = 1e-6); // 2.0 * 0.5
742        assert_relative_eq!(gradients[2], 6.0, epsilon = 1e-6); // 3.0 * 2.0
743    }
744
745    #[test]
746    fn test_gradient_mask_freeze_all() {
747        let grad_mask = GradientMask::<f64, scirs2_core::ndarray::Ix1>::freeze_all(
748            scirs2_core::ndarray::Ix1(3),
749        );
750        assert_eq!(grad_mask.num_frozen(), 3);
751        assert_eq!(grad_mask.num_active(), 0);
752    }
753
754    #[test]
755    fn test_gradient_mask_update_all() {
756        let grad_mask = GradientMask::<f64, scirs2_core::ndarray::Ix1>::update_all(
757            scirs2_core::ndarray::Ix1(3),
758        );
759        assert_eq!(grad_mask.num_frozen(), 0);
760        assert_eq!(grad_mask.num_active(), 3);
761    }
762}