The Math Behind AI
Vectors, matrices, gradients, and probability, built one picture at a time
Sjoerd Vink
Modern AI papers are written in three languages at once: linear algebra, calculus and probability. Most of what you need from each fits in a handful of ideas.
This essay builds those ideas one at a time. Each one appears three ways: as numbers you can check, as a picture you can see, and as code you can run.
v = np.array([3, 2])w = np.array([-1, 1])np.linalg.norm(v)1.5 * vv + wE["king"] - E["man"] + E["woman"]01 Vectors
A vector is a list of numbers.
A neural network can only work with numbers. A pixel, a word and a sound all get turned into lists of numbers called vectors. This one has two entries.
Papers write , which means is a list of two real numbers. Its entries are and .
The same two numbers are also directions: 3 steps to the right, then 2 up. An arrow from the origin to where you end up shows the vector as a point in space, with a direction.
Every vector is both a list of numbers to compute with and an arrow to think with.
The arrow has a length, written . The two entries are the sides of a right triangle, so Pythagoras gives it.
With more entries the rule stays the same: square each entry, add them up, take the square root. This length is called the norm.
Multiplying a vector by a number scales every entry. The arrow keeps its direction and changes length. A negative number also flips it to point the other way.
Adding two vectors adds their entries. In the picture, put at the tip of . The sum is the arrow from the origin to the end of .
Scaling and adding are all you need for most of what follows. A weighted sum such as is the two operations together.
Language models give every token a vector called its embedding. Directions in that space can carry meaning. In a famous result from early word embeddings, the step from “man” to “woman” is roughly the same arrow as the step from “king” to “queen”.
Real embeddings have hundreds or thousands of entries, not two. Nobody can draw them, but length, scaling and addition work exactly the same way.
a = np.array([3, 1])b = np.array([1, 2])a @ bnorm(a) * norm(b) * np.cos(theta)a @ b / norm(a)02 Dot product
The dot product multiplies and adds.
Take two vectors with the same number of entries. Multiply the entries that line up, then add the products. One number comes out.
Papers also write it as or . In NumPy it is a @ b.
That number also has a meaning in the picture. It is the product of the two lengths and the cosine of the angle between the arrows.
The lengths set the scale. The cosine measures agreement: 1 when the arrows point the same way, 0 at a right angle, −1 when they point in opposite directions.
From the tip of , drop a line onto the line through , meeting it at a right angle. The stretch from the origin to where it lands, shown in amber, is the projection of onto . Think of it as the shadow casts on . It is 1.58 long.
The dot product equals the length of that shadow times the length of .
The dot product measures how far one vector goes in the direction of the other.
Turn until it makes a right angle with . The shadow disappears and the dot product is 0.
Vectors at right angles are called orthogonal. Neither has any part that points along the other.
Turn further and the shadow falls behind the origin. The dot product becomes negative: -6.12 at 150°.
Now turn yourself. The value follows : largest when the arrows line up, zero at 90°, most negative when they point opposite ways.
The arithmetic does not care how many entries there are. In 4,096 dimensions the dot product still means agreement in direction, even though nobody can draw the angle.
A single dot product measures how much two vectors agree. Used on embeddings, it measures how similar two things are.
scores = E @ qE = E / np.linalg.norm(E, axis=1, keepdims=True)scores = E @ (q / np.linalg.norm(q))best = np.argsort(-scores)[:k]02 Dot product/ Similarity
Only the direction should count.
Search engines, recommendation systems and retrieval for chatbots all rank items by the dot product between their embeddings and a query vector .
Here the rose arrow is a query about puppies. The raw dot product ranks “dog” first, with 2.83 against 1.19, only because its arrow is longer. In real embeddings, length often reflects how common a word is, not what it means.
The fix is to divide every vector by its length first. All arrows then end on the unit circle, and the dot product is exactly the cosine of the angle between them.
Now “puppy” wins with 1.00 against 0.93. This is cosine similarity.
Stack all item embeddings as the rows of a matrix . One product, E @ q, then scores every item at once, and sorting the scores gives the best matches.
Attention in a Transformer uses the same step. Every token has a query, and it scores every token, itself included, by a dot product. The Transformer essay builds that up from here.
A dot product compares a vector with a fixed direction. Make that direction learnable, and you have a neuron.
score = w @ xscore = w @ x + bside = score > 0scores = W @ x + b02 Dot product/ Neuron
A neuron is a dot product.
An artificial neuron holds a weight vector and a number called the bias. Given an input , it computes one score.
Here and there are two kinds of points. We want the score to tell them apart.
Ignore the bias for now. Which points have ? Exactly the ones at a right angle to . Together they form a line through the origin, perpendicular to .
In three dimensions this is a plane. In more, it is called a hyperplane, and the idea is the same.
On the side points to, the score is positive. On the other side it is negative. The marked point scores 4.5.
The sign says which side of the line a point is on, and the size grows with its distance from the line. This line is in the wrong place, though: most rose points are on the positive side too.
The bias moves the line without turning it. With , the boundary moves 2.12 along , and the sign of the score now separates the two classes.
Training a neuron means finding the and that put the line in the right place.
A layer has many neurons, each with its own weights and bias, and so each with its own line. Stack the weight vectors as the rows of a matrix , and the whole layer becomes one operation.
A layer is many dot products computed at once. That is what a matrix does, which is the next chapter.
A = np.array([[2, -1], [1, 1]])A @ xx[0] * A[:, 0] + x[1] * A[:, 1]np.linalg.det(A)(R @ A) @ x03 Matrices
A matrix moves space.
A matrix is a grid of numbers. Multiplying it by a vector gives a new vector. Read row by row, each output entry is the dot product of one row with the input, exactly like a layer of neurons.
The same product can also be read column by column. takes 1 of the first column and 2 of the second, and adds them.
In the picture, is 1 step along followed by 2 steps along .
Now apply to every point of the plane at once. The grid tilts and stretches, but its lines stay straight, parallel and evenly spaced, and the origin does not move. That is what linear means.
The columns of a matrix are where and land.
Every other point follows the same recipe as before: 1 step along the new , 2 along the new .
The unit square becomes a parallelogram. Its area is the determinant, and every area in the plane is scaled by the same factor.
A determinant of 0 means the plane is squashed onto a line or a point, and some information is lost for good. A negative determinant means the plane is flipped over.
Follow with a quarter turn . The two steps together are a single matrix, the product .
Multiplying matrices composes transformations, and . The order matters: turns first and gives a different result.
C = A @ BC[i, j] = A[i, :] @ B[:, j]Y = X @ W + bY = X @ layer.weight.T + layer.bias03 Matrices/ Multiplication
Matrix multiplication is a grid of dot products.
Matrices do not have to be square. A matrix with 2 rows and 3 columns has shape (2, 3) and lives in .
Two matrices can be multiplied when the inner sizes match. Those sizes are summed over and disappear.
Each entry of the result is a row of dotted with a column of . Entry uses row and column .
Every row meets every column. An by product is dot products, each of length .
All of those dot products are independent of each other, so they can run in parallel. GPUs are built to do exactly that.
This is how a layer runs on a batch. Each row of is an input, each column of is one neuron's weights, and one product scores every input against every neuron.
Papers often write a single input as a column and use , with one row of per neuron, as in the previous chapter. Code puts inputs in rows and writes X @ W, with one column per neuron. It is the same computation with everything transposed, because .
The transpose swaps rows and columns: . PyTorch's nn.Linear stores its weight with shape (out, in), so it computes x @ W.T + b.
When shapes do not line up, a missing transpose is the first thing to check.
Multiplying matrices only ever gives another matrix. Something else has to bend space.
mlp = nn.Sequential( nn.Linear(2, 2), nn.ReLU(), nn.Linear(2, 2),)03 Matrices/ Nonlinearity
Depth needs a bend.
Here is the plane again, with a circle drawn on it. We will push it through a tiny network with two layers and watch what happens to the shape.
The first layer, , turns and shrinks the plane, and the bias shifts it. The circle shrinks and moves, and the grid lines stay straight.
Apply the second layer straight away and the grid is still straight. Two linear layers multiply into one:
A hundred linear layers in a row can do no more than a single one. Depth alone adds nothing.
Put a ReLU between them. ReLU keeps positive numbers and replaces negative ones with zero, entry by entry.
Every point with a negative coordinate is pushed onto an axis. The plane folds, and everything outside the top-right quarter is pressed flat against its edges.
The second layer then moves the folded sheet. The result is no longer one straight grid. It is made of flat pieces joined at creases, and each ReLU neuron adds a crease.
This two-layer network is an MLP. Every Transformer block contains one, with thousands of dimensions instead of two and often a smoother cousin of ReLU such as GELU. With enough creases it can bend space into almost any shape.
Matrices move space. Nonlinearities bend it. Training chooses the matrices.
f = lambda x: (x - 1) ** 2 / 3 + 0.3slope = (f(x + h) - f(x)) / hdf = lambda x: 2 * (x - 1) / 3x = x - lr * df(x)04 Derivatives
A derivative is a slope.
Training means turning a model's weights until a loss, a number that measures its mistakes, is as small as possible. Start with a single weight and a loss .
We are at , where the loss is 1.05. Should go up or down? From the picture it is obvious. A computer has no picture. It can only evaluate at points.
So it tries a nearby point. Move by and compare the two values of . Rise over run gives the slope of the line through both points.
A smaller step, , gives a slope of 1.17. The line turns and hugs the curve more closely around .
Let shrink towards zero. The line ends up touching the curve at a single point. This is the tangent, and its slope is the derivative.
Nobody takes these limits by hand. A few rules, such as , and , together with the chain rule from the next chapter, cover nearly everything in deep learning.
A positive slope means the loss goes up to the right, so we step left, against the slope. This is gradient descent.
The step size is the learning rate. With , each step here halves the distance to the minimum: .
Make the learning rate too large, , and every step overshoots to the other side and lands further out than it started: . The loss gets worse and worse.
The derivative gives the direction. The learning rate decides how far to trust it.
Real networks do not have one weight. They have millions or billions. Start with two.
loss = w[0] ** 2 / 2 + 2 * w[1] ** 2loss.backward()with torch.no_grad(): w -= lr * w.grad w.grad.zero_()04 Derivatives/ Gradient
The gradient points uphill.
With two weights the loss becomes a landscape over the plane. Here it is drawn as a contour map: each ellipse joins points with the same loss, like height lines on a hiking map. The minimum sits in the middle.
Freeze and move only along . That is an ordinary one-dimensional slope, called the partial derivative . Then do the same in the other direction.
The curly means: change one input and hold all the others still.
Put the partial derivatives in a vector and you get the gradient, . It points in the direction where the loss rises fastest, which is always at right angles to the contour line through the point.
The gradient is the slope in many dimensions: one number per weight, pointing uphill.
Gradient descent takes a small step against the gradient, computes a new gradient, and repeats. It is the same update as before, now with vectors.
With the path curves down into the valley and settles at the minimum.
With the path zigzags. The valley is steep across and shallow along , so a step that suits one direction overshoots in the other.
Real loss landscapes are full of valleys like this, and better optimizers reduce the zigzag. Momentum keeps a running average of past gradients, so the back-and-forth cancels out. Adam does the same and also gives each weight its own step size.
In PyTorch you never write the gradient yourself. loss.backward() fills w.grad with , for millions of weights at once. It can do that because of the chain rule.
u = x ** 2y = 3 * u + 1dy_dx = 3 * (2 * x)05 Chain rule
Rates multiply.
A neural network is a chain of functions: each layer feeds the next. Take a short chain, followed by .
At we get and . How strongly does react when changes?
Nudge by a small amount. Near , the square changes about times as fast as , so the nudge is stretched 3 times on its way to .
Then stretches it 3 times more. Every function in the chain stretches the nudge by its own derivative.
So moves about 3 × 3 = 9 times as much as . That is the chain rule: the rate of a chain is the product of the rates of its links.
The notation makes it look as though the 's cancel like in a fraction. They are not really fractions, but it is a good way to remember the rule.
p = w * xz = p + ba = torch.sigmoid(z)loss = (a - y) ** 2loss.backward()w.grad, b.grad05 Chain rule/ Backpropagation
Backpropagation runs the chain rule backwards.
Here is one neuron with a sigmoid, scored by a squared error against a target , drawn as a computation graph. Each circle is one simple operation.
The forward pass computes left to right. The values are written in blue above the edges, ending in a loss of 0.072.
Now ask how the loss depends on every value, starting from the end. The last operation is a square, whose derivative is simple.
It is negative: raising would lower the loss.
Next comes the sigmoid. Its local derivative is . By the chain rule, multiply it with what arrived from the right.
An addition passes the gradient on unchanged to both of its inputs, because nudging either input nudges the sum by the same amount. So .
For a multiplication , the local derivative with respect to each input is the other input. So the gradient for is scaled by , and the gradient for by .
Each operation only needs its own local derivative. Multiply them together on the way back.
That is backpropagation. During the forward pass, autograd records the graph. loss.backward() then walks it in reverse and fills in the gradient of every weight. One backward pass costs only a small multiple of a forward pass, typically about two, however many weights there are.
Deep networks multiply many local derivatives together, and that can go wrong. The sigmoid's derivative is never more than 0.25, so a long chain of sigmoids drives gradients towards zero. This vanishing gradient problem is one reason modern networks use ReLU and residual connections.
x = np.array([1, 3, 4, 5, 6, 7, 9])mu = x.mean()var = ((x - mu) ** 2).mean()sigma = np.sqrt(var)z = (x - mu) / sigma06 Probability
Data has a center and a spread.
Here are seven measurements. Networks constantly summarize and rescale numbers like these: their inputs, their activations inside every layer, and their weights when they are first set up.
The mean is the balance point. If every value were a weight on a ruler, the ruler would balance there. To compute it, add the values and divide by how many there are.
Papers write the mean as an expectation, . Read as “the average of”.
How far are the values from the mean? Draw each distance as a square. The variance is the average area of those squares.
Squaring makes every distance positive, and it makes values far from the mean count much more than values close to it.
Variance is measured in squared units. Its square root, the standard deviation , is the side of the average square, back in the original units. It is the typical distance from the mean.
Many quantities bunch up around their mean in a bell shape. This is the normal distribution , and the mean and variance are all it needs.
It is everywhere in AI. Weights often start out as random draws from it, and variational autoencoders and diffusion models generate new samples by starting from it.
Subtract the mean and divide by the standard deviation. The data now has mean 0 and standard deviation 1, and its shape is unchanged.
LayerNorm does exactly this to every token vector inside a Transformer, and then applies a learned scale and shift. It keeps numbers in a steady range as they pass through dozens of layers.
A model that predicts the next word has to say how likely each word is. Raw scores are not enough for that.
logits = np.array([3.0, 2.0, 0.5, -1.0])e = np.exp(logits / T)p = e / e.sum()06 Probability/ Softmax
Softmax turns scores into probabilities.
A language model ends with one score per word in its vocabulary. These raw scores are called logits. Here are four candidates for the next word in “the cat sat on the”.
Logits can be any number, negative ones included. They are not probabilities yet.
First, exponentiate. is always positive and keeps the order of the scores. It also stretches the gaps: a logit that is 1 higher becomes times larger.
Then divide by the total. The results are positive and sum to 1, so they form a probability distribution. “mat” gets 0.68.
Dividing the logits by a temperature first changes the shape. A low temperature sharpens the distribution towards the top word. A high temperature flattens it towards uniform.
This is the temperature setting of a chatbot. Drag it and watch the probabilities move.
loss = -np.log(p[target])loss = F.cross_entropy(logits, target)grad = p - np.eye(len(p))[target]06 Probability/ Cross-entropy
Cross-entropy measures surprise.
The right next word is “mat”, and the model gave it a probability of 0.68. The loss is the negative log of that probability.
The curve shows this loss for every possible probability. It is 0 when the model gives the right answer probability 1, and grows as that probability shrinks.
A confident mistake is expensive. If the model gives “mat” only 0.05, the loss jumps to 3.00. The curve rises steeply near zero, so wrong answers given with confidence are punished hard.
A confident correct answer costs almost nothing: 0.03. Averaged over every token in the training data, this is the cross-entropy loss.
Read as “the probability the model with weights gives to when it sees ”. Minimizing this loss is the same as maximizing the likelihood of the data. Language model papers often report instead, called perplexity.
With softmax and cross-entropy combined, the gradient of one example's loss with respect to the logits is remarkably simple. With the one-hot vector of the right answer:
The right word's logit gets a gradient of -0.32, so a descent step raises it. Every other logit gets its own probability as gradient, so it is lowered in proportion to how likely the model thought it was.
Prediction minus truth. That is the error signal backpropagation carries into the network.
for step in range(60): logits = X @ W + b P = softmax(logits) loss = -np.log(P[np.arange(n), y]).mean() g = (P - Y) / n dW, db = X.T @ g, g.sum(axis=0) W -= lr * dW b -= lr * db- 01 Vectors
- 02 Dot product
- 03 Matrices
- 04 Derivatives
- 05 Chain rule
- 06 Probability
One training loop uses every chapter.
Here is a complete classifier trained from scratch in NumPy. The inputs are 45 points in the plane, each belonging to one of three classes. holds one point per row, and has one column of weights per class.
The forward pass is a single matrix product. Every point is dotted with every class's weight vector.
Softmax turns each row of logits into probabilities over the three classes.
The loss is the average cross-entropy: the negative log probability of the correct class. Before training, every class gets a third, so the loss starts at .
holds the correct classes as one-hot rows. At the logits, the gradient of the average loss is : prediction minus truth, averaged. The chain rule carries it back through the matrix product.
The transpose is there because of the shapes: (2, n) @ (n, 3) gives (2, 3), the shape of .
When you are unsure about a gradient formula, check that the shapes match.
Then take one step of gradient descent against each gradient, with learning rate .
Repeat. After 60 steps the loss has dropped from 1.10 to 0.04.
Large language models are trained with this same loop. The forward pass is a far bigger function, gradients come from autograd, the optimizer is Adam instead of plain descent, and the data is trillions of tokens. The math is the same.
Reading the notation
Papers pack these ideas into dense symbols. Here are the ones that appear most often, with what they mean and how they look in code.
- a vector of d real numbers
- x.shape == (d,)
- a matrix with m rows and n columns
- W.shape == (m, n)
- a single entry
- x[i], W[i, j]
- dot product
- x @ y
- length, or norm
- np.linalg.norm(x)
- transpose: rows become columns
- W.T
- matrix times vector
- W @ x
- entry-by-entry product
- x * y
- how L changes when only w changes
- w.grad
- gradient of L for all weights θ
- loss.backward()
- one step of gradient descent
- optimizer.step()
- expected value, the average
- x.mean()
- variance and standard deviation
- np.var(x), np.std(x)
- the sigmoid; σ means two things, context decides
- torch.sigmoid(z)
- normal distribution
- mu + sigma * torch.randn(n)
- x is drawn at random from p
- x = p.sample()
- probability of y given x, under weights θ
- softmax(model(x))[y]
- cross-entropy loss for one example
- F.cross_entropy(logits, y)
Vectors hold the data. Dot products compare it. Matrices transform it, and nonlinearities bend it. Derivatives say which way to change the weights, the chain rule gets that answer through every layer, and probability turns outputs into predictions and losses.
That is the toolkit. Put it to work in the Transformer and the autoencoder, where every step uses something from this page.