Steering the Latent Space

Directions, paths, and dials for controlling generative AI, built one vector at a time

Sjoerd Vink

A prompt is a rough way to ask a model for something. Under every image, sound and sentence a model produces sits a vector, and a vector can be moved precisely.

This essay is about moving it on purpose. One tiny model that draws faces runs through every chapter. With it we find concepts as directions, travel between outputs, edit one trait without touching another, guide a diffusion model, steer a language model, and learn what a person wants from nothing but their choices.

  1. 01 Latent space
  2. 02 Directions
  3. 03 Paths
  4. 04 Independence
  5. 05 Guidance
  6. 06 Steering
  7. 07 Feedback
z = torch.randn(2)face = decoder(z)ticks = torch.linspace(-2.4, 2.4, 7)faces = decoder(torch.cartesian_prod(ticks, ticks))
z1z2gz = [−1.6, −1.2]smile−0.92eyes0.41

01 Latent space

Every output has an address.

A generative model is a function. It takes a short list of numbers zz and turns it into something: an image, a sound, a sentence. The list is called a latent vector, and the set of every list the model accepts is its latent space.

Our model is as small as it gets. Its latent vector has two numbers, so every zz is a point in a plane, and its decoder gg draws a face. The red point is zz, and the face on the right is g(z)g(z).

Real models work the same way with more numbers: 512 for a face GAN, and 16,384 for the noise that Stable Diffusion turns into an image. Nobody chose what these numbers mean. The model invented them while learning to rebuild its training data, which is why they are called latent, or hidden. The autoencoder essay shows how such a space is learned.

Draw the face at every point of a seven by seven grid and you get an atlas of everything this model can make. Frowns are in the lower left, grins in the upper right, and the faces in between change a little at a time.

That smoothness is what makes control possible at all. A small step in zz gives a small change in the output, so getting the output you want comes down to getting to the right place in the space.

The axes, however, are no help. Walk to the right along z1z_1 and the face starts smiling but also closes its eyes. Walk up along z2z_2 and the eyes open while the smile grows a little. Each axis mixes both traits, and researchers call such a space entangled.

The model has no reason to do better. Training only asks it to reproduce the data, and a rotated copy of the plane, with the decoder rotated to match, would reproduce the data just as well. Nothing pushes the axes to line up with the words a person would use.

Try it. Drag the point around the square and watch which way you have to move to change only the smile. It is a diagonal, and you have to find it by feel.

The model has its own coordinates. To control it, we have to find our concepts inside them.

If no axis means smile, where in the space does smiling live?

v = Z[smiling].mean(0) - Z[~smiling].mean(0)z_edit = z + alpha * vscore = Z @ v / np.linalg.norm(v)
vnot smiling (17)smiling (23)labelled by a personthe face at each meannot smilingsmilingα = 0.00smile−0.62eyes0.75−202smile score

02 Directions

A concept is a direction.

Start with examples. Here are 40 faces from the model, each a point in the latent space, and a person has labelled each one: 23 smiling, filled, and 17 not smiling, hollow.

The two groups overlap, but the smiling ones lean to the right. With a real image model you would get these points by encoding labelled photos, or by keeping the zz behind each generated image.

Average each group. The two crosses are the means, and the arrow from one to the other is the smile direction:

v=μsmiling−μnot=[1.15, 0.10]−[−1.40, 0.04]=[2.55, 0.06]\begin{aligned} v &= \mu_{\text{smiling}} - \mu_{\text{not}} \\ &= [1.15,\ 0.10] - [-1.40,\ 0.04] = [2.55,\ 0.06] \end{aligned}

That is the whole method. There is nothing to train, just two averages and a subtraction. This difference of means turns up everywhere, from word vectors to steering language models.

To edit a face, add the direction to its latent vector and decode again:

zedit=z+α vz_{\text{edit}} = z + \alpha\, v

The hollow red point is a frowning face with smile −0.62. One step of vv takes it to 0.79. The same vv works on every face, which is the real claim: a concept is a single direction, the same everywhere in the space.

