Structured extraction · Narrative → JSON · Showcase 3

Narrative in, JSON out: a 1.5B accident-report extractor that knows when to say null

Our first two showcases were a span tagger and a passage reader that answers questions. This one is neither. It reads a U.S. aviation accident narrative and writes one flat JSON record: aircraft, damage, injuries, operating rule, flight purpose, date, state and pilot hours. When the narrative doesn't state a field, it writes null instead of guessing. On 1,234 test narratives fixed before any model was scored, it gets 0.959 of fields exactly right, against 0.494 for the same base model. When a field isn't in the text, it leaves it null 97.6% of the time, against 54.5%. All three training seeds passed the rule we wrote down first. It is on the Hugging Face Hub.

For education only. The model in this post demonstrates a training method. It is not an aviation-safety or accident-investigation expert and makes no claim to be one. It misses about half the aircraft model designators the narratives state (see below). Its output is not an NTSB finding. For anything real, use the official records at data.ntsb.gov.

The result, in one table

1,234 held-out test narratives. Every output is parsed as JSON and compared field by field with the answer key. The comparison is typed: numbers as numbers, strings ignoring case and spacing, and null, a missing key and "" all count as null.

Base model, same promptBase model, schema-aware prompt*Fine-tuned (seed 42, published)
Output matches the schema (valid JSON, every field, right types, allowed values)0.0420.7700.995
Fields exactly right (13 fields, all rows)0.4940.4810.959
Whole record right (all 13 fields)0.000—0.625
When it says null, the field really is unstated (null precision)0.9010.5330.954
When a field is unstated, it says null (null recall)0.5450.3710.976

* A 300-row subset of the same test set, run by a separate script and scored with BrewSLM's own scorer. It gives the base model its chat template, every field's type and allowed values, and "use null when the narrative doesn't state it". We ran it so the fine-tune couldn't be credited with what a better prompt would have done anyway. Prompting fixed the format (0.042 → 0.770 schema-valid), but accuracy didn't move, and the base model started inventing: guessed dates, "Not specified in the narrative" as an aircraft make, values for fields the text never mentions. Null recall fell, from 0.545 to 0.371. Telling a small model "don't guess" isn't the same as teaching it not to.

Why this task, and why null is the point

Extraction is the most common thing people want a small model for that isn't chat: read a document, fill in a form. The NTSB's accident database is a good test bed. It's public domain, it's large, and every event has both a narrative written by an investigator and a set of coded fields filled in by hand. That looks like a ready-made answer key. It isn't, and that's what this project was about.

The coded fields come from the whole investigation: forms, records, interviews. The narrative is a summary, and it often doesn't repeat them. In the 12,421 events we used, the narrative names the aircraft make in 40% of cases and states the pilot's total hours in 7%. Train a model to output the coded value from the narrative, and for those rows you are training it to produce a fact that isn't in its input. That is invention, taught on purpose. A model trained that way will fill in the pilot's hours for any narrative you give it.

So the answer key is masked. For each field we start from the investigators' coded value, and keep it only if a fixed, deterministic text check finds it stated in the narrative. Otherwise it's null. The make or model has to appear as written. The injury counts have to be stated. The date has to appear in the text: when the text's date is a day off the coded one, the text wins, because the model can only learn to read what's there. No language model touched the labels, training or test. The masking script went through three rounds of fixes on a 50-row hand audit. Its first version, for example, accepted the bare word "air" as a mention of the make Air Tractor.

That gives the score a specific meaning. It measures extracts what the text says, not knows what happened. It is also slightly conservative. A check can miss a value phrased in an unusual way, and the model is then marked wrong for extracting it.

The rule, written down first

Before training we froze the rule and a checksum of it. Three seeds (13, 42, 1234) train on identical data. Each seed is evaluated on test once, and every gate must pass on every seed:

Gate (test)FloorBaseSeed 13Seed 42Seed 1234
Output matches the schema≥ 0.950.0420.9940.9950.997
Fields exactly right, all 13≥ 0.850.4940.9580.9590.957
Weakest of the 8 fact fields, exactly right≥ 0.70—0.7490.7520.742
Null precision≥ 0.950.9010.9550.9540.952
Null recall≥ 0.900.5450.9760.9760.977
Weakest of the 8 fact fields, null recall≥ 0.80—0.9640.9640.964
Paired per-row gain over base, 95% interval> 0—+0.430 to +0.458+0.430 to +0.458+0.429 to +0.457

Validation scores were 0.9595, 0.9614 and 0.9610 for seeds 13, 42 and 1234, so seed 42 is the published one. All three seeds passed every gate. Pass. The seeds agree to within 0.002 on field accuracy, so this isn't a lucky draw.

