Skip to main content

kopitiam_tensor/tensor/
conv.rs

1//! 1-D convolution and max-pooling: [`Tensor::conv1d`] and
2//! [`Tensor::maxpool1d`].
3//!
4//! Some LSTM OCR network configurations put a small convolution and/or a
5//! max-pool at the input stage, ahead of the recurrent layers, to reduce the
6//! along-width resolution and mix neighbouring columns before the LSTM sees
7//! them. These are the standard signal-processing ops (valid, no-padding
8//! cross-correlation; windowed max) — clean-room standard math, not a port of
9//! Tesseract's image-stacking `Convolve`/reconfig `Maxpool`, which are shaped
10//! around its `StrideMap`/`NetworkIO` plumbing rather than a plain 1-D layer.
11
12use kopitiam_core::{DType, Error, Result, Shape};
13
14use super::Tensor;
15
16impl Tensor {
17    /// 1-D convolution (cross-correlation, the ML convention — the kernel is
18    /// *not* flipped), no padding ("valid"), with a configurable `stride`.
19    ///
20    /// `self` is the input `[in_channels, length]`; `weight` is
21    /// `[out_channels, in_channels, kernel]`; `bias`, if given, is
22    /// `[out_channels]`. The result is `[out_channels, out_length]` where
23    /// `out_length = (length - kernel) / stride + 1`. Each output element is
24    ///
25    /// ```text
26    /// out[oc][t] = bias[oc] + sum_ic sum_k input[ic][t*stride + k] * weight[oc][ic][k]
27    /// ```
28    ///
29    /// # Errors
30    ///
31    /// [`Error::DTypeMismatch`] if any operand is not `f32`.
32    /// [`Error::ShapeMismatch`] if `self` is not rank 2, `weight` is not
33    /// rank 3, the channel counts disagree, `stride` is 0, or `kernel` is
34    /// larger than `length` (which would make `out_length` empty or
35    /// ill-defined).
36    pub fn conv1d(&self, weight: &Tensor, bias: Option<&Tensor>, stride: usize) -> Result<Tensor> {
37        self.require_dtype(DType::F32)?;
38        weight.require_dtype(DType::F32)?;
39
40        if self.rank() != 2 || weight.rank() != 3 || stride == 0 {
41            return Err(Error::ShapeMismatch { expected: self.shape.clone(), actual: weight.shape.clone() });
42        }
43        let (in_channels, length) = (self.shape.dims()[0], self.shape.dims()[1]);
44        let wdims = weight.shape.dims();
45        let (out_channels, w_in_channels, kernel) = (wdims[0], wdims[1], wdims[2]);
46        if w_in_channels != in_channels || kernel == 0 || kernel > length {
47            return Err(Error::ShapeMismatch { expected: self.shape.clone(), actual: weight.shape.clone() });
48        }
49        let out_length = (length - kernel) / stride + 1;
50
51        // Materialize both operands in logical row-major order so the inner
52        // loops can index flat slices directly (correct for a transposed or
53        // narrowed input view too), the same approach matmul/norm take.
54        let input = self.to_vec_f32()?;
55        let w = weight.to_vec_f32()?;
56        let bias_vec = match bias {
57            Some(b) => {
58                b.require_dtype(DType::F32)?;
59                if b.elem_count() != out_channels {
60                    return Err(Error::ShapeMismatch { expected: weight.shape.clone(), actual: b.shape.clone() });
61                }
62                b.to_vec_f32()?
63            }
64            None => vec![0.0f32; out_channels],
65        };
66
67        let mut out = vec![0f32; out_channels * out_length];
68        for oc in 0..out_channels {
69            for t in 0..out_length {
70                let start = t * stride;
71                let mut acc = bias_vec[oc];
72                for ic in 0..in_channels {
73                    let in_row = &input[ic * length + start..ic * length + start + kernel];
74                    let w_row = &w[(oc * in_channels + ic) * kernel..(oc * in_channels + ic) * kernel + kernel];
75                    for (x, wv) in in_row.iter().zip(w_row) {
76                        acc += x * wv;
77                    }
78                }
79                out[oc * out_length + t] = acc;
80            }
81        }
82        Tensor::from_f32(out, Shape::new([out_channels, out_length]))
83    }
84
85    /// 1-D max-pooling over a sliding window, no padding, with a configurable
86    /// `stride`. Applied independently per channel.
87    ///
88    /// `self` is `[channels, length]`; the result is `[channels, out_length]`
89    /// where `out_length = (length - kernel) / stride + 1` and
90    /// `out[c][t] = max(input[c][t*stride .. t*stride + kernel])`.
91    ///
92    /// # Errors
93    ///
94    /// [`Error::DTypeMismatch`] if `self` is not `f32`.
95    /// [`Error::ShapeMismatch`] if `self` is not rank 2, `stride` or `kernel`
96    /// is 0, or `kernel` exceeds `length`.
97    pub fn maxpool1d(&self, kernel: usize, stride: usize) -> Result<Tensor> {
98        self.require_dtype(DType::F32)?;
99        if self.rank() != 2 || stride == 0 || kernel == 0 {
100            return Err(Error::ShapeMismatch { expected: self.shape.clone(), actual: self.shape.clone() });
101        }
102        let (channels, length) = (self.shape.dims()[0], self.shape.dims()[1]);
103        if kernel > length {
104            return Err(Error::ShapeMismatch { expected: self.shape.clone(), actual: self.shape.clone() });
105        }
106        let out_length = (length - kernel) / stride + 1;
107
108        let input = self.to_vec_f32()?;
109
110        let mut out = vec![0f32; channels * out_length];
111        for c in 0..channels {
112            for t in 0..out_length {
113                let start = t * stride;
114                let window = &input[c * length + start..c * length + start + kernel];
115                out[c * out_length + t] = window.iter().copied().fold(f32::NEG_INFINITY, f32::max);
116            }
117        }
118        Tensor::from_f32(out, Shape::new([channels, out_length]))
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn conv1d_single_channel_matches_hand_computation() {
128        // input [1,5] = [1,2,3,4,5], kernel [1,1,3] = [1,0,-1], stride 1.
129        // valid positions: out_len = (5-3)/1 + 1 = 3.
130        // out[0] = 1*1 + 2*0 + 3*(-1) = -2
131        // out[1] = 2*1 + 3*0 + 4*(-1) = -2
132        // out[2] = 3*1 + 4*0 + 5*(-1) = -2
133        let x = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0, 5.0], [1, 5]).unwrap();
134        let w = Tensor::from_f32(vec![1.0, 0.0, -1.0], [1, 1, 3]).unwrap();
135        let out = x.conv1d(&w, None, 1).unwrap();
136        assert_eq!(out.shape().dims(), &[1, 3]);
137        assert_eq!(out.to_vec_f32().unwrap(), vec![-2.0, -2.0, -2.0]);
138    }
139
140    #[test]
141    fn conv1d_applies_bias_and_stride() {
142        // input [1,5] = [1,2,3,4,5], kernel [1,1,2] = [1,1], bias [10], stride 2.
143        // out_len = (5-2)/2 + 1 = 2. positions t=0 (idx 0..2), t=1 (idx 2..4).
144        // out[0] = (1+2) + 10 = 13 ; out[1] = (3+4) + 10 = 17
145        let x = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0, 5.0], [1, 5]).unwrap();
146        let w = Tensor::from_f32(vec![1.0, 1.0], [1, 1, 2]).unwrap();
147        let bias = Tensor::from_f32(vec![10.0], [1]).unwrap();
148        let out = x.conv1d(&w, Some(&bias), 2).unwrap();
149        assert_eq!(out.shape().dims(), &[1, 2]);
150        assert_eq!(out.to_vec_f32().unwrap(), vec![13.0, 17.0]);
151    }
152
153    #[test]
154    fn conv1d_sums_over_input_channels_and_across_output_channels() {
155        // 2 in-channels, length 3; 2 out-channels; kernel 2. stride 1 -> out_len 2.
156        // input: ch0 = [1,2,3], ch1 = [4,5,6]
157        // oc0 weights: ic0=[1,0], ic1=[0,1]; oc1 weights: ic0=[1,1], ic1=[1,1]
158        // oc0,t0 = (1*1+2*0) + (4*0+5*1) = 1 + 5 = 6
159        // oc0,t1 = (2*1+3*0) + (5*0+6*1) = 2 + 6 = 8
160        // oc1,t0 = (1+2) + (4+5) = 12 ; oc1,t1 = (2+3) + (5+6) = 16
161        let x = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3]).unwrap();
162        let w = Tensor::from_f32(
163            vec![
164                1.0, 0.0, 0.0, 1.0, // oc0: ic0=[1,0], ic1=[0,1]
165                1.0, 1.0, 1.0, 1.0, // oc1: ic0=[1,1], ic1=[1,1]
166            ],
167            [2, 2, 2],
168        )
169        .unwrap();
170        let out = x.conv1d(&w, None, 1).unwrap();
171        assert_eq!(out.shape().dims(), &[2, 2]);
172        assert_eq!(out.to_vec_f32().unwrap(), vec![6.0, 8.0, 12.0, 16.0]);
173    }
174
175    #[test]
176    fn conv1d_rejects_a_channel_count_mismatch() {
177        let x = Tensor::from_f32(vec![1.0; 5], [1, 5]).unwrap();
178        let w = Tensor::from_f32(vec![1.0; 6], [1, 2, 3]).unwrap(); // wants 2 in-channels.
179        assert!(matches!(x.conv1d(&w, None, 1), Err(Error::ShapeMismatch { .. })));
180    }
181
182    #[test]
183    fn conv1d_rejects_a_kernel_wider_than_the_input() {
184        let x = Tensor::from_f32(vec![1.0, 2.0], [1, 2]).unwrap();
185        let w = Tensor::from_f32(vec![1.0; 3], [1, 1, 3]).unwrap();
186        assert!(matches!(x.conv1d(&w, None, 1), Err(Error::ShapeMismatch { .. })));
187    }
188
189    #[test]
190    fn conv1d_rejects_non_f32_input() {
191        let x = Tensor::from_i32(vec![1; 5], [1, 5]).unwrap();
192        let w = Tensor::from_f32(vec![1.0; 3], [1, 1, 3]).unwrap();
193        assert!(matches!(x.conv1d(&w, None, 1), Err(Error::DTypeMismatch { .. })));
194    }
195
196    #[test]
197    fn maxpool1d_matches_hand_computation() {
198        // input [1,6] = [1,3,2,5,4,0], kernel 2, stride 2.
199        // out_len = (6-2)/2 + 1 = 3. windows: [1,3]->3, [2,5]->5, [4,0]->4
200        let x = Tensor::from_f32(vec![1.0, 3.0, 2.0, 5.0, 4.0, 0.0], [1, 6]).unwrap();
201        let out = x.maxpool1d(2, 2).unwrap();
202        assert_eq!(out.shape().dims(), &[1, 3]);
203        assert_eq!(out.to_vec_f32().unwrap(), vec![3.0, 5.0, 4.0]);
204    }
205
206    #[test]
207    fn maxpool1d_overlapping_windows_per_channel() {
208        // 2 channels, length 4, kernel 2, stride 1 -> out_len 3 (overlapping).
209        // ch0 = [1,3,2,4]: [1,3]->3, [3,2]->3, [2,4]->4
210        // ch1 = [9,0,7,7]: [9,0]->9, [0,7]->7, [7,7]->7
211        let x = Tensor::from_f32(vec![1.0, 3.0, 2.0, 4.0, 9.0, 0.0, 7.0, 7.0], [2, 4]).unwrap();
212        let out = x.maxpool1d(2, 1).unwrap();
213        assert_eq!(out.shape().dims(), &[2, 3]);
214        assert_eq!(out.to_vec_f32().unwrap(), vec![3.0, 3.0, 4.0, 9.0, 7.0, 7.0]);
215    }
216
217    #[test]
218    fn maxpool1d_handles_negative_values() {
219        // All-negative window: the max must be the least-negative, not 0.
220        let x = Tensor::from_f32(vec![-3.0, -1.0, -5.0, -2.0], [1, 4]).unwrap();
221        let out = x.maxpool1d(2, 2).unwrap();
222        assert_eq!(out.to_vec_f32().unwrap(), vec![-1.0, -2.0]);
223    }
224
225    #[test]
226    fn maxpool1d_rejects_a_kernel_wider_than_the_input() {
227        let x = Tensor::from_f32(vec![1.0, 2.0], [1, 2]).unwrap();
228        assert!(matches!(x.maxpool1d(3, 1), Err(Error::ShapeMismatch { .. })));
229    }
230
231    #[test]
232    fn maxpool1d_rejects_non_f32_input() {
233        let x = Tensor::from_i32(vec![1; 4], [1, 4]).unwrap();
234        assert!(matches!(x.maxpool1d(2, 2), Err(Error::DTypeMismatch { .. })));
235    }
236}