engineering case study

Fine-tuning a 4B-parameter legal clause analyzer to 98.7% — for under $5 of GPU time

How we taught a small open model to read lease clauses against state law, show its reasoning, and know when to ask a human — the dataset design, a tokenizer trap that cost 3 accuracy points, and the error-driven fixes that closed the gap.

By the Ductape engineering team · July 2026 · 10 min read

98.7%
verdict accuracy, held-out eval (77/78)
53/53
legal clauses correct — zero false accusations
100%
structurally valid JSON outputs
24 min
final training run on one rented A100
<$5
total GPU spend across every experiment

Why fine-tune at all?

Our product analyzes residential leases for clauses that are unenforceable, illegal, or abusive toward tenants — things like self-help eviction provisions, habitability waivers, and confession-of-judgment devices. The runtime is retrieval-augmented: a lease is split clause by clause, each clause is paired with the governing state statutes retrieved from our law database, and a model renders a verdict with a visible reasoning trace.

Frontier API models can do this, but per-clause calls across a 30-page lease add up in cost and latency, and lease documents are sensitive. We wanted a model that is small enough to self-host, cheap enough to call on every clause, and specialized enough to follow our exact contract: read one clause against provided law, think step by step, answer Predatory or Legal — and never invent law that wasn't retrieved.

The dataset: clause-level verdicts with reasoning traces

We started from a synthetic corpus of 570 residential leases across 20 U.S. states, written by dozens of simulated "drafters" — corporate property managers, informal landlords, non-native-English writers, even sovereign-citizen pseudo-legal documents — each labeled at the document level against that state's landlord-tenant law. We transformed those labels into a clause-level training set:

training_example.jsonl
{
  "system_prompt": "You are a legal parser. Compare the lease clause against the
                    provided state law context. Output a reasoning trace and a
                    final conclusion.",
  "input": {
    "context":      "C.R.S. 38-12-105: a late fee may not exceed the greater of
                     $50 or 5% of the monthly rent, may not be imposed until the
                     rent is at least seven days late...",
    "lease_clause": "3. RENT ... If rent come late after the 3rd, late fee $50.
                     After the 5th day, I must charge more, total $75."
  },
  "output": {
    "trace":      "<trace> 1. The clause charges fees from day 4... 2. The provided
                   law bars any late fee before rent is seven days late... </trace>",
    "conclusion": "Predatory"
  }
}

Three design decisions did most of the work:

Training setup

ComponentChoiceWhy
Base modelQwen3-4B-Instruct-2507Apache-2.0 (commercially clean), strong instruction-following at 4B, runs on an 8 GB laptop GPU in 4-bit
MethodQLoRA — 4-bit NF4 base, LoRA r=16 on attention + MLP33M trainable params (0.8%); the adapter is a 132 MB file
HardwareOne rented A100 40GB (marketplace GPU)10–24 min per full run; every experiment in this post cost cents
Config3 epochs, effective batch 16, lr 2e-4 cosine, 2,048-token context, completion-only lossThe longest example is ~1,100 tokens, so nothing truncates

The same script trains on a consumer laptop (RTX 5060, 8 GB) in about an hour — the A100 was a convenience, not a requirement.

Four versions, three lessons

fig_01 · accuracy_by_version.svgeval

Verdict accuracy by model version

same 66-example held-out benchmark for all four versions · higher is better

90%92.5% 95%97.5%100% v1 reserved-token markers 93.9% v2 plain-text trace markers 97.0% v3 error-driven data fixes 98.5% v4 + 20th state labeled 98.5%

v4 matches v3 on the original 66 examples while adding a 20th state; on its expanded 78-example benchmark (12 new Missouri rows) v4 scores 98.7%, including 12/12 on documents from lease families it never saw in training.

Data table
VersionChangeAccuracy (66-example benchmark)
v1baseline, ⟨think⟩ trace markers93.9%
v2plain-text ⟨trace⟩ markers97.0%
v3context enrichment + 63 targeted synthetic examples98.5%
v4+ Missouri labeled (997 examples, 20 states)98.5% (98.7% on expanded 78)

Lesson 1 — never put a base model's reserved tokens in your training text

