Lecture 08
The Complete Solution & Rank
One anchor plus the whole null space. Whether b fits at all, and how a single number, the rank, tells you how many answers to expect before you compute one.
103 slides / MIT 18.06, Lecture 8 / Strang §3.3–3.5

The idea in one sentence
Find one solution of , add every vector in the null space to it, and you have found them all.
One anchor, and the whole null space
Lecture 7.5 ended with a shape and no formula. The solutions of form an affine set, a flat sheet pushed off the origin. That left one question open: what does the pushing?
The answer takes one line. Suppose is any single vector with . Call it the particular solution. Suppose is any vector in the null space, so . Then
So solves the system too. Every choice of gives a solution, and there are no others: if and both solve the system then , which puts in the null space. Write and you are back where you started.
One anchor, and a whole subspace of drift. Watch it in a case small enough to draw. Take
The second row is twice the first, so both equations say the same thing, . One solution is . The null space is the line through , since . The complete solution is for every real .
The dashed line is the null space, which passes through the origin. The blue line is the whole solution set, the same line pushed over so it passes through . Orange is the anchor, green is the drift you add to it. Land on the blue line once and you can slide along it forever.
Notice what changes when changes and what does not. The direction of the drift belongs to alone, so every solvable produces a line parallel to that dashed one. Only the anchor moves.
Three right-hand sides, three parallel lines. The dashed one runs through the origin and is itself, the case . Orange and blue are the other two, colored apart only so you can follow them. They are the same line slid sideways, and no choice of can ever tilt it.
x = xₚ + t xₙ = (0.6, 1.2)
Ax = (3.0, 6.0)
b = (3.0, 6.0)
‖Ax − b‖ = 0.00
Every t on the blue line solves the system.
Does b even fit?
The recipe assumed a particular solution exists. Often it does not, and elimination tells you which case you are in without any extra work. Put next to as one more column and reduce the whole thing:
Row 3 of is row 1 plus row 2. Whatever the third equation claims, it is the sum of the first two, so the only value of that can possibly work is . It is 7. Subtract rows 1 and 2 from row 3 of the augmented matrix and the last row goes completely flat, right side included.
The same matrix, two right-hand sides. Elimination flattens the third row either way, and what sits to the right of the divider decides everything. A zero there is the harmless statement . Anything else is a contradiction, which is what the pink row is.
That is the whole consistency test. Reduce , look at every row of the reduced matrix that is all zeros on the left, and check that its right-hand entry is zero too. If one of them is not, some combination of your equations says and no on earth will fix it.
When the test passes, the particular solution comes out almost for free. Set every free variable to zero. In the reduced matrix above the pivots sit in columns 1 and 3, so and are free. Setting both to zero leaves and , which is exactly the right-hand column:
Check it: the first equation gives , the second gives , and the third gives . Now add the null space. The free columns give two special solutions by the method of Lecture 7.5, and , so the complete solution is
An anchor in , plus a two-dimensional sheet of drift, for any and you like.
import numpy as np
A = np.array([[1., 3., 0., 2.],
[0., 0., 1., 4.],
[1., 3., 1., 6.]]) # row 3 = row 1 + row 2
b = np.array([1., 6., 7.]) # and 7 = 1 + 6, so it fits
M = np.hstack([A, b.reshape(-1, 1)]) # the augmented matrix
M[2] -= M[0] + M[1] # the only step elimination needs
print(M) # [[1. 3. 0. 2. 1.]
# [0. 0. 1. 4. 6.]
# [0. 0. 0. 0. 0.]] <- 0 = 0, consistent
xp = np.array([1., 0., 6., 0.]) # free variables set to zero
s1 = np.array([-3., 1., 0., 0.]) # switch on x2
s2 = np.array([-2., 0., -4., 1.]) # switch on x4
print(A @ xp, A @ s1, A @ s2) # [1. 6. 7.] [0. 0. 0.] [0. 0. 0.]
for c, d in [(0., 0.), (2., -1.), (-0.5, 3.)]:
print(A @ (xp + c * s1 + d * s2)) # [1. 6. 7.] every time
bad = np.array([1., 6., 8.]) # 8 is not 1 + 6
Mbad = np.hstack([A, bad.reshape(-1, 1)])
Mbad[2] -= Mbad[0] + Mbad[1]
print(Mbad[2]) # [0. 0. 0. 0. 1.] <- 0 = 1, no good
print(np.linalg.matrix_rank(A), np.linalg.matrix_rank(Mbad)) # 2 3import torch
A = torch.tensor([[1., 3., 0., 2.],
[0., 0., 1., 4.],
[1., 3., 1., 6.]]) # row 3 = row 1 + row 2
b = torch.tensor([1., 6., 7.]) # and 7 = 1 + 6, so it fits
M = torch.cat([A, b.reshape(-1, 1)], dim=1) # the augmented matrix
M[2] -= M[0] + M[1] # the only step elimination needs
print(M) # tensor([[1., 3., 0., 2., 1.],
# [0., 0., 1., 4., 6.],
# [0., 0., 0., 0., 0.]]) <- 0 = 0
xp = torch.tensor([1., 0., 6., 0.]) # free variables set to zero
s1 = torch.tensor([-3., 1., 0., 0.]) # switch on x2
s2 = torch.tensor([-2., 0., -4., 1.]) # switch on x4
print(A @ xp, A @ s1, A @ s2) # [1., 6., 7.] and two zero vectors
for c, d in [(0., 0.), (2., -1.), (-0.5, 3.)]:
print(A @ (xp + c * s1 + d * s2)) # tensor([1., 6., 7.]) every time
bad = torch.tensor([1., 6., 8.]) # 8 is not 1 + 6
Mbad = torch.cat([A, bad.reshape(-1, 1)], dim=1)
Mbad[2] -= Mbad[0] + Mbad[1]
print(Mbad[2]) # tensor([0., 0., 0., 0., 1.]) <- 0 = 1
print(torch.linalg.matrix_rank(A), torch.linalg.matrix_rank(Mbad)) # 2 3import jax.numpy as jnp
A = jnp.array([[1., 3., 0., 2.],
[0., 0., 1., 4.],
[1., 3., 1., 6.]]) # row 3 = row 1 + row 2
b = jnp.array([1., 6., 7.]) # and 7 = 1 + 6, so it fits
M = jnp.hstack([A, b.reshape(-1, 1)]) # the augmented matrix
M = M.at[2].add(-(M[0] + M[1])) # the only step elimination needs
print(M) # [[1. 3. 0. 2. 1.]
# [0. 0. 1. 4. 6.]
# [0. 0. 0. 0. 0.]] <- 0 = 0, consistent
xp = jnp.array([1., 0., 6., 0.]) # free variables set to zero
s1 = jnp.array([-3., 1., 0., 0.]) # switch on x2
s2 = jnp.array([-2., 0., -4., 1.]) # switch on x4
print(A @ xp, A @ s1, A @ s2) # [1. 6. 7.] [0. 0. 0.] [0. 0. 0.]
for c, d in [(0., 0.), (2., -1.), (-0.5, 3.)]:
print(A @ (xp + c * s1 + d * s2)) # [1. 6. 7.] every time
bad = jnp.array([1., 6., 8.]) # 8 is not 1 + 6
Mbad = jnp.hstack([A, bad.reshape(-1, 1)])
Mbad = Mbad.at[2].add(-(Mbad[0] + Mbad[1]))
print(Mbad[2]) # [0. 0. 0. 0. 1.] <- 0 = 1, no good
print(jnp.linalg.matrix_rank(A), jnp.linalg.matrix_rank(Mbad)) # 2 3import tensorflow as tf
A = tf.constant([[1., 3., 0., 2.],
[0., 0., 1., 4.],
[1., 3., 1., 6.]]) # row 3 = row 1 + row 2
b = tf.constant([1., 6., 7.]) # and 7 = 1 + 6, so it fits
M = tf.concat([A, tf.reshape(b, [-1, 1])], axis=1) # the augmented matrix
M = tf.tensor_scatter_nd_update(M, [[2]], [M[2] - M[0] - M[1]])
print(M) # [[1. 3. 0. 2. 1.]
# [0. 0. 1. 4. 6.]
# [0. 0. 0. 0. 0.]] <- 0 = 0, consistent
xp = tf.constant([1., 0., 6., 0.]) # free variables set to zero
s1 = tf.constant([-3., 1., 0., 0.]) # switch on x2
s2 = tf.constant([-2., 0., -4., 1.]) # switch on x4
print(A @ tf.reshape(xp, [-1, 1])) # [[1.] [6.] [7.]]
print(A @ tf.reshape(s1, [-1, 1])) # [[0.] [0.] [0.]]
print(A @ tf.reshape(s2, [-1, 1])) # [[0.] [0.] [0.]]
for c, d in [(0., 0.), (2., -1.), (-0.5, 3.)]:
x = xp + c * s1 + d * s2
print(tf.squeeze(A @ tf.reshape(x, [-1, 1]))) # [1. 6. 7.] every time
bad = tf.constant([1., 6., 8.]) # 8 is not 1 + 6
Mbad = tf.concat([A, tf.reshape(bad, [-1, 1])], axis=1)
print(Mbad[2] - Mbad[0] - Mbad[1]) # [0. 0. 0. 0. 1.] <- 0 = 1, no goodRank
Everything above turned on two counts: how many pivots the matrix has, and how many columns are left over. The first count has a name.
The rank of a matrix is the number of pivots elimination produces. Equivalently, it is the number of columns that carry information no earlier column already carried, which makes it the dimension of the column space . The rows tell the same story, so is also the dimension of the row space . That last claim is the one surprise in this chapter, and Lecture 9 proves it properly.
Two consequences you already used. The null space has dimension , one direction per free column. And a system has a solution exactly when adding as an extra column leaves the rank alone, since a column that raises the rank is a column the old ones could not reach. So is solvable precisely when
Rank is bounded by both dimensions, so and . Those two comparisons, each either tight or slack, sort every matrix into four cases.
Four shapes, four answers
Two questions, four answers. Cyan marks a free column, which gives the answer set room to move. Pink marks a zero row, which is where can fail. Case 1 has neither and case 4 has both, and you can read every verdict off which colors are present.
Case 1, . Square with a pivot in every row and column. No free columns means and no drift, and no zero rows means always fits. Exactly one solution, for every . Take
Eliminate and back-substitute to get , and check: and . There is nothing to add, because the null space holds only the zero vector. This is the invertible case from Lecture 5, seen from the rank side.
Case 2, . Short and wide with a pivot in every row, so elimination never produces a zero row and always fits. But free columns remain, so the answer is never unique. Infinitely many solutions, for every . Take
Subtract twice row 2 from row 1 to reach and . Column 3 is free, so and . Check the special solution: and . The complete solution is .
Case 3, . Tall and thin with a pivot in every column. No free columns means at most one solution, and the leftover rows mean has to cooperate. No solution or exactly one. Take
Its column space is the plane of all , which is the set of vectors satisfying . So works, with the unique answer , and does not, because . One extra unit in the third slot and the target lifts clean off the plane.
Two columns in span a plane, never all of space. The green target sits on the plane and has exactly one recipe, since the columns are independent. The orange one sits above it and has none, no matter how you mix.
Case 4, and . Neither full. Free columns bring back the drift, leftover rows bring back the consistency test, and you get the worst of both. No solution, or infinitely many. This case is usually left symbolic. Here it is with real numbers:
Row 2 is twice row 1, so while . With the second equation is twice the first, which is consistent, and subtracting the first equation from the third gives , leaving . One answer is , the null space is the line through , and the complete solution is . Check the anchor: , then , then . With instead, the second equation demands 3 where the first forces 2. Nothing works.
The measurements table
Here is a table you will meet again. Five people, two measurements each, and one target column:
| weight | height | target |
|---|---|---|
| 5 | 2 | 3 |
| 3 | 4 | 8 |
| 2 | 8 | 7 |
| 1 | 5 | 9 |
| 4 | 7 | 2 |
The question is the one from Lecture 1, asked with real data. Is there a pair of numbers with equal to the target column? That is with the 5 by 2 matrix of measurements, so and . The two columns are independent, so and this is case 3: tall and thin, no solution or exactly one.
It is the no-solution branch. The rank of is 2 and the rank of with the target attached is 3, so the target column adds something the measurements never had. In geometry: is a two-dimensional plane sitting inside , and the target sits off it.
Two columns of five numbers each span the green plane inside . The orange target is a vector in that does not lie on that plane, so has no solution and the dashed gap can never be closed to zero.
That is the normal situation for every real dataset. Five equations and two unknowns almost never agree. Lecture 11 measures the gap, and Lecture 12 makes it as small as possible, which is what fitting a model means. Until then it is enough to know that the answer is honestly “none”, and that “none” has a size.
import numpy as np
square = (np.array([[1., 2.], [3., 8.]]), np.array([5., 17.]))
wide = (np.array([[1., 2., 3.], [0., 1., 4.]]), np.array([6., 5.]))
tall = (np.array([[1., 0.], [0., 1.], [1., 1.]]), np.array([2., 3., 5.]))
flat = (np.array([[1., 2., 3.], [2., 4., 6.], [1., 2., 4.]]),
np.array([1., 2., 3.]))
table = (np.array([[5., 2.], [3., 4.], [2., 8.], [1., 5.], [4., 7.]]),
np.array([3., 8., 7., 9., 2.]))
cases = {"square, invertible": square, "short and wide": wide,
"tall and thin": tall, "not full rank": flat,
"measurements table": table}
for name, (A, b) in cases.items():
m, n = A.shape
r = np.linalg.matrix_rank(A)
fits = np.linalg.matrix_rank(np.hstack([A, b.reshape(-1, 1)])) == r
how = "none" if not fits else ("one" if r == n else "infinitely many")
print(f"{name:20s} m={m} n={n} r={r} fits:{str(fits):6s}{how}")
# square, invertible m=2 n=2 r=2 fits:True one
# short and wide m=2 n=3 r=2 fits:True infinitely many
# tall and thin m=3 n=2 r=2 fits:True one
# not full rank m=3 n=3 r=2 fits:True infinitely many
# measurements table m=5 n=2 r=2 fits:False none
A, b = table
best = np.linalg.pinv(A) @ b # the closest attempt, Lecture 12's answer
print(A @ best - b)
# [-0.84187449 -4.06166073 0.66072897 -4.22554124 4.82460948]
print(np.linalg.norm(A @ best - b)) # 7.666450205657343import torch
square = (torch.tensor([[1., 2.], [3., 8.]]), torch.tensor([5., 17.]))
wide = (torch.tensor([[1., 2., 3.], [0., 1., 4.]]), torch.tensor([6., 5.]))
tall = (torch.tensor([[1., 0.], [0., 1.], [1., 1.]]),
torch.tensor([2., 3., 5.]))
flat = (torch.tensor([[1., 2., 3.], [2., 4., 6.], [1., 2., 4.]]),
torch.tensor([1., 2., 3.]))
table = (torch.tensor([[5., 2.], [3., 4.], [2., 8.], [1., 5.], [4., 7.]]),
torch.tensor([3., 8., 7., 9., 2.]))
cases = {"square, invertible": square, "short and wide": wide,
"tall and thin": tall, "not full rank": flat,
"measurements table": table}
for name, (A, b) in cases.items():
m, n = A.shape
r = int(torch.linalg.matrix_rank(A))
aug = torch.cat([A, b.reshape(-1, 1)], dim=1)
fits = int(torch.linalg.matrix_rank(aug)) == r
how = "none" if not fits else ("one" if r == n else "infinitely many")
print(f"{name:20s} m={m} n={n} r={r} fits:{str(fits):6s}{how}")
# square, invertible m=2 n=2 r=2 fits:True one
# short and wide m=2 n=3 r=2 fits:True infinitely many
# tall and thin m=3 n=2 r=2 fits:True one
# not full rank m=3 n=3 r=2 fits:True infinitely many
# measurements table m=5 n=2 r=2 fits:False none
A, b = table
best = torch.linalg.pinv(A) @ b # the closest attempt, Lecture 12's answer
print(A @ best - b)
# tensor([-0.8419, -4.0617, 0.6607, -4.2255, 4.8246])
print(torch.linalg.norm(A @ best - b)) # tensor(7.6665)import jax.numpy as jnp
square = (jnp.array([[1., 2.], [3., 8.]]), jnp.array([5., 17.]))
wide = (jnp.array([[1., 2., 3.], [0., 1., 4.]]), jnp.array([6., 5.]))
tall = (jnp.array([[1., 0.], [0., 1.], [1., 1.]]), jnp.array([2., 3., 5.]))
flat = (jnp.array([[1., 2., 3.], [2., 4., 6.], [1., 2., 4.]]),
jnp.array([1., 2., 3.]))
table = (jnp.array([[5., 2.], [3., 4.], [2., 8.], [1., 5.], [4., 7.]]),
jnp.array([3., 8., 7., 9., 2.]))
cases = {"square, invertible": square, "short and wide": wide,
"tall and thin": tall, "not full rank": flat,
"measurements table": table}
for name, (A, b) in cases.items():
m, n = A.shape
r = int(jnp.linalg.matrix_rank(A))
aug = jnp.hstack([A, b.reshape(-1, 1)])
fits = int(jnp.linalg.matrix_rank(aug)) == r
how = "none" if not fits else ("one" if r == n else "infinitely many")
print(f"{name:20s} m={m} n={n} r={r} fits:{str(fits):6s}{how}")
# square, invertible m=2 n=2 r=2 fits:True one
# short and wide m=2 n=3 r=2 fits:True infinitely many
# tall and thin m=3 n=2 r=2 fits:True one
# not full rank m=3 n=3 r=2 fits:True infinitely many
# measurements table m=5 n=2 r=2 fits:False none
A, b = table
best = jnp.linalg.pinv(A) @ b # the closest attempt, Lecture 12's answer
print(A @ best - b)
# [-0.8418745 -4.0616603 0.660729 -4.225541 4.8246098]
print(jnp.linalg.norm(A @ best - b)) # 7.66645import tensorflow as tf
square = (tf.constant([[1., 2.], [3., 8.]]), tf.constant([[5.], [17.]]))
wide = (tf.constant([[1., 2., 3.], [0., 1., 4.]]), tf.constant([[6.], [5.]]))
tall = (tf.constant([[1., 0.], [0., 1.], [1., 1.]]),
tf.constant([[2.], [3.], [5.]]))
flat = (tf.constant([[1., 2., 3.], [2., 4., 6.], [1., 2., 4.]]),
tf.constant([[1.], [2.], [3.]]))
table = (tf.constant([[5., 2.], [3., 4.], [2., 8.], [1., 5.], [4., 7.]]),
tf.constant([[3.], [8.], [7.], [9.], [2.]]))
cases = {"square, invertible": square, "short and wide": wide,
"tall and thin": tall, "not full rank": flat,
"measurements table": table}
def rank(M): # tf has no matrix_rank: count the nonzero singular values
s = tf.linalg.svd(M, compute_uv=False)
return int(tf.reduce_sum(tf.cast(s > 1e-8 * s[0], tf.int32)))
for name, (A, b) in cases.items():
m, n = A.shape
r = rank(A)
fits = rank(tf.concat([A, b], axis=1)) == r
how = "none" if not fits else ("one" if r == n else "infinitely many")
print(f"{name:20s} m={m} n={n} r={r} fits:{str(fits):6s}{how}")
# square, invertible m=2 n=2 r=2 fits:True one
# short and wide m=2 n=3 r=2 fits:True infinitely many
# tall and thin m=3 n=2 r=2 fits:True one
# not full rank m=3 n=3 r=2 fits:True infinitely many
# measurements table m=5 n=2 r=2 fits:False none
A, b = table
best = tf.linalg.pinv(A) @ b # the closest attempt, Lecture 12's answer
print(tf.squeeze(A @ best - b))
# [-0.8418745 -4.0616603 0.660729 -4.225541 4.8246098]
print(tf.norm(A @ best - b)) # 7.66645Basis, independence, dimension
Three words have been doing quiet work throughout, and they deserve one paragraph each.
Vectors are independent when the only combination of them equal to zero is the one with all coefficients zero. Stack them as the columns of a matrix and that sentence becomes a null space statement: the columns are independent exactly when . Free columns are the dependent ones, and each free column hands you a nonzero vector in the null space that proves the dependence.
A set spans a space when every vector in that space is some combination of the set. A basis is a set that does both jobs, independent and spanning. Independence keeps it from being wasteful and spanning keeps it from being short, and together they buy you something worth having: in terms of a basis, every vector in the space has exactly one recipe. Two different recipes for the same vector would subtract to a nonzero combination equal to zero.
The two orange vectors are the basis, the blue steps are the walk, and green is where it lands: from two steps of and one of . Independence is what makes the walk unique. Throw in a third vector like and the same suddenly has many walks, which is exactly what dependence costs you.
Every basis of a given space has the same number of vectors, and that number is the dimension. For the pivot columns of form a basis, so the dimension is . For the special solutions form a basis, so the dimension is . The space has dimension 0 and its basis is the empty set, which is the only sensible answer: the zero vector can never belong to a basis, since times it is already a nonzero combination equal to zero.
Where this is going
Count the dimensions on both sides of . On the input side holds the null space with dimensions and the row space with . On the output side holds the column space with , and one more space: the vectors with . That one is the left null space , and it has dimensions. Four spaces, two on each side, and the counts add up exactly.
A thumbnail of the four fundamental subspaces. The four colors are fixed for the whole course: orange row space, cyan null space, green column space, purple left null space. Lecture 9 finds a basis for each one and explains why both counts are the same number. It also draws the version of this picture that everything later refers back to.
Two of those four spaces you have already met and can compute. The other two arrive next, along with the reason the two counts match. After that comes the question this chapter left open: when misses the column space, as it did for the measurements table, what is the closest thing to an answer?