This is the old word vector result, king−man+woman≈queen\text{king} - \text{man} + \text{woman} \approx \text{queen}, and the DCGAN demo where a smiling woman, minus a neutral woman, plus a neutral man gave a smiling man.

Now α\alpha is a dial. Turn it up and the face smiles more, turn it below zero and it frowns. Many sliders in face editing tools are a vector like this one with its own α\alpha.

Watch the eyes, though. As the smile grows they close, from 0.75 to 0.30 at α=1\alpha = 1. We asked for a smile and got a squint as well. Chapter 4 is about why.

A direction can also be read. Project any point onto the line through vv, by dropping a perpendicular, and the position along the line is a smile score:

score⁡(z)=z⋅v^=z⋅v∥v∥\operatorname{score}(z) = z \cdot \hat{v} = \frac{z \cdot v}{\lVert v \rVert}

On the right, every face is placed by its score. Smiling faces score high and the rest score low, with a little overlap in the middle. A single dot product says how much smile a point contains.

One vector does two jobs. Adding it changes the concept, and projecting onto it measures the concept.

An edit is a straight line through the space. Is a straight line always a safe way to travel?

def lerp(a, b, t):    return (1 - t) * a + t * bdef slerp(a, b, t):    cos = a @ b / (np.linalg.norm(a) * np.linalg.norm(b))    omega = np.arccos(cos)    wa = np.sin((1 - t) * omega) / np.sin(omega)    wb = np.sin(t * omega) / np.sin(omega)    return wa * a + wb * b
00.63abshell of a 2-dimensional Gaussian0.51.000.51t‖z(t)‖ / √dlerpslerp

03 Paths

In high dimensions, the straight line cuts through empty space.

The simplest path between two latent vectors aa and bb is the straight one, linear interpolation, or lerp:

z(t)=(1−t) a+t b,t∈[0,1]z(t) = (1 - t)\, a + t\, b, \qquad t \in [0, 1]

In two dimensions it works. The faces along the bottom morph smoothly from a wide-eyed frown into a squinting grin. The chart tracks the length of z(t)z(t), which dips in the middle but stays well inside the shaded band where samples usually fall.

Most generative models draw their latent vectors from a standard Gaussian, z∼N(0,I)z \sim \mathcal{N}(0, I). Its squared length is a sum of dd independent squares, each about 1 on average, so

∥z∥2=∑i=1dzi2≈d,∥z∥≈d\lVert z \rVert^2 = \sum_{i=1}^{d} z_i^2 \approx d, \qquad \lVert z \rVert \approx \sqrt{d}

and the spread around d\sqrt{d} stays near 1/21/\sqrt{2} no matter how large dd gets. With d=512d = 512, almost every sample has a length within 6 percent of 22.6. The samples live on a thin shell, and the center of the Gaussian, its single most likely point, is never drawn.

Two random samples are also nearly at right angles, so the midpoint of the straight line has length 0.71d0.71\sqrt{d}, far inside the shell. The decoder has never seen anything like it. Real models give blurry, washed-out images there, drawn here as faded faces.

The fix is to travel along the shell instead of through it. Spherical interpolation, or slerp, rotates from aa to bb along the arc between them, where Ω\Omega is the angle between the two vectors:

z(t)=sin⁡((1−t)Ω)sin⁡Ω a+sin⁡(tΩ)sin⁡Ω bz(t) = \frac{\sin\big((1 - t)\Omega\big)}{\sin \Omega}\, a + \frac{\sin(t\Omega)}{\sin \Omega}\, b

The length stays the same all the way, and every face stays sharp. Many image pipelines interpolate their starting noise this way.

The same goes for edits. A large α\alpha in z+αvz + \alpha v pushes a point off the shell, so a common trick is to rescale it back to length d\sqrt{d} afterward.

Change dd yourself. At d=2d = 2 the shell is a thick ring, and nearly any point is a plausible input. Each step up makes the shell thinner relative to its radius, and the straight line falls off it sooner.

A latent space is not a uniform box. Good control stays where the model has seen data.

