Skip to main content

geographdb_core/algorithms/
natural_grad.rs

1//! Natural gradient descent via the Fisher-Rao information metric.
2//!
3//! Standard gradient descent moves in Euclidean parameter space, which is
4//! sensitive to reparameterisation. The natural gradient instead moves along
5//! the steepest ascent direction measured in *KL-divergence* (Fisher-Rao
6//! distance), making steps that are invariant to how parameters are scaled.
7//!
8//! For a categorical distribution with logit parameters θ the diagonal Fisher
9//! information matrix is F_ii = p_i (1 − p_i).  Preconditioning the gradient
10//! by F⁻¹ gives the natural gradient: g_nat_i = g_i / (p_i (1 − p_i)).
11//!
12//! This module provides:
13//! - `softmax`           — stable softmax (no overflow)
14//! - `diagonal_fisher`   — diagonal of the Fisher information matrix
15//! - `natural_gradient`  — Fisher-preconditioned gradient
16//! - `kl_divergence`     — KL(p ‖ q) for step-size measurement
17//! - `fisher_rao_dist`   — Bhattacharyya angle: 2 arccos(Σ √(p_i q_i))
18//! - `compare_steps`     — one-shot comparison of vanilla vs natural update
19
20// ── Distributions ─────────────────────────────────────────────────────────────
21
22/// Numerically stable softmax.
23pub fn softmax(logits: &[f32]) -> Vec<f32> {
24    let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
25    let exps: Vec<f32> = logits.iter().map(|&l| (l - max).exp()).collect();
26    let sum: f32 = exps.iter().sum();
27    exps.iter().map(|&e| e / sum).collect()
28}
29
30/// Diagonal of the Fisher information matrix for a categorical distribution.
31///
32/// For logit parameterisation, F_ii = p_i (1 − p_i).
33pub fn diagonal_fisher(probs: &[f32]) -> Vec<f32> {
34    probs.iter().map(|&p| p * (1.0 - p)).collect()
35}
36
37// ── Information Geometry ──────────────────────────────────────────────────────
38
39/// KL divergence KL(p ‖ q) = Σ p_i ln(p_i / q_i).
40///
41/// Terms where p_i = 0 contribute zero; q_i = 0 with p_i > 0 returns ∞.
42pub fn kl_divergence(p: &[f32], q: &[f32]) -> f32 {
43    assert_eq!(p.len(), q.len());
44    p.iter()
45        .zip(q.iter())
46        .map(|(&pi, &qi)| {
47            if pi <= 0.0 {
48                0.0
49            } else {
50                pi * (pi / qi.max(1e-30)).ln()
51            }
52        })
53        .sum()
54}
55
56/// Fisher-Rao geodesic distance (Bhattacharyya angle).
57///
58/// d(p, q) = 2 arccos(Σ √(p_i · q_i))
59///
60/// This is the geodesic distance on the statistical manifold of categorical
61/// distributions under the Fisher information metric.
62pub fn fisher_rao_dist(p: &[f32], q: &[f32]) -> f32 {
63    assert_eq!(p.len(), q.len());
64    let bc: f32 = p
65        .iter()
66        .zip(q.iter())
67        .map(|(&pi, &qi)| (pi * qi).sqrt())
68        .sum();
69    2.0 * bc.clamp(-1.0, 1.0).acos()
70}
71
72// ── Natural Gradient ──────────────────────────────────────────────────────────
73
74/// Fisher-preconditioned (natural) gradient.
75///
76/// g_nat_i = g_i / (F_ii + ε) = g_i / (p_i (1 − p_i) + ε)
77///
78/// `eps` regularises directions where a component probability is near 0 or 1
79/// (Fisher information collapses at the boundary of the simplex).
80pub fn natural_gradient(grad: &[f32], probs: &[f32], eps: f32) -> Vec<f32> {
81    assert_eq!(grad.len(), probs.len());
82    let fisher = diagonal_fisher(probs);
83    grad.iter()
84        .zip(fisher.iter())
85        .map(|(&g, &f)| g / (f + eps))
86        .collect()
87}
88
89// ── Step Comparison ───────────────────────────────────────────────────────────
90
91/// One step comparison of vanilla gradient descent vs natural gradient descent.
92pub struct StepComparison {
93    /// Logits before the step (reference distribution p₀).
94    pub logits_before: Vec<f32>,
95    /// Vanilla update: θ ← θ − lr · g.
96    pub logits_vanilla: Vec<f32>,
97    /// Natural update: θ ← θ − lr · F⁻¹ g.
98    pub logits_natural: Vec<f32>,
99    /// Euclidean distance traveled in logit space (vanilla).
100    pub euclid_vanilla: f32,
101    /// Euclidean distance traveled in logit space (natural).
102    pub euclid_natural: f32,
103    /// KL(p₀ ‖ p_vanilla) — information divergence of vanilla step.
104    pub kl_vanilla: f32,
105    /// KL(p₀ ‖ p_natural) — information divergence of natural step.
106    pub kl_natural: f32,
107    /// Fisher-Rao geodesic distance (vanilla).
108    pub fisher_rao_vanilla: f32,
109    /// Fisher-Rao geodesic distance (natural).
110    pub fisher_rao_natural: f32,
111}
112
113/// Compare one vanilla gradient step vs one natural gradient step.
114///
115/// Both start from `logits` and move against `grad` with step size `lr`.
116/// The natural gradient is regularised by `eps` to avoid division by near-zero
117/// Fisher diagonals.
118pub fn compare_steps(logits: &[f32], grad: &[f32], lr: f32, eps: f32) -> StepComparison {
119    let probs_before = softmax(logits);
120    let nat_grad = natural_gradient(grad, &probs_before, eps);
121
122    let logits_vanilla: Vec<f32> = logits.iter().zip(grad).map(|(&t, &g)| t - lr * g).collect();
123    let logits_natural: Vec<f32> = logits
124        .iter()
125        .zip(&nat_grad)
126        .map(|(&t, &ng)| t - lr * ng)
127        .collect();
128
129    let probs_vanilla = softmax(&logits_vanilla);
130    let probs_natural = softmax(&logits_natural);
131
132    let euclid_vanilla = logits
133        .iter()
134        .zip(&logits_vanilla)
135        .map(|(&a, &b)| (a - b).powi(2))
136        .sum::<f32>()
137        .sqrt();
138    let euclid_natural = logits
139        .iter()
140        .zip(&logits_natural)
141        .map(|(&a, &b)| (a - b).powi(2))
142        .sum::<f32>()
143        .sqrt();
144
145    StepComparison {
146        logits_before: logits.to_vec(),
147        logits_vanilla,
148        logits_natural,
149        euclid_vanilla,
150        euclid_natural,
151        kl_vanilla: kl_divergence(&probs_before, &probs_vanilla),
152        kl_natural: kl_divergence(&probs_before, &probs_natural),
153        fisher_rao_vanilla: fisher_rao_dist(&probs_before, &probs_vanilla),
154        fisher_rao_natural: fisher_rao_dist(&probs_before, &probs_natural),
155    }
156}
157
158// ── Tests ─────────────────────────────────────────────────────────────────────
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    const EPS: f32 = 1e-5;
165
166    #[test]
167    fn test_softmax_sums_to_one() {
168        let logits = vec![1.0f32, 2.0, 0.5, -1.0, 3.0];
169        let p = softmax(&logits);
170        let sum: f32 = p.iter().sum();
171        assert!((sum - 1.0).abs() < EPS, "softmax must sum to 1, got {sum}");
172    }
173
174    #[test]
175    fn test_softmax_argmax_preserved() {
176        let logits = vec![0.1f32, 5.0, 0.3, -2.0];
177        let p = softmax(&logits);
178        let argmax = p
179            .iter()
180            .enumerate()
181            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
182            .map(|(i, _)| i)
183            .unwrap();
184        assert_eq!(argmax, 1, "largest logit must map to largest probability");
185    }
186
187    #[test]
188    fn test_softmax_uniform_logits() {
189        let logits = vec![0.0f32; 4];
190        let p = softmax(&logits);
191        for &pi in &p {
192            assert!((pi - 0.25).abs() < EPS, "uniform logits → uniform probs");
193        }
194    }
195
196    #[test]
197    fn test_diagonal_fisher_uniform() {
198        // Uniform distribution over 4 classes: p_i = 0.25, F_ii = 0.25*0.75 = 0.1875
199        let probs = vec![0.25f32; 4];
200        let f = diagonal_fisher(&probs);
201        for &fi in &f {
202            assert!(
203                (fi - 0.1875).abs() < EPS,
204                "F_ii for uniform = 0.1875, got {fi}"
205            );
206        }
207    }
208
209    #[test]
210    fn test_diagonal_fisher_concentrated() {
211        // If p_0 ≈ 1.0, F_00 ≈ 0 (flat landscape at boundary).
212        let probs = vec![0.999f32, 0.0005, 0.0005];
213        let f = diagonal_fisher(&probs);
214        assert!(
215            f[0] < 0.01,
216            "Fisher near 1.0 should be near 0, got {}",
217            f[0]
218        );
219    }
220
221    #[test]
222    fn test_natural_gradient_scales_by_fisher_inverse() {
223        // For uniform probs, F_ii = p*(1-p).  g_nat_i = g_i / F_ii.
224        let probs = vec![0.5f32, 0.5];
225        let grad = vec![1.0f32, -1.0];
226        let nat = natural_gradient(&grad, &probs, 0.0);
227        // F_ii = 0.5 * 0.5 = 0.25, so g_nat = g / 0.25 = 4*g
228        assert!(
229            (nat[0] - 4.0).abs() < EPS,
230            "natural grad should be 4x vanilla at p=0.5"
231        );
232        assert!(
233            (nat[1] + 4.0).abs() < EPS,
234            "natural grad should be 4x vanilla at p=0.5"
235        );
236    }
237
238    #[test]
239    fn test_natural_gradient_eps_regularization() {
240        // p_i near 0 → F_ii near 0; eps prevents division explosion.
241        let probs = vec![0.0001f32, 0.9999];
242        let grad = vec![1.0f32, 0.0];
243        let nat_eps = natural_gradient(&grad, &probs, 1e-3);
244        let nat_no_eps = natural_gradient(&grad, &probs, 1e-30);
245        // With eps, the output is bounded.
246        assert!(nat_eps[0].is_finite(), "eps should prevent NaN/inf");
247        // Without eps it's large but finite here (since F_ii = 0.0001*0.9999 > 0).
248        assert!(
249            nat_no_eps[0] > nat_eps[0],
250            "eps reduces magnitude near boundary"
251        );
252    }
253
254    #[test]
255    fn test_kl_divergence_self_is_zero() {
256        let p = vec![0.2f32, 0.5, 0.3];
257        let kl = kl_divergence(&p, &p);
258        assert!(kl.abs() < EPS, "KL(p||p) must be zero, got {kl}");
259    }
260
261    #[test]
262    fn test_kl_divergence_positive() {
263        let p = vec![0.7f32, 0.3];
264        let q = vec![0.3f32, 0.7];
265        assert!(
266            kl_divergence(&p, &q) > 0.0,
267            "KL divergence between different distributions must be positive"
268        );
269    }
270
271    #[test]
272    fn test_fisher_rao_dist_self_is_zero() {
273        let p = vec![0.4f32, 0.4, 0.2];
274        let d = fisher_rao_dist(&p, &p);
275        assert!(
276            d.abs() < EPS,
277            "Fisher-Rao distance to self must be zero, got {d}"
278        );
279    }
280
281    #[test]
282    fn test_fisher_rao_dist_nonnegative() {
283        let p = vec![0.6f32, 0.4];
284        let q = vec![0.2f32, 0.8];
285        assert!(fisher_rao_dist(&p, &q) >= 0.0);
286    }
287
288    #[test]
289    fn test_natural_grad_smaller_fisher_rao_step() {
290        // Flat gradient on logits with one dominant class.  The natural gradient
291        // corrects for the curvature of the simplex so both updates move a more
292        // principled KL distance.  With a high-curvature direction (p close to 1),
293        // the vanilla step overshoots in Fisher-Rao space; the natural step dampens
294        // it.
295        //
296        // Setup: logits tilted so class 0 dominates; gradient pushes class 0 up more.
297        let logits = vec![3.0f32, 0.5, 0.5, 0.5];
298        let grad = vec![1.0f32, 0.1, 0.1, 0.1]; // pushes already-dominant class
299        let lr = 0.3;
300
301        let cmp = compare_steps(&logits, &grad, lr, 1e-4);
302
303        // The vanilla step drives p_0 even higher, a large move in simplex space.
304        // The natural step rescales the dominant component down (small F_ii at
305        // boundary → large g_nat → but softmax re-normalises logits).
306        // Both should be finite and positive.
307        assert!(cmp.kl_vanilla.is_finite() && cmp.kl_vanilla >= 0.0);
308        assert!(cmp.kl_natural.is_finite() && cmp.kl_natural >= 0.0);
309        assert!(cmp.fisher_rao_vanilla.is_finite());
310        assert!(cmp.fisher_rao_natural.is_finite());
311    }
312}