Skip to content
academia.sh

Lesson 23 / 26

Fine-Tuning

Measuring side by side on the same 60 fictional test records what happens when the same labeled examples are put into the prompt and when they are written into the weights: the baseline without scaffolding is 0.6000, fine-tuning on four examples moves no weight and stays at 0.6000, at eight examples it reaches 0.7333 and passes the 0.6500 of prompt scaffolding, at sixteen examples 0.8833. At thirty-two examples the two paths overlap in the band 0.9000 to 0.9500, at sixty examples prompt scaffolding gives 0.9667 and fine-tuning stays at 0.9500: fine-tuning does not win. The cost is in two separate units; prompt scaffolding raises the context per request from 28.48 to 623.48 tokens, that is 21.9 times, while fine-tuning is a one-time 180 update steps and moves a 0.2992 share of the 605 weights.

Published Updated

In this lesson

  • Compares putting the same labeled example set into the prompt with writing it into the weights, on the same test set.
  • Writes the cost of fine-tuning as example count, epoch count and the share of weights that move.
  • Uses the difference between two draws to decide which gain counts as measured.
  • Determines the example count at which fine-tuning does not win and computes the per-request cost of prompt scaffolding.
Contents

In the previous two lessons the weights were always taken as given: a scale was chosen, the number of digits was lowered, and memory and accuracy were written side by side. The weights themselves were never touched. Yet this is the real possibility of a local setup that a distant endpoint does not have; because the weights are in your hands, they can be changed.

The table at the end of the second lesson imposed a choice: when the budget forces the small scale, accuracy on this task stays low. There are two ways to raise it, and both need the same thing, labeled examples. The first way is to put the examples in the prompt; with every request the model is shown a few solved tickets. The second way is to write the examples into the weights; the model is trained once and the prompt stays short. This lesson measures both at the same example count and shows the point where fine-tuning does not win. The two-stage structure of pretraining and fine-tuning was covered in the architectures topic of the Introduction to Machine Learning course and in the language models topic of the Introduction to AI Engineering course; that distinction is not repeated here, only the numbers of the two paths are compared.

  • LA21. The code is a simulator; the tickets are fictional, the seed is 20260218. The scale is the small scale of the previous lessons, because the budget forced it.
  • LA22. The first 60 of the 120 fictional tickets are the training pool, the last 60 are the test set. No scaffolding touches the test set.
  • LA23. Prompt scaffolding: the chosen examples are put into the prompt. An example in the context gives its own pieces a temporary support for its own topic; the support is not carried from request to request.
  • LA24. Fine-tuning: an error-driven update on the same examples. Weights move only on examples the model knows wrongly, and the weights that move are permanent.
  • LA25. The number of epochs is how many times the training set is passed through from start to end; the number of update steps is the example count times the number of epochs.
  • LA26. The two paths use the same examples. The comparison is at equal labeled example counts; the only thing that changes is where the example is put.
  • LA27. The two runs are two separate draws of the examples (seed 20260218 and 20260219). At sixty examples the two draws give the same set, so the difference there is zero.
  • LA28. Gain rule: the superiority of a path does not count as measured unless it points the same way in both draws.
  • LA29. The cost is in two separate units: prompt scaffolding is tokens per request, fine-tuning is a one-time example count and epoch count.
  • LA30. The weight table is 121 rows times five topics, that is 605 weights; the number of records fine-tuning moves is counted out of this.

The Same Examples, Two Different Places

The simulator is the same as in the previous lessons; the only change is that a correction table can be added on top of the score. If the correction comes from the prompt it is temporary and goes away with the context of that request; if it comes from training it is permanent and the prompt stays short. The input of both is the same labeled examples.

# SIMULATOR -- FICTIONAL. The simulator of the previous lessons, built in shortened form.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def tokenize(m):                        # FICTIONAL tokenizer, a piece is at most five characters
    b = []
    for s in m.lower().split():
        while len(s) > 5:
            b.append(s[:5])
            s = s[5:]
        b.append(s)
    return b