Our first dataset wrapped reasoning in <think>…</think> tags. It turns out <think> is a reserved special token in Qwen3's tokenizer — used by its hybrid thinking mode — so our "text" tokenized into control tokens the model had strong prior behavior about. The result: 65 of 66 generated traces opened with a stray </tool_call> token and dropped the closing tag entirely.

The surprise wasn't the formatting — it was the accuracy. Re-training with inert <trace>…</trace> markers didn't just clean the output; it recovered two real classification errors (+3.1 points). Fighting the base model's token priors was costing correctness, not just cosmetics. Our regression test now round-trips every training target through the tokenizer: decode(encode(target)) == target, or the build fails.

Lesson 2 — diagnose misses before adding data

v2 still missed two predatory clauses, and each miss had a different, instructive cause:

The fix for both was 63 surgically targeted synthetic examples (train-only, never evaluated on): late-fee scenarios with exact computable arithmetic across five states' cap formulas, violations deliberately buried inside benign-looking sections across nine states, and benign look-alikes so the model couldn't just learn "TERM + waiver words = predatory." Result: 98.5%, zero regressions.

Lesson 3 — new-state generalization comes from structure, not memorization

For v4 we labeled the 20th state (Missouri) with a statute-verified governing-law block and re-trained. The held-out set gained 12 Missouri rows from two lease families the model never trained on — including the corpus's single most abusive document, a flipped-house lease with 10 distinct violations (three separate self-help clauses, a habitability waiver, a gag on calling code inspectors, a Delaware arbitration trap, and more). The model went 12/12, correctly flagging every violation and passing both legal clauses.

Knowing when to ask a human

In a legal product, the failure mode that matters is the silent miss. So instead of trusting the model's final word, we measure it: at the position where the model commits to a verdict, we read the log-probability gap between Predatory and Legal. The distribution told us something striking:

fig_02 · confidence_margins.svglogprobs

Verdict confidence margin per held-out clause

logP(Predatory) − logP(Legal) at the conclusion token, v3 checkpoint, 66 clauses · hover a dot for details

Labeled Predatory (15)Labeled Legal (51)

The model is decisively confident on 64 of 66 clauses — the two exceptions are genuinely ambiguous cases (one was graded "concern," the softest tier, even by the human labeling pass). Anything landing in the normally-empty gap is routed to human review instead of silently passing.

Data table (abbreviated)
GroupCountMargin rangeAuto-verdict
Confident Predatory13+7.4 to +8.9Predatory
Uncertain (review band)2−9.25 to −9.0→ human review
Confident Legal51−11.75 to −9.375Legal

That bimodal shape became our deployment architecture — a three-way gate:

MarginActionHeld-out behavior
> 0Auto-flag Predatory13/13 correct — no false accusations
< −9.7Auto-pass Legal~90% of legal clauses clear automatically
in betweenHuman reviewCatches both borderline predatory clauses (~10% review load)

Two more guardrails sit around the model. A deterministic verifier — plain code, not a model — is the final authority on anything numeric (deposit caps, late-fee arithmetic). And the reasoning trace is shown to reviewers, so a wrong verdict is an argument you can inspect, not a black-box score.

What we'd tell other teams

Methodology & limitations

Evaluation used a held-out split of 78 clause-level examples (25 predatory, 53 legal) from lease families excluded from training, over a synthetic-but-adversarial corpus spanning 20 states and deliberately diverse drafting registers. We report verdict accuracy against the reference labels; reasoning traces were spot-audited, not exhaustively verified. Numbers this strong on a 78-example benchmark carry meaningful confidence intervals (roughly ±3 points), and synthetic leases — however varied — are not a substitute for evaluation on real-world documents, which is our next milestone before expanding rollout. The model's output is an analysis aid reviewed within our product flow; it is not legal advice, and the deterministic verifier plus human-review band exist precisely because no classifier should be the last line of defense in a legal workflow.

Want numbers like these on your use case?

A 30-minute model audit maps your workload to the same targets you just read about — accuracy, latency, and cost. If fine-tuning isn't the answer for you, we'll say that too.

Book my free model audit

no retainer · baseline guarantee on every build