III-31 · PCA Projection and Reconstruction

Keep a shadow and measure the missing part

Project centered observations into k coordinates, reconstruct them in the original feature space, and account for every squared residual.

Chapters in Part VI
  1. III-29 · High-Dimensional Data and Covariance
  2. III-30 · PCA: Eigenvectors and Explained Variance
  3. III-31 · PCA Projection and Reconstruction

Part VI · inspectable two-feature PCA

Keep components; measure what is lost

Hand-authored synthetic observations; no random draws or seed. Edits change the displayed cloud. Fit and reconstruction use these same rows.

PCA data, principal lines and reconstruction planeFilled circles are observations. The plus is the mean. Solid and dashed principal lines pass through the mean; hollow squares are reconstructions, connected by residual segments. Equal units on both axes. Drag a point or focus its handle and use arrow keys.
● Observation · + Mean · □ Reconstruction · residual segment · solid PC1 / dashed PC2. Lines have arbitrary signs. Repeated-eigenvalue lines show one conventional basis.

Drag any point; arrows move 0.1, Shift + arrow 0.5. Pointer edits snap to 0.1 and stop at ±5. Numeric entries retain finer precision. Overlapping points remain available in the point selector; its chosen handle is brought to the front. Scroll the page outside the handles.

Covariance and retained space
Observation editor

Axes are unoriented lines: v and −v describe the same component. Signs are display conventions.

Frame 1 / 1. Edits begin at the displayed frame.

Input X / centered XcSamples in rows; observations are column vectors in equations. No labels, intercept or standardization.
4 × 2 / 4 × 2
Mean μ2 × 1; axes pass through this mean in source coordinates.
[0, 0]
Covariance Csample divisor 3.
2 × 2
Basis Vk / scores Zk=0 gives empty scores and mean-only reconstruction.
2 × 1 / 4 × 1
Ambiguity
Axes are unoriented lines: v and −v describe the same component. Signs are display conventions.
λ1
6
λ2
0.66667
Retained variance fraction
0.9
Reconstruction SSE
2
Per-observation MSE · SSE/n
0.5
Per-entry MSE · SSE/(2n)
0.25
Xc=X−1μT,X∈R4×2,μ=[00]X_c=X-\mathbf1\mu^T,\quad X\in\mathbb R^{4\times2},\quad\mu=\begin{bmatrix}0\\0\end{bmatrix}
C=XcTXc3,n−1=3C=\frac{X_c^TX_c}{3},\quad n-1=3
Cvj=λjvj,VTV=I,λ1≥λ2,k=1Cv_j=\lambda_jv_j,\quad V^TV=I,\quad\lambda_1\ge\lambda_2,\quad k=1
Z=XcVk∈R4×1,X^=ZVkT+1μTZ=X_cV_k\in\mathbb R^{4\times1},\quad\hat X=ZV_k^T+\mathbf1\mu^T
SSE=∥X−X^∥F2,MSE=SSE/4\mathrm{SSE}=\lVert X-\hat X\rVert_F^2,\quad\mathrm{MSE}=\mathrm{SSE}/4
Covariance C (2 × 2), ordered directions (2 × 1), and variance ratios
QuantityValueInterpretation
C row 1[3.33333, 2.66667]Variance x; covariance x,y
C row 2[2.66667, 3.33333]Covariance y,x; variance y
PC1[0.70711, 0.70711]λ ≈ 6; fraction 0.9
PC2[−0.70711, 0.70711]λ ≈ 0.66667; fraction 0.1
Actual row-batch values; products accumulate into C; residual = observation − reconstruction
IDx,yCentereddx²; dx·dy; dy²Scores (k)ReconstructionResidualSquared error
a[−2, −1][−2, −1]4; 2; 1[−2.12132][−1.5, −1.5][−0.5, 0.5]0.5
b[−1, −2][−1, −2]1; 2; 4[−2.12132][−1.5, −1.5][0.5, −0.5]0.5
c[1, 2][1, 2]1; 2; 4[2.12132][1.5, 1.5][−0.5, 0.5]0.5
d[2, 1][2, 1]4; 2; 1[2.12132][1.5, 1.5][0.5, −0.5]0.5

