Lecture 05
The Algebra of Matrices
Four readings of AB on one small example, why order matters and grouping does not, and what it takes to undo a matrix at all.
104 slides / MIT 18.06, Lecture 3 / Strang §2.4–2.5

The idea in one sentence
There are four ways to read the product , they always agree on the numbers, and the three you were not taught in school are the ones you will think with.
One product, four readings
Everything in this chapter runs on one pair:
Four times now you will get back. What changes each time is which piece of meets which piece of , and therefore what the product is telling you.
Four cameras on one product. Orange is what you take from and , green is what comes out, and that pairing holds for the rest of the chapter. Each reading hands back a different shape: one entry, one whole layer, one column, one row. Three of them are pieces of you can read straight off; the second is a layer, and three layers add to the same answer.
Row times column. The school rule. Entry of is row of dotted with column of :
Four dot products, four numbers. This is how you compute by hand and how the innermost loop of a matrix library is written. It says nothing about what the product means.
Draw it once and the mechanics stop being a rule to remember. Put above the answer and beside it, and the row and the column you are pairing run straight into the cell they build.
Where one entry comes from. The orange row of runs right, the orange column of runs down, and the green cell is where they meet. The three products are stacked on the right, one for each row of , so the index that vanishes in the sum is the one running down the page.
Column times row. Take column of and row of , multiply them the wide way, and add up over :
Add the three and you get again. Each piece is a whole matrix built from one column and one row, so both of its columns are multiples of , or of , or of . A matrix like that is called rank one: it has one direction in it, repeated. This reading says is a sum of three rank-one layers. It is the least obvious of the four and by far the most valuable later; the whole of Part VI is about which layers of a matrix carry the information and which can be thrown away.
The same product as a sum of three flat pieces. Each orange layer is one column of meeting one row of , so it carries a single direction and nothing more. Part VI lives here.
Matrix times column. Column of is acting on column of , and Lecture 2 already told you what that is: a combination of the columns of , with the entries of that column as the weights. So column 1 of is
That has a consequence you get for free: every column of is a mix of columns of , so can never reach anywhere could not reach on its own. Multiplying on the right can only narrow the set of reachable outputs.
Row times matrix. The mirror image. Row of is row of used as weights on the rows of , so row 1 of is
This is the reading Lecture 4 leaned on the whole way through. An elimination matrix is the identity with one entry changed, and row 2 of is row 2 of used as weights on the rows of . Change one weight and you change one row of the answer. That is why a matrix on the left moves rows.
Move between the readings until they stop feeling like four rules and start feeling like one object seen from four sides:
Orange is what you take in, green is what comes out. The numbers never change.
Order matters
Take two matrices that both do something simple to the rows of whatever they touch. adds row 2 into row 1. adds row 1 into row 2:
Different matrices, from the same two factors. The reason is easy to say out loud. In the matrix is closer to whatever comes next, so acts first: row 1 gets added into row 2, and then the already-changed row 2 gets added into row 1. In the first step is the other one, so the second step works on a row nobody has touched yet. Operations that interfere cannot be reordered.
The yellow square is where both products start, and the blue arrows show where its two sides land. The green regions have the same area and different shapes. Two shears in two orders are two different machines.
Sometimes the failure is louder than that. With the and from the top of the chapter, is 2 by 2 and is 3 by 3, so the question of whether they are equal never gets asked. The one pair that always commutes is a matrix and the identity: , because doing nothing before and doing nothing after are the same nothing.
Grouping does not matter
Order is fixed. Bracketing is free:
The reason is that both sides compute the same double sum. Entry is either way; the brackets only decide which sum you do first. This law looks like bookkeeping and pays like an investment.
It is what let Lecture 4 collapse the chain into one matrix acting on alone. It is also the entire reason an inverse is unique, in a single line: if and , then . Two candidate inverses have no room to differ.
And it decides how fast your code runs.
Both groupings give the same answer for matrices. Pink is the pair to group first if you enjoy waiting; green is the one to group first otherwise. Never build a matrix you are only going to hit with a vector.
Cutting a matrix into blocks
Nothing in the four readings cared that the pieces were single rows and columns. Cut into rectangular blocks instead, cut to match, and the same rule holds with numbers replaced by blocks:
The one condition is that the vertical cuts in line up with the horizontal cuts in . If is split after column 2, then has to be split after row 2, so that has as many columns as has rows.
Block multiplication is the ordinary rule with numbers replaced by blocks. Orange meets orange and green meets green, which is what the blue cuts have to guarantee.
You have already used this. The augmented matrix from Lecture 4 is a block matrix with one skinny block glued on the right, and is block multiplication written out. Deep learning uses the same idea at scale: a batch of inputs is stacked into one wide block so that one call to the hardware does the work of a thousand.
The shape the hardware actually wants
Blocks are not only a proof device. A library never multiplies two matrices entry by entry: it cuts the answer into tiles and builds one tile at a time, because a tile is small enough to sit in fast memory while the panels stream past it.
How the machine multiplies. Orange is what you take in, green is what comes out, and the yellow edge is the shared dimension that meets and vanishes. One tile of needs a panel of rows from and a panel of columns from ; the loop then takes the next tile, and the next, until is full. This loop has a name, GEMM, and it is where the time goes.
Now put a real layer through it. A batch of 32 images, each flattened to 784 numbers, meets a weight matrix that turns 784 numbers into 128.
One layer, one call. The 784 that both operands share is the yellow edge, and it is gone from the answer, exactly as was. Every forward pass in every network you will meet is this picture, repeated once per layer.
The four readings from the top of this chapter are four ways to describe what happens inside one tile. The machine picks whichever one its memory prefers, and the answer is the same either way.
Undoing a matrix
A matrix takes to . The natural question is whether anything takes back to . If some matrix does, for every , then
and is called invertible. For square matrices either equation implies the other, so you only ever have to check one.
When is there no such matrix? Suppose for some that is not the zero vector. also sends to . So two different inputs produce the same output, and no machine on earth can send that one output back to both places. Lecture 2 met this already: the ring road machine sent to zero because raising all three cities by a meter changed no climb, and that is exactly why it could not be undone. Singular and “kills a nonzero vector” are the same condition wearing different clothes.
What singular costs you. Blue is the map. On the left two different inputs land on the same pink point, so an arrow back would have to choose between them. On the right nothing was lost, and every blue arrow has a green one returning beside it.
At size 2 you can write the answer down. For
Try it on , where :
The number is the determinant. The formula divides by it, so a zero determinant is a matrix with no inverse. At size 2 that happens exactly when the two columns point along the same line and the machine flattens the plane. Lecture 14 builds the determinant properly, for every size, out of that one idea.
Inverses come in reverse order
Put on socks, then shoes. To undo that you take off the shoes first. Matrices are the same:
Orange is doing, green is undoing. Walking a chain backwards means the last thing done is the first thing undone, so the bottom row is the top row reversed.
The proof is one line of associativity used twice:
Check it on the two shears. and both come out as , while comes out as , a different matrix. The order in the answer really is the reverse of the order in the question.
Computing an inverse, once
Here is the honest way to find by hand. Elimination does all the work; what changes is where you point it. says that times column of equals column of . So the columns of the inverse are the solutions of systems that all share the same matrix. Solve them together: glue the whole identity on as extra columns and run elimination on the block . When the left half becomes , the right half has become . Elimination downward makes the staircase, elimination upward clears above the pivots, and one division per row finishes it. That last upward sweep is the only new move, and it is why the method carries Jordan’s name alongside Gauss’s.
Run it on the matrix this course will keep coming back to, the second-difference matrix:
Downward, the pivots are , then , then . Upward and scaled, the right half settles at
That answer has more in it than the nine numbers.
The pivots multiply to , and 4 is the determinant of . Every entry of the inverse is then divided by that 4. A matrix with determinant zero has nothing to divide by, which is the same sentence as “no inverse”, arrived at from a third direction.
is symmetric and so is . That always happens, and it will matter a great deal from Lecture 15 onward.
And is as sparse as a matrix can be while still being connected: seven nonzeros out of nine, and every nonzero sits on or beside the diagonal. Its inverse has nine out of nine. Grow the matrix and the contrast grows with it.
Sparse in, dense out. Every entry of the green inverse depends on every entry of the orange original, so the band spreads to fill the whole square. Depth of colour is size.
Why nobody calls inv
Start with the picture above. A sparse matrix of size a million has a few million nonzeros and fits in memory. Its inverse has a trillion entries and does not exist on any machine you own.
Then there is speed. The inverse costs a full elimination plus back substitutions, and then you still have to multiply by . Solving directly costs one elimination and one back substitution. On a system, measured on the machine that wrote this chapter, inverting and multiplying took 0.20 seconds while solving took 0.067.
The reason that bites silently is accuracy. The code below solves a system whose true answer is all ones, using a matrix chosen to be nasty. Going through the inverse gives an answer wrong by 12.6 in its worst coordinate. Solving directly is wrong by 0.22. Both are bad, because the matrix is genuinely awful, and one of them is nearly sixty times worse for no benefit.
is how you think. solve is how you compute. The code below shows the four readings
agreeing, then the inverse story end to end.
import numpy as np
A = np.array([[1., 2., 3.], [4., 5., 6.]])
B = np.array([[1., 0.], [2., 1.], [0., 3.]])
print(A @ B) # [[ 5. 11.] [14. 23.]]
print(np.array([[A[i] @ B[:, j] for j in range(2)] # rows dot columns
for i in range(2)])) # [[ 5. 11.] [14. 23.]]
print(sum(np.outer(A[:, k], B[k]) for k in range(3))) # rank-one layers
# [[ 5. 11.] [14. 23.]]
print(np.column_stack([A @ B[:, j] for j in range(2)])) # columns of A, mixed
# [[ 5. 11.] [14. 23.]]
print(np.vstack([A[i] @ B for i in range(2)])) # rows of B, mixed
# [[ 5. 11.] [14. 23.]]
S = np.array([[1., 1.], [0., 1.]])
T = np.array([[1., 0.], [1., 1.]])
print(S @ T) # [[2. 1.] [1. 1.]]
print(T @ S) # [[1. 1.] [1. 2.]] order is not negotiableimport torch
A = torch.tensor([[1., 2., 3.], [4., 5., 6.]])
B = torch.tensor([[1., 0.], [2., 1.], [0., 3.]])
print(A @ B) # [[ 5. 11.] [14. 23.]]
print(torch.stack([torch.stack([A[i] @ B[:, j] for j in range(2)])
for i in range(2)])) # rows dot columns
# [[ 5. 11.] [14. 23.]]
print(sum(torch.outer(A[:, k], B[k]) for k in range(3))) # rank-one layers
# [[ 5. 11.] [14. 23.]]
print(torch.stack([A @ B[:, j] for j in range(2)], dim=1)) # columns of A, mixed
# [[ 5. 11.] [14. 23.]]
print(torch.stack([A[i] @ B for i in range(2)])) # rows of B, mixed
# [[ 5. 11.] [14. 23.]]
S = torch.tensor([[1., 1.], [0., 1.]])
T = torch.tensor([[1., 0.], [1., 1.]])
print(S @ T) # [[2. 1.] [1. 1.]]
print(T @ S) # [[1. 1.] [1. 2.]] order is not negotiableimport jax
jax.config.update("jax_enable_x64", True) # jax is single precision unless you ask
import jax.numpy as jnp
A = jnp.array([[1., 2., 3.], [4., 5., 6.]])
B = jnp.array([[1., 0.], [2., 1.], [0., 3.]])
print(A @ B) # [[ 5. 11.] [14. 23.]]
print(jnp.einsum('ik,kj->ij', A, B)) # rows dot columns
# [[ 5. 11.] [14. 23.]]
print(sum(jnp.outer(A[:, k], B[k]) for k in range(3))) # rank-one layers
# [[ 5. 11.] [14. 23.]]
print(jnp.stack([A @ B[:, j] for j in range(2)], axis=1)) # columns of A, mixed
# [[ 5. 11.] [14. 23.]]
print(jnp.stack([A[i] @ B for i in range(2)])) # rows of B, mixed
# [[ 5. 11.] [14. 23.]]
S = jnp.array([[1., 1.], [0., 1.]])
T = jnp.array([[1., 0.], [1., 1.]])
print(S @ T) # [[2. 1.] [1. 1.]]
print(T @ S) # [[1. 1.] [1. 2.]] order is not negotiableimport tensorflow as tf
A = tf.constant([[1., 2., 3.], [4., 5., 6.]])
B = tf.constant([[1., 0.], [2., 1.], [0., 3.]])
print(A @ B) # [[ 5. 11.] [14. 23.]]
print(tf.einsum('ik,kj->ij', A, B)) # rows dot columns
# [[ 5. 11.] [14. 23.]]
print(sum(tf.einsum('i,j->ij', A[:, k], B[k]) for k in range(3)))
# rank-one layers
# [[ 5. 11.] [14. 23.]]
print(tf.stack([A @ B[:, k:k+1] for k in range(2)], axis=1)[..., 0])
# columns of A, mixed
# [[ 5. 11.] [14. 23.]]
print(tf.concat([A[i:i+1] @ B for i in range(2)], axis=0)) # rows of B, mixed
# [[ 5. 11.] [14. 23.]]
S = tf.constant([[1., 1.], [0., 1.]])
T = tf.constant([[1., 0.], [1., 1.]])
print(S @ T) # [[2. 1.] [1. 1.]]
print(T @ S) # [[1. 1.] [1. 2.]] order is not negotiableThe second block is Gauss-Jordan written out, then the measurements behind the paragraphs above. Only NumPy is timed, because a stopwatch reading is a fact about one machine.
import numpy as np
K = np.array([[2., -1., 0.], [-1., 2., -1.], [0., -1., 2.]])
M = np.hstack([K, np.eye(3)]) # the block [K | I], three rows of six
for k in range(3): # down: clear below every pivot
for i in range(k + 1, 3):
M[i] -= (M[i, k] / M[k, k]) * M[k]
pivots = np.diag(M[:, :3]).copy()
print(pivots) # [2. 1.5 1.33333333]
print(np.prod(pivots), np.linalg.det(K)) # 4.0 4.0 pivots multiply to det
for k in (2, 1): # up: clear above every pivot
for i in range(k):
M[i] -= (M[i, k] / M[k, k]) * M[k]
M /= np.diag(M[:, :3])[:, None] # one division per row
print(M[:, :3]) # the identity, so the left half is done
print(4 * M[:, 3:]) # [[3. 2. 1.] [2. 4. 2.] [1. 2. 3.]]
print(np.allclose(M[:, 3:], np.linalg.inv(K))) # True
K8 = (np.diag(2 * np.ones(8)) - np.diag(np.ones(7), 1) - np.diag(np.ones(7), -1))
print(np.count_nonzero(K8)) # 22 sparse going in
print(np.sum(np.abs(np.linalg.inv(K8)) > 1e-9)) # 64 dense coming outimport torch
K = torch.tensor([[2., -1., 0.], [-1., 2., -1.], [0., -1., 2.]])
M = torch.cat([K, torch.eye(3)], dim=1) # the block [K | I], three rows of six
for k in range(3): # down: clear below every pivot
for i in range(k + 1, 3):
M[i] -= (M[i, k] / M[k, k]) * M[k]
pivots = torch.diag(M[:, :3]).clone()
print(pivots) # tensor([2.0000, 1.5000, 1.3333])
print(pivots.prod(), torch.linalg.det(K)) # tensor(4.) tensor(4.)
for k in (2, 1): # up: clear above every pivot
for i in range(k):
M[i] -= (M[i, k] / M[k, k]) * M[k]
M /= torch.diag(M[:, :3])[:, None] # one division per row
print(M[:, :3]) # the identity, so the left half is done
print(4 * M[:, 3:]) # [[3. 2. 1.] [2. 4. 2.] [1. 2. 3.]]
print(torch.allclose(M[:, 3:], torch.linalg.inv(K), atol=1e-6)) # True
K8 = (torch.diag(2 * torch.ones(8)) - torch.diag(torch.ones(7), 1)
- torch.diag(torch.ones(7), -1))
print(torch.count_nonzero(K8)) # tensor(22) sparse going in
print((torch.linalg.inv(K8).abs() > 1e-9).sum()) # tensor(64) dense coming outimport jax
jax.config.update("jax_enable_x64", True) # jax is single precision unless you ask
import jax.numpy as jnp
K = jnp.array([[2., -1., 0.], [-1., 2., -1.], [0., -1., 2.]])
M = jnp.hstack([K, jnp.eye(3)]) # the block [K | I], three rows of six
for k in range(3): # down: clear below every pivot
for i in range(k + 1, 3):
M = M.at[i].add(-(M[i, k] / M[k, k]) * M[k])
pivots = jnp.diag(M[:, :3])
print(pivots) # [2. 1.5 1.33333333]
print(jnp.prod(pivots), jnp.linalg.det(K)) # 4.0 4.0
for k in (2, 1): # up: clear above every pivot
for i in range(k):
M = M.at[i].add(-(M[i, k] / M[k, k]) * M[k])
M = M / jnp.diag(M[:, :3])[:, None] # one division per row
print(M[:, :3]) # the identity, so the left half is done
print(4 * M[:, 3:]) # [[3. 2. 1.] [2. 4. 2.] [1. 2. 3.]]
print(jnp.allclose(M[:, 3:], jnp.linalg.inv(K), atol=1e-6)) # True
K8 = (jnp.diag(2 * jnp.ones(8)) - jnp.diag(jnp.ones(7), 1) - jnp.diag(jnp.ones(7), -1))
print(jnp.count_nonzero(K8)) # 22 sparse going in
print(jnp.sum(jnp.abs(jnp.linalg.inv(K8)) > 1e-9)) # 64 dense coming outimport numpy as np
import tensorflow as tf
K = tf.constant([[2., -1., 0.], [-1., 2., -1.], [0., -1., 2.]])
M = np.hstack([K.numpy(), np.eye(3)]) # loop in numpy: tf tensors do not mutate
for k in range(3): # down: clear below every pivot
for i in range(k + 1, 3):
M[i] -= (M[i, k] / M[k, k]) * M[k]
pivots = tf.constant(np.diag(M[:, :3]).copy())
print(pivots) # [2. 1.5 1.33333333]
print(tf.reduce_prod(pivots), tf.linalg.det(K)) # 4.0 4.0
for k in (2, 1): # up: clear above every pivot
for i in range(k):
M[i] -= (M[i, k] / M[k, k]) * M[k]
M /= np.diag(M[:, :3])[:, None] # one division per row
print(M[:, :3]) # the identity, so the left half is done
print(4 * M[:, 3:]) # [[3. 2. 1.] [2. 4. 2.] [1. 2. 3.]]
print(np.allclose(M[:, 3:], tf.linalg.inv(K).numpy())) # True
K8 = (np.diag(2 * np.ones(8)) - np.diag(np.ones(7), 1) - np.diag(np.ones(7), -1))
print(tf.math.count_nonzero(K8)) # 22 sparse going in
print(tf.math.count_nonzero(tf.abs(tf.linalg.inv(K8)) > 1e-9))
# 64 dense coming outimport numpy as np
from time import perf_counter
def best(f, reps=3):
out = float("inf")
for _ in range(reps):
t0 = perf_counter(); f(); out = min(out, perf_counter() - t0)
return out
n, rng = 3000, np.random.default_rng(0)
A = rng.standard_normal((n, n)) + np.sqrt(n) * np.eye(n)
b = rng.standard_normal(n)
print(best(lambda: np.linalg.inv(A) @ b)) # 0.20 seconds, on the machine that wrote this
print(best(lambda: np.linalg.solve(A, b))) # 0.067 seconds
n = 12 # a matrix chosen to be numerically awful
H = 1.0 / (np.arange(1, n + 1)[:, None] + np.arange(1, n + 1) - 1)
x_true = np.ones(n)
c = H @ x_true
print(np.abs(np.linalg.inv(H) @ c - x_true).max()) # 12.59375
print(np.abs(np.linalg.solve(H, c) - x_true).max()) # 0.22008972067760946Where this is going
Elimination gave you a way to solve one system. This chapter gave you the algebra to talk about what elimination did: a product of simple matrices sitting to the left of , regrouped into one matrix, and inverted by walking the chain backwards. Put those together and a question appears. If the expensive part of solving is making the staircase, and the staircase is a product, why not compute the product once, write it down, and reuse it for every that ever arrives?
That is a factorization, and the next chapter delivers two of them. is elimination’s receipt: is the staircase from Lecture 4 and holds the multipliers, unmixed, for the reason this chapter half revealed. goes the other way, building out of its independent columns, and it answers a question you have not been able to ask yet: how much genuinely different information is in a matrix at all?