fine-tuning series / structured-output LoRA

Local LoRA fine-tune, benchmarked against Claude

A 1.5B model tags dishes for 14 allergens with no schema in the prompt

Menu Decoder reads a photographed menu, translates each dish, and tags it for cuisine, 14 EU allergens, spice and diet. The tagger is Qwen2.5-1.5B with a LoRA adapter that writes the whole tag set as JSON from a prompt as short as Dish: Pad Thai. Claude given that prompt describes the dish instead. Claude given the full schema catches more allergens than the fine-tune does, and misses fewer outright.

At a glanceeval/benchmark.json, eval/raw_outputs.json
Task
From a bare Dish: X. prompt, write JSON tags for cuisine (15), 14 EU allergens, spice and diet
Eval set
303 dishes held out by dish family (101 families, 3 variants each), leakage-checked; Claude on a 100-dish subsample. Labels are Claude-generated and not yet human-checked
Baseline
Claude with the schema in its prompt: 79.2% allergen recall, 1.7% false-safe, 81.0% cuisine
Result
LoRA: 100% valid JSON, 99.3% cuisine, 69.3% allergen recall, 7.3% false-safe
Cost / dish
$0 per call on the laptop; schema-primed Claude $0.056 API-equivalent ($56.02 per 1,000), from 25 dishes rerun on 2026-11-05 with the benchmark's prompt and CLI flags (eval/claude_cost/)
Latency
LoRA 6.5 s median, 7.6 s p95 (n = 303, model reloaded every call); Claude with schema 12.3 s, 18.4 s p95 (n = 100)
Main failure
Rare allergens: it catches 0 of 11 sulphite dishes and 0 of 2 celery dishes, and calls 39.1% of the 23 mollusc dishes safe
Held-out test set, 303 disheseval/benchmark.json
System
Valid JSON
Cuisine
Allergen recall
False-safe
Base Qwen2.5-1.5Bbare prompt, n = 303, 5.4 s
0.0%
0.0%
0.0%
100.0%
Qwen2.5-1.5B + LoRAbare prompt, n = 303, 6.7 s
100.0%
99.3%
69.3%
7.3%
Claudebare prompt, n = 100, 16.5 s
94.0%
43.0%
0.0%
100.0%
Claudeschema in prompt, n = 100, 13.4 s
99.0%
81.0%
79.2%
1.7%
Allergen recall is macro recall on dishes labeled contains. False-safe is the share of contains dishes tagged not_indicated, the dangerous miss; a may still warns, so it does not count. Claude ran on a 100-row subsample.

Benchmark

Why Claude has two rows

Given only Dish: X., Claude answers a different question, "tell me about this dish". It returns valid JSON 94% of the time, but under its own keys (region, main_ingredients, flavor_profile), none of which match the scored fields, so its allergen scores are zero by construction. The second row appends src/decoder.py's tagging rubric and a description of the output format, with no examples and no answers. With that, Claude beats the fine-tune on allergens.

The fine-tune's case is narrower. The 14-allergen contract lives in its weights, so the prompt can be one line, and it runs locally with no per-call cost. It also gets cuisine right 99.3% of the time against Claude's 81.0%.

Per allergen

Close on the common allergens, behind on the rare ones

An average hides the dangerous cases, so the benchmark scores each allergen on its own. Switch to false-safe to see where the fine-tune tells a diner a dish is clear when it is not.

Per allergen, LoRA vs Claude with the schemaeval/benchmark.json

Within 11 points on the eight common allergens, then the fine-tune falls behind

Higher is better. Rows run from most to fewest test cases.

LoRA (square)Claude, schema (diamond)
0%50%100%
n is the number of test dishes labeled contains for that allergen: the first figure for LoRA on all 303 rows, the second for Claude on its 100-row subsample. Faded rows have fewer than 5 Claude cases. Lupin had no contains cases and is left out.

