use crate::error::{Context, Error};
use crate::neural_network::Tensor;
use crate::parallel_gates::{cheap_map_parallel_threshold, exp_map_parallel_threshold};
use crate::{Deserialize, Serialize};
use ndarray::{Array2, ArrayView1, ArrayViewMut1, Axis, Zip};
use rayon::iter::{IntoParallelIterator, ParallelIterator};
pub mod elu;
pub mod exponential;
pub mod gelu;
pub mod hard_sigmoid;
pub mod leaky_relu;
pub mod linear;
pub mod mish;
pub mod p_relu;
pub mod relu;
pub mod selu;
pub mod sigmoid;
pub mod silu;
pub mod softmax;
pub mod softplus;
pub mod softsign;
pub mod tanh;
pub use elu::ELU;
pub use exponential::Exponential;
pub use gelu::GELU;
pub use hard_sigmoid::HardSigmoid;
pub use leaky_relu::LeakyReLU;
pub use linear::Linear;
pub use mish::Mish;
pub use p_relu::PReLU;
pub use relu::ReLU;
pub use selu::SELU;
pub use sigmoid::Sigmoid;
pub use silu::SiLU;
pub use softmax::Softmax;
pub use softplus::Softplus;
pub use softsign::Softsign;
pub use tanh::Tanh;
const SELU_ALPHA: f32 = 1.673_263_2;
const SELU_SCALE: f32 = 1.050_701;
const SELU_SCALE_ALPHA: f32 = SELU_SCALE * SELU_ALPHA;
const HARD_SIGMOID_SLOPE: f32 = 1.0 / 6.0;
const GELU_TANH_SCALE: f32 = 0.797_884_6;
const GELU_TANH_CUBIC: f32 = 0.044_715;
const NORMAL_DENSITY_SCALE: f32 = 0.398_942_3;
pub const DEFAULT_SOFTMAX_AXIS: i32 = -1;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum Activation {
Linear,
ReLU,
Sigmoid,
Tanh,
Softmax {
axis: i32,
},
LeakyReLU {
negative_slope: f32,
},
ELU {
alpha: f32,
},
SELU,
Softplus,
Softsign,
HardSigmoid,
Exponential,
GELU {
approximate: bool,
},
SiLU,
Mish,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActivationInput {
Output,
PreActivation,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ActivationCache {
holds: ActivationInput,
tensor: Tensor,
}
impl ActivationCache {
pub(crate) fn new(holds: ActivationInput, tensor: Tensor) -> Self {
Self { holds, tensor }
}
pub fn holds(&self) -> ActivationInput {
self.holds
}
pub fn tensor(&self) -> &Tensor {
&self.tensor
}
pub fn shape(&self) -> &[usize] {
self.tensor.shape()
}
pub fn into_tensor(self) -> Tensor {
self.tensor
}
}
#[inline]
fn sigmoid(x: f32) -> f32 {
1.0 / (1.0 + (-x).exp())
}
#[inline]
fn softplus(x: f32) -> f32 {
if x > 0.0 {
x + (-x).exp().ln_1p()
} else {
x.exp().ln_1p()
}
}
#[inline]
fn gelu_exact(x: f32) -> f32 {
x * 0.5 * libm::erfcf(-x * std::f32::consts::FRAC_1_SQRT_2)
}
#[inline]
fn gelu_exact_grad(x: f32) -> f32 {
let cdf = 0.5 * libm::erfcf(-x * std::f32::consts::FRAC_1_SQRT_2);
let pdf = NORMAL_DENSITY_SCALE * (-0.5 * x * x).exp();
cdf + x * pdf
}
#[inline]
fn gelu_tanh_argument(x: f32) -> f32 {
2.0 * GELU_TANH_SCALE * (x + GELU_TANH_CUBIC * x * x * x)
}
#[inline]
fn gelu_tanh(x: f32) -> f32 {
x * sigmoid(gelu_tanh_argument(x))
}
#[inline]
fn gelu_tanh_grad(x: f32) -> f32 {
let s = sigmoid(gelu_tanh_argument(x));
let slope = s * (1.0 - s);
if slope == 0.0 {
return s;
}
let argument_grad = 2.0 * GELU_TANH_SCALE * (1.0 + 3.0 * GELU_TANH_CUBIC * x * x);
s + x * slope * argument_grad
}
#[inline]
fn silu(x: f32) -> f32 {
x * sigmoid(x)
}
#[inline]
fn silu_grad(x: f32) -> f32 {
let s = sigmoid(x);
s * (1.0 + x * (1.0 - s))
}
#[inline]
fn mish(x: f32) -> f32 {
x * softplus(x).tanh()
}
#[inline]
fn mish_grad(x: f32) -> f32 {
let t = softplus(x).tanh();
t + x * (1.0 - t * t) * sigmoid(x)
}
fn map_elements(z: &Tensor, threshold: usize, f: impl Fn(f32) -> f32 + Sync + Send) -> Tensor {
if z.len() >= threshold {
Zip::from(z).par_map_collect(|&x| f(x))
} else {
z.mapv(f)
}
}
fn scale_by_derivative(
z: &Tensor,
grad_output: &Tensor,
derivative: impl Fn(f32) -> f32 + Sync + Send,
) -> Result<Tensor, Error> {
if grad_output.shape() != z.shape() {
return Err(Error::shape_mismatch(z.shape(), grad_output.shape()));
}
let mut grad = grad_output.clone();
let scale = |g: &mut f32, &x: &f32| *g *= derivative(x);
if z.len() >= exp_map_parallel_threshold() {
Zip::from(&mut grad).and(z).par_for_each(scale);
} else {
Zip::from(&mut grad).and(z).for_each(scale);
}
Ok(grad)
}
impl Activation {
pub fn forward(&self, z: &Tensor) -> Result<Tensor, Error> {
match self {
Activation::Linear => Ok(z.clone()),
Activation::ReLU => {
let relu = |x: f32| if x <= 0.0 { 0.0 } else { x };
let out = if z.len() >= cheap_map_parallel_threshold() {
Zip::from(z).par_map_collect(|&x| relu(x))
} else {
z.mapv(relu)
};
Ok(out)
}
Activation::Sigmoid => {
let sigmoid = |x: f32| 1.0 / (1.0 + (-x).exp());
let out = if z.len() >= exp_map_parallel_threshold() {
Zip::from(z).par_map_collect(|&x| sigmoid(x))
} else {
z.mapv(sigmoid)
};
Ok(out)
}
Activation::Tanh => {
let tanh = |x: f32| x.tanh();
let out = if z.len() >= exp_map_parallel_threshold() {
Zip::from(z).par_map_collect(|&x| tanh(x))
} else {
z.mapv(tanh)
};
Ok(out)
}
Activation::Softmax { axis } => softmax_forward(z, *axis),
Activation::LeakyReLU { negative_slope } => {
let slope = *negative_slope;
let leaky_relu = |x: f32| if x >= 0.0 { x } else { slope * x };
let out = if z.len() >= cheap_map_parallel_threshold() {
Zip::from(z).par_map_collect(|&x| leaky_relu(x))
} else {
z.mapv(leaky_relu)
};
Ok(out)
}
Activation::ELU { alpha } => {
let alpha = *alpha;
let elu = |x: f32| if x > 0.0 { x } else { alpha * x.exp_m1() };
let out = if z.len() >= exp_map_parallel_threshold() {
Zip::from(z).par_map_collect(|&x| elu(x))
} else {
z.mapv(elu)
};
Ok(out)
}
Activation::SELU => {
let selu = |x: f32| {
if x > 0.0 {
SELU_SCALE * x
} else {
SELU_SCALE_ALPHA * x.exp_m1()
}
};
let out = if z.len() >= exp_map_parallel_threshold() {
Zip::from(z).par_map_collect(|&x| selu(x))
} else {
z.mapv(selu)
};
Ok(out)
}
Activation::Softplus => Ok(map_elements(z, exp_map_parallel_threshold(), softplus)),
Activation::Softsign => {
let softsign = |x: f32| x / (1.0 + x.abs());
let out = if z.len() >= cheap_map_parallel_threshold() {
Zip::from(z).par_map_collect(|&x| softsign(x))
} else {
z.mapv(softsign)
};
Ok(out)
}
Activation::HardSigmoid => {
let hard_sigmoid = |x: f32| (x + 3.0).clamp(0.0, 6.0) / 6.0;
let out = if z.len() >= cheap_map_parallel_threshold() {
Zip::from(z).par_map_collect(|&x| hard_sigmoid(x))
} else {
z.mapv(hard_sigmoid)
};
Ok(out)
}
Activation::Exponential => {
let exponential = |x: f32| x.exp();
let out = if z.len() >= exp_map_parallel_threshold() {
Zip::from(z).par_map_collect(|&x| exponential(x))
} else {
z.mapv(exponential)
};
Ok(out)
}
Activation::GELU { approximate: false } => {
Ok(map_elements(z, exp_map_parallel_threshold(), gelu_exact))
}
Activation::GELU { approximate: true } => {
Ok(map_elements(z, exp_map_parallel_threshold(), gelu_tanh))
}
Activation::SiLU => Ok(map_elements(z, exp_map_parallel_threshold(), silu)),
Activation::Mish => Ok(map_elements(z, exp_map_parallel_threshold(), mish)),
}
}
pub fn saves(&self) -> ActivationInput {
match self {
Activation::GELU { .. } | Activation::SiLU | Activation::Mish => {
ActivationInput::PreActivation
}
_ => ActivationInput::Output,
}
}
pub fn forward_train(&self, z: Tensor) -> Result<(Tensor, ActivationCache), Error> {
let output = self.forward(&z)?;
let cache = match self.saves() {
ActivationInput::Output => {
ActivationCache::new(ActivationInput::Output, output.clone())
}
ActivationInput::PreActivation => {
ActivationCache::new(ActivationInput::PreActivation, z)
}
};
Ok((output, cache))
}
pub fn output_of(&self, cache: &ActivationCache) -> Result<Tensor, Error> {
self.check_cache(cache)?;
match cache.holds {
ActivationInput::Output => Ok(cache.tensor.clone()),
ActivationInput::PreActivation => self.forward(&cache.tensor),
}
}
fn check_cache(&self, cache: &ActivationCache) -> Result<(), Error> {
if cache.holds != self.saves() {
return Err(Error::invalid_input(format!(
"{self:?} reads {:?}, but the cache holds {:?}",
self.saves(),
cache.holds
)));
}
Ok(())
}
pub fn backward(&self, cache: &ActivationCache, grad_output: &Tensor) -> Result<Tensor, Error> {
self.check_cache(cache)?;
let activated = &cache.tensor;
if grad_output.shape() != activated.shape() {
return Err(Error::shape_mismatch(
activated.shape(),
grad_output.shape(),
));
}
match self {
Activation::Linear => Ok(grad_output.clone()),
Activation::ReLU => {
let mut grad = grad_output.clone();
let relu_grad = |g: &mut f32, &a: &f32| {
if a <= 0.0 {
*g = 0.0;
}
};
if activated.len() >= cheap_map_parallel_threshold() {
Zip::from(&mut grad).and(activated).par_for_each(relu_grad);
} else {
Zip::from(&mut grad).and(activated).for_each(relu_grad);
}
Ok(grad)
}
Activation::Sigmoid => {
let mut grad = grad_output.clone();
let sigmoid_grad = |g: &mut f32, &a: &f32| {
*g *= a * (1.0 - a);
};
if grad.len() >= cheap_map_parallel_threshold() {
Zip::from(&mut grad)
.and(activated)
.par_for_each(sigmoid_grad);
} else {
Zip::from(&mut grad).and(activated).for_each(sigmoid_grad);
}
Ok(grad)
}
Activation::Tanh => {
let mut grad = grad_output.clone();
let tanh_grad = |g: &mut f32, &a: &f32| {
*g *= 1.0 - a * a;
};
if activated.len() >= cheap_map_parallel_threshold() {
Zip::from(&mut grad).and(activated).par_for_each(tanh_grad);
} else {
Zip::from(&mut grad).and(activated).for_each(tanh_grad);
}
Ok(grad)
}
Activation::Softmax { axis } => softmax_backward(activated, grad_output, *axis),
Activation::LeakyReLU { negative_slope } => {
let slope = *negative_slope;
let mut grad = grad_output.clone();
let leaky_relu_grad = |g: &mut f32, &a: &f32| {
if a < 0.0 {
*g *= slope;
}
};
if activated.len() >= cheap_map_parallel_threshold() {
Zip::from(&mut grad)
.and(activated)
.par_for_each(leaky_relu_grad);
} else {
Zip::from(&mut grad)
.and(activated)
.for_each(leaky_relu_grad);
}
Ok(grad)
}
Activation::ELU { alpha } => {
let alpha = *alpha;
let mut grad = grad_output.clone();
let elu_grad = |g: &mut f32, &a: &f32| {
if a <= 0.0 {
*g *= a + alpha;
}
};
if activated.len() >= cheap_map_parallel_threshold() {
Zip::from(&mut grad).and(activated).par_for_each(elu_grad);
} else {
Zip::from(&mut grad).and(activated).for_each(elu_grad);
}
Ok(grad)
}
Activation::SELU => {
let mut grad = grad_output.clone();
let selu_grad = |g: &mut f32, &a: &f32| {
*g *= if a > 0.0 {
SELU_SCALE
} else {
a + SELU_SCALE_ALPHA
};
};
if activated.len() >= cheap_map_parallel_threshold() {
Zip::from(&mut grad).and(activated).par_for_each(selu_grad);
} else {
Zip::from(&mut grad).and(activated).for_each(selu_grad);
}
Ok(grad)
}
Activation::Softplus => {
let mut grad = grad_output.clone();
let softplus_grad = |g: &mut f32, &a: &f32| {
*g *= -(-a).exp_m1();
};
if activated.len() >= exp_map_parallel_threshold() {
Zip::from(&mut grad)
.and(activated)
.par_for_each(softplus_grad);
} else {
Zip::from(&mut grad).and(activated).for_each(softplus_grad);
}
Ok(grad)
}
Activation::Softsign => {
let mut grad = grad_output.clone();
let softsign_grad = |g: &mut f32, &a: &f32| {
let t = 1.0 - a.abs();
*g *= t * t;
};
if activated.len() >= cheap_map_parallel_threshold() {
Zip::from(&mut grad)
.and(activated)
.par_for_each(softsign_grad);
} else {
Zip::from(&mut grad).and(activated).for_each(softsign_grad);
}
Ok(grad)
}
Activation::HardSigmoid => {
let mut grad = grad_output.clone();
let hard_sigmoid_grad = |g: &mut f32, &a: &f32| {
*g *= if a > 0.0 && a < 1.0 {
HARD_SIGMOID_SLOPE
} else {
0.0
};
};
if activated.len() >= cheap_map_parallel_threshold() {
Zip::from(&mut grad)
.and(activated)
.par_for_each(hard_sigmoid_grad);
} else {
Zip::from(&mut grad)
.and(activated)
.for_each(hard_sigmoid_grad);
}
Ok(grad)
}
Activation::Exponential => {
let mut grad = grad_output.clone();
let exponential_grad = |g: &mut f32, &a: &f32| {
*g *= a;
};
if activated.len() >= cheap_map_parallel_threshold() {
Zip::from(&mut grad)
.and(activated)
.par_for_each(exponential_grad);
} else {
Zip::from(&mut grad)
.and(activated)
.for_each(exponential_grad);
}
Ok(grad)
}
Activation::GELU { approximate: false } => {
scale_by_derivative(activated, grad_output, gelu_exact_grad)
}
Activation::GELU { approximate: true } => {
scale_by_derivative(activated, grad_output, gelu_tanh_grad)
}
Activation::SiLU => scale_by_derivative(activated, grad_output, silu_grad),
Activation::Mish => scale_by_derivative(activated, grad_output, mish_grad),
}
}
pub fn validate(&self) -> Result<(), Error> {
let (name, value, reason) = match self {
Activation::LeakyReLU { negative_slope } => (
"negative_slope",
*negative_slope,
"must be finite and greater than 0 (use Activation::ReLU for 0)",
),
Activation::ELU { alpha } => ("alpha", *alpha, "must be finite and greater than 0"),
Activation::Softmax { axis } => {
if *axis != DEFAULT_SOFTMAX_AXIS {
return Err(Error::invalid_parameter(
"axis",
"must be -1 in a trainable layer (use the Softmax layer for another axis)",
));
}
return Ok(());
}
_ => return Ok(()),
};
if !value.is_finite() || value <= 0.0 {
return Err(Error::invalid_parameter(name, reason));
}
Ok(())
}
}
impl From<Linear> for Activation {
#[inline]
fn from(_: Linear) -> Self {
Activation::Linear
}
}
impl From<ReLU> for Activation {
#[inline]
fn from(_: ReLU) -> Self {
Activation::ReLU
}
}
impl From<Sigmoid> for Activation {
#[inline]
fn from(_: Sigmoid) -> Self {
Activation::Sigmoid
}
}
impl From<Tanh> for Activation {
#[inline]
fn from(_: Tanh) -> Self {
Activation::Tanh
}
}
impl From<Softmax> for Activation {
#[inline]
fn from(layer: Softmax) -> Self {
Activation::Softmax { axis: layer.axis }
}
}
impl From<LeakyReLU> for Activation {
#[inline]
fn from(layer: LeakyReLU) -> Self {
Activation::LeakyReLU {
negative_slope: layer.negative_slope,
}
}
}
impl From<ELU> for Activation {
#[inline]
fn from(layer: ELU) -> Self {
Activation::ELU { alpha: layer.alpha }
}
}
impl From<SELU> for Activation {
#[inline]
fn from(_: SELU) -> Self {
Activation::SELU
}
}
impl From<Softplus> for Activation {
#[inline]
fn from(_: Softplus) -> Self {
Activation::Softplus
}
}
impl From<Softsign> for Activation {
#[inline]
fn from(_: Softsign) -> Self {
Activation::Softsign
}
}
impl From<HardSigmoid> for Activation {
#[inline]
fn from(_: HardSigmoid) -> Self {
Activation::HardSigmoid
}
}
impl From<Exponential> for Activation {
#[inline]
fn from(_: Exponential) -> Self {
Activation::Exponential
}
}
impl From<GELU> for Activation {
#[inline]
fn from(layer: GELU) -> Self {
Activation::GELU {
approximate: layer.approximate,
}
}
}
impl From<SiLU> for Activation {
#[inline]
fn from(_: SiLU) -> Self {
Activation::SiLU
}
}
impl From<Mish> for Activation {
#[inline]
fn from(_: Mish) -> Self {
Activation::Mish
}
}
fn resolve_softmax_axis(axis: i32, ndim: usize) -> Result<usize, Error> {
let resolved = if axis < 0 {
axis as i64 + ndim as i64
} else {
axis as i64
};
if resolved < 0 || resolved >= ndim as i64 {
return Err(Error::invalid_input(format!(
"Softmax axis {axis} is out of bounds for an input of rank {ndim}"
)));
}
Ok(resolved as usize)
}
fn softmax_forward(input: &Tensor, axis: i32) -> Result<Tensor, Error> {
let shape = input.shape();
let ndim = shape.len();
let axis = resolve_softmax_axis(axis, ndim)?;
let apply_softmax = |mut lane: ArrayViewMut1<f32>| {
let max_val = lane.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
lane.map_inplace(|x| *x = (*x - max_val).exp());
let sum = lane.sum();
lane.map_inplace(|x| *x /= sum);
};
if axis + 1 == ndim {
let batch_size: usize = shape[..ndim - 1].iter().product();
let num_features = shape[ndim - 1];
let mut output_2d = input
.as_standard_layout()
.into_owned()
.into_shape_with_order((batch_size, num_features))
.context("Failed to reshape for softmax computation")?;
if batch_size > 1 && batch_size * num_features >= exp_map_parallel_threshold() {
output_2d
.axis_iter_mut(Axis(0))
.into_par_iter()
.for_each(apply_softmax);
} else {
output_2d.axis_iter_mut(Axis(0)).for_each(apply_softmax);
}
return Ok(output_2d
.into_shape_with_order(shape)
.context("Failed to reshape back after softmax computation")?
.into_dyn());
}
let mut output = Tensor::zeros(input.raw_dim());
output.assign(input);
let lane_len = shape[axis];
let lane_count: usize = shape
.iter()
.enumerate()
.filter(|(index, _)| *index != axis)
.map(|(_, &length)| length)
.product();
if lane_count > 1 && lane_count * lane_len >= exp_map_parallel_threshold() {
Zip::from(output.lanes_mut(Axis(axis))).par_for_each(apply_softmax);
} else {
Zip::from(output.lanes_mut(Axis(axis))).for_each(apply_softmax);
}
Ok(output)
}
fn softmax_backward(output: &Tensor, grad_output: &Tensor, axis: i32) -> Result<Tensor, Error> {
let shape = output.shape();
let ndim = shape.len();
let axis = resolve_softmax_axis(axis, ndim)?;
if grad_output.shape() != shape {
return Err(Error::shape_mismatch(shape, grad_output.shape()));
}
let compute_gradient = |mut grad_lane: ArrayViewMut1<f32>,
out_lane: ArrayView1<f32>,
grad_out_lane: ArrayView1<f32>| {
let dot: f32 = out_lane
.iter()
.zip(grad_out_lane.iter())
.map(|(&o, &g)| o * g)
.sum();
Zip::from(&mut grad_lane)
.and(&out_lane)
.and(&grad_out_lane)
.for_each(|grad, &o, &g| *grad = o * (g - dot));
};
if axis + 1 == ndim {
let batch_size: usize = shape[..ndim - 1].iter().product();
let num_features = shape[ndim - 1];
let output_2d = output
.to_shape((batch_size, num_features))
.context("Failed to reshape output for backward")?;
let grad_output_2d = grad_output
.to_shape((batch_size, num_features))
.context("Failed to reshape grad_output for backward")?;
let mut grad_input_2d = Array2::<f32>::zeros((batch_size, num_features));
if batch_size > 1 && batch_size * num_features >= cheap_map_parallel_threshold() {
Zip::from(grad_input_2d.axis_iter_mut(Axis(0)))
.and(output_2d.axis_iter(Axis(0)))
.and(grad_output_2d.axis_iter(Axis(0)))
.par_for_each(compute_gradient);
} else {
Zip::from(grad_input_2d.axis_iter_mut(Axis(0)))
.and(output_2d.axis_iter(Axis(0)))
.and(grad_output_2d.axis_iter(Axis(0)))
.for_each(compute_gradient);
}
return Ok(grad_input_2d
.into_shape_with_order(shape)
.context("Failed to reshape grad_input back")?
.into_dyn());
}
let mut grad_input = Tensor::zeros(output.raw_dim());
let lane_len = shape[axis];
let lane_count: usize = shape
.iter()
.enumerate()
.filter(|(index, _)| *index != axis)
.map(|(_, &length)| length)
.product();
if lane_count > 1 && lane_count * lane_len >= cheap_map_parallel_threshold() {
Zip::from(grad_input.lanes_mut(Axis(axis)))
.and(output.lanes(Axis(axis)))
.and(grad_output.lanes(Axis(axis)))
.par_for_each(compute_gradient);
} else {
Zip::from(grad_input.lanes_mut(Axis(axis)))
.and(output.lanes(Axis(axis)))
.and(grad_output.lanes(Axis(axis)))
.for_each(compute_gradient);
}
Ok(grad_input)
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
use ndarray::Array2;
fn tensor2(rows: usize, cols: usize, data: Vec<f32>) -> Tensor {
Array2::from_shape_vec((rows, cols), data)
.expect("shape/data mismatch")
.into_dyn()
}
#[test]
fn softmax_forward_basic_row() {
let input = tensor2(1, 3, vec![0.0, 1.0, 2.0]);
let output = softmax_forward(&input, DEFAULT_SOFTMAX_AXIS).expect("softmax_forward failed");
let vals = output.as_slice().expect("not contiguous");
assert_abs_diff_eq!(vals[0], 0.09003_f32, epsilon = 1e-4);
assert_abs_diff_eq!(vals[1], 0.24473_f32, epsilon = 1e-4);
assert_abs_diff_eq!(vals[2], 0.66524_f32, epsilon = 1e-4);
}
#[test]
fn softmax_forward_sums_to_one() {
let input = tensor2(1, 3, vec![0.0, 1.0, 2.0]);
let output = softmax_forward(&input, DEFAULT_SOFTMAX_AXIS).expect("softmax_forward failed");
let sum: f32 = output.iter().sum();
assert_abs_diff_eq!(sum, 1.0_f32, epsilon = 1e-6);
}
#[test]
fn softmax_forward_large_equal_values_stable() {
let input = tensor2(1, 3, vec![1000.0, 1000.0, 1000.0]);
let output = softmax_forward(&input, DEFAULT_SOFTMAX_AXIS).expect("softmax_forward failed");
let vals = output.as_slice().expect("not contiguous");
let third = 1.0_f32 / 3.0;
assert_abs_diff_eq!(vals[0], third, epsilon = 1e-6);
assert_abs_diff_eq!(vals[1], third, epsilon = 1e-6);
assert_abs_diff_eq!(vals[2], third, epsilon = 1e-6);
}
#[test]
fn softmax_forward_single_element_row() {
let input = tensor2(1, 1, vec![5.0]);
let output = softmax_forward(&input, DEFAULT_SOFTMAX_AXIS).expect("softmax_forward failed");
let vals = output.as_slice().expect("not contiguous");
assert_abs_diff_eq!(vals[0], 1.0_f32, epsilon = 1e-6);
}
#[test]
fn softmax_forward_accepts_input_that_is_not_in_c_order() {
use ndarray::IxDyn;
let base = tensor2(2, 3, vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
let transposed = base.view().permuted_axes(IxDyn(&[1, 0])).to_owned();
assert!(
!transposed.is_standard_layout(),
"the test input must not be in C order"
);
let output = softmax_forward(&transposed, DEFAULT_SOFTMAX_AXIS)
.expect("softmax_forward must accept it");
assert_eq!(output.shape(), &[3, 2]);
let c_order: Tensor = transposed.as_standard_layout().into_owned();
let want = softmax_forward(&c_order, DEFAULT_SOFTMAX_AXIS).expect("softmax_forward failed");
for (got, expected) in output.iter().zip(want.iter()) {
assert_abs_diff_eq!(*got, *expected, epsilon = 1e-6);
}
}
#[test]
fn softmax_forward_accepts_1d_input() {
use ndarray::Array1;
let input = Array1::from_vec(vec![0.0_f32, 1.0, 2.0]).into_dyn();
let output =
softmax_forward(&input, DEFAULT_SOFTMAX_AXIS).expect("1-D input must be accepted");
assert_eq!(output.shape(), &[3]);
let vals: Vec<f32> = output.iter().cloned().collect();
assert_abs_diff_eq!(vals[0], 0.09003_f32, epsilon = 1e-4);
assert_abs_diff_eq!(vals[1], 0.24473_f32, epsilon = 1e-4);
assert_abs_diff_eq!(vals[2], 0.66524_f32, epsilon = 1e-4);
}
#[test]
fn softmax_forward_rejects_0d_input() {
let input: Tensor = ndarray::arr0(1.0_f32).into_dyn();
assert!(
matches!(
softmax_forward(&input, DEFAULT_SOFTMAX_AXIS),
Err(Error::InvalidInput(_))
),
"0-D input must return InvalidInput"
);
}
#[test]
fn softmax_forward_rejects_out_of_range_axis() {
let input = tensor2(2, 3, vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
for axis in [2, 7, -3, -5, i32::MIN, i32::MAX] {
assert!(
matches!(softmax_forward(&input, axis), Err(Error::InvalidInput(_))),
"axis {axis} must be rejected for a rank-2 input"
);
}
}
#[test]
fn softmax_forward_resolves_a_negative_axis_at_call_time() {
use ndarray::{Array, Ix3};
let rank_2 = tensor2(2, 3, vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
let last = softmax_forward(&rank_2, -1).expect("rank-2 forward");
let same = softmax_forward(&rank_2, 1).expect("rank-2 forward");
assert_eq!(
last.iter().cloned().collect::<Vec<f32>>(),
same.iter().cloned().collect::<Vec<f32>>(),
"-1 and 1 name the same axis of a rank-2 input"
);
let rank_3: Tensor =
Array::from_shape_fn((2, 3, 4), |(i, j, k)| (i * 12 + j * 4 + k) as f32 * 0.25)
.into_dyn();
let middle = softmax_forward(&rank_3, -2).expect("rank-3 forward");
let by_index = softmax_forward(&rank_3, 1).expect("rank-3 forward");
assert_eq!(
middle.iter().cloned().collect::<Vec<f32>>(),
by_index.iter().cloned().collect::<Vec<f32>>(),
"-2 and 1 name the same axis of a rank-3 input"
);
let view = middle
.view()
.into_dimensionality::<Ix3>()
.expect("the result is rank 3");
for lane in view.lanes(Axis(1)) {
assert_abs_diff_eq!(lane.sum(), 1.0_f32, epsilon = 1e-6);
}
}
#[test]
fn softmax_forward_lane_path_agrees_with_the_fold_path() {
use ndarray::IxDyn;
let base = tensor2(3, 4, (0..12).map(|v| v as f32 * 0.3 - 1.5).collect());
let by_lane = softmax_forward(&base, 0).expect("axis 0 forward");
let transposed: Tensor = {
let view = base.view().permuted_axes(IxDyn(&[1, 0]));
let mut owned = Tensor::zeros(view.raw_dim());
owned.assign(&view);
owned
};
let by_fold = softmax_forward(&transposed, -1).expect("axis -1 forward");
for (row, column) in (0..3).flat_map(|r| (0..4).map(move |c| (r, c))) {
assert_abs_diff_eq!(
by_lane[[row, column]],
by_fold[[column, row]],
epsilon = 1e-7
);
}
}
#[test]
fn softmax_backward_jacobian_vector_product() {
let output = tensor2(1, 3, vec![0.25, 0.25, 0.5]);
let grad_output = tensor2(1, 3, vec![1.0, 0.0, 0.0]);
let grad_input = softmax_backward(&output, &grad_output, DEFAULT_SOFTMAX_AXIS)
.expect("softmax_backward failed");
let vals = grad_input.as_slice().expect("not contiguous");
assert_abs_diff_eq!(vals[0], 0.1875_f32, epsilon = 1e-6);
assert_abs_diff_eq!(vals[1], -0.0625_f32, epsilon = 1e-6);
assert_abs_diff_eq!(vals[2], -0.125_f32, epsilon = 1e-6);
}
#[test]
fn softmax_backward_row_sums_to_zero() {
let output = tensor2(1, 3, vec![0.25, 0.25, 0.5]);
let grad_output = tensor2(1, 3, vec![1.0, 0.0, 0.0]);
let grad_input = softmax_backward(&output, &grad_output, DEFAULT_SOFTMAX_AXIS)
.expect("softmax_backward failed");
let row_sum: f32 = grad_input.iter().sum();
assert_abs_diff_eq!(row_sum, 0.0_f32, epsilon = 1e-6);
}
#[test]
fn softmax_backward_rejects_a_mismatched_grad_output() {
use ndarray::IxDyn;
let output = Tensor::zeros(IxDyn(&[2, 3, 4]));
let grad_output = Tensor::zeros(IxDyn(&[2, 3, 5]));
match softmax_backward(&output, &grad_output, 1) {
Err(Error::ShapeMismatch { expected, found }) => {
assert_eq!(expected, vec![2, 3, 4]);
assert_eq!(found, vec![2, 3, 5]);
}
other => panic!("the lane path must return ShapeMismatch, got {other:?}"),
}
assert!(
matches!(
softmax_backward(&output, &grad_output, DEFAULT_SOFTMAX_AXIS),
Err(Error::ShapeMismatch { .. })
),
"the fold path must return ShapeMismatch as well"
);
}
#[test]
fn activation_softmax_forward_via_enum() {
let input = tensor2(1, 3, vec![0.0, 1.0, 2.0]);
let output = Activation::Softmax {
axis: DEFAULT_SOFTMAX_AXIS,
}
.forward(&input)
.expect("Activation::Softmax forward failed");
let vals = output.as_slice().expect("not contiguous");
assert_abs_diff_eq!(vals[0], 0.09003_f32, epsilon = 1e-4);
assert_abs_diff_eq!(vals[1], 0.24473_f32, epsilon = 1e-4);
assert_abs_diff_eq!(vals[2], 0.66524_f32, epsilon = 1e-4);
}
#[test]
fn activation_softmax_backward_via_enum() {
let output = tensor2(1, 3, vec![0.25, 0.25, 0.5]);
let grad_output = tensor2(1, 3, vec![1.0, 0.0, 0.0]);
let cache = ActivationCache::new(ActivationInput::Output, output);
let grad_input = Activation::Softmax {
axis: DEFAULT_SOFTMAX_AXIS,
}
.backward(&cache, &grad_output)
.expect("Activation::Softmax backward failed");
let vals = grad_input.as_slice().expect("not contiguous");
assert_abs_diff_eq!(vals[0], 0.1875_f32, epsilon = 1e-6);
assert_abs_diff_eq!(vals[1], -0.0625_f32, epsilon = 1e-6);
assert_abs_diff_eq!(vals[2], -0.125_f32, epsilon = 1e-6);
}
const PROBES: [f32; 11] = [-5.0, -3.0, -2.0, -1.0, -0.5, 0.0, 0.5, 1.0, 2.0, 3.0, 5.0];
fn assert_pinned(name: &str, actual: &Tensor, expected: &[f32]) {
let got: Vec<f32> = actual.iter().cloned().collect();
assert_eq!(got.len(), expected.len(), "{name}: length mismatch");
for (i, (&g, &e)) in got.iter().zip(expected.iter()).enumerate() {
let tol = 1e-5 * e.abs().max(1.0);
assert!(
(g - e).abs() <= tol,
"{name}[{i}] at x = {}: got {g}, want {e}, tolerance {tol}",
PROBES[i]
);
}
}
fn check_against_reference(name: &str, activation: Activation, fwd: &[f32], grad: &[f32]) {
let input = tensor2(1, PROBES.len(), PROBES.to_vec());
let (output, cache) = activation.forward_train(input).expect("forward failed");
assert_pinned(&format!("{name} forward"), &output, fwd);
let ones = tensor2(1, PROBES.len(), vec![1.0; PROBES.len()]);
let derivative = activation.backward(&cache, &ones).expect("backward failed");
assert_pinned(&format!("{name} backward"), &derivative, grad);
}
#[test]
fn leaky_relu_matches_reference() {
check_against_reference(
"LeakyReLU(0.3)",
Activation::LeakyReLU {
negative_slope: 0.3,
},
&[
-1.5,
-0.90000004,
-0.6,
-0.3,
-0.15,
0.0,
0.5,
1.0,
2.0,
3.0,
5.0,
],
&[0.3, 0.3, 0.3, 0.3, 0.3, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
);
}
#[test]
fn elu_matches_reference() {
check_against_reference(
"ELU(1.0)",
Activation::ELU { alpha: 1.0 },
&[
-0.99326205,
-0.95021296,
-0.86466473,
-0.63212055,
-0.39346933,
0.0,
0.5,
1.0,
2.0,
3.0,
5.0,
],
&[
0.0067379475,
0.049787045,
0.13533527,
0.36787945,
0.60653067,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
],
);
}
#[test]
fn elu_half_alpha_matches_reference() {
check_against_reference(
"ELU(0.5)",
Activation::ELU { alpha: 0.5 },
&[
-0.49663103,
-0.47510648,
-0.43233237,
-0.31606027,
-0.19673467,
0.0,
0.5,
1.0,
2.0,
3.0,
5.0,
],
&[
0.0033689737,
0.024893522,
0.067_667_63,
0.18393973,
0.30326533,
0.5,
1.0,
1.0,
1.0,
1.0,
1.0,
],
);
}
#[test]
fn selu_matches_reference() {
check_against_reference(
"SELU",
Activation::SELU,
&[
-1.7462534,
-1.6705688,
-1.5201665,
-1.1113307,
-0.691_758_2,
0.0,
0.525_350_5,
1.050701,
2.101402,
3.152_103,
5.253_505,
],
&[
0.011845981,
0.087_530_57,
0.23793285,
0.646_768_6,
1.0663412,
1.7580993,
1.050701,
1.050701,
1.050701,
1.050701,
1.050701,
],
);
}
#[test]
fn softplus_matches_reference() {
check_against_reference(
"Softplus",
Activation::Softplus,
&[
0.0067153485,
0.048587352,
0.126928,
0.313_261_7,
0.474_077,
std::f32::consts::LN_2,
0.974_077,
1.3132617,
2.126_928,
3.0485873,
5.0067153,
],
&[
0.006692851,
0.047425874,
0.11920291,
0.2689414,
0.37754068,
0.5,
0.62245935,
0.73105854,
0.880_797,
0.95257413,
0.993_307_2,
],
);
}
#[test]
fn softsign_matches_reference() {
check_against_reference(
"Softsign",
Activation::Softsign,
&[
-0.833_333_3,
-0.75,
-0.666_666_7,
-0.5,
-0.33333334,
0.0,
0.33333334,
0.5,
0.666_666_7,
0.75,
0.833_333_3,
],
&[
0.027777776,
0.0625,
0.11111112,
0.25,
0.44444448,
1.0,
0.44444448,
0.25,
0.11111112,
0.0625,
0.027777776,
],
);
}
#[test]
fn hard_sigmoid_matches_reference() {
check_against_reference(
"HardSigmoid",
Activation::HardSigmoid,
&[
0.0,
0.0,
0.16666667,
0.33333334,
0.416_666_7,
0.5,
0.583_333_4,
0.666_666_7,
0.833_333_4,
1.0,
1.0,
],
&[
0.0, 0.0, 0.16666667, 0.16666667, 0.16666667, 0.16666667, 0.16666667, 0.16666667,
0.16666667, 0.0, 0.0,
],
);
}
#[test]
fn exponential_matches_reference() {
let table = [
0.006737947,
0.049787067,
0.13533528,
0.36787945,
0.60653067,
1.0,
1.6487212,
2.7182817,
7.389_056,
20.085537,
148.41316,
];
check_against_reference("Exponential", Activation::Exponential, &table, &table);
}
#[test]
fn softplus_backward_keeps_the_far_negative_tail() {
let input = tensor2(1, 1, vec![-40.0]);
let (_, cache) = Activation::Softplus.forward_train(input).expect("forward");
let ones = tensor2(1, 1, vec![1.0]);
let derivative = Activation::Softplus
.backward(&cache, &ones)
.expect("backward");
let got = derivative.iter().next().copied().expect("1 element");
let expected = 4.248_354e-18_f32;
assert!(
(got - expected).abs() <= 1e-5 * expected,
"softplus derivative at x = -40: got {got}, want {expected}"
);
}
#[test]
fn validate_rejects_unusable_leaky_relu_slope() {
for slope in [0.0, -0.1, f32::NAN, f32::INFINITY] {
let result = Activation::LeakyReLU {
negative_slope: slope,
}
.validate();
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"slope {slope} must be rejected, got {result:?}"
);
}
}
#[test]
fn validate_rejects_unusable_elu_alpha() {
for alpha in [0.0, -1.0, f32::NAN, f32::NEG_INFINITY] {
let result = Activation::ELU { alpha }.validate();
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"alpha {alpha} must be rejected, got {result:?}"
);
}
}
#[test]
fn validate_accepts_usable_activations() {
let usable = [
Activation::Linear,
Activation::ReLU,
Activation::Sigmoid,
Activation::Tanh,
Activation::Softmax {
axis: DEFAULT_SOFTMAX_AXIS,
},
Activation::LeakyReLU {
negative_slope: 0.3,
},
Activation::ELU { alpha: 1.0 },
Activation::SELU,
Activation::Softplus,
Activation::Softsign,
Activation::HardSigmoid,
Activation::Exponential,
Activation::GELU { approximate: false },
Activation::GELU { approximate: true },
Activation::SiLU,
Activation::Mish,
];
for activation in usable {
assert!(
activation.validate().is_ok(),
"{activation:?} must pass validation"
);
}
}
#[test]
fn gelu_matches_reference() {
check_against_reference(
"GELU",
Activation::GELU { approximate: false },
&[
-1.433_257_9e-6,
-0.004_049_694,
-0.045_500_264,
-0.158_655_25,
-0.154_268_77,
0.0,
0.345_731_23,
0.841_344_8,
1.954_499_7,
2.995_950_3,
4.999_998_6,
],
&[
-7.146_946e-6,
-0.011_945_647,
-0.085_231_8,
-0.083_315_47,
0.132_504_88,
0.5,
0.867_495_1,
1.083_315_5,
1.085_231_8,
1.011_945_6,
1.000_007_1,
],
);
}
#[test]
fn gelu_tanh_matches_reference() {
check_against_reference(
"GELU(approximate)",
Activation::GELU { approximate: true },
&[
-2.291_796_2e-7,
-0.003_637_392,
-0.045_402_306,
-0.158_808_01,
-0.154_286,
0.0,
0.345_714,
0.841_192,
1.954_597_7,
2.996_362_6,
5.0,
],
&[
-1.546_362e-6,
-0.011_584_167,
-0.086_099_26,
-0.082_964_084,
0.132_630_1,
0.5,
0.867_369_9,
1.082_964_1,
1.086_099_3,
1.011_584_2,
1.000_001_5,
],
);
}
#[test]
fn silu_matches_reference() {
check_against_reference(
"SiLU",
Activation::SiLU,
&[
-0.033_464_255,
-0.142_277_62,
-0.238_405_84,
-0.268_941_42,
-0.188_770_33,
0.0,
0.311_229_67,
0.731_058_6,
1.761_594_2,
2.857_722_4,
4.966_535_6,
],
&[
-0.026_547_432,
-0.088_104_11,
-0.090_784_25,
0.072_329_49,
0.260_038_8,
0.5,
0.739_961_2,
0.927_670_5,
1.090_784_2,
1.088_104_1,
1.026_547_4,
],
);
}
#[test]
fn mish_matches_reference() {
check_against_reference(
"Mish",
Activation::Mish,
&[
-0.033_576_24,
-0.145_647_46,
-0.252_501_5,
-0.303_401_46,
-0.220_743_77,
0.0,
0.375_245_2,
0.865_098_4,
1.943_959,
2.986_535,
4.999_552,
],
&[
-0.026_747_498,
-0.093_393_12,
-0.108_355_09,
0.059_216_756,
0.289_510_68,
0.6,
0.886_424_4,
1.049_036_2,
1.069_317_9,
1.021_107,
1.000_800_2,
],
);
}
#[test]
fn gelu_keeps_the_negative_tail() {
let input = tensor2(1, 1, vec![-12.0]);
let activation = Activation::GELU { approximate: false };
let (output, cache) = activation.forward_train(input).expect("forward");
let derivative = activation
.backward(&cache, &tensor2(1, 1, vec![1.0]))
.expect("backward");
for (name, got, want) in [
("value", output[[0, 0]], -2.131_778_5e-32_f32),
("derivative", derivative[[0, 0]], -2.557_895_7e-31_f32),
] {
assert!(
(got - want).abs() <= 1e-4 * want.abs(),
"GELU {name} at x = -12: got {got}, want {want}"
);
}
}
#[test]
fn gelu_tanh_derivative_stays_finite_at_huge_inputs() {
let activation = Activation::GELU { approximate: true };
let input = tensor2(1, 4, vec![1e30, -1e30, f32::MAX, f32::MIN]);
let (_, cache) = activation.forward_train(input).expect("forward");
let derivative = activation
.backward(&cache, &tensor2(1, 4, vec![1.0; 4]))
.expect("backward");
let got: Vec<f32> = derivative.iter().copied().collect();
assert_eq!(got, vec![1.0, 0.0, 1.0, 0.0]);
}
#[test]
fn pre_activation_variants_match_a_central_difference() {
let points: Vec<f32> = (-60..=60).map(|i| i as f32 * 0.125).collect();
let step = 1e-2_f32;
for activation in [
Activation::GELU { approximate: false },
Activation::GELU { approximate: true },
Activation::SiLU,
Activation::Mish,
] {
let input = tensor2(1, points.len(), points.clone());
let (_, cache) = activation.forward_train(input).expect("forward");
let ones = tensor2(1, points.len(), vec![1.0; points.len()]);
let derivative = activation.backward(&cache, &ones).expect("backward");
let shifted = |delta: f32| {
let moved = tensor2(1, points.len(), points.iter().map(|x| x + delta).collect());
activation.forward(&moved).expect("forward")
};
let (above, below) = (shifted(step), shifted(-step));
for (i, &x) in points.iter().enumerate() {
let difference =
(above[[0, i]] as f64 - below[[0, i]] as f64) / (2.0 * step as f64);
let got = derivative[[0, i]] as f64;
assert!(
(got - difference).abs() <= 1e-3,
"{activation:?} at x = {x}: backward {got}, central difference {difference}"
);
}
}
}
#[test]
fn only_gelu_silu_and_mish_read_the_pre_activation() {
for activation in [
Activation::GELU { approximate: false },
Activation::GELU { approximate: true },
Activation::SiLU,
Activation::Mish,
] {
assert_eq!(activation.saves(), ActivationInput::PreActivation);
}
for activation in [
Activation::Linear,
Activation::ReLU,
Activation::Sigmoid,
Activation::Tanh,
Activation::Softmax {
axis: DEFAULT_SOFTMAX_AXIS,
},
Activation::LeakyReLU {
negative_slope: 0.3,
},
Activation::ELU { alpha: 1.0 },
Activation::SELU,
Activation::Softplus,
Activation::Softsign,
Activation::HardSigmoid,
Activation::Exponential,
] {
assert_eq!(activation.saves(), ActivationInput::Output);
}
}
#[test]
fn backward_refuses_a_cache_of_the_other_tensor() {
let tensor = tensor2(1, 3, vec![-1.0, 0.0, 1.0]);
let ones = tensor2(1, 3, vec![1.0; 3]);
let output_cache = ActivationCache::new(ActivationInput::Output, tensor.clone());
let input_cache = ActivationCache::new(ActivationInput::PreActivation, tensor);
for (activation, cache) in [
(Activation::SiLU, &output_cache),
(Activation::Tanh, &input_cache),
] {
assert!(
matches!(
activation.backward(cache, &ones),
Err(Error::InvalidInput(_))
),
"{activation:?} must refuse a {:?} cache",
cache.holds()
);
assert!(matches!(
activation.output_of(cache),
Err(Error::InvalidInput(_))
));
}
}
#[test]
fn backward_refuses_a_gradient_of_another_shape() {
let ones = tensor2(1, 4, vec![1.0; 4]);
for activation in [Activation::ReLU, Activation::Mish] {
let (_, cache) = activation
.forward_train(tensor2(1, 3, vec![-1.0, 0.0, 1.0]))
.expect("forward");
assert!(matches!(
activation.backward(&cache, &ones),
Err(Error::ShapeMismatch { .. })
));
}
}
#[test]
fn output_of_recovers_the_forward_output() {
let input = tensor2(1, 4, vec![-2.0, -0.5, 0.5, 2.0]);
for activation in [Activation::Tanh, Activation::GELU { approximate: false }] {
let (output, cache) = activation.forward_train(input.clone()).expect("forward");
assert_eq!(activation.output_of(&cache).expect("output_of"), output);
}
}
}