use ndarray::Array1;
use rand::Rng;
pub fn softmax(logits: &Array1<f32>) -> Array1<f32> {
let max = logits.fold(f32::NEG_INFINITY, |a, &b| a.max(b));
let exp = logits.mapv(|x| (x - max).exp());
let sum = exp.sum();
exp / sum
}
fn sample_gumbel(n: usize, rng: &mut impl Rng) -> Array1<f32> {
Array1::from_iter((0..n).map(|_| {
let u: f32 = rng.gen_range(1e-7..1.0 - 1e-7);
-(-u.ln()).ln()
}))
}
#[derive(Debug, Clone)]
pub struct GumbelSample {
pub soft: Array1<f32>,
pub adhesion: Array1<f32>,
pub winner: usize,
}
pub fn gumbel_softmax(logits: &Array1<f32>, tau: f32, hard: bool, rng: &mut impl Rng) -> GumbelSample {
assert!(tau > 0.0, "gumbel_softmax: tau must be positive, got {tau}");
assert!(!logits.is_empty(), "gumbel_softmax: empty logits");
let g = sample_gumbel(logits.len(), rng);
let perturbed = logits / tau + &g;
let soft = softmax(&perturbed);
let winner = soft
.iter()
.enumerate()
.max_by(|a, b| a.1.total_cmp(b.1))
.map(|(i, _)| i)
.expect("non-empty logits");
let adhesion = if hard {
let mut one_hot = Array1::zeros(soft.len());
one_hot[winner] = 1.0;
one_hot
} else {
soft.clone()
};
GumbelSample { soft, adhesion, winner }
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::array;
use rand::{rngs::StdRng, SeedableRng};
#[test]
fn hard_sample_is_one_hot() {
let mut rng = StdRng::seed_from_u64(7);
let s = gumbel_softmax(&array![0.1, 2.0, -1.0], 0.5, true, &mut rng);
assert_eq!(s.adhesion.iter().filter(|&&x| x == 1.0).count(), 1);
assert!((s.adhesion.sum() - 1.0).abs() < 1e-6);
assert_eq!(s.adhesion[s.winner], 1.0);
}
#[test]
fn soft_sample_sums_to_one() {
let mut rng = StdRng::seed_from_u64(7);
let s = gumbel_softmax(&array![0.0, 0.0, 0.0, 0.0], 1.0, false, &mut rng);
assert!((s.adhesion.sum() - 1.0).abs() < 1e-5);
}
#[test]
fn seeded_draws_are_deterministic() {
let logits = array![1.0, 0.5, 0.2];
let a = gumbel_softmax(&logits, 0.3, true, &mut StdRng::seed_from_u64(42));
let b = gumbel_softmax(&logits, 0.3, true, &mut StdRng::seed_from_u64(42));
assert_eq!(a.winner, b.winner);
assert_eq!(a.soft, b.soft);
}
#[test]
fn low_temperature_tracks_dominant_logit() {
let logits = array![10.0, 0.0, 0.0];
let mut rng = StdRng::seed_from_u64(1);
let wins = (0..200)
.filter(|_| gumbel_softmax(&logits, 0.1, true, &mut rng).winner == 0)
.count();
assert!(wins >= 198, "dominant logit won only {wins}/200 draws");
}
#[test]
fn tau_genuinely_changes_the_hard_decision() {
let logits = array![1.0, 0.0];
let mut rng = StdRng::seed_from_u64(11);
let low_tau_wins = (0..300).filter(|_| gumbel_softmax(&logits, 0.05, true, &mut rng).winner == 0).count();
let high_tau_wins =
(0..300).filter(|_| gumbel_softmax(&logits, 20.0, true, &mut rng).winner == 0).count();
assert!(low_tau_wins >= 295, "low tau should track the true winner nearly always, got {low_tau_wins}/300");
assert!(
(100..200).contains(&high_tau_wins),
"high tau should wash out the gap toward a coin flip, got {high_tau_wins}/300"
);
}
}