We asked for a smile. Why did the eyes close?

probe = LogisticRegression().fit(Z, eyes_open)n = probe.coef_[0] / np.linalg.norm(probe.coef_[0])v_only = v - (v @ n) * nz_edit = z + alpha * v_only
neyes openvv⊥(v · n) nsmileeyesstart−0.620.75+ v0.790.30+ v⊥0.800.73v · n = −1.15

04 Independence

Remove what you did not ask for.

The edit from chapter 2 raises the smile from −0.62 to 0.79, but it also drops the eyes from 0.75 to 0.30.

The cause is the data, not the model. People who smile tend to squint, so the smiling examples also had narrower eyes. A difference of means captures every way the two groups differ, the correlations included.

Real models have the same problem on a larger scale. In face GANs, the direction that makes a face older also tends to add glasses, because older people in the training photos wear glasses more often.

To keep the eyes fixed, we first need a direction for eyes. Here it comes from a linear probe, a logistic regression trained to tell open eyes from closed ones. Its weight vector, scaled to length 1, is nn. It is perpendicular to the probe's decision boundary and points toward wider eyes.

The grey lines are perpendicular to nn. Along each line, eye openness is constant: these are its level sets. Moving along a line leaves the eyes alone, and crossing lines changes them. The smile vector vv crosses several.

Split vv into a part along nn and a part perpendicular to it:

v=(v⋅n) n⏟changes the eyes+v⊥⏟does notv = \underbrace{(v \cdot n)\, n}_{\text{changes the eyes}} + \underbrace{v_{\perp}}_{\text{does not}}

Here v⋅n=−1.15v \cdot n = -1.15, so the smile vector was hiding a component that says “close your eyes”. Removing it is a projection, the dashed red segment in the figure:

v⊥=v−(v⋅n) nv_{\perp} = v - (v \cdot n)\, n

This is one step of Gram–Schmidt, the procedure that makes vectors orthogonal to each other.

Editing with v⊥v_{\perp} moves the face along its own level line. The smile goes to 0.80, and the eyes stay at 0.73.

To hold several traits fixed at once, put their unit directions in the columns of a matrix NN, make them orthonormal, and project out all of them together:

v⊥=(I−NN⊤) vv_{\perp} = (I - N N^{\top})\, v

InterFaceGAN calls this conditional manipulation.

It only works as well as the space is flat. If the level sets curve, a direction is only right locally. That is why some methods look for better coordinates after training, and others, such as β-VAE, try to untangle the axes during training.

To change one thing, move along the lines where everything else stays the same.

A diffusion model starts from fresh noise every time, so there is no single z to edit. How do you steer something that is always moving?

eps_uncond = model(x_t, t, prompt=None)eps_cond = model(x_t, t, prompt="a smiling face")eps = eps_uncond + w * (eps_cond - eps_uncond)
frowningsmilingsamplessmiling64%mean smile0.22spread0.78w = 0.0

05 Guidance

Guidance is a direction, recomputed at every step.

A diffusion model generates by removing noise a little at a time. At each step it estimates which way the data lies, the gradient of the log density, called the score:

s(x)=∇xlog⁡p(x)s(x) = \nabla_x \log p(x)

Following the score is like walking uphill on the density. In our plane the data has two clusters, frowning and smiling faces. Each grey dot is a starting point of pure noise, each line is its path, and the green dots are where the paths end. Without guidance, 9 of 14 samples smile and the rest frown, a mix of both clusters like the data itself.

Train the same network with a prompt cc as an extra input, and it learns the score of p(x∣c)p(x \mid c). With the prompt “a smiling face” every path ends in the smiling cluster.

Bayes' rule shows what the prompt adds:

∇xlog⁡p(x∣c)=∇xlog⁡p(x)+∇xlog⁡p(c∣x)\nabla_x \log p(x \mid c) = \nabla_x \log p(x) + \nabla_x \log p(c \mid x)

The last term is the gradient of a classifier, the direction in which xx looks most like cc. It plays the same role as the smile vector of chapter 2, except that it is recomputed at every point and every step. Using a separately trained classifier for it is called classifier guidance.

