sklears_model_selection/
adaptive_resource_allocation.rs

1//! Adaptive Resource Allocation for hyperparameter optimization
2//!
3//! This module implements adaptive resource allocation strategies that dynamically
4//! adjust the computational budget for different hyperparameter configurations
5//! based on their performance, uncertainty, and potential for improvement.
6//!
7//! Key strategies include:
8//! - Multi-Armed Bandit allocation
9//! - Successive Halving with adaptive thresholds
10//! - Resource allocation based on upper confidence bounds
11//! - Dynamic budget reallocation based on learning curves
12
13use scirs2_core::random::rngs::StdRng;
14use scirs2_core::random::{Rng, SeedableRng};
15use sklears_core::error::{Result, SklearsError};
16use std::collections::HashMap;
17use std::fmt::Debug;
18
19/// Resource allocation strategy
20#[derive(Debug, Clone)]
21pub enum AllocationStrategy {
22    /// Equal allocation to all configurations
23    Uniform,
24    /// Multi-armed bandit allocation based on Upper Confidence Bound
25    UCB { exploration_factor: f64 },
26    /// Thompson sampling for resource allocation
27    ThompsonSampling,
28    /// Successive halving with adaptive promotion
29    SuccessiveHalving { reduction_factor: f64 },
30    /// Custom allocation function
31    Custom(fn(&[ResourceConfiguration]) -> Vec<f64>),
32}
33
34/// Configuration being evaluated with allocated resources
35#[derive(Debug, Clone)]
36pub struct ResourceConfiguration {
37    /// Unique identifier for this configuration
38    pub id: String,
39    /// Parameter values for this configuration
40    pub parameters: HashMap<String, f64>,
41    /// Number of evaluations performed
42    pub evaluations: usize,
43    /// Performance scores from evaluations
44    pub scores: Vec<f64>,
45    /// Current mean performance
46    pub mean_score: f64,
47    /// Performance variance
48    pub variance: f64,
49    /// Confidence interval width
50    pub confidence_width: f64,
51    /// Total resources allocated (e.g., training epochs, CV folds)
52    pub resources_allocated: f64,
53    /// Resource efficiency (score per unit resource)
54    pub efficiency: f64,
55    /// Whether this configuration is still active
56    pub is_active: bool,
57}
58
59impl ResourceConfiguration {
60    /// Create a new resource configuration
61    pub fn new(id: String, parameters: HashMap<String, f64>) -> Self {
62        Self {
63            id,
64            parameters,
65            evaluations: 0,
66            scores: Vec::new(),
67            mean_score: 0.0,
68            variance: 0.0,
69            confidence_width: f64::INFINITY,
70            resources_allocated: 0.0,
71            efficiency: 0.0,
72            is_active: true,
73        }
74    }
75
76    /// Add a new evaluation result
77    pub fn add_evaluation(&mut self, score: f64, resources_used: f64) {
78        self.scores.push(score);
79        self.evaluations += 1;
80        self.resources_allocated += resources_used;
81
82        // Update statistics
83        self.update_statistics();
84    }
85
86    /// Update internal statistics
87    pub fn update_statistics(&mut self) {
88        if self.scores.is_empty() {
89            return;
90        }
91
92        // Calculate mean
93        self.mean_score = self.scores.iter().sum::<f64>() / self.scores.len() as f64;
94
95        // Calculate variance
96        if self.scores.len() > 1 {
97            let variance = self
98                .scores
99                .iter()
100                .map(|&x| (x - self.mean_score).powi(2))
101                .sum::<f64>()
102                / (self.scores.len() - 1) as f64;
103            self.variance = variance;
104
105            // Calculate 95% confidence interval width
106            let std_error = (variance / self.scores.len() as f64).sqrt();
107            self.confidence_width = 1.96 * std_error;
108        }
109
110        // Calculate efficiency
111        if self.resources_allocated > 0.0 {
112            self.efficiency = self.mean_score / self.resources_allocated;
113        }
114    }
115
116    /// Calculate Upper Confidence Bound
117    pub fn upper_confidence_bound(&self, exploration_factor: f64, total_evaluations: usize) -> f64 {
118        if self.evaluations == 0 {
119            return f64::INFINITY;
120        }
121
122        let confidence_bonus = exploration_factor
123            * (2.0 * (total_evaluations as f64).ln() / self.evaluations as f64).sqrt();
124
125        self.mean_score + confidence_bonus
126    }
127
128    /// Calculate Lower Confidence Bound
129    pub fn lower_confidence_bound(&self, exploration_factor: f64, total_evaluations: usize) -> f64 {
130        if self.evaluations == 0 {
131            return f64::NEG_INFINITY;
132        }
133
134        let confidence_bonus = exploration_factor
135            * (2.0 * (total_evaluations as f64).ln() / self.evaluations as f64).sqrt();
136
137        self.mean_score - confidence_bonus
138    }
139
140    /// Check if this configuration should be promoted (continue receiving resources)
141    pub fn should_promote(&self, threshold_percentile: f64, all_scores: &[f64]) -> bool {
142        if all_scores.is_empty() || self.evaluations == 0 {
143            return true;
144        }
145
146        let mut sorted_scores = all_scores.to_vec();
147        sorted_scores.sort_by(|a, b| a.partial_cmp(b).unwrap());
148
149        let threshold_idx = (threshold_percentile * sorted_scores.len() as f64) as usize;
150        let threshold = sorted_scores
151            .get(threshold_idx)
152            .unwrap_or(&f64::NEG_INFINITY);
153
154        self.mean_score >= *threshold
155    }
156}
157
158/// Adaptive resource allocator
159pub struct AdaptiveResourceAllocator {
160    /// Allocation strategy to use
161    strategy: AllocationStrategy,
162    /// All configurations being evaluated
163    configurations: Vec<ResourceConfiguration>,
164    /// Total resource budget
165    total_budget: f64,
166    /// Resources used so far
167    resources_used: f64,
168    /// Minimum resource allocation per configuration
169    min_allocation: f64,
170    /// Maximum resource allocation per configuration
171    max_allocation: f64,
172    /// Random number generator
173    rng: StdRng,
174}
175
176impl AdaptiveResourceAllocator {
177    pub fn new(
178        strategy: AllocationStrategy,
179        total_budget: f64,
180        min_allocation: f64,
181        max_allocation: f64,
182        random_state: Option<u64>,
183    ) -> Self {
184        let rng = match random_state {
185            Some(seed) => StdRng::seed_from_u64(seed),
186            None => {
187                use scirs2_core::random::thread_rng;
188                StdRng::from_rng(&mut thread_rng())
189            }
190        };
191
192        Self {
193            strategy,
194            configurations: Vec::new(),
195            total_budget,
196            resources_used: 0.0,
197            min_allocation,
198            max_allocation,
199            rng,
200        }
201    }
202
203    /// Add a new configuration to evaluate
204    pub fn add_configuration(&mut self, config: ResourceConfiguration) {
205        self.configurations.push(config);
206    }
207
208    /// Get next resource allocations for all active configurations
209    pub fn allocate_resources(&mut self) -> Vec<(String, f64)> {
210        let active_configs: Vec<ResourceConfiguration> = self
211            .configurations
212            .iter()
213            .filter(|c| c.is_active)
214            .cloned()
215            .collect();
216
217        if active_configs.is_empty() {
218            return Vec::new();
219        }
220
221        let remaining_budget = self.total_budget - self.resources_used;
222        if remaining_budget <= 0.0 {
223            return Vec::new();
224        }
225
226        let active_refs: Vec<&ResourceConfiguration> = active_configs.iter().collect();
227        let allocations = match &self.strategy {
228            AllocationStrategy::Uniform => self.uniform_allocation(&active_refs, remaining_budget),
229            AllocationStrategy::UCB { exploration_factor } => {
230                self.ucb_allocation(&active_refs, remaining_budget, *exploration_factor)
231            }
232            AllocationStrategy::ThompsonSampling => {
233                self.thompson_sampling_allocation(&active_configs, remaining_budget)
234            }
235            AllocationStrategy::SuccessiveHalving { reduction_factor } => self
236                .successive_halving_allocation(&active_refs, remaining_budget, *reduction_factor),
237            AllocationStrategy::Custom(allocation_fn) => {
238                self.custom_allocation(&active_refs, remaining_budget, allocation_fn)
239            }
240        };
241
242        // Apply min/max constraints
243        let constrained_allocations: Vec<(String, f64)> = allocations
244            .into_iter()
245            .map(|(id, alloc)| (id, alloc.clamp(self.min_allocation, self.max_allocation)))
246            .collect();
247
248        // Update resources used
249        let total_allocated: f64 = constrained_allocations.iter().map(|(_, alloc)| alloc).sum();
250        self.resources_used += total_allocated;
251
252        constrained_allocations
253    }
254
255    /// Uniform resource allocation
256    fn uniform_allocation(
257        &self,
258        configs: &[&ResourceConfiguration],
259        budget: f64,
260    ) -> Vec<(String, f64)> {
261        let allocation_per_config = budget / configs.len() as f64;
262        configs
263            .iter()
264            .map(|config| (config.id.clone(), allocation_per_config))
265            .collect()
266    }
267
268    /// Upper Confidence Bound based allocation
269    fn ucb_allocation(
270        &self,
271        configs: &[&ResourceConfiguration],
272        budget: f64,
273        exploration_factor: f64,
274    ) -> Vec<(String, f64)> {
275        let total_evaluations: usize = configs.iter().map(|c| c.evaluations).sum();
276
277        // Calculate UCB scores
278        let ucb_scores: Vec<f64> = configs
279            .iter()
280            .map(|config| config.upper_confidence_bound(exploration_factor, total_evaluations))
281            .collect();
282
283        // Allocate proportional to UCB scores
284        let total_ucb: f64 = ucb_scores.iter().sum();
285        if total_ucb <= 0.0 {
286            return self.uniform_allocation(configs, budget);
287        }
288
289        configs
290            .iter()
291            .zip(ucb_scores.iter())
292            .map(|(config, &ucb)| {
293                let allocation = budget * (ucb / total_ucb);
294                (config.id.clone(), allocation)
295            })
296            .collect()
297    }
298
299    /// Thompson sampling based allocation
300    fn thompson_sampling_allocation(
301        &mut self,
302        configs: &[ResourceConfiguration],
303        budget: f64,
304    ) -> Vec<(String, f64)> {
305        let mut samples = Vec::new();
306
307        for config in configs {
308            let sample = if config.evaluations == 0 {
309                // For untested configurations, sample from prior
310                self.rng.gen::<f64>()
311            } else {
312                // Sample from posterior (using normal approximation)
313                let std_dev = if config.variance > 0.0 {
314                    (config.variance / config.evaluations as f64).sqrt()
315                } else {
316                    0.1 // Small default standard deviation
317                };
318
319                use scirs2_core::random::essentials::Normal;
320                let normal = Normal::new(config.mean_score, std_dev)
321                    .unwrap_or_else(|_| Normal::new(0.0, 1.0).unwrap());
322                self.rng.sample(normal)
323            };
324            samples.push(sample);
325        }
326
327        // Allocate proportional to samples
328        let total_samples: f64 = samples.iter().sum();
329        if total_samples <= 0.0 {
330            let config_refs: Vec<&ResourceConfiguration> = configs.iter().collect();
331            return self.uniform_allocation(&config_refs, budget);
332        }
333
334        configs
335            .iter()
336            .zip(samples.iter())
337            .map(|(config, &sample)| {
338                let allocation = budget * (sample / total_samples);
339                (config.id.clone(), allocation)
340            })
341            .collect()
342    }
343
344    /// Successive halving allocation
345    fn successive_halving_allocation(
346        &self,
347        configs: &[&ResourceConfiguration],
348        budget: f64,
349        reduction_factor: f64,
350    ) -> Vec<(String, f64)> {
351        // Sort configurations by performance
352        let mut config_scores: Vec<(usize, f64)> = configs
353            .iter()
354            .enumerate()
355            .map(|(i, config)| (i, config.mean_score))
356            .collect();
357
358        config_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
359
360        // Determine how many configurations to keep in next round
361        let num_to_keep = ((configs.len() as f64) * reduction_factor).ceil() as usize;
362        let num_to_keep = num_to_keep.max(1);
363
364        let mut allocations = vec![(String::new(), 0.0); configs.len()];
365        let allocation_per_kept = budget / num_to_keep as f64;
366
367        for (config_idx, _score) in config_scores
368            .iter()
369            .take(num_to_keep.min(config_scores.len()))
370        {
371            allocations[*config_idx] = (configs[*config_idx].id.clone(), allocation_per_kept);
372        }
373
374        allocations
375            .into_iter()
376            .filter(|(id, _)| !id.is_empty())
377            .collect()
378    }
379
380    /// Custom allocation function
381    fn custom_allocation(
382        &self,
383        configs: &[&ResourceConfiguration],
384        budget: f64,
385        allocation_fn: &fn(&[ResourceConfiguration]) -> Vec<f64>,
386    ) -> Vec<(String, f64)> {
387        let config_refs: Vec<ResourceConfiguration> = configs.iter().map(|&c| c.clone()).collect();
388        let allocations = allocation_fn(&config_refs);
389
390        if allocations.len() != configs.len() {
391            return self.uniform_allocation(configs, budget);
392        }
393
394        let total_weight: f64 = allocations.iter().sum();
395        if total_weight <= 0.0 {
396            return self.uniform_allocation(configs, budget);
397        }
398
399        configs
400            .iter()
401            .zip(allocations.iter())
402            .map(|(config, &weight)| {
403                let allocation = budget * (weight / total_weight);
404                (config.id.clone(), allocation)
405            })
406            .collect()
407    }
408
409    /// Update configuration with evaluation result
410    pub fn update_configuration(
411        &mut self,
412        config_id: &str,
413        score: f64,
414        resources_used: f64,
415    ) -> Result<()> {
416        let config = self
417            .configurations
418            .iter_mut()
419            .find(|c| c.id == config_id)
420            .ok_or_else(|| {
421                SklearsError::InvalidInput(format!("Configuration {} not found", config_id))
422            })?;
423
424        config.add_evaluation(score, resources_used);
425        Ok(())
426    }
427
428    /// Deactivate underperforming configurations
429    pub fn prune_configurations(&mut self, min_evaluations: usize, percentile_threshold: f64) {
430        if self.configurations.len() <= 1 {
431            return;
432        }
433
434        // Only consider configurations with minimum evaluations
435        let eligible_scores: Vec<f64> = self
436            .configurations
437            .iter()
438            .filter(|c| c.evaluations >= min_evaluations)
439            .map(|c| c.mean_score)
440            .collect();
441
442        if eligible_scores.is_empty() {
443            return;
444        }
445
446        for config in &mut self.configurations {
447            if config.evaluations >= min_evaluations
448                && !config.should_promote(percentile_threshold, &eligible_scores)
449            {
450                config.is_active = false;
451            }
452        }
453    }
454
455    /// Get current statistics
456    pub fn get_statistics(&self) -> AllocationStatistics {
457        let active_count = self.configurations.iter().filter(|c| c.is_active).count();
458        let total_count = self.configurations.len();
459
460        let best_score = self
461            .configurations
462            .iter()
463            .filter(|c| c.evaluations > 0)
464            .map(|c| c.mean_score)
465            .fold(f64::NEG_INFINITY, |a, b| a.max(b));
466
467        let total_evaluations: usize = self.configurations.iter().map(|c| c.evaluations).sum();
468
469        AllocationStatistics {
470            active_configurations: active_count,
471            total_configurations: total_count,
472            resources_used: self.resources_used,
473            resources_remaining: self.total_budget - self.resources_used,
474            best_score,
475            total_evaluations,
476        }
477    }
478
479    /// Get active configurations
480    pub fn active_configurations(&self) -> Vec<&ResourceConfiguration> {
481        self.configurations.iter().filter(|c| c.is_active).collect()
482    }
483
484    /// Get best configuration so far
485    pub fn best_configuration(&self) -> Option<&ResourceConfiguration> {
486        self.configurations
487            .iter()
488            .filter(|c| c.evaluations > 0)
489            .max_by(|a, b| a.mean_score.partial_cmp(&b.mean_score).unwrap())
490    }
491}
492
493/// Statistics about resource allocation
494#[derive(Debug, Clone)]
495pub struct AllocationStatistics {
496    pub active_configurations: usize,
497    pub total_configurations: usize,
498    pub resources_used: f64,
499    pub resources_remaining: f64,
500    pub best_score: f64,
501    pub total_evaluations: usize,
502}
503
504/// Configuration for adaptive resource allocation
505#[derive(Debug, Clone)]
506pub struct AdaptiveAllocationConfig {
507    /// Total resource budget
508    pub total_budget: f64,
509    /// Minimum resource allocation per configuration
510    pub min_allocation: f64,
511    /// Maximum resource allocation per configuration
512    pub max_allocation: f64,
513    /// Minimum evaluations before pruning
514    pub min_evaluations_for_pruning: usize,
515    /// Percentile threshold for pruning (bottom X% get pruned)
516    pub pruning_percentile: f64,
517    /// How often to perform pruning (in allocation rounds)
518    pub pruning_frequency: usize,
519    /// Random seed for reproducibility
520    pub random_state: Option<u64>,
521}
522
523impl Default for AdaptiveAllocationConfig {
524    fn default() -> Self {
525        Self {
526            total_budget: 1000.0,
527            min_allocation: 1.0,
528            max_allocation: 100.0,
529            min_evaluations_for_pruning: 3,
530            pruning_percentile: 0.25,
531            pruning_frequency: 5,
532            random_state: None,
533        }
534    }
535}
536
537#[allow(non_snake_case)]
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn test_resource_configuration() {
544        let mut config = ResourceConfiguration::new("test".to_string(), HashMap::new());
545        assert_eq!(config.evaluations, 0);
546        assert!(config.is_active);
547
548        config.add_evaluation(0.8, 10.0);
549        assert_eq!(config.evaluations, 1);
550        assert_eq!(config.mean_score, 0.8);
551        assert_eq!(config.resources_allocated, 10.0);
552    }
553
554    #[test]
555    fn test_ucb_calculation() {
556        let mut config = ResourceConfiguration::new("test".to_string(), HashMap::new());
557        config.add_evaluation(0.8, 10.0);
558
559        let ucb = config.upper_confidence_bound(1.0, 10);
560        assert!(ucb > config.mean_score);
561    }
562
563    #[test]
564    fn test_uniform_allocation() {
565        let allocator =
566            AdaptiveResourceAllocator::new(AllocationStrategy::Uniform, 100.0, 1.0, 50.0, Some(42));
567
568        let configs = vec![
569            ResourceConfiguration::new("1".to_string(), HashMap::new()),
570            ResourceConfiguration::new("2".to_string(), HashMap::new()),
571        ];
572        let config_refs: Vec<&ResourceConfiguration> = configs.iter().collect();
573
574        let allocations = allocator.uniform_allocation(&config_refs, 100.0);
575        assert_eq!(allocations.len(), 2);
576        assert_eq!(allocations[0].1, 50.0);
577        assert_eq!(allocations[1].1, 50.0);
578    }
579
580    #[test]
581    fn test_configuration_pruning() {
582        let mut config = ResourceConfiguration::new("test".to_string(), HashMap::new());
583        config.add_evaluation(0.1, 10.0); // Low score
584
585        let all_scores = vec![0.8, 0.9, 0.7, 0.1]; // This config is worst
586        assert!(!config.should_promote(0.5, &all_scores)); // Should be pruned
587    }
588
589    #[test]
590    fn test_allocation_statistics() {
591        let mut allocator =
592            AdaptiveResourceAllocator::new(AllocationStrategy::Uniform, 100.0, 1.0, 50.0, Some(42));
593
594        allocator.add_configuration(ResourceConfiguration::new("1".to_string(), HashMap::new()));
595        allocator.add_configuration(ResourceConfiguration::new("2".to_string(), HashMap::new()));
596
597        let stats = allocator.get_statistics();
598        assert_eq!(stats.active_configurations, 2);
599        assert_eq!(stats.total_configurations, 2);
600        assert_eq!(stats.resources_used, 0.0);
601    }
602}