Feature Space: What Does a Linear Model Actually See?

Explain this chapter with AI

Copy this prompt into ChatGPT, Claude, Gemini, a local model, or another AI.

Apply this chapter with AI

Copy this prompt into ChatGPT, Claude, Gemini, a local model, or another AI.

Here is a sequence classifier. Eight examples, 128 positions each, 768 features per position, and a linear head that produces one score.

x = torch.randn(8, 128, 768)
classifier = nn.Linear(768, 1)

scores = classifier(x)
input           (8, 128, 768)
Linear(768,1)  -> (8, 128, 1)

One thousand and twenty-four scores where eight were expected. Nothing raised, nothing is non-finite, and the shape is entirely predictable once you know the rule. The layer did exactly what the tensor asked of it: nn.Linear transforms the last axis and preserves every axis before it, so it produced one score for every (example, position) pair — 128 scores per example, computed independently.

The layer is fine. The tensor is fine. What was missing is a question nobody asked out loud:

What object am I classifying?

The programmer meant one score per sequence. The tensor contains 128 vectors per sequence, and a linear head has no way to know that those 128 vectors were supposed to be one thing. Getting one score per sequence requires first deciding how 128 vectors become one:

[B, T, D]
     ↓   aggregate or select — an architectural decision
[B, D]
     ↓   linear classifier
[B, 1]
x.mean(dim=1)       (8, 128, 768) -> (8, 768)
classifier(pooled) -> (8, 1)

x[:, 0]             (8, 128, 768) -> (8, 768)
classifier(x[:,0]) -> (8, 1)

mean-pool == first-token scores: False

Both produce [8, 1]. Both are legal. They are different models, and neither is the default correct answer — mean pooling, taking a designated position, attention pooling and max pooling are all real choices with different behavior. The shape does not choose for you.

This chapter is about the layer of reasoning that sits underneath that mistake.

Where we are

Chapter 8 ended by watching the grid disappear. A convolutional body turns

[B, C, H, W]

into

[B, D]

and the meaning of the axes changes when it does. H and W had adjacency: pixel (h, w) sits next to (h, w+1), and convolution was built entirely around that fact. After pooling and flattening, what remains is a list of coordinates:

image   (16, 3, 64, 64)     grid axes have adjacency
body    (16, 32, 1, 1)
flatten (16, 32)            D=32 coordinates, no adjacency

There is no sense in which feature 17 is next to feature 18. They are two learned measurements of the same object, and reordering all of them consistently throughout the network would change nothing but the names.

So the reader arrives here with a tensor whose geometry Chapter 8 can no longer describe. Chapter 8’s question was what happens to a spatial grid. This chapter’s question is what happens after the grid is gone:

When one object is represented by hundreds or thousands of coordinates, what do direction, score, distance and decision boundary mean — and how do we reason about them without being able to draw the space?

The honest answer is that visualization was never doing the work. Every quantity that matters is a number you can compute and print. The skill is knowing which number answers which question, and that is what this chapter builds.

The environment

Every number, shape and training result in this chapter came from executing the code shown, with fixed seeds.

PyTorch      2.13.0
TorchVision  0.28.0
Python       3.12.3
OS           Linux, CPU only

Tensor rank is not representation dimensionality

The word dimension does two jobs, and the opening failure lives in the gap between them.

shape (768,)             ndim=1   D=768   one object
shape (32, 768)          ndim=2   D=768   32 objects
shape (8, 128, 768)      ndim=3   D=768   8 x 128 objects

Three different tensor ranks. The same representation space in all three. torch.randn(768) is a rank-1 tensor, and if those 768 numbers describe one object, that object is a point in a 768-dimensional feature space. Both statements are true because they answer different questions:

TENSOR AXES
    how representations are organized

FEATURE DIMENSION D
    how many coordinates describe one represented object

That gives a reading procedure for any shape you are handed. Split the axes into structure and coordinates:

[32, 768]        32 objects,  768 coordinates each
[8, 128, 768]    8 sequences, 128 positions each, 768 coordinates per position
[16, 32]         16 images,   32 coordinates each

The leading axes say how representations are organized. The last axis, in the conventions this book uses, holds the coordinates of one represented object; the surrounding model and data contract tell you what that object actually is. Say that out loud before doing any linear algebra, and the opening failure becomes impossible to write: [8, 128, 768] contains 1,024 represented vectors, so a per-vector classifier will return 1,024 scores.

nn.Linear transforms the last axis

The rule behind the opening is worth stating precisely, because it is the same rule that makes transformer code readable.

lin = nn.Linear(768, 16)
(768,)               -> (16,)
(32, 768)            -> (32, 16)
(8, 128, 768)        -> (8, 128, 16)
(4, 7, 9, 768)       -> (4, 7, 9, 16)

In general:

[..., D_in]  ->  [..., D_out]

Every leading axis is preserved, whatever it means. The layer holds weight of shape [D_out, D_in] and bias of shape [D_out], and it applies them to each D_in-vector independently. Confirming that on one position of the opening tensor:

one = x[3, 17]                                    # (768,)
manual = one @ classifier.weight[0] + classifier.bias[0]
manual  : 0.047477103769779205
observed: 0.047477103769779205
close   : True

So the [8, 128, 1] output is 1,024 independent dot products, and the layer never had an opinion about whether those 128 vectors belonged together.

