Local LoRA fine-tune, benchmarked against Claude
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.
The adapter takes the same 1.5B model from 27.7% to 90.5%
claude -p on a Max subscription. Claude row rerun 2026-10-28 (the first run's 56.0% is retired; see section 02).Benchmark
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.
Household is the weak spot: 9 of 16, with 7 sent to other
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).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.
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
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.
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.
| Merchant, item | Label | LoRA | Claude |
|---|---|---|---|
| CHEVRON #7843UNLEADED 87 10.5 GAL | fuel | fuel | fuel |
| HYATT REGENCY #3421LODGING ROOM 1705 2NTS | travel | travel | travel |
| CVS #2156FIRST AID KIT | other | other | health |
| CVS PHARMACY #4782COLD RELIEF CAPLETS 24CT | other | other | health |
| STARBUCKS COFFEE #2156GRANDE CAFFE LATTE | dining | coffee_snacks | coffee_snacks |
| SHELL*GAS STATIONUNLEADED 87 12.5 GAL | transport | fuel | fuel |
Claude's column is its answer in the 2026-10-28 rerun.
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.
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
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.
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
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.
Warm, the local model is 19 times faster than Claude at the median
| Per 1,000 items | LoRA on the M4 | Claude |
|---|---|---|
| Cost | $0 per call | $3.03 API-equivalent |
| Time, one at a time | 194 s | 4,401 s (73 min) |
| Time, batches of 16 | 78 s | not measured |
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
Two language models touch a receipt, and neither does arithmetic. Each step below is labeled with what runs it.
tesseract reads the photo locally.Receipt. The raw OCR text is then discarded.privacy.sweep_receipt masks card-like digit runs to the last four and replaces emails and phone numbers.reconcile 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.analysis.answer_query computes the figure from the stored receipts.assert_numbers_present checks every number survived to within a cent. If one was dropped or changed, the app shows a template sentence instead.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
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
mlx_lm.generatetesseract OCR, then one claude -p call per receiptsrc/reconcile.pyQuery, answered by pandas, repeats served from a semantic cache