TOPICS = ["invoice", "breakdown", "reading", "tariff", "outage"]
TEMPLATES = {"invoice": ["bu ayki tutar gecen aya gore cok yuksek",
                         "odedim ama sistemde borc gorunuyor",
                         "ayrintili dokumu nereden gorebilirim"],
             "breakdown": ["evde su akmiyor bir sorun var",
                           "baglanti bozuk oldu ve hic calismiyor",
                           "ariza kaydi actim ama ekip gelmedi"],
             "reading": ["okuma yanlis girilmis endeks tutmuyor",
                         "cihaz degisimi icin randevu istiyorum",
                         "endeksi ile fatura ayni degil"],
             "tariff": ["hangi basamaktayim ogrenmek istiyorum",
                        "tarife degisikligi icin ne yapmaliyim",
                        "birim ucret abone tipine gore degisir mi"],
             "outage": ["mahallede elektrik yok ne zaman gelecek",
                        "planli duyuru nereden takip edilir",
                        "aksamdan beri kesinti yasiyoruz"]}
EXTRAS = ["ayrica gecen hafta da aradim", "kayit numarasi verildi",
          "ekip bilgi verecekti", "degisiklik talebim de var"]
TICKETS = []                            # 120 FICTIONAL support tickets
for i in range(120):
    r = rng(SEED + i)
    k = TOPICS[pick(r(), [0.24, 0.22, 0.18, 0.20, 0.16])]
    m = TEMPLATES[k][int(r() * 3)]
    if r() < 0.34:
        m += " " + EXTRAS[int(r() * 4)]
    TICKETS.append({"id": 4001 + i, "topic": k, "text": m})

PIECE_TOPICS = {}
for k, items in TEMPLATES.items():
    for m in items:
        for t in set(tokenize(m)):
            PIECE_TOPICS.setdefault(t, {})[k] = 1
for e in EXTRAS:
    for t in set(tokenize(e)):
        PIECE_TOPICS.setdefault(t, {})

BASE, WEIGHTS = 0.50, {}
for i, t in enumerate(sorted(PIECE_TOPICS)):
    r = rng(SEED + 7919 * (i + 1))
    row = {k: BASE for k in TOPICS}
    if PIECE_TOPICS[t]:
        for k in PIECE_TOPICS[t]:
            row[k] = BASE + 0.05 + 0.40 * r()
    else:
        row[TOPICS[int(r() * 5)]] = BASE + 0.02 + 0.10 * r()
    WEIGHTS[t] = row

ROWS = 12                               # small scale: the scale the budget chose
LEARNED = set(sorted(WEIGHTS,
                     key=lambda t: (BASE - max(WEIGHTS[t].values()), t))[:ROWS])
TRAIN, TEST = TICKETS[:60], TICKETS[60:]
INSTRUCTION = "Label this ticket as one of: invoice breakdown reading tariff outage . "


def score(text, correction=None):
    """The base score is the highest value of the learned rows; the correction is added on top."""
    pieces = tokenize(text)
    p = {k: BASE for k in TOPICS}
    for t in pieces:
        if t in LEARNED:
            for k in TOPICS:
                p[k] = max(p[k], WEIGHTS[t][k])
    for t in pieces:
        for k in TOPICS:
            p[k] += (correction or {}).get((t, k), 0.0)
    return max(sorted(p), key=lambda a: p[a])


def measure(correction=None):
    return sum(score(x["text"], correction) == x["topic"] for x in TEST) / 60


print("training pool", len(TRAIN), "records ; test", len(TEST), "records")
print("baseline accuracy (small scale without scaffolding):", f"{measure():.4f}")
print("instruction tokens:", len(tokenize(INSTRUCTION)),
      "; mean ticket tokens:",
      round(sum(len(tokenize(x["text"])) for x in TEST) / 60, 2))
training pool 60 records ; test 60 records
baseline accuracy (small scale without scaffolding): 0.6000
instruction tokens: 18 ; mean ticket tokens: 10.48

The baseline is 0.6000. This is the state, on this task, of the scale the budget imposed at the end of the second lesson, and it is the number both paths have to beat. An average ticket is 10.48 tokens and the instruction is 18 tokens; that is, a request without scaffolding is about 28 tokens. This number will be the yardstick when reading the cost of prompt scaffolding.

As the Example Count Grows

The code below puts the same example set in both places and measures only on the test set. The examples are chosen from the training pool with two separate draws; the second draw is there to decide whether a gain counts as measured.

EXAMPLE, STEP = 0.30, 0.05              # support from the context, and the fine-tuning step


