Skip to main content

optirs_nas/
architecture_embedding.rs

1//! Architecture embedding for similarity search and performance prediction.
2//!
3//! Provides methods to embed optimizer architectures into dense vector spaces,
4//! enabling similarity comparison, clustering, and gradient-free embedding updates
5//! based on architecture performance.
6
7use crate::architecture::{Architecture, ComponentType};
8use crate::error::{OptimError, Result};
9use scirs2_core::ndarray::{Array1, ScalarOperand, Zip};
10use scirs2_core::numeric::Float;
11use std::collections::HashMap;
12use std::fmt::Debug;
13
14/// Method used to aggregate component embeddings into a single architecture embedding
15#[derive(Debug, Clone, Copy)]
16pub enum AggregationMethod {
17    /// Simple mean of all component embeddings
18    Mean,
19    /// Weighted sum based on component counts
20    WeightedSum,
21    /// Softmax-weighted attention pooling
22    AttentionPooling,
23}
24
25/// Embeds optimizer architectures into dense vector representations
26///
27/// Uses per-component-type embeddings that are aggregated to produce
28/// architecture-level vectors suitable for similarity search and clustering.
29#[derive(Debug, Clone)]
30pub struct ArchitectureEmbedder<T: Float + Debug + Send + Sync + 'static + ScalarOperand> {
31    /// Dimensionality of embedding vectors
32    pub embedding_dim: usize,
33    /// Learned embeddings for each component type
34    pub component_embeddings: HashMap<ComponentType, Array1<T>>,
35    /// Aggregation method for combining component embeddings
36    pub aggregation_method: AggregationMethod,
37    /// Performance-based weights for embedding updates
38    pub performance_weights: HashMap<String, T>,
39}
40
41/// All component types for deterministic initialization
42const ALL_COMPONENT_TYPES: &[ComponentType] = &[
43    ComponentType::SGD,
44    ComponentType::Adam,
45    ComponentType::AdamW,
46    ComponentType::RMSprop,
47    ComponentType::AdaGrad,
48    ComponentType::AdaDelta,
49    ComponentType::Momentum,
50    ComponentType::Nesterov,
51    ComponentType::LRScheduler,
52    ComponentType::GradientClipping,
53    ComponentType::BatchNorm,
54    ComponentType::Dropout,
55    ComponentType::LAMB,
56    ComponentType::LARS,
57    ComponentType::Lion,
58    ComponentType::RAdam,
59    ComponentType::Lookahead,
60    ComponentType::SAM,
61    ComponentType::LBFGS,
62    ComponentType::SparseAdam,
63    ComponentType::GroupedAdam,
64    ComponentType::MAML,
65    ComponentType::L1Regularizer,
66    ComponentType::L2Regularizer,
67    ComponentType::ElasticNetRegularizer,
68    ComponentType::DropoutRegularizer,
69    ComponentType::WeightDecay,
70    ComponentType::AdaptiveLR,
71    ComponentType::AdaptiveMomentum,
72    ComponentType::AdaptiveRegularization,
73    ComponentType::LSTMOptimizer,
74    ComponentType::TransformerOptimizer,
75    ComponentType::AttentionOptimizer,
76    ComponentType::MetaSGD,
77    ComponentType::ConstantLR,
78    ComponentType::ExponentialLR,
79    ComponentType::StepLR,
80    ComponentType::CosineAnnealingLR,
81    ComponentType::OneCycleLR,
82    ComponentType::CyclicLR,
83    ComponentType::Reptile,
84    ComponentType::Custom,
85];
86
87impl<T: Float + Debug + Send + Sync + 'static + ScalarOperand> ArchitectureEmbedder<T> {
88    /// Create a new embedder with deterministic sin/cos initialization
89    ///
90    /// Each component type gets a unique embedding based on positional encoding.
91    pub fn new(embedding_dim: usize, aggregation: AggregationMethod) -> Self {
92        let mut component_embeddings = HashMap::new();
93
94        for (idx, &comp_type) in ALL_COMPONENT_TYPES.iter().enumerate() {
95            let embedding = Self::sincos_encoding(idx, embedding_dim);
96            component_embeddings.insert(comp_type, embedding);
97        }
98
99        Self {
100            embedding_dim,
101            component_embeddings,
102            aggregation_method: aggregation,
103            performance_weights: HashMap::new(),
104        }
105    }
106
107    /// Generate a deterministic sin/cos positional encoding for a given index
108    fn sincos_encoding(index: usize, dim: usize) -> Array1<T> {
109        let mut values = Vec::with_capacity(dim);
110        let idx_f64 = index as f64;
111
112        for d in 0..dim {
113            let denom = (10000.0_f64).powf(2.0 * (d / 2) as f64 / dim as f64);
114            let val = if d % 2 == 0 {
115                (idx_f64 / denom).sin()
116            } else {
117                (idx_f64 / denom).cos()
118            };
119            let t_val: T = scirs2_core::numeric::NumCast::from(val).unwrap_or_else(|| T::zero());
120            values.push(t_val);
121        }
122
123        Array1::from_vec(values)
124    }
125
126    /// Embed an architecture into a dense vector by aggregating component embeddings
127    pub fn embed(&self, architecture: &Architecture) -> Result<Array1<T>> {
128        if architecture.components.is_empty() {
129            return Err(OptimError::ArchitectureError(
130                "Cannot embed architecture with no components".to_string(),
131            ));
132        }
133
134        // Collect embeddings for all enabled components
135        let mut embeddings: Vec<Array1<T>> = Vec::new();
136        for component in &architecture.components {
137            if !component.enabled {
138                continue;
139            }
140            let emb = self
141                .component_embeddings
142                .get(&component.component_type)
143                .ok_or_else(|| {
144                    OptimError::ArchitectureError(format!(
145                        "No embedding found for component type {:?}",
146                        component.component_type
147                    ))
148                })?;
149            embeddings.push(emb.clone());
150        }
151
152        if embeddings.is_empty() {
153            return Err(OptimError::ArchitectureError(
154                "No enabled components to embed".to_string(),
155            ));
156        }
157
158        match self.aggregation_method {
159            AggregationMethod::Mean => self.aggregate_mean(&embeddings),
160            AggregationMethod::WeightedSum => {
161                self.aggregate_weighted_sum(&embeddings, architecture)
162            }
163            AggregationMethod::AttentionPooling => self.aggregate_attention(&embeddings),
164        }
165    }
166
167    /// Aggregate embeddings by taking the mean
168    fn aggregate_mean(&self, embeddings: &[Array1<T>]) -> Result<Array1<T>> {
169        let n: T =
170            scirs2_core::numeric::NumCast::from(embeddings.len()).unwrap_or_else(|| T::one());
171        let mut result = Array1::zeros(self.embedding_dim);
172
173        for emb in embeddings {
174            Zip::from(&mut result).and(emb).for_each(|r, &e| {
175                *r = *r + e;
176            });
177        }
178
179        result.mapv_inplace(|v| v / n);
180        Ok(result)
181    }
182
183    /// Aggregate embeddings by weighted sum (weight by occurrence count)
184    fn aggregate_weighted_sum(
185        &self,
186        embeddings: &[Array1<T>],
187        architecture: &Architecture,
188    ) -> Result<Array1<T>> {
189        // Count occurrences of each component type
190        let mut type_counts: HashMap<ComponentType, usize> = HashMap::new();
191        for comp in &architecture.components {
192            if comp.enabled {
193                *type_counts.entry(comp.component_type).or_insert(0) += 1;
194            }
195        }
196
197        let total_count: usize = type_counts.values().sum();
198        let total_t: T =
199            scirs2_core::numeric::NumCast::from(total_count.max(1)).unwrap_or_else(|| T::one());
200
201        let mut result = Array1::zeros(self.embedding_dim);
202        let mut emb_idx = 0;
203
204        for comp in &architecture.components {
205            if !comp.enabled {
206                continue;
207            }
208            if emb_idx >= embeddings.len() {
209                break;
210            }
211
212            let count = type_counts.get(&comp.component_type).copied().unwrap_or(1);
213            let weight: T = scirs2_core::numeric::NumCast::from(count).unwrap_or_else(|| T::one());
214
215            let emb = &embeddings[emb_idx];
216            Zip::from(&mut result).and(emb).for_each(|r, &e| {
217                *r = *r + e * weight;
218            });
219            emb_idx += 1;
220        }
221
222        // Normalize by total count
223        result.mapv_inplace(|v| v / total_t);
224        Ok(result)
225    }
226
227    /// Aggregate embeddings using softmax-weighted attention pooling
228    fn aggregate_attention(&self, embeddings: &[Array1<T>]) -> Result<Array1<T>> {
229        // Compute attention scores as L2 norm of each embedding
230        let mut scores: Vec<T> = Vec::with_capacity(embeddings.len());
231        for emb in embeddings {
232            let norm_sq = emb.iter().fold(T::zero(), |acc, &v| acc + v * v);
233            scores.push(norm_sq.sqrt());
234        }
235
236        // Softmax over scores
237        let max_score = scores
238            .iter()
239            .copied()
240            .fold(T::neg_infinity(), |a, b| if b > a { b } else { a });
241
242        let mut exp_scores: Vec<T> = scores.iter().map(|&s| (s - max_score).exp()).collect();
243
244        let sum_exp: T = exp_scores.iter().copied().fold(T::zero(), |a, b| a + b);
245        if sum_exp > T::zero() {
246            for s in &mut exp_scores {
247                *s = *s / sum_exp;
248            }
249        }
250
251        // Weighted sum
252        let mut result = Array1::zeros(self.embedding_dim);
253        for (emb, &weight) in embeddings.iter().zip(exp_scores.iter()) {
254            Zip::from(&mut result).and(emb).for_each(|r, &e| {
255                *r = *r + e * weight;
256            });
257        }
258
259        Ok(result)
260    }
261
262    /// Compute cosine similarity between two architecture embeddings
263    pub fn compute_similarity(&self, arch1: &Architecture, arch2: &Architecture) -> Result<T> {
264        let emb1 = self.embed(arch1)?;
265        let emb2 = self.embed(arch2)?;
266
267        let dot: T = Zip::from(&emb1)
268            .and(&emb2)
269            .fold(T::zero(), |acc, &a, &b| acc + a * b);
270
271        let norm1 = emb1.iter().fold(T::zero(), |acc, &v| acc + v * v).sqrt();
272        let norm2 = emb2.iter().fold(T::zero(), |acc, &v| acc + v * v).sqrt();
273
274        let denom = norm1 * norm2;
275        if denom <= T::zero() {
276            return Ok(T::zero());
277        }
278
279        Ok(dot / denom)
280    }
281
282    /// Find the top-k most similar architectures to a query
283    ///
284    /// Returns vector of (index, similarity_score) pairs sorted by similarity descending.
285    pub fn find_similar(
286        &self,
287        query: &Architecture,
288        candidates: &[Architecture],
289        k: usize,
290    ) -> Result<Vec<(usize, T)>> {
291        if candidates.is_empty() {
292            return Ok(Vec::new());
293        }
294
295        let mut similarities: Vec<(usize, T)> = Vec::with_capacity(candidates.len());
296        for (idx, candidate) in candidates.iter().enumerate() {
297            let sim = self.compute_similarity(query, candidate)?;
298            similarities.push((idx, sim));
299        }
300
301        // Sort by similarity descending
302        similarities.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
303
304        similarities.truncate(k);
305        Ok(similarities)
306    }
307
308    /// Update embeddings based on architecture performance (gradient-free)
309    ///
310    /// For high-performing architectures, slightly increase the magnitude of
311    /// their component embeddings. For low-performing ones, slightly decrease.
312    /// Embeddings are normalized after the update.
313    pub fn update_embeddings(
314        &mut self,
315        architectures: &[Architecture],
316        performances: &[T],
317    ) -> Result<()> {
318        if architectures.len() != performances.len() {
319            return Err(OptimError::InvalidParameter(format!(
320                "Architectures count ({}) must match performances count ({})",
321                architectures.len(),
322                performances.len()
323            )));
324        }
325
326        if architectures.is_empty() {
327            return Ok(());
328        }
329
330        // Compute mean performance for normalization
331        let n: T =
332            scirs2_core::numeric::NumCast::from(performances.len()).unwrap_or_else(|| T::one());
333        let mean_perf = performances.iter().copied().fold(T::zero(), |a, b| a + b) / n;
334
335        let learning_rate: T =
336            scirs2_core::numeric::NumCast::from(0.01).unwrap_or_else(|| T::zero());
337
338        // Update component embeddings based on relative performance
339        for (arch, &perf) in architectures.iter().zip(performances.iter()) {
340            let relative_perf = perf - mean_perf;
341
342            for component in &arch.components {
343                if !component.enabled {
344                    continue;
345                }
346                if let Some(emb) = self.component_embeddings.get_mut(&component.component_type) {
347                    // Scale embedding: positive relative perf -> increase, negative -> decrease
348                    let scale = T::one() + learning_rate * relative_perf;
349                    emb.mapv_inplace(|v| v * scale);
350                }
351            }
352        }
353
354        // Normalize all embeddings to unit length
355        for emb in self.component_embeddings.values_mut() {
356            let norm = emb.iter().fold(T::zero(), |acc, &v| acc + v * v).sqrt();
357            if norm > T::zero() {
358                emb.mapv_inplace(|v| v / norm);
359            }
360        }
361
362        Ok(())
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::architecture::{ComponentPosition, Connection, ConnectionType, OptimizerComponent};
370
371    fn make_test_architecture(id: &str, component_types: &[ComponentType]) -> Architecture {
372        let components: Vec<OptimizerComponent> = component_types
373            .iter()
374            .enumerate()
375            .map(|(i, ct)| OptimizerComponent {
376                id: format!("comp_{}", i),
377                component_type: *ct,
378                hyperparameters: HashMap::new(),
379                position: ComponentPosition {
380                    layer: i as u32,
381                    index: 0,
382                    x: i as f32 * 100.0,
383                    y: 0.0,
384                },
385                enabled: true,
386            })
387            .collect();
388
389        let mut connections = Vec::new();
390        for i in 0..components.len().saturating_sub(1) {
391            connections.push(Connection {
392                from: components[i].id.clone(),
393                to: components[i + 1].id.clone(),
394                weight: 1.0,
395                connection_type: ConnectionType::Gradient,
396                enabled: true,
397            });
398        }
399
400        Architecture {
401            id: id.to_string(),
402            components,
403            connections,
404            hyperparameters: HashMap::new(),
405            performance: None,
406            generation: 0,
407        }
408    }
409
410    #[test]
411    fn test_embed_architecture() {
412        let embedder = ArchitectureEmbedder::<f64>::new(16, AggregationMethod::Mean);
413
414        let arch = make_test_architecture(
415            "test_arch",
416            &[
417                ComponentType::Adam,
418                ComponentType::SGD,
419                ComponentType::BatchNorm,
420            ],
421        );
422
423        let embedding = embedder.embed(&arch);
424        assert!(embedding.is_ok());
425        let embedding = embedding.ok().unwrap_or_else(|| Array1::zeros(0));
426        assert_eq!(embedding.len(), 16);
427
428        // Embedding should not be all zeros (since we use sin/cos encoding)
429        let sum: f64 = embedding.iter().map(|v| v.abs()).sum();
430        assert!(sum > 0.0, "Embedding should be non-zero");
431    }
432
433    #[test]
434    fn test_compute_similarity() {
435        let embedder = ArchitectureEmbedder::<f64>::new(32, AggregationMethod::Mean);
436
437        // Same architecture should have similarity 1.0
438        let arch1 = make_test_architecture("arch1", &[ComponentType::Adam, ComponentType::SGD]);
439        let arch2 = make_test_architecture("arch2", &[ComponentType::Adam, ComponentType::SGD]);
440
441        let sim = embedder.compute_similarity(&arch1, &arch2);
442        assert!(sim.is_ok());
443        let sim = sim.ok().unwrap_or(0.0);
444        assert!(
445            (sim - 1.0).abs() < 1e-6,
446            "Same components should have similarity ~1.0, got {}",
447            sim
448        );
449
450        // Different architectures should have lower similarity
451        let arch3 = make_test_architecture(
452            "arch3",
453            &[
454                ComponentType::LBFGS,
455                ComponentType::Nesterov,
456                ComponentType::BatchNorm,
457            ],
458        );
459        let sim_diff = embedder.compute_similarity(&arch1, &arch3);
460        assert!(sim_diff.is_ok());
461        let sim_diff = sim_diff.ok().unwrap_or(1.0);
462        assert!(
463            sim_diff < sim,
464            "Different architectures should be less similar: {} vs {}",
465            sim_diff,
466            sim
467        );
468    }
469
470    #[test]
471    fn test_find_similar_architectures() {
472        let embedder = ArchitectureEmbedder::<f64>::new(16, AggregationMethod::Mean);
473
474        let query = make_test_architecture("query", &[ComponentType::Adam, ComponentType::SGD]);
475
476        let candidates = vec![
477            make_test_architecture("c0", &[ComponentType::Adam, ComponentType::SGD]),
478            make_test_architecture(
479                "c1",
480                &[
481                    ComponentType::LBFGS,
482                    ComponentType::Nesterov,
483                    ComponentType::BatchNorm,
484                ],
485            ),
486            make_test_architecture("c2", &[ComponentType::Adam]),
487            make_test_architecture(
488                "c3",
489                &[
490                    ComponentType::RMSprop,
491                    ComponentType::AdaDelta,
492                    ComponentType::Dropout,
493                ],
494            ),
495        ];
496
497        let result = embedder.find_similar(&query, &candidates, 2);
498        assert!(result.is_ok());
499        let top_k = result.ok().unwrap_or_default();
500        assert_eq!(top_k.len(), 2);
501
502        // The first result should be the most similar (same components)
503        assert_eq!(
504            top_k[0].0, 0,
505            "Candidate 0 (identical components) should be most similar"
506        );
507        // Similarities should be in descending order
508        assert!(
509            top_k[0].1 >= top_k[1].1,
510            "Results should be sorted by similarity descending"
511        );
512    }
513
514    #[test]
515    fn test_update_embeddings() {
516        let mut embedder = ArchitectureEmbedder::<f64>::new(16, AggregationMethod::Mean);
517
518        let arch_good =
519            make_test_architecture("good", &[ComponentType::Adam, ComponentType::BatchNorm]);
520        let arch_bad = make_test_architecture("bad", &[ComponentType::SGD, ComponentType::Dropout]);
521
522        // Record pre-update embedding for Adam
523        let adam_emb_before = embedder
524            .component_embeddings
525            .get(&ComponentType::Adam)
526            .cloned()
527            .unwrap_or_else(|| Array1::zeros(16));
528
529        let architectures = vec![arch_good, arch_bad];
530        let performances = vec![0.95, 0.3];
531
532        let result = embedder.update_embeddings(&architectures, &performances);
533        assert!(result.is_ok());
534
535        // After update, embeddings should be normalized (unit length)
536        let adam_emb_after = embedder
537            .component_embeddings
538            .get(&ComponentType::Adam)
539            .cloned()
540            .unwrap_or_else(|| Array1::zeros(16));
541
542        let norm: f64 = adam_emb_after.iter().map(|v| v * v).sum::<f64>().sqrt();
543        assert!(
544            (norm - 1.0).abs() < 1e-6,
545            "Embeddings should be normalized after update, norm = {}",
546            norm
547        );
548
549        // Embedding should have changed
550        let diff: f64 = Zip::from(&adam_emb_before)
551            .and(&adam_emb_after)
552            .fold(0.0, |acc, &a, &b| acc + (a - b).abs());
553        assert!(diff > 1e-10, "Embeddings should change after update");
554    }
555
556    #[test]
557    fn test_aggregation_methods() {
558        let arch = make_test_architecture(
559            "agg_test",
560            &[
561                ComponentType::Adam,
562                ComponentType::SGD,
563                ComponentType::BatchNorm,
564                ComponentType::GradientClipping,
565            ],
566        );
567
568        // Test all three aggregation methods produce valid embeddings
569        for &method in &[
570            AggregationMethod::Mean,
571            AggregationMethod::WeightedSum,
572            AggregationMethod::AttentionPooling,
573        ] {
574            let embedder = ArchitectureEmbedder::<f64>::new(8, method);
575            let result = embedder.embed(&arch);
576            assert!(result.is_ok(), "Aggregation {:?} should succeed", method);
577            let emb = result.ok().unwrap_or_else(|| Array1::zeros(0));
578            assert_eq!(emb.len(), 8, "Embedding dim should match for {:?}", method);
579
580            let sum: f64 = emb.iter().map(|v| v.abs()).sum();
581            assert!(sum > 0.0, "Embedding from {:?} should be non-zero", method);
582        }
583
584        // Different aggregation methods should produce different embeddings
585        // Use architecture with repeated component types so WeightedSum differs from Mean
586        let arch_repeated = make_test_architecture(
587            "repeated",
588            &[
589                ComponentType::Adam,
590                ComponentType::Adam,
591                ComponentType::SGD,
592                ComponentType::BatchNorm,
593            ],
594        );
595        let mean_embedder = ArchitectureEmbedder::<f64>::new(8, AggregationMethod::Mean);
596        let ws_embedder = ArchitectureEmbedder::<f64>::new(8, AggregationMethod::WeightedSum);
597
598        let emb_mean = mean_embedder
599            .embed(&arch_repeated)
600            .ok()
601            .unwrap_or_else(|| Array1::zeros(8));
602        let emb_ws = ws_embedder
603            .embed(&arch_repeated)
604            .ok()
605            .unwrap_or_else(|| Array1::zeros(8));
606
607        let diff: f64 = Zip::from(&emb_mean)
608            .and(&emb_ws)
609            .fold(0.0, |acc, &a, &b| acc + (a - b).abs());
610        assert!(
611            diff > 1e-10,
612            "Mean and WeightedSum should produce different embeddings for repeated components"
613        );
614    }
615}