Skip to main content

optirs_learned/few_shot/
prototypical.rs

1//! Real encoder for the prototypical network (finding F16).
2//!
3//! Two things were wrong in `few_shot.rs`:
4//!
5//! 1. `PrototypicalNetwork::new` and `::from_dims` built their `EncoderLayer`
6//!    with `Array2::zeros(...)`, so the encoder — had it ever been used — would
7//!    have output the zero vector for every input.
8//! 2. `encode_task` never used `self.encoder` at all. It computed a truncated
9//!    coordinate-wise mean of the *raw* support features, which made the encoder
10//!    dead weight and the "embedding" nothing more than the input.
11//!
12//! This module supplies the pieces that fix both: Xavier initialization and a
13//! real [`EncoderNetwork::forward`]. It lives in a child module because
14//! `few_shot.rs` is within a few dozen lines of the 2000-line cap.
15//!
16//! The embedding follows the standard prototypical-network formulation
17//! (Snell et al. 2017): each support example is embedded by `f_φ` and the
18//! prototype is the *mean of the embeddings*, not the embedding of the mean.
19
20use scirs2_core::ndarray::{Array1, Array2};
21use scirs2_core::numeric::Float;
22use std::fmt::Debug;
23
24use super::{ActivationFunction, EncoderLayer, EncoderNetwork, LayerType};
25use crate::error::{OptimError, Result};
26
27/// Build a Xavier/Glorot-uniform encoder layer of shape `(input_dim, output_dim)`.
28///
29/// The limit is `sqrt(6 / (input_dim + output_dim))`. The previous code used
30/// `Array2::zeros`, which makes the layer output identically zero and its
31/// gradient identically zero — an encoder that can never learn anything.
32///
33/// # Errors
34/// Returns `Err` when either dimension is zero.
35pub fn xavier_encoder_layer<T>(
36    input_dim: usize,
37    output_dim: usize,
38    layer_type: LayerType,
39) -> Result<EncoderLayer<T>>
40where
41    T: Float + Debug + Send + Sync + 'static,
42{
43    if input_dim == 0 || output_dim == 0 {
44        return Err(OptimError::InvalidConfig(format!(
45            "encoder layer dimensions must be positive, got {input_dim}x{output_dim}"
46        )));
47    }
48    let bound = (6.0 / (input_dim + output_dim) as f64).sqrt();
49    let mut rng = scirs2_core::random::thread_rng();
50    let weights = Array2::from_shape_fn((input_dim, output_dim), |_| {
51        let sample = rng.random::<f64>() * 2.0 - 1.0;
52        scirs2_core::numeric::NumCast::from(sample * bound).unwrap_or_else(|| T::zero())
53    });
54    Ok(EncoderLayer {
55        weights,
56        bias: Array1::zeros(output_dim),
57        layer_type,
58    })
59}
60
61/// Apply an activation elementwise.
62///
63/// `Swish` uses `x·σ(x)` (never `x·eˣ/(1+eˣ)`, which overflows to `NaN` for
64/// large `x`), and `Mish` uses `x·tanh(softplus(x))` with the numerically stable
65/// `softplus` for large arguments.
66pub fn activate<T>(value: T, activation: ActivationFunction) -> T
67where
68    T: Float + Debug + Send + Sync + 'static,
69{
70    let sigmoid = |x: T| T::one() / (T::one() + (-x).exp());
71    match activation {
72        ActivationFunction::ReLU => {
73            if value > T::zero() {
74                value
75            } else {
76                T::zero()
77            }
78        }
79        ActivationFunction::Tanh => value.tanh(),
80        ActivationFunction::Sigmoid => sigmoid(value),
81        ActivationFunction::Swish => value * sigmoid(value),
82        ActivationFunction::GELU => {
83            // tanh approximation (Hendrycks & Gimpel 2016).
84            let c: T =
85                scirs2_core::numeric::NumCast::from(0.797_884_560_802_865_4).unwrap_or_else(T::one);
86            let k: T = scirs2_core::numeric::NumCast::from(0.044_715).unwrap_or_else(T::zero);
87            let half: T = scirs2_core::numeric::NumCast::from(0.5).unwrap_or_else(T::one);
88            half * value * (T::one() + (c * (value + k * value * value * value)).tanh())
89        }
90        ActivationFunction::Mish => {
91            // softplus(x) = ln(1 + eˣ); for large x that is just x.
92            let threshold: T = scirs2_core::numeric::NumCast::from(20.0).unwrap_or_else(T::one);
93            let softplus = if value > threshold {
94                value
95            } else {
96                (T::one() + value.exp()).ln()
97            };
98            value * softplus.tanh()
99        }
100    }
101}
102
103impl<T: Float + Debug + Send + Sync + 'static> EncoderNetwork<T> {
104    /// Embed a raw feature vector.
105    ///
106    /// Each layer computes `Wᵗx + b` (the weight matrix is stored
107    /// `(input_dim, output_dim)`), and the activation is applied to every layer
108    /// **except the last** — a prototypical-network embedding is the final linear
109    /// map's output, not a rectified version of it.
110    ///
111    /// A feature vector that does not match the first layer's input width is
112    /// zero-padded or truncated to fit, which is documented rather than silent:
113    /// the caller's raw task features have no fixed width, and refusing them
114    /// outright would make the encoder unusable on real task data.
115    ///
116    /// # Errors
117    /// Returns `Err` when the network has no layers, or when two consecutive
118    /// layers disagree on width.
119    pub fn forward(&self, features: &Array1<T>) -> Result<Array1<T>> {
120        let first = self.layers.first().ok_or_else(|| {
121            OptimError::InvalidConfig("encoder network has no layers".to_string())
122        })?;
123
124        let mut activation = Array1::<T>::zeros(first.weights.nrows());
125        let copy = features.len().min(activation.len());
126        for i in 0..copy {
127            activation[i] = features[i];
128        }
129
130        let last_index = self.layers.len() - 1;
131        for (index, layer) in self.layers.iter().enumerate() {
132            if layer.weights.nrows() != activation.len() {
133                return Err(OptimError::InvalidConfig(format!(
134                    "encoder layer {index} expects {} inputs but received {}",
135                    layer.weights.nrows(),
136                    activation.len()
137                )));
138            }
139            let out_dim = layer.weights.ncols();
140            if layer.bias.len() != out_dim {
141                return Err(OptimError::InvalidConfig(format!(
142                    "encoder layer {index} has {out_dim} outputs but {} biases",
143                    layer.bias.len()
144                )));
145            }
146            let mut next = Array1::<T>::zeros(out_dim);
147            for j in 0..out_dim {
148                let mut sum = layer.bias[j];
149                for i in 0..activation.len() {
150                    sum = sum + layer.weights[[i, j]] * activation[i];
151                }
152                next[j] = if index == last_index {
153                    sum
154                } else {
155                    activate(sum, self.activation)
156                };
157            }
158            activation = next;
159        }
160
161        Ok(activation)
162    }
163
164    /// Width of the embedding this network produces.
165    ///
166    /// # Errors
167    /// Returns `Err` when the network has no layers.
168    pub fn embedding_width(&self) -> Result<usize> {
169        self.layers
170            .last()
171            .map(|l| l.weights.ncols())
172            .ok_or_else(|| OptimError::InvalidConfig("encoder network has no layers".to_string()))
173    }
174}
175
176#[cfg(test)]
177impl<T: Float + Debug + Send + Sync + 'static> super::PrototypicalNetwork<T> {
178    /// Set one encoder weight. Test-only hook used to prove that the encoder
179    /// genuinely participates in `encode_task`.
180    pub(crate) fn perturb_encoder_weight_for_test(&mut self, row: usize, col: usize, value: T) {
181        if let Some(layer) = self.encoder.layers.first_mut() {
182            if row < layer.weights.nrows() && col < layer.weights.ncols() {
183                layer.weights[[row, col]] = value;
184            }
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    fn network(dims: &[(usize, usize)]) -> EncoderNetwork<f64> {
194        EncoderNetwork {
195            layers: dims
196                .iter()
197                .map(|&(i, o)| xavier_encoder_layer::<f64>(i, o, LayerType::Linear).expect("layer"))
198                .collect(),
199            activation: ActivationFunction::ReLU,
200        }
201    }
202
203    #[test]
204    fn xavier_layers_are_not_zero() {
205        let layer = xavier_encoder_layer::<f64>(6, 4, LayerType::Linear).expect("layer");
206        let magnitude = layer.weights.iter().fold(0.0_f64, |a, &w| a.max(w.abs()));
207        assert!(magnitude > 0.0, "encoder weights are all zero");
208        let bound = (6.0_f64 / 10.0).sqrt();
209        assert!(
210            layer.weights.iter().all(|w| w.abs() <= bound + 1e-12),
211            "a weight escaped the Xavier bound {bound}"
212        );
213    }
214
215    #[test]
216    fn rejects_zero_dimensions() {
217        assert!(xavier_encoder_layer::<f64>(0, 4, LayerType::Linear).is_err());
218        assert!(xavier_encoder_layer::<f64>(4, 0, LayerType::Linear).is_err());
219    }
220
221    #[test]
222    fn forward_produces_the_embedding_width() {
223        let net = network(&[(6, 5), (5, 3)]);
224        assert_eq!(net.embedding_width().expect("width"), 3);
225        let embedded = net
226            .forward(&Array1::from_vec(vec![1.0, -0.5, 0.25, 2.0, -1.0, 0.1]))
227            .expect("forward");
228        assert_eq!(embedded.len(), 3);
229        assert!(embedded.iter().all(|v| v.is_finite()));
230    }
231
232    #[test]
233    fn forward_depends_on_its_input() {
234        let net = network(&[(4, 3)]);
235        let a = net
236            .forward(&Array1::from_vec(vec![1.0, 0.0, 0.0, 0.0]))
237            .expect("forward on the first one-hot input");
238        let b = net
239            .forward(&Array1::from_vec(vec![0.0, 1.0, 0.0, 0.0]))
240            .expect("forward on the second one-hot input");
241        let delta = a
242            .iter()
243            .zip(b.iter())
244            .map(|(x, y)| (x - y).abs())
245            .fold(0.0, f64::max);
246        assert!(
247            delta > 1e-9,
248            "the encoder ignores its input (delta {delta})"
249        );
250    }
251
252    #[test]
253    fn short_and_long_features_are_padded_and_truncated() {
254        let net = network(&[(4, 2)]);
255        assert!(net.forward(&Array1::from_vec(vec![1.0, 2.0])).is_ok());
256        assert!(net
257            .forward(&Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]))
258            .is_ok());
259        // Padding must be zeros, so a short vector equals the same vector
260        // explicitly zero-extended.
261        let short = net
262            .forward(&Array1::from_vec(vec![1.0, 2.0]))
263            .expect("short");
264        let padded = net
265            .forward(&Array1::from_vec(vec![1.0, 2.0, 0.0, 0.0]))
266            .expect("padded");
267        assert_eq!(short, padded);
268    }
269
270    #[test]
271    fn an_empty_network_is_an_error() {
272        let empty = EncoderNetwork::<f64> {
273            layers: Vec::new(),
274            activation: ActivationFunction::ReLU,
275        };
276        assert!(empty.forward(&Array1::from_vec(vec![1.0])).is_err());
277        assert!(empty.embedding_width().is_err());
278    }
279
280    /// F16: `PrototypicalNetwork::encode_task` used to compute a truncated
281    /// coordinate-wise mean of the **raw** support features and never call
282    /// `self.encoder`. It must now embed each example and average the embeddings,
283    /// which is observable two ways: the result is not the raw feature mean, and
284    /// perturbing an encoder weight changes it.
285    #[test]
286    fn encode_task_goes_through_the_encoder() {
287        use crate::few_shot::{
288            DifficultyLevel, DomainCharacteristics, DomainInfo, DomainType, ExampleMetadata,
289            PrototypicalNetwork, PrototypicalNetworkConfig, QuerySet, QuerySetStatistics,
290            SupportExample, SupportSet, SupportSetStatistics, TaskData, TaskMetadata,
291        };
292        use std::collections::HashMap;
293
294        let features = [
295            Array1::from_vec(vec![1.0_f64, 2.0, 3.0, 4.0]),
296            Array1::from_vec(vec![-1.0_f64, 0.5, 2.0, -3.0]),
297        ];
298        let examples: Vec<SupportExample<f64>> = features
299            .iter()
300            .map(|f| SupportExample {
301                features: f.clone(),
302                target: 0.0,
303                weight: 1.0,
304                context: HashMap::new(),
305                metadata: ExampleMetadata {
306                    source: "test".to_string(),
307                    quality_score: 1.0,
308                    created_at: std::time::SystemTime::now(),
309                },
310            })
311            .collect();
312
313        let task = TaskData::<f64> {
314            task_id: "f16".to_string(),
315            support_set: SupportSet {
316                examples,
317                task_metadata: TaskMetadata {
318                    task_name: "f16".to_string(),
319                    domain: DomainType::Optimization,
320                    difficulty: DifficultyLevel::Easy,
321                    created_at: std::time::SystemTime::now(),
322                },
323                statistics: SupportSetStatistics {
324                    mean: Array1::zeros(4),
325                    variance: Array1::zeros(4),
326                    size: 2,
327                    diversity_score: 1.0,
328                },
329                temporal_order: None,
330            },
331            query_set: QuerySet {
332                examples: Vec::new(),
333                statistics: QuerySetStatistics {
334                    mean: Array1::zeros(4),
335                    variance: Array1::zeros(4),
336                    size: 0,
337                },
338                eval_metrics: Vec::new(),
339            },
340            task_params: HashMap::new(),
341            domain_info: DomainInfo {
342                domain_type: DomainType::Optimization,
343                characteristics: DomainCharacteristics {
344                    input_dim: 4,
345                    output_dim: 1,
346                    temporal: false,
347                    stochasticity: 0.0,
348                    noise_level: 0.0,
349                    sparsity: 0.0,
350                },
351                difficulty_level: DifficultyLevel::Easy,
352                constraints: Vec::new(),
353            },
354        };
355
356        let mut network = PrototypicalNetwork::<f64>::new(PrototypicalNetworkConfig {
357            embedding_dim: 3,
358            learning_rate: 0.01,
359            num_layers: 1,
360            hidden_dim: 4,
361        })
362        .expect("network");
363
364        let encoded = network.encode_task(&task).expect("encode_task");
365        assert_eq!(
366            encoded.len(),
367            3,
368            "embedding width must come from the encoder"
369        );
370
371        // The raw truncated feature mean the old code returned.
372        let raw_mean = [
373            (features[0][0] + features[1][0]) / 2.0,
374            (features[0][1] + features[1][1]) / 2.0,
375            (features[0][2] + features[1][2]) / 2.0,
376        ];
377        let to_raw = encoded
378            .iter()
379            .zip(raw_mean.iter())
380            .map(|(a, b)| (a - b).abs())
381            .fold(0.0_f64, f64::max);
382        assert!(
383            to_raw > 1e-9,
384            "encode_task still returns the raw feature mean (delta {to_raw})"
385        );
386
387        // Changing an encoder weight must change the embedding. Use input row 1:
388        // input row 0 carries features 1.0 and -1.0, whose mean is exactly zero,
389        // so a weight on that row cannot move the averaged embedding at all.
390        network.perturb_encoder_weight_for_test(1, 0, 5.0);
391        let after = network.encode_task(&task).expect("encode_task again");
392        let delta = encoded
393            .iter()
394            .zip(after.iter())
395            .map(|(a, b)| (a - b).abs())
396            .fold(0.0_f64, f64::max);
397        assert!(
398            delta > 1e-9,
399            "the encoder weights do not affect the embedding (delta {delta})"
400        );
401    }
402
403    #[test]
404    fn swish_and_mish_stay_finite_for_large_inputs() {
405        for a in [ActivationFunction::Swish, ActivationFunction::Mish] {
406            for x in [-1e3_f64, -10.0, 0.0, 10.0, 1e3] {
407                let y = activate(x, a);
408                assert!(y.is_finite(), "{a:?} at {x} produced {y}");
409            }
410        }
411    }
412}