1#![allow(clippy::neg_cmp_op_on_partial_ord)]
30
31use scirs2_core::ndarray::{Array, ArrayBase, Data, DataMut, Dimension, Zip};
32use scirs2_core::numeric::Float;
33use std::cmp::Ordering;
34use std::collections::{HashMap, VecDeque};
35
36use super::accountant::{build_accountant, AccountingSegment, PrivacyAccountant};
37use super::{
38 standard_laplace, standard_normal, DifferentialPrivacyConfig, NoiseMechanism, PrivacyBudget,
39};
40use crate::error::{OptimError, Result};
41use crate::optimizers::Optimizer;
42
43pub struct DPSGDOptimizer<O, A, D>
45where
46 A: Float
47 + Send
48 + Sync
49 + scirs2_core::ndarray::ScalarOperand
50 + std::fmt::Debug
51 + Default
52 + Clone
53 + std::iter::Sum,
54 D: scirs2_core::ndarray::Dimension,
55 O: Optimizer<A, D>,
56{
57 baseoptimizer: O,
59
60 config: DifferentialPrivacyConfig,
62
63 accountant: Box<dyn PrivacyAccountant>,
65
66 pure_epsilon_spent: f64,
68
69 rng: scirs2_core::random::CoreRandom,
71
72 adaptive_clipping: Option<AdaptiveClippingState>,
74
75 privacy_budget: PrivacyBudgetTracker,
77
78 gradient_stats: GradientStatistics<A>,
80
81 noise_calibrator: NoiseCalibrator<A>,
83
84 step_count: usize,
86
87 current_batch_size: usize,
89
90 _phantom: std::marker::PhantomData<D>,
92}
93
94#[derive(Debug, Clone)]
96struct AdaptiveClippingState {
97 current_threshold: f64,
99
100 target_quantile: f64,
102
103 adaptationlr: f64,
105
106 fraction_history: VecDeque<f64>,
108
109 quantile_estimator: QuantileEstimator,
112
113 updates: usize,
115}
116
117#[derive(Debug, Clone)]
119struct QuantileEstimator {
120 p2_state: P2AlgorithmState,
122
123 ema: f64,
125
126 ema_decay: f64,
128}
129
130#[derive(Debug, Clone)]
136struct P2AlgorithmState {
137 heights: [f64; 5],
138 positions: [f64; 5],
139 desired: [f64; 5],
140 increments: [f64; 5],
141 count: usize,
142 initial: Vec<f64>,
143}
144
145#[derive(Debug, Clone)]
147struct PrivacyBudgetTracker {
148 epsilon_consumed: f64,
150
151 target_epsilon: f64,
153
154 reporting_delta: f64,
156
157 consumption_history: Vec<PrivacyConsumption>,
159}
160
161#[derive(Debug, Clone)]
163pub struct PrivacyConsumption {
164 pub step: usize,
166 pub epsilon_spent: f64,
168 pub reporting_delta: f64,
170 pub batchsize: usize,
172 pub noise_multiplier: f64,
174}
175
176#[derive(Debug, Clone)]
178struct GradientStatistics<A: Float + Default + Clone + std::iter::Sum> {
179 norm_history: VecDeque<A>,
181
182 clipping_frequency: f64,
184
185 clipping_frequency_rate: f64,
187
188 avg_norm: A,
190
191 std_norm: A,
193
194 percentiles: HashMap<String, A>,
196
197 max_history_size: usize,
199}
200
201#[derive(Debug, Clone)]
203struct NoiseCalibrator<A: Float> {
204 noise_multiplier: A,
206
207 base_noise_scale: A,
209
210 mechanism: NoiseMechanism,
212
213 calibration_history: Vec<NoiseCalibration<A>>,
215}
216
217#[derive(Debug, Clone)]
219pub struct NoiseCalibration<A: Float> {
220 pub step: usize,
222 pub noise_scale: A,
224 pub gradientnorm: A,
226 pub clipping_threshold: A,
228 pub privacy_cost: A,
230}
231
232impl<O, A, D> DPSGDOptimizer<O, A, D>
233where
234 A: Float
235 + Default
236 + Clone
237 + Send
238 + Sync
239 + scirs2_core::ndarray::ScalarOperand
240 + std::fmt::Debug
241 + std::iter::Sum
242 + std::ops::AddAssign,
243 D: scirs2_core::ndarray::Dimension,
244 O: Optimizer<A, D> + Send + Sync,
245{
246 pub fn new(baseoptimizer: O, config: DifferentialPrivacyConfig) -> Result<Self> {
252 config.validate()?;
253
254 if config.secure_aggregation {
255 return Err(OptimError::InvalidPrivacyConfig(
256 "secure_aggregation is not wired into DPSGDOptimizer; use \
257 privacy::secure_aggregation::SecureAggregator explicitly rather than enabling \
258 a flag that would otherwise be ignored"
259 .to_string(),
260 ));
261 }
262
263 let accountant = build_accountant(
264 config.accounting_method,
265 config.noise_multiplier,
266 config.target_delta,
267 config.batch_size,
268 config.dataset_size,
269 )?;
270
271 let rng = scirs2_core::random::thread_rng();
272
273 let adaptive_clipping = if config.adaptive_clipping {
274 Some(AdaptiveClippingState::new(
275 config.adaptive_clip_init,
276 config.adaptive_clip_lr,
277 config.adaptive_clip_target_quantile,
278 )?)
279 } else {
280 None
281 };
282
283 let privacy_budget = PrivacyBudgetTracker::new(&config);
284 let gradient_stats = GradientStatistics::new();
285 let noise_calibrator = NoiseCalibrator::new(&config)?;
286
287 let batchsize = config.batch_size;
288 Ok(Self {
289 baseoptimizer,
290 config,
291 accountant,
292 pure_epsilon_spent: 0.0,
293 rng,
294 adaptive_clipping,
295 privacy_budget,
296 gradient_stats,
297 noise_calibrator,
298 step_count: 0,
299 current_batch_size: batchsize,
300 _phantom: std::marker::PhantomData,
301 })
302 }
303
304 pub fn dp_step_per_example(
311 &mut self,
312 params: &Array<A, D>,
313 per_example_gradients: &[Array<A, D>],
314 ) -> Result<Array<A, D>> {
315 if per_example_gradients.is_empty() {
316 return Err(OptimError::InvalidConfig(
317 "dp_step_per_example requires at least one per-example gradient".to_string(),
318 ));
319 }
320 for gradient in per_example_gradients {
321 if gradient.raw_dim() != params.raw_dim() {
322 return Err(OptimError::DimensionMismatch(format!(
323 "per-example gradient shape {:?} does not match parameter shape {:?}",
324 gradient.shape(),
325 params.shape()
326 )));
327 }
328 }
329
330 let batchsize = per_example_gradients.len();
331 self.enforce_budget(batchsize)?;
332
333 let clipping_threshold = self.get_clipping_threshold();
334 let mut summed: Array<A, D> = Array::zeros(params.raw_dim());
335 let mut norm_sum = 0.0;
336 let mut below_threshold = 0usize;
337 let mut clipped_count = 0usize;
338
339 for gradient in per_example_gradients {
340 let norm = self.compute_gradient_norm_f64(gradient)?;
341 norm_sum += norm;
342
343 let scale = if norm > clipping_threshold && norm > 0.0 {
344 clipped_count += 1;
345 clipping_threshold / norm
346 } else {
347 below_threshold += 1;
348 1.0
349 };
350
351 let scale_a = A::from(scale).ok_or_else(|| {
352 OptimError::InvalidConfig("failed to convert clipping scale".to_string())
353 })?;
354 Zip::from(&mut summed)
355 .and(gradient)
356 .for_each(|acc, &g| *acc += g * scale_a);
357 }
358
359 self.add_noise(&mut summed, clipping_threshold)?;
360
361 let batch_a = A::from(batchsize as f64)
362 .ok_or_else(|| OptimError::InvalidConfig("failed to convert batch size".to_string()))?;
363 summed.mapv_inplace(|x| x / batch_a);
364
365 let mean_norm = norm_sum / batchsize as f64;
366 self.finish_step(batchsize, mean_norm, clipping_threshold, clipped_count)?;
367
368 if self.config.adaptive_clipping {
369 self.update_adaptive_clipping(below_threshold, batchsize)?;
370 }
371
372 self.baseoptimizer.step(params, &summed)
373 }
374
375 pub fn dp_step_presummed(
381 &mut self,
382 params: &Array<A, D>,
383 summed_clipped_gradients: &Array<A, D>,
384 batchsize: usize,
385 ) -> Result<Array<A, D>> {
386 if batchsize == 0 {
387 return Err(OptimError::InvalidConfig(
388 "batchsize must be positive".to_string(),
389 ));
390 }
391 if summed_clipped_gradients.raw_dim() != params.raw_dim() {
392 return Err(OptimError::DimensionMismatch(format!(
393 "gradient shape {:?} does not match parameter shape {:?}",
394 summed_clipped_gradients.shape(),
395 params.shape()
396 )));
397 }
398
399 self.enforce_budget(batchsize)?;
400
401 let clipping_threshold = self.get_clipping_threshold();
402 let mut summed = summed_clipped_gradients.clone();
403 self.add_noise(&mut summed, clipping_threshold)?;
404
405 let batch_a = A::from(batchsize as f64)
406 .ok_or_else(|| OptimError::InvalidConfig("failed to convert batch size".to_string()))?;
407 summed.mapv_inplace(|x| x / batch_a);
408
409 let mean_norm =
410 self.compute_gradient_norm_f64(summed_clipped_gradients)? / batchsize as f64;
411 self.finish_step(batchsize, mean_norm, clipping_threshold, 0)?;
412
413 self.baseoptimizer.step(params, &summed)
414 }
415
416 pub fn dp_step(
427 &mut self,
428 params: &Array<A, D>,
429 gradients: &mut Array<A, D>,
430 batchsize: usize,
431 ) -> Result<Array<A, D>> {
432 if !self.config.acknowledge_aggregate_clipping {
433 return Err(OptimError::InvalidPrivacyConfig(
434 "dp_step clips an already-aggregated gradient and therefore does NOT provide \
435 per-example differential privacy. Use dp_step_per_example (or \
436 dp_step_presummed), or set acknowledge_aggregate_clipping = true to accept \
437 batch-level adjacency semantics"
438 .to_string(),
439 ));
440 }
441 if batchsize == 0 {
442 return Err(OptimError::InvalidConfig(
443 "batchsize must be positive".to_string(),
444 ));
445 }
446
447 self.enforce_budget(self.config.dataset_size)?;
449
450 let pre_clip_norm = self.compute_gradient_norm_f64(gradients)?;
451 let clipping_threshold = self.get_clipping_threshold();
452 let clipped = if pre_clip_norm > clipping_threshold && pre_clip_norm > 0.0 {
453 let scale = A::from(clipping_threshold / pre_clip_norm).ok_or_else(|| {
454 OptimError::InvalidConfig("failed to convert clipping scale".to_string())
455 })?;
456 gradients.mapv_inplace(|g| g * scale);
457 1
458 } else {
459 0
460 };
461
462 self.add_noise(gradients, clipping_threshold)?;
463 self.finish_step(
464 self.config.dataset_size,
465 pre_clip_norm,
466 clipping_threshold,
467 clipped,
468 )?;
469
470 self.baseoptimizer.step(params, gradients)
471 }
472
473 fn enforce_budget(&mut self, batchsize: usize) -> Result<()> {
475 if self.step_count >= self.config.max_steps {
476 return Err(OptimError::PrivacyBudgetExhausted {
477 consumed_epsilon: self.consumed_epsilon()?,
478 target_epsilon: self.config.target_epsilon,
479 });
480 }
481
482 let projected = self.projected_epsilon(batchsize)?;
483 if projected > self.config.target_epsilon {
484 return Err(OptimError::PrivacyBudgetExhausted {
485 consumed_epsilon: self.consumed_epsilon()?,
486 target_epsilon: self.config.target_epsilon,
487 });
488 }
489 Ok(())
490 }
491
492 fn finish_step(
494 &mut self,
495 batchsize: usize,
496 mean_pre_clip_norm: f64,
497 clipping_threshold: f64,
498 clipped_count: usize,
499 ) -> Result<()> {
500 self.step_count += 1;
501 self.current_batch_size = batchsize;
502
503 match self.config.noise_mechanism {
504 NoiseMechanism::Gaussian => {
505 let q = self.sampling_probability(batchsize);
506 self.accountant
507 .compose_subsampled_gaussian(self.config.noise_multiplier, q, 1)?;
508 }
509 NoiseMechanism::Laplace => {
510 self.pure_epsilon_spent += self.laplace_epsilon_per_step();
511 }
512 other => {
513 return Err(OptimError::InvalidPrivacyConfig(format!(
514 "noise mechanism {other:?} is not implemented for DP-SGD"
515 )));
516 }
517 }
518
519 let epsilon_spent = self.consumed_epsilon()?;
520 self.privacy_budget.record(
521 self.step_count,
522 epsilon_spent,
523 self.reporting_delta(),
524 batchsize,
525 self.config.noise_multiplier,
526 );
527
528 let norm_a = A::from(mean_pre_clip_norm).ok_or_else(|| {
529 OptimError::InvalidConfig("failed to convert gradient norm".to_string())
530 })?;
531 self.gradient_stats.update_norm(norm_a)?;
532 self.gradient_stats
533 .update_clipping(clipped_count * 2 > batchsize.max(1));
534
535 if let Some(ref mut state) = self.adaptive_clipping {
536 state.observe_norm(mean_pre_clip_norm);
537 }
538
539 let threshold_a = A::from(clipping_threshold).ok_or_else(|| {
540 OptimError::InvalidConfig("failed to convert clipping threshold".to_string())
541 })?;
542 let cost_a = A::from(if epsilon_spent.is_finite() {
543 epsilon_spent
544 } else {
545 f64::MAX
546 })
547 .ok_or_else(|| OptimError::InvalidConfig("failed to convert privacy cost".to_string()))?;
548 self.noise_calibrator
549 .update_calibration(self.step_count, norm_a, threshold_a, cost_a);
550
551 Ok(())
552 }
553
554 fn update_adaptive_clipping(&mut self, below_threshold: usize, batchsize: usize) -> Result<()> {
562 if batchsize == 0 {
563 return Ok(());
564 }
565
566 let sigma_count = self.config.adaptive_clip_noise_multiplier;
567 let noisy_below = below_threshold as f64 + sigma_count * standard_normal(&mut self.rng);
568 let fraction = (noisy_below / batchsize as f64).clamp(0.0, 1.0);
569
570 if let Some(ref mut state) = self.adaptive_clipping {
571 state.update_threshold(fraction);
572 } else {
573 return Ok(());
574 }
575
576 match self.config.noise_mechanism {
577 NoiseMechanism::Gaussian => {
578 let q = self.sampling_probability(batchsize);
579 self.accountant
580 .compose_subsampled_gaussian(sigma_count, q, 1)?;
581 }
582 NoiseMechanism::Laplace => {
583 self.pure_epsilon_spent += self.laplace_epsilon_per_step();
584 }
585 other => {
586 return Err(OptimError::InvalidPrivacyConfig(format!(
587 "noise mechanism {other:?} is not implemented for DP-SGD"
588 )));
589 }
590 }
591
592 let epsilon_spent = self.consumed_epsilon()?;
593 self.privacy_budget.record(
594 self.step_count,
595 epsilon_spent,
596 self.reporting_delta(),
597 batchsize,
598 sigma_count,
599 );
600
601 Ok(())
602 }
603
604 fn sampling_probability(&self, batchsize: usize) -> f64 {
606 if self.config.dataset_size == 0 {
607 0.0
608 } else {
609 (batchsize as f64 / self.config.dataset_size as f64).min(1.0)
610 }
611 }
612
613 fn laplace_epsilon_per_step(&self) -> f64 {
615 self.config.target_epsilon / self.config.max_steps.max(1) as f64
616 }
617
618 fn reporting_delta(&self) -> f64 {
620 match self.config.noise_mechanism {
621 NoiseMechanism::Laplace => 0.0,
622 _ => self.config.target_delta,
623 }
624 }
625
626 pub fn consumed_epsilon(&self) -> Result<f64> {
629 match self.config.noise_mechanism {
630 NoiseMechanism::Laplace => Ok(self.pure_epsilon_spent),
631 _ => {
632 let (epsilon, _) = self.accountant.privacy_spent(self.config.target_delta)?;
633 Ok(epsilon)
634 }
635 }
636 }
637
638 fn projected_epsilon(&self, batchsize: usize) -> Result<f64> {
640 match self.config.noise_mechanism {
641 NoiseMechanism::Laplace => {
642 Ok(self.pure_epsilon_spent + self.laplace_epsilon_per_step())
643 }
644 NoiseMechanism::Gaussian => {
645 let q = self.sampling_probability(batchsize);
646 let (epsilon, _) = self.accountant.projected_privacy_spent(
647 self.config.noise_multiplier,
648 q,
649 1,
650 self.config.target_delta,
651 )?;
652 Ok(epsilon)
653 }
654 other => Err(OptimError::InvalidPrivacyConfig(format!(
655 "noise mechanism {other:?} is not implemented for DP-SGD"
656 ))),
657 }
658 }
659
660 pub fn has_privacy_budget(&self) -> Result<bool> {
662 if self.step_count >= self.config.max_steps {
663 return Ok(false);
664 }
665 Ok(self.projected_epsilon(self.current_batch_size)? <= self.config.target_epsilon)
666 }
667
668 pub fn get_privacy_budget(&self) -> Result<PrivacyBudget> {
670 let epsilon_consumed = self.consumed_epsilon()?;
671 Ok(PrivacyBudget {
672 epsilon_consumed,
673 delta_consumed: 0.0,
675 epsilon_remaining: (self.config.target_epsilon - epsilon_consumed).max(0.0),
676 delta_remaining: self.reporting_delta(),
677 steps_taken: self.step_count,
678 accounting_method: self.config.accounting_method,
679 estimated_steps_remaining: self.estimate_remaining_steps(epsilon_consumed),
680 })
681 }
682
683 pub fn accounting_segments(&self) -> &[AccountingSegment] {
685 self.accountant.segments()
686 }
687
688 pub fn get_clipping_stats(&self) -> AdaptiveClippingStats {
690 AdaptiveClippingStats {
691 current_threshold: self.get_clipping_threshold(),
692 target_quantile: self
693 .adaptive_clipping
694 .as_ref()
695 .map(|ac| ac.target_quantile)
696 .unwrap_or(self.config.adaptive_clip_target_quantile),
697 clipping_frequency: self.gradient_stats.clipping_frequency,
698 avg_gradient_norm: self.gradient_stats.avg_norm.to_f64().unwrap_or(0.0),
699 std_gradient_norm: self.gradient_stats.std_norm.to_f64().unwrap_or(0.0),
700 adaptation_rate: self
701 .adaptive_clipping
702 .as_ref()
703 .map(|ac| ac.adaptationlr)
704 .unwrap_or(0.0),
705 quantile_estimate: self
706 .adaptive_clipping
707 .as_ref()
708 .map(|ac| ac.quantile_estimator.estimate())
709 .unwrap_or(0.0),
710 threshold_updates: self
711 .adaptive_clipping
712 .as_ref()
713 .map(|ac| ac.updates)
714 .unwrap_or(0),
715 }
716 }
717
718 pub fn set_batch_size(&mut self, batchsize: usize) -> Result<()> {
725 if batchsize == 0 || batchsize > self.config.dataset_size {
726 return Err(OptimError::InvalidConfig(format!(
727 "batch size must be in 1..={}, got {batchsize}",
728 self.config.dataset_size
729 )));
730 }
731 self.current_batch_size = batchsize;
732 self.config.batch_size = batchsize;
733 Ok(())
734 }
735
736 pub fn update_privacy_config(&mut self, newconfig: DifferentialPrivacyConfig) -> Result<()> {
744 newconfig.validate()?;
745
746 if newconfig.target_epsilon != self.config.target_epsilon
747 || newconfig.target_delta != self.config.target_delta
748 {
749 return Err(OptimError::InvalidConfig(
750 "the privacy target (epsilon, delta) cannot be changed mid-training: already \
751 spent privacy would be re-interpreted under a different target"
752 .to_string(),
753 ));
754 }
755 if newconfig.dataset_size != self.config.dataset_size {
756 return Err(OptimError::InvalidConfig(
757 "dataset_size cannot be changed mid-training: it defines the sampling \
758 probability of steps already accounted"
759 .to_string(),
760 ));
761 }
762 if newconfig.noise_mechanism != self.config.noise_mechanism
763 || newconfig.accounting_method != self.config.accounting_method
764 {
765 return Err(OptimError::InvalidConfig(
766 "the noise mechanism and accounting method cannot be changed mid-training"
767 .to_string(),
768 ));
769 }
770
771 self.privacy_budget.target_epsilon = newconfig.target_epsilon;
772 self.privacy_budget.reporting_delta = newconfig.target_delta;
773 self.noise_calibrator.noise_multiplier =
774 A::from(newconfig.noise_multiplier).ok_or_else(|| {
775 OptimError::InvalidConfig("failed to convert noise multiplier".to_string())
776 })?;
777 self.config = newconfig;
778 Ok(())
779 }
780
781 fn compute_gradient_norm_f64<S, DIM>(&self, gradients: &ArrayBase<S, DIM>) -> Result<f64>
783 where
784 S: Data<Elem = A>,
785 DIM: Dimension,
786 {
787 let mut sum_squares = 0.0f64;
788 for &value in gradients.iter() {
789 let v = value.to_f64().unwrap_or(f64::NAN);
790 if !v.is_finite() {
791 return Err(OptimError::InvalidConfig(
792 "gradient contains a non-finite value; clipping cannot bound its sensitivity"
793 .to_string(),
794 ));
795 }
796 sum_squares += v * v;
797 }
798 Ok(sum_squares.sqrt())
799 }
800
801 pub fn get_clipping_threshold(&self) -> f64 {
803 if let Some(ref adaptive_state) = self.adaptive_clipping {
804 adaptive_state.current_threshold
805 } else {
806 self.config.l2_norm_clip
807 }
808 }
809
810 fn add_noise<S, DIM>(
819 &mut self,
820 gradients: &mut ArrayBase<S, DIM>,
821 clipping_threshold: f64,
822 ) -> Result<()>
823 where
824 S: DataMut<Elem = A>,
825 DIM: Dimension,
826 {
827 match self.config.noise_mechanism {
828 NoiseMechanism::Gaussian => {
829 let sigma = self.config.noise_multiplier * clipping_threshold;
830 if !sigma.is_finite() || sigma <= 0.0 {
831 return Err(OptimError::InvalidConfig(format!(
832 "noise scale must be positive and finite, got {sigma}"
833 )));
834 }
835 let mut failed = false;
836 gradients.mapv_inplace(|g| {
837 let sample = standard_normal(&mut self.rng) * sigma;
838 match A::from(sample) {
839 Some(noise) => g + noise,
840 None => {
841 failed = true;
842 g
843 }
844 }
845 });
846 if failed {
847 return Err(OptimError::InvalidConfig(
848 "failed to convert Gaussian noise sample".to_string(),
849 ));
850 }
851 }
852 NoiseMechanism::Laplace => {
853 let dimension = gradients.len().max(1) as f64;
854 let l1_sensitivity = clipping_threshold * dimension.sqrt();
855 let epsilon_step = self.laplace_epsilon_per_step();
856 if !(epsilon_step > 0.0) {
857 return Err(OptimError::InvalidPrivacyConfig(
858 "Laplace mechanism requires a positive per-step epsilon".to_string(),
859 ));
860 }
861 let scale = l1_sensitivity / epsilon_step;
862 if !scale.is_finite() || scale <= 0.0 {
863 return Err(OptimError::InvalidConfig(format!(
864 "Laplace scale must be positive and finite, got {scale}"
865 )));
866 }
867 let mut failed = false;
868 gradients.mapv_inplace(|g| {
869 let sample = standard_laplace(&mut self.rng) * scale;
870 match A::from(sample) {
871 Some(noise) => g + noise,
872 None => {
873 failed = true;
874 g
875 }
876 }
877 });
878 if failed {
879 return Err(OptimError::InvalidConfig(
880 "failed to convert Laplace noise sample".to_string(),
881 ));
882 }
883 }
884 other => {
885 return Err(OptimError::InvalidPrivacyConfig(format!(
886 "noise mechanism {other:?} is not implemented for DP-SGD; use Gaussian or \
887 Laplace"
888 )));
889 }
890 }
891
892 Ok(())
893 }
894
895 fn estimate_remaining_steps(&self, epsilon_consumed: f64) -> usize {
897 let by_steps = self.config.max_steps.saturating_sub(self.step_count);
898 if self.step_count == 0 || !epsilon_consumed.is_finite() || epsilon_consumed <= 0.0 {
899 return by_steps;
900 }
901
902 let epsilon_per_step = epsilon_consumed / self.step_count as f64;
903 let remaining_epsilon = (self.config.target_epsilon - epsilon_consumed).max(0.0);
904 let by_budget = (remaining_epsilon / epsilon_per_step) as usize;
905 by_budget.min(by_steps)
906 }
907
908 pub fn get_privacy_accounting_details(&self) -> PrivacyAccountingDetails {
910 PrivacyAccountingDetails {
911 accounting_segments: self.accountant.segments().to_vec(),
912 privacy_consumption_history: self.privacy_budget.consumption_history.clone(),
913 gradient_statistics: GradientStatsSnapshot {
914 avg_norm: self.gradient_stats.avg_norm.to_f64().unwrap_or(0.0),
915 std_norm: self.gradient_stats.std_norm.to_f64().unwrap_or(0.0),
916 clipping_frequency: self.gradient_stats.clipping_frequency,
917 percentiles: self
918 .gradient_stats
919 .percentiles
920 .iter()
921 .map(|(k, v)| (k.clone(), v.to_f64().unwrap_or(0.0)))
922 .collect(),
923 },
924 noise_calibration_history: self
925 .noise_calibrator
926 .calibration_history
927 .iter()
928 .map(|entry| NoiseCalibration {
929 step: entry.step,
930 noise_scale: entry.noise_scale.to_f64().unwrap_or(0.0),
931 gradientnorm: entry.gradientnorm.to_f64().unwrap_or(0.0),
932 clipping_threshold: entry.clipping_threshold.to_f64().unwrap_or(0.0),
933 privacy_cost: entry.privacy_cost.to_f64().unwrap_or(0.0),
934 })
935 .collect(),
936 }
937 }
938
939 pub fn validate_configuration(&self) -> Result<ConfigurationValidation> {
941 let mut warnings = Vec::new();
942 let mut errors = Vec::new();
943
944 if let Err(err) = self.config.validate() {
945 errors.push(err.to_string());
946 }
947
948 if self.config.noise_multiplier < 0.5 {
949 warnings.push(
950 "Noise multipliers below 0.5 give very weak guarantees for DP-SGD".to_string(),
951 );
952 }
953 if self.config.noise_multiplier > 10.0 {
954 warnings.push("Very high noise multiplier may severely impact utility".to_string());
955 }
956 if self.config.l2_norm_clip < 0.01 {
957 warnings.push("Very low clipping threshold may destroy gradient signal".to_string());
958 }
959 if self.config.l2_norm_clip > 100.0 {
960 warnings.push(
961 "Very high clipping threshold may not provide effective clipping".to_string(),
962 );
963 }
964 if self.config.batch_size < 16 {
965 warnings.push("Small batch size reduces privacy amplification benefits".to_string());
966 }
967 if self.config.dataset_size < 1000 {
968 warnings
969 .push("Small dataset limits the achievable privacy-utility tradeoff".to_string());
970 }
971 if self.config.target_epsilon > 10.0 {
972 warnings.push("Large epsilon provides a weak privacy guarantee".to_string());
973 }
974 if self.config.target_delta > 1.0 / self.config.dataset_size as f64 {
975 errors.push("Delta should be much smaller than 1/n".to_string());
976 }
977
978 Ok(ConfigurationValidation {
979 is_valid: errors.is_empty(),
980 warnings,
981 errors,
982 recommended_adjustments: self.generate_recommendations(),
983 })
984 }
985
986 fn generate_recommendations(&self) -> Vec<String> {
987 let mut recommendations = Vec::new();
988
989 if self.gradient_stats.clipping_frequency > 0.8 {
990 recommendations.push(
991 "Consider increasing the clipping threshold - high clipping frequency detected"
992 .to_string(),
993 );
994 }
995 if self.gradient_stats.clipping_frequency < 0.1 && self.step_count > 0 {
996 recommendations.push(
997 "Consider decreasing the clipping threshold - low clipping frequency detected"
998 .to_string(),
999 );
1000 }
1001 if let Ok(epsilon) = self.consumed_epsilon() {
1002 if epsilon / self.config.target_epsilon > 0.9 {
1003 recommendations.push(
1004 "Privacy budget nearly exhausted - increase the noise multiplier or stop"
1005 .to_string(),
1006 );
1007 }
1008 }
1009
1010 recommendations
1011 }
1012}
1013
1014impl AdaptiveClippingState {
1017 fn new(initial_threshold: f64, adaptationlr: f64, target_quantile: f64) -> Result<Self> {
1018 if !initial_threshold.is_finite() || initial_threshold <= 0.0 {
1019 return Err(OptimError::InvalidConfig(format!(
1020 "initial clipping threshold must be positive and finite, got {initial_threshold}"
1021 )));
1022 }
1023 if !adaptationlr.is_finite() || adaptationlr <= 0.0 {
1024 return Err(OptimError::InvalidConfig(format!(
1025 "adaptation rate must be positive and finite, got {adaptationlr}"
1026 )));
1027 }
1028 if !(0.0..=1.0).contains(&target_quantile) {
1029 return Err(OptimError::InvalidConfig(format!(
1030 "target quantile must be in [0, 1], got {target_quantile}"
1031 )));
1032 }
1033
1034 Ok(Self {
1035 current_threshold: initial_threshold,
1036 target_quantile,
1037 adaptationlr,
1038 fraction_history: VecDeque::with_capacity(1000),
1039 quantile_estimator: QuantileEstimator::new(target_quantile.clamp(0.01, 0.99)),
1040 updates: 0,
1041 })
1042 }
1043
1044 fn observe_norm(&mut self, norm: f64) {
1046 if norm.is_finite() {
1047 self.quantile_estimator.update(norm);
1048 }
1049 }
1050
1051 fn update_threshold(&mut self, private_fraction_below: f64) {
1054 self.fraction_history.push_back(private_fraction_below);
1055 if self.fraction_history.len() > 1000 {
1056 self.fraction_history.pop_front();
1057 }
1058
1059 let factor = (-self.adaptationlr * (private_fraction_below - self.target_quantile)).exp();
1060 self.current_threshold = (self.current_threshold * factor).clamp(1e-6, 1e6);
1061 self.updates += 1;
1062 }
1063}
1064
1065impl QuantileEstimator {
1066 fn new(quantile: f64) -> Self {
1067 Self {
1070 p2_state: P2AlgorithmState::new(quantile),
1071 ema: 0.0,
1072 ema_decay: 0.99,
1073 }
1074 }
1075
1076 fn update(&mut self, value: f64) {
1077 self.p2_state.update(value);
1078
1079 if self.p2_state.count == 1 {
1080 self.ema = value;
1081 } else {
1082 self.ema = self.ema_decay * self.ema + (1.0 - self.ema_decay) * value;
1083 }
1084 }
1085
1086 fn estimate(&self) -> f64 {
1088 match self.p2_state.estimate() {
1089 Some(value) => value,
1090 None => self.ema,
1091 }
1092 }
1093}
1094
1095impl P2AlgorithmState {
1096 fn new(quantile: f64) -> Self {
1097 let p = quantile.clamp(0.0, 1.0);
1098 Self {
1099 heights: [0.0; 5],
1100 positions: [1.0, 2.0, 3.0, 4.0, 5.0],
1101 desired: [1.0, 1.0 + 2.0 * p, 1.0 + 4.0 * p, 3.0 + 2.0 * p, 5.0],
1102 increments: [0.0, p / 2.0, p, (1.0 + p) / 2.0, 1.0],
1103 count: 0,
1104 initial: Vec::with_capacity(5),
1105 }
1106 }
1107
1108 fn update(&mut self, value: f64) {
1113 if !value.is_finite() {
1114 return;
1115 }
1116
1117 self.count += 1;
1118
1119 if self.initial.len() < 5 {
1120 self.initial.push(value);
1121 if self.initial.len() == 5 {
1122 self.initial
1123 .sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
1124 for (i, &v) in self.initial.iter().enumerate() {
1125 self.heights[i] = v;
1126 }
1127 }
1128 return;
1129 }
1130
1131 let k = if value < self.heights[0] {
1133 self.heights[0] = value;
1134 0usize
1135 } else if value >= self.heights[4] {
1136 self.heights[4] = value;
1137 3usize
1138 } else {
1139 let mut cell = 0usize;
1140 for i in 0..4 {
1141 if self.heights[i] <= value && value < self.heights[i + 1] {
1142 cell = i;
1143 break;
1144 }
1145 }
1146 cell
1147 };
1148
1149 for i in (k + 1)..5 {
1151 self.positions[i] += 1.0;
1152 }
1153 for i in 0..5 {
1154 self.desired[i] += self.increments[i];
1155 }
1156
1157 for i in 1..4 {
1159 let d = self.desired[i] - self.positions[i];
1160 let gap_up = self.positions[i + 1] - self.positions[i];
1161 let gap_down = self.positions[i - 1] - self.positions[i];
1162
1163 if (d >= 1.0 && gap_up > 1.0) || (d <= -1.0 && gap_down < -1.0) {
1164 let step = if d >= 0.0 { 1.0 } else { -1.0 };
1165
1166 let parabolic = self.parabolic_prediction(i, step);
1167 let new_height =
1168 if self.heights[i - 1] < parabolic && parabolic < self.heights[i + 1] {
1169 parabolic
1170 } else {
1171 self.linear_prediction(i, step)
1172 };
1173
1174 self.heights[i] = new_height;
1175 self.positions[i] += step;
1176 }
1177 }
1178 }
1179
1180 fn parabolic_prediction(&self, i: usize, d: f64) -> f64 {
1181 let n_prev = self.positions[i - 1];
1182 let n_cur = self.positions[i];
1183 let n_next = self.positions[i + 1];
1184 let q_prev = self.heights[i - 1];
1185 let q_cur = self.heights[i];
1186 let q_next = self.heights[i + 1];
1187
1188 let denom = n_next - n_prev;
1189 if denom == 0.0 {
1190 return q_cur;
1191 }
1192
1193 let left =
1194 (n_cur - n_prev + d) * (q_next - q_cur) / (n_next - n_cur).max(f64::MIN_POSITIVE);
1195 let right =
1196 (n_next - n_cur - d) * (q_cur - q_prev) / (n_cur - n_prev).max(f64::MIN_POSITIVE);
1197
1198 q_cur + (d / denom) * (left + right)
1199 }
1200
1201 fn linear_prediction(&self, i: usize, d: f64) -> f64 {
1202 let neighbour = if d > 0.0 { i + 1 } else { i - 1 };
1203 let gap = self.positions[neighbour] - self.positions[i];
1204 if gap == 0.0 {
1205 return self.heights[i];
1206 }
1207 self.heights[i] + d * (self.heights[neighbour] - self.heights[i]) / gap
1208 }
1209
1210 fn estimate(&self) -> Option<f64> {
1212 if self.initial.len() < 5 {
1213 None
1214 } else {
1215 Some(self.heights[2])
1216 }
1217 }
1218}
1219
1220impl PrivacyBudgetTracker {
1221 fn new(config: &DifferentialPrivacyConfig) -> Self {
1222 Self {
1223 epsilon_consumed: 0.0,
1224 target_epsilon: config.target_epsilon,
1225 reporting_delta: config.target_delta,
1226 consumption_history: Vec::new(),
1227 }
1228 }
1229
1230 fn record(
1231 &mut self,
1232 step: usize,
1233 epsilon_spent: f64,
1234 reporting_delta: f64,
1235 batchsize: usize,
1236 noise_multiplier: f64,
1237 ) {
1238 self.epsilon_consumed = epsilon_spent;
1239 self.consumption_history.push(PrivacyConsumption {
1240 step,
1241 epsilon_spent,
1242 reporting_delta,
1243 batchsize,
1244 noise_multiplier,
1245 });
1246
1247 if self.consumption_history.len() > 10_000 {
1248 self.consumption_history.remove(0);
1249 }
1250 }
1251}
1252
1253impl<A: Float + Default + Clone + std::iter::Sum + Send + Sync> GradientStatistics<A> {
1254 fn new() -> Self {
1255 Self {
1256 norm_history: VecDeque::with_capacity(1000),
1257 clipping_frequency: 0.0,
1258 clipping_frequency_rate: 0.01,
1259 avg_norm: A::zero(),
1260 std_norm: A::zero(),
1261 percentiles: HashMap::new(),
1262 max_history_size: 1000,
1263 }
1264 }
1265
1266 fn update_norm(&mut self, norm: A) -> Result<()> {
1267 self.norm_history.push_back(norm);
1268 if self.norm_history.len() > self.max_history_size {
1269 self.norm_history.pop_front();
1270 }
1271
1272 let n = A::from(self.norm_history.len()).ok_or_else(|| {
1273 OptimError::InvalidConfig("failed to convert history length".to_string())
1274 })?;
1275 self.avg_norm = self.norm_history.iter().cloned().sum::<A>() / n;
1276
1277 let variance = self
1278 .norm_history
1279 .iter()
1280 .map(|&x| (x - self.avg_norm) * (x - self.avg_norm))
1281 .sum::<A>()
1282 / n;
1283 self.std_norm = variance.sqrt();
1284
1285 self.update_percentiles();
1286 Ok(())
1287 }
1288
1289 fn update_percentiles(&mut self) {
1290 if self.norm_history.is_empty() {
1291 return;
1292 }
1293
1294 let mut sorted: Vec<A> = self.norm_history.iter().cloned().collect();
1295 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
1296
1297 for (label, q) in [("p50", 0.5), ("p90", 0.9), ("p99", 0.99)] {
1298 let idx = ((sorted.len() - 1) as f64 * q).round() as usize;
1299 if let Some(&value) = sorted.get(idx.min(sorted.len() - 1)) {
1300 self.percentiles.insert(label.to_string(), value);
1301 }
1302 }
1303 }
1304
1305 fn update_clipping(&mut self, was_clipped: bool) {
1311 let alpha = self.clipping_frequency_rate;
1312 let indicator = if was_clipped { 1.0 } else { 0.0 };
1313 self.clipping_frequency = (1.0 - alpha) * self.clipping_frequency + alpha * indicator;
1314 }
1315}
1316
1317impl<A: Float + Default + Clone + Send + Sync> NoiseCalibrator<A> {
1318 fn new(config: &DifferentialPrivacyConfig) -> Result<Self> {
1319 let noise_multiplier = A::from(config.noise_multiplier).ok_or_else(|| {
1320 OptimError::InvalidConfig("failed to convert noise multiplier".to_string())
1321 })?;
1322 let base_noise_scale =
1323 A::from(config.noise_multiplier * config.l2_norm_clip).ok_or_else(|| {
1324 OptimError::InvalidConfig("failed to convert base noise scale".to_string())
1325 })?;
1326
1327 Ok(Self {
1328 noise_multiplier,
1329 base_noise_scale,
1330 mechanism: config.noise_mechanism,
1331 calibration_history: Vec::new(),
1332 })
1333 }
1334
1335 fn update_calibration(
1336 &mut self,
1337 step: usize,
1338 gradientnorm: A,
1339 clipping_threshold: A,
1340 privacy_cost: A,
1341 ) {
1342 let noise_scale = self.noise_multiplier * clipping_threshold;
1343
1344 self.calibration_history.push(NoiseCalibration {
1345 step,
1346 noise_scale,
1347 gradientnorm,
1348 clipping_threshold,
1349 privacy_cost,
1350 });
1351
1352 if self.calibration_history.len() > 1000 {
1353 self.calibration_history.remove(0);
1354 }
1355 }
1356
1357 #[allow(dead_code)]
1359 fn mechanism(&self) -> NoiseMechanism {
1360 self.mechanism
1361 }
1362
1363 #[allow(dead_code)]
1365 fn base_noise_scale(&self) -> A {
1366 self.base_noise_scale
1367 }
1368}
1369
1370#[derive(Debug, Clone)]
1372pub struct AdaptiveClippingStats {
1373 pub current_threshold: f64,
1375 pub target_quantile: f64,
1377 pub clipping_frequency: f64,
1379 pub avg_gradient_norm: f64,
1381 pub std_gradient_norm: f64,
1383 pub adaptation_rate: f64,
1385 pub quantile_estimate: f64,
1387 pub threshold_updates: usize,
1389}
1390
1391#[derive(Debug, Clone)]
1393pub struct PrivacyAccountingDetails {
1394 pub accounting_segments: Vec<AccountingSegment>,
1396 pub privacy_consumption_history: Vec<PrivacyConsumption>,
1398 pub gradient_statistics: GradientStatsSnapshot,
1400 pub noise_calibration_history: Vec<NoiseCalibration<f64>>,
1402}
1403
1404#[derive(Debug, Clone)]
1406pub struct GradientStatsSnapshot {
1407 pub avg_norm: f64,
1409 pub std_norm: f64,
1411 pub clipping_frequency: f64,
1413 pub percentiles: HashMap<String, f64>,
1415}
1416
1417#[derive(Debug, Clone)]
1419pub struct ConfigurationValidation {
1420 pub is_valid: bool,
1422 pub warnings: Vec<String>,
1424 pub errors: Vec<String>,
1426 pub recommended_adjustments: Vec<String>,
1428}
1429
1430#[cfg(test)]
1431mod tests {
1432 use super::*;
1433 use crate::optimizers::SGD;
1434 use crate::privacy::AccountingMethod;
1435 use scirs2_core::ndarray::{Array1, Ix1};
1436
1437 fn test_config() -> DifferentialPrivacyConfig {
1438 DifferentialPrivacyConfig {
1439 target_epsilon: 10.0,
1440 target_delta: 1e-5,
1441 noise_multiplier: 1.1,
1442 l2_norm_clip: 1.0,
1443 batch_size: 64,
1444 dataset_size: 50_000,
1445 max_steps: 1000,
1446 ..Default::default()
1447 }
1448 }
1449
1450 fn build(config: DifferentialPrivacyConfig) -> DPSGDOptimizer<SGD<f64>, f64, Ix1> {
1451 match DPSGDOptimizer::new(SGD::new(0.01), config) {
1452 Ok(optimizer) => optimizer,
1453 Err(err) => panic!("construction failed: {err}"),
1454 }
1455 }
1456
1457 #[test]
1458 fn test_dp_sgd_creation() {
1459 let dp_sgd = DPSGDOptimizer::<_, f64, Ix1>::new(
1460 SGD::new(0.01),
1461 DifferentialPrivacyConfig::default(),
1462 );
1463 assert!(dp_sgd.is_ok());
1464 }
1465
1466 #[test]
1467 fn test_per_example_step_runs_many_steps_with_monotone_epsilon() {
1468 let mut optimizer = build(test_config());
1469 let params = Array1::<f64>::zeros(4);
1470 let batch: Vec<Array1<f64>> = (0..8).map(|_| Array1::from_elem(4, 0.25)).collect();
1471
1472 let mut previous = 0.0;
1473 for step in 1..=100 {
1474 let result = optimizer.dp_step_per_example(¶ms, &batch);
1475 assert!(result.is_ok(), "step {step} failed: {result:?}");
1476 let epsilon = match optimizer.consumed_epsilon() {
1477 Ok(value) => value,
1478 Err(err) => panic!("accounting failed: {err}"),
1479 };
1480 assert!(
1481 epsilon > previous,
1482 "epsilon must increase strictly: {previous} -> {epsilon}"
1483 );
1484 previous = epsilon;
1485 }
1486
1487 let budget = match optimizer.get_privacy_budget() {
1488 Ok(budget) => budget,
1489 Err(err) => panic!("budget query failed: {err}"),
1490 };
1491 assert_eq!(budget.steps_taken, 100);
1492 assert!(budget.epsilon_consumed < budget.epsilon_consumed + budget.epsilon_remaining);
1493 }
1494
1495 #[test]
1496 fn test_budget_exhaustion_terminates_training() {
1497 let config = DifferentialPrivacyConfig {
1498 target_epsilon: 3.0,
1499 noise_multiplier: 1.0,
1500 batch_size: 64,
1501 dataset_size: 640,
1502 max_steps: 100_000,
1503 ..test_config()
1504 };
1505 let mut optimizer = build(config);
1506 let params = Array1::<f64>::zeros(2);
1507 let batch: Vec<Array1<f64>> = (0..64).map(|_| Array1::from_elem(2, 0.5)).collect();
1508
1509 let mut steps = 0;
1510 loop {
1511 match optimizer.dp_step_per_example(¶ms, &batch) {
1512 Ok(_) => {
1513 steps += 1;
1514 assert!(steps < 100_000, "budget was never enforced");
1515 }
1516 Err(OptimError::PrivacyBudgetExhausted { .. }) => break,
1517 Err(other) => panic!("unexpected error: {other}"),
1518 }
1519 }
1520
1521 assert!(steps > 0);
1522 let epsilon = match optimizer.consumed_epsilon() {
1523 Ok(value) => value,
1524 Err(err) => panic!("accounting failed: {err}"),
1525 };
1526 assert!(epsilon <= 3.0 + 1e-12);
1527 }
1528
1529 #[test]
1530 fn test_aggregate_step_is_opt_in() {
1531 let mut optimizer = build(test_config());
1532 let params = Array1::<f64>::zeros(2);
1533 let mut gradients = Array1::from_elem(2, 0.5);
1534 assert!(optimizer.dp_step(¶ms, &mut gradients, 8).is_err());
1535
1536 let mut acknowledged = build(DifferentialPrivacyConfig {
1537 acknowledge_aggregate_clipping: true,
1538 ..test_config()
1539 });
1540 assert!(acknowledged.dp_step(¶ms, &mut gradients, 8).is_ok());
1541 }
1542
1543 #[test]
1544 fn test_batch_size_is_used_by_accounting() {
1545 let params = Array1::<f64>::zeros(2);
1549
1550 let mut small = build(test_config());
1551 let small_batch: Vec<Array1<f64>> = (0..4).map(|_| Array1::from_elem(2, 0.1)).collect();
1552 assert!(small.dp_step_per_example(¶ms, &small_batch).is_ok());
1553
1554 let mut large = build(test_config());
1555 let large_batch: Vec<Array1<f64>> = (0..512).map(|_| Array1::from_elem(2, 0.1)).collect();
1556 assert!(large.dp_step_per_example(¶ms, &large_batch).is_ok());
1557
1558 let eps_small = match small.consumed_epsilon() {
1559 Ok(value) => value,
1560 Err(err) => panic!("accounting failed: {err}"),
1561 };
1562 let eps_large = match large.consumed_epsilon() {
1563 Ok(value) => value,
1564 Err(err) => panic!("accounting failed: {err}"),
1565 };
1566 assert!(
1567 eps_large > eps_small,
1568 "batch size must affect accounting: {eps_small} vs {eps_large}"
1569 );
1570 assert_eq!(
1571 small.accounting_segments()[0].sampling_probability,
1572 4.0 / 50_000.0
1573 );
1574 assert_eq!(
1575 large.accounting_segments()[0].sampling_probability,
1576 512.0 / 50_000.0
1577 );
1578 }
1579
1580 #[test]
1581 fn test_changing_batch_size_appends_a_segment_and_never_rewrites_history() {
1582 let mut optimizer = build(test_config());
1583 let params = Array1::<f64>::zeros(2);
1584 let batch_a: Vec<Array1<f64>> = (0..8).map(|_| Array1::from_elem(2, 0.1)).collect();
1585 for _ in 0..5 {
1586 assert!(optimizer.dp_step_per_example(¶ms, &batch_a).is_ok());
1587 }
1588 let epsilon_before = match optimizer.consumed_epsilon() {
1589 Ok(value) => value,
1590 Err(err) => panic!("accounting failed: {err}"),
1591 };
1592
1593 assert!(optimizer.set_batch_size(256).is_ok());
1594 let batch_b: Vec<Array1<f64>> = (0..256).map(|_| Array1::from_elem(2, 0.1)).collect();
1595 assert!(optimizer.dp_step_per_example(¶ms, &batch_b).is_ok());
1596
1597 let segments = optimizer.accounting_segments();
1598 assert_eq!(segments.len(), 2, "a parameter change must open a segment");
1599 assert_eq!(segments[0].steps, 5);
1600 assert_eq!(segments[1].steps, 1);
1601
1602 let epsilon_after = match optimizer.consumed_epsilon() {
1603 Ok(value) => value,
1604 Err(err) => panic!("accounting failed: {err}"),
1605 };
1606 assert!(
1607 epsilon_after > epsilon_before,
1608 "already-spent privacy must never be reduced by a configuration change"
1609 );
1610 }
1611
1612 #[test]
1613 fn test_privacy_target_cannot_be_changed_mid_training() {
1614 let mut optimizer = build(test_config());
1615 let relaxed = DifferentialPrivacyConfig {
1616 target_epsilon: 100.0,
1617 ..test_config()
1618 };
1619 assert!(optimizer.update_privacy_config(relaxed).is_err());
1620
1621 let tightened = DifferentialPrivacyConfig {
1622 target_delta: 1e-9,
1623 ..test_config()
1624 };
1625 assert!(optimizer.update_privacy_config(tightened).is_err());
1626
1627 let operational = DifferentialPrivacyConfig {
1628 noise_multiplier: 2.0,
1629 ..test_config()
1630 };
1631 assert!(optimizer.update_privacy_config(operational).is_ok());
1632 }
1633
1634 #[test]
1635 fn test_unimplemented_mechanisms_and_methods_error() {
1636 for mechanism in [
1637 NoiseMechanism::TreeAggregation,
1638 NoiseMechanism::ImprovedComposition,
1639 ] {
1640 let mut optimizer = build(DifferentialPrivacyConfig {
1641 noise_mechanism: mechanism,
1642 ..test_config()
1643 });
1644 let params = Array1::<f64>::zeros(2);
1645 let batch = vec![Array1::from_elem(2, 0.1)];
1646 assert!(optimizer.dp_step_per_example(¶ms, &batch).is_err());
1647 }
1648
1649 for method in [
1650 AccountingMethod::AdvancedComposition,
1651 AccountingMethod::ZCDP,
1652 ] {
1653 let result = DPSGDOptimizer::<SGD<f64>, f64, Ix1>::new(
1654 SGD::new(0.01),
1655 DifferentialPrivacyConfig {
1656 accounting_method: method,
1657 ..test_config()
1658 },
1659 );
1660 assert!(result.is_err(), "{method:?} must not be silently accepted");
1661 }
1662 }
1663
1664 #[test]
1665 fn test_non_finite_gradients_are_rejected() {
1666 let mut optimizer = build(test_config());
1667 let params = Array1::<f64>::zeros(2);
1668 let batch = vec![Array1::from_vec(vec![f64::NAN, 1.0])];
1669 assert!(optimizer.dp_step_per_example(¶ms, &batch).is_err());
1670 }
1671
1672 #[test]
1673 fn test_adaptive_clipping_state() {
1674 let state = AdaptiveClippingState::new(1.0, 0.1, 0.5);
1675 assert!(state.is_ok());
1676 let state = match state {
1677 Ok(state) => state,
1678 Err(err) => panic!("construction failed: {err}"),
1679 };
1680 assert_eq!(state.current_threshold, 1.0);
1681 assert_eq!(state.adaptationlr, 0.1);
1682
1683 assert!(AdaptiveClippingState::new(0.0, 0.1, 0.5).is_err());
1684 assert!(AdaptiveClippingState::new(1.0, -1.0, 0.5).is_err());
1685 assert!(AdaptiveClippingState::new(1.0, 0.1, 2.0).is_err());
1686 }
1687
1688 #[test]
1689 fn test_adaptive_threshold_moves_towards_the_target_quantile() {
1690 let mut state = match AdaptiveClippingState::new(1.0, 0.5, 0.5) {
1691 Ok(state) => state,
1692 Err(err) => panic!("construction failed: {err}"),
1693 };
1694
1695 state.update_threshold(0.0);
1697 assert!(state.current_threshold > 1.0);
1698
1699 let raised = state.current_threshold;
1701 state.update_threshold(1.0);
1702 assert!(state.current_threshold < raised);
1703 assert_eq!(state.updates, 2);
1704 }
1705
1706 #[test]
1707 fn test_privacy_budget_tracker() {
1708 let config = DifferentialPrivacyConfig::default();
1709 let tracker = PrivacyBudgetTracker::new(&config);
1710 assert_eq!(tracker.target_epsilon, config.target_epsilon);
1711 assert_eq!(tracker.epsilon_consumed, 0.0);
1712 }
1713
1714 #[test]
1715 fn test_p2_quantile_estimator_tracks_the_median() {
1716 let mut estimator = QuantileEstimator::new(0.5);
1717 for i in 1..=1000 {
1718 estimator.update(i as f64);
1719 }
1720 let estimate = estimator.estimate();
1721 assert!(
1722 (estimate - 500.0).abs() < 60.0,
1723 "P-squared median estimate {estimate} is far from 500"
1724 );
1725 }
1726
1727 #[test]
1728 fn test_p2_quantile_estimator_honours_the_requested_quantile() {
1729 let mut p90 = QuantileEstimator::new(0.9);
1730 let mut p10 = QuantileEstimator::new(0.1);
1731 for i in 1..=1000 {
1732 p90.update(i as f64);
1733 p10.update(i as f64);
1734 }
1735 let high = p90.estimate();
1736 let low = p10.estimate();
1737 assert!(
1738 high > low,
1739 "the 0.9 quantile ({high}) must exceed the 0.1 quantile ({low})"
1740 );
1741 assert!((high - 900.0).abs() < 120.0, "p90 estimate was {high}");
1742 assert!((low - 100.0).abs() < 120.0, "p10 estimate was {low}");
1743 }
1744
1745 #[test]
1746 fn test_p2_estimator_does_not_freeze_after_five_samples() {
1747 let mut estimator = QuantileEstimator::new(0.5);
1748 for i in 1..=5 {
1749 estimator.update(i as f64);
1750 }
1751 let early = estimator.estimate();
1752 for i in 6..=500 {
1753 estimator.update(i as f64);
1754 }
1755 let late = estimator.estimate();
1756 assert!(
1757 late > early,
1758 "the estimator must keep updating past five samples: {early} -> {late}"
1759 );
1760 }
1761
1762 #[test]
1763 fn test_p2_estimator_ignores_non_finite_input() {
1764 let mut estimator = QuantileEstimator::new(0.5);
1765 for i in 1..=10 {
1766 estimator.update(i as f64);
1767 }
1768 let before = estimator.estimate();
1769 estimator.update(f64::NAN);
1770 estimator.update(f64::INFINITY);
1771 assert_eq!(estimator.estimate(), before);
1772 }
1773
1774 #[test]
1775 fn test_gradient_statistics() {
1776 let mut stats = GradientStatistics::<f64>::new();
1777 assert!(stats.update_norm(1.0).is_ok());
1778 assert!(stats.update_norm(2.0).is_ok());
1779 assert!(stats.update_norm(3.0).is_ok());
1780
1781 assert_eq!(stats.avg_norm, 2.0);
1782 assert!(stats.std_norm > 0.0);
1783 assert!(stats.percentiles.contains_key("p50"));
1784 }
1785
1786 #[test]
1787 fn test_clipping_frequency_ema_moves_in_both_directions() {
1788 let mut stats = GradientStatistics::<f64>::new();
1789 for _ in 0..500 {
1790 stats.update_clipping(true);
1791 }
1792 let after_clipping = stats.clipping_frequency;
1793 assert!(after_clipping > 0.9);
1794
1795 for _ in 0..500 {
1796 stats.update_clipping(false);
1797 }
1798 assert!(
1799 stats.clipping_frequency < after_clipping,
1800 "the EMA must fall when clipping stops: {after_clipping} -> {}",
1801 stats.clipping_frequency
1802 );
1803 }
1804}