Consistent formatting pass: line wrapping, import sorting, trailing whitespace removal, let-chain indentation, merged derive attributes, and unsafe block reformatting. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
611 lines
17 KiB
Rust
611 lines
17 KiB
Rust
//! Differential privacy mechanisms for federated learning.
|
|
//!
|
|
//! This module implements privacy-preserving mechanisms including gradient
|
|
//! clipping, noise addition, and privacy budget accounting.
|
|
|
|
use rand::Rng;
|
|
|
|
use fedmed_shared::{PrivacyAccountantType, PrivacyConfig};
|
|
|
|
/// Differential privacy mechanism for adding noise to gradients.
|
|
#[derive(Debug, Clone)]
|
|
pub struct DifferentialPrivacy {
|
|
/// Privacy configuration.
|
|
config: PrivacyConfig,
|
|
/// Random number generator seed (reserved for reproducibility).
|
|
#[allow(dead_code)]
|
|
seed: u64,
|
|
}
|
|
|
|
impl DifferentialPrivacy {
|
|
/// Create a new differential privacy mechanism.
|
|
#[must_use]
|
|
pub fn new(config: PrivacyConfig) -> Self {
|
|
Self { config, seed: 42 }
|
|
}
|
|
|
|
/// Add Gaussian noise to gradients for differential privacy.
|
|
#[must_use]
|
|
pub fn add_noise(&self, gradients: &[f32]) -> Vec<f32> {
|
|
let mut rng = rand::thread_rng();
|
|
let sigma = self.config.noise_multiplier as f32 * self.config.clip_norm as f32;
|
|
|
|
gradients
|
|
.iter()
|
|
.map(|g| {
|
|
// Box-Muller transform for Gaussian noise
|
|
let u1: f32 = rng.r#gen::<f32>();
|
|
let u2: f32 = rng.r#gen::<f32>();
|
|
let noise = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f32::consts::PI * u2).cos();
|
|
g + noise * sigma
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Add noise using local differential privacy (for client-side).
|
|
#[must_use]
|
|
pub fn add_local_noise(&self, gradients: &[f32]) -> Vec<f32> {
|
|
if !self.config.local_dp {
|
|
return gradients.to_vec();
|
|
}
|
|
|
|
let mut rng = rand::thread_rng();
|
|
// Local DP typically uses higher noise
|
|
let sigma = self.config.noise_multiplier as f32 * self.config.clip_norm as f32 * 2.0;
|
|
|
|
gradients
|
|
.iter()
|
|
.map(|g| {
|
|
let u1: f32 = rng.r#gen::<f32>();
|
|
let u2: f32 = rng.r#gen::<f32>();
|
|
let noise = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f32::consts::PI * u2).cos();
|
|
g + noise * sigma
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Get the noise standard deviation.
|
|
#[must_use]
|
|
pub fn noise_std(&self) -> f64 {
|
|
self.config.noise_multiplier * self.config.clip_norm
|
|
}
|
|
|
|
/// Get the privacy configuration.
|
|
#[must_use]
|
|
pub fn config(&self) -> &PrivacyConfig {
|
|
&self.config
|
|
}
|
|
|
|
/// Update the noise multiplier.
|
|
pub fn set_noise_multiplier(&mut self, multiplier: f64) {
|
|
self.config.noise_multiplier = multiplier;
|
|
}
|
|
}
|
|
|
|
/// Gradient clipper for bounding sensitivity.
|
|
#[derive(Debug, Clone)]
|
|
pub struct GradientClipper {
|
|
/// Maximum L2 norm for gradients.
|
|
clip_norm: f64,
|
|
/// Whether to use per-sample clipping (reserved for advanced use).
|
|
#[allow(dead_code)]
|
|
per_sample: bool,
|
|
}
|
|
|
|
impl Default for GradientClipper {
|
|
fn default() -> Self {
|
|
Self::new(1.0)
|
|
}
|
|
}
|
|
|
|
impl GradientClipper {
|
|
/// Create a new gradient clipper.
|
|
#[must_use]
|
|
pub fn new(clip_norm: f64) -> Self {
|
|
Self {
|
|
clip_norm,
|
|
per_sample: false,
|
|
}
|
|
}
|
|
|
|
/// Create a per-sample gradient clipper.
|
|
#[must_use]
|
|
pub fn per_sample(clip_norm: f64) -> Self {
|
|
Self {
|
|
clip_norm,
|
|
per_sample: true,
|
|
}
|
|
}
|
|
|
|
/// Clip gradients to bound their L2 norm.
|
|
#[must_use]
|
|
pub fn clip(&self, gradients: &[f32]) -> Vec<f32> {
|
|
let norm = self.l2_norm(gradients);
|
|
|
|
if norm <= self.clip_norm as f32 {
|
|
return gradients.to_vec();
|
|
}
|
|
|
|
let scale = self.clip_norm as f32 / norm;
|
|
gradients.iter().map(|g| g * scale).collect()
|
|
}
|
|
|
|
/// Clip gradients with adaptive clipping.
|
|
#[must_use]
|
|
pub fn adaptive_clip(&self, gradients: &[f32], quantile: f32) -> Vec<f32> {
|
|
// Compute per-coordinate absolute values
|
|
let mut abs_grads: Vec<f32> = gradients.iter().map(|g| g.abs()).collect();
|
|
abs_grads.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
|
|
|
// Find the quantile threshold
|
|
let idx = ((abs_grads.len() as f32 * quantile) as usize).min(abs_grads.len() - 1);
|
|
let threshold = abs_grads[idx];
|
|
|
|
// Clip to threshold
|
|
gradients
|
|
.iter()
|
|
.map(|g| g.clamp(-threshold, threshold))
|
|
.collect()
|
|
}
|
|
|
|
/// Compute L2 norm of gradients.
|
|
#[must_use]
|
|
pub fn l2_norm(&self, gradients: &[f32]) -> f32 {
|
|
gradients.iter().map(|g| g * g).sum::<f32>().sqrt()
|
|
}
|
|
|
|
/// Compute L1 norm of gradients.
|
|
#[must_use]
|
|
pub fn l1_norm(&self, gradients: &[f32]) -> f32 {
|
|
gradients.iter().map(|g| g.abs()).sum()
|
|
}
|
|
|
|
/// Compute infinity norm of gradients.
|
|
#[must_use]
|
|
pub fn linf_norm(&self, gradients: &[f32]) -> f32 {
|
|
gradients.iter().map(|g| g.abs()).fold(0.0_f32, f32::max)
|
|
}
|
|
|
|
/// Get the clip norm.
|
|
#[must_use]
|
|
pub fn clip_norm(&self) -> f64 {
|
|
self.clip_norm
|
|
}
|
|
|
|
/// Set the clip norm.
|
|
pub fn set_clip_norm(&mut self, clip_norm: f64) {
|
|
self.clip_norm = clip_norm;
|
|
}
|
|
}
|
|
|
|
/// Privacy accountant for tracking privacy budget.
|
|
#[derive(Debug, Clone)]
|
|
pub struct PrivacyAccountant {
|
|
/// Privacy configuration.
|
|
config: PrivacyConfig,
|
|
/// Total privacy spent (epsilon).
|
|
epsilon_spent: f64,
|
|
/// Number of composition steps.
|
|
num_steps: usize,
|
|
/// Sample rate for subsampling amplification.
|
|
sample_rate: f64,
|
|
/// Accountant type.
|
|
accountant_type: PrivacyAccountantType,
|
|
}
|
|
|
|
impl PrivacyAccountant {
|
|
/// Create a new privacy accountant.
|
|
#[must_use]
|
|
pub fn new(config: PrivacyConfig) -> Self {
|
|
Self {
|
|
accountant_type: config.accountant,
|
|
config,
|
|
epsilon_spent: 0.0,
|
|
num_steps: 0,
|
|
sample_rate: 1.0,
|
|
}
|
|
}
|
|
|
|
/// Create an accountant with a specific sample rate.
|
|
#[must_use]
|
|
pub fn with_sample_rate(config: PrivacyConfig, sample_rate: f64) -> Self {
|
|
Self {
|
|
accountant_type: config.accountant,
|
|
config,
|
|
epsilon_spent: 0.0,
|
|
num_steps: 0,
|
|
sample_rate: sample_rate.clamp(0.0, 1.0),
|
|
}
|
|
}
|
|
|
|
/// Record a privacy-consuming step and return the epsilon spent.
|
|
pub fn step(&mut self, num_clients: usize) -> f64 {
|
|
self.num_steps += 1;
|
|
|
|
// Compute epsilon for this step based on accountant type
|
|
let step_epsilon = match self.accountant_type {
|
|
PrivacyAccountantType::Moments => self.moments_epsilon(num_clients),
|
|
PrivacyAccountantType::RDP => self.rdp_epsilon(num_clients),
|
|
PrivacyAccountantType::GDP => self.gdp_epsilon(num_clients),
|
|
PrivacyAccountantType::PLD => self.pld_epsilon(num_clients),
|
|
};
|
|
|
|
self.epsilon_spent += step_epsilon;
|
|
step_epsilon
|
|
}
|
|
|
|
/// Compute epsilon using moments accountant.
|
|
fn moments_epsilon(&self, _num_clients: usize) -> f64 {
|
|
// Simplified moments accountant
|
|
let sigma = self.config.noise_multiplier;
|
|
let q = self.sample_rate;
|
|
|
|
// Basic composition: epsilon per step
|
|
if sigma > 0.0 {
|
|
q * (1.0 / sigma.powi(2))
|
|
} else {
|
|
f64::INFINITY
|
|
}
|
|
}
|
|
|
|
/// Compute epsilon using Renyi differential privacy.
|
|
fn rdp_epsilon(&self, _num_clients: usize) -> f64 {
|
|
// Simplified RDP accountant
|
|
let sigma = self.config.noise_multiplier;
|
|
let q = self.sample_rate;
|
|
let alpha = 2.0; // RDP order
|
|
|
|
if sigma > 0.0 {
|
|
// RDP bound with subsampling: q * alpha / (2 * sigma^2)
|
|
let rdp = q * alpha / (2.0 * sigma.powi(2));
|
|
// Convert to (epsilon, delta)-DP
|
|
let delta = self.config.delta;
|
|
rdp + (1.0 / delta).ln() / (alpha - 1.0)
|
|
} else {
|
|
f64::INFINITY
|
|
}
|
|
}
|
|
|
|
/// Compute epsilon using Gaussian differential privacy.
|
|
fn gdp_epsilon(&self, _num_clients: usize) -> f64 {
|
|
// Simplified GDP accountant
|
|
let sigma = self.config.noise_multiplier;
|
|
|
|
if sigma > 0.0 {
|
|
// Central limit theorem approximation
|
|
1.0 / sigma
|
|
} else {
|
|
f64::INFINITY
|
|
}
|
|
}
|
|
|
|
/// Compute epsilon using privacy loss distribution.
|
|
fn pld_epsilon(&self, _num_clients: usize) -> f64 {
|
|
// Simplified PLD accountant (falls back to RDP for simplicity)
|
|
self.rdp_epsilon(_num_clients)
|
|
}
|
|
|
|
/// Get total epsilon spent.
|
|
#[must_use]
|
|
pub fn epsilon_spent(&self) -> f64 {
|
|
self.epsilon_spent
|
|
}
|
|
|
|
/// Get number of steps.
|
|
#[must_use]
|
|
pub fn num_steps(&self) -> usize {
|
|
self.num_steps
|
|
}
|
|
|
|
/// Check if privacy budget is exhausted.
|
|
#[must_use]
|
|
pub fn is_budget_exhausted(&self) -> bool {
|
|
if let Some(target) = self.config.target_epsilon {
|
|
self.epsilon_spent >= target
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Get remaining privacy budget.
|
|
#[must_use]
|
|
pub fn remaining_budget(&self) -> Option<f64> {
|
|
self.config
|
|
.target_epsilon
|
|
.map(|target| (target - self.epsilon_spent).max(0.0))
|
|
}
|
|
|
|
/// Estimate number of remaining steps.
|
|
#[must_use]
|
|
pub fn estimate_remaining_steps(&self) -> Option<usize> {
|
|
if self.num_steps == 0 || self.epsilon_spent <= 0.0 {
|
|
return None;
|
|
}
|
|
|
|
let epsilon_per_step = self.epsilon_spent / self.num_steps as f64;
|
|
self.remaining_budget()
|
|
.map(|remaining| (remaining / epsilon_per_step) as usize)
|
|
}
|
|
|
|
/// Reset the accountant.
|
|
pub fn reset(&mut self) {
|
|
self.epsilon_spent = 0.0;
|
|
self.num_steps = 0;
|
|
}
|
|
|
|
/// Get the target epsilon.
|
|
#[must_use]
|
|
pub fn target_epsilon(&self) -> Option<f64> {
|
|
self.config.target_epsilon
|
|
}
|
|
|
|
/// Get delta.
|
|
#[must_use]
|
|
pub fn delta(&self) -> f64 {
|
|
self.config.delta
|
|
}
|
|
}
|
|
|
|
/// Secure noise generation for distributed settings.
|
|
#[derive(Debug)]
|
|
pub struct SecureNoiseGenerator {
|
|
/// Seed for reproducibility.
|
|
seed: u64,
|
|
/// Number of parties.
|
|
num_parties: usize,
|
|
}
|
|
|
|
impl SecureNoiseGenerator {
|
|
/// Create a new secure noise generator.
|
|
#[must_use]
|
|
pub fn new(seed: u64, num_parties: usize) -> Self {
|
|
Self { seed, num_parties }
|
|
}
|
|
|
|
/// Generate correlated noise that sums to Gaussian noise.
|
|
/// Each party generates their share independently.
|
|
#[must_use]
|
|
pub fn generate_noise_share(&self, party_id: usize, size: usize, sigma: f32) -> Vec<f32> {
|
|
use rand::SeedableRng;
|
|
let mut rng = rand::rngs::StdRng::seed_from_u64(self.seed.wrapping_add(party_id as u64));
|
|
|
|
(0..size)
|
|
.map(|_| {
|
|
let u1: f32 = rng.r#gen::<f32>();
|
|
let u2: f32 = rng.r#gen::<f32>();
|
|
let noise = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f32::consts::PI * u2).cos();
|
|
noise * sigma / (self.num_parties as f32).sqrt()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Combine noise shares from all parties.
|
|
#[must_use]
|
|
pub fn combine_shares(&self, shares: &[Vec<f32>]) -> Vec<f32> {
|
|
if shares.is_empty() {
|
|
return vec![];
|
|
}
|
|
|
|
let size = shares[0].len();
|
|
let mut combined = vec![0.0_f32; size];
|
|
|
|
for share in shares {
|
|
for (i, &s) in share.iter().enumerate() {
|
|
if i < combined.len() {
|
|
combined[i] += s;
|
|
}
|
|
}
|
|
}
|
|
|
|
combined
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn sample_config() -> PrivacyConfig {
|
|
PrivacyConfig {
|
|
epsilon: 8.0,
|
|
delta: 1e-5,
|
|
clip_norm: 1.0,
|
|
noise_multiplier: 1.1,
|
|
local_dp: false,
|
|
accountant: PrivacyAccountantType::RDP,
|
|
target_epsilon: Some(10.0),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_differential_privacy() {
|
|
let config = sample_config();
|
|
let dp = DifferentialPrivacy::new(config);
|
|
|
|
let gradients = vec![0.1, 0.2, 0.3, 0.4, 0.5];
|
|
let noisy = dp.add_noise(&gradients);
|
|
|
|
assert_eq!(noisy.len(), gradients.len());
|
|
// Noisy gradients should be different from original
|
|
assert!(
|
|
noisy
|
|
.iter()
|
|
.zip(gradients.iter())
|
|
.any(|(n, g)| (n - g).abs() > 0.0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_local_dp() {
|
|
let mut config = sample_config();
|
|
config.local_dp = true;
|
|
let dp = DifferentialPrivacy::new(config);
|
|
|
|
let gradients = vec![0.1, 0.2, 0.3];
|
|
let noisy = dp.add_local_noise(&gradients);
|
|
|
|
assert_eq!(noisy.len(), gradients.len());
|
|
}
|
|
|
|
#[test]
|
|
fn test_gradient_clipping() {
|
|
let clipper = GradientClipper::new(1.0);
|
|
|
|
// Large gradients should be clipped
|
|
let large_grads = vec![10.0, 10.0, 10.0];
|
|
let clipped = clipper.clip(&large_grads);
|
|
|
|
let clipped_norm = clipper.l2_norm(&clipped);
|
|
assert!(clipped_norm <= 1.01); // Allow small floating point error
|
|
|
|
// Small gradients should not be affected
|
|
let small_grads = vec![0.1, 0.1, 0.1];
|
|
let not_clipped = clipper.clip(&small_grads);
|
|
assert_eq!(not_clipped, small_grads);
|
|
}
|
|
|
|
#[test]
|
|
fn test_adaptive_clipping() {
|
|
let clipper = GradientClipper::new(1.0);
|
|
|
|
let gradients = vec![0.1, 0.5, 1.0, 2.0, 5.0];
|
|
let clipped = clipper.adaptive_clip(&gradients, 0.5);
|
|
|
|
// Median value should be the threshold
|
|
assert!(clipped.iter().all(|&g| g.abs() <= 1.01));
|
|
}
|
|
|
|
#[test]
|
|
fn test_norms() {
|
|
let clipper = GradientClipper::new(1.0);
|
|
let gradients = vec![3.0, 4.0];
|
|
|
|
assert!((clipper.l2_norm(&gradients) - 5.0).abs() < 0.001);
|
|
assert!((clipper.l1_norm(&gradients) - 7.0).abs() < 0.001);
|
|
assert!((clipper.linf_norm(&gradients) - 4.0).abs() < 0.001);
|
|
}
|
|
|
|
#[test]
|
|
fn test_privacy_accountant() {
|
|
let config = sample_config();
|
|
let mut accountant = PrivacyAccountant::new(config);
|
|
|
|
assert_eq!(accountant.epsilon_spent(), 0.0);
|
|
assert_eq!(accountant.num_steps(), 0);
|
|
|
|
let epsilon = accountant.step(5);
|
|
assert!(epsilon > 0.0);
|
|
assert_eq!(accountant.num_steps(), 1);
|
|
assert!(accountant.epsilon_spent() > 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_budget_exhaustion() {
|
|
let mut config = sample_config();
|
|
config.target_epsilon = Some(0.1);
|
|
config.noise_multiplier = 0.1; // Low noise = high epsilon per step
|
|
|
|
let mut accountant = PrivacyAccountant::new(config);
|
|
|
|
// Take many steps until budget is exhausted
|
|
for _ in 0..1000 {
|
|
accountant.step(5);
|
|
if accountant.is_budget_exhausted() {
|
|
break;
|
|
}
|
|
}
|
|
|
|
assert!(accountant.is_budget_exhausted());
|
|
assert!(accountant.remaining_budget().unwrap() <= 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_remaining_steps_estimate() {
|
|
let config = sample_config();
|
|
let mut accountant = PrivacyAccountant::new(config);
|
|
|
|
// Take some steps
|
|
for _ in 0..10 {
|
|
accountant.step(5);
|
|
}
|
|
|
|
let remaining = accountant.estimate_remaining_steps();
|
|
assert!(remaining.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_accountant_reset() {
|
|
let config = sample_config();
|
|
let mut accountant = PrivacyAccountant::new(config);
|
|
|
|
accountant.step(5);
|
|
assert!(accountant.epsilon_spent() > 0.0);
|
|
|
|
accountant.reset();
|
|
assert_eq!(accountant.epsilon_spent(), 0.0);
|
|
assert_eq!(accountant.num_steps(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_secure_noise_generator() {
|
|
let generator = SecureNoiseGenerator::new(42, 3);
|
|
|
|
let shares: Vec<_> = (0..3)
|
|
.map(|party| generator.generate_noise_share(party, 10, 1.0))
|
|
.collect();
|
|
|
|
let combined = generator.combine_shares(&shares);
|
|
assert_eq!(combined.len(), 10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_different_accountant_types() {
|
|
for accountant_type in [
|
|
PrivacyAccountantType::Moments,
|
|
PrivacyAccountantType::RDP,
|
|
PrivacyAccountantType::GDP,
|
|
PrivacyAccountantType::PLD,
|
|
] {
|
|
let mut config = sample_config();
|
|
config.accountant = accountant_type;
|
|
|
|
let mut accountant = PrivacyAccountant::new(config);
|
|
let epsilon = accountant.step(5);
|
|
|
|
assert!(epsilon > 0.0);
|
|
assert!(epsilon < f64::INFINITY);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_sample_rate() {
|
|
let config = sample_config();
|
|
let mut accountant_full = PrivacyAccountant::new(config.clone());
|
|
let mut accountant_sub = PrivacyAccountant::with_sample_rate(config, 0.1);
|
|
|
|
let eps_full = accountant_full.step(5);
|
|
let eps_sub = accountant_sub.step(5);
|
|
|
|
// Subsampled should have lower epsilon (privacy amplification)
|
|
assert!(eps_sub < eps_full);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clipper_set_norm() {
|
|
let mut clipper = GradientClipper::new(1.0);
|
|
assert_eq!(clipper.clip_norm(), 1.0);
|
|
|
|
clipper.set_clip_norm(2.0);
|
|
assert_eq!(clipper.clip_norm(), 2.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_dp_set_noise_multiplier() {
|
|
let config = sample_config();
|
|
let mut dp = DifferentialPrivacy::new(config);
|
|
|
|
dp.set_noise_multiplier(2.0);
|
|
assert_eq!(dp.config().noise_multiplier, 2.0);
|
|
}
|
|
}
|