Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Reductions and matrix multiplication

matten provides whole-tensor reductions, axis reductions, and explicit matrix/vector multiplication. * remains element-wise — matrix multiplication always requires matmul or dot.

Whole-tensor reductions

#![allow(unused)]
fn main() {
use matten::Tensor;

let v = Tensor::from_vec(vec![1.0, 2.0, 3.0, 4.0]);

v.sum();   // 10.0
v.mean();  // 2.5
v.min();   // 1.0
v.max();   // 4.0
assert_eq!(v.sum(), 10.0);
assert_eq!(v.mean(), 2.5);
assert_eq!(v.min(), 1.0);
assert_eq!(v.max(), 4.0);
}

All four return f64. sum and mean propagate NaN naturally (IEEE 754). min and max return NaN if any element is NaN — this is deliberate and documented (see below).

NaN / Inf policy

OperationNaN behaviour
sumpropagates (NaN + x = NaN)
meanpropagates
minreturns NaN if any element is NaN
maxreturns NaN if any element is NaN
argmin / argmaxerror/panic if any element is NaN (an index is ill-defined)
#![allow(unused)]
fn main() {
let t = Tensor::from_vec(vec![1.0, f64::NAN, 3.0]);
assert!(t.min().is_nan());
assert!(t.max().is_nan());
}

Inf is handled normally: it participates in comparisons as expected.

Implementation note: min/max detect NaN explicitly and short-circuit. They do not use f64::min/f64::max (which silently ignore NaN).

Index reductions (argmin / argmax, RFC-038)

argmin/argmax return the flat, row-major index of the smallest/largest element, with the first occurrence winning ties:

#![allow(unused)]
fn main() {
use matten::Tensor;
let t = Tensor::new(vec![2.0, 9.0, 3.0, 1.0, 0.0, 4.0], &[2, 3]);
assert_eq!(t.argmin(), 4); // the 0.0
assert_eq!(t.argmax(), 1); // the 9.0
}

Unlike the value reductions above, an index is ill-defined when any element is NaN. These therefore follow the selection branch of the NaN policy: try_argmin/try_argmax return MattenError::InvalidArgument, and the convenience argmin/argmax panic with the same context. (On a dynamic tensor the try_* forms return MattenError::Unsupported; call try_numeric() first.)

Axis reductions

#![allow(unused)]
fn main() {
// [[1,2,3],[4,5,6]]
let m = Tensor::new(vec![1.0,2.0,3.0,4.0,5.0,6.0], &[2,3]);

m.sum_axis(0);   // column sums  -> shape [3]  -> [5,7,9]
m.sum_axis(1);   // row sums     -> shape [2]  -> [6,15]
m.mean_axis(0);  // column means -> shape [3]  -> [2.5,3.5,4.5]
m.mean_axis(1);  // row means    -> shape [2]  -> [2.0,5.0]
assert_eq!(m.sum_axis(0).shape(), &[3]);
assert_eq!(m.sum_axis(0).to_vec(), vec![5.0, 7.0, 9.0]);
assert_eq!(m.sum_axis(1).shape(), &[2]);
assert_eq!(m.sum_axis(1).to_vec(), vec![6.0, 15.0]);
assert_eq!(m.mean_axis(0).shape(), &[3]);
assert_eq!(m.mean_axis(0).to_vec(), vec![2.5, 3.5, 4.5]);
assert_eq!(m.mean_axis(1).shape(), &[2]);
assert_eq!(m.mean_axis(1).to_vec(), vec![2.0, 5.0]);
}

The reduced axis is removed from the output shape. Reducing a vector along its only axis gives a scalar-shaped tensor.

Both panic with an actionable message if axis >= ndim.

Empty reduced axis (RFC-110): mean_axis/try_mean_axis error (MattenError::InvalidArgument, or a panic carrying that message) when the reduced axis has length 0 — the mean of nothing is undefined. sum_axis is unaffected and returns the additive identity 0.0 per output slot, the same boundary RFC-105 drew for whole-tensor sum. A zero-length axis that survives the reduction (the axis you did not reduce) is a different case entirely and still returns Ok with an empty result — no constructor accepts a zero-sized shape, but slicing reaches one (t.slice().range(0..0).all().build()).

Read an axis reduction as “collapse that axis and keep the others”:

