Skip to main content

ipfrs_tensorlogic/
batch_norm.rs

1//! Batch Normalization layer with running statistics tracking.
2//!
3//! Provides [`TensorBatchNorm`] — a full batch normalisation layer that
4//! supports both *training* mode (batch statistics, running stat updates) and
5//! *inference* mode (uses pre-accumulated running statistics).  Learnable
6//! affine parameters γ (gamma / scale) and β (beta / shift) are optional.
7
8/// Execution mode for batch normalisation.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum NormMode {
11    /// Use batch statistics and update running statistics.
12    Training,
13    /// Use accumulated running statistics only (no update).
14    Inference,
15}
16
17/// Configuration for [`TensorBatchNorm`].
18#[derive(Clone, Debug)]
19pub struct BatchNormConfig {
20    /// Number of channels / features (C in an [N, C] input).
21    pub num_features: usize,
22    /// Small constant added to the variance for numerical stability.
23    pub epsilon: f64,
24    /// Momentum for the exponential moving average of running statistics.
25    pub momentum: f64,
26    /// Whether to apply learnable affine parameters γ and β.
27    pub affine: bool,
28}
29
30impl BatchNormConfig {
31    /// Create a `BatchNormConfig` with sensible defaults for `num_features`.
32    pub fn default_for(num_features: usize) -> Self {
33        Self {
34            num_features,
35            epsilon: 1e-5,
36            momentum: 0.1,
37            affine: true,
38        }
39    }
40}
41
42/// Aggregate statistics collected across [`TensorBatchNorm::forward`] calls.
43#[derive(Clone, Debug, Default)]
44pub struct BatchNormStats {
45    /// Total number of `forward` invocations (training + inference).
46    pub total_forward_passes: u64,
47    /// Number of successful `forward` calls in [`NormMode::Training`].
48    pub training_passes: u64,
49    /// Number of successful `forward` calls in [`NormMode::Inference`].
50    pub inference_passes: u64,
51}
52
53/// Batch normalisation layer.
54///
55/// # Layout
56///
57/// Input is expected to have shape `[batch_size, num_features]`.
58///
59/// # Example
60///
61/// ```rust
62/// use ipfrs_tensorlogic::{TensorBatchNorm, BatchNormConfig, NormMode};
63///
64/// let config = BatchNormConfig::default_for(4);
65/// let mut bn = TensorBatchNorm::new(config);
66///
67/// let batch = vec![
68///     vec![1.0_f64, 2.0, 3.0, 4.0],
69///     vec![5.0_f64, 6.0, 7.0, 8.0],
70/// ];
71/// let output = bn.forward(&batch).expect("forward failed");
72/// assert_eq!(output.len(), 2);
73/// assert_eq!(output[0].len(), 4);
74/// ```
75pub struct TensorBatchNorm {
76    /// Layer configuration.
77    pub config: BatchNormConfig,
78    /// Learnable scale parameters, one per feature (γ, initialised to 1.0).
79    pub gamma: Vec<f64>,
80    /// Learnable shift parameters, one per feature (β, initialised to 0.0).
81    pub beta: Vec<f64>,
82    /// Exponential moving average of per-feature batch means.
83    pub running_mean: Vec<f64>,
84    /// Exponential moving average of per-feature batch variances.
85    pub running_var: Vec<f64>,
86    /// Current execution mode.
87    pub mode: NormMode,
88    /// Collected statistics.
89    pub stats: BatchNormStats,
90}
91
92impl TensorBatchNorm {
93    /// Construct a new `TensorBatchNorm` from a [`BatchNormConfig`].
94    pub fn new(config: BatchNormConfig) -> Self {
95        let n = config.num_features;
96        Self {
97            gamma: vec![1.0; n],
98            beta: vec![0.0; n],
99            running_mean: vec![0.0; n],
100            running_var: vec![1.0; n],
101            mode: NormMode::Training,
102            stats: BatchNormStats::default(),
103            config,
104        }
105    }
106
107    /// Switch the layer between training and inference mode.
108    pub fn set_mode(&mut self, mode: NormMode) {
109        self.mode = mode;
110    }
111
112    /// Run a forward pass through the batch normalisation layer.
113    ///
114    /// * `input` — a slice of rows, each of length `num_features`.
115    ///
116    /// Returns `None` when:
117    /// - `input` is empty, or
118    /// - any row does not have exactly `num_features` elements.
119    pub fn forward(&mut self, input: &[Vec<f64>]) -> Option<Vec<Vec<f64>>> {
120        let n = self.config.num_features;
121        let batch = input.len();
122
123        if batch == 0 {
124            return None;
125        }
126        for row in input {
127            if row.len() != n {
128                return None;
129            }
130        }
131
132        let output = match self.mode {
133            NormMode::Training => self.forward_training(input, batch, n),
134            NormMode::Inference => self.forward_inference(input, batch, n),
135        };
136
137        // Update stats regardless of mode.
138        self.stats.total_forward_passes += 1;
139        match self.mode {
140            NormMode::Training => self.stats.training_passes += 1,
141            NormMode::Inference => self.stats.inference_passes += 1,
142        }
143
144        Some(output)
145    }
146
147    // ------------------------------------------------------------------
148    // Internal helpers
149    // ------------------------------------------------------------------
150
151    fn forward_training(&mut self, input: &[Vec<f64>], batch: usize, n: usize) -> Vec<Vec<f64>> {
152        let batch_f = batch as f64;
153        let momentum = self.config.momentum;
154        let epsilon = self.config.epsilon;
155
156        // Per-feature mean and population variance over the batch.
157        let mut batch_mean = vec![0.0_f64; n];
158        let mut batch_var = vec![0.0_f64; n];
159
160        for row in input {
161            for f in 0..n {
162                batch_mean[f] += row[f];
163            }
164        }
165        for m in batch_mean.iter_mut() {
166            *m /= batch_f;
167        }
168
169        for row in input {
170            for f in 0..n {
171                let diff = row[f] - batch_mean[f];
172                batch_var[f] += diff * diff;
173            }
174        }
175        for v in batch_var.iter_mut() {
176            *v /= batch_f; // population variance
177        }
178
179        // Update running statistics (exponential moving average).
180        for f in 0..n {
181            self.running_mean[f] =
182                (1.0 - momentum) * self.running_mean[f] + momentum * batch_mean[f];
183            self.running_var[f] = (1.0 - momentum) * self.running_var[f] + momentum * batch_var[f];
184        }
185
186        // Normalise and apply optional affine transform.
187        self.normalise(input, batch, n, &batch_mean, &batch_var, epsilon)
188    }
189
190    fn forward_inference(&self, input: &[Vec<f64>], batch: usize, n: usize) -> Vec<Vec<f64>> {
191        let epsilon = self.config.epsilon;
192        self.normalise(
193            input,
194            batch,
195            n,
196            &self.running_mean,
197            &self.running_var,
198            epsilon,
199        )
200    }
201
202    /// Shared normalisation kernel.
203    fn normalise(
204        &self,
205        input: &[Vec<f64>],
206        batch: usize,
207        n: usize,
208        mean: &[f64],
209        var: &[f64],
210        epsilon: f64,
211    ) -> Vec<Vec<f64>> {
212        let mut output = vec![vec![0.0_f64; n]; batch];
213        for b in 0..batch {
214            for f in 0..n {
215                let x_hat = (input[b][f] - mean[f]) / (var[f] + epsilon).sqrt();
216                output[b][f] = if self.config.affine {
217                    self.gamma[f] * x_hat + self.beta[f]
218                } else {
219                    x_hat
220                };
221            }
222        }
223        output
224    }
225
226    /// Replace the gamma (scale) parameters.
227    ///
228    /// Returns `false` and leaves `gamma` unchanged when `gamma.len() !=
229    /// num_features`.
230    pub fn set_gamma(&mut self, gamma: Vec<f64>) -> bool {
231        if gamma.len() != self.config.num_features {
232            return false;
233        }
234        self.gamma = gamma;
235        true
236    }
237
238    /// Replace the beta (shift) parameters.
239    ///
240    /// Returns `false` and leaves `beta` unchanged when `beta.len() !=
241    /// num_features`.
242    pub fn set_beta(&mut self, beta: Vec<f64>) -> bool {
243        if beta.len() != self.config.num_features {
244            return false;
245        }
246        self.beta = beta;
247        true
248    }
249
250    /// Borrow the accumulated statistics.
251    pub fn stats(&self) -> &BatchNormStats {
252        &self.stats
253    }
254}
255
256// ---------------------------------------------------------------------------
257// Tests
258// ---------------------------------------------------------------------------
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    // -----------------------------------------------------------------------
265    // Helpers
266    // -----------------------------------------------------------------------
267
268    fn make_bn(num_features: usize) -> TensorBatchNorm {
269        TensorBatchNorm::new(BatchNormConfig::default_for(num_features))
270    }
271
272    /// Compute mean of a flat f64 slice.
273    fn mean(v: &[f64]) -> f64 {
274        v.iter().sum::<f64>() / v.len() as f64
275    }
276
277    /// Compute population variance of a flat f64 slice.
278    fn variance(v: &[f64]) -> f64 {
279        let m = mean(v);
280        v.iter().map(|x| (x - m).powi(2)).sum::<f64>() / v.len() as f64
281    }
282
283    // -----------------------------------------------------------------------
284    // 1. Basic construction
285    // -----------------------------------------------------------------------
286
287    #[test]
288    fn test_new_initialisation() {
289        let bn = make_bn(3);
290        assert_eq!(bn.gamma, vec![1.0, 1.0, 1.0]);
291        assert_eq!(bn.beta, vec![0.0, 0.0, 0.0]);
292        assert_eq!(bn.running_mean, vec![0.0, 0.0, 0.0]);
293        assert_eq!(bn.running_var, vec![1.0, 1.0, 1.0]);
294        assert_eq!(bn.mode, NormMode::Training);
295    }
296
297    // -----------------------------------------------------------------------
298    // 2. forward — Training mode normalises batch
299    // -----------------------------------------------------------------------
300
301    #[test]
302    fn test_training_output_shape() {
303        let mut bn = make_bn(4);
304        let batch = vec![
305            vec![1.0, 2.0, 3.0, 4.0],
306            vec![5.0, 6.0, 7.0, 8.0],
307            vec![9.0, 10.0, 11.0, 12.0],
308        ];
309        let out = bn.forward(&batch).expect("forward returned None");
310        assert_eq!(out.len(), 3);
311        for row in &out {
312            assert_eq!(row.len(), 4);
313        }
314    }
315
316    #[test]
317    fn test_training_output_mean_near_zero() {
318        let mut bn = make_bn(2);
319        // Affine=false so we get raw normalised values.
320        bn.config.affine = false;
321        let batch = vec![
322            vec![1.0, 10.0],
323            vec![2.0, 20.0],
324            vec![3.0, 30.0],
325            vec![4.0, 40.0],
326        ];
327        let out = bn.forward(&batch).expect("forward None");
328
329        // For feature 0: collect the 4 outputs.
330        let f0: Vec<f64> = out.iter().map(|r| r[0]).collect();
331        let f1: Vec<f64> = out.iter().map(|r| r[1]).collect();
332
333        assert!(mean(&f0).abs() < 1e-10, "mean of feature 0 ≈ 0");
334        assert!(mean(&f1).abs() < 1e-10, "mean of feature 1 ≈ 0");
335    }
336
337    #[test]
338    fn test_training_output_var_near_one() {
339        let mut bn = make_bn(2);
340        bn.config.affine = false;
341        let batch = vec![
342            vec![1.0, 10.0],
343            vec![2.0, 20.0],
344            vec![3.0, 30.0],
345            vec![4.0, 40.0],
346        ];
347        let out = bn.forward(&batch).expect("forward None");
348        let f0: Vec<f64> = out.iter().map(|r| r[0]).collect();
349        let f1: Vec<f64> = out.iter().map(|r| r[1]).collect();
350
351        // Population variance of the normalised values should be ≈ 1.
352        let var0 = variance(&f0);
353        let var1 = variance(&f1);
354        assert!((var0 - 1.0).abs() < 1e-4, "var feature 0 ≈ 1, got {var0}");
355        assert!((var1 - 1.0).abs() < 1e-4, "var feature 1 ≈ 1, got {var1}");
356    }
357
358    // -----------------------------------------------------------------------
359    // 3. Running statistics updated with momentum
360    // -----------------------------------------------------------------------
361
362    #[test]
363    fn test_running_mean_updated() {
364        let mut bn = make_bn(1);
365        // With momentum=0.1: new_running = 0.9*0 + 0.1*batch_mean
366        // batch_mean for [3.0, 7.0] = 5.0  → running_mean = 0.5
367        let batch = vec![vec![3.0], vec![7.0]];
368        bn.forward(&batch);
369        let expected = 0.1 * 5.0; // 0.9*0 + 0.1*5
370        assert!(
371            (bn.running_mean[0] - expected).abs() < 1e-12,
372            "running_mean = {}, expected {expected}",
373            bn.running_mean[0]
374        );
375    }
376
377    #[test]
378    fn test_running_var_updated() {
379        let mut bn = make_bn(1);
380        // batch_var for [3, 7] (population) = ((3-5)^2 + (7-5)^2) / 2 = 4
381        // running_var starts at 1.0
382        // new_running_var = 0.9*1.0 + 0.1*4.0 = 0.9 + 0.4 = 1.3
383        let batch = vec![vec![3.0], vec![7.0]];
384        bn.forward(&batch);
385        let expected = 0.9 * 1.0 + 0.1 * 4.0;
386        assert!(
387            (bn.running_var[0] - expected).abs() < 1e-12,
388            "running_var = {}, expected {expected}",
389            bn.running_var[0]
390        );
391    }
392
393    #[test]
394    fn test_running_stats_accumulate_over_multiple_passes() {
395        let mut bn = make_bn(1);
396        let batch1 = vec![vec![0.0], vec![2.0]]; // mean=1, var=1
397        let batch2 = vec![vec![4.0], vec![8.0]]; // mean=6, var=4
398
399        bn.forward(&batch1);
400        let rm1 = bn.running_mean[0]; // 0.9*0 + 0.1*1 = 0.1
401        let rv1 = bn.running_var[0]; // 0.9*1 + 0.1*1 = 1.0
402
403        bn.forward(&batch2);
404        let rm2_expected = 0.9 * rm1 + 0.1 * 6.0;
405        let rv2_expected = 0.9 * rv1 + 0.1 * 4.0;
406
407        assert!((bn.running_mean[0] - rm2_expected).abs() < 1e-12);
408        assert!((bn.running_var[0] - rv2_expected).abs() < 1e-12);
409    }
410
411    // -----------------------------------------------------------------------
412    // 4. Inference mode uses running stats
413    // -----------------------------------------------------------------------
414
415    #[test]
416    fn test_inference_uses_running_stats() {
417        let mut bn = make_bn(1);
418
419        // Manually set running stats so the result is predictable.
420        bn.running_mean[0] = 5.0;
421        bn.running_var[0] = 4.0; // std=2
422        bn.config.affine = false;
423        bn.set_mode(NormMode::Inference);
424
425        let batch = vec![vec![7.0]];
426        let out = bn.forward(&batch).expect("None");
427        // x_hat = (7 - 5) / sqrt(4 + 1e-5) ≈ 1.0
428        let expected = (7.0 - 5.0) / (4.0_f64 + 1e-5).sqrt();
429        assert!((out[0][0] - expected).abs() < 1e-9);
430    }
431
432    #[test]
433    fn test_inference_running_stats_not_updated() {
434        let mut bn = make_bn(1);
435        bn.running_mean[0] = 3.0;
436        bn.running_var[0] = 2.0;
437        bn.set_mode(NormMode::Inference);
438
439        bn.forward(&[vec![10.0], vec![20.0]]);
440        // Running stats should not change.
441        assert!((bn.running_mean[0] - 3.0).abs() < 1e-15);
442        assert!((bn.running_var[0] - 2.0).abs() < 1e-15);
443    }
444
445    // -----------------------------------------------------------------------
446    // 5. Affine transform
447    // -----------------------------------------------------------------------
448
449    #[test]
450    fn test_affine_true_applies_gamma_beta() {
451        let mut bn = make_bn(1);
452        bn.gamma[0] = 2.0;
453        bn.beta[0] = 1.0;
454        bn.config.affine = true;
455
456        // batch: constant  → x_hat = 0 for both (var → epsilon only)
457        // Actually use a proper batch with variance.
458        let batch = vec![vec![1.0], vec![3.0]]; // mean=2, var=1
459        let out = bn.forward(&batch).expect("None");
460
461        // x_hat[0] = (1-2)/sqrt(1+eps) ≈ -1,  y = 2*(-1)+1 = -1
462        // x_hat[1] = (3-2)/sqrt(1+eps) ≈  1,  y = 2*( 1)+1 =  3
463        let eps: f64 = 1e-5;
464        let expected0 = 2.0 * ((1.0 - 2.0) / (1.0_f64 + eps).sqrt()) + 1.0;
465        let expected1 = 2.0 * ((3.0 - 2.0) / (1.0_f64 + eps).sqrt()) + 1.0;
466        assert!((out[0][0] - expected0).abs() < 1e-9);
467        assert!((out[1][0] - expected1).abs() < 1e-9);
468    }
469
470    #[test]
471    fn test_affine_false_no_gamma_beta() {
472        let mut bn = make_bn(1);
473        bn.gamma[0] = 99.0; // should NOT be applied
474        bn.beta[0] = 99.0;
475        bn.config.affine = false;
476
477        let batch = vec![vec![1.0], vec![3.0]]; // mean=2, var=1
478        let out = bn.forward(&batch).expect("None");
479        let eps: f64 = 1e-5;
480        let expected0 = (1.0 - 2.0) / (1.0_f64 + eps).sqrt();
481        let expected1 = (3.0 - 2.0) / (1.0_f64 + eps).sqrt();
482        assert!((out[0][0] - expected0).abs() < 1e-9);
483        assert!((out[1][0] - expected1).abs() < 1e-9);
484    }
485
486    // -----------------------------------------------------------------------
487    // 6. set_gamma / set_beta
488    // -----------------------------------------------------------------------
489
490    #[test]
491    fn test_set_gamma_correct_size() {
492        let mut bn = make_bn(3);
493        assert!(bn.set_gamma(vec![2.0, 3.0, 4.0]));
494        assert_eq!(bn.gamma, vec![2.0, 3.0, 4.0]);
495    }
496
497    #[test]
498    fn test_set_gamma_wrong_size_returns_false() {
499        let mut bn = make_bn(3);
500        let old = bn.gamma.clone();
501        assert!(!bn.set_gamma(vec![1.0, 2.0]));
502        assert_eq!(bn.gamma, old, "gamma must be unchanged after rejection");
503    }
504
505    #[test]
506    fn test_set_beta_correct_size() {
507        let mut bn = make_bn(2);
508        assert!(bn.set_beta(vec![0.5, -0.5]));
509        assert_eq!(bn.beta, vec![0.5, -0.5]);
510    }
511
512    #[test]
513    fn test_set_beta_wrong_size_returns_false() {
514        let mut bn = make_bn(2);
515        let old = bn.beta.clone();
516        assert!(!bn.set_beta(vec![1.0, 2.0, 3.0]));
517        assert_eq!(bn.beta, old);
518    }
519
520    #[test]
521    fn test_set_gamma_empty_wrong_size() {
522        let mut bn = make_bn(2);
523        assert!(!bn.set_gamma(vec![]));
524    }
525
526    #[test]
527    fn test_set_beta_empty_wrong_size() {
528        let mut bn = make_bn(2);
529        assert!(!bn.set_beta(vec![]));
530    }
531
532    // -----------------------------------------------------------------------
533    // 7. forward returns None for invalid input
534    // -----------------------------------------------------------------------
535
536    #[test]
537    fn test_forward_none_empty_batch() {
538        let mut bn = make_bn(3);
539        assert!(bn.forward(&[]).is_none());
540    }
541
542    #[test]
543    fn test_forward_none_wrong_feature_count() {
544        let mut bn = make_bn(3);
545        let bad = vec![vec![1.0, 2.0]]; // only 2 features, expected 3
546        assert!(bn.forward(&bad).is_none());
547    }
548
549    #[test]
550    fn test_forward_none_one_row_wrong_features() {
551        let mut bn = make_bn(4);
552        let batch = vec![
553            vec![1.0, 2.0, 3.0, 4.0],
554            vec![5.0, 6.0, 7.0], // wrong!
555        ];
556        assert!(bn.forward(&batch).is_none());
557    }
558
559    // -----------------------------------------------------------------------
560    // 8. Stats counting
561    // -----------------------------------------------------------------------
562
563    #[test]
564    fn test_stats_training_pass_count() {
565        let mut bn = make_bn(2);
566        let batch = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
567        bn.forward(&batch);
568        bn.forward(&batch);
569        assert_eq!(bn.stats().training_passes, 2);
570        assert_eq!(bn.stats().inference_passes, 0);
571        assert_eq!(bn.stats().total_forward_passes, 2);
572    }
573
574    #[test]
575    fn test_stats_inference_pass_count() {
576        let mut bn = make_bn(2);
577        bn.set_mode(NormMode::Inference);
578        let batch = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
579        bn.forward(&batch);
580        assert_eq!(bn.stats().inference_passes, 1);
581        assert_eq!(bn.stats().training_passes, 0);
582        assert_eq!(bn.stats().total_forward_passes, 1);
583    }
584
585    #[test]
586    fn test_stats_mixed_modes() {
587        let mut bn = make_bn(1);
588        let b = vec![vec![1.0], vec![2.0]];
589        bn.set_mode(NormMode::Training);
590        bn.forward(&b);
591        bn.forward(&b);
592        bn.set_mode(NormMode::Inference);
593        bn.forward(&b);
594        assert_eq!(bn.stats().training_passes, 2);
595        assert_eq!(bn.stats().inference_passes, 1);
596        assert_eq!(bn.stats().total_forward_passes, 3);
597    }
598
599    #[test]
600    fn test_stats_not_incremented_on_none() {
601        let mut bn = make_bn(2);
602        bn.forward(&[]); // returns None
603        assert_eq!(bn.stats().total_forward_passes, 0);
604    }
605
606    // -----------------------------------------------------------------------
607    // 9. Epsilon / momentum config
608    // -----------------------------------------------------------------------
609
610    #[test]
611    fn test_custom_epsilon() {
612        let config = BatchNormConfig {
613            num_features: 1,
614            epsilon: 1.0,
615            momentum: 0.1,
616            affine: false,
617        };
618        let mut bn = TensorBatchNorm::new(config);
619        // batch: [1, 3], mean=2, var=1
620        // x_hat[0] = (1-2)/sqrt(1+1) = -1/sqrt(2)
621        let batch = vec![vec![1.0], vec![3.0]];
622        let out = bn.forward(&batch).expect("None");
623        let expected = (1.0 - 2.0) / (1.0_f64 + 1.0).sqrt();
624        assert!((out[0][0] - expected).abs() < 1e-9);
625    }
626
627    #[test]
628    fn test_custom_momentum() {
629        let config = BatchNormConfig {
630            num_features: 1,
631            epsilon: 1e-5,
632            momentum: 0.5, // high momentum
633            affine: false,
634        };
635        let mut bn = TensorBatchNorm::new(config);
636        // batch_mean = 2, running_mean starts at 0
637        // new_running_mean = 0.5*0 + 0.5*2 = 1.0
638        let batch = vec![vec![1.0], vec![3.0]];
639        bn.forward(&batch);
640        assert!((bn.running_mean[0] - 1.0).abs() < 1e-12);
641    }
642
643    // -----------------------------------------------------------------------
644    // 10. set_mode
645    // -----------------------------------------------------------------------
646
647    #[test]
648    fn test_set_mode_switches_correctly() {
649        let mut bn = make_bn(1);
650        assert_eq!(bn.mode, NormMode::Training);
651        bn.set_mode(NormMode::Inference);
652        assert_eq!(bn.mode, NormMode::Inference);
653        bn.set_mode(NormMode::Training);
654        assert_eq!(bn.mode, NormMode::Training);
655    }
656
657    // -----------------------------------------------------------------------
658    // 11. default_for
659    // -----------------------------------------------------------------------
660
661    #[test]
662    fn test_default_for_values() {
663        let cfg = BatchNormConfig::default_for(8);
664        assert_eq!(cfg.num_features, 8);
665        assert!((cfg.epsilon - 1e-5).abs() < 1e-15);
666        assert!((cfg.momentum - 0.1).abs() < 1e-15);
667        assert!(cfg.affine);
668    }
669
670    // -----------------------------------------------------------------------
671    // 12. Multi-feature forward correctness
672    // -----------------------------------------------------------------------
673
674    #[test]
675    fn test_multi_feature_independent_normalisation() {
676        let mut bn = make_bn(2);
677        bn.config.affine = false;
678        // Feature 0: values [0, 4] → mean=2, var=4, std=2
679        // Feature 1: values [10, 10] → mean=10, var=0, std≈0
680        let batch = vec![vec![0.0, 10.0], vec![4.0, 10.0]];
681        let out = bn.forward(&batch).expect("None");
682
683        // Feature 0: normalised outputs
684        let eps = 1e-5;
685        let exp0_0 = (0.0 - 2.0) / (4.0_f64 + eps).sqrt();
686        let exp0_1 = (4.0 - 2.0) / (4.0_f64 + eps).sqrt();
687        assert!((out[0][0] - exp0_0).abs() < 1e-9);
688        assert!((out[1][0] - exp0_1).abs() < 1e-9);
689
690        // Feature 1: var≈0, so x_hat ≈ 0 for both rows.
691        let exp1 = (10.0 - 10.0) / (0.0_f64 + eps).sqrt();
692        assert!((out[0][1] - exp1).abs() < 1e-9);
693        assert!((out[1][1] - exp1).abs() < 1e-9);
694    }
695
696    // -----------------------------------------------------------------------
697    // 13. NormMode Clone/Copy/Eq
698    // -----------------------------------------------------------------------
699
700    #[test]
701    fn test_norm_mode_copy_clone() {
702        let m = NormMode::Training;
703        let m2 = m; // Copy
704        let m3 = m; // Clone
705        assert_eq!(m, m2);
706        assert_eq!(m, m3);
707        assert_ne!(m, NormMode::Inference);
708    }
709
710    // -----------------------------------------------------------------------
711    // 14. Large single-feature batch — statistical accuracy
712    // -----------------------------------------------------------------------
713
714    #[test]
715    fn test_large_batch_normalisation_accuracy() {
716        let n = 1;
717        let mut bn = TensorBatchNorm::new(BatchNormConfig {
718            num_features: n,
719            epsilon: 1e-8,
720            momentum: 0.1,
721            affine: false,
722        });
723
724        // 100 samples from [1..=100]
725        let batch: Vec<Vec<f64>> = (1..=100).map(|i| vec![i as f64]).collect();
726        let out = bn.forward(&batch).expect("None");
727
728        let vals: Vec<f64> = out.iter().map(|r| r[0]).collect();
729        let m = mean(&vals);
730        let v = variance(&vals);
731
732        assert!(m.abs() < 1e-10, "mean ≈ 0, got {m}");
733        assert!((v - 1.0).abs() < 1e-6, "var ≈ 1, got {v}");
734    }
735}