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.
z = torch.randn(2)face = decoder(z)ticks = torch.linspace(-2.4, 2.4, 7)faces = decoder(torch.cartesian_prod(ticks, ticks))01 Latent space
Every output has an address.
A generative model is a function. It takes a short list of numbers 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 is a point in a plane, and its decoder draws a face. The red point is , and the face on the right is .
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 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 and the face starts smiling but also closes its eyes. Walk up along 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)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 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:
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:
The hollow red point is a frowning face with smile −0.62. One step of takes it to 0.79. The same 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, , and the DCGAN demo where a smiling woman, minus a neutral woman, plus a neutral man gave a smiling man.
Now 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 .
Watch the eyes, though. As the smile grows they close, from 0.75 to 0.30 at . 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 , by dropping a perpendicular, and the position along the line is a smile score:
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 * b03 Paths
In high dimensions, the straight line cuts through empty space.
The simplest path between two latent vectors and is the straight one, linear interpolation, or lerp:
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 , 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, . Its squared length is a sum of independent squares, each about 1 on average, so
and the spread around stays near no matter how large gets. With , 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 , 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 to along the arc between them, where is the angle between the two vectors:
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 in pushes a point off the shell, so a common trick is to rescale it back to length afterward.
Change yourself. At 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_only04 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 . It is perpendicular to the probe's decision boundary and points toward wider eyes.
The grey lines are perpendicular to . 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 crosses several.
Split into a part along and a part perpendicular to it:
Here , 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:
This is one step of Gram–Schmidt, the procedure that makes vectors orthogonal to each other.
Editing with 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 , make them orthonormal, and project out all of them together:
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)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:
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 as an extra input, and it learns the score of . With the prompt “a smiling face” every path ends in the smiling cluster.
Bayes' rule shows what the prompt adds:
The last term is the gradient of a classifier, the direction in which looks most like . 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 plays the role of :
Here is the predicted noise, which is the score times , the noise level at step . With this is just the conditional model. With it overshoots, sampling roughly from , which favors the points the classifier is most sure about.
At 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 , 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)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, , 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:
Reading it works as before. The projection 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 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.
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 , exactly the projection from chapter 4:
Every point slides onto the line through the origin perpendicular to . 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()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 , but they can look at two faces and pick one.
Assume their taste is linear, a score . Then picking A over B says
That is a half-plane. Every direction on the wrong side of the line through the origin perpendicular to 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:
Fitting to many comparisons with this likelihood is the code above. Replace with 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:
Comparisons reveal a direction, and alignment is the craft of following it without going too far.
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.
- a latent vector
- z = torch.randn(d)
- the generator or decoder
- decoder(z)
- the standard Gaussian prior
- torch.randn(n, d)
- Gaussian samples live on a thin shell
- z.norm() / d ** 0.5
- a concept direction from a difference of means
- Z[pos].mean(0) - Z[neg].mean(0)
- an edit along a direction
- z + alpha * v
- projection, the concept score
- z @ (v / v.norm())
- remove the component along a unit vector n
- v - (v @ n) * n
- remove a whole subspace
- v - N @ (N.T @ v)
- interpolation along the arc
- slerp(a, b, t)
- the score, which way the data lies
- -eps / sigma
- a diffusion model's noise prediction
- model(x_t, t, prompt)
- classifier-free guidance
- guidance_scale=w
- the residual stream after layer l
- cache["resid_post", l]
- directional ablation
- h - (h @ r_hat)[..., None] * r_hat
- Bradley–Terry preference probability
- torch.sigmoid(r_a - r_b)
- how far a tuned model has drifted
- (logp - logp_ref).mean()
Where to go next
Roughly in the order of the chapters, from word vectors and face editing to guidance, steering and learning from preferences.
Mikolov, Yih and Zweig, 2013
Linguistic Regularities in Continuous Space Word RepresentationsKing minus man plus woman: concepts as directions, in words.
Radford, Metz and Chintala, 2016
Unsupervised Representation Learning with Deep Convolutional Generative Adversarial NetworksDCGAN, and the same vector arithmetic on faces.
White, 2016
Sampling Generative NetworksWhy lerp leaves the shell, and slerp as the fix.
Higgins et al., 2017
β-VAE: Learning Basic Visual Concepts with a Constrained Variational FrameworkTraining a latent space whose axes come apart.
Shen, Gu, Tang and Zhou, 2020
Interpreting the Latent Space of GANs for Semantic Face EditingInterFaceGAN: directions from linear boundaries, and the projection of chapter 4.
Härkönen, Hertzmann, Lehtinen and Paris, 2020
GANSpace: Discovering Interpretable GAN ControlsFinding useful directions without labels, using PCA.
Dhariwal and Nichol, 2021
Diffusion Models Beat GANs on Image SynthesisClassifier guidance.
Ho and Salimans, 2022
Classifier-Free Diffusion GuidanceThe guidance formula of chapter 5.
Turner et al., 2023
Steering Language Models With Activation EngineeringActivation addition: steering by adding a difference of activations.
Zou et al., 2023
Representation Engineering: A Top-Down Approach to AI TransparencyReading and controlling honesty, emotion and more through directions.
Park, Choe and Veitch, 2024
The Linear Representation Hypothesis and the Geometry of Large Language ModelsWhat it means, precisely, for a concept to be a direction.
Arditi et al., 2024
Refusal in Language Models Is Mediated by a Single DirectionThe refusal direction and its ablation, from chapter 6.
Templeton et al., 2024
Scaling MonosemanticityMillions of directions from a sparse autoencoder, and steering with them.
Christiano et al., 2017
Deep Reinforcement Learning from Human PreferencesLearning a reward from pairwise comparisons.
Ouyang et al., 2022
Training Language Models to Follow Instructions with Human FeedbackRLHF for language models, with the KL penalty of chapter 7.
Gao, Schulman and Hilton, 2023
Scaling Laws for Reward Model OveroptimizationHow 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.