chapter 13 / capstone · the course's final hands-on project · estimated build time 1-2 weekends

The Capstone
Teaching a Small Model to Draw Pixel Art

AUDIO // Spoken guide to this chapter

This chapter has no new theory—it twists the knowledge of the previous 13 chapters into a single end-to-end project you can actually run on your own Mac: use LoRA+SFT to make a 4B-9B open-source small model reliably emit structured pixel-art JSON. Every step is tagged with the chapter it draws on. Finish it and you complete the leap from "having studied" to "having done."

Project roadmap (eight steps)
  1. Task definition and schema design
  2. Interactive lab: the validator itself
  3. Evaluation first: eval.py and the 0/12 baseline
  4. Dataset engineering: synthesis + filtering pipeline
  5. Choosing a base model: a controlled experiment
  6. Training: LoRA validation → overnight full run
  7. Failure-case analysis and data iteration
  8. Deployment, cost accounting, and Agent-ification
  9. Graduation quiz

Step one: task definition and schema design

Drawing on the first rule of task-design from Chapter 11: narrow the task surface. Don't build "AI draws pictures"; build "generate JSON conforming to this schema":

schema · pixel_art_v1
{
  "size": 8 | 16 | 32,                  // canvas side length
  "palette": ["#RRGGBB", ...],          // 2-16 hexadecimal colors
  "pixels": [[int, ...], ...]           // size×size matrix, values are palette indices
}

This representational choice alone is half the project's success (Chapter 8 argued this vs. diffusion models): every rule is programmatically decidable—the JSON parses, fields are complete, hex is valid, the matrix is rectangular, indices stay in bounds. So one and the same validate() function handles three jobs: scoring the evaluation, filtering the training data, and (advanced) supplying the RLVR reward (the terminus of Chapter 8's evolution axis of supervision signals). Verifiability isn't luck—it's designed in.

Interactive lab: the validator itself

Below is the web version of this validator. Required experiment: ① click the three error examples in turn—palette out of bounds (the model "wants to use" a color it never declared), uneven row length (miscounting a long sequence, the curse of tokenization, Chapter 7), and an illegal color value (treating a natural-language color name as hex)—these three are exactly the mistakes a small model makes most often before real training; ② edit the JSON directly in the text box and watch the checks turn red and green line by line as the canvas re-renders in real time (illegal cells flash bright red).

pixel_art.validate_and_render(json)

Edit freely on the left · instant render + line-by-line validation on the right · note that PASS/FAIL simultaneously drives evaluation/cleaning/reward, all three

Step two: evaluation first—eval.py and the 0/12 baseline

The first rule of Chapter 8's methodology: have an evaluation before you train. Twelve stratified cases (4 easy: single color blocks; 4 medium: specified object + color scheme; 4 hard: style constraint + size 32), each running validate() + a task assertion:

python · eval.py (core logic, corresponding line-by-line to the JS validator in the lab above)
import json, re, sys
from mlx_lm import load, generate

HEX = re.compile(r'^#[0-9a-fA-F]{6}$')

def validate(text: str) -> tuple[bool, str]:
    try: d = json.loads(extract_json(text))           # the model may wrap it in markdown, so extract first
    except Exception as e: return False, f'parse: {e}'
    if not all(k in d for k in ('size','palette','pixels')): return False, 'missing fields'
    if not (2 <= len(d['palette']) <= 16 and all(HEX.match(c) for c in d['palette'])):
        return False, 'bad palette'
    px, n = d['pixels'], d['size']
    if len(px) != n or any(len(r) != n for r in px): return False, 'not rectangular'
    if any(not (0 <= v < len(d['palette'])) for r in px for v in r): return False, 'index OOB'
    return True, 'ok'

model, tok = load(sys.argv[1])                         # base or adapter path
cases = [json.loads(l) for l in open('eval_cases.jsonl')]
passed = 0
for c in cases:
    out = generate(model, tok, prompt=render_chat(c['instruction']), max_tokens=2048)
    ok, why = validate(out)
    print(('✓' if ok else '✗'), c['id'], why)
    passed += ok
print(f'{passed}/{len(cases)} pass')

The expected result of running the base model (e.g., Qwen3-4B-Instruct): 0/12 to 2/12—it produces roughly plausible JSON, but with uneven row lengths and out-of-bounds indices (errors ②③ from the lab). This number is the frame of reference for all the work that follows.

Step three: dataset engineering—synthesis + filtering pipeline

