use crate::error::{OptimError, Result};
use scirs2_core::ndarray::{Array, ArrayBase, Data, DataMut, Dimension, ScalarOperand, Zip};
use scirs2_core::numeric::Float;
use scirs2_core::random::thread_rng;
use std::collections::VecDeque;
use std::fmt::Debug;
pub mod accountant;
pub mod byzantine_tolerance;
pub mod differential_privacy; pub mod dp_sgd;
pub mod enhanced_audit;
pub mod federated; pub mod federated_privacy;
pub mod moment_accountant;
pub mod noise_mechanisms;
pub mod private_hyperparameter_optimization;
pub mod renyi_accountant;
pub mod secure_aggregation;
pub mod secure_multiparty;
pub mod utility_analysis;
use crate::optimizers::Optimizer;
pub use utility_analysis::{
AnalysisConfig, AnalysisMetadata, BudgetRecommendations, OptimalConfiguration, ParetoPoint,
PrivacyConfiguration, PrivacyParameterSpace, PrivacyRiskAssessment, PrivacyUtilityAnalyzer,
PrivacyUtilityResults, RobustnessResults, SensitivityResults, StatisticalTestResults,
UtilityMetric,
};
pub use federated::{
ByzantineRobustAggregator, ByzantineRobustConfig, ByzantineRobustMethod, ClientComposition,
CompositionStats, CrossDeviceConfig, CrossDevicePrivacyManager, DeviceProfile, DeviceType,
FederatedCompositionAnalyzer, FederatedCompositionMethod, OutlierDetectionResult,
ReputationSystemConfig, RoundComposition, SecureAggregationConfig, SecureAggregationPlan,
SecureAggregator, SeedSharingMethod, StatisticalTestConfig, StatisticalTestType, TemporalEvent,
TemporalEventType,
};
pub use differential_privacy::{
AmplificationConfig, AmplificationStats, PrivacyAmplificationAnalyzer, SubsamplingEvent,
};
pub use renyi_accountant::{DpConversion, RdpSpend, RenyiAccountant};
pub use accountant::{
build_accountant, AccountingSegment, MomentsPrivacyAccountant, PrivacyAccountant,
PrivacyLedger, RenyiPrivacyAccountant,
};
pub use moment_accountant::MomentsAccountant;
#[derive(Debug, Clone)]
pub struct DifferentialPrivacyConfig {
pub target_epsilon: f64,
pub target_delta: f64,
pub noise_multiplier: f64,
pub l2_norm_clip: f64,
pub batch_size: usize,
pub dataset_size: usize,
pub max_steps: usize,
pub noise_mechanism: NoiseMechanism,
pub secure_aggregation: bool,
pub adaptive_clipping: bool,
pub adaptive_clip_init: f64,
pub adaptive_clip_lr: f64,
pub adaptive_clip_target_quantile: f64,
pub adaptive_clip_noise_multiplier: f64,
pub accounting_method: AccountingMethod,
pub acknowledge_aggregate_clipping: bool,
}
impl Default for DifferentialPrivacyConfig {
fn default() -> Self {
Self {
target_epsilon: 1.0,
target_delta: 1e-5,
noise_multiplier: 1.1,
l2_norm_clip: 1.0,
batch_size: 256,
dataset_size: 50000,
max_steps: 1000,
noise_mechanism: NoiseMechanism::Gaussian,
secure_aggregation: false,
adaptive_clipping: false,
adaptive_clip_init: 1.0,
adaptive_clip_lr: 0.2,
adaptive_clip_target_quantile: 0.5,
adaptive_clip_noise_multiplier: 1.0,
accounting_method: AccountingMethod::RenyiDP,
acknowledge_aggregate_clipping: false,
}
}
}
impl DifferentialPrivacyConfig {
pub fn validate(&self) -> Result<()> {
if !self.target_epsilon.is_finite() || self.target_epsilon <= 0.0 {
return Err(OptimError::InvalidPrivacyConfig(format!(
"target_epsilon must be a positive finite number, got {}",
self.target_epsilon
)));
}
if !self.target_delta.is_finite() || self.target_delta <= 0.0 || self.target_delta >= 1.0 {
return Err(OptimError::InvalidPrivacyConfig(format!(
"target_delta must be in (0, 1), got {}",
self.target_delta
)));
}
if !self.noise_multiplier.is_finite() || self.noise_multiplier <= 0.0 {
return Err(OptimError::InvalidPrivacyConfig(format!(
"noise_multiplier must be a positive finite number, got {}",
self.noise_multiplier
)));
}
if !self.l2_norm_clip.is_finite() || self.l2_norm_clip <= 0.0 {
return Err(OptimError::InvalidPrivacyConfig(format!(
"l2_norm_clip must be a positive finite number, got {}",
self.l2_norm_clip
)));
}
if self.batch_size == 0 || self.dataset_size == 0 {
return Err(OptimError::InvalidPrivacyConfig(
"batch_size and dataset_size must be positive".to_string(),
));
}
if self.batch_size > self.dataset_size {
return Err(OptimError::InvalidPrivacyConfig(
"batch_size cannot exceed dataset_size".to_string(),
));
}
if self.max_steps == 0 {
return Err(OptimError::InvalidPrivacyConfig(
"max_steps must be positive".to_string(),
));
}
if self.adaptive_clipping {
if !self.adaptive_clip_init.is_finite() || self.adaptive_clip_init <= 0.0 {
return Err(OptimError::InvalidPrivacyConfig(format!(
"adaptive_clip_init must be a positive finite number, got {}",
self.adaptive_clip_init
)));
}
if !self.adaptive_clip_lr.is_finite() || self.adaptive_clip_lr <= 0.0 {
return Err(OptimError::InvalidPrivacyConfig(format!(
"adaptive_clip_lr must be a positive finite number, got {}",
self.adaptive_clip_lr
)));
}
if !(0.0..=1.0).contains(&self.adaptive_clip_target_quantile) {
return Err(OptimError::InvalidPrivacyConfig(format!(
"adaptive_clip_target_quantile must be in [0, 1], got {}",
self.adaptive_clip_target_quantile
)));
}
if !self.adaptive_clip_noise_multiplier.is_finite()
|| self.adaptive_clip_noise_multiplier <= 0.0
{
return Err(OptimError::InvalidPrivacyConfig(format!(
"adaptive_clip_noise_multiplier must be positive and finite, got {}",
self.adaptive_clip_noise_multiplier
)));
}
}
Ok(())
}
pub fn sampling_probability(&self) -> f64 {
if self.dataset_size == 0 {
0.0
} else {
(self.batch_size as f64 / self.dataset_size as f64).min(1.0)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NoiseMechanism {
Gaussian,
Laplace,
TreeAggregation,
ImprovedComposition,
}
#[derive(Debug, Clone)]
pub struct PrivacyBudget {
pub epsilon_consumed: f64,
pub delta_consumed: f64,
pub epsilon_remaining: f64,
pub delta_remaining: f64,
pub steps_taken: usize,
pub accounting_method: AccountingMethod,
pub estimated_steps_remaining: usize,
}
impl Default for PrivacyBudget {
fn default() -> Self {
Self {
epsilon_consumed: 0.0,
delta_consumed: 0.0,
epsilon_remaining: 1.0,
delta_remaining: 1e-5,
steps_taken: 0,
accounting_method: AccountingMethod::RenyiDP,
estimated_steps_remaining: 1000,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountingMethod {
MomentsAccountant,
RenyiDP,
AdvancedComposition,
ZCDP,
}
pub struct DifferentiallyPrivateOptimizer<O, A, D>
where
A: Float + ScalarOperand + Debug + Send + Sync,
D: Dimension,
O: Optimizer<A, D>,
{
base_optimizer: O,
config: DifferentialPrivacyConfig,
accountant: Box<dyn PrivacyAccountant>,
pure_epsilon_spent: f64,
rng: scirs2_core::random::CoreRandom,
adaptive_clip_state: Option<AdaptiveClippingState>,
gradient_history: VecDeque<GradientNorms>,
audit_trail: Vec<PrivacyEvent>,
step_count: usize,
_phantom: std::marker::PhantomData<(A, D)>,
}
#[derive(Debug, Clone)]
struct AdaptiveClippingState {
current_threshold: f64,
target_quantile: f64,
learning_rate: f64,
last_fraction_estimate: f64,
updates: usize,
}
#[derive(Debug, Clone)]
struct GradientNorms {
#[allow(dead_code)]
step: usize,
pre_clip_norm: f64,
#[allow(dead_code)]
post_clip_norm: f64,
clipping_ratio: f64,
clipped: bool,
}
#[derive(Debug, Clone)]
pub struct PrivacyEvent {
pub step: usize,
pub event_type: PrivacyEventType,
pub epsilon_spent: f64,
pub reporting_delta: f64,
pub noise_scale: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrivacyEventType {
GradientRelease,
ModelUpdate,
ParameterQuery,
AdaptiveClipUpdate,
}
impl<O, A, D> DifferentiallyPrivateOptimizer<O, A, D>
where
A: Float
+ std::ops::AddAssign
+ std::ops::SubAssign
+ Send
+ Sync
+ scirs2_core::ndarray::ScalarOperand
+ std::fmt::Debug,
D: Dimension,
O: Optimizer<A, D>,
{
pub fn new(baseoptimizer: O, config: DifferentialPrivacyConfig) -> Result<Self> {
config.validate()?;
if config.secure_aggregation {
return Err(OptimError::InvalidPrivacyConfig(
"secure_aggregation is not wired into DifferentiallyPrivateOptimizer; use \
privacy::secure_aggregation::SecureAggregator explicitly instead of enabling \
this flag, which would otherwise train with plaintext aggregation"
.to_string(),
));
}
let accountant = build_accountant(
config.accounting_method,
config.noise_multiplier,
config.target_delta,
config.batch_size,
config.dataset_size,
)?;
let rng = thread_rng();
let adaptive_clip_state = if config.adaptive_clipping {
Some(AdaptiveClippingState {
current_threshold: config.adaptive_clip_init,
target_quantile: config.adaptive_clip_target_quantile,
learning_rate: config.adaptive_clip_lr,
last_fraction_estimate: config.adaptive_clip_target_quantile,
updates: 0,
})
} else {
None
};
Ok(Self {
base_optimizer: baseoptimizer,
config,
accountant,
pure_epsilon_spent: 0.0,
rng,
adaptive_clip_state,
gradient_history: VecDeque::with_capacity(1000),
audit_trail: Vec::new(),
step_count: 0,
_phantom: std::marker::PhantomData,
})
}
pub fn dp_step_per_example(
&mut self,
params: &Array<A, D>,
per_example_gradients: &[Array<A, D>],
) -> Result<Array<A, D>> {
if per_example_gradients.is_empty() {
return Err(OptimError::InvalidConfig(
"dp_step_per_example requires at least one per-example gradient".to_string(),
));
}
for gradient in per_example_gradients {
if gradient.raw_dim() != params.raw_dim() {
return Err(OptimError::DimensionMismatch(format!(
"per-example gradient shape {:?} does not match parameter shape {:?}",
gradient.shape(),
params.shape()
)));
}
}
let batch_size = per_example_gradients.len();
self.begin_step(batch_size)?;
let clip_threshold = self.get_clipping_threshold();
let mut summed: Array<A, D> = Array::zeros(params.raw_dim());
let mut pre_clip_norm_sum = 0.0;
let mut clipped_count = 0usize;
let mut below_threshold = 0usize;
for gradient in per_example_gradients {
let norm = self.compute_l2_norm(gradient);
if !norm.is_finite() {
return Err(OptimError::InvalidConfig(
"per-example gradient contains a non-finite value; clipping cannot bound \
its sensitivity"
.to_string(),
));
}
pre_clip_norm_sum += norm;
let scale = if norm > clip_threshold && norm > 0.0 {
clipped_count += 1;
clip_threshold / norm
} else {
below_threshold += 1;
1.0
};
let scale_a = A::from(scale).ok_or_else(|| {
OptimError::InvalidConfig("failed to convert clipping scale".to_string())
})?;
Zip::from(&mut summed)
.and(gradient)
.for_each(|acc, &g| *acc += g * scale_a);
}
let noise_scale = self.config.noise_multiplier * clip_threshold;
self.add_mechanism_noise(&mut summed, noise_scale, clip_threshold)?;
let batch_a = A::from(batch_size as f64)
.ok_or_else(|| OptimError::InvalidConfig("failed to convert batch size".to_string()))?;
summed.mapv_inplace(|x| x / batch_a);
self.commit_step(batch_size, noise_scale, PrivacyEventType::GradientRelease)?;
let mean_pre_clip = pre_clip_norm_sum / batch_size as f64;
self.record_gradient_stats(mean_pre_clip, clip_threshold, clipped_count, batch_size);
if self.config.adaptive_clipping {
self.update_adaptive_clipping(below_threshold, batch_size)?;
}
self.base_optimizer.step(params, &summed)
}
pub fn dp_step_presummed(
&mut self,
params: &Array<A, D>,
summed_clipped_gradients: &Array<A, D>,
batch_size: usize,
) -> Result<Array<A, D>> {
if batch_size == 0 {
return Err(OptimError::InvalidConfig(
"batch_size must be positive".to_string(),
));
}
if summed_clipped_gradients.raw_dim() != params.raw_dim() {
return Err(OptimError::DimensionMismatch(format!(
"gradient shape {:?} does not match parameter shape {:?}",
summed_clipped_gradients.shape(),
params.shape()
)));
}
self.begin_step(batch_size)?;
let clip_threshold = self.get_clipping_threshold();
let mut summed = summed_clipped_gradients.clone();
let noise_scale = self.config.noise_multiplier * clip_threshold;
self.add_mechanism_noise(&mut summed, noise_scale, clip_threshold)?;
let batch_a = A::from(batch_size as f64)
.ok_or_else(|| OptimError::InvalidConfig("failed to convert batch size".to_string()))?;
summed.mapv_inplace(|x| x / batch_a);
self.commit_step(batch_size, noise_scale, PrivacyEventType::GradientRelease)?;
let norm = self.compute_l2_norm(summed_clipped_gradients) / batch_size as f64;
self.record_gradient_stats(norm, clip_threshold, 0, batch_size);
self.base_optimizer.step(params, &summed)
}
pub fn dp_step(
&mut self,
params: &Array<A, D>,
gradients: &mut Array<A, D>,
) -> Result<Array<A, D>> {
if !self.config.acknowledge_aggregate_clipping {
return Err(OptimError::InvalidPrivacyConfig(
"dp_step clips an already-aggregated gradient and therefore does NOT provide \
per-example differential privacy. Use dp_step_per_example (or \
dp_step_presummed), or set acknowledge_aggregate_clipping = true to accept \
batch-level adjacency semantics"
.to_string(),
));
}
self.begin_step(self.config.dataset_size)?;
let pre_clip_norm = self.compute_l2_norm(gradients);
if !pre_clip_norm.is_finite() {
return Err(OptimError::InvalidConfig(
"gradient contains a non-finite value; clipping cannot bound its sensitivity"
.to_string(),
));
}
let clip_threshold = self.get_clipping_threshold();
let (clipping_ratio, clipped) = if pre_clip_norm > clip_threshold && pre_clip_norm > 0.0 {
let scale = clip_threshold / pre_clip_norm;
let scale_a = A::from(scale).ok_or_else(|| {
OptimError::InvalidConfig("failed to convert clipping scale".to_string())
})?;
gradients.mapv_inplace(|g| g * scale_a);
(scale, true)
} else {
(1.0, false)
};
let noise_scale = self.config.noise_multiplier * clip_threshold;
self.add_mechanism_noise(gradients, noise_scale, clip_threshold)?;
self.commit_step(
self.config.dataset_size,
noise_scale,
PrivacyEventType::GradientRelease,
)?;
let post_clip_norm = self.compute_l2_norm(gradients);
self.gradient_history.push_back(GradientNorms {
step: self.step_count,
pre_clip_norm,
post_clip_norm,
clipping_ratio,
clipped,
});
if self.gradient_history.len() > 1000 {
self.gradient_history.pop_front();
}
self.base_optimizer.step(params, gradients)
}
fn begin_step(&mut self, batch_size: usize) -> Result<()> {
if self.step_count >= self.config.max_steps {
return Err(OptimError::PrivacyBudgetExhausted {
consumed_epsilon: self.consumed_epsilon()?,
target_epsilon: self.config.target_epsilon,
});
}
let projected = self.projected_epsilon(batch_size)?;
if projected > self.config.target_epsilon {
return Err(OptimError::PrivacyBudgetExhausted {
consumed_epsilon: self.consumed_epsilon()?,
target_epsilon: self.config.target_epsilon,
});
}
Ok(())
}
fn commit_step(
&mut self,
batch_size: usize,
noise_scale: f64,
event_type: PrivacyEventType,
) -> Result<()> {
self.step_count += 1;
match self.config.noise_mechanism {
NoiseMechanism::Gaussian => {
let q = self.sampling_probability(batch_size);
self.accountant
.compose_subsampled_gaussian(self.config.noise_multiplier, q, 1)?;
}
NoiseMechanism::Laplace => {
self.pure_epsilon_spent += self.laplace_epsilon_per_step();
}
other => {
return Err(OptimError::InvalidPrivacyConfig(format!(
"noise mechanism {other:?} is not implemented for this optimizer"
)));
}
}
let epsilon_spent = self.consumed_epsilon()?;
self.audit_trail.push(PrivacyEvent {
step: self.step_count,
event_type,
epsilon_spent,
reporting_delta: self.reporting_delta(),
noise_scale,
});
Ok(())
}
fn record_gradient_stats(
&mut self,
mean_pre_clip_norm: f64,
clip_threshold: f64,
clipped_count: usize,
batch_size: usize,
) {
let clipping_ratio = if mean_pre_clip_norm > clip_threshold && mean_pre_clip_norm > 0.0 {
clip_threshold / mean_pre_clip_norm
} else {
1.0
};
self.gradient_history.push_back(GradientNorms {
step: self.step_count,
pre_clip_norm: mean_pre_clip_norm,
post_clip_norm: mean_pre_clip_norm.min(clip_threshold),
clipping_ratio,
clipped: clipped_count * 2 > batch_size,
});
if self.gradient_history.len() > 1000 {
self.gradient_history.pop_front();
}
}
fn update_adaptive_clipping(
&mut self,
below_threshold: usize,
batch_size: usize,
) -> Result<()> {
if batch_size == 0 {
return Ok(());
}
let sigma_count = self.config.adaptive_clip_noise_multiplier;
let noise = sigma_count * standard_normal(&mut self.rng);
let noisy_below = below_threshold as f64 + noise;
let fraction = (noisy_below / batch_size as f64).clamp(0.0, 1.0);
let (target, learning_rate) = match self.adaptive_clip_state {
Some(ref state) => (state.target_quantile, state.learning_rate),
None => return Ok(()),
};
let factor = (-learning_rate * (fraction - target)).exp();
let updated = if let Some(ref mut state) = self.adaptive_clip_state {
state.last_fraction_estimate = fraction;
state.updates += 1;
state.current_threshold = (state.current_threshold * factor).clamp(1e-6, 1e6);
state.current_threshold
} else {
return Ok(());
};
match self.config.noise_mechanism {
NoiseMechanism::Gaussian => {
let q = self.sampling_probability(batch_size);
self.accountant
.compose_subsampled_gaussian(sigma_count, q, 1)?;
}
NoiseMechanism::Laplace => {
self.pure_epsilon_spent += self.laplace_epsilon_per_step();
}
other => {
return Err(OptimError::InvalidPrivacyConfig(format!(
"noise mechanism {other:?} is not implemented for this optimizer"
)));
}
}
let epsilon_spent = self.consumed_epsilon()?;
self.audit_trail.push(PrivacyEvent {
step: self.step_count,
event_type: PrivacyEventType::AdaptiveClipUpdate,
epsilon_spent,
reporting_delta: self.reporting_delta(),
noise_scale: sigma_count,
});
debug_assert!(updated > 0.0);
Ok(())
}
fn sampling_probability(&self, batch_size: usize) -> f64 {
if self.config.dataset_size == 0 {
0.0
} else {
(batch_size as f64 / self.config.dataset_size as f64).min(1.0)
}
}
fn laplace_epsilon_per_step(&self) -> f64 {
self.config.target_epsilon / self.config.max_steps.max(1) as f64
}
fn reporting_delta(&self) -> f64 {
match self.config.noise_mechanism {
NoiseMechanism::Laplace => 0.0,
_ => self.config.target_delta,
}
}
pub fn consumed_epsilon(&self) -> Result<f64> {
match self.config.noise_mechanism {
NoiseMechanism::Laplace => Ok(self.pure_epsilon_spent),
_ => {
let (epsilon, _) = self.accountant.privacy_spent(self.config.target_delta)?;
Ok(epsilon)
}
}
}
fn projected_epsilon(&self, batch_size: usize) -> Result<f64> {
match self.config.noise_mechanism {
NoiseMechanism::Laplace => {
Ok(self.pure_epsilon_spent + self.laplace_epsilon_per_step())
}
NoiseMechanism::Gaussian => {
let q = self.sampling_probability(batch_size);
let (epsilon, _) = self.accountant.projected_privacy_spent(
self.config.noise_multiplier,
q,
1,
self.config.target_delta,
)?;
Ok(epsilon)
}
other => Err(OptimError::InvalidPrivacyConfig(format!(
"noise mechanism {other:?} is not implemented for this optimizer"
))),
}
}
pub fn has_privacy_budget(&self) -> Result<bool> {
if self.step_count >= self.config.max_steps {
return Ok(false);
}
let projected = self.projected_epsilon(self.config.batch_size)?;
Ok(projected <= self.config.target_epsilon)
}
pub fn get_privacy_budget(&self) -> Result<PrivacyBudget> {
let epsilon_consumed = self.consumed_epsilon()?;
let epsilon_remaining = (self.config.target_epsilon - epsilon_consumed).max(0.0);
let epsilon_per_step = if self.step_count > 0 && epsilon_consumed.is_finite() {
epsilon_consumed / self.step_count as f64
} else {
0.0
};
let estimated_steps_remaining = if epsilon_per_step > 0.0 {
let by_budget = (epsilon_remaining / epsilon_per_step) as usize;
by_budget.min(self.config.max_steps.saturating_sub(self.step_count))
} else {
self.config.max_steps.saturating_sub(self.step_count)
};
Ok(PrivacyBudget {
epsilon_consumed,
delta_consumed: 0.0,
epsilon_remaining,
delta_remaining: self.reporting_delta(),
steps_taken: self.step_count,
accounting_method: self.config.accounting_method,
estimated_steps_remaining,
})
}
pub fn accounting_segments(&self) -> &[AccountingSegment] {
self.accountant.segments()
}
pub fn config(&self) -> &DifferentialPrivacyConfig {
&self.config
}
fn compute_l2_norm<S, DIM>(&self, array: &ArrayBase<S, DIM>) -> f64
where
S: Data<Elem = A>,
DIM: Dimension,
{
array
.iter()
.map(|&x| {
let val = x.to_f64().unwrap_or(f64::NAN);
val * val
})
.sum::<f64>()
.sqrt()
}
pub fn get_clipping_threshold(&self) -> f64 {
if let Some(ref state) = self.adaptive_clip_state {
state.current_threshold
} else {
self.config.l2_norm_clip
}
}
fn add_mechanism_noise<S, DIM>(
&mut self,
gradients: &mut ArrayBase<S, DIM>,
gaussian_noise_scale: f64,
clip_threshold: f64,
) -> Result<()>
where
S: DataMut<Elem = A>,
DIM: Dimension,
{
match self.config.noise_mechanism {
NoiseMechanism::Gaussian => {
let sigma = gaussian_noise_scale;
let mut failed = false;
gradients.mapv_inplace(|g| {
let sample = standard_normal(&mut self.rng) * sigma;
match A::from(sample) {
Some(noise) => g + noise,
None => {
failed = true;
g
}
}
});
if failed {
return Err(OptimError::InvalidConfig(
"failed to convert Gaussian noise sample into the gradient element type"
.to_string(),
));
}
}
NoiseMechanism::Laplace => {
let dimension = gradients.len().max(1) as f64;
let l1_sensitivity = clip_threshold * dimension.sqrt();
let epsilon_step = self.laplace_epsilon_per_step();
if epsilon_step <= 0.0 {
return Err(OptimError::InvalidPrivacyConfig(
"Laplace mechanism requires a positive per-step epsilon".to_string(),
));
}
let scale = l1_sensitivity / epsilon_step;
let mut failed = false;
gradients.mapv_inplace(|g| {
let sample = standard_laplace(&mut self.rng) * scale;
match A::from(sample) {
Some(noise) => g + noise,
None => {
failed = true;
g
}
}
});
if failed {
return Err(OptimError::InvalidConfig(
"failed to convert Laplace noise sample into the gradient element type"
.to_string(),
));
}
}
other => {
return Err(OptimError::InvalidPrivacyConfig(format!(
"noise mechanism {other:?} is not implemented for this optimizer; use \
Gaussian or Laplace"
)));
}
}
Ok(())
}
pub fn get_clipping_stats(&self) -> ClippingStats {
if self.gradient_history.is_empty() {
return ClippingStats {
current_threshold: self.get_clipping_threshold(),
..ClippingStats::default()
};
}
let total_steps = self.gradient_history.len();
let clipped_steps = self
.gradient_history
.iter()
.filter(|stats| stats.clipped)
.count();
let avg_clipping_ratio: f64 = self
.gradient_history
.iter()
.map(|stats| stats.clipping_ratio)
.sum::<f64>()
/ total_steps as f64;
let avg_pre_clip_norm: f64 = self
.gradient_history
.iter()
.map(|stats| stats.pre_clip_norm)
.sum::<f64>()
/ total_steps as f64;
ClippingStats {
total_steps,
clipped_steps,
clipping_frequency: clipped_steps as f64 / total_steps as f64,
avg_clipping_ratio,
avg_pre_clip_norm,
current_threshold: self.get_clipping_threshold(),
}
}
pub fn get_audit_trail(&self) -> &[PrivacyEvent] {
&self.audit_trail
}
pub fn validate_privacy(&self) -> Result<PrivacyValidation> {
let budget = self.get_privacy_budget()?;
let clipping_stats = self.get_clipping_stats();
let mut warnings = Vec::new();
let mut is_valid = true;
if !budget.epsilon_consumed.is_finite() {
warnings.push(
"Privacy accounting saturated: the configured noise provides no usable guarantee"
.to_string(),
);
is_valid = false;
} else if budget.epsilon_consumed > self.config.target_epsilon {
warnings.push("Epsilon budget exceeded".to_string());
is_valid = false;
}
if clipping_stats.total_steps > 0 {
if clipping_stats.clipping_frequency < 0.1 {
warnings.push(
"Low clipping frequency may indicate sub-optimal privacy-utility tradeoff"
.to_string(),
);
}
if clipping_stats.clipping_frequency > 0.9 {
warnings.push("High clipping frequency may severely impact utility".to_string());
}
}
let recommendations = self.generate_recommendations(&budget, &clipping_stats);
Ok(PrivacyValidation {
is_valid,
budget,
clipping_stats,
warnings,
recommendations,
})
}
fn generate_recommendations(
&self,
budget: &PrivacyBudget,
clipping: &ClippingStats,
) -> Vec<String> {
let mut recommendations = Vec::new();
if clipping.total_steps > 0 {
if clipping.clipping_frequency > 0.8 {
recommendations.push("Consider increasing the clipping threshold".to_string());
}
if clipping.clipping_frequency < 0.2 {
recommendations.push("Consider decreasing the clipping threshold".to_string());
}
}
if budget.epsilon_remaining < budget.epsilon_consumed * 0.1 {
recommendations.push(
"Privacy budget nearly exhausted - increase the noise multiplier or stop training"
.to_string(),
);
}
recommendations
}
}
pub(crate) fn standard_normal(rng: &mut scirs2_core::random::CoreRandom) -> f64 {
let u1: f64 = 1.0 - rng.gen_range(0.0..1.0);
let u2: f64 = rng.gen_range(0.0..1.0);
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}
pub(crate) fn standard_laplace(rng: &mut scirs2_core::random::CoreRandom) -> f64 {
let raw: f64 = rng.gen_range(0.0..1.0);
let u = raw.clamp(f64::MIN_POSITIVE, 1.0 - f64::EPSILON);
if u < 0.5 {
(2.0 * u).ln()
} else {
-(2.0 * (1.0 - u)).ln()
}
}
#[derive(Debug, Clone)]
pub struct ClippingStats {
pub total_steps: usize,
pub clipped_steps: usize,
pub clipping_frequency: f64,
pub avg_clipping_ratio: f64,
pub avg_pre_clip_norm: f64,
pub current_threshold: f64,
}
impl Default for ClippingStats {
fn default() -> Self {
Self {
total_steps: 0,
clipped_steps: 0,
clipping_frequency: 0.0,
avg_clipping_ratio: 1.0,
avg_pre_clip_norm: 0.0,
current_threshold: 1.0,
}
}
}
#[derive(Debug, Clone)]
pub struct PrivacyValidation {
pub is_valid: bool,
pub budget: PrivacyBudget,
pub clipping_stats: ClippingStats,
pub warnings: Vec<String>,
pub recommendations: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::optimizers::SGD;
use scirs2_core::ndarray::{Array1, Ix1};
fn test_config() -> DifferentialPrivacyConfig {
DifferentialPrivacyConfig {
target_epsilon: 10.0,
target_delta: 1e-5,
noise_multiplier: 1.1,
l2_norm_clip: 1.0,
batch_size: 64,
dataset_size: 50_000,
max_steps: 1000,
..Default::default()
}
}
fn build_optimizer(
config: DifferentialPrivacyConfig,
) -> DifferentiallyPrivateOptimizer<SGD<f64>, f64, Ix1> {
match DifferentiallyPrivateOptimizer::new(SGD::new(0.01), config) {
Ok(optimizer) => optimizer,
Err(err) => panic!("optimizer construction failed: {err}"),
}
}
#[test]
fn test_dp_config_default() {
let config = DifferentialPrivacyConfig::default();
assert_eq!(config.target_epsilon, 1.0);
assert_eq!(config.noise_multiplier, 1.1);
assert!(matches!(config.noise_mechanism, NoiseMechanism::Gaussian));
assert!(matches!(
config.accounting_method,
AccountingMethod::RenyiDP
));
assert!(!config.acknowledge_aggregate_clipping);
assert!(config.validate().is_ok());
}
#[test]
fn test_config_validation_rejects_unusable_parameters() {
let mut config = DifferentialPrivacyConfig {
noise_multiplier: 0.0,
..Default::default()
};
assert!(config.validate().is_err());
config = DifferentialPrivacyConfig {
target_delta: 0.0,
..Default::default()
};
assert!(config.validate().is_err());
config = DifferentialPrivacyConfig {
batch_size: 100,
dataset_size: 10,
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_dp_optimizer_creation() {
let optimizer = DifferentiallyPrivateOptimizer::<_, f64, Ix1>::new(
SGD::new(0.01),
DifferentialPrivacyConfig::default(),
);
assert!(optimizer.is_ok());
}
#[test]
fn test_unimplemented_accounting_method_is_rejected() {
for method in [
AccountingMethod::AdvancedComposition,
AccountingMethod::ZCDP,
] {
let config = DifferentialPrivacyConfig {
accounting_method: method,
..Default::default()
};
let result =
DifferentiallyPrivateOptimizer::<SGD<f64>, f64, Ix1>::new(SGD::new(0.01), config);
assert!(result.is_err(), "{method:?} must not be silently accepted");
}
}
#[test]
fn test_privacy_budget_tracking_starts_empty() {
let optimizer = build_optimizer(test_config());
let budget = match optimizer.get_privacy_budget() {
Ok(budget) => budget,
Err(err) => panic!("budget query failed: {err}"),
};
assert_eq!(budget.epsilon_consumed, 0.0);
assert_eq!(budget.epsilon_remaining, 10.0);
assert_eq!(budget.steps_taken, 0);
assert_eq!(budget.delta_consumed, 0.0);
assert_eq!(budget.delta_remaining, 1e-5);
assert!(match optimizer.has_privacy_budget() {
Ok(available) => available,
Err(err) => panic!("budget check failed: {err}"),
});
}
#[test]
fn test_per_example_step_succeeds_and_spends_epsilon_monotonically() {
let mut optimizer = build_optimizer(test_config());
let params = Array1::<f64>::zeros(4);
let batch: Vec<Array1<f64>> = (0..8)
.map(|i| Array1::from_elem(4, 0.1 * (i as f64 + 1.0)))
.collect();
let mut previous = 0.0;
for step in 1..=25 {
let updated = optimizer.dp_step_per_example(¶ms, &batch);
assert!(updated.is_ok(), "step {step} failed: {updated:?}");
let epsilon = match optimizer.consumed_epsilon() {
Ok(value) => value,
Err(err) => panic!("accounting failed at step {step}: {err}"),
};
assert!(
epsilon > previous,
"epsilon must strictly increase: {previous} -> {epsilon} at step {step}"
);
previous = epsilon;
}
assert_eq!(optimizer.accounting_segments().len(), 1);
}
#[test]
fn test_per_example_clipping_bounds_the_summed_contribution() {
let config = DifferentialPrivacyConfig {
noise_multiplier: 0.001,
l2_norm_clip: 1.0,
target_epsilon: 1e9,
..test_config()
};
let mut optimizer = build_optimizer(config);
let params = Array1::<f64>::zeros(3);
let batch = vec![
Array1::from_vec(vec![1e6, 0.0, 0.0]),
Array1::from_vec(vec![0.0, 0.0, 0.0]),
Array1::from_vec(vec![0.0, 0.0, 0.0]),
Array1::from_vec(vec![0.0, 0.0, 0.0]),
];
let updated = match optimizer.dp_step_per_example(¶ms, &batch) {
Ok(updated) => updated,
Err(err) => panic!("step failed: {err}"),
};
let bound = 0.01 * (1.0 / 4.0) * 1.05;
assert!(
updated[0].abs() <= bound,
"clipping failed to bound the update: {} > {bound}",
updated[0].abs()
);
}
#[test]
fn test_budget_exhaustion_is_enforced_before_release() {
let config = DifferentialPrivacyConfig {
target_epsilon: 3.0,
noise_multiplier: 1.0,
batch_size: 64,
dataset_size: 640,
max_steps: 100_000,
..test_config()
};
let mut optimizer = build_optimizer(config);
let params = Array1::<f64>::zeros(2);
let batch: Vec<Array1<f64>> = (0..64).map(|_| Array1::from_elem(2, 0.5)).collect();
let mut steps = 0usize;
loop {
match optimizer.dp_step_per_example(¶ms, &batch) {
Ok(_) => {
steps += 1;
assert!(steps < 100_000, "budget was never enforced");
}
Err(OptimError::PrivacyBudgetExhausted {
consumed_epsilon,
target_epsilon,
}) => {
assert!(steps > 0, "the very first step must be allowed");
assert!(consumed_epsilon <= target_epsilon);
break;
}
Err(other) => panic!("unexpected error: {other}"),
}
}
assert!(match optimizer.has_privacy_budget() {
Ok(available) => !available,
Err(err) => panic!("budget check failed: {err}"),
});
let budget = match optimizer.get_privacy_budget() {
Ok(budget) => budget,
Err(err) => panic!("budget query failed: {err}"),
};
assert!(budget.epsilon_consumed <= 3.0 + 1e-12);
assert_eq!(budget.steps_taken, steps);
}
#[test]
fn test_max_steps_is_enforced() {
let config = DifferentialPrivacyConfig {
target_epsilon: 1e9,
max_steps: 3,
..test_config()
};
let mut optimizer = build_optimizer(config);
let params = Array1::<f64>::zeros(2);
let batch = vec![Array1::from_elem(2, 0.1)];
for _ in 0..3 {
assert!(optimizer.dp_step_per_example(¶ms, &batch).is_ok());
}
match optimizer.dp_step_per_example(¶ms, &batch) {
Err(OptimError::PrivacyBudgetExhausted { .. }) => {}
other => panic!("max_steps must be enforced, got {other:?}"),
}
}
#[test]
fn test_aggregate_step_requires_explicit_acknowledgement() {
let mut optimizer = build_optimizer(test_config());
let params = Array1::<f64>::zeros(2);
let mut gradients = Array1::from_elem(2, 0.5);
match optimizer.dp_step(¶ms, &mut gradients) {
Err(OptimError::InvalidPrivacyConfig(message)) => {
assert!(message.contains("per-example"));
}
other => panic!("aggregate clipping must be opt-in, got {other:?}"),
}
let config = DifferentialPrivacyConfig {
acknowledge_aggregate_clipping: true,
..test_config()
};
let mut acknowledged = build_optimizer(config);
assert!(acknowledged.dp_step(¶ms, &mut gradients).is_ok());
}
#[test]
fn test_unimplemented_noise_mechanisms_error() {
for mechanism in [
NoiseMechanism::TreeAggregation,
NoiseMechanism::ImprovedComposition,
] {
let config = DifferentialPrivacyConfig {
noise_mechanism: mechanism,
..test_config()
};
let mut optimizer = build_optimizer(config);
let params = Array1::<f64>::zeros(2);
let batch = vec![Array1::from_elem(2, 0.1)];
match optimizer.dp_step_per_example(¶ms, &batch) {
Err(OptimError::InvalidPrivacyConfig(_)) => {}
other => panic!("{mechanism:?} must not silently fall back: {other:?}"),
}
}
}
#[test]
fn test_laplace_path_uses_pure_epsilon_ledger() {
let config = DifferentialPrivacyConfig {
noise_mechanism: NoiseMechanism::Laplace,
target_epsilon: 1.0,
max_steps: 10,
..test_config()
};
let mut optimizer = build_optimizer(config);
let params = Array1::<f64>::zeros(2);
let batch = vec![Array1::from_elem(2, 0.1)];
for step in 1..=10 {
assert!(
optimizer.dp_step_per_example(¶ms, &batch).is_ok(),
"step {step} should fit in the pure-epsilon budget"
);
let epsilon = match optimizer.consumed_epsilon() {
Ok(value) => value,
Err(err) => panic!("accounting failed: {err}"),
};
assert!((epsilon - 0.1 * step as f64).abs() < 1e-12);
}
let budget = match optimizer.get_privacy_budget() {
Ok(budget) => budget,
Err(err) => panic!("budget query failed: {err}"),
};
assert_eq!(budget.delta_remaining, 0.0);
match optimizer.dp_step_per_example(¶ms, &batch) {
Err(OptimError::PrivacyBudgetExhausted { .. }) => {}
other => panic!("pure epsilon budget must be enforced, got {other:?}"),
}
}
#[test]
fn test_noise_is_actually_random_across_instances() {
let params = Array1::<f64>::zeros(64);
let batch = vec![Array1::<f64>::zeros(64)];
let mut first = build_optimizer(test_config());
let mut second = build_optimizer(test_config());
let a = match first.dp_step_per_example(¶ms, &batch) {
Ok(value) => value,
Err(err) => panic!("step failed: {err}"),
};
let b = match second.dp_step_per_example(¶ms, &batch) {
Ok(value) => value,
Err(err) => panic!("step failed: {err}"),
};
let identical = a
.iter()
.zip(b.iter())
.all(|(x, y)| (x - y).abs() < f64::EPSILON);
assert!(!identical, "two optimizers produced identical noise");
}
#[test]
fn test_adaptive_clipping_updates_threshold_and_charges_budget() {
let config = DifferentialPrivacyConfig {
adaptive_clipping: true,
adaptive_clip_init: 1.0,
adaptive_clip_lr: 0.5,
adaptive_clip_target_quantile: 0.5,
target_epsilon: 1e6,
..test_config()
};
let mut optimizer = build_optimizer(config);
let params = Array1::<f64>::zeros(2);
let batch: Vec<Array1<f64>> = (0..16).map(|_| Array1::from_elem(2, 100.0)).collect();
let before = optimizer.get_clipping_threshold();
let epsilon_before = match optimizer.consumed_epsilon() {
Ok(value) => value,
Err(err) => panic!("accounting failed: {err}"),
};
for _ in 0..5 {
assert!(optimizer.dp_step_per_example(¶ms, &batch).is_ok());
}
let after = optimizer.get_clipping_threshold();
let epsilon_after = match optimizer.consumed_epsilon() {
Ok(value) => value,
Err(err) => panic!("accounting failed: {err}"),
};
assert!(after > before, "threshold should grow: {before} -> {after}");
assert!(epsilon_after > epsilon_before);
assert!(optimizer
.get_audit_trail()
.iter()
.any(|event| event.event_type == PrivacyEventType::AdaptiveClipUpdate));
}
#[test]
fn test_secure_aggregation_flag_is_not_silently_ignored() {
let config = DifferentialPrivacyConfig {
secure_aggregation: true,
..test_config()
};
let result =
DifferentiallyPrivateOptimizer::<SGD<f64>, f64, Ix1>::new(SGD::new(0.01), config);
assert!(result.is_err(), "an unimplemented flag must not be ignored");
}
#[test]
fn test_dimension_mismatch_is_rejected() {
let mut optimizer = build_optimizer(test_config());
let params = Array1::<f64>::zeros(4);
let batch = vec![Array1::<f64>::zeros(3)];
assert!(optimizer.dp_step_per_example(¶ms, &batch).is_err());
assert!(optimizer.dp_step_per_example(¶ms, &[]).is_err());
}
#[test]
fn test_non_finite_gradients_are_rejected() {
let mut optimizer = build_optimizer(test_config());
let params = Array1::<f64>::zeros(2);
let batch = vec![Array1::from_vec(vec![f64::NAN, 0.0])];
assert!(optimizer.dp_step_per_example(¶ms, &batch).is_err());
}
#[test]
fn test_presummed_path_matches_per_example_accounting() {
let mut optimizer = build_optimizer(test_config());
let params = Array1::<f64>::zeros(2);
let summed = Array1::from_elem(2, 0.5);
assert!(optimizer.dp_step_presummed(¶ms, &summed, 8).is_ok());
assert_eq!(optimizer.accounting_segments().len(), 1);
assert_eq!(optimizer.accounting_segments()[0].steps, 1);
assert!(match optimizer.consumed_epsilon() {
Ok(value) => value > 0.0,
Err(err) => panic!("accounting failed: {err}"),
});
assert!(optimizer.dp_step_presummed(¶ms, &summed, 0).is_err());
}
#[test]
fn test_standard_normal_never_returns_non_finite() {
let mut rng = thread_rng();
for _ in 0..10_000 {
let sample = standard_normal(&mut rng);
assert!(sample.is_finite(), "Box-Muller produced {sample}");
let laplace = standard_laplace(&mut rng);
assert!(
laplace.is_finite(),
"inverse-CDF Laplace produced {laplace}"
);
}
}
}