fine-tuning series / LoRA vs Claude

Local LoRA fine-tune, benchmarked against Claude

A 1.5B model fine-tuned on a laptop sorts receipt items at 90.5%. Claude zero-shot: 80.0%.

Receipt Auditor reads photographed receipts, checks each total in code, strips card numbers and other personal data, and puts every line item in one of 12 spend categories. The categorizer is Qwen2.5-1.5B with a LoRA adapter trained on 4,799 synthetic line items. Claude, given the 12 labels and asked for one, gets 80.0% on the same rows. Most of its misses are items whose generated labels a person would argue with, which the fine-tune learned and Claude could not. Section 02 goes through them.

At a glanceeval/, measured 2026-10-28 and 2026-11-04
Task
Put one receipt line item in one of 12 spend categories
Eval set
220 items from 158 merchants absent from training; fixed merchant split, leakage-checked, same rows for every system
Baseline
80.0% Claude zero-shot with the label list; 27.7% base Qwen
Result
90.5% top-1, macro-F1 0.893 (199 of 220)
Cost / item
$0 local; Claude $0.0030 API-equivalent ($3.03 per 1,000)
Latency
LoRA 0.19 s median, 0.225 s p95 (n = 100, Apple M4); Claude 3.6 s, 9.0 s p95 (n = 25)
Main failure
Boundaries the synthetic labels draw both ways: 14 of 21 LoRA misses are hardware (household or other) or gasoline (fuel or transport). Section 03.
Benchmark, held-out test seteval/benchmark.json, eval/claude_rerun

The adapter takes the same 1.5B model from 27.7% to 90.5%

Base Qwen2.5-1.5Bn = 22027.7%
Qwen2.5-1.5B + LoRAn = 22090.5%
Claude, zero-shotn = 22080.0%
220 line items from 158 merchants that never appear in training; all three systems ran on every row. Claude's prompt adds the list of 12 allowed labels, which a zero-shot model needs and the fine-tune learned in training. Its answers were scored by exact match, and none of the 220 was invalid. Local runs cost nothing per call; Claude ran through claude -p on a Max subscription. Claude row rerun 2026-10-28 (the first run's 56.0% is retired; see section 02).

Benchmark

21 errors in 220, most of them between neighbors

The training data is 4,799 synthetic line items generated with claude -p in the abbreviated styles point-of-sale systems print: SBUX #4821, WM SUPERCENTER #2847, TST* JOE'S PIZZA. It is split by merchant into 4,309 train, 270 validation and 220 test rows, and a leakage check confirmed no merchant sits on both sides. Training ran on Apple Silicon with MLX: batch 4, 12 LoRA layers, 800 iterations, learning rate 1.5e-4. Validation loss fell from 5.085 to 0.419.

The fine-tuned model gets 199 of the 220 test items right. Its mistakes cluster where the categories touch: household goods called other, travel called transport, transport called fuel.

Per categorycorrect / test items

Household is the weak spot: 9 of 16, with 7 sent to other

Test counts per category from eval/dataset_card.json; LoRA errors from eval/confusions.json, Claude errors from eval/claude_rerun/results.json. The listed errors account for every miss: 21 for LoRA (199 correct), 44 for Claude (176 correct).
Latency, every itemeval/raw_outputs.json, eval/claude_rerun

In the benchmark run Claude's median call (2.4 s) beat the local model, which reloaded itself for every item

Hover a dot, or focus the chart and use the arrow keys.

Wall-clock seconds per item on a log scale; each dot is one call. The local runs include loading the model for every call through mlx_lm.generate, which is why the base model and the adapter both sit near 5 s. Means: 5.0 s base, 4.5 s LoRA, 3.3 s Claude (rerun; the first run, inside the repo with tools on, averaged 17.7 s). Section 04 times the adapter with the model loaded once: 0.19 s median.

Reading the gap

Most of the 10.5-point gap is label noise