Choose k=0,1,2. Hollow squares are actual reconstructions and the connecting segments are actual residual vectors, all from the displayed numerical state.

Remove a coordinate and inspect what survives

At k=1, points a and b have the same hollow reconstruction square. Their first scores agree, even though the points differ. The residual segments show which information was discarded. Move either observation and inspect its scores, reconstruction and squared error in the table.

Select Replay k = 0, 1, 2. The first frame keeps no components and reconstructs every observation at the mean. Next keeps the leading direction. The last retains the whole two-feature space. This trace changes the component count; PCA is not taking three optimization iterations. Editing a point, normalization or k during replay starts from the displayed cloud and ends replay.

Reset restores the original cloud with k=1. Try line cloud: a single component suffices in exact arithmetic. Try isotropic cloud: any one line loses the same total energy, although the individual shadows depend on the conventional basis. Try constant cloud: reconstruction error is zero even though retained variance is undefined. A reconstruction can be perfect without a meaningful variance ratio.

Projection changes the representation, not the observation count

Let Vk contain k orthonormal direction columns, so Vk is d×k. Each centered observation has a k×1 score vector zi=VkT(xi−μ)z_i=V_k^T(x_i-\mu). The row-batch equation is

Z=XcVk∈Rn×k,X^=ZVkT+1μT∈Rn×d.Z=X_cV_k\in\mathbb R^{n\times k},\qquad \hat X=ZV_k^T+\mathbf1\mu^T\in\mathbb R^{n\times d}.

The observation count n remains the same. Scores are coordinates in a learned basis, not selected original feature columns. A single principal component can combine every original feature. Reconstructing adds the training mean back into source coordinates; forgetting it would place the cloud around the origin rather than its actual location.

At k=0, Z has n×0 shape. The empty product is zero, so reconstruction is the mean-only baseline. At k=d, VkVkᵀ=I and reconstruction is exactly X in real arithmetic. Both the browser and canonical Python apply that full-space identity directly for k=2, copying the original coordinate values. They still calculate and expose the scores, but avoid an unnecessary floating rotate/unrotate round trip. Thus full-space residuals and SSE are exactly zero in this implementation, rather than accumulated rounding artifacts.

Derive the residual and its optimality

The projector Pk=VkVkᵀ is symmetric and idempotent. Reconstruction of the centered column xci is Pkxci; the residual is

ri=xi−x^i=(I−Pk)(xi−μ),VkTri=0.r_i=x_i-\hat x_i=(I-P_k)(x_i-\mu),\qquad V_k^Tr_i=0.

The residual is perpendicular to every retained direction. Pythagoras separates retained and discarded energy. Over the whole batch,

SSE=∥X−X^∥F2=∥Xc(I−Pk)∥F2=q∑j=k+1dλj.\mathrm{SSE}=\|X-\hat X\|_F^2 =\|X_c(I-P_k)\|_F^2 =q\sum_{j=k+1}^d\lambda_j.

Why is PCA the best rank-k orthogonal reconstruction for this centered batch? A k-dimensional projector retains variance tr(VkᵀCVk), maximized by the leading eigenvectors. Total variance tr(C) is fixed, so maximizing retained variance minimizes the remainder. Equivalently, the SVD keeps the largest singular values and discards the rest. This is a training reconstruction optimum in the declared Euclidean metric, not a guarantee about downstream prediction or future data.

We report SSE, per-observation MSE=SSE/n, and per-entry MSE=SSE/(nd). They answer different questions. The first averages each observation’s squared vector norm; the second averages individual scalar-coordinate errors. For this lab d=2, they differ by a factor of two. None uses n−1: that divisor belongs to sample covariance, not the reconstruction metric.