Note the naming trap while we are here. Chapter 8 used H for image height; here D_in and D_out are the feature dimensions. Using H for a hidden size next to a chapter about [B, C, H, W] is how confusion enters, so this chapter says D and means coordinates.

One more temptation worth naming, since it is the other thing people reach for:

x.flatten(1)
x.flatten(1): (8, 128, 768) -> (8, 98304)   128*768 = 98304
classifier(flat): mat1 and mat2 shapes cannot be multiplied (8x98304 and 768x1)

That is a different representation, not a fix. It concatenates 128 position-vectors into one 98,304-coordinate vector in which the distinction between which position and which coordinate has been erased. Chapter 8 made the same point about flattening a spatial grid: the operation is cheap, and it changes the representation contract. It is the right move only when the collapse is what you meant.

The technique: name the object, find the feature axis

Every chapter of this book has added an investigation method. Chapter 2 asked for the first wrong tensor rather than the first illegal one. Chapter 3 asked where the gradient path stops existing. Chapter 5 asked which structure disagreed about ownership. Chapter 7 asked where a sample stopped satisfying its contract. Chapter 8 asked where derived and observed geometry first diverged.

Chapter 9 adds:

Name the object one vector represents. Find the axis holding its coordinates. Derive what the operation does to that axis. Only then interpret the number that comes out.

Four steps, and the opening failure fails at step one. The rest of this chapter is what happens when you take step four seriously — because the numbers that come out of feature-space operations are much easier to misread than a shape is.

A dot product is a score along a direction

Start in two dimensions, where the answer can be checked against a drawing, and then take the drawing away.

Given a direction w and an offset b, the score of a point is

s(x) = x · w + b = x₁w₁ + x₂w₂ + ... + x_D w_D + b

With w = [0.8, 1.3] and b = -0.2:

point               x@w+b    side
[2.0, -1.0]         0.100       +
[3.0, 2.0]          4.800       +
[-2.0, -1.0]       -3.100       -
[0.0, 0.0]         -0.200       -
[1.5, 0.0]          1.000       +

expanded for [2,-1]: 2.0*0.8 + (-1.0)*1.3 + (-0.2) = 0.10000000000000003

The sign says which side of a boundary the point is on. The boundary itself is the set where the score is zero:

w · x + b = 0

In two dimensions that equation describes a line; in three, a plane; in D, a hyperplane. The name changes and the equation does not, which is the single most useful fact in this chapter.

And w is perpendicular to that boundary. The proof takes two lines and no picture. If x₁ and x₂ both lie on the boundary, then w·x₁ + b = 0 and w·x₂ + b = 0, so subtracting gives w·(x₁ - x₂) = 0. Every displacement along the boundary is orthogonal to w. Verified:

boundary point p1: [1.0, -0.4615]   score: -0.0
boundary point p2: [4.0, -2.3077]   score:  0.0
w . (p1 - p2) = -0.0

Now remove the drawing and change nothing else:

D=2     X (32, 2)      w (2,)     -> scores (32,)   positive: 21/32
D=100   X (32, 100)    w (100,)   -> scores (32,)   positive: 18/32
D=768   X (32, 768)    w (768,)   -> scores (32,)   positive: 12/32

Same expression, same output shape, same interpretation of the sign. The picture disappeared. The computation did not, and neither did the geometry — w is still normal to the boundary at D=768, by exactly the two-line argument above, which never mentioned how many coordinates there were.

This is also where a familiar layer becomes geometric. nn.Linear(D, C) holds weight of shape [C, D]: that is C directions in the same feature space, plus C offsets. Each output is one point’s score along one of those directions. Chapter 4’s x @ W.T + b and Chapter 8’s classifier head were doing this all along; the only new thing is the vocabulary.

A dot product is not a similarity, a cosine or a distance

Here is where interpretation starts going wrong, and it goes wrong quietly. The dot product of two nonzero vectors decomposes exactly:

x · y = ||x|| ||y|| cos(θ)
x = [3.0, 4.0]  ||x|| = 5.0
y = [1.0, 0.0]  ||y|| = 1.0
x @ y            = 3.0
||x|| ||y|| cos  = 3.0
cos              = 0.6000000238418579
angle (degrees)  = 53.130104064941406

Three factors, only one of which is about direction. So scaling one vector changes the dot product while leaving the angle untouched:

  scale         x@y      cosine     ||x||
    0.5      1.5000    0.600000     2.500
    1.0      3.0000    0.600000     5.000
    2.0      6.0000    0.600000    10.000
   10.0     30.0000    0.600000    50.000
  100.0    300.0000    0.600000   500.000

A hundredfold change in the “similarity” between two vectors that point in exactly the same relative direction they always did. If a ranking, a retrieval system or a threshold uses a raw dot product and the vector norms vary across your dataset, the ranking is partly measuring magnitude.

Normalize both vectors and the two quantities coincide, which is the whole reason cosine similarity exists:

||x_hat|| = 1.0   ||y_hat|| = 1.0
x_hat @ y_hat = 0.6000000238418579   cosine = 0.6000000238418579   equal: True

Euclidean distance answers a third question. One example makes the three disagree:

a = [1.0, 1.0]

b1 = same direction, far          b2 = near, different direction
   dot          20.0000              dot           2.2000
   cosine        1.0000              cosine        0.9959
   euclidean    12.7279              euclidean     0.2000

