burn-nn 0.22.0

Neural network building blocks for the Burn deep learning framework
use crate::Initializer;
use burn_core as burn;

use burn::config::Config;
use burn::module::Content;
use burn::module::DisplaySettings;
use burn::module::Module;
use burn::module::ModuleDisplay;
use burn::module::Param;
use burn::tensor::Device;
use burn::tensor::FloatDType;
use burn::tensor::Tensor;
use burn::tensor::assert_shape;
use burn::tensor::module::layer_norm;

use super::accumulation_dtype;

/// Configuration to create a [LayerNorm](LayerNorm) layer using the [init function](LayerNormConfig::init).
#[derive(Debug, Config)]
pub struct LayerNormConfig {
    /// The size of the input features.
    pub d_model: usize,
    /// A value required for numerical stability. Default: 1e-5
    #[config(default = 1e-5)]
    pub epsilon: f64,
    /// If a bias (beta) should be applied during the normalization. Default: true
    #[config(default = true)]
    pub bias: bool,
}

/// Applies Layer Normalization over an input tensor as described in the paper [Layer Normalization](https://arxiv.org/abs/1607.06450).
///
/// `Y = norm(X) * γ + β`
///
/// Where:
/// - `X` is the input tensor
/// - `Y` is the output tensor
/// - `γ` is the learnable weight (scale)
/// - `β` is the learnable bias (optional)
///
/// Should be created using [LayerNormConfig](LayerNormConfig).
#[derive(Module, Debug)]
#[module(custom_display)]
pub struct LayerNorm {
    /// The learnable weight (scale).
    pub gamma: Param<Tensor<1>>,
    /// The learnable bias (optional).
    pub beta: Option<Param<Tensor<1>>>,
    /// A value required for numerical stability.
    epsilon: f64,
}

impl LayerNormConfig {
    /// Initialize a new [layer norm](LayerNorm) module.
    pub fn init(&self, device: &Device) -> LayerNorm {
        let gamma = Initializer::Ones.init([self.d_model], device);
        let beta = if self.bias {
            Some(Initializer::Zeros.init([self.d_model], device))
        } else {
            None
        };

        LayerNorm {
            gamma,
            beta,
            epsilon: self.epsilon,
        }
    }
}

impl LayerNorm {
    /// Applies the forward pass on the input tensor.
    ///
    /// See the [LayerNorm](LayerNorm) documentation for more information.
    ///
    /// # Shapes
    ///
    /// - input: `[..., any, d_model]`
    /// - output: `[..., any, d_model]`
    ///
    /// # Panics
    ///
    /// Panics if the last axis of `input` is not `d_model`.
    pub fn forward<const D: usize>(&self, input: Tensor<D>) -> Tensor<D> {
        let gamma = self.gamma.val();
        let [d_model] = gamma.dims();
        assert_shape!(input, [.., d_model]);
        let beta = self.beta.as_ref().map(|b| b.val());

        // Widen when the input dtype cannot hold the sum of squares the
        // reduction accumulates (see [`accumulation_dtype`]). The normalization
        // is a backend op, so the parameters are widened with it — the op sees a
        // single dtype — and the result is narrowed back to the model's own.
        let original: FloatDType = input.dtype().into();
        match accumulation_dtype(input.dtype()) {
            Some(dtype) => layer_norm(
                input.cast(dtype),
                gamma.cast(dtype),
                beta.map(|b| b.cast(dtype)),
                self.epsilon,
            )
            .cast(original),
            None => layer_norm(input, gamma, beta, self.epsilon),
        }
    }
}

impl ModuleDisplay for LayerNorm {
    fn custom_settings(&self) -> Option<DisplaySettings> {
        DisplaySettings::new()
            .with_new_line_after_attribute(false)
            .optional()
    }