Work every shadow in the original cloud

For v1=(1,1)T/2v_1=(1,1)^T/\sqrt2 and μ=0, point a=(−2,−1) has

za=v1Txa=−3/2,x^a=v1za=(−3/2,−3/2)T,ra=(−1/2,1/2)T,∥ra∥22=1/2.z_a=v_1^Tx_a=-3/\sqrt2,\quad \hat x_a=v_1z_a=(-3/2,-3/2)^T,\quad r_a=(-1/2,1/2)^T,\quad \|r_a\|_2^2=1/2.

Point b has the same first score and reconstruction, with residual (1/2,−1/2). Points c and d reconstruct at (3/2,3/2), each with squared residual 1/2. Hence SSE=2, per-observation MSE=1/2 and per-entry MSE=1/4. With sample eigenvalues 6 and 2/3, the spectral identity gives qλ2=3(2/3)=2. Population covariance instead gives 4(1/2)=2: the geometric error agrees.

kScore shapeReconstructionRetained fractionSSEMSE per observation
04 × 0All rows at (0,0)0205
14 × 1Rows at (−1.5,−1.5) or (1.5,1.5)0.920.5
24 × 2Source rows, direct identity100

Changing the first eigenvector to its negative flips −3/√2 to +3/√2 and flips the reconstruction direction at the same time. The product is identical. If the spectrum repeats at the truncation boundary, changing the retained line can change the individual reconstructed points while preserving the optimal total SSE.

Use the learned transform on future rows

Fit μ and Vk using training data. A future row xnew uses znew=Vkᵀ(xnew−μ) and reconstructs as μ+Vkznew. Refitting the basis on the future row would be a different operation. The lab refits its displayed descriptive cloud after edits; it does not implement a frozen train/test pipeline or estimate unseen reconstruction performance.

A retained fraction can guide a compression budget, but low-variance directions may contain important predictive information. Consider a class label encoded in a tiny perpendicular offset from a long line. One-component PCA can retain almost all variation while discarding exactly that label signal. Use evaluation suited to the downstream task, rather than assuming a 90% retained fraction is a 90% accuracy promise.

Units matter here too. Raw squared reconstruction error adds squared errors from each feature. Changing one feature’s numerical scale changes the objective and can change the chosen basis. Standardization and inverse scaling must be explicit parts of a larger pipeline; this Part applies neither automatically. It also does not whiten, invent missing values or claim arbitrary-dimensional browser support.

Numerical and shape caveats

The 2D core accepts 1–128 dense finite observations with unique IDs and integral numeric k in [0,2]. Sparse array holes, ragged rows, NaN, infinity, invalid configuration and computed overflow are rejected before degenerate returns. k=0 still validates every coordinate: “not retaining components” is not permission to accept malformed data.

For a singleton, population covariance has zero spread and reconstruction is the observed mean. Sample covariance is undefined, and the lab withholds PCA outputs instead of fabricating them. For a constant batch, covariance is zero, reconstruction error is zero and ratios stay undefined at every k. There is no unique principal direction for zero spread.

Execute the actual Python and NumPy

The reference pca(request) returns each row’s scores, fullScores, reconstructed, residual and squaredError, plus batch SSE and both MSE conventions. scores has k entries; fullScores contains the two inspectable coordinates before truncation. axes is a list of direction columns. The kernel deliberately handles k=2 as the identity and k=0 as the empty-basis mean baseline.

.venv/bin/python -B code/python/p06/pca.py

That terminal invocation uses the four original rows. Complete JSON on stdin selects another cloud or k. Independent committed literals include the k=0/1/2 cloud, a rank-one line, repeated spectrum, zero spread, one-sample conventions and a tiny positive minor eigenvalue. The independent Fraction/SVD/eigh oracle executes the canonical module and checks actual reconstruction energy; it does not trust copied browser output.