By cosine, b1 is a perfect match. By Euclidean distance, b1 is sixty times further away than b2. Both are correct answers to different questions:

dot product          direction and both magnitudes together
cosine similarity    direction only, after normalizing
Euclidean distance   displacement magnitude

Say which one you want before you compute it. “Similarity” is not a quantity; it is a category of quantities, and the code has to pick one.

Normalizing the wrong axis

The correct choice of quantity does not protect you if the operation is applied to the wrong axis. This failure has Chapter 7’s exact signature — same shape, same dtype, legal operation, wrong geometry — and it is easy to write.

x = torch.arange(1., 25.).reshape(2, 4, 3)     # B=2 sequences, T=4 positions, D=3

n_last = F.normalize(x, dim=-1)
n_one  = F.normalize(x, dim=1)
F.normalize(x, dim=-1).shape: (2, 4, 3)
F.normalize(x, dim=1).shape : (2, 4, 3)
same shape: True    same values: False

Two calls, identical shapes, different tensors. The question that separates them is which vectors are unit length afterward:

norms of each D-vector (over dim=-1):
   after dim=-1: [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
   after dim=1 : [0.2409, 0.5887, 0.9456, 1.3043, 0.6445, 0.7829, 0.9213, 1.0597]

norms over dim=1 (across positions):
   after dim=1 : [1.0, 1.0, 1.0, 1.0, 1.0, 1.0]

dim=-1 makes each D-dimensional representation a unit vector, which is the contract intended in this example. dim=1 makes each coordinate have unit norm across positions — a different, coherent operation that does not normalize the representation vectors themselves.

The consequence shows up the moment those vectors are compared. Flatten the two leading axes so the eight position vectors become a matrix [8, 3], then compare each vector with itself:

Before applying a feature-space operation, state which axis holds the coordinates whose geometry you meant to change. F.normalize, vector_norm, cosine_similarity and softmax all take a dim, and all of them will accept the wrong one.

The diagnostic is cheap and worth building into any embedding pipeline: after normalizing, assert that torch.linalg.vector_norm(x, dim=-1) is one.

A raw score is not a geometric distance

The third misreading concerns the classifier’s own output, and it is the one most likely to reach a dashboard.

The score is s(x) = w·x + b. The signed Euclidean distance from x to the boundary is

distance = (w·x + b) / ||w||

for w ≠ 0. The difference matters because the boundary is invariant to a rescaling that the score is not. Multiplying both w and b by 100 leaves the set {x : w·x + b = 0} completely unchanged:

w, b
   ||w||     1.5264
   scores    [0.1, 4.8, -3.1, -0.2]
   distances [0.0655, 3.1446, -2.0309, -0.131]
   predicted [1, 1, -1, -1]

100w, 100b
   ||w||     152.6434
   scores    [10.0, 480.0, -310.0, -20.0]
   distances [0.0655, 3.1446, -2.0309, -0.131]
   predicted [1, 1, -1, -1]

predictions identical: True
scores ratio         : [99.99993, 99.99999, 99.99999, 100.0]
distances identical  : True

Same boundary, same predictions, every score a hundred times larger. So the common move of ranking examples by abs(score) and calling the result “distance from the boundary” measures a quantity that depends on an arbitrary parameter scaling:

max abs 'distance' claimed  : 4.8 then 480.0
true max geometric distance : 3.1446 then 3.1446

The ordering survives here, which is exactly why the bug persists — for a single fixed model, ranking by abs(score) and ranking by distance agree, so nothing looks broken. It stops being harmless as soon as the number is compared across models, across training runs, against a fixed confidence threshold, or against a calibration curve fitted at another parameter scale. Two models with identical decision boundaries can report scores differing by any factor you like.

distance = scores / torch.linalg.vector_norm(model.w)

Before interpreting a number, ask whether it is invariant to things that should not matter. A model score is not; a geometric distance is.

Coordinates and weights only mean something together

There is one more invariance, and it disposes of a very popular claim.

Take a trained linear model, multiply one feature by 100, and divide the corresponding weight by 100:

Xs = X.clone();  Xs[:, 3] *= 100
ws = w.clone();  ws[3] /= 100
original w[3]        : 0.387591
adjusted w[3]        : 0.00387591
max abs score change : 2.384185791015625e-07
scores identical     : True
x[0,3] * w[3]        : -0.659439  ->  -0.659439

Every score is preserved, because the product x_j · w_j is what the model uses and only the split between the two factors changed. The same model, expressed in a different coordinate system. And therefore:

|w| original: [0.7121, 0.2718, 0.7974, 0.3876, 1.1464, ...]
|w| rescaled: [0.7121, 0.2718, 0.7974, 0.0039, 1.1464, ...]

Coordinate 3 went from the fourth-largest coefficient to the smallest, and the model did not change at all.

A coefficient magnitude is not a property of the model alone. It is a property of the model and the units its coordinates are measured in.

This is the feature-space analogue of Chapter 7’s value-scale contract. There, a float32 tensor did not record whether its values lived in [0,1] or [0,255], and Normalize could not check. Here, a weight vector does not record what units its coordinates are in, and no assertion can recover them.

What badly scaled coordinates do to a real model

The invariance above is exact, so it might look harmless. It is not, because training is not invariant: regularization penalizes ||w|| in whatever coordinate system you hand it.

Two identical datasets, except that coordinate 99 — pure noise, irrelevant to the label — is multiplied by 1000. Same seed, same architecture, same objective:

unit scale
   held-out accuracy    : 0.9337
   w[99]                : -0.025984
   mean |x_j * w_j| j=99: 0.0210
   mean |x_j * w_j| j=7 : 0.4685
   largest contributor  : coord 7 at 0.4685

feature 99 x1000
   held-out accuracy    : 0.6862
   w[99]                : 0.001156
   mean |x_j * w_j| j=99: 0.9328
   mean |x_j * w_j| j=7 : 0.4572
   largest contributor  : coord 99 at 0.9328

Accuracy falls from 0.934 to 0.686. Look at how it happened. The model did shrink w[99], from -0.026 to 0.0012 — more than twentyfold. It was not nearly enough. The contribution |x₉₉ · w₉₉| rose to 0.93, making a noise coordinate the largest single term in the score, larger than the genuinely informative coordinate 7.

The mechanism is more direct. Once coordinate 99 is 1000× larger, the model can obtain a given score contribution x₉₉w₉₉ with a much smaller w₉₉. The L2 term penalizes w₉₉², not the contribution x₉₉w₉₉, so that large-scale coordinate becomes comparatively cheap to use under the regularizer. The optimization dynamics change too. The feature’s units have altered the objective’s trade-off even though the feature contains no more information.

Standardizing the coordinates using statistics from the training split recovers it:

held-out accuracy   : 0.9312
cosine(w, true_w)   : 0.9750
top-|w| coordinates : [7, 42, 81, 97]

Fit those statistics on training data only — that is Chapter 7’s rule, and it applies unchanged to any preprocessing state estimated from data.

Before interpreting distances, margins or coefficients, look at the coordinate scales. A feature space is only as meaningful as its units.

Margin: which separating hyperplane?

Everything so far describes a linear classifier. Nothing so far says which one to pick.

If the classes are separable, many hyperplanes separate them. Some pass within a hair of the nearest examples; others leave a wide corridor. That question — which separator — is what earns the support vector machine, and it is answered by maximizing the margin: the width of the empty corridor around the boundary.

Two conventions make this tractable. First, labels are y ∈ {-1, +1} rather than {0, 1}, so that a single product captures both correctness and confidence:

y · s(x)

is positive exactly when the sign of the score matches the label, and its magnitude says how far into the correct region the point sits.

Second, the canonical SVM parameterization places the two margin surfaces at w·x + b = ±1, while hinge loss penalizes examples with y·(w·x+b) < 1. This ties the otherwise arbitrary scale of the score to the data: simply shrinking w and b would widen the ±1 surfaces geometrically, but it would also push more examples inside the margin and increase the hinge term. Under this scaling the corridor has geometric width 2/||w||, so reducing ||w|| favors a wider margin while the hinge term enforces the competing data constraint.

The three regions:

Hinge loss

The loss that expresses this is max(0, 1 - y·s), and a table beats a paragraph:

hinge = torch.relu(1 - y * scores)
   y    score   y*score   hinge   region
   1      2.0       2.0     0.0   beyond margin
      1      1.0       1.0     0.0   on margin
   1      0.4       0.4     0.6   inside margin
   1     -0.5      -0.5     1.5   wrong side
  -1     -2.0       2.0     0.0   beyond margin
  -1     -0.3       0.3     0.7   inside margin
  -1      1.1      -1.1     2.1   wrong side

Two properties are worth naming. The loss is zero at and beyond the canonical margin — once y·s >= 1, pushing the point farther away does not reduce hinge loss further. Cross-entropy behaves differently: for a correctly classified example it continues to decrease as the correct-class logit margin grows, although it approaches zero. Hinge loss instead grows linearly with the amount by which the margin constraint is violated.

The full objective used in this chapter:

loss = 0.5 * model.w.square().sum() + C * torch.relu(1 - y * scores).mean()

C trades the two terms off: larger C weights the data-fitting term, smaller C weights the margin. State the convention rather than trusting it to transfer — implementations differ in whether the hinge term is a sum or a mean, and in how the regularizer is scaled, so a given numeric C does not have the same meaning across libraries.

This chapter derives a working linear SVM from that objective and stops there. Kernel methods are the other famous half of the SVM story, and they are deliberately out of scope: a kernel makes a feature space implicit, and this chapter’s entire purpose is to make an explicit high-dimensional representation inspectable.

Building the model

class LinearSVM(nn.Module):
    def __init__(self, features):
        super().__init__()
        self.w = nn.Parameter(torch.zeros(features))
        self.b = nn.Parameter(torch.zeros(()))

    def forward(self, x):
        return x @ self.w + self.b
named parameters: [('w', (100,)), ('b', ())]
[32,100] -> (32,)
[100]    -> ()

Nothing here is new machinery. nn.Parameter is Chapter 5’s declaration that these tensors belong to the model and should reach the optimizer. x @ self.w is Chapter 2’s matrix-vector product, mapping [B, D] @ [D] -> [B]. The scalar bias broadcasts across the batch. Autograd will record the whole thing exactly as Chapter 3 described.

The training loop is Chapter 1’s loop with a different loss:

Chapter 1:   parameter → prediction → loss → backward → update
Chapter 9:   w, b      → projection score → hinge objective → backward → update

And the same class serves any feature dimension:

LinearSVM(2   ) params=3     [32,2]   -> (32,)
LinearSVM(100 ) params=101   [32,100] -> (32,)
LinearSVM(768 ) params=769   [32,768] -> (32,)

Validate where the answer can be seen

Two Gaussian clouds in two dimensions, 400 points, so the learned boundary can be checked against geometry rather than trusted.

neg = torch.randn(200, 2) * 0.7 + torch.tensor([-2.0, -1.5])
pos = torch.randn(200, 2) * 0.7 + torch.tensor([ 2.0,  1.5])
X = torch.cat([neg, pos])
y = torch.cat([-torch.ones(200), torch.ones(200)])
step=0000 loss=1.0000 hinge=1.0000 |w|=0.0505 acc=0.500
step=0250 loss=0.1643 hinge=0.0532 |w|=0.4713 acc=1.000
step=0500 loss=0.1643 hinge=0.0533 |w|=0.4711 acc=1.000
step=0999 loss=0.1643 hinge=0.0533 |w|=0.4711 acc=1.000

learned w: [0.373, 0.2878]   b: 0.0108
||w||: 0.4711
accuracy: 1.0
margin width 2/||w||: 4.2451

Then check the geometry rather than the accuracy. Two points constructed to lie on the boundary should score zero, and their difference should be orthogonal to w:

boundary points score: -0.0  -0.0
w . (xa - xb): -0.0

The derivation from earlier holds for the learned parameters. Note also what the loss did: it stopped falling at 0.1643 with a nonzero hinge of 0.053 while accuracy was already 1.000. Perfect classification is not zero hinge loss, because points inside the canonical margin still contribute. The objective is still trading margin against violations after every point is on the correct side.

Recovering a direction in a space you cannot draw

Now the experiment that makes the chapter’s claim testable. Construct data whose labels genuinely depend on a known direction in 100-dimensional space, train without ever showing the model that direction, and measure how close it got.

X = torch.randn(4000, 100)
true_w = torch.zeros(100)
true_w[7] = 2.0;  true_w[42] = -1.5;  true_w[81] = 0.8
y = torch.where(X @ true_w + 0.15 * torch.randn(4000) >= 0, 1.0, -1.0)

Ninety-seven of the hundred coordinates are pure noise. Three carry the signal. Split 3000/1000 so the result is measured on data the model never optimized against:

train (3000, 100)  test (1000, 100)
class balance train: 0.491

step=0000 loss=2.0000 hinge=1.0000 |w|=0.5000 acc=0.491
step=0300 loss=1.2008 hinge=0.4267 |w|=0.8334 acc=0.948
step=0900 loss=1.2012 hinge=0.4266 |w|=0.8358 acc=0.947
step=1499 loss=1.2012 hinge=0.4275 |w|=0.8314 acc=0.948

Accuracy alone would be a weak result — it says the model classifies well, not that it found the right direction. So compare the geometry directly:

train accuracy      : 0.9473
held-out accuracy   : 0.9490
||w_learned||       : 0.8314
||w_true||          : 2.6249
cosine(w, true_w)   : 0.9833
angle (degrees)     : 10.50
cosine(random, true): -0.0649   (baseline)

top-|w| coordinates : [7, 42, 81, 5, 10, 93]
their values        : [0.6208, -0.4514, 0.2837, -0.0365, 0.0324, -0.0318]
true nonzero coords : [7, 42, 81] values [2.0, -1.5, 0.8]

Read those two blocks carefully, because they say different things.

The direction was recovered: cosine 0.983, an angle of about ten degrees, while one seeded random direction gave cosine -0.065. The magnitude did not match: ||w|| came out at 0.83 against a true norm of 2.62. That mismatch is expected here. The binary labels primarily identify which side of the latent boundary examples occupy, while the SVM objective chooses its own parameter scale through the hinge/regularization trade-off. Cosine similarity is therefore the useful recovery measure: we want the learned separator to align with the planted direction, not to reproduce its raw coefficient scale.

The top three coefficients are exactly the three planted coordinates, in the right order, with the right signs, and the ratios roughly track 2.0 : -1.5 : 0.8. That is a satisfying result and it is worth stating what makes it possible: the coordinates here are independent, identically scaled, and only three of them matter. The previous section already showed what happens to that ranking when one coordinate is rescaled, and correlated coordinates would blur it further. A coefficient ranking is a hypothesis about which coordinates matter, and it is trustworthy in proportion to how well you understand the coordinate system.

Margin analysis

The learned boundary can be interrogated with the three regions defined earlier, on held-out data:

misclassified   (y*s < 0)  :    51 (0.051)
inside margin   (0<=y*s<1) :   728 (0.728)
beyond margin   (y*s >= 1) :   221 (0.221)
signed distance  min/mean/max: -3.205 -0.095 3.345
closest to canonical margin, y*score: [0.999, 1.0035, 1.0038, 1.0041, 1.0054]

Nearly three quarters of held-out points sit inside the canonical margin under this C. That is a fact about the trade-off chosen, not a defect: a smaller C favors a wider margin and admits more violations.

A note on vocabulary, because it is commonly abused. Those points at y·s ≈ 1 are the ones sitting on the canonical margin surfaces, and it is fair to call them margin-constraining candidates. Calling them support vectors in the strict sense is a claim about the solution of the SVM optimization problem, and this model was fitted with gradient descent on a soft-margin objective for a fixed number of steps. Describe what was measured.

The feature-space inspector

Chapter 7 had a stage report. Chapter 8 had a shape ledger. This chapter’s equivalent makes the geometry of a [B, D] representation observable, because none of the failures above show up in a shape.

def inspect(X, name="X", model=None, y=None):
    assert X.ndim == 2, f"{name}: expected [B, D], got {tuple(X.shape)}"
    X = X.detach()
    B, D = X.shape
    norms = torch.linalg.vector_norm(X, dim=1)
    std = X.std(dim=0, correction=0)

    print(f"  {name}: shape={tuple(X.shape)} dtype={X.dtype} D={D}")
    print(f"    finite                 : {bool(torch.isfinite(X).all())}"
          f"  (non-finite {int((~torch.isfinite(X)).sum())})")
    print(f"    row norm  min/mean/max : {norms.min():.4g} {norms.mean():.4g} {norms.max():.4g}")
    print(f"    feature std min/med/max: {std.min():.4g} {std.median():.4g} {std.max():.4g}")
    print(f"    widest coord {int(std.argmax()):<4} scale ratio "
          f"{(std.max()/std.min().clamp_min(1e-30)):.4g}x")

    s = F.normalize(X[:64], dim=1)
    cos = s @ s.T
    mask = ~torch.eye(len(s), dtype=torch.bool, device=cos.device)
    off = cos[mask]
    if off.numel():
        print(f"    pairwise cosine (64)   : mean {off.mean():.4f}  max {off.max():.4f}")

    if model is not None:
        with torch.no_grad():
            sc = model(X)
            wn = model.w.norm()
            print(f"    ||w|| {wn.item():.4f}  b {model.b.item():.4f}")
            print(f"    score    min/mean/max : {sc.min():.4f} {sc.mean():.4f} {sc.max():.4f}")
            if wn.item() == 0.0:
                print("    distance              : undefined (||w|| = 0)")
            else:
                d = sc / wn
                print(f"    distance min/mean/max : {d.min():.4f} {d.mean():.4f} {d.max():.4f}")
            if y is not None:
                sg = y * sc
                print(f"    wrong side {(sg<0).float().mean():.3f}"
                      f"  inside margin {((sg>=0)&(sg<1)).float().mean():.3f}"
                      f"  at/beyond {(sg>=1).float().mean():.3f}")

A healthy representation and the badly scaled one, side by side:

healthy: shape=(2200, 100) dtype=torch.float32 D=100
  finite                 : True  (non-finite 0)
  row norm  min/mean/max : 7.863 9.989 12.66
  feature std min/med/max: 0.9674 1.002 1.034
  widest coord 60   scale ratio 1.069x
  pairwise cosine (64)   : mean -0.0021  max 0.2931

one coordinate x1000: shape=(2200, 100) dtype=torch.float32 D=100
  finite                 : True  (non-finite 0)
  row norm  min/mean/max : 9.129 807.4 3385
  feature std min/med/max: 0.9674 1.002 1014
  widest coord 99   scale ratio 1048x
  pairwise cosine (64)   : mean 0.0086  max 1.0000

Three rows name the problem. The scale ratio is 1048×. The widest coordinate is identified as 99. And the maximum pairwise cosine has gone to 1.0000 — two vectors now look identical in direction, because one enormous shared coordinate dominates the geometry of every vector in the set.

It catches two other representation failures that produce perfectly legal tensors:

collapsed to near-zero norm: shape=(200, 64) D=64
  row norm  min/mean/max : 5.707e-08 8.005e-08 9.949e-08
  pairwise cosine (64)   : mean 0.0014  max 0.4460

collapsed to one direction: shape=(200, 64) D=64
  row norm  min/mean/max : 7.576 7.579 7.582
  pairwise cosine (64)   : mean 1.0000  max 1.0000

The first has vanished into numerical noise; the row norms say so. The second has healthy norms and every pair at cosine 1.0000 — 200 vectors that are all effectively the same vector. A downstream classifier fed either of these will train, produce finite losses, and learn nothing, and neither condition is visible in a shape or a dtype.

With a classifier attached:

held-out with classifier: shape=(800, 100) D=100
  ||w|| 0.8238  b -0.0233
  score    min/mean/max : -2.4186 -0.0246 2.2989
  distance min/mean/max : -2.9358 -0.0299 2.7904
  wrong side 0.066  inside margin 0.716  beyond 0.218

Score and distance are reported separately and deliberately, because the previous sections established that they are different quantities.

The questions this report answers, which are the questions worth asking when feature-space behavior looks strange:

Is D what the model expects?
Are the vector norms exploding, or collapsed to zero?
Is one coordinate scaled a thousand times larger than the rest?
Has the representation collapsed so that every vector points the same way?
Are the classifier's scores saturated on one side?
How much of the data sits inside the margin?

More coordinates is not more expressive power

It is tempting to conclude that a large D makes problems easy. It does not, and the cleanest counterexample is the smallest one.

XOR in its raw two coordinates:

raw 2 coordinates:
   w = [0.0, 0.0]   b = 0.0
   scores   : [0.0, 0.0, 0.0, 0.0]
   accuracy : 0.5

The model converged to the zero vector, which is the honest answer: no hyperplane separates these four points, so the regularizer wins. The impossibility is two lines of algebra, no training required:

need  s(0,0) < 0,  s(0,1) > 0,  s(1,0) > 0,  s(1,1) < 0

s(0,1) + s(1,0) = w₁ + w₂ + 2b
s(0,0) + s(1,1) = w₁ + w₂ + 2b

the two sums are identical, so they cannot have opposite signs

Now add coordinates. First, 998 random ones:

XOR padded with 998 random coordinates (D=1000):
   train accuracy on those 4 rows      : 1.0
   accuracy on same 4 inputs, new noise: 0.485

Perfect training accuracy at D=1000. With hundreds of independent continuous noise coordinates, these four sampled rows are almost surely in general position and are easy for a hyperplane to separate. That is not a theorem that every four labeled points in every high-dimensional dataset are separable — duplicates with conflicting labels are an obvious counterexample. Present these same four logical inputs with fresh noise coordinates and accuracy falls to chance. The model separated four sampled rows, not the XOR function. Dimensionality bought memorization-friendly separability of this training set and nothing else.

Then add one coordinate that is a function of the others, x₁·x₂:

with one extra coordinate x1*x2 (D=3):
   features : [[0,0,0], [0,1,0], [1,0,0], [1,1,1]]
   scores   : [-0.9958, 1.0034, 1.0034, -0.9942]
   accuracy : 1.0

Three coordinates, perfect separation, and it generalizes because the added coordinate encodes something true about the problem. The difference between D=1000 and D=3 here is not size. It is that one representation contains the interaction the task depends on and the other contains noise.

A linear model learns one hyperplane regardless of D. What changes the difficulty is the representation the hyperplane operates in.

This is precisely what the earlier chapters’ networks were doing. A CNN body is a learned transformation into a representation where a single nn.Linear suffices — which is why Chapter 8’s classifier could be nn.Linear(32, 10). The convolutional stack did the work of making the decision linear.

Embeddings, and what a linear probe establishes

The same reasoning applies to representations produced by a model you did not train. Text, audio and images are routinely mapped to vectors:

raw input  →  encoder  →  [B, 768]  →  linear classifier  →  score

and the classifier neither knows nor cares where the 768 numbers came from. Two cautions apply, and both are about overreading.

Coordinates are usually not concepts. The picture where dimension 0 is clarity and dimension 1 is humor is almost never how learned representations work. A property may correspond to a direction, a subspace, or a nonlinear region rather than one axis, which is exactly why this chapter emphasizes the learned direction w over any individual coefficient. The 100-dimensional experiment recovered the direction at cosine 0.983 — and that was a synthetic problem constructed to have three meaningful axes.

A successful probe is bounded evidence. Training a linear classifier on frozen embeddings and reaching high held-out accuracy establishes something real: information sufficient for that linear decision is linearly accessible in that representation, under that training setup. It does not establish that the upstream model represents the concept in any human-like way, that any single coordinate encodes it, that the concept is causally used by the upstream model when it does its own job, or that the representation is disentangled. Report the first claim; the others need different experiments and are a research area, not a corollary.

Many vectors per example

Return to the opening shape, now with the vocabulary to read it.

[8, 128, 768]

8     sequences
128   positions per sequence
768   coordinates describing the representation at each position

That tensor holds 1,024 points in one 768-dimensional space, organized by which sequence and which position produced them. x[0, 17] is one such point, shape [768], and everything this chapter established applies to it: it has a norm, it makes an angle with other vectors, a linear layer scores it along a direction.

The operations behave accordingly. nn.Linear(768, 16) maps [8, 128, 768] -> [8, 128, 16], transforming all 1,024 representations independently. F.normalize(x, dim=-1) makes all 1,024 unit length. And a dot product between two of those vectors asks how much they agree in direction, scaled by both norms — the decomposition from earlier in this chapter, which is the operation with the most consequence still ahead of it.

Using AI on feature-space code

An assistant reading feature-space code sees shapes and operations, both of which are usually correct in exactly the failures this chapter covers. What it cannot see is what one vector represents, because that is not in the code. So the prompt has to force the labeling before the interpretation.

Here is a PyTorch tensor and the code that operates on it.
Do not rewrite the code and do not propose fixes yet.

1. Label every tensor axis semantically. Say which axes are structure
   (how representations are organized) and which axis holds the coordinates
   of one represented object.
2. State plainly what ONE represented object is in this tensor.
3. For every Linear, matmul, dot product or norm:
     - write the input shapes;
     - write the mathematical operation;
     - derive the output shape;
     - state which leading axes are preserved and what that means here.
4. For every call taking a `dim` argument (normalize, softmax, vector_norm,
   cosine_similarity, mean), state which objects that dim makes the operation
   act on, and which vectors become unit norm if it is a normalization.
5. For every number the code eventually reports, classify it as one of:
     raw dot product / cosine similarity / classifier score /
     signed geometric distance / Euclidean distance
   and say what it is invariant to and what it is not.
6. Identify the FIRST place where an operation acts on a different object or
   a different axis than the surrounding code appears to intend.

Only after identifying that divergence should you propose a repair.

Step 5 is the one that earns its place. An assistant will happily call x @ y a similarity score, and so will most of the code it learned from. Asking what a number is invariant to is a question with a checkable answer, and it is the question that separates a score from a distance.

There is a second question worth asking whenever a coefficient interpretation is on the table:

Here are the per-feature standard deviations of my training data and the
learned weight vector. Tell me what I can and cannot conclude about which
features matter, given these scales. Do not rank the features yet.

Ask AI to identify the represented object and the feature axis before asking it to interpret the numbers.

The feature-space debugging sequence

1.  Print the shape and name every axis. Which are structure, which holds
    the coordinates of one object?

2.  Say out loud what ONE vector represents: a token, a position, an image,
    a whole sequence, a user, a document.

3.  Count the represented objects. If a per-object operation returns more
    numbers than you expected, this is where it went wrong.

4.  Identify D and confirm it matches what the layer was built for.

5.  Run the inspector: norms, per-coordinate scales, non-finite values,
    pairwise cosine. Look for collapse and for one dominant coordinate.

6.  For every `dim=` argument, state which objects that operation acts on.
    Assert the invariant afterwards: unit norms along dim=-1, and so on.

7.  Name the quantity you are about to interpret:
      dot product          not invariant to either norm
      cosine similarity    invariant to both norms, direction only
      Euclidean distance   displacement magnitude
      classifier score     not invariant to parameter rescaling
      signed distance      score / ||w||, invariant to parameter rescaling

8.  If a classifier is involved, report ||w||, scores and distances
    separately, and the fractions wrong-side / inside-margin / beyond.

9.  Before ranking features by coefficient, check the coordinate scales.

10. Change one representation assumption at a time and re-measure.

Steps 1 through 3 catch the opening failure. Steps 5 through 7 catch everything else in this chapter.

Do not try to visualize the space. Interrogate the quantities that define its geometry.

Exercises

These are written to be run, and they map onto the notebook that accompanies this chapter.

  1. The opening failure. Reproduce [8, 128, 768] -> [8, 128, 1], then verify one output value by hand from weight[0] and bias[0]. Produce [8, 1] three different ways and show that the three sets of scores differ.

  2. The last-axis rule. Send tensors of rank 1, 2, 3 and 4 through one nn.Linear and predict every output shape before running. Then explain why flatten(1) on [B, T, D] is not an alternative route to a per-sequence score.

  3. Three quantities. Construct two vectors and report their dot product, cosine similarity and Euclidean distance. Then find a third vector that is the nearest by cosine and the furthest by Euclidean distance, and one where the ranking reverses.

  4. Scale and angle. Fix an angle between two vectors and scale one of them over four orders of magnitude. Plot or tabulate the dot product and the cosine. Then normalize both and show the dot product equals the cosine.

  5. The wrong axis. Build a small deterministic [B, T, D] tensor and normalize it along dim=1 and dim=-1. For each, report which vectors have unit norm. Then write the assertion that would have caught the mistake.

  6. Score versus distance. Train any linear classifier, then multiply w and b by 100. Verify predictions are identical, scores scale by 100, and geometric distances do not move. Find a downstream use of the score where this matters and one where it does not.

  7. The coordinate system. Multiply one feature by 100 and divide its weight by 100. Confirm the scores are unchanged and the coefficient ranking is not. Then train two models on the scaled and unscaled data and compare held-out accuracy.

  8. Hinge by hand. Compute the hinge table for at least eight (y, score) pairs and classify each into the three margin regions. Then train to perfect accuracy and explain why the hinge loss is still nonzero.

  9. Recover a direction. Plant a known direction in D=100, train on a proper split, and report held-out accuracy, cosine similarity to the true direction, the angle, and a random-vector baseline. Then increase the noise, shrink the training set, and vary C, and record how each affects the cosine rather than the accuracy.

  10. Build the inspector. Write it, then run it on four representations: healthy, one coordinate scaled by 1000, collapsed to near-zero norm, and collapsed to a single direction. For each, name the row that identifies the problem.

  11. Representation, not dimension. Show XOR is unseparable in its two raw coordinates, both empirically and algebraically. Then separate it by adding one derived coordinate, and separately by padding to D=1000 with noise. Explain why only one of those generalizes.

Next: comparing vectors with each other

The grid is gone and the feature space is no longer abstract. One object is a list of coordinates; a direction is another list of the same length; a dot product between them is a score, and its sign says which side of a hyperplane the object falls on. Dividing by ||w|| turns that score into a geometric distance. Normalizing both vectors turns a dot product into a cosine. None of that required a picture, and all of it is checkable with numbers you can print.

The three misreadings in this chapter are worth carrying forward together, because they share a structure. A dot product was called a similarity when it was also measuring magnitude. A score was called a distance when it was not invariant to parameter scaling. A coefficient was called an importance when it was a coefficient in unstated units. In every case the code ran, the shapes were right, and the number meant something other than its name.

Name the represented object. Find the feature axis. Derive the operation. Then interpret the score — and say what that score is invariant to.

Notice which operation this chapter kept returning to. A linear classifier compares one vector against one learned direction. But w was never special: it is a vector in the same space as x, and the dot product does not care where either came from. Nothing stops one vector in a batch from being scored against another vector in the same batch.

That is exactly the shape we already have:

[B, T, D]     B sequences, T positions, D coordinates per position

T vectors per sequence, all living in the same D-dimensional space, any one of which can be compared with any other by the operation this chapter just made concrete. Doing that for every pair of positions produces a T × T grid of scores. Their magnitude depends on the vector norms and, when component scale is held roughly fixed as D grows, can also grow systematically with the feature dimension. The next chapter will make that scaling question explicit.

The next chapter takes that seriously. It asks what happens when every position produces vectors that are compared against vectors from every other position — how those comparisons are shaped, scaled, masked and combined, and where the tensor bookkeeping goes wrong.

Into attention.