Lecture 10
Orthogonality of the Four Subspaces
Row space and null space meet at a right angle, and so does the other pair. Nothing perpendicular is missing, every reachable b comes from exactly one x, and that is where the pseudoinverse comes from.
61 slides / MIT 18.06, Lecture 14 / Strang §4.1

The idea in one sentence
The four subspaces come in two perpendicular pairs, and each pair covers its whole space with nothing left over, which is why every reachable comes from one and only one .
The matrix and its four bases
Everything in this chapter happens inside one matrix, the same one Lecture 9 took apart. Its four bases are already in hand:
In : the row space has basis and , and the null space has basis and .
In : the column space has basis and , and the left null space has basis .
Four sets of vectors, four dimensions that add to 4 and to 3. What the last chapter did not say is how they sit relative to each other. That is one line of algebra away.
The proof is one line
Take any in the null space. The statement looks like a fact about a matrix. Read it by rows and it is a fact about angles. Each entry of is a row of dotted with , so says every one of those dot products is zero:
A zero dot product means a right angle. So is perpendicular to every row of . It is then perpendicular to every combination of the rows, because dotting distributes over sums and scalar multiples. The combinations of the rows are the row space. Done:
Every entry of is a row dotted with . Orange is the row space, cyan the null space. The single equation is three statements about right angles at once, and any combination of the rows inherits all three.
Now turn the whole argument around by writing where was. Take any in the left null space. Then says every row of , which is to say every column of , is perpendicular to . So is perpendicular to every combination of the columns:
Check it on the numbers. The left null space vector is , and the two column space basis vectors give and . Both zero, both right angles.
Two subspaces are called orthogonal when every vector in one is perpendicular to every vector in the other. That is a strong condition, and it has an immediate consequence. If some vector belonged to both, it would have to be perpendicular to itself, so , so . Orthogonal subspaces meet at the origin and nowhere else.
The right angle you can draw
Abstract right angles are easy to nod at and hard to feel. Here is one with numbers. Take and split it into the row space piece and the null space piece from Lecture 9:
Their dot product is . So the two pieces of sit at a right angle, and the three vectors , and form a right triangle inside . That triangle lives in an ordinary flat plane, so you can draw it exactly, and Pythagoras has to hold:
The split of a vector into its row space part and its null space part, drawn to scale. Blue is , green is the part that survives , orange the part that dies. The legs are perpendicular because the row space is perpendicular to the null space, so the triangle obeys Pythagoras and the numbers close exactly.
Move around and the triangle changes shape, but the corner at the origin stays square. Try it.
xᵣ = (0.60, 0.60, 1.20, 1.20)
xₙ = (0.40, 0.40, −0.20, −0.20)
xᵣ dot xₙ = 0.00
Ax = (9, 33, 42)
Axᵣ = (9, 33, 42)
The drawing rescales to fit, so watch the shape and not the size. The corner at the origin never opens, and the last two lines never disagree.
Nothing perpendicular is missing
Perpendicular is less than it sounds. In the axis and the axis are perpendicular, yet together they reach only a plane. Everything in the direction is missing. Two subspaces can meet at right angles and still leave most of the space uncovered.
Orthogonal is not the same as complementary. Orange marks the subspaces being added up, blue the direction that finishes the job. Both pictures show subspaces meeting at a right angle, and only the right one covers . The four subspaces always behave like the right picture, and the dimension count is what proves it.
The four subspaces do better than that. Nothing is left over, and the proof runs both ways in two short steps.
If is in then , so is perpendicular to every row, so is perpendicular to the row space. That is the direction already proved. Now the other one. Suppose is perpendicular to every row of . Then every entry of , being a row dotted with , is zero, so and is in .
Put those together and the null space holds every vector perpendicular to the row space, and nothing else. The name for that is the orthogonal complement, and the dimensions are the receipt:
For our matrix, in and in . If the null space had come out one dimension short, some direction perpendicular to the row space would have had nowhere to live, and the count would have caught it.
The side is small enough to draw honestly. The column space is a plane through the origin. The left null space is the single line through . The plane and the line are perpendicular, and a plane plus its normal line is all of .
The output side of the picture, drawn in full. Every direction inside the green plane is a reachable . The one direction perpendicular to it is the left null space. A plane and its normal line fill between them, and says so in numbers.
Every reachable b comes from exactly one x
Here is the payoff.
Take any in the column space. Since is a combination of columns, there is some with . Split it: , and , so as well. So comes from at least one vector in the row space.
Could it come from two? Suppose and both sit in the row space and both satisfy . Then , so the difference is in the null space. It is also in the row space, since the row space is closed under subtraction. A vector in both spaces is perpendicular to itself, and the only such vector is zero. So .
The matrix may crush a whole two-dimensional null space to zero. Underneath the crushing there is still a perfect one-to-one correspondence between two spaces of dimension , and restricted to the row space is invertible.
Watch it happen. The vectors and differ by the null space vector , so both give the same output:
Feed each through the split and both hand back the same row space vector, . Two inputs, one output, one row space representative. And that representative is the shortest of all the solutions, since while and . Pythagoras again: any other solution carries an extra perpendicular leg, and a leg can only make a vector longer.
The full solution set of is the dashed line, which is plus the null space. It crosses the row space once, at , and it crosses at a right angle. Every other solution is the hypotenuse of a triangle with as one leg, so every other solution is longer. The null space here is two-dimensional; only one of its directions fits on a page.
The whole of in one picture, in the chapter’s colors. The blue pairings across the top never collide: distinct points of the row space land on distinct points of the column space. Below, an entire two-dimensional null space collapses onto the single point .
The four rank cases, seen through the right angle
Lecture 8 sorted every matrix into four cases by comparing the rank with the shape and , and gave a worked example of each. The Big Picture says what those four cases really are: which of the two null spaces has collapsed to a point.
The four cases are four answers to one question: which null space is a single point? The colors are the same four rooms throughout. In every panel the two solid regions on the left and right have the same dimension and are linked one to one, which is the part that never changes. A fat null space on the left means many for each . A fat left null space on the right means some are out of reach.
Read the panels off the two null spaces. A null space that is only the zero vector means no two inputs collide, so a solution is unique when it exists. A left null space that is only the zero vector means the column space fills , so every is reachable. Case 1 has both, which is what invertible means. Case 4 has neither, and every unpleasant thing that can happen to a linear system happens there.
The part that survives all four cases is the one-to-one link across the middle. Even in case 4, where crushes a null space and misses part of , the row space and the column space are still perfectly matched.
The pseudoinverse, defined honestly
A matrix with a nonzero null space has no inverse. There is no undoing the crush: once and have both been sent to , nothing downstream can tell them apart. That looks like the end of the story. The one-to-one link says otherwise.
Here is what , the pseudoinverse, does with a vector in .
- Split into the part inside and the part inside . Those two spaces are complements, so the split exists and is unique.
- Throw away the part. It was never reachable, so no was ever going to produce it.
- Undo the one-to-one link on what is left, landing on the single in the row space with equal to the kept part.
In one line: invert the part that is invertible and ignore the null spaces. Every property of follows from those three steps. is the projection onto the row space, because it sends to . is the projection onto the column space, because it sends to its reachable part. And , since ignores the discarded pieces anyway.
The pseudoinverse is the return trip along the one-to-one link: blue out, green back. It reaches only the row space, and it listens only to the column space. The two faded rooms are exactly the information destroyed, and no inverse of any kind can bring them back.
On the running matrix, numpy.linalg.pinv reproduces all of this exactly. Feeding it
returns ,
the row space representative. Feed it the unreachable , which is plus the
left null space vector, and it returns the same answer, because step 2 discarded the difference.
import numpy as np
np.set_printoptions(precision=4, suppress=True)
A = np.array([[1., 2., 2., 4.], [3., 8., 6., 16.], [4., 10., 8., 20.]])
row = np.array([[1., 0., 2., 0.], [0., 1., 0., 2.]]) # row space basis
nul = np.array([[-2., 0., 1., 0.], [0., -2., 0., 1.]]) # null space basis
lnul = np.array([[-1., -1., 1.]]) # left null basis
print(row @ nul.T) # [[0. 0.] [0. 0.]] every pair perpendicular
print(A[:, :2].T @ lnul.T) # [[0.] [0.]] and on the other side too
x = np.array([1., 1., 1., 1.])
xr = (x @ row[0]) / 5 * row[0] + (x @ row[1]) / 5 * row[1]
xn = x - xr
print(xr, xn) # [0.6 0.6 1.2 1.2] [ 0.4 0.4 -0.2 -0.2]
print(xr @ xn) # 1.1102230246251565e-16 zero, in floating point
print(np.array([xr @ xr, xn @ xn, x @ x])) # [3.6 0.4 4. ] Pythagoras
print(A @ x, A @ xr, A @ xn) # [ 9. 33. 42.] [ 9. 33. 42.] [0. 0. 0.]import torch
A = torch.tensor([[1., 2., 2., 4.], [3., 8., 6., 16.], [4., 10., 8., 20.]])
row = torch.tensor([[1., 0., 2., 0.], [0., 1., 0., 2.]]) # row space basis
nul = torch.tensor([[-2., 0., 1., 0.], [0., -2., 0., 1.]]) # null space basis
lnul = torch.tensor([[-1., -1., 1.]]) # left null basis
print(row @ nul.T) # tensor([[0., 0.], [0., 0.]])
print(A[:, :2].T @ lnul.T) # tensor([[0.], [0.]])
x = torch.tensor([1., 1., 1., 1.])
xr = (x @ row[0]) / 5 * row[0] + (x @ row[1]) / 5 * row[1]
xn = x - xr
print(xr, xn) # tensor([0.6, 0.6, 1.2, 1.2]) tensor([0.4, 0.4, -0.2, -0.2])
print(xr @ xn) # tensor(0.) zero, in floating point
print(xr @ xr, xn @ xn, x @ x) # tensor(3.6) tensor(0.4) tensor(4.)
print(A @ x, A @ xr, A @ xn) # [9., 33., 42.] [9., 33., 42.] [0., 0., 0.]import jax.numpy as jnp
A = jnp.array([[1., 2., 2., 4.], [3., 8., 6., 16.], [4., 10., 8., 20.]])
row = jnp.array([[1., 0., 2., 0.], [0., 1., 0., 2.]]) # row space basis
nul = jnp.array([[-2., 0., 1., 0.], [0., -2., 0., 1.]]) # null space basis
lnul = jnp.array([[-1., -1., 1.]]) # left null basis
print(row @ nul.T) # [[0. 0.] [0. 0.]]
print(A[:, :2].T @ lnul.T) # [[0.] [0.]]
x = jnp.array([1., 1., 1., 1.])
xr = (x @ row[0]) / 5 * row[0] + (x @ row[1]) / 5 * row[1]
xn = x - xr
print(xr, xn) # [0.6 0.6 1.2 1.2] [ 0.4 0.4 -0.2 -0.2]
print(xr @ xn) # 0.0 zero, in floating point
print(jnp.array([xr @ xr, xn @ xn, x @ x])) # [3.6 0.4 4. ] Pythagoras
print(A @ x, A @ xr, A @ xn) # [ 9. 33. 42.] [ 9. 33. 42.] [0. 0. 0.]import tensorflow as tf
A = tf.constant([[1., 2., 2., 4.], [3., 8., 6., 16.], [4., 10., 8., 20.]])
row = tf.constant([[1., 0., 2., 0.], [0., 1., 0., 2.]]) # row space basis
nul = tf.constant([[-2., 0., 1., 0.], [0., -2., 0., 1.]]) # null space basis
lnul = tf.constant([[-1., -1., 1.]]) # left null basis
print(row @ tf.transpose(nul)) # [[0. 0.] [0. 0.]]
print(tf.transpose(A[:, :2]) @ tf.transpose(lnul)) # [[0.] [0.]]
x = tf.constant([1., 1., 1., 1.])
xr = tf.tensordot(x, row[0], 1) / 5 * row[0] + tf.tensordot(x, row[1], 1) / 5 * row[1]
xn = x - xr
print(xr, xn) # [0.6 0.6 1.2 1.2] [ 0.4 0.4 -0.2 -0.2]
print(tf.tensordot(xr, xn, 1)) # 0.0 zero, in floating point
print(tf.reduce_sum(xr * xr), tf.reduce_sum(xn * xn)) # 3.6 0.4
print(tf.linalg.matvec(A, x), tf.linalg.matvec(A, xr)) # [9. 33. 42.] twiceThe second block is the pseudoinverse doing the three steps.
import numpy as np
np.set_printoptions(precision=4, suppress=True)
A = np.array([[1., 2., 2., 4.], [3., 8., 6., 16.], [4., 10., 8., 20.]])
Aplus = np.linalg.pinv(A)
b = np.array([9., 33., 42.]) # reachable: b is in C(A)
print(Aplus @ b) # [0.6 0.6 1.2 1.2] the row space x
print(A @ (Aplus @ b)) # [ 9. 33. 42.] reached exactly
x = np.array([1., 1., 1., 1.])
s1 = np.array([-2., 0., 1., 0.])
print(Aplus @ A @ x) # [0.6 0.6 1.2 1.2] x projected onto the row space
print(Aplus @ A @ s1) # [0. 0. 0. 0.] the null part dies
b_off = np.array([8., 32., 43.]) # b plus the left null space vector
print(Aplus @ b_off) # [0.6 0.6 1.2 1.2] same answer
print(A @ (Aplus @ b_off) - b_off) # [ 1. 1. -1.] the part it threw away
print(np.allclose(A @ Aplus @ A, A)) # Trueimport torch
A = torch.tensor([[1., 2., 2., 4.], [3., 8., 6., 16.], [4., 10., 8., 20.]])
Aplus = torch.linalg.pinv(A)
b = torch.tensor([9., 33., 42.]) # reachable: b is in C(A)
print(Aplus @ b) # tensor([0.6, 0.6, 1.2, 1.2])
print(A @ (Aplus @ b)) # tensor([ 9., 33., 42.])
x = torch.tensor([1., 1., 1., 1.])
s1 = torch.tensor([-2., 0., 1., 0.])
print(Aplus @ A @ x) # tensor([0.6, 0.6, 1.2, 1.2])
print(Aplus @ A @ s1) # tensor([0., 0., 0., 0.])
b_off = torch.tensor([8., 32., 43.]) # b plus the left null space vector
print(Aplus @ b_off) # tensor([0.6, 0.6, 1.2, 1.2]) same answer
print(A @ (Aplus @ b_off) - b_off) # tensor([ 1., 1., -1.])
print(torch.allclose(A @ Aplus @ A, A)) # Trueimport jax.numpy as jnp
A = jnp.array([[1., 2., 2., 4.], [3., 8., 6., 16.], [4., 10., 8., 20.]])
Aplus = jnp.linalg.pinv(A)
b = jnp.array([9., 33., 42.]) # reachable: b is in C(A)
print(Aplus @ b) # [0.6 0.6 1.2 1.2] the row space x
print(A @ (Aplus @ b)) # [ 9. 33. 42.] reached exactly
x = jnp.array([1., 1., 1., 1.])
s1 = jnp.array([-2., 0., 1., 0.])
print(Aplus @ A @ x) # [0.6 0.6 1.2 1.2]
print(Aplus @ A @ s1) # [0. 0. 0. 0.]
b_off = jnp.array([8., 32., 43.]) # b plus the left null space vector
print(Aplus @ b_off) # [0.6 0.6 1.2 1.2] same answer
print(A @ (Aplus @ b_off) - b_off) # [ 1. 1. -1.]
print(jnp.allclose(A @ Aplus @ A, A)) # Trueimport tensorflow as tf
A = tf.constant([[1., 2., 2., 4.], [3., 8., 6., 16.], [4., 10., 8., 20.]])
Aplus = tf.linalg.pinv(A)
b = tf.constant([[9.], [33.], [42.]]) # reachable: b is in C(A)
print(Aplus @ b) # [[0.6] [0.6] [1.2] [1.2]]
print(A @ (Aplus @ b)) # [[ 9.] [33.] [42.]]
x = tf.constant([[1.], [1.], [1.], [1.]])
s1 = tf.constant([[-2.], [0.], [1.], [0.]])
print(Aplus @ (A @ x)) # [[0.6] [0.6] [1.2] [1.2]]
print(Aplus @ (A @ s1)) # [[0.] [0.] [0.] [0.]]
b_off = tf.constant([[8.], [32.], [43.]]) # b plus the left null space vector
print(Aplus @ b_off) # [[0.6] [0.6] [1.2] [1.2]] same answer
print(A @ (Aplus @ b_off) - b_off) # [[ 1.] [ 1.] [-1.]]
print(tf.reduce_all(tf.abs(A @ Aplus @ A - A) < 1e-5)) # TrueWhere this is going
Two things in this chapter were used before they were earned. The split was described as though computing it were easy, and step 1 of the pseudoinverse asked for the part of that lies inside the column space. Both are the same operation: dropping a vector perpendicularly onto a subspace. That operation is called projection, it has a short formula, and it is the next chapter. Once you can project, the whole of least squares is one substitution away, and least squares is how every linear model in machine learning gets fitted.