The scripts are in the repository, not in the installed package. Clone it,
then
cd recipes/community/what-trl-does-with-the-loss-mask before running the commands below. Browse this recipe on GitHub.wai.export(..., format="trl") writes a loss_mask on every row and reports
mask_mode: "assistant". TRL’s SFTTrainer has no concept of that column.
It loads the file happily and trains on the whole sequence — the system
prompt, the customer’s message and the tool’s JSON result included. This
recipe measures how much that changes the model you get.
What you will learn: that the exported file trains on 9x the tokens the
export marked; that TRL’s own assistant_only_loss=True cannot fix it on
Qwen2.5-Instruct; and what the two models actually differ by, with paired
intervals on a held-out set. You need nothing for the offline half (no key,
no account, no GPU) and a Modal account for the GPU half. Two minutes
offline; nineteen A10G-minutes for both arms.
Run against whileai==0.88, trl==0.19.1, transformers==4.54.0,
peft==0.16.0, torch==2.7.1.
Run it
The setup
94 graded SFT rows fromwai.seeded_agent + simulate(simulator=False), no
model key. Split by scenario_id into 94 train and 57 holdout, so no
situation straddles the two. select(mode="sft") kept 94 of 99 (4 dropped
for quoting their own privileged context). Exported with format="trl".
Two LoRA arms, identical rows, hyperparameters and seed. Only the labels
differ:
as_exported is faithful: loading the same file into a real SFTTrainer on
CPU supervises 0.964 of tokens (the gap is batch padding, which is masked).
The export’s own report agrees about the intent: trained_messages: 188,
masked_messages: 282. TRL trains on all 470.
TRL’s escape hatch does not work here:
Qwen/Qwen2.5-0.5B-Instruct nor Qwen/Qwen2.5-1.5B-Instruct has a
{% generation %} block. (Watch out: a substring search for generation
gives a false positive on add_generation_prompt — match the block.)
What you get
Base sampled three times over the same 57 prompts for the noise floor; paired bootstrap over prompts, 10k resamples.
Both surviving intervals exclude zero, and both arms sit outside the base
band on length. Which masking you get decisively changes the model.
At this data scale the arm that honours the mask is worse: it collapses
into stub replies 42% of the time (“Okay. Can you please provide more
details?”) against 16% for
as_exported. That is what 11% supervision on 94
rows buys you — starvation, not virtue. The useful claim is not “the mask is
wrong”; it is that the choice is consequential and currently invisible, and
a reader of mask_mode: "assistant" cannot tell which of these two models
they trained.
The pre-registered metric that found nothing
Before looking at any arm output I registered this primary metric: the share of replies containing content that only ever appears in a masked-off message — a tool-result JSON key or a rendered role marker. The damage story saysas_exported should emit more of it.
It was exactly 0.000 on every arm, including base. A floor effect: with
94 rows neither arm learned to emit tool JSON at all. No support for the
obvious story. It is in the table because it was pre-registered, not because
it worked.
What did not work
- The pre-registered primary metric had no resolution. See above. A bigger training set, or a metric measured on tokens rather than whole replies, would have more chance.
- Neither arm learned to call tools. Both produce plain prose. 94 rows and 3 epochs of LoRA on a 1.5B is not enough to teach the tool protocol, so this recipe says nothing about tool-calling accuracy.
- I ran
decontaminateand then did not apply it. It found 4 near- duplicate rows (8-gram,n_near: 4,n_kept: 90) and I exported the original 94 anyway. Both arms trained on the identical 94 rows, so the A−B comparison is unaffected; any arm-vs-base reading inherits a 4.3% contaminated train set.decontaminatereturning a(rows, report)tuple whileexportreturns a bare dict (issue #457) is exactly the shape that invites this mistake, and I made it. - Training losses are not comparable across arms (1.614 vs 0.730) — they
are averages over different label sets. Ignore them; they are in
results.jsononly for the record. - One seed, one pair of arms. The noise floor is three base passes, but each arm was trained once. A second seed per arm is the obvious next run.
Cost
One A10G, ~19 minutes wall clock for the base noise floor plus both arms plus three eval passes. About $0.35. The offline half is free. Modal appap-W5HWyHhAx9Sy435whg9R2x, stopped.
Next
- Run both arms at 500+ rows. The starvation explanation predicts the gap
closes and may reverse; if
mask_honoredwins there, the ignored mask is a real correctness bug rather than a documentation one. - Add a third arm at
mask_mode="final"and one atunroll=True, whichtraining_rowsdocuments as the properly-masked multi-turn shape. - The ~20 lines in
run.py:tokenizethat turn a per-messageloss_maskinto per-token labels are the only thing here the SDK should arguably own (issue #507).