input shape [2, 3]
axes         0  1

axis 0 = rows      axis 1 remains, output shape [3]
axis 1 = columns   axis 0 remains, output shape [2]

For a [2, 3] matrix:

            columns / axis 1
             0   1   2
rows 0     [ 1   2   3 ]
axis 0     [ 4   5   6 ]

mean_axis(0): collapse rows, keep columns
             [ (1+4)/2  (2+5)/2  (3+6)/2 ]
          -> [   2.5      3.5      4.5   ]   shape [3]

mean_axis(1): collapse columns, keep rows
             [ (1+2+3)/3  (4+5+6)/3 ]
          -> [     2.0        5.0   ]         shape [2]

Vector dot product

#![allow(unused)]
fn main() {
let a = Tensor::from_vec(vec![1.0, 2.0, 3.0]);
let b = Tensor::from_vec(vec![4.0, 5.0, 6.0]);

let d = a.dot(&b);
assert!(d.is_scalar());
assert_eq!(d.as_slice(), &[32.0]); // 1*4 + 2*5 + 3*6
}

dot on two vectors [n] and [n] returns a scalar tensor (shape []).

try_dot returns Result<Tensor, MattenError> instead of panicking: MattenError::Shape on incompatible shapes or an unsupported rank combination, MattenError::Unsupported on a dynamic tensor (call try_numeric() on each operand first). dot delegates to try_dot and panics with the same message on error.

Matrix multiplication

matmul is an alias for dot, including its try_matmul/try_dot non-panicking form. Use whichever reads more clearly.

Left shapeRight shapeResult shape
[n][n][] scalar
[m, n][n][m]
[n][n, p][p]
[m, n][n, p][m, p]

Shape flow for the common matrix-matrix case:

left shape       right shape       result shape
[m, n]       x   [n, p]        ->  [m, p]
    ^             ^
    |             |
    shared inner dimension must match

Each output cell is one row from the left dotted with one column from the right:

left [2, 3]          right [3, 2]           result [2, 2]

[ a b c ]            [ x y ]                [ ax+bz+cu   ay+bw+cv ]
[ d e f ]       x    [ z w ]          ->    [ dx+ez+fu   dy+ew+fv ]
                     [ u v ]
#![allow(unused)]
fn main() {
let a = Tensor::new(vec![1.0,2.0,3.0,4.0], &[2,2]);
let b = Tensor::new(vec![5.0,6.0,7.0,8.0], &[2,2]);

let c = a.matmul(&b);
// [[19,22],[43,50]]
assert_eq!(c.as_slice(), &[19.0, 22.0, 43.0, 50.0]);
}

Incompatible shapes panic with an actionable message including both shapes, or with try_matmul, return Err(MattenError::Shape { .. }) with the same message. Batched matmul (rank > 2) is out of scope for the numeric core.

Axis reductions (min and max)

min_axis and max_axis reduce along an axis, removing it from the output shape, and propagate NaN the same way min and max do.

#![allow(unused)]
fn main() {
use matten::Tensor;

// [[3,1,4],[1,5,9]]
let m = Tensor::new(vec![3.0,1.0,4.0,1.0,5.0,9.0], &[2,3]);

m.min_axis(0);  // column minimums -> shape [3] -> [1.0, 1.0, 4.0]
m.max_axis(0);  // column maximums -> shape [3] -> [3.0, 5.0, 9.0]
m.min_axis(1);  // row minimums   -> shape [2] -> [1.0, 1.0]
m.max_axis(1);  // row maximums   -> shape [2] -> [4.0, 9.0]
assert_eq!(m.min_axis(0).to_vec(), vec![1.0, 1.0, 4.0]);
assert_eq!(m.max_axis(0).to_vec(), vec![3.0, 5.0, 9.0]);
assert_eq!(m.min_axis(1).to_vec(), vec![1.0, 1.0]);
assert_eq!(m.max_axis(1).to_vec(), vec![4.0, 9.0]);
}

NaN propagation: if any element along the reduced axis is NaN, the output for that position is NaN.

Empty reduced axis (RFC-110): min_axis/try_min_axis and max_axis/try_max_axis error (MattenError::InvalidArgument, or a panic carrying that message) when the reduced axis has length 0, rather than returning f64::INFINITY/f64::NEG_INFINITY — those are fold identities, not answers. A zero-length axis that survives the reduction still returns Ok with an empty result.