def pick_examples(n, seed):
    r, h = rng(seed), list(range(60))
    for i in range(59, 0, -1):
        j = int(r() * (i + 1))
        h[i], h[j] = h[j], h[i]
    return [TRAIN[i] for i in sorted(h[:n])]


def from_context(examples):             # examples put into the prompt: temporary support
    return {(t, o["topic"]): EXAMPLE for o in examples for t in tokenize(o["text"])}


def train_model(examples, epochs):      # error-driven update; the weights are permanent
    D = {}
    for _ in range(epochs):
        for o in examples:
            s = score(o["text"], D)
            if s != o["topic"]:
                for t in tokenize(o["text"]):
                    D[(t, o["topic"])] = D.get((t, o["topic"]), 0.0) + STEP
                    D[(t, s)] = D.get((t, s), 0.0) - STEP
    return D


print(" n  prompt A  prompt B  tune k=1  tune k=3  tuneB k=3  prompt tok  updated")
for n in (4, 8, 16, 32, 60):
    a, b = pick_examples(n, SEED), pick_examples(n, SEED + 1)
    ia, ib = measure(from_context(a)), measure(from_context(b))
    y1, y3, yb = measure(train_model(a, 1)), measure(train_model(a, 3)), measure(train_model(b, 3))
    tok = sum(len(tokenize(o["text"])) for o in a)
    print(f"{n:>2}  {ia:>8.4f}  {ib:>8.4f}  {y1:>8.4f}  {y3:>8.4f}  {yb:>9.4f}"
          f"  {tok:>10}  {len(train_model(a, 3)):>7}")

print("\nn=60 fixed , number of epochs varies:")
print("  k  accuracy  update steps  updated records")
for k in (1, 2, 3, 6, 10):
    D = train_model(pick_examples(60, SEED), k)
    print(f" {k:>2}  {measure(D):>8.4f}  {60 * k:>12}  {len(D):>15}")

mean = sum(len(tokenize(x["text"])) for x in TEST) / 60
tok60 = sum(len(tokenize(o["text"])) for o in pick_examples(60, SEED))
with_scaffolding = len(tokenize(INSTRUCTION)) + tok60 + mean
tuned = len(tokenize(INSTRUCTION)) + mean
D60 = train_model(pick_examples(60, SEED), 3)
print(f"\ncontext per request: prompt scaffolding {with_scaffolding:.2f} , fine-tuning {tuned:.2f} tokens"
      f" ({with_scaffolding / tuned:.1f} times)")
print(f"cost of fine-tuning n=60 , k=3: {60 * 3} update steps , {len(D60)} records ,"
      f" {len(D60) / 605:.4f} share of the 605 weights")
 n  prompt A  prompt B  tune k=1  tune k=3  tuneB k=3  prompt tok  updated
 4    0.5667    0.6500    0.6000    0.6000     0.7333          38        0
 8    0.6500    0.6667    0.7333    0.7333     0.8000          73       35
16    0.8333    0.7500    0.8833    0.8833     0.8667         138       49
32    0.9000    0.9500    0.8500    0.9167     0.9000         292      101
60    0.9667    0.9667    0.9333    0.9500     0.9500         595      181

n=60 fixed , number of epochs varies:
  k  accuracy  update steps  updated records
  1    0.9333            60              121
  2    0.9333           120              162
  3    0.9500           180              181
  6    0.9500           360              181
 10    0.9500           600              181

context per request: prompt scaffolding 623.48 , fine-tuning 28.48 tokens (21.9 times)
cost of fine-tuning n=60 , k=3: 180 update steps , 181 records , 0.2992 share of the 605 weights

The top row is where neither path works. With four examples, fine-tuning moves no weight: the number of updated records is 0, because the model already knew those four examples correctly and an error is needed to trigger an error-driven update. Accuracy stays at the baseline, at 0.6000. When the same four examples are put into the prompt, on the other hand, accuracy falls to 0.5667; the four examples in the context pull a part of the test records toward their own topics. In the second draw the same two numbers are 0.6500 and 0.7333, that is, not even the direction is the same. With four examples nothing has been measured.

At eight and sixteen examples fine-tuning is clearly ahead. At eight it is 0.7333 and 0.8000, above 0.6667, the best of prompt scaffolding in the two draws; at sixteen it is 0.8833 and 0.8667, above 0.8333 and 0.7500 of prompt scaffolding. Because it points the same way in both draws, this superiority counts as measured. The reason is structural: an error-driven update uses an example several times and corrects only the places that are known wrongly, while an example put into the context is read once and also moves the places that were already known correctly.

