LLM + RoboticsBuild a Robot Brain · Week 9 · Lesson 13

Teaching an old model a new trick: fine-tuning and LoRA

A factory trained a model for robots like yours. But your robot is a little bit different, and the model gets things wrong. You could retrain every number in it. Or you could notice that the fix is simple, and train a tiny add-on instead.

Time2–3 hours
You'll needPython 3 (no earlier lesson files needed this time), paper and a pencil, a recipe or a kitchen scale
New wordsmatrix, matrix × vector, fine-tuning, parameters, LoRA, adapter, rank, frozen
Project pieceAdapts a factory model to YOUR robot cheaply

Start here

When a big model needs a small, simple change, don't rewrite the whole model: freeze it and train a thin add-on that holds only the change.

The problem

Robo-Helper has six distance sensors, one for each direction it cares about. The factory shipped a model that turns those six readings into six "danger levels", and it works perfectly on the factory's robots. But on your robot, sensor 2 got bolted on at a slight angle. Now it picks up things that are really in front of the other sensors, and its readings leak into all the danger levels. The factory model is confused. Measured on 200 tests, its error is 0.0673. For a robot about to bump into things, that's too much.

The idea in one picture

Imagine you buy glasses off the shelf, and they're nearly right, but your left eye is a bit weaker. You don't grind a whole new pair of lenses from scratch. You add a thin clip-on lens to fix just the difference. The original glasses stay exactly as they were, and you can unclip the add-on and use a different one for reading or for sunshine. LoRA is a clip-on lens for a model.

Words to know

Matrix
A table of numbers in rows and columns. A model's "knowledge" is stored in matrices. Ours, W, is 6 × 6: 36 numbers.
Matrix × vector
How a matrix turns inputs into outputs: each output is one row of the table multiplied number-by-number with the inputs, then added up.
Parameters
The numbers a model learns. "Trained 12 numbers" means 12 parameters.
Fine-tuning
Taking a model that's already trained and nudging its numbers a bit more on new data, so it fits a new situation.
Frozen
Not allowed to change during training. In LoRA the original W is frozen.
LoRA
Low-Rank Adaptation. Keep W frozen and learn a small correction written as a thin column of numbers times a thin row: A @ B.
Adapter
The small A and B you trained. You can save it, swap it, or have one per robot.
Rank
How many independent changes the add-on can express. Rank 1 = one pattern of change. Rank 2 = two.

Going deeper: everything below is the full lesson: the idea built from nothing, a demo with live heatmaps, your homework, and a challenge away from the screen.

1. The problem

The factory model W gets six sensor readings x (each from 0 to 1) and gives six danger levels. On a perfect robot, sensor 0 should drive danger 0, sensor 1 drive danger 1, and so on. So W looks almost like this, with 1s down the diagonal and tiny "cross-talk" numbers everywhere else:

W (rounded)    s0     s1     s2     s3     s4     s5
danger 0     1.07   0.05  -0.02  -0.05   0.00  -0.02
danger 1     0.06   0.96  -0.01   0.02   0.08   0.00
danger 2    -0.04   0.05   1.02  -0.05   0.08   0.10
danger 3     0.06   0.08  -0.04   1.05   0.08   0.04
danger 4    -0.01  -0.08  -0.01   0.02   1.08   0.09
danger 5    -0.01   0.07  -0.05   0.06   0.01   0.90

Your robot's angled sensor 2 leaks: what it reads should push danger 0 up by 0.5, danger 1 down by 0.3, danger 2 up by 0.8, and so on. The factory model doesn't know that. You can measure 12 examples on your robot: six readings, and the danger levels that turned out to be right.

Obvious idea 1: train a new model from scratch on your 12 examples. You'd throw away everything the factory learned from thousands of robots. And 12 examples aren't nearly enough to learn a big model from nothing.

Obvious idea 2: fine-tune, i.e. keep training all 36 numbers of W on your 12 examples. This actually works here. But imagine W was a real AI model with billions of numbers. Training all of them needs huge computers, and every robot would need its own giant copy of the whole model. A factory with 1,000 slightly different robots would store 1,000 giant models. There must be a cheaper way.

2. First principles

Step 1: a model is a table of numbers

Let's shrink it to 2 sensors and 2 danger levels. The model is a 2 × 2 table:

W = [[1, 2],
     [3, 4]]          x = [1, 1]