Target: 3000-5000 "instruction → standard JSON" pairs (the sweet spot from MLX community experience). The pipeline (a miniature of Chapter 7's FineWeb philosophy):

  1. Seed: hand-craft 30-50 examples covering the full difficulty range (Chapter 8's LIMA: quality > quantity, the seed sets the tone).
  2. Programmatically generate half the data: the beauty of pixel art—you can generate it in reverse: a program randomly combines "object template × palette × size" to draw legal JSON, then has a strong model write a natural-language instruction for it ("draw a frog wearing a hat, retro green palette"). The instruction is generated, the answer is constructed and inherently 100% compliant.
  3. Synthesize the other half with a strong model: a flagship model batch-produces "instruction + JSON" pairs in the seed style, each passing through validate(); anything non-compliant is discarded outright—the validator reports for duty a second time here.
  4. Diversity audit: bucket and count by object category / color style / size, then fill in whichever category is missing (preventing a lopsided dataset → a lopsided model).

The format is chat JSONL (Chapter 8's template + loss mask), on the order of 13M tokens.

Step four: choosing a base model—a controlled experiment

Candidates: 4B dense / 8B dense / a same-tier hybrid architecture (linear-attention family) / a small MoE. Here are the real conclusions and the method straight from Chapter 7:

Step five: training—LoRA validation → overnight full run

shell · two-stage training plan
# —— Stage A: LoRA pipeline validation (about 20 minutes, the Chapter 8 calculator did the memory math) ——
python -m mlx_lm lora --model Qwen/Qwen3-4B-Instruct \
  --train --data ./data --iters 600 --batch-size 4 \
  --num-layers 16 --learning-rate 1e-5            # 600 steps ≈ 3 epochs (small subset)
python eval.py ./adapters                          # expect: 0/12 → 6-9/12, loss decreasing smoothly
# the question this step answers is "is the pipeline right", not "is the quality good"

# —— Stage B: full data, overnight run (about 13M tokens, 8B is 3-4 hours per epoch / 4B is half that) ——
python -m mlx_lm lora --model Qwen/Qwen3-8B-Instruct \
  --train --data ./data_full --iters 2400 --batch-size 4 \
  --num-layers 32 --learning-rate 1e-5 \
  --steps-per-eval 200 --val-batches 25            # start before bed, check the val loss curve when you wake

The rhythm is exactly what Chapter 8 described: build data and evaluations by day, run training at night, check scores in the morning. Three signals worth watching: val loss falling together with train loss (no overfitting, Chapter 1), the row-length error rate of the generated samples (the most stubborn error type), and the situation where loss falls but the evaluation doesn't budge (which means the problem isn't fitting, it's the data—proceed to the next step).

Step six: failure-case analysis and data iteration

Suppose after the overnight run you're at 9/12. The 3 you got wrong aren't noise, they're the work orders for the next round of data engineering (Chapter 8's controlled-experiment discipline):

Failure caseDiagnosisCountermeasure
Row length errors on 32×32 large imageslong-sequence counting degrades (size-32 samples are only 8% of the data)targeted top-up of 500 32×32 samples
"Retro palette" style driftsparse data mapping style words → palettesexplicitly enumerate style words into the instruction template during programmatic generation
Occasional markdown-wrapped JSONthe SFT data is all bare JSON, but the system prompt didn't forbid itunify on the data side + a system prompt explicitly stating "output JSON only"—a double safeguard

After two or three rounds of "evaluate → attribute → top up data → retrain" it stabilizes at 11-12/12, and this loop itself (dataset + evaluation + pipeline) is the asset you've accumulated—the model is merely its current output (the judgment of Chapters 7/8 closes the loop here).

Step seven: deployment, cost accounting, and Agent-ification

VIDEO 01 · hands-on reference
Apple MLX Fine Tuning Guide
Chris Hay 47:17
Viewing guide · follow along once and put this chapter's commands under your fingertips
  • Covers: dataset construction → Qwen-family model selection (500M-7B comparison) → LoRA vs. full fine-tuning → fuse and export.
  • Pay special attention to the segments where he handles "model forgetting" (preventing base-capability regression) and data diversity—they correspond to step six of this chapter.
  • Companion reading: Chapter 8's MLX command cheat sheet + the LoRA calculator for choosing r.
Course complete. From a single regression line in Chapter 1 to the complete post-training loop you can now run on your own—look back at that timeline in the prologue: you've walked through the entire technical history it cataloged, and you now stand at the blank space on its far right. The iterations that come next will be written by you. The appendix's list of information sources will help you stay on the frontier; this course site (including all the engineering records in PROGRESS.md) is left to you as a reference manual.

Graduation quiz