Classifier-free guidance gets that direction without a classifier. The network is trained with the prompt sometimes dropped, so it can predict both with and without it. Their difference is the prompt direction, and the guidance weight ww plays the role of α\alpha:

ϵ^=ϵθ(xt)+w(ϵθ(xt,c)−ϵθ(xt))\hat{\epsilon} = \epsilon_\theta(x_t) + w \big(\epsilon_\theta(x_t, c) - \epsilon_\theta(x_t)\big)

Here ϵθ\epsilon_\theta is the predicted noise, which is the score times −σt-\sigma_t, the noise level at step tt. With w=1w = 1 this is just the conditional model. With w>1w > 1 it overshoots, sampling roughly from p(x) p(c∣x)wp(x)\, p(c \mid x)^{w}, which favors the points the classifier is most sure about.

At w=4w = 4 the mean smile rises from 0.79 to 0.93, and the spread between samples falls from 0.16 to 0.03. Every face becomes the same broad grin.

Try other weights. Image models usually run around w=7w = 7, where pictures look sharp and on prompt but start to look alike. Much higher, and colors saturate and textures go strange.

Negative prompts use the same formula with the unconditional prediction replaced by a prediction for the thing you do not want, so the sample is pushed away from it.

Guidance trades diversity for fidelity, by pushing every step along the difference between with the prompt and without it.

A language model has no latent vector you choose up front. But every layer inside it holds vectors, and the same geometry applies there.

_, cache = model.run_with_cache(prompts)acts = cache["resid_post", 12][:, -1]r = acts[harmful].mean(0) - acts[harmless].mean(0)r_hat = r / r.norm()steer = lambda h, hook: h + alpha * rablate = lambda h, hook: h - (h @ r_hat)[..., None] * r_hathook = ("blocks.12.hook_resid_post", steer)with model.hooks(fwd_hooks=[hook]):    model.generate(prompt)
refusesrharmless promptsharmful prompts02h · r̂, read out at layer 12steered promptanswersharmful refused11 of 11

06 Steering

Inside a language model, behaviours are directions too.

A Transformer keeps one vector per token, the residual stream, and every layer adds its output to it. Take the vector at the last token after layer 12, h∈Rdh \in \mathbb{R}^{d}, for two sets of prompts: harmful requests the model refuses, and harmless ones it answers.

Projected onto a plane, the two sets form two clusters. The points here are a toy, but the method is the one used on real chat models.

The recipe from chapter 2 carries over unchanged. The difference of the means is a refusal direction:

r=μharmful−μharmless,r^=r/∥r∥r = \mu_{\text{harmful}} - \mu_{\text{harmless}}, \qquad \hat{r} = r / \lVert r \rVert

Reading it works as before. The projection h⋅r^h \cdot \hat{r} says how strongly a prompt activates refusal, and past a threshold, the shaded side, the model refuses. A readout like this is a probe, and probes are being studied as monitors for things a model will not say out loud.

Arditi and colleagues found that in many open chat models, refusal is carried largely by one such direction.

Writing works as before too. Add αr\alpha r to the residual stream at every token while the model generates, and a harmless prompt is pushed over the threshold. The model refuses to give a recipe for pancakes.

h←h+α rh \leftarrow h + \alpha\, r

This is called activation steering. The same trick makes models more honest, less flattering, more cheerful, or obsessed with a single topic. It needs no retraining, and it can be switched off again at any moment. Sparse autoencoders, from the autoencoder essay, find thousands of such directions at once.

Now remove the direction instead. Subtract each vector's component along r^\hat{r}, exactly the projection from chapter 4:

h←h−(h⋅r^) r^h \leftarrow h - (h \cdot \hat{r})\, \hat{r}

Every point slides onto the line through the origin perpendicular to rr. The model can no longer represent “this should be refused”, and it refuses nothing at all.

For alignment this cuts both ways. Safety training can end up as a thin layer, a single direction that anyone with the weights can remove. The same geometry also gives us handles that are easier to read and more precise to adjust than retraining a whole model.

