1use crate::error::{OptimError, Result};
6use crate::privacy::PrivacyBudget;
7use scirs2_core::numeric::Float;
8use std::collections::HashMap;
9use std::fmt::Debug;
10
11use super::budget_manager::{HPOBudgetManager, DEFAULT_SELECTION_BUDGET_FRACTION};
12use super::functions::{NoisyOptimizer, ObjectiveFn};
13use super::results::{PrivateResultsAggregator, SelectionReport, PRIVATE_TOP_K};
14use super::types::{
15 unix_timestamp, EvaluationStatus, HPOEvaluation, HyperparameterNoiseMechanism, NoiseParameters,
16 ObjectiveNoiseMechanism, OptimizationStats, ParameterSpace, PrivateBayesianOptimization,
17 PrivateHPOConfig, PrivateHPOResults, PrivateObjective, PrivateRandomSearch, SearchAlgorithm,
18};
19
20pub(crate) fn optimizer_key(algorithm: SearchAlgorithm) -> Result<&'static str> {
27 match algorithm {
28 SearchAlgorithm::RandomSearch => Ok("random_search"),
29 SearchAlgorithm::BayesianOptimization => Ok("bayesian_opt"),
30 other => Err(OptimError::UnsupportedOperation(format!(
31 "SearchAlgorithm::{other:?} has no differentially private implementation in this \
32 crate; configure SearchAlgorithm::RandomSearch or \
33 SearchAlgorithm::BayesianOptimization"
34 ))),
35 }
36}
37
38pub struct PrivateHyperparameterOptimizer<T: Float + Debug + Send + Sync + 'static> {
40 config: PrivateHPOConfig<T>,
42 budget_manager: HPOBudgetManager,
44 noisy_optimizers: HashMap<String, Box<dyn NoisyOptimizer<T>>>,
46 parameterspace: ParameterSpace<T>,
48 private_objective: PrivateObjective<T>,
50 results_aggregator: PrivateResultsAggregator<T>,
52}
53
54impl<T: Float + Debug + Send + Sync + 'static> PrivateHyperparameterOptimizer<T> {
55 pub fn new(config: PrivateHPOConfig<T>, parameterspace: ParameterSpace<T>) -> Result<Self> {
66 if parameterspace.parameters.is_empty() {
67 return Err(OptimError::InvalidConfig(
68 "the parameter space declares no hyperparameter to search".to_string(),
69 ));
70 }
71
72 let objective_sensitivity = match config.sensitivity_bounds.objective_sensitivity() {
75 Some(sensitivity) => {
76 let as_f64 = sensitivity.to_f64().unwrap_or(f64::NAN);
77 if !as_f64.is_finite() || as_f64 <= 0.0 {
78 return Err(OptimError::InvalidPrivacyConfig(format!(
79 "the declared objective sensitivity is {as_f64}; it must be positive \
80 and finite"
81 )));
82 }
83 sensitivity
84 }
85 None => {
86 return Err(OptimError::InvalidPrivacyConfig(format!(
87 "no objective sensitivity is declared in \
88 sensitivity_bounds.global_sensitivity (expected the key `{}`); the \
89 objective's noise scale is sensitivity / epsilon and the exponential \
90 mechanism is calibrated with the same number, so neither can be derived \
91 without it",
92 super::selection::OBJECTIVE_SENSITIVITY_KEY
93 )))
94 }
95 };
96 let selection_sensitivity = if config.private_model_selection {
97 Some(objective_sensitivity)
98 } else {
99 None
100 };
101
102 let selection_fraction = if config.private_model_selection {
103 DEFAULT_SELECTION_BUDGET_FRACTION
104 } else {
105 0.0
106 };
107 let budget_manager = HPOBudgetManager::with_selection_reserve(
108 config.base_privacyconfig.clone(),
109 config.budget_allocation,
110 config.num_evaluations,
111 selection_fraction,
112 )?;
113 let optimizer_name = optimizer_key(config.search_algorithm)?;
119 let mut noisy_optimizers: HashMap<String, Box<dyn NoisyOptimizer<T>>> = HashMap::new();
120 match config.search_algorithm {
121 SearchAlgorithm::RandomSearch => {
122 noisy_optimizers.insert(
123 optimizer_name.to_string(),
124 Box::new(PrivateRandomSearch::new(config.clone())?),
125 );
126 }
127 SearchAlgorithm::BayesianOptimization => {
128 noisy_optimizers.insert(
129 optimizer_name.to_string(),
130 Box::new(PrivateBayesianOptimization::new(config.clone())?),
131 );
132 }
133 other => {
134 return Err(OptimError::UnsupportedOperation(format!(
135 "SearchAlgorithm::{other:?} has no differentially private implementation in \
136 this crate; configure SearchAlgorithm::RandomSearch or \
137 SearchAlgorithm::BayesianOptimization"
138 )))
139 }
140 }
141
142 let selection_delta = if matches!(
148 config.noise_mechanism,
149 HyperparameterNoiseMechanism::Gaussian
150 ) {
151 let delta = config.base_privacyconfig.target_delta;
152 if !delta.is_finite() || !(0.0..1.0).contains(&delta) || delta <= 0.0 {
153 return Err(OptimError::InvalidPrivacyConfig(format!(
154 "Gaussian hyperparameter selection needs a reporting delta in (0, 1), but \
155 base_privacyconfig.target_delta is {delta}"
156 )));
157 }
158 let draws = PRIVATE_TOP_K.min(config.num_evaluations.max(1));
166 let per_draw_epsilon = budget_manager.selection_epsilon() * 0.5 / draws as f64;
167 if per_draw_epsilon > 1.0 {
168 return Err(OptimError::InvalidPrivacyConfig(format!(
169 "Gaussian selection would draw at epsilon {per_draw_epsilon} per selection, \
170 but the classic Gaussian bound requires epsilon <= 1; lower target_epsilon \
171 or choose HyperparameterNoiseMechanism::Exponential"
172 )));
173 }
174 Some(delta)
175 } else {
176 None
177 };
178
179 let results_aggregator = match selection_sensitivity {
180 Some(sensitivity) => PrivateResultsAggregator::with_selection_budget(
181 budget_manager.selection_epsilon(),
182 selection_delta,
183 config.noise_mechanism,
184 sensitivity,
185 1.0,
186 )?,
187 None => PrivateResultsAggregator::new()?,
188 };
189
190 let objective_mechanism = match config.noise_mechanism {
195 HyperparameterNoiseMechanism::Gaussian => HyperparameterNoiseMechanism::Gaussian,
196 _ => HyperparameterNoiseMechanism::Laplace,
197 };
198 let private_objective =
199 PrivateObjective::with_noise_mechanism(ObjectiveNoiseMechanism::with_parameters(
200 objective_mechanism,
201 NoiseParameters {
202 scale: T::one(),
203 sensitivity: objective_sensitivity,
204 epsilon: 1.0,
205 delta: Some(
206 config
207 .base_privacyconfig
208 .target_delta
209 .max(f64::MIN_POSITIVE),
210 ),
211 },
212 )?)?;
213
214 Ok(Self {
215 config,
216 budget_manager,
217 noisy_optimizers,
218 parameterspace,
219 private_objective,
220 results_aggregator,
221 })
222 }
223
224 pub fn seed_for_tests(&mut self, seed: u64) {
234 const OBJECTIVE_DOMAIN: u64 = 0x9E37_79B9_7F4A_7C15;
235 const SELECTION_DOMAIN: u64 = 0xC2B2_AE3D_27D4_EB4F;
236 self.results_aggregator
237 .seed_for_tests(seed.wrapping_mul(SELECTION_DOMAIN) | 1);
238 self.private_objective
239 .seed_for_tests(seed.wrapping_mul(OBJECTIVE_DOMAIN) | 1);
240 }
241
242 pub fn total_privacy_cost(&self) -> PrivacyBudget {
256 self.budget_manager.get_total_consumed_budget()
257 }
258
259 pub fn budget_manager(&self) -> &HPOBudgetManager {
261 &self.budget_manager
262 }
263
264 pub fn private_objective(&self) -> &PrivateObjective<T> {
267 &self.private_objective
268 }
269
270 pub fn optimize(&mut self, objective_fn: ObjectiveFn<T>) -> Result<PrivateHPOResults<T>> {
278 self.private_objective.set_objective(objective_fn)?;
279 let started = std::time::Instant::now();
280 let mut evaluations = Vec::new();
281 let mut evaluation_durations: Vec<f64> = Vec::new();
282 let mut failed_evaluations = 0usize;
283 let mut last_error: Option<OptimError> = None;
284 let mut best_score_so_far = T::neg_infinity();
285 let mut convergence_iteration = None;
286 let optimizer_name = optimizer_key(self.config.search_algorithm)?;
290 for iteration in 0..self.config.num_evaluations {
291 if !self.budget_manager.has_budget_remaining()? {
292 break;
293 }
294 let evaluation_budget = self.budget_manager.get_evaluation_budget(iteration)?;
295 let config = if let Some(optimizer) = self.noisy_optimizers.get_mut(optimizer_name) {
296 optimizer.suggest_next(&self.parameterspace, &evaluations, &evaluation_budget)?
297 } else {
298 return Err(OptimError::InvalidConfig(
299 "No optimizer available".to_string(),
300 ));
301 };
302 let evaluation_started = std::time::Instant::now();
303 let result = match self.private_objective.evaluate(&config, &evaluation_budget) {
304 Ok(result) => result,
305 Err(err) => {
306 failed_evaluations += 1;
311 last_error = Some(err);
312 self.budget_manager
313 .record_evaluation(&evaluation_budget, 0.0)?;
314 continue;
315 }
316 };
317 evaluation_durations.push(evaluation_started.elapsed().as_secs_f64());
318 let evaluation = HPOEvaluation {
319 id: format!("eval_{}", iteration),
320 configuration: config.clone(),
321 result: result.clone(),
322 privacy_cost: evaluation_budget.clone(),
323 timestamp: unix_timestamp()?,
324 metadata: HashMap::new(),
325 };
326 if result.objective_value > best_score_so_far {
327 best_score_so_far = result.objective_value;
328 convergence_iteration = Some(iteration);
329 }
330 if let Some(optimizer) = self.noisy_optimizers.get_mut(optimizer_name) {
331 optimizer.update(&config, &result, &evaluation_budget)?;
332 }
333 evaluations.push(evaluation);
334 self.budget_manager.record_evaluation(
335 &evaluation_budget,
336 result.objective_value.to_f64().unwrap_or(0.0),
337 )?;
338 if self.should_stop_early(&evaluations)? {
339 break;
340 }
341 }
342
343 if evaluations.is_empty() {
344 return Err(last_error.unwrap_or(OptimError::PrivacyBudgetExhausted {
345 consumed_epsilon: self.budget_manager.epsilon_spent(),
346 target_epsilon: self.config.base_privacyconfig.target_epsilon,
347 }));
348 }
349
350 let final_results = self.results_aggregator.aggregate_results(&evaluations)?;
351
352 let (bestconfiguration, best_score, selection) = if self.config.private_model_selection {
353 let spent = self
354 .results_aggregator
355 .selection_mechanism()
356 .epsilon_spent();
357 self.budget_manager.record_selection_spend(spent)?;
358 let mut report = self.results_aggregator.selection_report();
359 let chosen = final_results
360 .topconfigurations
361 .first()
362 .cloned()
363 .ok_or_else(|| {
364 OptimError::InvalidState(
365 "the private selection returned no configuration".to_string(),
366 )
367 })?;
368 report.selected_probability = final_results
369 .model_selection
370 .as_ref()
371 .map(|selection| selection.selection_confidence);
372 (Some(chosen.0), chosen.1, report)
373 } else {
374 let mut best_index = 0usize;
376 let mut best = T::neg_infinity();
377 for (index, evaluation) in evaluations.iter().enumerate() {
378 if evaluation.result.objective_value > best {
379 best = evaluation.result.objective_value;
380 best_index = index;
381 }
382 }
383 (
384 Some(evaluations[best_index].configuration.clone()),
385 best,
386 SelectionReport {
387 was_private: false,
388 mechanism: "exact_argmax".to_string(),
389 epsilon_spent: 0.0,
390 delta_spent: 0.0,
391 utility_sensitivity: f64::NAN,
392 selected_probability: None,
393 },
394 )
395 };
396
397 let optimization_stats = self.compute_optimization_stats(
398 &evaluations,
399 &evaluation_durations,
400 failed_evaluations,
401 started.elapsed().as_secs_f64(),
402 convergence_iteration,
403 )?;
404
405 Ok(PrivateHPOResults {
406 bestconfiguration,
407 best_score,
408 all_evaluations: evaluations,
409 final_results,
410 total_privacy_cost: self.budget_manager.get_total_consumed_budget(),
411 optimization_stats,
412 selection,
413 })
414 }
415 fn should_stop_early(&self, evaluations: &[HPOEvaluation<T>]) -> Result<bool> {
417 if !self.config.early_stopping.enabled {
418 return Ok(false);
419 }
420 if evaluations.len() < self.config.early_stopping.patience {
421 return Ok(false);
422 }
423 let recent_scores: Vec<T> = evaluations
424 .iter()
425 .rev()
426 .take(self.config.early_stopping.patience)
427 .map(|eval| eval.result.objective_value)
428 .collect();
429 let best_recent =
430 recent_scores
431 .iter()
432 .fold(T::neg_infinity(), |acc, &x| if x > acc { x } else { acc });
433 let best_overall = evaluations
434 .iter()
435 .map(|eval| eval.result.objective_value)
436 .fold(T::neg_infinity(), |acc, x| if x > acc { x } else { acc });
437 let improvement = best_recent - best_overall;
438 Ok(improvement
439 < T::from(self.config.early_stopping.min_improvement).unwrap_or_else(|| T::zero()))
440 }
441 fn compute_optimization_stats(
446 &self,
447 evaluations: &[HPOEvaluation<T>],
448 durations: &[f64],
449 failed_evaluations: usize,
450 total_time: f64,
451 convergence_iteration: Option<usize>,
452 ) -> Result<OptimizationStats<T>> {
453 let successful = evaluations
454 .iter()
455 .filter(|evaluation| matches!(evaluation.result.status, EvaluationStatus::Success))
456 .count();
457 let average_evaluation_time = if durations.is_empty() {
458 0.0
459 } else {
460 durations.iter().sum::<f64>() / durations.len() as f64
461 };
462
463 let epsilon_spent = self.budget_manager.epsilon_spent();
465 let budget_efficiency = if epsilon_spent > 0.0 && evaluations.len() >= 2 {
466 let first = evaluations[0]
467 .result
468 .objective_value
469 .to_f64()
470 .unwrap_or(0.0);
471 let best = evaluations
472 .iter()
473 .filter_map(|evaluation| evaluation.result.objective_value.to_f64())
474 .fold(f64::NEG_INFINITY, f64::max);
475 if best.is_finite() {
476 (best - first) / epsilon_spent
477 } else {
478 0.0
479 }
480 } else {
481 0.0
482 };
483
484 Ok(OptimizationStats {
485 total_evaluations: evaluations.len() + failed_evaluations,
486 successful_evaluations: successful,
487 failed_evaluations,
488 average_evaluation_time,
489 total_optimization_time: total_time,
490 convergence_iteration,
491 budget_efficiency,
492 _phantom: std::marker::PhantomData,
493 })
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::super::types::ParameterConfiguration;
500 use super::*;
501 use crate::privacy::private_hyperparameter_optimization::selection::OBJECTIVE_SENSITIVITY_KEY;
502 use crate::privacy::private_hyperparameter_optimization::types::{
503 BudgetAllocationStrategy, EarlyStoppingConfig, ParameterBounds, ParameterDefinition,
504 ParameterType, ParameterValue, SensitivityBounds, ValidationStrategy,
505 };
506 use crate::privacy::DifferentialPrivacyConfig;
507
508 fn sensitivity_bounds(declared: Option<f64>) -> SensitivityBounds<f64> {
509 let mut global_sensitivity = HashMap::new();
510 if let Some(value) = declared {
511 global_sensitivity.insert(OBJECTIVE_SENSITIVITY_KEY.to_string(), value);
512 }
513 SensitivityBounds {
514 global_sensitivity,
515 local_sensitivity: HashMap::new(),
516 smooth_sensitivity: HashMap::new(),
517 }
518 }
519
520 fn config(private_selection: bool, declared: Option<f64>) -> PrivateHPOConfig<f64> {
521 PrivateHPOConfig {
522 base_privacyconfig: DifferentialPrivacyConfig {
523 target_epsilon: 4.0,
524 ..DifferentialPrivacyConfig::default()
525 },
526 budget_allocation: BudgetAllocationStrategy::Equal,
527 search_algorithm: SearchAlgorithm::RandomSearch,
528 num_evaluations: 10,
529 cv_folds: 3,
530 early_stopping: EarlyStoppingConfig {
531 enabled: false,
532 patience: 3,
533 min_improvement: 1e-4,
534 max_evaluations: 10,
535 },
536 noise_mechanism: HyperparameterNoiseMechanism::Laplace,
537 sensitivity_bounds: sensitivity_bounds(declared),
538 private_model_selection: private_selection,
539 validation_strategy: ValidationStrategy::HoldOut,
540 }
541 }
542
543 fn space() -> ParameterSpace<f64> {
544 let mut parameters = HashMap::new();
545 parameters.insert(
546 "learning_rate".to_string(),
547 ParameterDefinition {
548 name: "learning_rate".to_string(),
549 param_type: ParameterType::Continuous,
550 bounds: ParameterBounds {
551 min: Some(0.0),
552 max: Some(1.0),
553 step: None,
554 valid_values: None,
555 },
556 prior: None,
557 transformation: None,
558 },
559 );
560 ParameterSpace {
561 parameters,
562 constraints: Vec::new(),
563 defaultconfig: None,
564 }
565 }
566
567 fn objective() -> ObjectiveFn<f64> {
569 Box::new(
570 |config: &ParameterConfiguration<f64>| match config.values.get("learning_rate") {
571 Some(ParameterValue::Continuous(rate)) => Ok(1.0 - (rate - 0.75).abs()),
572 other => Err(crate::error::OptimError::InvalidParameter(format!(
573 "expected a continuous learning_rate, got {other:?}"
574 ))),
575 },
576 )
577 }
578
579 fn learning_rate(config: &ParameterConfiguration<f64>) -> f64 {
580 match config.values.get("learning_rate") {
581 Some(ParameterValue::Continuous(rate)) => *rate,
582 other => panic!("expected a continuous learning_rate, got {other:?}"),
583 }
584 }
585
586 #[test]
587 fn private_selection_requires_a_declared_objective_sensitivity() {
588 let outcome = PrivateHyperparameterOptimizer::new(config(true, None), space());
591 let message = match outcome {
592 Err(err) => err.to_string(),
593 Ok(_) => panic!("an undeclared sensitivity must be refused"),
594 };
595 assert!(
596 message.contains(OBJECTIVE_SENSITIVITY_KEY),
597 "got: {message}"
598 );
599 assert!(
600 PrivateHyperparameterOptimizer::new(config(true, Some(1.0)), space()).is_ok(),
601 "a declared sensitivity must be accepted"
602 );
603 for bad in [0.0f64, -1.0, f64::NAN] {
604 assert!(
605 PrivateHyperparameterOptimizer::new(config(true, Some(bad)), space()).is_err(),
606 "sensitivity {bad} must be refused"
607 );
608 }
609 }
610
611 #[test]
612 fn an_empty_parameter_space_is_refused() {
613 let empty = ParameterSpace {
614 parameters: HashMap::new(),
615 constraints: Vec::new(),
616 defaultconfig: None,
617 };
618 assert!(PrivateHyperparameterOptimizer::new(config(false, Some(1.0)), empty).is_err());
621 }
622
623 #[test]
624 fn an_end_to_end_run_selects_privately_and_charges_for_it() {
625 let mut optimizer =
626 match PrivateHyperparameterOptimizer::new(config(true, Some(1.0)), space()) {
627 Ok(optimizer) => optimizer,
628 Err(err) => panic!("construction failed: {err}"),
629 };
630 optimizer.seed_for_tests(11);
631 let results = match optimizer.optimize(objective()) {
632 Ok(results) => results,
633 Err(err) => panic!("optimize failed: {err}"),
634 };
635
636 assert_eq!(results.all_evaluations.len(), 10);
637 assert!(results.bestconfiguration.is_some());
638 assert!(
639 results.selection.was_private,
640 "the selection must be reported as private"
641 );
642 assert_eq!(results.selection.mechanism, "laplace_report_noisy_max");
645 assert!(
646 results.selection.epsilon_spent > 0.0,
647 "the selection must cost epsilon, got {}",
648 results.selection.epsilon_spent
649 );
650 assert_eq!(results.selection.utility_sensitivity, 1.0);
651 match results.selection.selected_probability {
652 Some(probability) => {
653 assert!(
654 (0.0..=1.0).contains(&probability),
655 "probability {probability} is not a probability"
656 );
657 }
658 None => panic!("the mechanism's own selection probability must be reported"),
659 }
660
661 let spent = results.total_privacy_cost.epsilon_consumed;
663 assert!(
664 spent > 0.0 && spent <= 4.0 + 1e-9,
665 "spent {spent} of a 4.0 budget"
666 );
667 assert!(results.total_privacy_cost.epsilon_remaining >= 0.0);
668 assert_eq!(results.total_privacy_cost.delta_consumed, 0.0);
669
670 assert_eq!(results.optimization_stats.total_evaluations, 10);
672 assert_eq!(results.optimization_stats.successful_evaluations, 10);
673 assert_eq!(results.optimization_stats.failed_evaluations, 0);
674 assert!(results.optimization_stats.total_optimization_time > 0.0);
675 assert!(results.optimization_stats.convergence_iteration.is_some());
676
677 assert!(results.final_results.summary_stats.noisy_std > 0.0);
679 assert!(results.final_results.confidence_intervals.is_some());
680 assert!(!results.final_results.topconfigurations.is_empty());
681 assert!(results.final_results.model_selection.is_some());
682 }
683
684 #[test]
685 fn the_reported_configuration_is_not_always_the_exact_argmax() {
686 let mut deviations = 0usize;
691 for seed in 0..24u64 {
692 let mut optimizer =
693 match PrivateHyperparameterOptimizer::new(config(true, Some(1.0)), space()) {
694 Ok(optimizer) => optimizer,
695 Err(err) => panic!("construction failed: {err}"),
696 };
697 optimizer.seed_for_tests(seed);
698 let results = match optimizer.optimize(objective()) {
699 Ok(results) => results,
700 Err(err) => panic!("optimize failed: {err}"),
701 };
702 let best_observed = results
703 .all_evaluations
704 .iter()
705 .map(|evaluation| evaluation.result.objective_value)
706 .fold(f64::NEG_INFINITY, f64::max);
707 if (results.best_score - best_observed).abs() > 1e-12 {
708 deviations += 1;
709 }
710 }
711 assert!(
717 deviations >= 12,
718 "only {deviations}/24 runs deviated from the exact argmax; the selection is not \
719 behaving like a private mechanism"
720 );
721 }
722
723 #[test]
724 fn a_non_private_selection_is_reported_as_such() {
725 let mut optimizer =
726 match PrivateHyperparameterOptimizer::new(config(false, Some(1.0)), space()) {
727 Ok(optimizer) => optimizer,
728 Err(err) => panic!("construction failed: {err}"),
729 };
730 optimizer.seed_for_tests(5);
731 let results = match optimizer.optimize(objective()) {
732 Ok(results) => results,
733 Err(err) => panic!("optimize failed: {err}"),
734 };
735 assert!(
736 !results.selection.was_private,
737 "an exact argmax must not be reported as private"
738 );
739 assert_eq!(results.selection.mechanism, "exact_argmax");
740 assert_eq!(results.selection.epsilon_spent, 0.0);
741
742 let best_observed = results
744 .all_evaluations
745 .iter()
746 .map(|evaluation| evaluation.result.objective_value)
747 .fold(f64::NEG_INFINITY, f64::max);
748 assert!((results.best_score - best_observed).abs() < 1e-12);
749 }
750
751 #[test]
752 fn the_released_objectives_are_noisy_not_the_raw_values() {
753 let mut optimizer =
756 match PrivateHyperparameterOptimizer::new(config(false, Some(1.0)), space()) {
757 Ok(optimizer) => optimizer,
758 Err(err) => panic!("construction failed: {err}"),
759 };
760 optimizer.seed_for_tests(3);
761 let results = match optimizer.optimize(objective()) {
762 Ok(results) => results,
763 Err(err) => panic!("optimize failed: {err}"),
764 };
765 let mut noisy_count = 0usize;
766 for evaluation in &results.all_evaluations {
767 let rate = learning_rate(&evaluation.configuration);
768 let exact = 1.0 - (rate - 0.75).abs();
769 if (evaluation.result.objective_value - exact).abs() > 1e-9 {
770 noisy_count += 1;
771 }
772 assert!(
773 evaluation.result.standard_error.is_some(),
774 "the release must report the noise scale that was applied"
775 );
776 }
777 assert_eq!(
778 noisy_count,
779 results.all_evaluations.len(),
780 "every released objective must be perturbed"
781 );
782 }
783
784 #[test]
785 fn a_failing_objective_is_counted_and_propagated_when_nothing_succeeds() {
786 let mut optimizer =
787 match PrivateHyperparameterOptimizer::new(config(false, Some(1.0)), space()) {
788 Ok(optimizer) => optimizer,
789 Err(err) => panic!("construction failed: {err}"),
790 };
791 let always_fails: ObjectiveFn<f64> = Box::new(|_| {
792 Err(crate::error::OptimError::ComputationError(
793 "the trial crashed".to_string(),
794 ))
795 });
796 let message = match optimizer.optimize(always_fails) {
797 Err(err) => err.to_string(),
798 Ok(_) => panic!("a run in which every trial failed must not succeed"),
799 };
800 assert!(message.contains("the trial crashed"), "got: {message}");
801 }
802
803 #[test]
804 fn an_unset_objective_errors_instead_of_scoring_zero() {
805 let mut objective: PrivateObjective<f64> = match PrivateObjective::new() {
807 Ok(objective) => objective,
808 Err(err) => panic!("construction failed: {err}"),
809 };
810 let config = ParameterConfiguration {
811 values: HashMap::new(),
812 id: "c".to_string(),
813 metadata: HashMap::new(),
814 };
815 let budget = crate::privacy::PrivacyBudget {
816 epsilon_consumed: 0.5,
817 ..crate::privacy::PrivacyBudget::default()
818 };
819 let message = match objective.evaluate(&config, &budget) {
820 Err(err) => err.to_string(),
821 Ok(result) => panic!("an unset objective scored {:?}", result.objective_value),
822 };
823 assert!(message.contains("no objective function"), "got: {message}");
824 }
825
826 #[test]
827 fn a_zero_epsilon_grant_is_refused_by_the_objective_release() {
828 let mut objective: PrivateObjective<f64> = match PrivateObjective::new() {
829 Ok(objective) => objective,
830 Err(err) => panic!("construction failed: {err}"),
831 };
832 let ok = objective.set_objective(Box::new(|_| Ok(1.0)));
833 assert!(ok.is_ok());
834 let config = ParameterConfiguration {
835 values: HashMap::new(),
836 id: "c".to_string(),
837 metadata: HashMap::new(),
838 };
839 for epsilon in [0.0f64, -1.0, f64::NAN] {
840 let budget = crate::privacy::PrivacyBudget {
841 epsilon_consumed: epsilon,
842 ..crate::privacy::PrivacyBudget::default()
843 };
844 assert!(
845 objective.evaluate(&config, &budget).is_err(),
846 "an epsilon of {epsilon} must not buy a release"
847 );
848 }
849 }
850
851 #[test]
852 fn the_configured_noise_mechanism_drives_the_selection() {
853 for (mechanism, expected) in [
854 (
855 HyperparameterNoiseMechanism::Exponential,
856 "exponential_mechanism",
857 ),
858 (
859 HyperparameterNoiseMechanism::NoisyMax,
860 "gumbel_report_noisy_max",
861 ),
862 (
863 HyperparameterNoiseMechanism::Laplace,
864 "laplace_report_noisy_max",
865 ),
866 (
867 HyperparameterNoiseMechanism::Gaussian,
868 "gaussian_report_noisy_max",
869 ),
870 ] {
871 let mut hpo_config = config(true, Some(1.0));
872 hpo_config.noise_mechanism = mechanism;
873 let mut optimizer = match PrivateHyperparameterOptimizer::new(hpo_config, space()) {
874 Ok(optimizer) => optimizer,
875 Err(err) => panic!("{mechanism:?} construction failed: {err}"),
876 };
877 optimizer.seed_for_tests(23);
878 let results = match optimizer.optimize(objective()) {
879 Ok(results) => results,
880 Err(err) => panic!("{mechanism:?} optimize failed: {err}"),
881 };
882 assert_eq!(results.selection.mechanism, expected);
883 assert!(results.selection.was_private);
884 assert!(results.selection.epsilon_spent > 0.0);
885 }
886 }
887
888 #[test]
889 fn the_bayesian_search_path_also_runs_end_to_end() {
890 let mut hpo_config = config(true, Some(1.0));
891 hpo_config.search_algorithm = SearchAlgorithm::BayesianOptimization;
892 let mut optimizer = match PrivateHyperparameterOptimizer::new(hpo_config, space()) {
893 Ok(optimizer) => optimizer,
894 Err(err) => panic!("construction failed: {err}"),
895 };
896 optimizer.seed_for_tests(19);
897 let results = match optimizer.optimize(objective()) {
898 Ok(results) => results,
899 Err(err) => panic!("optimize failed: {err}"),
900 };
901 assert_eq!(results.all_evaluations.len(), 10);
902 for evaluation in &results.all_evaluations {
903 assert_eq!(
904 evaluation.configuration.values.len(),
905 1,
906 "every Bayesian proposal must set the parameter"
907 );
908 }
909 assert!(results.selection.was_private);
910 }
911
912 #[test]
913 fn the_objective_release_also_requires_a_declared_sensitivity() {
914 let outcome = PrivateHyperparameterOptimizer::new(config(false, None), space());
921 let message = match outcome {
922 Err(err) => err.to_string(),
923 Ok(_) => panic!("an undeclared objective sensitivity must be refused"),
924 };
925 assert!(
926 message.contains(OBJECTIVE_SENSITIVITY_KEY),
927 "the error must name the key the sensitivity is expected under, got: {message}"
928 );
929 assert!(
930 PrivateHyperparameterOptimizer::new(config(false, Some(2.0)), space()).is_ok(),
931 "a declared sensitivity must be accepted with private selection off"
932 );
933 }
934
935 #[test]
936 fn the_declared_sensitivity_reaches_the_objective_noise_scale() {
937 let mut scales = Vec::new();
942 for declared in [1.0f64, 4.0] {
943 let mut optimizer =
944 match PrivateHyperparameterOptimizer::new(config(false, Some(declared)), space()) {
945 Ok(optimizer) => optimizer,
946 Err(err) => panic!("construction failed for sensitivity {declared}: {err}"),
947 };
948 optimizer.seed_for_tests(7);
949 if let Err(err) = optimizer.optimize(objective()) {
950 panic!("optimize failed for sensitivity {declared}: {err}");
951 }
952 let params = optimizer
953 .private_objective()
954 .noise_mechanism()
955 .noise_params();
956 assert_eq!(params.sensitivity, declared);
957 let epsilon = params.epsilon;
958 assert!(epsilon > 0.0, "the release must have been charged epsilon");
959 let expected = declared / epsilon;
960 assert!(
961 (params.scale - expected).abs() < 1e-12,
962 "recorded scale {} is not sensitivity/epsilon = {expected}",
963 params.scale
964 );
965 scales.push(params.scale);
966 }
967 assert!(
968 (scales[1] / scales[0] - 4.0).abs() < 1e-9,
969 "quadrupling the declared sensitivity must quadruple the noise scale, got {scales:?}"
970 );
971 }
972
973 #[test]
974 fn the_reported_privacy_cost_is_the_real_ledger_not_an_unstepped_accountant() {
975 let mut optimizer =
980 match PrivateHyperparameterOptimizer::new(config(true, Some(1.0)), space()) {
981 Ok(optimizer) => optimizer,
982 Err(err) => panic!("construction failed: {err}"),
983 };
984 assert_eq!(
985 optimizer.total_privacy_cost().epsilon_consumed,
986 0.0,
987 "nothing has been released yet"
988 );
989 optimizer.seed_for_tests(31);
990 let results = match optimizer.optimize(objective()) {
991 Ok(results) => results,
992 Err(err) => panic!("optimize failed: {err}"),
993 };
994
995 let reported = optimizer.total_privacy_cost();
996 assert!(
997 reported.epsilon_consumed > 0.0,
998 "a completed search must report a positive spend, got {}",
999 reported.epsilon_consumed
1000 );
1001 assert!(
1002 (reported.epsilon_consumed - results.total_privacy_cost.epsilon_consumed).abs() < 1e-12,
1003 "the accessor and the results must read the same ledger: {} vs {}",
1004 reported.epsilon_consumed,
1005 results.total_privacy_cost.epsilon_consumed
1006 );
1007 assert!(
1008 reported.epsilon_consumed <= 4.0 + 1e-9,
1009 "the spend must not exceed the 4.0 target, got {}",
1010 reported.epsilon_consumed
1011 );
1012 assert!(
1015 reported.epsilon_consumed >= results.selection.epsilon_spent,
1016 "the ledger {} does not cover the selection's own charge {}",
1017 reported.epsilon_consumed,
1018 results.selection.epsilon_spent
1019 );
1020 }
1021
1022 #[test]
1023 fn search_algorithms_without_a_private_implementation_are_refused() {
1024 for algorithm in [
1028 SearchAlgorithm::GridSearch,
1029 SearchAlgorithm::GeneticAlgorithm,
1030 SearchAlgorithm::ParticleSwarm,
1031 SearchAlgorithm::SimulatedAnnealing,
1032 SearchAlgorithm::TPE,
1033 ] {
1034 let mut hpo_config = config(true, Some(1.0));
1035 hpo_config.search_algorithm = algorithm;
1036 let message = match PrivateHyperparameterOptimizer::new(hpo_config, space()) {
1037 Err(err) => err.to_string(),
1038 Ok(_) => panic!(
1039 "{algorithm:?} has no private implementation and must not be substituted"
1040 ),
1041 };
1042 assert!(
1043 message.contains(&format!("{algorithm:?}")),
1044 "the error must name the refused algorithm, got: {message}"
1045 );
1046 assert!(
1047 optimizer_key(algorithm).is_err(),
1048 "{algorithm:?} must not resolve to an optimizer key"
1049 );
1050 }
1051
1052 assert_eq!(
1053 optimizer_key(SearchAlgorithm::RandomSearch).ok(),
1054 Some("random_search")
1055 );
1056 assert_eq!(
1057 optimizer_key(SearchAlgorithm::BayesianOptimization).ok(),
1058 Some("bayesian_opt")
1059 );
1060 }
1061}