To get the outputs, take each row, multiply it number-by-number with x, and add up. That's called matrix × vector (or "matvec").

output 0 = row 0 · x = 1×1 + 2×1 = 3
output 1 = row 1 · x = 3×1 + 4×1 = 7
W x = [3, 7]

That's all a layer of a neural network does (plus a squash like sigmoid, sometimes). It's Lesson 4's neuron, done for several neurons at once: each row is one neuron's weights.

Predict first: W = [[2, 0], [1, 3]] and x = [1, 2]. What is W x?

[2, 7]. Row 0: 2×1 + 0×2 = 2. Row 1: 1×1 + 3×2 = 7. Notice the 0 in row 0: input 1 has no effect at all on output 0. Zeros in the table mean "no connection".

Step 2: fine-tuning means nudging the numbers

Fine-tuning uses the same idea as Lesson 3: look at the error, nudge each number to shrink it. For a table, the rule is:

err = W x − y                      (prediction minus the right answer)
W[i][j] −= lr × err[i] × x[j]       (for every row i and column j)

Example. A tiny robot with W = [[1, 0], [0, 1]]. It reads x = [1, 0.5], and the right danger levels are y = [1, 1]. Use lr = 0.5:

W x       = [1, 0.5]
err       = [1 − 1, 0.5 − 1] = [0, −0.5]
row 0:      err is 0, so nothing changes
W[1][0]   = 0 − 0.5 × (−0.5) × 1   = 0.25
W[1][1]   = 1 − 0.5 × (−0.5) × 0.5 = 1.125
new W     = [[1, 0], [0.25, 1.125]]
new W x   = [1, 0.25 + 0.5625] = [1, 0.8125]      closer to [1, 1]

Do that many times, over many examples, and W fits your robot. Every number in the table can move. That's full fine-tuning. On a 6 × 6 table, that's 36 numbers.

Step 3: big models make this expensive

Real models are made of many layers, and one layer of a big language model is a table of about 4096 × 4096 = 16,777,216 numbers. A model has hundreds of tables like that. To fine-tune all of it you need memory not just for the numbers but for their training bookkeeping too, which needs big, expensive computers. And afterwards, you have a whole new copy of the model. Ten robots, ten copies.

Step 4: the change is often simple

Here's the key insight. Look at what your robot actually needs: W_new = W + change. What does the change look like? Sensor 2 leaks into every danger level, with amounts u = [0.5, −0.3, 0.8, 0, −0.4, 0.2]. So the change table is all zeros except column 2:

change      s0   s1    s2    s3   s4   s5
danger 0     0    0   0.5    0    0    0
danger 1     0    0  -0.3    0    0    0
danger 2     0    0   0.8    0    0    0
danger 3     0    0   0      0    0    0
danger 4     0    0  -0.4    0    0    0
danger 5     0    0   0.2    0    0    0

That's 36 numbers, but there's really only one idea in it: "sensor 2 leaks, by these amounts". You can write the whole table as a thin column times a thin row:

A (6 × 1) = a column  [0.5, −0.3, 0.8, 0, −0.4, 0.2]     "how much each danger level is affected"
B (1 × 6) = a row     [0, 0, 1, 0, 0, 0]                 "which sensor is causing it"
change = A @ B        entry [i][j] = A[i] × B[j]

Try the small version from check.py by hand. A column times a row makes a whole table:

[[1],      @   [[3, 4]]   =   [[1×3, 1×4],    =   [[3, 4],
 [2]]                          [2×3, 2×4]]         [6, 8]]

Here 2 + 2 = 4 numbers build a table of 4, so no saving yet (the table is tiny). For 6 × 6: A has 6 numbers, B has 6, so 12 numbers build a table of 36. That's LoRA: freeze W, and only learn A and B.

Predict first: a 4096 × 4096 layer, and a LoRA where A is 4096 × 8 and B is 8 × 4096. How many numbers does LoRA train, and what fraction is that?

4096 × 8 + 8 × 4096 = 32,768 + 32,768 = 65,536. Out of 16,777,216, that's about 0.4%. You train 256 times fewer numbers, and the adapter file is tiny. That "8" is the rank, and it's coming up next.

Step 5: rank = how many independent changes

