Skip to main content

kopitiam_tensor/tensor/
activation.rs

1//! `silu` and `gelu` activations, plus the `tanh` and `sigmoid`/`logistic`
2//! pair the LSTM OCR recognizer's gates are built from.
3
4use kopitiam_core::{DType, Result};
5
6use crate::storage::Storage;
7
8use super::Tensor;
9
10/// `sqrt(2 / pi)`, the constant in the tanh approximation of GELU.
11const SQRT_2_OVER_PI: f32 = 0.797_884_6;
12
13impl Tensor {
14    /// SiLU / swish (Elfwing, Uchibe & Doya, 2017; also Hendrycks & Gimpel):
15    /// `x * sigmoid(x) = x / (1 + exp(-x))`. Used by LLaMA-family FFNs.
16    pub fn silu(&self) -> Result<Tensor> {
17        self.unary_f32(|x| x / (1.0 + (-x).exp()))
18    }
19
20    /// GELU (Hendrycks & Gimpel, 2016), tanh approximation:
21    /// `0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3)))`.
22    ///
23    /// This crate implements the tanh approximation rather than the exact
24    /// `0.5 * x * (1 + erf(x / sqrt(2)))` form because `erf` is not in
25    /// Rust's `std` — computing it exactly would mean shipping a rational
26    /// or Chebyshev `erf` approximation of our own, which is *more* code
27    /// and *more* numerical risk than the tanh form for a difference that
28    /// is below `1e-3` everywhere and is what GPT-2/GPT-NeoX-family models
29    /// were themselves trained and evaluated with (the tanh form is
30    /// sometimes called "gelu_new" in that lineage). If a model that
31    /// specifically requires the exact erf form shows up, add it as a
32    /// second method rather than replacing this one.
33    pub fn gelu(&self) -> Result<Tensor> {
34        self.unary_f32(|x| 0.5 * x * (1.0 + (SQRT_2_OVER_PI * (x + 0.044_715 * x * x * x)).tanh()))
35    }
36
37    /// Hyperbolic tangent, elementwise: `tanh(x)`, in `(-1, 1)`. The
38    /// cell-candidate (`g`) nonlinearity and the output squashing (`tanh(c')`)
39    /// of an LSTM cell — see [`Tensor::lstm_cell`].
40    ///
41    /// Matches Tesseract's `Tanh` (`functions.cpp`), computed directly via
42    /// `f32::tanh` rather than through that file's 8k-entry lookup table
43    /// (`TanhTable`): standard math, so there is nothing Tesseract-specific to
44    /// port here — the LUT is a speed/accuracy trade this crate has no reason
45    /// to reproduce.
46    pub fn tanh(&self) -> Result<Tensor> {
47        self.unary_f32(f32::tanh)
48    }
49
50    /// Logistic sigmoid, elementwise: `1 / (1 + exp(-x))`, in `(0, 1)`. The
51    /// input/forget/output gate nonlinearity of an LSTM cell — see
52    /// [`Tensor::lstm_cell`].
53    ///
54    /// Matches Tesseract's `Logistic` (`functions.cpp`), computed directly
55    /// rather than via its 8k-entry lookup table (`LogisticTable`); standard
56    /// math, no Tesseract-specific code to port. [`Tensor::logistic`] is an
57    /// alias for callers that prefer Tesseract's name for the same function.
58    pub fn sigmoid(&self) -> Result<Tensor> {
59        self.unary_f32(|x| 1.0 / (1.0 + (-x).exp()))
60    }
61
62    /// Alias for [`Tensor::sigmoid`] under Tesseract's name for the function.
63    pub fn logistic(&self) -> Result<Tensor> {
64        self.sigmoid()
65    }
66
67    fn unary_f32(&self, f: impl Fn(f32) -> f32) -> Result<Tensor> {
68        self.require_dtype(DType::F32)?;
69        let Storage::F32(data) = self.storage.as_ref() else { unreachable!() };
70        let out: Vec<f32> = self.logical_offsets().map(|i| f(data[i])).collect();
71        Tensor::from_f32(out, self.shape.clone())
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use kopitiam_core::Error;
78
79    use super::*;
80
81    fn assert_close(a: f32, b: f32, epsilon: f32) {
82        assert!((a - b).abs() < epsilon, "expected {b}, got {a}");
83    }
84
85    #[test]
86    fn silu_matches_hand_computation() {
87        // silu(0) = 0 * sigmoid(0) = 0 * 0.5 = 0
88        let t = Tensor::from_f32(vec![0.0, 1.0, -1.0], [3]).unwrap();
89        let out = t.silu().unwrap().to_vec_f32().unwrap();
90        assert_close(out[0], 0.0, 1e-6);
91        // silu(1) = 1 / (1 + e^-1) ~= 0.7310586
92        assert_close(out[1], 0.731_058_6, 1e-5);
93        // silu(-1) = -1 / (1 + e^1) ~= -0.2689414
94        assert_close(out[2], -0.268_941_4, 1e-5);
95    }
96
97    #[test]
98    fn silu_matches_the_reference_formula_across_a_range_of_inputs() {
99        // silu is *not* globally monotonic (it dips to a minimum around
100        // x ~= -1.278 before rising) so the meaningful correctness check is
101        // "matches x * sigmoid(x) pointwise", not a monotonicity claim.
102        let xs = vec![-5.0, -3.0, -1.278, -1.0, -0.5, 0.0, 0.5, 1.0, 3.0, 5.0];
103        let t = Tensor::from_f32(xs.clone(), [xs.len()]).unwrap();
104        let out = t.silu().unwrap().to_vec_f32().unwrap();
105        for (x, o) in xs.iter().zip(&out) {
106            let expected = x / (1.0 + (-x).exp());
107            assert_close(*o, expected, 1e-5);
108        }
109    }
110
111    #[test]
112    fn silu_is_monotonically_increasing_on_its_increasing_branch() {
113        // For x >= -1.278ish, silu is monotonically increasing; this is the
114        // range every real activation input in a trained model's residual
115        // stream overwhelmingly falls into after the first few layers.
116        let t = Tensor::from_f32(vec![-1.0, 0.0, 1.0, 2.0, 3.0], [5]).unwrap();
117        let out = t.silu().unwrap().to_vec_f32().unwrap();
118        for pair in out.windows(2) {
119            assert!(pair[1] > pair[0], "silu should be increasing here: {out:?}");
120        }
121    }
122
123    #[test]
124    fn gelu_matches_hand_computation_at_zero_and_matches_known_values() {
125        // gelu(0) = 0.
126        let t = Tensor::from_f32(vec![0.0, 1.0, -1.0], [3]).unwrap();
127        let out = t.gelu().unwrap().to_vec_f32().unwrap();
128        assert_close(out[0], 0.0, 1e-6);
129        // Known reference values for the tanh-approximation GELU (matches
130        // the widely used "gelu_new" implementation to ~1e-6).
131        assert_close(out[1], 0.841_192, 1e-4);
132        assert_close(out[2], -0.158_808, 1e-4);
133    }
134
135    #[test]
136    fn gelu_approaches_the_identity_for_large_positive_x_and_zero_for_large_negative_x() {
137        let t = Tensor::from_f32(vec![10.0, -10.0], [2]).unwrap();
138        let out = t.gelu().unwrap().to_vec_f32().unwrap();
139        assert_close(out[0], 10.0, 1e-3);
140        assert_close(out[1], 0.0, 1e-3);
141    }
142
143    #[test]
144    fn activations_reject_non_f32_input() {
145        let t = Tensor::from_i32(vec![1, 2, 3], [3]).unwrap();
146        assert!(matches!(t.silu(), Err(Error::DTypeMismatch { .. })));
147        assert!(matches!(t.gelu(), Err(Error::DTypeMismatch { .. })));
148        assert!(matches!(t.tanh(), Err(Error::DTypeMismatch { .. })));
149        assert!(matches!(t.sigmoid(), Err(Error::DTypeMismatch { .. })));
150    }
151
152    #[test]
153    fn tanh_matches_known_values_and_is_odd() {
154        // tanh(0) = 0; tanh(1) ~= 0.7615942; tanh is odd: tanh(-1) = -tanh(1).
155        let t = Tensor::from_f32(vec![0.0, 1.0, -1.0], [3]).unwrap();
156        let out = t.tanh().unwrap().to_vec_f32().unwrap();
157        assert_close(out[0], 0.0, 1e-6);
158        assert_close(out[1], 0.761_594_2, 1e-6);
159        assert_close(out[2], -0.761_594_2, 1e-6);
160    }
161
162    #[test]
163    fn tanh_is_monotonic_and_saturates_towards_plus_minus_one() {
164        let t = Tensor::from_f32(vec![-20.0, -2.0, -0.5, 0.0, 0.5, 2.0, 20.0], [7]).unwrap();
165        let out = t.tanh().unwrap().to_vec_f32().unwrap();
166        for pair in out.windows(2) {
167            assert!(pair[1] > pair[0], "tanh should be strictly increasing: {out:?}");
168        }
169        assert_close(out[0], -1.0, 1e-6); // saturates to -1 for large negative x.
170        assert_close(out[6], 1.0, 1e-6); //  saturates to +1 for large positive x.
171    }
172
173    #[test]
174    fn sigmoid_matches_known_values_and_the_reflection_identity() {
175        // sigmoid(0) = 0.5; sigmoid(2) ~= 0.8807971; sigmoid(-x) = 1 - sigmoid(x).
176        let t = Tensor::from_f32(vec![0.0, 2.0, -2.0], [3]).unwrap();
177        let out = t.sigmoid().unwrap().to_vec_f32().unwrap();
178        assert_close(out[0], 0.5, 1e-6);
179        assert_close(out[1], 0.880_797_1, 1e-6);
180        assert_close(out[2], 1.0 - 0.880_797_1, 1e-6);
181    }
182
183    #[test]
184    fn sigmoid_is_monotonic_saturates_and_stays_in_the_open_unit_interval() {
185        let t = Tensor::from_f32(vec![-40.0, -3.0, 0.0, 3.0, 40.0], [5]).unwrap();
186        let out = t.sigmoid().unwrap().to_vec_f32().unwrap();
187        for pair in out.windows(2) {
188            assert!(pair[1] > pair[0], "sigmoid should be strictly increasing: {out:?}");
189        }
190        for &v in &out {
191            assert!((0.0..=1.0).contains(&v), "sigmoid output escaped [0, 1]: {v}");
192        }
193        assert_close(out[0], 0.0, 1e-6); // saturates towards 0.
194        assert_close(out[4], 1.0, 1e-6); // saturates towards 1.
195    }
196
197    #[test]
198    fn logistic_is_an_alias_for_sigmoid() {
199        let t = Tensor::from_f32(vec![-1.5, 0.0, 0.25, 3.0], [4]).unwrap();
200        assert_eq!(
201            t.logistic().unwrap().to_vec_f32().unwrap(),
202            t.sigmoid().unwrap().to_vec_f32().unwrap(),
203        );
204    }
205
206    #[test]
207    fn tanh_and_sigmoid_preserve_shape_and_respect_a_transposed_view() {
208        // Elementwise ops must read through logical order, not raw storage.
209        let t = Tensor::from_f32(vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0], [2, 3]).unwrap();
210        let tt = t.transpose(0, 1).unwrap(); // shape [3, 2], non-contiguous.
211        let out = tt.tanh().unwrap();
212        assert_eq!(out.shape().dims(), &[3, 2]);
213        assert_eq!(
214            out.to_vec_f32().unwrap(),
215            [0.0f32, 3.0, 1.0, 4.0, 2.0, 5.0].iter().map(|x| x.tanh()).collect::<Vec<_>>(),
216        );
217        assert_eq!(tt.sigmoid().unwrap().shape().dims(), &[3, 2]);
218    }
219}