Mathematical foundations
Tensors, Shapes, and Broadcasting
Learn tensor notation, axis semantics, reshaping, broadcasting, contractions, and the shape reasoning that prevents silent model errors.
By the end you can
- Interpret tensor axes as named semantic dimensions rather than anonymous integers
- Trace reshaping, transposition, broadcasting, and contraction operations
- Distinguish views that preserve data from operations that aggregate or duplicate values
- Build a shape-checking habit for sequence, image, and attention computations
Visual
Common axes in modern ML
Different modalities reuse a small vocabulary of semantic dimensions: batch, sequence or time, channel or feature, spatial axes, head or expert. The vocabulary exists in every framework. What does not exist is any mechanism that holds a name to its axis. The array stores sizes and offsets. The meaning lives in your head, or in a comment.
Three researchers proposed writing the names into the mathematics itself, in a 2023 paper in Transactions on Machine Learning Research: “We propose a notation for tensors with named axes, which relieves the author, reader, and future implementers of machine learning models from the burden of keeping track of the order of axes and the purpose of each.”
Position is what the machine tracks. Meaning is what you have to track. The rest of this lesson is about what happens when you stop.
Batch
Independent examples processed together.
Sequence or time
Ordered positions such as tokens, frames, or events.
Channel or feature
Measurements or learned coordinates at each position.
Spatial axes
Height, width, depth, or graph-local structure.
Head or expert
Parallel submodules whose outputs are later combined.
Naming axes makes otherwise cryptic tensor expressions readable.
A tensor is an array with a contract
In applied ML, a tensor is a multidimensional array whose axes carry meaning. A shape such as 32×128×768 might mean batch, token, and hidden dimension. The numbers alone are ambiguous. Swapping the token and hidden axes preserves the element count and changes the computation completely.
Tensor literacy is therefore semantic bookkeeping. Every operation should state which axes it preserves, reorders, expands, reduces, or contracts — and, as the strides below show, which of those changes touch memory at all.
A correct shape is not enough; each axis must still mean what the next operation assumes.
Case
Strides of (24, 8), and the same shape at (3072, 1, 96, 3)
Shape counts the elements along each axis. Strides count the bytes you jump to reach the next one along it. NumPy's own paper, published in Nature in 2020, works it through: “Consider, for example, a two-dimensional array of floating-point numbers with shape (4, 3), where each element occupies 8 bytes in memory.” Then the answer: “The strides of that array are therefore (24, 8).” Twenty-four bytes to step to the next row, eight to step to the next column.
The same paper disposes of the idea that broadcasting moves anything: “In broadcasting, one or both arrays are virtually duplicated (that is, without copying any data in memory), so that the shapes of the operands match”. The duplication is bookkeeping.
Strides are also where logical axis order parts company with the hardware, and the gap has a price you can measure. NVIDIA's guide for convolutional layers tells users which order to store: “Layout choice has an effect on performance, as convolutions implemented for Tensor Cores require NHWC layout and are fastest when input tensors are laid out in NHWC.” NCHW is not refused, only taxed — “NCHW layouts can still be operated on by Tensor Cores, but include some overhead due to automatic transpose operations”.
PyTorch exposes the same split between logical shape and physical order directly. Its own tutorial states it: “The channels last memory format is an alternative way of ordering NCHW tensors in memory preserving dimensions ordering”. The printed numbers make that literal. After .to(memory_format=torch.channels_last), x.shape is still torch.Size([10, 3, 32, 32]) while the strides move from (3072, 1024, 32, 1) to (3072, 1, 96, 3). Same shape, same elements, a different walk through memory, a different speed.
Tensor contraction generalizes the dot product
A contraction multiplies entries and sums over one or more matched axes. Matrix multiplication contracts the shared inner dimension. Einstein notation writes repeated indices as summed, allowing compact expressions such as yᵢ = Aᵢⱼxⱼ. The notation highlights which indices remain in the output. Attention scores, convolutions, and batched linear maps are all structured contractions.
Batched linear maps are where that index logic became a language feature, with a written rule about which axes are contracted and which merely come along. PEP 465 added Python's @ operator, and its specification states the rule: “For inputs with more than 2 dimensions, we treat the last two dimensions as being the dimensions of the matrices to multiply, and ‘broadcast’ across the other dimensions.” Its worked example is small enough to check by hand — “arr(10, 2, 3) @ arr(10, 3, 4) performs 10 separate matrix multiplies … returns the 10 resulting matrices together in an array with shape (10, 2, 4)”.
NumPy's reference page for numpy.matmul repeats it — “If either argument is N-D, N > 2, it is treated as a stack of matrices residing in the last two indexes and broadcast accordingly” — and then shows what the older function does with identical operands. On a = np.ones([9,5,7,4]) and c = np.ones([9,5,4,3]), np.dot(a, c).shape is (9, 5, 7, 9, 5, 3) while np.matmul(a, c).shape is (9, 5, 7, 3). Two functions, the same two arrays, two perfectly legal output shapes. Only one of them is the batched contraction you meant.
Python 3.5 shipped the operator in 2015 and recorded the addition plainly: “PEP 465 adds the @ infix operator for matrix multiplication”, and “NumPy 1.10 has support for the new operator”. Libraries provide the optimized kernels. The index logic stays yours.
Comparison
Reshape, transpose, broadcast, and reduce change different things
These four operations can share output dimensions while meaning very different things, and only one of them has a published contract you can cite when a shape surprises you. NumPy's user guide gives it: “When operating on two arrays, NumPy compares their shapes element-wise. It starts with the trailing (i.e. rightmost) dimension and works its way left.” Two axes are compatible when “they are equal, or” when “one of them is 1”. Otherwise the operation stops with the exact message “ValueError: operands could not be broadcast together”. Where it succeeds, it proceeds “without making needless copies of data”.
That right-to-left rule is not a NumPy convenience. The Python array API standard, in its 2025.12 text, defines broadcasting as “the automatic (implicit) expansion of array dimensions to be of equal sizes without copying array data” and writes out the rank-promotion algorithm step by step (“If d1 == 1, then set the i-th element of shape to d2” …). The ONNX operator specification adopts it wholesale — “Multidirectional broadcasting is the same as Numpy's broadcasting” — and tabulates worked cases such as shape(A) = (1, 4, 5), shape(B) = (2, 3, 1, 1) ==> shape(result) = (2, 3, 4, 5).
Three independent bodies, one rule. And the rule aligns axes by position rather than by meaning. Reshape, transpose and reduce have no comparable published contract about what your axes mean. They have only your intent, which is why the columns below have to be read as semantics and not as shapes.
Reshape or view
Reinterprets contiguous elements under a new grouping.
- Preserves element count
- May require memory contiguity
- Does not aggregate values
- Can destroy axis meaning if used carelessly
Transpose or permute
Reorders axes.
- Preserves all elements
- Changes strides and downstream interpretation
- Often needed to align matrix products
- Not equivalent to reshaping
Broadcast
Virtually expands size-one or missing axes.
- Avoids explicit copying in many systems
- Applies one value across several positions
- Can hide unintended pairwise operations
- Requires compatible axis alignment
Reduce
Aggregates over one or more axes.
- Changes information content
- Includes sum, mean, max, and norm
- Must specify retained dimensions
- Can mix examples if the wrong axis is chosen
Example
Shape tracing through one attention head
The Transformer's own numbers make the trace concrete. The 2017 paper that introduced it fixes the count: “In this work we employ h = 8 parallel attention layers, or heads.” At d_model = 512 that fixes d_k = d_v = d_model/h = 64, and the operation itself is Attention(Q, K, V) = softmax(QK^T/sqrt(d_k))V. So the trace runs over batch, 8 heads, tokens, and a 64-wide feature axis — not over an abstract d.
- Projection: Harvard NLP's line-by-line reimplementation writes the split as `lin(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)`, under the comment `# 1) Do all the linear projections in batch from d_model => h x d_k`. That turns batch×tokens×512 into batch×8×tokens×64.
- Scores: QKᵀ contracts the 64-wide d_k axis and produces batch×8×tokens×tokens pairwise scores. The two axes that survive are both token axes. That is why the score matrix is square in tokens and not in features.
- Softmax and weighted values: normalization runs across the key-token axis for each query. The score matrix then multiplies V, contracting the key-token axis and returning batch×8×tokens×64. Then `x.transpose(1, 2).contiguous().view(nbatches, -1, self.h * self.d_k)` glues the 8 heads back into 512.
- Masking: a broadcastable mask must align with query and key positions, not with the 64-wide feature axis. And since alignment is decided from the trailing dimension leftward, a mask with the right number of elements in the wrong position will still broadcast.
- Failure mode: an accidental transpose can produce legal dimensions while changing which positions attend to which. Note that transpose(1, 2) is the legitimate projection step above; the same call in the wrong place is the bug in the next section.
Example
torch.Size([6, 96, 96, 3]) in, torch.Size([96, 3, 96]) out
This failure has been written up with real shapes rather than a hypothetical one. Alexander Rush opens his account bluntly: “Despite its ubiquity in deep learning, Tensor is broken. It forces bad habits such as exposing private dimensions, broadcasting based on absolute position, and keeping type information in documentation.”
The demonstration is a rotate() implemented as ims.transpose(1, 2). On the batch it was written against, ims.shape is torch.Size([6, 96, 96, 3]) and the function does what its name says. Handed a single image, ims[0], the same call returns torch.Size([96, 3, 96]). The three-wide channel axis is now in the middle, sitting where a spatial axis used to be. Nothing raised.
- Shape compatibility: later operations may still accept it. torch.Size([96, 3, 96]) is a legal three-axis tensor with the same element count as the input, so shape validation has nothing to object to.
- Semantic failure: a reduction meant for space now averages channels, and alignment fails in ways that read as arithmetic rather than as meaning. Rush's own printout of the position rule misfiring is the line `Broadcasting fail torch.Size([96, 96]) torch.Size([6, 96, 96, 3])`.
- Diagnostic: use named dimensions. PyTorch shipped exactly this response on 8 October 2019 — “Named Tensors allow users to give explicit names to tensor dimensions… avoiding the need to track dimensions by position”, and “Named tensors use names to automatically check that APIs are being called correctly at runtime.” The point is to support “broadcasting by name” rather than “broadcasting by position”. The documentation still warns that “The named tensor API is a prototype feature and subject to change.”
- Test: feed a pattern that varies along only one axis, at a size that cannot alias — a 96×96×3 image will not tell you much, but distinct small values along one axis will show a transpose immediately.
- Lesson: matching element counts does not preserve axis meaning. Six years after the transpose bug was written up, the durable fix is still a name attached to the axis rather than a check on its size.
Steps
A disciplined shape trace
Write this trace before implementing an unfamiliar tensor expression. The np.dot and np.matmul pair above is the reason it is worth the minute it costs: identical operands, (9, 5, 7, 9, 5, 3) against (9, 5, 7, 3), both legal, one of them silently the wrong computation. Steps one and two are what separate those two outcomes. They are cheaper than debugging a model that trains to a mediocre loss for a reason no exception ever reported.
1. Name every axis
Attach semantic labels to each dimension of every input.
2. Write the intended output
State output axes before choosing operations.
3. Mark contractions
Identify which axes are multiplied and summed.
4. Mark broadcasts and reductions
Record where values repeat or information is aggregated.
5. Test a tiny case
Use small distinct values so transposes and reductions are visible.
Analogy
A tensor index is a coordinate in a labeled storage system
Goods in a distribution network are organized by warehouse, aisle, shelf, and product type. A tensor index is a coordinate in that labeled storage system. Permuting axes changes the order in which shelves are described. Broadcasting copies one instruction across many shelves. Reduction totals goods across a chosen label.
Two caveats keep the picture honest, and both are measurable rather than rhetorical. Broadcasting is virtual duplication rather than physical copying, as the Nature paper states outright. And memory layout can differ from logical axis order: the channels-last tensor holds torch.Size([10, 3, 32, 32]) while its strides move from (3072, 1024, 32, 1) to (3072, 1, 96, 3).
The warehouse can be re-shelved without a single aisle being renamed. It can be renamed without a single crate being moved.
Tensor operations are safe when axis labels survive every transformation.
Key idea
Shape-compatible does not mean semantically correct
Many tensor bugs do not raise an exception. Summing over batch instead of time can produce a perfectly valid tensor with the wrong meaning. Broadcasting can also create a large pairwise interaction accidentally. A vector intended to align by feature may align by example after an inserted dimension. So use named dimensions in comments or types, assert shapes at boundaries, and test operations on tiny hand-checkable tensors.
This is not only a hazard for learners writing their own layers. Broadcast shape arithmetic is a tracked defect surface in mature frameworks too. A crash in TensorFlow's broadcast helper was published as CVE-2022-41890 on 18 November 2022: “If `BCast::ToShape` is given input larger than an `int32`, it will crash, despite being supposed to handle up to an `int64`.”
The vulnerability record files it under weakness CWE-704, with CVSS 3.1 base scores of 7.5 from NVD and 4.8 from GitHub. The reproduction path runs through tf.experimental.numpy.outer — an outer product, which is to say precisely the accidental pairwise interaction this section warns about, arriving through a supported public API. The fix, commit 8310bf8dd188ff780e7fc53245058215a05bdbe5, shipped in TensorFlow 2.11.0 and was back-ported to 2.8.4, 2.9.3 and 2.10.1.
A shape computation large enough to overflow an int32 is a shape computation nobody read.
The most dangerous tensor error is one whose dimensions are legal.
Key takeaways
- Tensor axes are named semantic dimensions rather than anonymous positions. PyTorch shipped named dimensions on 8 October 2019, and a 2023 paper in Transactions on Machine Learning Research proposed a notation for them.
- Reshaping, transposing, broadcasting and reducing preserve or destroy different structure: ims.transpose(1, 2) is the legitimate projection step inside multi-head attention and the whole of the rotate() bug that turns torch.Size([6, 96, 96, 3]) into torch.Size([96, 3, 96]).
- Contraction generalizes the dot product, and which axes are contracted is a specified choice, not a detail: on the same operands np.dot gives (9, 5, 7, 9, 5, 3) where np.matmul gives (9, 5, 7, 3).
- Attention math becomes transparent once the numbers are real — d_model = 512 split across h = 8 heads of d_k = d_v = 64, with the 64-wide feature axis contracted and the two token axes surviving.
- Broadcasting has a published right-to-left rule in NumPy's user guide, the Python array API standard 2025.12 and the ONNX specification. It aligns axes by position rather than by meaning, which is what makes silent misalignment possible.
- Tiny hand-checkable tensors, boundary assertions, named dimensions and attention to memory layout — (3072, 1024, 32, 1) against (3072, 1, 96, 3) at identical shape — are the practical defenses. CVE-2022-41890 shows that mature frameworks need them too.