* is always element-wise

#![allow(unused)]
fn main() {
let a = Tensor::new(vec![1.0,2.0,3.0,4.0], &[2,2]);
let b = Tensor::new(vec![5.0,6.0,7.0,8.0], &[2,2]);

let elem = &a * &b;        // [5, 12, 21, 32]  ← element-wise
let mat  = a.matmul(&b);   // [19, 22, 43, 50] ← matrix product
}

matten never overloads * for matrix multiplication. If you need the matrix product, always call matmul or dot explicitly.

Performance note

matmul uses plain nested loops — correct and readable, but not cache-optimised. For large matrices, migrate the flat data to ndarray or nalgebra:

let flat: Vec<f64> = tensor.into_vec();
// hand off to your preferred crate

Display / formatting (RFC-100)

Tensor implements Display ({}) for a human-facing rendering, distinct from the single-line Debug ({:?}) used for diagnostics:

#![allow(unused)]
fn main() {
use matten::Tensor;

let t = Tensor::new(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], &[2, 3]);
assert_eq!(t.to_string(), "1.0 2.0 3.0\n4.0 5.0 6.0");
assert_eq!(
    format!("{t:?}"),
    "Tensor(shape=[2, 3], data=[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])"
);
}

Rank 0 is the bare scalar; rank 1 is one right-aligned row; rank 2 is a right-aligned grid, per-column widths, no brackets and no commas — matching what this project already renders elsewhere rather than ndarray’s [[1, 2], [3, 4]] syntax:

#![allow(unused)]
fn main() {
use matten::Tensor;

assert_eq!(Tensor::scalar(3.5).to_string(), "3.5");
assert_eq!(Tensor::new(vec![1.0, 2.0, 3.0], &[3]).to_string(), "1.0 2.0 3.0");
}

Every cell uses {:?} (Debug) formatting, not bare Displaymatten’s only element type is f64, and bare Display drops the .0 on whole numbers, which would make a grid of floats read as one of integers:

#![allow(unused)]
fn main() {
use matten::Tensor;
// Deliberately diverges from ndarray, which prints "1" here, not "1.0".
assert_eq!(Tensor::new(vec![1.0, 2.0], &[2]).to_string(), "1.0 2.0");
}

Rank > 2 has no honest 2-D arrangement, so it falls back to the flat form used before this RFC existed:

#![allow(unused)]
fn main() {
use matten::Tensor;
let t = Tensor::new((1..=8).map(|x| x as f64).collect(), &[2, 2, 2]);
assert_eq!(
    t.to_string(),
    "shape=[2, 2, 2] values=[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]"
);
}

A rank-1 row truncates past 12 values, and a rank-2 grid truncates past 12 columns, so Display on a huge tensor cannot flood a terminal. {:#} (the alternate flag) disables truncation:

#![allow(unused)]
fn main() {
use matten::Tensor;
let row: Vec<f64> = (1..=13).map(|x| x as f64).collect();
let t = Tensor::new(row, &[13]);
assert!(t.to_string().ends_with("... 1 more values"));
assert!(!format!("{t:#}").contains("more values"));
}

On a dynamic tensor (#[cfg(feature = "dynamic")]), each cell renders via Element’s own Display in the same grid, except Float, which uses {:?} on the inner f64 instead so a whole-number float stays visibly distinct from an Int of the same value — a dynamic tensor exists precisely to carry mixed types in one grid, and Element’s own Display alone would render Float(2.0) and Int(2) identically as 2:

#![allow(unused)]
fn main() {
use matten::{Element, Tensor};

let t = Tensor::from_elements(
    vec![Element::Float(2.0), Element::Int(2), Element::Float(1.5), Element::Int(7)],
    &[2, 2],
);
assert_eq!(t.to_string(), "2.0 2\n1.5 7");
}

Debug is unchanged by this RFC — it stays the single-line, truncated-at-8 diagnostic form RFC-020 defined, and is still the better choice for logs. Display is for a human looking at the data.

See also

For the three linalg-adjacent helpers norm, trace, and outer — and the list of advanced linear algebra that is intentionally out of core scope — see Linear algebra (core-lite).

For population variance and standard deviation — var, std, var_axis, std_axis — see Statistics (core-lite).