code/python/p06/pca.py
"""Inspectable two-feature PCA. X stores observations in rows; directions are columns.

No scaling or whitening. Sample normalization divides by n-1; population by n.
One sample has undefined sample covariance. Zero spread has undefined variance ratios.
"""
import json
import math
import sys
import numpy as np

CLOUD = [{"id": i, "x": x} for i, x in zip("abcd", [[-2, -1], [-1, -2], [1, 2], [2, 1]])]
DEFAULT = dict(points=CLOUD, normalization="sample", k=1)


def finite(value, label):
    if isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, float, np.integer, np.floating)):
        raise ValueError(f"{label} must be a finite real number.")
    try:
        number = float(value)
    except (OverflowError, ValueError) as error:
        raise ValueError(f"{label} must be finite; binary64 conversion overflow is rejected.") from error
    if not math.isfinite(number):
        raise ValueError(f"{label} must be finite; arithmetic overflow is rejected.")
    return number + 0.0


def energy(values):
    total = 0.0
    for value in values:
        square = finite(float(value) * float(value), "Squared component")
        if value != 0 and square == 0:
            raise ValueError("Squared component underflow: rescale the observations.")
        total = finite(total + square, "Sum of squares")
    return total


def normalized_energy(values, divisor):
    total = energy(values)
    value = finite(total / divisor, "Normalized energy")
    if total > 0 and value == 0:
        raise ValueError("Variance underflow: rescale the observations.")
    return value


