The Measurement Was Harder Than the Model A developer building a specialist AI agent to query Bureau of Labor Statistics data found that initial accuracy claims of 93% were invalid due to data leaks, stale evaluation files, and incorrect labels, with the true exact-match rate measured at 13.3%. The project, which distilled Qwen3-1.7B into a small model using MLX LoRA and 229 training examples, required redesigning the validation split to hold out phrasings rather than concepts and adding build-time assertions to prevent errors. The developer discovered that validation loss and actual tool-call accuracy disagreed violently, with the best exact-match score of 37.2% occurring at iteration 200 despite rising loss. The Measurement Was Harder Than the Model https://kovashikawa.github.io/ai/projects/distilling-bls-agent/ The idea Take Qwen3-1.7B, give it a set of BLS economic data tools, and distill it into a specialist small enough to run on an M4 Mini. Ask it “what happened to food prices since 2021?” and it should emit get series series id="CUUR0000SAF1", start="2021" . The pipeline: 205 hand-written seed questions over 82 economic concepts, 229 training examples, MLX LoRA, a 67MB adapter, six tools. That part worked. Everything I initially believed about how well it worked was wrong, in several separate ways, and finding each one required disproving the previous one. The measurement was harder than the model. The fix, when it finally came, was not a hyperparameter but a change to what the model was asked to say. Act 1: the number that wasn’t real Round one looked clean. Train loss fell from 3.4 to 0.027. Fifteen held-out examples, 87% accuracy. I fixed some data issues, retrained, got 93%. Base model scored roughly zero. Ship it. Then I ran a separate adversarial review of the pipeline, and none of it survived. The held-out set was not held out. I had split the seeds before expanding them, which sounds right. But the expander drew from shared pools a list of series IDs, a list of search terms regardless of which split a seed belonged to. So a training seed and a test seed could independently emit byte-identical rows. 34% of my validation set appeared verbatim in training. Worse, the eval file itself was stale: it was the first round’s holdout, and the second round’s reshuffle had moved all 15 of those questions into training. The 93% was measured on training data. The metric was the easy half. “Accuracy” meant did it pick the right tool : a 6-way classification. Whether the emitted call was actually correct was never scored. A ninth of the labels were wrong. Eleven series IDs named the wrong concept. CUUR0000SETG01 was mapped to “energy”; the BLS catalog calls it airline fares . CUUR0000SAH1 was “housing” but means shelter ; CUUR0000SAM1 was “medical care” but means medical care commodities . Four IDs SAR1 , SAC1 , SAS1 , SEHA01 do not exist in any BLS catalog at all. I had invented them by pattern-matching the real ones. Honest number, measured properly: 13.3% exact match. The lesson I’d draw is not “be careful.” It’s that every one of these bugs pushed the number up . Nobody investigates a pleasant surprise as hard as a disappointing one, and that asymmetry is the whole problem. Act 2: testing the impossible Fixing the leak exposed a deeper design error. The split held out whole concepts . If “medical care” appeared only in the test set, the model was being asked to produce CUUR0000SAM having never once seen that mapping. Four of eleven scored items were unanswerable by construction. This is a lookup task. "medical care" → CUUR0000SAM cannot be derived from first principles; it can only be recalled. So the split should hold out phrasings , not concepts: - Every concept contributes at least one phrasing to training - Remaining phrasings go to val/test - The build fails if any concept is held out entirely I rewrote the seed data as concept tables 82 concepts, each with 2+ distinct phrasings and added build-time assertions: every series ID must exist in the bundled 8,103-row catalog, no example may appear in two splits, no concept may be missing from train. Assertions, not intentions. Two of them have fired on me since. Act 3: the discovery With clean data I swept checkpoints from 100 to 1400 iterations, planning to pick the lowest validation loss like every tutorial says. Val loss bottomed at iteration 250 and rose steadily afterward. Textbook overfitting. But I was also scoring actual tool-call accuracy, and the two disagreed violently: | iter | val loss | exact match | |---|---|---| | 200 | 0.135 min | 37.2% | | 400 | 0.135 | 65.1% | | 600 | 0.147 | 90.7% | | 800 | 0.156 | 81.4% | | 1000 | 0.169 | 88.4% | | 1400 | 0.169 | 74.4% | Stopping at the val-loss minimum would have shipped a 37% model instead of a 91% one. I wrote this up as a finding: val loss is a trap for structured-output tasks. Cross-entropy punishes a confidently-wrong series ID exactly as hard as gibberish, while a task evaluator sees a near-miss. I had citations lined up. It was a config bug. Act 4: disproving my own finding Before publishing I checked one number I had never looked at: the ratio of completion length to prompt length. mean prompt tokens : 134 mean completion tokens : 19 generation ratio : 0.141 Every training row carried a ~120-token system prompt, byte-identical across the dataset, and a ~19-token tool call. And I was training with prompt loss unmasked . 87.7% of every gradient was the model re-predicting a fixed preamble. That explains all of it. Train loss of 0.027 was never impressive: most of it was copying a constant. And validation loss was ~88% a measurement of preamble reproduction , which is uncorrelated with whether the tool call is right. The two curves weren’t in tension for any deep reason. One of them was mostly noise. This is documented. Huerta-Enochian and Ko 2024 found a statistically significant effect of prompt-loss weight specifically for short-completion data, and Vaughn’s walkthrough of the same phenomenon on a multiple-choice dataset generation ratio 0.01 reports that stopping at the full-sequence val-loss minimum yields 53% accuracy while completion loss is still falling. I reproduced a known failure mode and mistook it for a discovery. So I masked the prompt and reran. Prediction: val loss should re-couple with accuracy. | iter | val loss masked | exact match | |---|---|---| | 200 | 0.046 | 62.8% | | 400 | 0.028 | 86.0% | | 600 | 0.027 min | 90.7% | | 800 | 0.028 | 90.7% | | unmasked | masked | | |---|---|---| | pick by val-loss minimum | 37.2% | 86.8% | | best checkpoint available | 90.7% | 88.4% | penalty for trusting val loss | ~50 pts | 1.6 pts | The trap was self-inflicted. Mask the prompt and standard practice works fine. Switching to chat-format data to enable masking fixed two other things I had been carrying without noticing: Qwen3’s chat template emits an empty