The Results → Verdict card for run #164: Better than the base model, across 3 seeds. Run #164, NTSB extractor, Qwen2.5-1.5B, 3 seeds vs base Qwen/Qwen2.5-1.5B-Instruct, 1234 test examples. f1: 0.519 (base) → 0.960 ± 0.001 (fine-tuned, mean of 3 seeds), +0.442. Holds across seeds: F1 0.960, 0.961, 0.960, every seed beat the base model; runs #165 (seed 13) 0.960, #166 (seed 42) 0.961, #167 (seed 1234) 0.960; failures and row counts are from run #165, the median seed. Ship one seed, picked by lowest validation loss, ties to the lower run id, test not used to pick: seed 13 run #165 validation loss 0.008 test f1 0.960; seed 42 run #166 0.008, 0.961; seed 1234 run #167 0.008, 0.960, marked picked; button Ship this seed (run #167). Gain on these rows: fine-tuning helped 1228 rows, hurt 3, no change on 3; average change +0.444 F1 per row, 95% range +0.430 to +0.458, a gain beyond row-to-row noise.
Results → Verdict for the three-seed run. Two things differ from the tables above. The card scores per-field F1 (0.519 → 0.960), not exact match (0.494 → 0.959). And BrewSLM's built-in picker chooses by lowest validation loss, so it marks seed 1234 (0.00782 against 0.00784 for seed 42, a gap the card rounds away). Our frozen rule picks by validation field accuracy, which gives seed 42. Either choice is within seed-to-seed noise; we published the one the rule named.

Where it still fails

FieldExactly right, all rowsRight when the text states it (rows)Null recall
aircraft_model0.7520.388 (454)0.964
pilot_total_hours0.9700.765 (85)0.985
aircraft_make0.9170.811 (508)0.992
damage0.9770.990 (1,155)0.785
the other 9 fields0.960 to 0.9940.975 to 0.9960.946 to 0.991 (category: 0.571, 14 rows)

What BrewSLM needed for this

Three things BrewSLM couldn't do at the start, each built as a general feature rather than for NTSB:

Training

StageWhat came out
PopulationNTSB accidents since 2015, one aircraft, with a probable-cause narrative under 14,000 characters: 12,421 events
Split, by a hash of the event id9,931 train / 1,256 validation / 1,234 test; the import found 0 shared inputs and 0 duplicates
PromptThe field list, "reply with a single JSON object", the narrative. No chat template. Longest row 3,366 tokens.
Training, per seedQwen2.5-1.5B-Instruct, LoRA r 16 on every linear layer, loss on the JSON answer only, max length 4,096 (nothing truncated), 1 epoch, 621 steps, about 4 hours on an NVIDIA GB10
Final loss (seed 42)train 0.009, validation 0.008

One seed didn't start cleanly. Seed 1234 failed two minutes in, before its first training step, on a DNS error reaching the model hub. No eval had run, so we restarted it with the same config. We mention it because the run log shows a FAILED row, and it should.

Try it

The model is TensorGreed/brewslm-ntsb-accident-extractor (Apache 2.0, merged weights, about 3 GB). Its card has the exact prompt, a working transformers snippet, the schema with every allowed value, and every number above. Before publishing, we re-ran that snippet against the merged weights on 100 test narratives. It parsed all 100 outputs and scored 0.976 field accuracy on that sample.

The same run from a script

Against a running BrewSLM, the whole project is a handful of requests:

# recipe + output schema (types, enums, nullable fields)
PUT  /api/projects/{id}/recipe                 {"recipe_id": "json-extraction"}
PUT  /api/projects/{id}/recipe/output-schema   {"output_schema": { ...13 properties... }}
# your own splits, exact membership; refuse any train row that leaks into val/test
POST /api/projects/{id}/dataset/predefined-split   train=@train.jsonl val=@val.jsonl test=@test.jsonl on_leak=fail
# three seeds of one config, as a seed group
POST /api/projects/{id}/training/experiments   {"name": "...", "config": {
  "base_model": "Qwen/Qwen2.5-1.5B-Instruct", "use_lora": true,
  "max_seq_length": 4096, "seeds": [13, 42, 1234]}}
# one full held-out eval per seed, greedy
POST /api/projects/{id}/evaluation/run-heldout?async_job=true   {"experiment_id": {run},
  "dataset_name": "test", "max_samples": 5000, "max_new_tokens": 512, "temperature": 0}

The NTSB data is U.S. government work in the public domain. Courtesy: National Transportation Safety Board. The answer key is a masked derivative of the NTSB's coded fields, not NTSB findings, and this project isn't affiliated with or endorsed by the NTSB. NTSB accident reports can't be used as evidence in a civil action for damages (49 U.S.C. § 1154(b)), and nor should a model trained on them.