flowchart LR
reviews[("2,300 real<br/>reviews")] --> teacher["teacher model<br/>structured JSON labels"]
teacher --> gate{"gate A:<br/>hand-check 50"}
gate -->|pass| train[("training set<br/>2,000 labeled")]
gate -->|fail| fix["fix prompt or schema,<br/>relabel"] --> teacher
train --> qlora["QLoRA fine-tune<br/>1-2B student"]
reviews --> held[("held-out 300")]
qlora --> eval["head-to-head eval<br/>student vs teacher"]
held --> eval
eval --> econ["economics:<br/>break-even volume"]
econ --> memo["go / no-go memo"]
28 Project: Review extraction via distillation
Can a 2B model you own replace the API?
This project puts three chapters to work on one decision. Chapter 15 taught us when fine-tuning pays, Chapter 16 taught us how to do it on one GPU, and Chapter 17 taught us to let a big model label data for a small one. Here we run the full sequence: a strong teacher model labels product reviews with structured JSON, a small open model learns to do the same extraction by QLoRA (Chapter 16’s quantized low-rank fine-tuning), and a head-to-head evaluation tells us, in numbers, whether the student can take over. Although the milestones produce a trained model, the deliverable is a one-page go/no-go memo with the measurements behind it.
Unlike the executable chapters, this one is a guided project: the code blocks are scaffolding for you to run on your own machine, and the training milestone needs a GPU with at least 8 GB of VRAM (the GPU’s own memory; Chapter 4 covers renting one for under a dollar an hour). You need an API key for a strong teacher model, the gaba helpers from earlier chapters (or your own equivalents), and a product-review dataset; any public one works, and the Amazon Reviews 2023 release on Hugging Face is a good default because it spans many product categories. Budget a few evenings: labeling is slow, training is fast, and the memo takes longer than you think.
28.1 The brief
Imagine the pipeline this project stands in for. A retailer receives half a million product reviews a month and runs every one through a large API (application programming interface) model to extract structured facts: overall sentiment, the specific issues the customer raises, the specific things they praise. Product teams query the results; nobody reads half a million reviews. At a realistic price of a fraction of a cent per review, the pipeline costs on the order of $1,500 a month, every month, forever. And every review, including the angry ones with names and order details in them, is sent to a third party to be processed.
The question is whether a 1 to 2 billion parameter open model, fine-tuned on this one narrow task and running on hardware we control, can do the extraction well enough to replace the API. Everything turns on “well enough”: a small model that is 30 points worse is useless, while one within 2 points of the teacher at a tenth of the cost deletes a recurring budget line. The only way to know which one we have is to build it and measure.
Your deliverable is a recommendation, go or no-go, defended with four measurements: teacher label quality, student output validity, student-versus-teacher quality gap, and the break-even economics from Chapter 15 computed on your real numbers.
28.2 The architecture
The quality gate comes before training, because, as we saw in Chapter 17, a distilled model is only as good as the labels it learns from. If the teacher’s labels fail the hand-check, then nothing downstream of them is worth running.
28.3 Milestone 1: the teacher set
We need 2,300 real reviews: 2,000 to train on and 300 held out for the showdown. Pull them from at least three product categories; the distribution-shift trap below is the reason. Then the teacher labels them with the structured-output pattern from Chapter 3, a Pydantic schema enforced through Instructor, so every label is valid by construction.
Writing the teacher’s ReviewExtraction schema and the teacher_label call is a good place to let an assistant do the typing for you: describe the three fields you want (sentiment, issues, positives) and ask for the Pydantic model and the call_structured wrapper. Read the result against what the student will inherit: does every field description tell the teacher to extract only what is stated? Does the schema’s serialized form match, byte for byte, what to_chat will later serialize into the student’s training examples? A mismatch here is invisible until Milestone 2’s training loss looks fine and Milestone 3’s JSON validity does not.
from pydantic import BaseModel, Field
from typing import Literal
from gaba.llm import call_structured
class ReviewExtraction(BaseModel):
"""What we want to know from every review."""
sentiment: Literal["positive", "negative", "mixed"]
issues: list[str] = Field(description="specific problems the customer raises, short noun phrases, empty if none")
positives: list[str] = Field(description="specific things the customer praises, short noun phrases, empty if none")
def teacher_label(review: str) -> ReviewExtraction:
return call_structured(
f"Extract sentiment, issues, and positives from this product review:\n\n{review}",
ReviewExtraction,
model=TEACHER_MODEL, # a strong frontier model: this is the skill we are copying
).dataLabel a pilot batch of 50 first, before spending anything on the other 1,950. Then apply the gate: read all 50 yourself, next to the review text, and record your own sentiment call and your own issue list for each. This sample does double duty for the rest of the project, so save it carefully; it is both the teacher’s report card now and the human reference at evaluation time.
import json
pilot = reviews[:50]
hand_labels = [...] # your 50 sentiment calls, recorded before seeing the teacher's
teacher_pilot = [teacher_label(r) for r in pilot]
agreement = sum(t.sentiment == h for t, h in zip(teacher_pilot, hand_labels)) / 50
print(f"gate A, teacher vs hand labels: {agreement:.0%}") # need >= 90%
# Where they disagree, read the review and decide who is right. If it is
# usually you, the teacher is not good enough to copy yet.
for r, t, h in zip(pilot, teacher_pilot, hand_labels):
if t.sentiment != h:
print(f"teacher={t.sentiment} you={h} :: {r[:100]}")Compute the teacher’s sentiment agreement with your 50 hand labels. If it is below 90 percent, stop. Do not label 2,000 reviews with a teacher you just measured failing one in ten; fix the prompt, tighten the field descriptions in the schema, or pick a stronger teacher, and re-run the pilot. This is Chapter 17’s garbage-in rule with a number attached: the student will learn the teacher’s mistakes with perfect fidelity, so the only cheap moment to catch them is now.
When the pilot passes, label the remaining reviews and write everything to JSONL: the review text, the teacher’s JSON, and a split field separating the 2,000 training rows from the 300 held-out ones. The held-out rows get teacher labels too, since the showdown compares student to teacher on them, but they must never appear in training.
with open("teacher_set.jsonl", "w") as f:
for i, review in enumerate(reviews):
label = teacher_label(review)
f.write(json.dumps({
"review": review,
"teacher_json": label.model_dump_json(), # compact, stable key order
"split": "train" if i < 2000 else "heldout",
}) + "\n")Most frontier-model providers’ terms of service restrict using their outputs to train models that compete with them. A narrow internal extraction model is a different case from a rival chatbot, and providers differ on where they draw the line, but the line exists, and because it is contractual, the question cannot be settled on technical grounds. Chapter 17 introduced distillation as an engineering pattern; before you deploy one at work, have someone who can read your provider’s terms confirm that your use is allowed, and write the answer into the memo. Some providers and most open-weight model licenses permit it explicitly, which can itself be a reason to choose your teacher.
28.4 Milestone 2: train the student
Pick a small instruct model with open weights: Qwen2.5-1.5B-Instruct (the same model Chapter 16 quantized) and Llama-3.2-1B-Instruct are both good candidates. We fine-tune with QLoRA exactly as Chapter 16 did, 4-bit frozen base plus low-rank adapters, using either Unsloth (a fine-tuning library, fastest on a single consumer GPU) or Hugging Face TRL’s SFTTrainer (the standard route). Because the training itself repeats Chapter 16’s recipe, the new work in this milestone is the data formatting: each example becomes a chat exchange in the model’s own template, with the review as the user turn and the teacher’s JSON, serialized exactly the way we will parse it later, as the assistant turn.
def to_chat(row) -> dict:
return {"messages": [
{"role": "system", "content": "Extract sentiment, issues, and positives from the product review. Reply with JSON only."},
{"role": "user", "content": row["review"]},
{"role": "assistant", "content": row["teacher_json"]}, # compact JSON, stable key order
]}Keep the assistant text byte-stable (same key order, same compact separators, no trailing prose). The student will reproduce whatever format it saw, and a consistent format is what makes gate B reachable.
The training configuration is Chapter 16’s recipe with one-line reasons:
from trl import SFTConfig, SFTTrainer
from peft import LoraConfig
peft_config = LoraConfig(
r=16, # rank: enough capacity for a structured task, still a tiny adapter
lora_alpha=32, # scale, conventionally 2x the rank
target_modules="all-linear", # adapt every projection; cheap at this model size
task_type="CAUSAL_LM",
)
args = SFTConfig(
num_train_epochs=2, # 2,000 examples is small; a third epoch mostly memorizes
learning_rate=2e-4, # the high-for-adapters rate from Chapter 16
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # effective batch of 16 on a small GPU
bf16=True,
output_dir="student-extractor",
)Then the training run itself is three lines, after which the adapter, a few megabytes as promised in Chapter 16, sits in the output directory next to nothing else of size:
trainer = SFTTrainer(model=STUDENT_MODEL, train_dataset=train_ds,
peft_config=peft_config, args=args)
trainer.train()
trainer.save_model("student-extractor/final") # adapter weights onlyVerify your run against these expectations: on a single consumer GPU (an RTX 4060 Ti or a rented T4/A10), QLoRA on a 1.5B model over 2,000 short examples takes roughly 20 to 60 minutes and peaks under 8 GB of VRAM. If you are seeing hours or out-of-memory, something is misconfigured, most likely the sequence length. Since reviews are short, cap max_length near 512, which is well below the default of 4,096.
Teacher labeling: 2,300 reviews at roughly 500 input and 120 output tokens each is about 1.2M input and 0.3M output tokens; at frontier-model prices of a few dollars per million tokens, roughly $5 to $10 total. Training: under an hour of GPU at well under $1 per hour rented, so under $1, or free on hardware you own, where the cost of the same hour is the capacity it occupies. The experiment that decides a $1,500-per-month question costs about $10, an asymmetry that makes measurement cheaper than guessing at this volume. Serving the student is the one recurring cost, which belongs in Milestone 3’s table.
28.5 Milestone 3: the showdown
Run the student on the 300 held-out reviews, greedy decoding (temperature 0), and parse every reply with the same Pydantic schema. No retries and no repair: a reply either validates or it does not, because in production a malformed reply is a failure whether or not a human could have fixed it.
from pydantic import ValidationError
valid, results = 0, []
for row in heldout:
reply = student_generate(row["review"]) # greedy, same system prompt as training
try:
pred = ReviewExtraction.model_validate_json(reply)
valid += 1
except ValidationError:
pred = None # a failure, counted, not repaired
results.append((row, pred))
print(f"gate B, JSON validity: {valid / len(heldout):.1%}") # need >= 99%Measure four things.
JSON validity rate. The share of the 300 replies that parse and validate against ReviewExtraction. This is the decisive metric for a structured pipeline; a model that is brilliant 97 percent of the time and emits prose the other 3 percent breaks every downstream consumer.
Sentiment accuracy, twice. Against the teacher’s labels on all 300 (how faithfully did the student copy the skill?) and against your 50 hand labels (is the skill itself any good?). The second comparison is the one Chapter 17 insisted on: agreement with the teacher can be high while both are wrong, and only the human reference catches it.
Issue-extraction F1 with fuzzy matching. Exact string match is too strict; “battery life” and “short battery life” are the same complaint. Score a predicted issue as a hit if it fuzzy-matches any teacher issue for that review, with token-overlap or embedding similarity above a threshold you fix in advance, then compute precision, recall, and F1 over all 300 reviews. Score positives the same way.
def fuzzy_match(pred: str, gold: str, threshold: float = 0.6) -> bool:
p, g = set(pred.lower().split()), set(gold.lower().split())
return len(p & g) / max(len(p | g), 1) >= threshold # Jaccard overlap on tokens (shared over total)
def issue_prf(pred_issues: list[str], gold_issues: list[str]) -> tuple[int, int, int]:
hits = sum(any(fuzzy_match(p, g) for g in gold_issues) for p in pred_issues)
return hits, len(pred_issues), len(gold_issues) # pool over reviews, then P, R, F1Set the threshold before you look at any scores, and note it in the memo. A threshold tuned after the fact to make F1 look good is the evaluation equivalent of moving the goalposts, and anyone who reruns your harness will find it.
Latency and cost per 1,000 reviews, for three deployment options: the teacher via API (the incumbent), the student on a hosted inference endpoint (someone else runs the GPU), and the student self-hosted on a GPU you rent and keep busy (Chapter 4’s flat-cost regime). For the self-hosted column, measure throughput with batched inference, because a one-review-at-a-time measurement understates the GPU’s sustained rate; a 1.5B model under vLLM should sustain thousands of short extractions per minute on one modest GPU.
Assemble the results into one table. The skeleton below is deliberately blank, since this is the form your memo fills in, with only the cells that are true by definition pre-filled. The reference run later in this chapter shows one completed version to calibrate against.
| metric | teacher (API) | student (hosted) | student (self-hosted) |
|---|---|---|---|
| JSON validity rate | yours | yours | yours |
| sentiment accuracy vs hand labels (n=50) | yours | yours | yours |
| sentiment agreement with teacher (n=300) | 1.000 by definition | yours | yours |
| issues F1 vs teacher (fuzzy) | 1.000 by definition | yours | yours |
| positives F1 vs teacher (fuzzy) | 1.000 by definition | yours | yours |
| median latency per review | yours | yours | yours |
| cost per 1,000 reviews | yours | yours | yours |
| cost at 500k reviews/month | yours | yours | yours |
It is tempting to call agreement-with-teacher “accuracy,” although what the number measures is fidelity. If the teacher systematically reads sarcastic reviews as positive, a perfectly distilled student inherits the error and scores 100 percent agreement while being wrong, and the table looks splendid. The 50 hand labels are small, but they are the only cell in the table connected to ground truth. If student-versus-teacher looks great and student-versus-human does not, the problem is upstream in the teacher, and no amount of student training fixes it.
28.6 Milestone 4: the decision memo
The memo is one page, with the recommendation, go or no-go, in the first sentence. The evidence follows: the showdown table, the quality gap stated plainly (“the student is within 1.4 points of the teacher on sentiment and 4 points of F1 on issues”), and the economics computed with Chapter 15’s break-even arithmetic, this time on your measured numbers where the chapter used illustrative ones:
training_cost = 13.00 # teacher labels + GPU time, measured
api_cost_per_review = ... # from your table
student_cost_per_review = ... # self-hosted column, at realistic utilization
breakeven_reviews = training_cost / (api_cost_per_review - student_cost_per_review)
months_to_breakeven = breakeven_reviews / 500_000With a training cost this small, the break-even volume will fall in the tens of thousands of reviews, days of production traffic, and the economics will almost certainly say go. This is exactly why the memo must lead with quality: the decision is really gated on whether the quality gap is acceptable, and the economics only tell you how fast a “yes” pays off.
The calculator below is initialized with our reference run’s measured numbers, whose source the next section describes; replace them with yours and observe where the lines cross.
Close the memo with one paragraph on what would change the answer: the quality gap at which you would stay on the API, the volume below which the fixed costs stop mattering, and what happens when the review distribution drifts (new product lines, new languages) and the student needs retraining while the teacher would have adapted for free. A recommendation that names its own reversal conditions is one a manager can actually act on.
An assistant will write the labeling script, the training config, and the evaluation harness, and you should let it. It will also cheerfully draft a confident go/no-go memo from numbers it has never questioned. The judgment in this project, whether a 2-point quality gap is acceptable for this business use, whether the hand-label sample is trustworthy enough to bet on, whether the terms-of-service (ToS) answer permits deployment at all, is the deliverable, and it is yours. A memo you cannot defend line by line in front of the person who pays the API bill is not done.
28.7 The reference run
We ran this project once, end to end, exactly as the milestones describe, so you can see what the recipe produces and calibrate your own results against something real. The configuration: 2,300 reviews streamed from three categories (video games, appliances, beauty), openai/gpt-4.1-mini as the teacher, Qwen2.5-1.5B-Instruct as the student, QLoRA at r=16 and two epochs. The scripts are in the book repository under scripts/p3_reference/, with the run’s measured outputs beside them.
Gate A. We hand-labeled the 50-review pilot before looking at the teacher’s answers. Teacher versus hand labels: 45/50, exactly 90 percent, a pass at the threshold. All five disagreements were mixed-versus-positive boundary cases where, on re-reading, either call is defensible; none were teacher blunders. Labeling all 2,300 reviews then cost $0.51.
Training. Two epochs over 2,000 examples took 304 seconds on one RTX 3090, loss falling from 1.55 to 1.43. Five minutes of GPU.
The showdown, on the 300 held-out reviews, greedy decoding, no retries:
| metric | result | gate |
|---|---|---|
| JSON validity | 300/300, 100% | B: needs 99%, passes |
| Sentiment accuracy vs teacher | 90.7% | C |
| Sentiment by category | 88.7 / 91.5 / 92.6% | no distribution cliff |
| Student vs our 50 hand labels | 94% (teacher: 90%) | statistically a tie at n=50 |
| Positives F1, fuzzy 0.6 | 0.49 | C |
| Issues F1, fuzzy 0.6 | 0.33 | C: fails |
| Throughput, batched, one 3090 | 189 reviews/min | feeds the cost row |
Read the table the way the memo will present it. The student is deployable today as a sentiment model: perfect formatting, teacher-level accuracy by the human reference (94 percent versus the teacher’s 90 on the same 50 reviews, indistinguishable at that sample size), stable across categories, and at 189 reviews per minute on one consumer GPU, roughly $0.03 per thousand reviews against the teacher’s $0.22. The student is not deployable as an issue extractor: an F1 of 0.33 means most extracted issue lists differ materially from the teacher’s. This is the format-before-task trap from the traps section, measured: validity is perfect while the open-ended skill lags, because two epochs on 2,000 examples teach a 1.5B model what JSON to emit long before they teach it editorial judgment about what counts as a distinct issue. The memo therefore splits the decision: go for sentiment triage now, no-go for issue extraction pending more data, more capacity, or a narrower issue taxonomy, and the next experiment is written in the gap itself.
A number like 0.33 needs examples behind it, so here are three held-out reviews with the teacher’s issue list and the student’s side by side. The run’s precision was 0.36 and its recall 0.30, which says the failures run in both directions: issues the teacher found that the student never mentions, and student phrases too coarse or off-target to match anything.
| review (excerpt) | teacher issues | student issues |
|---|---|---|
| “I have been using Turtle Beach headphones for years…” | disconnecting during play; friends not understanding me | disconnection during play |
| “I can never seem to find the right color to match my skin…” | finding the right color match; foundation line on chin | color mismatching |
| “no instructions on how to pair… paddle is too big battery life is not very good” | paddle is too big; battery life is not very good | too big paddle; battery life not very good |
One more check remains, and where the checks above measured the model, this one measures the harness. Every fuzzy-matched score above used the Jaccard threshold of 0.6 we fixed before scoring, and a fair reader should know how much of the verdict the threshold itself carries. Rescoring the same saved predictions at four thresholds:
| fuzzy threshold | issues F1 | positives F1 |
|---|---|---|
| 0.4 | 0.456 | 0.618 |
| 0.5 | 0.415 | 0.580 |
| 0.6 | 0.325 | 0.488 |
| 0.7 | 0.258 | 0.430 |
If the issues row cleared the quality bar only at the loosest threshold, the go/no-go call would have passed from the student to the harness, which is exactly the moved-goalpost failure Milestone 3 warned about. Report the sweep in your memo for the same reason you fixed the threshold in advance: it shows the conclusion survives the one parameter the evaluator controls.
Your run will differ, in data, in teacher, in scores. What should not differ is the structure of the report: every claim above traces to a number, every number to a script, and the decision follows from the table alone, so that enthusiasm plays no part in it.
28.8 Evaluation gates
The project passes through four gates, in order, and each one ends the project if it fails.
Gate A, teacher quality: the teacher’s sentiment labels agree with your 50 hand labels on at least 90 percent of reviews, checked before the full labeling run. Fail: fix the prompt or teacher and re-pilot.
Gate B, student validity: at least 99 percent of the student’s 300 held-out replies parse and validate against the schema with no retries. Fail: check the chat-template formatting first, because when this gate fails, the cause is almost always the formatting and only rarely the model’s capability.
Gate C, quality gap: student sentiment accuracy is within 3 points of the teacher’s, measured on the held-out set, and the student-versus-human number on your 50 hand labels does not tell a contradictory story. Fail: more training data is the usual remedy; the teacher is cheap, so doubling the training set costs another $5.
Gate D, economics: a computed break-even volume from your measured costs, compared against the scenario’s monthly volume, with the self-hosted column priced at the utilization you can realistically sustain, since a price computed at theoretical peak understates the true cost.
| gate | what is measured | bar | weight |
|---|---|---|---|
| A | teacher sentiment agreement with 50 hand labels | >= 90% before full labeling | 20% |
| B | student JSON validity on held-out 300 | >= 99%, no retries | 20% |
| C | student vs teacher gap, sanity-checked vs human | < 3 points sentiment accuracy | 25% |
| D | break-even volume from measured costs | computed and compared to scenario volume | 15% |
| memo | one page, recommendation first, reversal conditions named | defensible from the table alone | 20% |
28.9 Traps
Training on teacher errors. The student copies the teacher exactly, mistakes included, and because the mistakes are systematic, they do not average out the way random errors would. Gate A exists because the audit is only useful before training, after which the errors are baked into the weights.
Distribution shift. If all 2,300 reviews come from one product category, the student learns to extract complaints about, say, electronics, and fails on skincare reviews, where “burning” names a complaint although in an electronics review it can name a capability. Sample across categories, and if you want to see the failure yourself, hold out one entire category and watch the F1 drop.
Learning the format before the task. Two epochs on 2,000 examples can teach a model the JSON format while its judgment stays shallow, which looks like gate B passing and gate C failing. The signature is high validity with mediocre accuracy; the fix is more varied data, since additional epochs only reinforce what the model has already memorized. The reference run showed exactly this: 100 percent validity, strong sentiment, weak issue extraction.
The ToS issue. Covered in the compliance callout, and repeated here because it is the trap that surfaces after the work is done. Settle it before Milestone 1, while no demo has yet impressed anyone.
The quantization cliff. Chapter 16 showed that 4-bit weights cost almost nothing during training, since the base is only read. Serving is less forgiving: if you also quantize the deployed student aggressively to save serving memory, re-run gate B and gate C on the quantized artifact. Validity in particular can drop sharply at low precision, and a model that passed evaluation in bf16 has not passed it in 4-bit.
Serving is not free. The self-hosted cost column holds only at realistic utilization. Chapter 4’s warning applies in full: a GPU that runs your extractions for one hour a day at a flat hourly rate has a real per-review cost many times the back-of-the-envelope number. Batch the work, or price the hosted-endpoint column instead.
28.10 Going further
Three extensions, in rising order of ambition. First, serve the student properly: run it behind vLLM as Chapter 4 demonstrated, measure sustained throughput and p95 latency under batched load, and replace the estimated self-hosted column in your table with measured numbers. Second, push quality with preference tuning: collect pairs where the teacher and student disagree, treat the teacher’s output as preferred, and run DPO (Direct Preference Optimization) on the pairs; on narrow tasks this often recovers a point or two of the gate C gap without new labels. Third, plan for drift: wire in Chapter 24’s monitoring so a weekly sample of production reviews gets teacher labels and the student’s live agreement is tracked, giving you an alert that says “retrain” before the product team notices the extraction quality degrade. This last extension is the difference between a successful project and a system that is still trusted a year later.
This project is the book’s economics argument made concrete: Chapter 4’s crossover, Chapter 15’s break-even, and Chapter 17’s distillation all converge on a single memo with a number in it. If your numbers said go, you have deleted a recurring bill and kept customer text in-house. If they said no-go, you spent ten dollars to avoid a bad migration, which is a cheap way to avoid an expensive mistake.