    fn custom_content(&self, content: Content) -> Option<Content> {
        let [d_model] = self.gamma.shape().dims();
        content
            .add("d_model", &d_model)
            .add("epsilon", &self.epsilon)
            .add("bias", &self.beta.is_some())
            .optional()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use alloc::format;
    use burn::tensor::TensorData;
    use burn::tensor::Tolerance;
    type FT = f32;

    #[test]
    #[should_panic(expected = "assert_shape!(input, [.., d_model]): axis 1 expected 10, got 3")]
    fn input_d_model_must_match() {
        let device = Default::default();
        let module = LayerNormConfig::new(10).init(&device);
        let _ = module.forward(Tensor::<2>::zeros([1, 3], &device));
    }

    #[test]
    fn layer_norm_forward() {
        let device = Default::default();
        let module = LayerNormConfig::new(10).init(&device);
        let input = Tensor::<2>::from_data(
            TensorData::from([[
                -0.6897, -2.7106, 2.2222, -1.0330, -0.8933, 1.1765, 0.0601, 1.5252, -0.3630, 0.6728,
            ]]),
            &device,
        );

        let output = module.forward(input);

        let expected = TensorData::from([[
            -0.4990, -1.9680, 1.6178, -0.7486, -0.6470, 0.8576, 0.0461, 1.1111, -0.2614, 0.4915,
        ]]);
        output
            .to_data()
            .assert_approx_eq::<FT>(&expected, Tolerance::default());
    }

    #[test]
    fn layer_norm_forward_large_epsilon() {
        let device = Default::default();
        let module = LayerNormConfig::new(10).with_epsilon(1e-1).init(&device);
        let input = Tensor::<2>::from_data(
            TensorData::from([[
                -0.6897, -2.7106, 2.2222, -1.0330, -0.8933, 1.1765, 0.0601, 1.5252, -0.3630, 0.6728,
            ]]),
            &device,
        );

        let output = module.forward(input);

        let expected = TensorData::from([[
            -0.4863, -1.9180, 1.5766, -0.7295, -0.6305, 0.8358, 0.0449, 1.0828, -0.2548, 0.4790,
        ]]);
        output
            .to_data()
            .assert_approx_eq::<FT>(&expected, Tolerance::default());
    }

    #[test]
    fn layer_norm_forward_no_bias() {
        let device = Default::default();
        let module = LayerNormConfig::new(10).with_bias(false).init(&device);
        let input = Tensor::<2>::from_data(
            TensorData::from([[
                -0.6897, -2.7106, 2.2222, -1.0330, -0.8933, 1.1765, 0.0601, 1.5252, -0.3630, 0.6728,
            ]]),
            &device,
        );

        let output = module.forward(input);

        // With bias=false, output matches the bias=true case (beta is zero-initialized
        // by default), confirming the `None` branch in the backend hook produces the
        // pre-beta result.
        let expected = TensorData::from([[
            -0.4990, -1.9680, 1.6178, -0.7486, -0.6470, 0.8576, 0.0461, 1.1111, -0.2614, 0.4915,
        ]]);
        output
            .to_data()
            .assert_approx_eq::<FT>(&expected, Tolerance::default());
    }

    #[cfg(feature = "std")]
    #[test]
    fn layer_norm_backward() {
        let device = Device::default().autodiff();
        let module = LayerNormConfig::new(2).init(&device);
        let tensor_1 = Tensor::<2>::from_data(TensorData::from([[0.0, 1.0], [3.0, 4.0]]), &device)
            .require_grad();
        let tensor_2 = Tensor::<2>::from_data(TensorData::from([[6.0, 7.0], [9.0, 10.0]]), &device)
            .require_grad();

        let x = tensor_1.clone().matmul(tensor_2.clone());

        let output = module.forward(x);
        let grads = output.backward();

        let tensor_1_grad = tensor_1.grad(&grads).unwrap();
        let tensor_2_grad = tensor_2.grad(&grads).unwrap();
        let gamma_grad = module.gamma.grad(&grads).unwrap();
        let beta_grad = module.beta.as_ref().unwrap().grad(&grads).unwrap();

        let expected = TensorData::from([-2.0, 2.0]);
        gamma_grad
            .to_data()
            .assert_approx_eq::<FT>(&expected, Tolerance::default());

        let expected = TensorData::from([2.0, 2.0]);
        beta_grad
            .to_data()
            .assert_approx_eq::<FT>(&expected, Tolerance::default());

        let expected = TensorData::zeros::<f32, _>(tensor_1_grad.shape());
        tensor_1_grad
            .to_data()
            .assert_approx_eq::<FT>(&expected, Tolerance::default());

        let expected = TensorData::zeros::<f32, _>(tensor_2_grad.shape());
        tensor_2_grad
            .to_data()
            .assert_approx_eq::<FT>(&expected, Tolerance::default());
    }

    #[test]
    fn display() {
        let config = LayerNormConfig::new(6);
        let layer_norm = config.init(&Default::default());

        assert_eq!(
            format!("{layer_norm}"),
            "LayerNorm {d_model: 6, epsilon: 0.00001, bias: true, params: 12}"
        );
    }

    #[test]
    fn display_no_bias() {
        let config = LayerNormConfig::new(6).with_bias(false);
        let layer_norm = config.init(&Default::default());

        assert_eq!(
            format!("{layer_norm}"),
            "LayerNorm {d_model: 6, epsilon: 0.00001, bias: false, params: 6}"
        );
    }
}