Rank 1 means A is one column and B is one row: the add-on can express one pattern of change ("this sensor leaks, by these amounts"). What if two sensors leak, in different ways? Then the change is two separate patterns added together. One column and one row can't hold both. You need rank 2: A becomes 6 × 2 (two columns) and B becomes 2 × 6 (two rows), 24 numbers.

A good way to feel it: rank is the number of root causes. "My sensor is bolted on crooked" is one cause, even though it changes all six danger levels. "Two sensors are bolted on crooked" is two causes. Real LLM adapters use rank 8 or 16 or 64, because "talk like a pirate" or "answer in robot-command JSON" needs a few more independent changes, but still far fewer than the full table.

Step 6: using the add-on (and why B starts at zero)

With LoRA the model's output is

output = W x + A (B x)

Notice we never build the 6 × 6 table A @ B. We squeeze x through the thin middle first: B x is just 1 number (for rank 1), then A spreads it back out to 6. For a 4096-wide layer that's far less work. Here's the check.py example by hand, with W = [[1, 2], [3, 4]], A = [[1], [0]], B = [[0, 1]], x = [1, 1]:

W x       = [3, 7]
B x       = [0×1 + 1×1] = [1]
A (B x)   = [1×1, 0×1]  = [1, 0]
output    = [3 + 1, 7 + 0] = [4, 7]

Training starts with A as small random numbers and B as all zeros. Then A @ B is all zeros, and the model starts out as exactly the factory model. Training can only move away from "already pretty good", never start from a random mess.

Predict first: what goes wrong if A and B both start at zero?