The Point Where Fine-Tuning Does Not Win

At thirty-two examples the table turns. Fine-tuning with three epochs gives 0.9167 and 0.9000, prompt scaffolding gives 0.9000 and 0.9500. The bands overlap and the superiority does not point the same way in the two draws; according to LA28 there is no measured gain here. In the same row, fine-tuning with a single epoch is 0.8500, that is, below both values of prompt scaffolding. Raising the epochs to three gains 0.0667, and this is a gain that comes not from adding examples but from passing over the same examples again.

At sixty examples no uncertainty is left. Because both draws select the whole pool, the numbers are the same: prompt scaffolding 0.9667, fine-tuning 0.9500. The difference is 0.0167 and the run-to-run variation is zero. This is the point where fine-tuning does not win, and it is not chance. If all sixty examples sit in the context, the model reads every request together with a solved set; fine-tuning, on the other hand, has used from the same examples only the ones that gave errors, has moved 181 of the 605 weights and has left the rest untouched. Squeezing all the labeled data you have into a single training run is a loss on this task.

More epochs cannot close this gap. When sixty examples are held fixed and the epochs are increased, accuracy is 0.9333 at one epoch, 0.9500 at three, and stays there; six and ten epochs give the same 0.9500. Because after the third epoch no errors are left to correct in the training set, the number of updated records also settles at 181. The update steps go from 180 to 600 and nothing is received in return. The ceiling of fine-tuning on this task is 0.9500, and that ceiling rises not by adding epochs but only by another way.

What decides is not the accuracy column but the last two rows. With sixty examples, prompt scaffolding raises the context of the request from 28.48 tokens to 623.48 tokens, that is, to 21.9 times, and this cost is paid again on every request. According to the computation of the first lesson, context length is proportional to the intermediate state per request; twenty-one times the context means twenty-one times fewer concurrent requests in the same budget. The cost of fine-tuning, on the other hand, is one-time: sixty labeled examples, three epochs, 180 update steps and a 0.2992 share of weights that move. Every later request is still 28.48 tokens.

The comparison therefore does not close with a single number. When labeled examples are few, fine-tuning is both ahead and cheaper. As examples multiply, prompt scaffolding catches up and passes in accuracy, but it does so by enlarging the context; if how many requests are processed at the same time matters, an accuracy difference of 0.0167 is not worth twenty-one times the capacity. What has to be measured is both, and these two numbers are not in the same unit.

Summary

  • Both paths ask for the same input: labeled examples. The only thing that changes is whether the example is put into the prompt or into the weights; so the comparison is made at equal example counts.
  • With four examples fine-tuning moves no weight and the baseline stays at 0.6000; when the same four examples are put into the prompt, accuracy falls to 0.5667. The direction of the two draws is not even the same.
  • At eight and sixteen examples fine-tuning is ahead in both draws: 0.7333 and 0.8833 against 0.6500 and 0.8333. This superiority counts as measured.
  • At thirty-two examples the bands overlap (0.9000 and 0.9500 against 0.9000 and 0.9167); there is no measured gain, and single-epoch fine-tuning falls behind with 0.8500.
  • At sixty examples prompt scaffolding is 0.9667 and fine-tuning 0.9500; because the run-to-run variation is zero, the loss of fine-tuning is real.
  • The costs are not in the same unit: prompt scaffolding raises the context 21.9 times per request, fine-tuning moves a one-time 180 update steps and a 0.2992 share of the 605 weights.

Next Step

The 0.2992 share in the last row was passed over without being asked about. Fine-tuning did not decide which weights would move; the error-driven update touched whichever piece passed through an example known wrongly, and 181 records came out that way. This number is a by-product, not a design decision. Yet the number of weights that move is directly a cost: it is the thing that will be stored, carried, rolled back and kept separately for every task. Could the same accuracy be obtained by moving fewer weights, what would be lost if it were decided beforehand which weights to freeze, and how many numbers would be stored if the update itself were written onto the table as an add-on: none of these was tried. The next lesson measures side by side the full update, layer freezing and a low-rank correction added to the table, and prints two numbers: the updated-parameter share and accuracy.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close