Claude misses 44 of 220. 38 of those are rows the generator labeled other or transport where Claude answered household, health or fuel: paint, screws and HDMI cables labeled other, gasoline labeled transport. It gets none of the 28 other rows. The LoRA figure stands; these are the things to keep in mind when comparing it with Claude's.

  1. The labels are noisy, and the fine-tune learned the noise

    The test labels come from the same synthetic generator as the training data. A first-aid kit at CVS is other; Shell unleaded is fuel on one row and transport on another. The adapter learned those habits. Claude, answering one item at a time, often gives the more defensible category and is marked wrong.

    Test-set rows, verbatimlabel / LoRA / Claude
    Merchant, itemLabelLoRAClaude
    CHEVRON #7843UNLEADED 87 10.5 GALfuelfuelfuel
    HYATT REGENCY #3421LODGING ROOM 1705 2NTStraveltraveltravel
    CVS #2156FIRST AID KITotherotherhealth
    CVS PHARMACY #4782COLD RELIEF CAPLETS 24CTotherotherhealth
    STARBUCKS COFFEE #2156GRANDE CAFFE LATTEdiningcoffee_snackscoffee_snacks
    SHELL*GAS STATIONUNLEADED 87 12.5 GALtransportfuelfuel

    Claude's column is its answer in the 2026-10-28 rerun.

  2. Claude's prompt lists the labels; the fine-tune's does not

    Claude got the training prompt plus the 12 allowed labels and an instruction to answer with one of them. A zero-shot model has no other way to know the label set; the adapter learned it in training. The prompt is in eval/claude_rerun/prompt_template.txt.

  3. The base model's score comes from a reconstructed scorer

    The script that produced eval/benchmark.md was lost before it was committed. training/bench_categorizer.py was rebuilt against the stored raw outputs. It reproduces the test-set order and the LoRA figure exactly, because the adapter's outputs are bare category names. For the base model, which answered in prose, it gets about 26% against the published 27.7%.

Rerun, 2026-10-28. Until this date the page reported Claude at 56.0% on 150 rows. That run used claude -p from inside the repo with file access, and 101 of its 150 answers cite src/schemas.py, so most had read the category list from the code. Its answers were prose, 83 naming more than one category, and the script that picked one is lost. The rerun copies only the test rows and the prompt into a scratch directory, runs the CLI there with every tool disabled, and scores with an exact-match rule that lists anything else as invalid. Runner, prompt, raw outputs, scorer and results are in eval/claude_rerun/.

Everything above is on synthetic data. The spec calls for 10 to 20 hand-labeled receipts of my own as a final test, and the dataset card still records zero.

What the result does support is narrower than the headline. A 1.5B model with a 4M-parameter adapter learns a fixed 12-label scheme, quirks included, well enough to replace a frontier-model call for this step at no per-call cost.

Error analysis

Every miss, sorted into five kinds

All 65 misses (21 for the LoRA, 44 for Claude) from the saved predictions, grouped by a rule on the true and predicted labels. Four of the five groups are the same disputed boundaries, and both systems fall into all four; they disagree on which side of each boundary is right. The training data explains why. Of the 4,309 training rows, line items that look like gasoline are labeled fuel 370 times, transport 231 times and travel 67 times. Items from hardware-store merchants are household 118 times and other 109 times. A model can learn the majority habit and still miss every row that went the other way.

Misses by kind, 220 test itemseval/error_analysis.json
Hardware and supplies: household or otherLoRA 7Claude 20LoRA: ACE HARDWARE, PAINT ROLLER 9IN, household, said otherClaude: Best Buy, HDMI CABLE 6FT, other, said household
Gasoline: fuel, transport or travelLoRA 7Claude 11LoRA: Shell - Portland, FUEL PURCHASE REGULAR 16.2 GAL, fuel, said transportClaude: SHELL* FUEL 4729, Regular Gasoline, transport, said fuel
Airport rides: travel or transportLoRA 4Claude 4Both: UBER* TRIP, RIDE TO SFO AIRPORT, travel, said transport
Pharmacy items labeled otherLoRA 2Claude 6LoRA: Walgreens - Portland, MULTIVITAMIN 30CT, other, said healthClaude: WALGREENS #3421, FIRST AID KIT, other, said health
One-offsLoRA 1Claude 3Both: STARBUCKS COFFEE #2156, GRANDE CAFFE LATTE, dining, said coffee_snacksClaude: AP *METRO SUNDRIES, TRAVEL ADAPTER KIT, other, said travel
Total of 220LoRA 21Claude 44

Examples read: merchant, item, label, then the system's answer.

Counts and rows from eval/error_analysis.json, written by eval/error_analysis.py from eval/claude_rerun/results.json; no model was rerun. Training-label counts use a regex on item text (gasoline) and merchant name (hardware stores), listed in the same file.

Hardware is where the two systems split in opposite directions. The LoRA sends 7 household items (paint, caulk, hinges, trash bags) to other, following the half of its training data that did the same; Claude sends 20 other items (cables, screws, paint, laundromat time) to household. On 5 of the LoRA's 7 gasoline misses, Claude gave the same wrong answer. The kind of error a fine-tune removes here is a mismatch with the label scheme; it does not remove ambiguity the scheme itself contains.

Cost and latency

Loaded once, the adapter answers in 0.19 s at no per-call cost

