Lecture 04
Elimination
The algorithm inside every solver. Turn a system into a staircase, read the answer from the bottom up, and discover that every step you took was a matrix.
108 slides / MIT 18.06, Lectures 2 and 3 / Strang §2.2–2.3, §2.7

The idea in one sentence
Elimination turns a system of equations into a staircase you can read from the bottom up, and every step of it is a matrix you can write down.
Two lines, one crossing point
Every numerical library has a function called solve. Underneath it, on matrices with millions
of entries, the same procedure runs that you would use by hand on two equations. So start with
two equations.
Each equation is a line in the plane. A solution is a point sitting on both lines, so solving means finding where they cross. You could plot them and squint. Elimination does something better. It moves one of the lines without moving the crossing point.
Subtract 3 times the first equation from the second:
The is gone. The new second equation says on its own, and the first equation then hands you .
Why is the crossing point safe? A point that satisfies both original equations satisfies any combination of them, so it survives the subtraction. And nothing new sneaks in, because adding 3 times the first equation back recovers the original pair. The two systems have exactly the same solutions. What changed is the picture.
Orange is the first equation and green is the second, in this figure and the next. Elimination flattens the green line and leaves the blue crossing point exactly where it was. A flat line is a solved variable: you can read off it without doing any more work.
The number 3 has a name. It is the multiplier, the count of copies you subtract. It came from dividing the entry you wanted to kill by the entry above it, . That entry above, the leading 1 of the first equation, is the pivot. Every step of elimination divides by a pivot to get a multiplier, then subtracts. Nothing else ever happens. The one thing that can go wrong is a pivot that comes out zero, and that is where the whole story lives.
Three endings
Two lines in a plane can do three things, and a square system of any size can do the same three. They cross once. They never meet. They lie on top of each other.
The three endings of a square system, with the same orange and green equations throughout and the solutions in blue. Elimination reaches each ending without any drawing: the last row tells you which one you are in.
Elimination finds out which case you are in, in any number of dimensions, with no picture at all. Take the parallel pair and . Subtract 3 times the first from the second and you get . Nothing satisfies that, so there is no solution. Now take and . The same subtraction gives , a true statement carrying no information. One equation was doing the work of two, and every point on the single line solves the system.
The warning sign is the same in both failures: a pivot position that comes out zero and cannot be repaired. That is elimination telling you the matrix is singular, the word Lecture 2 attached to the ring road machine that could not be undone. The right-hand side decides which of the two failures you get.
The staircase
Real systems have more than two unknowns, and the picture stops helping. Here is the example this chapter and Lecture 6 both run on:
Work one column at a time, top down. The first pivot is the 2 in the corner. Below it sit 4 and , so the multipliers are and :
Column 1 is clear. Now ignore row 1 forever and repeat on what is left. The second pivot is the 1 in the middle, the entry below it is also 1, so the multiplier is 1 and row 3 loses a copy of row 2:
The right-hand side rode along: , then , then . The letter is for upper triangular. Its diagonal holds the pivots 2, 1 and 4. None of them is zero, so this system has exactly one solution and elimination already knows it.
Reading the answer from the bottom
takes almost no work, because the last equation has only one unknown left in it.
The staircase, with the pivots in yellow. Each row has one more zero than the row above it, so the bottom row solves itself and every row above it inherits an answer. Read upward.
So . Check it against the original equations: , and , and .
The two halves of the work cost very different amounts. Elimination on an by matrix takes about multiply-and-subtract operations; back substitution takes about . At that is roughly 300 million against 500 thousand. Nearly all the money goes into making the staircase. That asymmetry is the reason Lecture 6 saves the staircase as a factorization instead of rebuilding it for every new right-hand side.
Where the cost comes from. Yellow is the work still to do. Once a column is cleared, elimination never looks at it again, so the yellow square shrinks by one on each side every step. Adding up those squares is what makes the total about .
One object instead of two
Notice that never influenced a decision. The multipliers came from alone; just followed along getting the same treatment. So stop writing them apart. Glue on as a fourth column and call the result the augmented matrix:
One matrix, one pass, and the vertical bar is only there to remind you which column is not a coefficient. Every hand computation from here on runs on the augmented matrix.
One object, one pass. The yellow column is , riding along and taking the same treatment as the coefficients. Nothing about the step changed; only the bookkeeping did.
Every step is a matrix
Here is the move that turns a hand procedure into algebra.
Start from the identity matrix , the machine that leaves every vector alone. Its rows are the purest possible recipes: row 2 of is , which says “take one copy of row 2 and nothing else”. Change one entry of one row and the recipe changes with it. Put in position :
Row 2 of now reads : minus two copies of row 1, plus one copy of row 2. That is the first elimination step, written as a matrix.
An elimination matrix is the identity with one entry changed. That entry, in yellow, is the negative of the multiplier, and it sits in the position it is about to zero out.
Multiply and check. Row of a product is row of applied to the rows of , so keeps rows 1 and 3 of and replaces row 2 by row 2 minus twice row 1. That is exactly what your pencil did. The same acting on does the same bookkeeping to the right-hand side, which is why the augmented matrix works at all.
Two more entries finish the job. carries in position , because the multiplier there was . carries in position :
Three matrices, three zeros, one staircase. Each blue arrow is a single left multiplication, and the yellow cell marks the entry it has just wiped out.
Because matrix multiplication can be regrouped, that long product collapses into a single matrix with . Multiply it out and something odd shows up:
The multipliers were 2, then , then 1. In the bottom-left entry is 3, which is not any multiplier: the steps interfered with each other. In the three multipliers sit in their own slots, unmixed. That is not luck, and cashing it in is the whole of in Lecture 6.
When the pivot is zero
Elimination as described breaks the moment a pivot position holds a zero, because the multiplier would ask you to divide by it. Sometimes that is fatal. Often it is not. Take
Clear column 1 the usual way. Row 2 minus twice row 1, row 3 minus row 1:
The second pivot position holds a 0, and no multiple of row 2 will ever change that, since row 2 starts with two zeros of its own. But look one row down. There is a perfectly good waiting in that column. The rows of a system are in no particular order, so exchange them.
A zero in the pivot position is not always fatal. Pink marks the dead pivot, yellow the working one that replaces it. If a nonzero sits below in the same column, trade the two rows and carry on.
The exchange is a matrix too. Take the identity and swap its rows 2 and 3:
Row 1 of still says “take row 1”. Row 2 now says “take row 3”, and row 3 says “take row 2”. So is with those two rows traded, for any with three rows. A permutation matrix in general is the identity with its rows written in some other order. There are of them at size , and they cost nothing to apply. Undoing a single swap is doing it again, so .
After the exchange the staircase finishes on its own, with pivots 1, and 1:
Try it
Press the button and watch a zero appear. The picture on the left is the matrix responsible for that step. The state on the right is computed live, by actually multiplying the two. The second preset stalls on a dead pivot and needs the exchange.
step 0 of 3 pivots so far: none
Yellow on the left is the one entry doing the work. Yellow on the right is what it changed. Watch the second preset stall on a zero pivot.
Elimination in code
The first block builds the elimination matrices out of the identity and checks the chain. The frameworks disagree about one thing worth knowing: NumPy and PyTorch let you assign into an array, while JAX and TensorFlow refuse and hand you a modified copy instead.
import numpy as np
A = np.array([[2., 4., -2.], [4., 9., -3.], [-2., -3., 7.]])
b = np.array([2., 8., 10.])
E21 = np.eye(3); E21[1, 0] = -2.0 # row 2 minus 2 times row 1
E31 = np.eye(3); E31[2, 0] = 1.0 # row 3 plus row 1
E32 = np.eye(3); E32[2, 1] = -1.0 # row 3 minus row 2
print(E21 @ A) # [[2 4 -2] [0 1 1] [-2 -3 7]] one zero
print(E31 @ E21 @ A) # [[2 4 -2] [0 1 1] [ 0 1 5]] column 1 clear
print(E32 @ E31 @ E21 @ A) # [[2 4 -2] [0 1 1] [ 0 0 4]] the staircase U
print(E32 @ E31 @ E21 @ b) # [2. 4. 8.] and c
E = E32 @ E31 @ E21
print(E) # [[1 0 0] [-2 1 0] [ 3 -1 1]] the 3 is a mix
print(np.linalg.inv(E)) # [[1 0 0] [ 2 1 0] [-1 1 1]] multipliers, unmixedimport torch
A = torch.tensor([[2., 4., -2.], [4., 9., -3.], [-2., -3., 7.]])
b = torch.tensor([2., 8., 10.])
E21 = torch.eye(3); E21[1, 0] = -2.0 # row 2 minus 2 times row 1
E31 = torch.eye(3); E31[2, 0] = 1.0 # row 3 plus row 1
E32 = torch.eye(3); E32[2, 1] = -1.0 # row 3 minus row 2
print(E21 @ A) # [[2 4 -2] [0 1 1] [-2 -3 7]] one zero
print(E31 @ E21 @ A) # [[2 4 -2] [0 1 1] [ 0 1 5]] column 1 clear
print(E32 @ E31 @ E21 @ A) # [[2 4 -2] [0 1 1] [ 0 0 4]] the staircase U
print(E32 @ E31 @ E21 @ b) # tensor([2., 4., 8.]) and c
E = E32 @ E31 @ E21
print(E) # [[1 0 0] [-2 1 0] [ 3 -1 1]] the 3 is a mix
print(torch.linalg.inv(E)) # [[1 0 0] [ 2 1 0] [-1 1 1]] multipliers, unmixedimport jax
jax.config.update("jax_enable_x64", True) # jax is single precision unless you ask
import jax.numpy as jnp
A = jnp.array([[2., 4., -2.], [4., 9., -3.], [-2., -3., 7.]])
b = jnp.array([2., 8., 10.])
E21 = jnp.eye(3).at[1, 0].set(-2.0) # jax arrays never change in place:
E31 = jnp.eye(3).at[2, 0].set(1.0) # .at[...].set(...) returns a new one
E32 = jnp.eye(3).at[2, 1].set(-1.0)
print(E21 @ A) # [[2 4 -2] [0 1 1] [-2 -3 7]] one zero
print(E31 @ E21 @ A) # [[2 4 -2] [0 1 1] [ 0 1 5]] column 1 clear
print(E32 @ E31 @ E21 @ A) # [[2 4 -2] [0 1 1] [ 0 0 4]] the staircase U
print(E32 @ E31 @ E21 @ b) # [2. 4. 8.] and c
E = E32 @ E31 @ E21
print(E) # [[1 0 0] [-2 1 0] [ 3 -1 1]] the 3 is a mix
print(jnp.linalg.inv(E)) # [[1 0 0] [ 2 1 0] [-1 1 1]] multipliers, unmixedimport tensorflow as tf
A = tf.constant([[2., 4., -2.], [4., 9., -3.], [-2., -3., 7.]])
b = tf.constant([[2.], [8.], [10.]])
def edit(i, j, v): # tensorflow tensors are immutable too
return tf.tensor_scatter_nd_update(tf.eye(3), [[i, j]], [v])
E21, E31, E32 = edit(1, 0, -2.0), edit(2, 0, 1.0), edit(2, 1, -1.0)
print(E21 @ A) # [[2 4 -2] [0 1 1] [-2 -3 7]] one zero
print(E31 @ E21 @ A) # [[2 4 -2] [0 1 1] [ 0 1 5]] column 1 clear
print(E32 @ E31 @ E21 @ A) # [[2 4 -2] [0 1 1] [ 0 0 4]] the staircase U
print(E32 @ E31 @ E21 @ b) # [[2.] [4.] [8.]] and c
E = E32 @ E31 @ E21
print(E) # [[1 0 0] [-2 1 0] [ 3 -1 1]] the 3 is a mix
print(tf.linalg.inv(E)) # [[1 0 0] [ 2 1 0] [-1 1 1]] multipliers, unmixedThe second block writes the algorithm out in full, refuses to divide by a zero pivot, and then compares itself against the library. Same numbers in every framework, since the arithmetic is identical.
import numpy as np
def eliminate(M, rhs):
"""Forward elimination, no row exchanges. Returns U, c and the pivots."""
M, rhs, n = M.copy(), rhs.copy(), len(rhs)
for k in range(n):
if M[k, k] == 0.0:
raise ZeroDivisionError(f"zero pivot in column {k + 1}")
for i in range(k + 1, n):
m = M[i, k] / M[k, k]
M[i, k:] -= m * M[k, k:]
rhs[i] -= m * rhs[k]
return M, rhs, np.diag(M).copy()
def back_substitute(U, c):
x = np.zeros(len(c))
for i in range(len(c) - 1, -1, -1):
x[i] = (c[i] - U[i, i + 1:] @ x[i + 1:]) / U[i, i]
return x
A = np.array([[2., 4., -2.], [4., 9., -3.], [-2., -3., 7.]])
b = np.array([2., 8., 10.])
U, c, pivots = eliminate(A, b)
print(pivots) # [2. 1. 4.]
print(back_substitute(U, c)) # [-1. 2. 2.]
print(np.linalg.solve(A, b)) # [-1. 2. 2.] the library agrees
print(np.prod(pivots)) # 8.0 the pivots multiply to det A
B = np.array([[1., 2., 1.], [2., 4., 3.], [1., 1., 2.]])
d = np.array([4., 9., 4.])
try:
eliminate(B, d)
except ZeroDivisionError as e:
print(e) # zero pivot in column 2
print(np.linalg.solve(B, d)) # [1. 1. 1.] solve exchanged rows and finishedimport torch
def eliminate(M, rhs):
"""Forward elimination, no row exchanges. Returns U, c and the pivots."""
M, rhs, n = M.clone(), rhs.clone(), len(rhs)
for k in range(n):
if M[k, k] == 0.0:
raise ZeroDivisionError(f"zero pivot in column {k + 1}")
for i in range(k + 1, n):
m = M[i, k] / M[k, k]
M[i, k:] -= m * M[k, k:]
rhs[i] -= m * rhs[k]
return M, rhs, torch.diag(M).clone()
def back_substitute(U, c):
x = torch.zeros(len(c))
for i in range(len(c) - 1, -1, -1):
x[i] = (c[i] - U[i, i + 1:] @ x[i + 1:]) / U[i, i]
return x
A = torch.tensor([[2., 4., -2.], [4., 9., -3.], [-2., -3., 7.]])
b = torch.tensor([2., 8., 10.])
U, c, pivots = eliminate(A, b)
print(pivots) # tensor([2., 1., 4.])
print(back_substitute(U, c)) # tensor([-1., 2., 2.])
print(torch.linalg.solve(A, b)) # tensor([-1., 2., 2.]) the library agrees
print(pivots.prod()) # tensor(8.) the pivots multiply to det A
B = torch.tensor([[1., 2., 1.], [2., 4., 3.], [1., 1., 2.]])
d = torch.tensor([4., 9., 4.])
try:
eliminate(B, d)
except ZeroDivisionError as e:
print(e) # zero pivot in column 2
print(torch.linalg.solve(B, d)) # tensor([1., 1., 1.]) rows were exchangedimport jax
jax.config.update("jax_enable_x64", True) # jax is single precision unless you ask
import jax.numpy as jnp
def eliminate(M, rhs):
"""Forward elimination, no row exchanges. Returns U, c and the pivots."""
n = len(rhs)
for k in range(n):
if M[k, k] == 0.0:
raise ZeroDivisionError(f"zero pivot in column {k + 1}")
for i in range(k + 1, n):
m = M[i, k] / M[k, k]
M = M.at[i, k:].add(-m * M[k, k:])
rhs = rhs.at[i].add(-m * rhs[k])
return M, rhs, jnp.diag(M)
def back_substitute(U, c):
x = jnp.zeros(len(c))
for i in range(len(c) - 1, -1, -1):
x = x.at[i].set((c[i] - U[i, i + 1:] @ x[i + 1:]) / U[i, i])
return x
A = jnp.array([[2., 4., -2.], [4., 9., -3.], [-2., -3., 7.]])
b = jnp.array([2., 8., 10.])
U, c, pivots = eliminate(A, b)
print(pivots) # [2. 1. 4.]
print(back_substitute(U, c)) # [-1. 2. 2.]
print(jnp.linalg.solve(A, b)) # [-1. 2. 2.] the library agrees
print(jnp.prod(pivots)) # 8.0 the pivots multiply to det A
B = jnp.array([[1., 2., 1.], [2., 4., 3.], [1., 1., 2.]])
d = jnp.array([4., 9., 4.])
try:
eliminate(B, d)
except ZeroDivisionError as e:
print(e) # zero pivot in column 2
print(jnp.linalg.solve(B, d)) # [1. 1. 1.] rows were exchangedimport numpy as np
import tensorflow as tf
def eliminate(M, rhs):
"""Forward elimination, no row exchanges. Returns U, c and the pivots."""
M, rhs, n = np.array(M), np.array(rhs), len(rhs) # loop in numpy, then hand back
for k in range(n):
if M[k, k] == 0.0:
raise ZeroDivisionError(f"zero pivot in column {k + 1}")
for i in range(k + 1, n):
m = M[i, k] / M[k, k]
M[i, k:] -= m * M[k, k:]
rhs[i] -= m * rhs[k]
return tf.constant(M), tf.constant(rhs), tf.constant(np.diag(M).copy())
A = tf.constant([[2., 4., -2.], [4., 9., -3.], [-2., -3., 7.]])
b = tf.constant([[2.], [8.], [10.]])
U, c, pivots = eliminate(A, tf.reshape(b, [-1]))
print(pivots) # [2. 1. 4.]
print(tf.linalg.triangular_solve(U, tf.reshape(c, [-1, 1]), lower=False))
# [[-1.] [2.] [2.]] back substitution, built in
print(tf.linalg.solve(A, b)) # [[-1.] [2.] [2.]] the library agrees
print(tf.reduce_prod(pivots)) # 8.0 the pivots multiply to det A
B = tf.constant([[1., 2., 1.], [2., 4., 3.], [1., 1., 2.]])
d = tf.constant([[4.], [9.], [4.]])
try:
eliminate(B, tf.reshape(d, [-1]))
except ZeroDivisionError as e:
print(e) # zero pivot in column 2
print(tf.linalg.solve(B, d)) # [[1.] [1.] [1.]] rows were exchangedOne more thing the library does that the hand version does not. A tiny pivot is almost as dangerous as a zero one, because dividing by it inflates every rounding error in the row. So real solvers exchange rows at every column, moving the largest available entry into the pivot position. It is called partial pivoting, and here is why it exists:
eps = 1e-16
T = np.array([[eps, 1.], [1., 1.]])
t = np.array([1., 2.]) # the honest answer is x = 1, y = 1
U, c, _ = eliminate(T, t)
print(back_substitute(U, c)) # [2.22044605 1. ] the first coordinate should be 1
print(np.linalg.solve(T, t)) # [1. 1.] solve traded the rows firstNothing about that matrix is close to singular. Its determinant is , about : for a matrix whose entries are all around size one, a determinant of size one is as healthy as they come. The only mistake was the order of the rows.
Where this is going
You now have the algorithm that sits under every call to solve, and one surprising fact about
it: the whole procedure is a product of matrices standing to the left of . That fact was used
here on trust. Row of mixes the rows of , and a long product regroups without
changing anything. Both are claims about what means, and there are four different useful
answers to that question. The next chapter computes all four on the same small pair of matrices, and then uses
them to say when a matrix can be undone at all.