A direction that lets you steer a model also lets someone else steer it the other way.

So far someone always knew the direction they wanted. Often a person cannot say what they want, but they know it when they see it.

w = torch.zeros(2, requires_grad=True)opt = torch.optim.Adam([w], lr=0.05)for _ in range(500):    margin = (z_preferred - z_rejected) @ w    loss = -F.logsigmoid(margin).mean()    opt.zero_grad()    loss.backward()    opt.step()
ABThe person picks the face they prefer.hidden preferenceanswers1uncertainty±90°

07 Feedback

Every comparison cuts the possibilities in half.

Someone wants faces that smile with their eyes open. They could not write that down as a vector ww, but they can look at two faces and pick one.

Assume their taste is linear, a score r(z)=w⋅zr(z) = w \cdot z. Then picking A over B says

w⋅zA>w⋅zB  ⟺  w⋅(zA−zB)>0w \cdot z_A > w \cdot z_B \iff w \cdot (z_A - z_B) > 0

That is a half-plane. Every direction ww on the wrong side of the line through the origin perpendicular to zA−zBz_A - z_B is ruled out. On the dial of all possible directions, one answer removes half the circle.

The next pair is chosen so that its line splits what is left exactly in half. After 5 answers the uncertainty has shrunk from ±180° to ±6°, a factor of 32. The face in the middle of the dial is the model's current best guess.

This is binary search over directions. Choosing the most informative question is called active learning. Real answers are noisy and real spaces have many more dimensions, so it takes more questions, but each answer still removes a half-space.

Your turn. Think of the face you would like to see, then keep pressing A or B for the one that is closer. The face in the middle comes to match what you have in mind, although you never said what that was.

This is human–AI interaction at its simplest. The person only has to recognise what they like, and the geometry turns their choices into a direction.

People are not perfectly consistent, so real systems use a soft version, the Bradley–Terry model:

P(A≻B)=σ(r(zA)−r(zB))P(A \succ B) = \sigma\big(r(z_A) - r(z_B)\big)

Fitting ww to many comparisons with this likelihood is the code above. Replace zzwith a language model's last hidden state and you have the reward model of RLHF. Reinforcement learning then shifts the model so its outputs score higher, like guidance that is built into the weights.

Push too far and the outputs leave the region where the reward model was fitted, the same mistake as leaving the shell in chapter 3. The reward keeps rising while real quality falls, a failure called reward hacking. RLHF therefore keeps the new model close to the original:

max⁡π  Ey∼π[r(y)]−β KL(π ∥ πref)\max_{\pi}\; \mathbb{E}_{y \sim \pi}\big[r(y)\big] - \beta\, \mathrm{KL}\big(\pi \,\Vert\, \pi_{\text{ref}}\big)

Comparisons reveal a direction, and alignment is the craft of following it without going too far.

Field guide

Reading papers on latent control

The symbols that appear most often in papers on editing, guiding and steering generative models, with what they mean and how they look in PyTorch.

