optirs-core 0.3.2

OptiRS core optimization algorithms and utilities
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
// K-FAC layer state management
//
// This module contains the layer-specific state for K-FAC optimization,
// including covariance matrices and their inverses.

use super::config::LayerInfo;
use crate::error::{OptimError, Result};
use scirs2_core::ndarray::{s, Array1, Array2};
use scirs2_core::numeric::Float;
use std::fmt::Debug;

/// K-FAC optimizer state for a single layer
#[derive(Debug, Clone)]
pub struct KFACLayerState<T: Float + Debug + Send + Sync + 'static> {
    /// Input covariance matrix A = E[a a^T]
    pub a_cov: Array2<T>,

    /// Output gradient covariance matrix G = E[g g^T]
    pub g_cov: Array2<T>,

    /// Inverse of input covariance matrix
    pub a_cov_inv: Option<Array2<T>>,

    /// Inverse of output gradient covariance matrix
    pub g_cov_inv: Option<Array2<T>>,

    /// Number of updates performed
    pub num_updates: usize,

    /// Last update step for covariance matrices
    pub last_cov_update: usize,

    /// Last update step for inverse matrices
    pub last_inv_update: usize,

    /// Damping values for this layer
    pub damping_a: T,
    pub damping_g: T,

    /// Layer information
    pub layerinfo: LayerInfo,

    /// Precomputed Kronecker factors for bias
    pub bias_correction: Option<Array1<T>>,

    /// Moving average statistics
    pub running_mean_a: Option<Array1<T>>,
    pub running_mean_g: Option<Array1<T>>,
}

