Contents
Deep Breakdown
What it tells me (Situation Awareness): This is the core iterative step of Gram-Schmidt orthogonalization. At step , you have already built orthonormal vectors . To make the new vector orthogonal to all of them, you subtract from its entire projection onto the subspace spanned by the previous vectors. What remains () is the component of that is perpendicular to all previous directions. You then normalize to get . In DA/DS this step is the computational heart of QR decomposition and orthogonal feature decorrelation.
Flowchart
Real-world use
Where it is used
The projection subtraction step runs inside every QR decomposition you do (in least-squares regression, pseudoinverse computation, or solving linear systems). It is also what happens conceptually when you use sklearn's PCA: each principal component direction is orthogonal to the previous ones, which is produced by projecting out already-found variance directions.
Problem it solves
If you simply take a set of linearly independent vectors, they are generally not orthogonal, meaning they share components along each other's directions. Downstream algorithms that assume an orthonormal basis (like QR solvers or coordinate projections) will produce wrong or unstable results. Projection subtraction removes the "shared" part so only the new, independent direction survives.
How it solves the problem
The projection of onto the subspace spanned by is (this is exact because the are orthonormal). Subtracting this from zeros out all components of that lie along any previous direction, leaving only the perpendicular residual .
Example in a business
A BI analyst has 3 campaign features: total_spend, branded_spend, non_branded_spend. Since total = branded + non-branded, they are linearly dependent. The analyst drops one and applies Gram-Schmidt to the remaining two. Step subtracts from non_branded_spend its projection onto the normalized total_spend direction, yielding a new feature that captures purely the variation in non_branded_spend that is not explained by total_spend. This decorrelated feature is then used in a regression to isolate the branded vs non-branded effect.
The formula, piece by piece
- : the new unnormalized orthogonal vector at step ; what remains of after removing all components along previous directions
- : the -th input vector to be orthogonalized (original, not yet modified)
- : the orthogonal projection of onto the subspace spanned by all previously computed directions
- The projection expands as: (using the normalized vectors )
- (minus): subtraction removes the shared component; the result is perpendicular to the subspace Full formula:
The formula in words
"At each step, take the next input vector and strip away every component it shares with the directions you have already built. What is left is a new direction that is perpendicular to all the previous ones." Tiny numeric example (, 2D): , . (already unit-norm). . . . Verify: .
Critical Concepts
- Order matters: the resulting orthonormal basis depends on the order of the input vectors; different orders give different (but equally valid) orthonormal bases for the same subspace.
- If , the inputs are linearly dependent: the -th vector was already in the span of the previous ones; discard it and move on (or report rank deficiency).
- Modified vs classical Gram-Schmidt: in the classical form you compute all projections onto the original ; in the modified form you sequentially subtract projections onto each as you go, which is numerically more stable.
- Connection to QR: the projection coefficients are exactly the entries in the upper triangular matrix of the QR decomposition.
- Cost: each step requires inner products and vector subtractions; total cost for vectors is where is the vector dimension.
- Orthogonality check: after the process, verify (up to numerical tolerance); deviations indicate accumulated floating-point error.
Step-by-step calculation (tiny example)
Step 1: Start from initialization; have q_hat_1 Input: , , . From initialization: .
Step 2: Apply projection subtraction at k=2 . . . .
Step 3: Verify orthogonality between q_hat_1 and q_hat_2 . Both vectors are unit-norm and mutually orthogonal; is the new orthonormal basis vector.
Code examples
Beginner example (Python)
import numpy as np # -- Gram-Schmidt step k=2: subtract projection onto q_hat_1 ----------
v1 = np.array([1.0, 1.0, 0.0])
v2 = np.array([1.0, 0.0, 1.0]) # Initialization: q_hat_1
q1_hat = v1 / np.linalg.norm(v1) # Step k=2: projection subtraction
proj_coeff = float(v2 @ q1_hat) # <v2, q_hat_1>
proj_vec = proj_coeff * q1_hat # scalar * unit vector
u2 = v2 - proj_vec # remove the projection
q2_hat = u2 / np.linalg.norm(u2)
print(f"q_hat_1 = {q1_hat.round(4)}")
print(f"<v2, q_hat_1> = {proj_coeff:.4f}")
print(f"Projection of v2 onto q_hat_1 = {proj_vec.round(4)}")
print(f"u2 (residual) = {u2.round(4)}")
print(f"q_hat_2 = {q2_hat.round(4)}")
print(f"\n||q_hat_2|| = {np.linalg.norm(q2_hat):.6f} (should be 1.0)")
print(f"<q_hat_1, q_hat_2> = {float(q1_hat @ q2_hat):.2e} (should be ~0)")Beginner example (R)
# -- Gram-Schmidt step k=2: subtract projection onto q_hat_1 ----------
v1 <- c(1.0, 1.0, 0.0)
v2 <- c(1.0, 0.0, 1.0)
# Initialization
q1_hat <- v1 / sqrt(sum(v1^2))
# Step k=2
proj_coeff <- sum(v2 * q1_hat)
proj_vec <- proj_coeff * q1_hat
u2 <- v2 - proj_vec
q2_hat <- u2 / sqrt(sum(u2^2))
cat(sprintf("q_hat_1 = %s\n", paste(round(q1_hat, 4), collapse = ", ")))
cat(sprintf("<v2, q_hat_1> = %.4f\n", proj_coeff))
cat(sprintf("u2 (residual) = %s\n", paste(round(u2, 4), collapse = ", ")))
cat(sprintf("q_hat_2 = %s\n", paste(round(q2_hat, 4), collapse = ", ")))
cat(sprintf("\n||q_hat_2|| = %.6f (should be 1.0)\n", sqrt(sum(q2_hat^2))))
cat(sprintf("<q_hat_1, q_hat_2> = %.2e (should be ~0)\n", sum(q1_hat * q2_hat)))Professional example (Python)
import numpy as np
import pandas as pd
np.random.seed(42)
# -- Business scenario: decorrelate 4 marketing features via full Gram-Schmidt
# Produces orthonormal feature matrix Q and upper triangular R (QR decomposition).
N_OBS = 120
FEATURE_NAMES = ["spend_keur", "impressions_m", "clicks_k", "conversions"]
N_FEATURES = len(FEATURE_NAMES)
# Simulate correlated feature data
true_cov = np.array([
[4.0, 2.5, 1.8, 0.9],
[2.5, 3.5, 2.0, 1.0],
[1.8, 2.0, 2.5, 1.2],
[0.9, 1.0, 1.2, 1.5],
])
L_sim = np.linalg.cholesky(true_cov)
data = np.random.normal(size=(N_OBS, N_FEATURES)) @ L_sim.T
df = pd.DataFrame(data, columns=FEATURE_NAMES)
# Build orthonormal basis via classical Gram-Schmidt (column vectors = observations)
# Treat each feature as a vector in R^N_OBS
A = df.to_numpy()
Q = np.zeros_like(A)
R = np.zeros((N_FEATURES, N_FEATURES))
for k in range(N_FEATURES):
v_k = A[:, k].copy()
for i in range(k):
r_ik = float(A[:, k] @ Q[:, i])
R[i, k] = r_ik
v_k = v_k - r_ik * Q[:, i]
r_kk = float(np.linalg.norm(v_k))
R[k, k] = r_kk
Q[:, k] = v_k / r_kk
# Verify Q^T Q = I and Q R = A
qtq_error = np.max(np.abs(Q.T @ Q - np.eye(N_FEATURES)))
recon_error = np.max(np.abs(A - Q @ R))
print(f"Q^T Q - I max error: {qtq_error:.2e} (should be ~0: columns are orthonormal)")
print(f"Q R - A max error: {recon_error:.2e} (should be ~0: reconstruction correct)")
print(f"\nR (upper triangular, contains projection coefficients):")
print(pd.DataFrame(R.round(3), index=FEATURE_NAMES, columns=FEATURE_NAMES).to_string())
print("\nInterpretation: columns of Q are orthonormal feature directions; R encodes how much of each original feature was shared with previous orthonormal directions.")Source: domain knowledge