z∈Rdz \in \mathbb{R}^{d}
a latent vector
z = torch.randn(d)
g(z)g(z)
the generator or decoder
decoder(z)
z∼N(0,I)z \sim \mathcal{N}(0, I)
the standard Gaussian prior
torch.randn(n, d)
∥z∥≈d\lVert z \rVert \approx \sqrt{d}
Gaussian samples live on a thin shell
z.norm() / d ** 0.5
v=μ+−μ−v = \mu_{+} - \mu_{-}
a concept direction from a difference of means
Z[pos].mean(0) - Z[neg].mean(0)
z+αvz + \alpha v
an edit along a direction
z + alpha * v
z⋅v^z \cdot \hat{v}
projection, the concept score
z @ (v / v.norm())
v−(v⋅n) nv - (v \cdot n)\, n
remove the component along a unit vector n
v - (v @ n) * n
(I−NN⊤) v(I - N N^{\top})\, v
remove a whole subspace
v - N @ (N.T @ v)
slerp⁡(a,b,t)\operatorname{slerp}(a, b, t)
interpolation along the arc
slerp(a, b, t)
∇xlog⁡p(x)\nabla_x \log p(x)
the score, which way the data lies
-eps / sigma
ϵθ(xt,t,c)\epsilon_\theta(x_t, t, c)
a diffusion model's noise prediction
model(x_t, t, prompt)
ϵu+w(ϵc−ϵu)\epsilon_u + w(\epsilon_c - \epsilon_u)
classifier-free guidance
guidance_scale=w
h(l)h^{(l)}
the residual stream after layer l
cache["resid_post", l]
h−(h⋅r^) r^h - (h \cdot \hat{r})\, \hat{r}
directional ablation
h - (h @ r_hat)[..., None] * r_hat
σ(r(A)−r(B))\sigma\big(r(A) - r(B)\big)
Bradley–Terry preference probability
torch.sigmoid(r_a - r_b)
KL(π∥πref)\mathrm{KL}(\pi \Vert \pi_{\text{ref}})
how far a tuned model has drifted
(logp - logp_ref).mean()
Further reading

Where to go next

Roughly in the order of the chapters, from word vectors and face editing to guidance, steering and learning from preferences.

  1. Mikolov, Yih and Zweig, 2013

    Linguistic Regularities in Continuous Space Word Representations

    King minus man plus woman: concepts as directions, in words.

  2. Radford, Metz and Chintala, 2016

    Unsupervised Representation Learning with Deep Convolutional Generative Adversarial Networks

    DCGAN, and the same vector arithmetic on faces.

  3. White, 2016

    Sampling Generative Networks

    Why lerp leaves the shell, and slerp as the fix.

  4. Higgins et al., 2017

    β-VAE: Learning Basic Visual Concepts with a Constrained Variational Framework

    Training a latent space whose axes come apart.

  5. Shen, Gu, Tang and Zhou, 2020

    Interpreting the Latent Space of GANs for Semantic Face Editing

    InterFaceGAN: directions from linear boundaries, and the projection of chapter 4.

  6. Härkönen, Hertzmann, Lehtinen and Paris, 2020

    GANSpace: Discovering Interpretable GAN Controls

    Finding useful directions without labels, using PCA.

  7. Dhariwal and Nichol, 2021

    Diffusion Models Beat GANs on Image Synthesis

    Classifier guidance.

  8. Ho and Salimans, 2022

    Classifier-Free Diffusion Guidance

    The guidance formula of chapter 5.

  9. Turner et al., 2023

    Steering Language Models With Activation Engineering

    Activation addition: steering by adding a difference of activations.

  10. Zou et al., 2023

    Representation Engineering: A Top-Down Approach to AI Transparency

    Reading and controlling honesty, emotion and more through directions.

  11. Park, Choe and Veitch, 2024

    The Linear Representation Hypothesis and the Geometry of Large Language Models

    What it means, precisely, for a concept to be a direction.

  12. Arditi et al., 2024

    Refusal in Language Models Is Mediated by a Single Direction

    The refusal direction and its ablation, from chapter 6.

  13. Templeton et al., 2024

    Scaling Monosemanticity

    Millions of directions from a sparse autoencoder, and steering with them.

  14. Christiano et al., 2017

    Deep Reinforcement Learning from Human Preferences

    Learning a reward from pairwise comparisons.

  15. Ouyang et al., 2022

    Training Language Models to Follow Instructions with Human Feedback

    RLHF for language models, with the KL penalty of chapter 7.

  16. Gao, Schulman and Hilton, 2023

    Scaling Laws for Reward Model Overoptimization

    How far you can push along a learned reward before it breaks.

Controlling a generative model comes down to a few geometric moves. A point says what to make. A direction is a concept, which you add to edit and project onto to measure. A path should stay on the shell where the data lives. A projection removes the side effects you did not ask for.

Guidance and steering use the same moves at every step and in every layer, and preference learning finds directions from human choices. All of it rests on dot products and projections, covered in The Math Behind AI, and on learned spaces like the ones in Inside the Autoencoder.

← Back to blog