impl<
        T: Float
            + Debug
            + Send
            + Sync
            + 'static
            + scirs2_core::ndarray::ScalarOperand
            + scirs2_core::numeric::FromPrimitive,
    > KFACLayerState<T>
{
    /// Create a new layer state for the given layer
    pub fn new(layer_info: LayerInfo, initial_damping: T) -> Self {
        let input_size = layer_info.input_cov_size();
        let output_size = layer_info.output_cov_size();

        Self {
            a_cov: Array2::eye(input_size),
            g_cov: Array2::eye(output_size),
            a_cov_inv: None,
            g_cov_inv: None,
            num_updates: 0,
            last_cov_update: 0,
            last_inv_update: 0,
            damping_a: initial_damping,
            damping_g: initial_damping,
            layerinfo: layer_info,
            bias_correction: None,
            running_mean_a: None,
            running_mean_g: None,
        }
    }

    /// Initialize moving average statistics
    pub fn init_running_stats(&mut self) {
        let input_size = self.layerinfo.input_cov_size();
        let output_size = self.layerinfo.output_cov_size();

        self.running_mean_a = Some(Array1::zeros(input_size));
        self.running_mean_g = Some(Array1::zeros(output_size));
    }

    /// Update the input covariance matrix with new activations.
    ///
    /// `activations` has shape `[batch, input_dim]`. When the layer carries a bias, a
    /// homogeneous column of ones is appended so the bias is folded into the Kronecker
    /// factor `A`.
    ///
    /// # Errors
    ///
    /// Returns [`OptimError::DimensionMismatch`] if the activation width does not match
    /// the layer's registered input covariance size.
    pub fn update_input_covariance(&mut self, activations: &Array2<T>, decay: T) -> Result<()> {
        let batch_size = activations.nrows();
        if batch_size == 0 {
            return Ok(());
        }

        // Add bias term if needed
        let input_data = self.homogeneous_input(activations)?;

        // Compute the uncentered second moment
        let batch_cov = Self::second_moment(&input_data);

        // Update running covariance with exponential moving average
        self.a_cov = &self.a_cov * decay + &batch_cov * (T::one() - decay);
        self.num_updates += 1;
        Ok(())
    }

    /// Update the output gradient covariance matrix.
    ///
    /// `gradients` has shape `[batch, output_dim]`.
    ///
    /// # Errors
    ///
    /// Returns [`OptimError::DimensionMismatch`] if the gradient width does not match
    /// the layer's registered output covariance size.
    pub fn update_output_covariance(&mut self, gradients: &Array2<T>, decay: T) -> Result<()> {
        let batch_size = gradients.nrows();
        if batch_size == 0 {
            return Ok(());
        }

        let expected = self.layerinfo.output_cov_size();
        if gradients.ncols() != expected {
            return Err(OptimError::DimensionMismatch(format!(
                "layer '{}': output gradients have {} columns, expected {}",
                self.layerinfo.name,
                gradients.ncols(),
                expected
            )));
        }

        // Compute the uncentered second moment
        let batch_cov = Self::second_moment(gradients);

        // Update running covariance with exponential moving average
        self.g_cov = &self.g_cov * decay + &batch_cov * (T::one() - decay);
        Ok(())
    }

    /// Build the dense-layer weight gradient `[output_dim, input_cov_size]` from
    /// per-sample activations `[batch, input_dim]` and output gradients
    /// `[batch, output_dim]`.
    ///
    /// This is `grad_W = (1/n) * G^T · A_hom`, matching the `A ⊗ G` Kronecker
    /// factorization: the resulting matrix is exactly what
    /// [`super::core::KFAC::apply_update_weight`] preconditions.
    ///
    /// # Errors
    ///
    /// Returns [`OptimError::DimensionMismatch`] if the batch dimensions disagree or
    /// either width does not match the registered layer dimensions.
    pub fn weight_gradient(
        &self,
        activations: &Array2<T>,
        output_gradients: &Array2<T>,
    ) -> Result<Array2<T>> {
        let batch = activations.nrows();
        if batch != output_gradients.nrows() {
            return Err(OptimError::DimensionMismatch(format!(
                "layer '{}': activations have {} rows but output gradients have {}",
                self.layerinfo.name,
                batch,
                output_gradients.nrows()
            )));
        }

        let expected_out = self.layerinfo.output_cov_size();
        if output_gradients.ncols() != expected_out {
            return Err(OptimError::DimensionMismatch(format!(
                "layer '{}': output gradients have {} columns, expected {}",
                self.layerinfo.name,
                output_gradients.ncols(),
                expected_out
            )));
        }

        let input_data = self.homogeneous_input(activations)?;

        if batch == 0 {
            return Ok(Array2::zeros((expected_out, input_data.ncols())));
        }

        let scale = T::from_usize(batch).unwrap_or_else(T::one);
        Ok(output_gradients.t().dot(&input_data) / scale)
    }

    /// Compute the inverse of covariance matrices with regularization
    pub fn compute_inverses(&mut self, damping_a: T, damping_g: T) -> Result<()> {
        self.damping_a = damping_a;
        self.damping_g = damping_g;

        // Compute regularized inverse of input covariance
        let mut a_reg = self.a_cov.clone();
        for i in 0..a_reg.nrows() {
            a_reg[[i, i]] = a_reg[[i, i]] + damping_a;
        }

        self.a_cov_inv = Some(self.compute_matrix_inverse(&a_reg)?);

        // Compute regularized inverse of output gradient covariance
        let mut g_reg = self.g_cov.clone();
        for i in 0..g_reg.nrows() {
            g_reg[[i, i]] = g_reg[[i, i]] + damping_g;
        }

        self.g_cov_inv = Some(self.compute_matrix_inverse(&g_reg)?);

        self.last_inv_update = self.num_updates;
        Ok(())
    }

    /// Get the condition number estimate of covariance matrices
    pub fn condition_number_estimate(&self) -> (T, T) {
        let a_cond = self.estimate_condition_number(&self.a_cov);
        let g_cond = self.estimate_condition_number(&self.g_cov);
        (a_cond, g_cond)
    }

    /// Check if the layer state is ready for optimization (inverses computed)
    pub fn is_ready(&self) -> bool {
        self.a_cov_inv.is_some() && self.g_cov_inv.is_some()
    }

    /// Get memory usage estimate in bytes
    pub fn memory_usage(&self) -> usize {
        let float_size = std::mem::size_of::<T>();
        let mut size = 0;

        // Covariance matrices
        size += self.a_cov.len() * float_size;
        size += self.g_cov.len() * float_size;

        // Inverse matrices
        if let Some(ref inv) = self.a_cov_inv {
            size += inv.len() * float_size;
        }
        if let Some(ref inv) = self.g_cov_inv {
            size += inv.len() * float_size;
        }

        // Running statistics
        if let Some(ref mean) = self.running_mean_a {
            size += mean.len() * float_size;
        }
        if let Some(ref mean) = self.running_mean_g {
            size += mean.len() * float_size;
        }

        // Bias correction
        if let Some(ref bias) = self.bias_correction {
            size += bias.len() * float_size;
        }

        size
    }

    /// Reset the layer state
    pub fn reset(&mut self) {
        let input_size = self.layerinfo.input_cov_size();
        let output_size = self.layerinfo.output_cov_size();

        self.a_cov = Array2::eye(input_size);
        self.g_cov = Array2::eye(output_size);
        self.a_cov_inv = None;
        self.g_cov_inv = None;
        self.num_updates = 0;
        self.last_cov_update = 0;
        self.last_inv_update = 0;
        self.bias_correction = None;

        if self.running_mean_a.is_some() {
            self.running_mean_a = Some(Array1::zeros(input_size));
        }
        if self.running_mean_g.is_some() {
            self.running_mean_g = Some(Array1::zeros(output_size));
        }
    }

    // Private helper methods

    fn add_bias_column(&self, activations: &Array2<T>) -> Array2<T> {
        let (batch_size, input_dim) = activations.dim();
        let mut result = Array2::ones((batch_size, input_dim + 1));
        result.slice_mut(s![.., ..input_dim]).assign(activations);
        result
    }

    /// Return the activations in the layout the Kronecker factor `A` expects,
    /// appending the homogeneous bias column when the registered covariance size
    /// calls for it.
    fn homogeneous_input(&self, activations: &Array2<T>) -> Result<Array2<T>> {
        let expected = self.layerinfo.input_cov_size();
        let width = activations.ncols();

        if expected == width {
            Ok(activations.clone())
        } else if expected == width + 1 {
            Ok(self.add_bias_column(activations))
        } else {
            Err(OptimError::DimensionMismatch(format!(
                "layer '{}': activations have {} columns, expected {} (or {} plus the bias column)",
                self.layerinfo.name,
                width,
                expected,
                expected.saturating_sub(1)
            )))
        }
    }

    /// Uncentered second moment `E[x x^T] = (1/n) * X^T X`.
    ///
    /// K-FAC's Kronecker factors are second moments, **not** mean-centered
    /// covariances. Centering is wrong twice over here: it discards the mean, which
    /// carries genuine curvature information in the Fisher approximation, and it zeroes
    /// out the homogeneous bias column (whose entries are all ones), making `A`
    /// structurally singular for every layer with a bias.
    fn second_moment(data: &Array2<T>) -> Array2<T> {
        let batch_size = data.nrows();
        if batch_size == 0 {
            return Array2::eye(data.ncols());
        }

        let n = T::from_usize(batch_size).unwrap_or_else(T::one);
        data.t().dot(data) / n
    }

    fn compute_matrix_inverse(&self, matrix: &Array2<T>) -> Result<Array2<T>> {
        // Robust general inversion: Gauss-Jordan elimination with partial pivoting
        // and K-FAC-style Tikhonov damping on (near-)singular inputs. Implemented
        // once in `kfac::utils` and shared with the natural-gradient path so the
        // Kronecker-factor inverses are real (not a silent identity).
        if matrix.nrows() != matrix.ncols() {
            return Err(OptimError::InvalidParameter(
                "Matrix must be square".to_string(),
            ));
        }

        super::utils::general_matrix_inverse(matrix)
    }

    fn estimate_condition_number(&self, matrix: &Array2<T>) -> T {
        let mut max_diag = T::zero();
        let mut min_diag = T::infinity();

        for i in 0..matrix.nrows() {
            let diag = matrix[[i, i]];
            if diag > max_diag {
                max_diag = diag;
            }
            if diag < min_diag {
                min_diag = diag;
            }
        }

        if min_diag > T::zero() {
            max_diag / min_diag
        } else {
            T::infinity()
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::second_order::kfac::config::{LayerInfo, LayerType};

    #[test]
    fn test_layer_state_creation() {
        let layer_info = LayerInfo {
            name: "test_layer".to_string(),
            input_dim: 128,
            output_dim: 64,
            layer_type: LayerType::Dense,
            has_bias: true,
        };

        let state = KFACLayerState::<f32>::new(layer_info, 0.001);

        assert_eq!(state.a_cov.nrows(), 129); // +1 for bias
        assert_eq!(state.g_cov.nrows(), 64);
        assert!(!state.is_ready()); // No inverses computed yet
    }

    #[test]
    fn test_covariance_update() {
        let layer_info = LayerInfo {
            name: "test_layer".to_string(),
            input_dim: 4,
            output_dim: 2,
            layer_type: LayerType::Dense,
            has_bias: false,
        };

        let mut state = KFACLayerState::<f64>::new(layer_info, 0.001);
        let activations =
            Array2::from_shape_vec((2, 4), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0])
                .expect("Array2::from_shape_vec succeeds in test_covariance_update");

        state
            .update_input_covariance(&activations, 0.95)
            .expect("covariance update succeeds");

        assert_eq!(state.num_updates, 1);
        assert!(state.a_cov[[0, 0]] != 1.0); // Should have changed from identity
    }

    #[test]
    fn test_input_covariance_is_uncentered_and_keeps_bias_column() {
        // Regression for the mean-centered covariance bug: K-FAC's Kronecker factor is
        // the *uncentered* second moment E[a a^T]. Centering zeroes the homogeneous
        // bias column (all ones), which makes A structurally singular.
        let layer_info = LayerInfo {
            name: "dense".to_string(),
            input_dim: 2,
            output_dim: 2,
            layer_type: LayerType::Dense,
            has_bias: true,
        };
        let mut state = KFACLayerState::<f64>::new(layer_info, 0.0);

        // Batch of 3 samples, 2 input features (full rank once homogenized).
        let activations = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 7.0])
            .expect("shape is valid");

        // decay = 0 so a_cov is exactly the batch statistic.
        state
            .update_input_covariance(&activations, 0.0)
            .expect("covariance update succeeds");

        assert_eq!(state.a_cov.dim(), (3, 3));

        // Expected A = (1/3) * Â^T  with  = [[1,2,1],[3,4,1],[5,7,1]].
        let expected = [
            [35.0 / 3.0, 49.0 / 3.0, 3.0],
            [49.0 / 3.0, 23.0, 13.0 / 3.0],
            [3.0, 13.0 / 3.0, 1.0],
        ];
        for (i, row) in expected.iter().enumerate() {
            for (j, &exp) in row.iter().enumerate() {
                assert!(
                    (state.a_cov[[i, j]] - exp).abs() < 1e-12,
                    "A[{},{}] = {}, expected {}",
                    i,
                    j,
                    state.a_cov[[i, j]],
                    exp
                );
            }
        }

        // The bias block must be E[1*1] = 1, not the 0 that centering produces.
        assert!((state.a_cov[[2, 2]] - 1.0).abs() < 1e-12);

        // And the factor must be invertible (centering made it singular).
        state
            .compute_inverses(0.0, 0.0)
            .expect("uncentered A is invertible");
        let a_inv = state.a_cov_inv.as_ref().expect("a_inv present");
        let prod = state.a_cov.dot(a_inv);
        for i in 0..3 {
            for j in 0..3 {
                let expected_entry = if i == j { 1.0 } else { 0.0 };
                assert!((prod[[i, j]] - expected_entry).abs() < 1e-8);
            }
        }
    }

    #[test]
    fn test_weight_gradient_shape_and_value() {
        let layer_info = LayerInfo {
            name: "dense".to_string(),
            input_dim: 2,
            output_dim: 3,
            layer_type: LayerType::Dense,
            has_bias: false,
        };
        let state = KFACLayerState::<f64>::new(layer_info, 0.001);

        // batch = 2
        let activations =
            Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).expect("shape is valid");
        let out_grads = Array2::from_shape_vec((2, 3), vec![1.0, 0.0, -1.0, 2.0, 1.0, 0.0])
            .expect("shape is valid");

        let grad_w = state
            .weight_gradient(&activations, &out_grads)
            .expect("weight gradient computed");
        assert_eq!(grad_w.dim(), (3, 2));

        // grad_W = (1/2) * G^T A
        let expected = [[3.5, 5.0], [1.5, 2.0], [-0.5, -1.0]];
        for i in 0..3 {
            for j in 0..2 {
                assert!((grad_w[[i, j]] - expected[i][j]).abs() < 1e-12);
            }
        }

        // Mismatched batch dimension is an error, not a panic.
        let bad = Array2::<f64>::zeros((3, 3));
        assert!(state.weight_gradient(&activations, &bad).is_err());
    }

    #[test]
    fn test_condition_number_estimation() {
        let layer_info = LayerInfo {
            name: "test_layer".to_string(),
            input_dim: 3,
            output_dim: 3,
            layer_type: LayerType::Dense,
            has_bias: false,
        };

        let state = KFACLayerState::<f32>::new(layer_info, 0.001);
        let (a_cond, g_cond) = state.condition_number_estimate();

        // Identity matrix should have condition number 1
        assert!((a_cond - 1.0).abs() < 1e-6);
        assert!((g_cond - 1.0).abs() < 1e-6);
    }

    #[test]
    fn test_compute_inverses_are_real_not_identity() {
        // End-to-end KFAC path: drive non-trivial covariances into the layer state
        // and confirm the computed inverses are genuine (A · A_inv ≈ I) and are NOT
        // a silent identity (the previous bug).
        let layer_info = LayerInfo {
            name: "dense".to_string(),
            input_dim: 4,
            output_dim: 4,
            layer_type: LayerType::Dense,
            has_bias: false,
        };
        let mut state = KFACLayerState::<f64>::new(layer_info, 0.0);

        // Build a non-identity SPD input covariance: A = B^T B + I.
        let b = Array2::from_shape_vec(
            (4, 4),
            vec![
                1.0, 0.5, -0.3, 0.2, 0.0, 1.2, 0.7, -0.4, 0.3, -0.1, 0.9, 0.6, -0.2, 0.4, 0.1, 1.1,
            ],
        )
        .expect("shape");
        let mut a_cov = b.t().dot(&b);
        for i in 0..4 {
            a_cov[[i, i]] += 1.0;
        }
        state.a_cov = a_cov.clone();
        // A different non-identity SPD output covariance.
        let mut g_cov = Array2::<f64>::eye(4) * 3.0;
        g_cov[[0, 1]] = 0.5;
        g_cov[[1, 0]] = 0.5;
        g_cov[[2, 3]] = -0.7;
        g_cov[[3, 2]] = -0.7;
        state.g_cov = g_cov.clone();

        // Use zero damping so we can verify the inverse of the raw covariance.
        state.compute_inverses(0.0, 0.0).expect("inverses computed");
        assert!(state.is_ready());

        let a_inv = state.a_cov_inv.as_ref().expect("a_inv present");
        let g_inv = state.g_cov_inv.as_ref().expect("g_inv present");

        // Real inverse: A · A_inv ≈ I and G · G_inv ≈ I.
        let a_prod = a_cov.dot(a_inv);
        let g_prod = g_cov.dot(g_inv);
        let identity: Array2<f64> = Array2::eye(4);
        for i in 0..4 {
            for j in 0..4 {
                assert!((a_prod[[i, j]] - identity[[i, j]]).abs() < 1e-6);
                assert!((g_prod[[i, j]] - identity[[i, j]]).abs() < 1e-6);
            }
        }

        // Regression: the inverse must NOT be the identity for a non-identity input.
        let mut a_inv_is_identity = true;
        for i in 0..4 {
            for j in 0..4 {
                if (a_inv[[i, j]] - identity[[i, j]]).abs() > 1e-9 {
                    a_inv_is_identity = false;
                }
            }
        }
        assert!(
            !a_inv_is_identity,
            "Kronecker-factor inverse collapsed to identity (the old bug)"
        );
    }

    #[test]
    fn test_memory_usage() {
        let layer_info = LayerInfo {
            name: "test_layer".to_string(),
            input_dim: 100,
            output_dim: 50,
            layer_type: LayerType::Dense,
            has_bias: true,
        };

        let state = KFACLayerState::<f64>::new(layer_info, 0.001);
        let memory_usage = state.memory_usage();

        assert!(memory_usage > 0);
        // Should at least include the covariance matrices
        let expected_minimum = (101 * 101 + 50 * 50) * std::mem::size_of::<f64>();
        assert!(memory_usage >= expected_minimum);
    }
}