Nothing ever learns. A's update is multiplied by B x, which is 0. B's update is multiplied by A, which is 0. Both nudges are zero, every step, forever. One of the two must start non-zero to get things moving. (That's also why, in the demo, the first few steps barely change the error: A is small, so B grows slowly at first, then it speeds up.)

Step 7: what happened on our robot

Both methods train on the same 12 examples, for 400 passes, and are tested on 200 new ones:

factory model on OUR robot:          test error 0.0673
LoRA rank 1 (12 numbers trained):    test error 0.0000
full fine-tune (36 numbers trained): test error 0.0000

And when you look inside the trained adapter, LoRA found the leak: A came out as about [0.48, −0.29, 0.77, 0, −0.38, 0.19] and B as about [0.01, 0, 1.05, 0, 0, 0]. B points at sensor 2, and A × 1.05 gives back [0.5, −0.3, 0.8, 0, −0.4, 0.2]. Nobody told it which sensor was bent.

The harder robot, with two leaky sensors:

two leaky sensors, LoRA rank 1: test error 0.0154
two leaky sensors, LoRA rank 2: test error 0.0000

Rank 1 fixes the bigger of the two leaks and gets stuck. It simply doesn't have room for a second pattern. Rank 2 fixes both.

Be honest about what LoRA wins. In this small experiment, LoRA is not more accurate than full fine-tuning. Both hit 0.0000 with 12 examples. With only 2 to 6 examples, they land at about the same error as each other too (for example 0.0030 vs 0.0030 with 2 examples). LoRA's real wins are: (1) far fewer numbers to train and store (12 instead of 36 here, 0.4% of the table at real scale); (2) the original model stays untouched, so you can't wreck what the factory taught it, and you can always switch the adapter off; (3) small adapters you can swap, one per robot or per task, all sharing one big model.

Step 8: why robotics people love this

Modern robots are starting to use one big robot foundation model: a model trained on data from many robots that takes camera images and an instruction and outputs actions. OpenVLA is a well-known open one, and it's commonly fine-tuned with LoRA for a new robot or a new task. The picture is exactly our lesson, scaled up: one shared frozen model, plus a small adapter for "the arm in the kitchen", another for "the arm in the warehouse with a crooked camera". Servers can even keep one big model in memory and switch between many LoRA adapters request by request (called multi-LoRA serving), which is how one computer can run many robot "personalities" cheaply.

3. Play with it

Interactive: watch the adapter learn the leak

The same factory model, examples and starting A as lesson13_lora.py (from random.Random(0)), and the same training rule (lr 0.05). One "step" is one pass over the 12 examples, like one of the Python's 400 steps. Blue squares are positive numbers, red are negative; the stronger the colour, the bigger the number. Compare the add-on A @ B with the change your robot really needs. Train to 400 and the numbers match the Python: 0.0000 for one leak; 0.0154 (rank 1) and 0.0000 (rank 2) for two leaks.

Steps
0
Test error (200 tests)
–
Factory model error
–
Numbers trained
–

Things to try: press "Train one step" five times. The error barely moves (A is small, B starts at 0). Then +10 a few times and watch it drop fast. Now pick "two leaky sensors" with rank 1 and train to 400: A @ B copies one column of the needed change and can't do the other. Switch to rank 2, Reset, and train again.

4. Homework

Open homework/lesson13_lora.py. This file stands on its own: no earlier lessons needed. Matrices are plain Python lists of lists, like [[1, 2], [3, 4]]. Already written for you: the factory model, our robot's true behaviour, make_examples and train_full (full fine-tuning, for comparison). Write these seven functions, in order.

4.1 matvec(M, x)

Matrix × vector (Step 1). Example: matvec([[1, 2], [3, 4]], [1, 1]) → [3, 7].

Hint 1

One output number per row of M.

Hint 2

For one row, pair it up with x using zip(row, x), multiply each pair, add them up.

Hint 3

Return a list: for each row in M, the sum of m * xi for m, xi in zip(row, x).

4.2 matmul(A, B)

Matrix × matrix: result[i][j] = sum over k of A[i][k] * B[k][j]. Example: matmul([[1], [2]], [[3, 4]]) → [[3, 4], [6, 8]] (a column times a row, Step 4).

Hint 1

The result has as many rows as A and as many columns as B.

Hint 2

Three loops: i over the rows of A, j over the columns of B (len(B[0])), and k over the shared middle (len(B)).

Hint 3

Build each row i as a list over j of sum(A[i][k] * B[k][j] for k in range(len(B))). Test it with the identity: matmul(W, [[1, 0], [0, 1]]) must give back W.

4.3 add(M, N) and 4.4 count_params(*matrices)

add adds two same-shaped tables number by number: add([[1, 2], [3, 4]], [[1, 2], [3, 4]]) → [[2, 4], [6, 8]]. count_params counts all the numbers in any number of tables: count_params([[1, 2], [3, 4]], [[1, 2, 3]]) → 4 + 3 = 7.

Hint 1

add: zip the rows together, then zip the numbers inside each pair of rows. count_params: rows × columns for each table.

Hint 2

*matrices means the function receives a tuple of however many tables you passed. Loop over it.

Hint 3

add: [[a + b for a, b in zip(r1, r2)] for r1, r2 in zip(M, N)] is the shape. count_params: the sum of len(M) * len(M[0]) over the matrices.

4.5 lora_forward(W, A, B, x)

Output = W x + A (B x), without ever building A @ B (Step 6). Example: lora_forward([[1, 2], [3, 4]], [[1], [0]], [[0, 1]], [1, 1]) → [4, 7].

Hint 1

It's three matvecs and one add-up. Which one has to happen first?

Hint 2

First bx = matvec(B, x) (a short list, one number per rank). Then matvec(A, bx) gives 6 numbers. Add those to matvec(W, x).

Hint 3

Return the list of w + a for w, a in zip(matvec(W, x), matvec(A, bx)).

4.6 mse(predict, examples)

Mean squared error (Lesson 3) over every output number of every example. predict is a function you call with x. Example: mse(lambda x: [0, 0], [([1], [1, 3])]) → errors 1 and 3 → (1 + 9) / 2 = 5.0.

Hint 1

Keep a running total of squared errors and a count of how many numbers you added.

Hint 2

For each x, y in examples, call predict(x) once, then zip its outputs with y.

Hint 3

For each pair p, t: total += (p - t) ** 2 and count += 1. Return total / count.

4.7 train_lora(W, examples, rank=1, lr=0.05, steps=400, rng=None)

The main event. Keep W frozen. Make A (6 × rank) of small random numbers and B (rank × 6) of zeros. Then, for each step, for each example, nudge A and B. Return (A, B). The docstring gives the exact rule.

Hint 1

Look at train_full just below: same loops, same err. The difference is which numbers you change, and that there are two tables.

Hint 2

Set up: rng = rng or random.Random(0), then A with rng.uniform(-0.1, 0.1) for each of D rows × rank columns (row by row, so you match the Python output), and B as rank rows of [0.0] * D. Inside the loop, compute bx, err, and also, before touching A, the "back" signal for each r: sum(A[i][r] * err[i] for i in range(D)).

Hint 3

Per example: bx = matvec(B, x); err = lora_forward(W, A, B, x) minus y, number by number; back as in Hint 2. Then for every i and r: A[i][r] -= lr * err[i] * bx[r]. Then for every r and j: B[r][j] -= lr * back[r] * x[j]. Using back (computed from the old A) is what "use A from BEFORE this update" means.

Run it

cd homework
python3 lesson13_lora.py

What you should see (this is the real output of the reference solution):

factory model on OUR robot:   test error 0.0673
LoRA rank 1 (12 numbers trained): test error 0.0000
full fine-tune (36 numbers trained): test error 0.0000
two leaky sensors, LoRA rank 1: test error 0.0154
two leaky sensors, LoRA rank 2: test error 0.0000
a 6x6 layer: full = 36 numbers, LoRA rank 1 = 12 (33.3%)
a 4096x4096 layer: full = 16,777,216 numbers, LoRA rank 8 = 65,536 (0.4%)

Check it

cd ..
python3 check.py 13

A green ✓ means that part passed. A red ✗ comes with a hint, for example:

All green shows 6/6 passed.

Stretch

5. Real-world challenge: adapt, don't rebuild

You'll feel why "the change is usually simple" is true in everyday life.

Version A: a recipe or workout plan.

  1. Find a recipe (or a workout plan) someone else wrote for "standard" people. Count how many separate numbers and lines it has (quantities, times, reps). That's your "full model".
  2. Adapt it for yourself or your family: maybe you're cooking for 2 instead of 4, or you want it less sweet, or you can only do half the reps. Make the fewest edits that do the job. Write each edit on its own line.
  3. Count your edits, and compare them with the number of lines you'd write if you rewrote the whole thing from scratch.
  4. Now group your edits by why you made them. "Cooking for 2" probably caused lots of edits (every quantity halved) from one root cause. "Less sweet" is a second root cause.

Version B: a kitchen scale or measuring jug.

  1. Find 4–5 things whose true weight you know: a sealed 500 g packet of pasta, a 1 kg bag of sugar, 1 litre of water (1000 g, minus the jug).
  2. Weigh each and write down "true" and "reading" in a table.
  3. Is the scale off by the same amount every time (an offset, like always +20 g), or by the same percentage (like always 5% heavy)? Work out the one correction.
  4. Write that one correction on a sticky note on the scale, instead of re-marking every line.

What to notice: the number of root causes is the rank. One root cause can change many numbers, and still be described by one small rule. Most real adjustments are low rank, which is exactly why LoRA works so well on huge models.

6. Your Robo-Helper so far

Robo-Helper can now be adapted cheaply. Your file has lora_forward (run the factory model plus your adapter) and train_lora (learn the adapter from a handful of measurements on your robot). The factory model W stays frozen and shared; your robot's personality fits in 12 numbers. Together with Lesson 12 (learning what people prefer), that's the big idea of the later weeks: you rarely build a robot brain from nothing, you teach one that already exists.

In Lesson 14 you'll go real. Real sensors never match the factory spec exactly, so "measure a few examples on your actual robot and fit a small correction" is the first thing you'd do with real hardware, whether it's a 12-number adapter like train_lora makes, or a LoRA on a robot foundation model.

7. Common mistakes

8. Check yourself

Predict: matvec([[0, 1], [1, 0]], [5, 9]). What comes out, and what does this table do?

[9, 5]. Row 0 picks input 1, row 1 picks input 0. This table swaps the two inputs.

How many numbers does a rank-2 LoRA on a 6 × 6 table train?

A is 6 × 2 = 12, B is 2 × 6 = 12, so 24. Still fewer than the 36 in W.

Why does B start at zero?

So A @ B is all zeros at the start, and the adapted model begins as exactly the factory model. Training then only moves it away from "already pretty good".

Two sensors leak. Rank 1 gets 0.0154, rank 2 gets 0.0000. Explain in terms of root causes.

Each leaky sensor is one independent pattern of change (one column of the change table). Rank 1 can hold one pattern, so it fixes one leak (the bigger one) and is stuck with the other. Rank 2 holds two.

Is LoRA more accurate than full fine-tuning? What are its real advantages?

Not in our experiment: both reach about the same error. The wins are far fewer numbers to train and store, the original model left untouched, and tiny adapters you can swap per robot or task while sharing one big model.

9. Go deeper (optional)