Molluscs is the worst result: the fine-tune tagged 39.1% of mollusc dishes "not indicated", and peanuts follow at 18.2%. Sulphites and celery show 0% recall with no false-safes, which means the fine-tune never said contains for either; it said may, a warning without confidence.

Fail-safe design

The app can warn about an allergen but never clear one

The tagger misses allergens, so the app around it assumes it will. Three layers push every decision toward a warning, and two of them are plain code the model cannot override.

01Tagging rubricAnswer may whenever an allergen is plausible in a typical preparation: sesame oil in many Asian dishes, gluten in most sauces and breading. "When uncertain, prefer may over not_indicated."
02Badge rendererALLERGEN_BADGE in src/safelist.py maps contains to a cross, may to "ask the staff" and not_indicated to an open circle. It has no entry for a check mark.
03Safe-list filterfilter_safe_dishes removes every dish marked contains or may for any of the diner's allergens, and any dish that fails a stated diet or spice limit, before the recommendation call. The model picks from what is left.

The cost of layer 03 shows up when you run it over the fine-tune's own tags for the 303 test dishes. Pick allergens below and see how much of the menu survives.

Layer 03 on the test seteval/raw_outputs.json, lora_out

A gluten allergy clears none of the 303 test dishes

The fine-tune tagged gluten contains on 137 dishes and may on the other 166.

Allergens
0
of 303 dishes left to recommend from
containsmaydiet or spiceleft
Same rule as is_safe_for_brief in src/safelist.py, applied in your browser to the fine-tune's stored output for each test dish. A dish is counted under the first rule that removes it. The fine-tune called 11 of the 303 test dishes vegetarian and none vegan.

The filter is doing what it was built to do, and this is what it costs. For gluten the filter leaves nothing, for sulphites 31 dishes, and for soy or sesame fewer than half the menu. A diner with one of those allergies gets few or no recommendations and a menu of "ask the staff" badges. Lines the vision step can't read stay on the menu, flagged as unreadable, rather than disappearing.

Training

6,750 synthetic dishes, split by dish family

scripts/gen_dishes.py generated 6,750 rows with claude -p: 15 cuisines, 150 dishes each, three prompt variants per dish (name only, name with description, original script with translation, such as ผัดไทย (Pad Thai)). The three variants of a dish always share a split, so the model can't memorize a dish from one variant and get credit on another. Row IDs are hashes, so training/prep_tagger.py rebuilds the generator's item list to recover each row's family: 2,250 families, 6,096 train, 351 validation and 303 test rows, leakage check passing.

Training used MLX on Apple Silicon: batch 4, 12 LoRA layers, 800 iterations, learning rate 1.5e-4, sequence length 768, set after measuring a median target of about 204 tokens.

Limitations

Labels from Claude, no human check yet

Every label in the benchmark was generated by Claude, and Claude is also one of the systems being scored. The spec requires a human spot-check of about 60 dishes stratified across cuisines; it has not been done. Until it is, the Claude rows measure agreement with Claude's own labels, and every figure here is about synthetic data.

The first Claude run was contaminated. claude -p ran inside the repo, and 8 of the first 100 answers read the training code to find the schema. Turning tools off made it worse: answers turned into narrated tool calls. Running the CLI from a scratch directory outside the repo fixed it, and the rerun showed no contaminated answers.

The LoRA path in the app was broken at first too. It parsed mlx_lm.generate's raw output, which wraps the JSON in a banner, and expected a dish_id the model never produces, so every call fell back to the default. Both are fixed and the 10 tests pass. This is not a medical device; confirm allergens with restaurant staff.

Build

Stack and series

Tagger
Qwen2.5-1.5B-Instruct (4-bit), LoRA via MLX, served with mlx_lm.generate; a batched Gemini call is the fallback when no adapter is present
Extraction
One vision call per menu into a structured Menu: language, sections, dishes, translations, prices
Cache
Translations and tags keyed on (original_text, language)
Speech
Dish names read aloud in the menu's language; the button hides for unsupported locales
App
Gradio
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 per-allergen results