def pca(request):
    if not isinstance(request, dict):
        raise ValueError("PCA request must be an object.")
    normalization, k, points = request.get("normalization"), request.get("k"), request.get("points")
    if normalization not in ("sample", "population"):
        raise ValueError("Normalization must be sample or population.")
    k = finite(k, "Component count")
    if not k.is_integer() or not 0 <= k <= 2:
        raise ValueError("Component count must be an integer from 0 to 2.")
    k = int(k)
    if not isinstance(points, list) or not 1 <= len(points) <= 128:
        raise ValueError("Use 1 to 128 dense two-feature observations.")
    ids, coordinates = [], []
    for point in points:
        if not isinstance(point, dict) or not isinstance(point.get("id"), str) or not point["id"].strip() or point["id"] in ids:
            raise ValueError("Point IDs must be nonempty unique strings.")
        x = point.get("x")
        if not isinstance(x, list) or len(x) != 2:
            raise ValueError("Each observation must have exactly two dense components.")
        ids.append(point["id"])
        coordinates.append([finite(value, "Observation component") for value in x])
    X = np.asarray(coordinates, dtype=np.float64)  # n×2; boundary checked before degenerate returns
    n = len(points)
    mean = []
    for j in range(2):
        first = float(X[0, j])
        if all(float(x) == first for x in X[:, j]):
            mean.append(first)
        else:
            delta = 0.0
            for x in X[:, j]:
                delta = finite(delta + finite(float(x) - first, "Mean difference") / n, "Mean accumulation")
            mean.append(finite(first + delta, "Mean"))
    centered = np.asarray([[finite(float(x[j]) - mean[j], "Centering") for j in range(2)] for x in X])
    divisor = n - 1 if normalization == "sample" else n
    products = [[finite(float(x[0]) * float(x[0]), "Squared x"), finite(float(x[0]) * float(x[1]), "Cross product"), finite(float(x[1]) * float(x[1]), "Squared y")] for x in centered]
    basic = dict(n=n, divisor=divisor, mean=mean, centered=centered.tolist(), products=products)
    if divisor == 0:
        return dict(basic, status="undefined", reason="Sample covariance needs n ≥ 2; n−1 is zero.")
    a, c = (normalized_energy(centered[:, j], divisor) for j in range(2))
    b = 0.0
    for x in centered:
        b = finite(b + finite(float(x[0]) * float(x[1]), "Cross product"), "Covariance sum")
    b = finite(b / divisor, "Covariance")
    repeated, scale = a == c and b == 0, max(a, c, abs(b))
    v1, large = [1.0, 0.0], 0.0
    if scale > 0:
        A, B, C = a / scale, b / scale, c / scale
        L = (A + C) / 2 + math.hypot((A - C) / 2, B)
        large = finite(scale * L, "Largest eigenvalue")
        if b == 0:
            v1 = [1.0, 0.0] if a >= c else [0.0, 1.0]
        else:
            raw = [L - C, B] if A >= C else [B, L - A]
            norm = math.hypot(*raw)
            if norm == 0:
                raise ValueError("Eigenvector scaling underflow: direction is unresolved in binary64.")
            sign = -1 if raw[0] < 0 or (raw[0] == 0 and raw[1] < 0) else 1
            v1 = [sign * x / norm for x in raw]
    v2 = [-v1[1], v1[0]]
    axes = [v1, v2]  # serialized list of 2×1 columns, not a row-batch basis matrix
    def dot(x, v):
        return finite(finite(float(x[0]) * v[0], "Dot product") + finite(float(x[1]) * v[1], "Dot product"), "Dot product")
    scores = [[dot(x, v) for v in axes] for x in centered]
    small = normalized_energy([z[1] for z in scores], divisor)
    if small > large:  # last-bit ordering in a nearly repeated spectrum
        large, small = small, large
        axes = [v2, v1]
        scores = [[z[1], z[0]] for z in scores]
        v1 = axes[0]
    eigenvalues = [large, small]
    total = finite(large + small, "Total variance")
    ratios = None if total == 0 else [v / total for v in eigenvalues]
    rows = []
    for i, x in enumerate(X):
        z = scores[i]
        # Full-space identity is applied directly, avoiding needless rotate/unrotate roundoff.
        reconstructed = x.tolist() if k == 2 else [finite(mean[j] + (finite(z[0] * v1[j], "Reconstruction product") if k else 0), "Reconstruction") for j in range(2)]
        residual = [finite(float(x[j]) - reconstructed[j], "Residual") for j in range(2)]
        rows.append(dict(id=ids[i], x=x.tolist(), centered=centered[i].tolist(), scores=z[:k], fullScores=z,
                         reconstructed=reconstructed, residual=residual, squaredError=energy(residual)))
    sse = 0.0
    for row in rows:
        sse = finite(sse + row["squaredError"], "Reconstruction SSE")
    return dict(basic, status="defined", covariance=[[a, b], [b, c]], axes=axes, eigenvalues=eigenvalues,
                totalVariance=total, ratios=ratios, retained=None if ratios is None else sum(ratios[:k]),
                repeated=repeated, nearRepeated=not repeated and abs(large - small) <= 64 * sys.float_info.epsilon * total,
                zeroVariance=total == 0, rows=rows, sse=sse, mse=sse / n, entryMse=sse / (2 * n))


if __name__ == "__main__":
    try:
        request = DEFAULT if sys.stdin.isatty() else json.load(sys.stdin)
        print(json.dumps(pca(request), allow_nan=False))
    except (ValueError, TypeError, OverflowError) as error:
        print(json.dumps({"error": str(error)}), file=sys.stderr)
        sys.exit(2)

Five graded investigations

X has shape 12 × 5 and k=2. What are the score and reconstruction shapes?
What does k=0 reconstruct?
For a=(−2,−1), v1=(1,1)/√2 and μ=0, what is the one-component reconstruction?
SSE is 2 for four two-feature rows. What are the two MSE conventions?
One component retains 99% of variance. What does this establish?

Carry the mechanism forward

You can compute scores and source-space reconstructions, prove residual orthogonality, relate discarded eigenvalues to SSE, distinguish both MSE denominators and handle zero or repeated spectra honestly. III-29 established centered covariance; III-30 selected its directions. This completes the geometry of dimensionality reduction and prepares you to ask how a representation affects later supervised evaluation.