The benchmark timed a fresh mlx_lm.generate process per item, so its 4.5 s per call is mostly model loading. A service loads the model once. With Qwen2.5-1.5B-Instruct (4-bit) and the adapter held in memory on an Apple M4 with 16 GB, the first 100 test items took a median 0.19 s each, 0.225 s at p95, about 5.2 items per second one at a time and 12.8 per second in batches of 16. All 100 answers match the benchmark's stored LoRA outputs. Loading the model and adapter takes 1.0 s.

For Claude I sent the first 25 of the same items through claude -p with the rerun's exact prompt and flags. Median 3.6 s, p95 9.0 s, and every answer matched the 2026-10-28 rerun. The CLI reports an API-equivalent cost of $0.0030 per item. On a Max plan nothing is billed per call; at API prices 1,000 items would cost $3.03. Each call sends about 745 input tokens, almost all of them written to the prompt cache, and gets back about 6.

Seconds per item, median bar and p95 tickeval/cost_latency/summary.json

Warm, the local model is 19 times faster than Claude at the median

LoRA, model loaded oncen = 100, 2026-11-04 0.19 sp95 0.225 s
LoRA, reloaded per itembenchmark run, n = 220 4.5 sp95 5.7 s
Claude zero-shotn = 25, 2026-11-04 3.6 sp95 9.0 s
Claude zero-shot2026-10-28 rerun, n = 220 2.4 sp95 6.5 s
Per 1,000 itemsLoRA on the M4Claude
Cost$0 per call$3.03 API-equivalent
Time, one at a time194 s4,401 s (73 min)
Time, batches of 1678 snot measured
Wall-clock seconds on an Apple M4 (16 GB, macOS 26.5), MLX 0.32, greedy decoding, up to 20 new tokens; Claude was claude-sonnet-5-5 through claude -p with tools off, two seconds between calls. p95 is nearest rank, so at n = 25 it is the 24th-slowest call. Per-1,000 figures multiply the measured means; the Mac's power draw was not measured, and Claude was not tried in parallel. The 25-call sample ran slower than the 220-call rerun a week earlier; both are shown. Script and raw calls: eval/cost_latency/.

Design

The model phrases numbers; code computes them

Two language models touch a receipt, and neither does arithmetic. Each step below is labeled with what runs it.

  1. 01codetesseract reads the photo locally.
  2. 02claude -pOne call per receipt rejects photos that are not receipts and fills a Pydantic Receipt. The raw OCR text is then discarded.
  3. 03codeprivacy.sweep_receipt masks card-like digit runs to the last four and replaces emails and phone numbers.
  4. 04codereconcile adds line items and tax and compares the sum with the printed total. A mismatch is flagged for a one-tap correction, never adjusted to agree.
  5. 05LoRA 1.5BEach line item gets one of 12 categories.
  6. Later, a spending question
  7. 06pandasanalysis.answer_query computes the figure from the stored receipts.
  8. 07claude -pWrites one sentence around the computed numbers.
  9. 08codeassert_numbers_present checks every number survived to within a cent. If one was dropped or changed, the app shows a template sentence instead.
Try the reconciliation rulesrc/reconcile.py
Computed total
22.41
Difference
-0.10
Tolerance
0.11

ok: within tolerance

Example values; change any of them. The rule is the one in the code: computed total is items plus tax (the printed subtotal is not trusted), and the tolerance is the larger of $0.02 or 0.5% of the printed total.

Privacy

Four layers of PII handling

The Receipt and LineItem schemas have no field for a cardholder name, card number, address or loyalty number, so extraction never asks for them. The raw OCR text is discarded once a validated Receipt exists. The regex sweep in step 03 catches what slips through and reports how many redactions it made. On a hosted deployment, receipts stay in memory for the session; privacy.is_space_environment() is the one check both paths use before writing anything to disk.

Build

Stack and series

Classifier
Qwen2.5-1.5B-Instruct (4-bit), LoRA via MLX on Apple Silicon, 3.96M trainable parameters, served with mlx_lm.generate
Extraction
tesseract OCR, then one claude -p call per receipt
Validation
Pydantic schemas; reconciliation and narration guard in src/reconcile.py
Queries
Spoken or typed questions parsed into a structured Query, answered by pandas, repeats served from a semantic cache
App
Gradio: batch upload, reconciled grid, dashboard, voice query
Tests
19, across reconciliation, PII redaction and the spend-query layer
Series
One of four apps that fine-tune a local model and benchmark it against Claude: Cellar Scanner, Race Day Copilot, Receipt Auditor, Menu Decoder

Read the code and the raw outputs