A LoRA SFT fine-tune of
Qwen/Qwen3.5-2B on the success-filtered split of
colab-potsdam/playpen-data, submitted to the
LM Playschool Challenge.
Evaluated with
playpen eval --suite all. Per-game and per-benchmark breakdowns in
results/iter2.md.
1git clone https://github.com/silvererudite/lm-playschool-submission && cd lm-playschool-submission
2bash scripts/setup.sh
3# paste your HF token into playpen/key.json
4source env.sh
5python -m accelerate.commands.launch --num_processes 4 --num_machines 1 \
6 --mixed_precision bf16 \
7 "$(which playpen)" run scripts/train_sft.py -l Qwen3.5-2B
-
modules_to_save=[] instead of upstream's ["lm_head", "embed_token"]. This was the load-bearing fix between iter 1 (regression to clemscore 3.86) and iter 2 (gain to 46.66). Reasons:
- Typo bug: the upstream string is
embed_token (singular). Qwen's actual module is embed_tokens (plural), so peft silently ignored that entry — only lm_head was made trainable.
- Tied-weight breakage: Qwen3.5 ships with
tie_word_embeddings=True (the input embedding is the output head). Adding lm_head to modules_to_save causes peft to set tie_word_embeddings=False and create a separate trainable copy. After thousands of steps the head drifts away from the still-tied embedding, breaking an architectural invariant the model was pretrained against.
- With
modules_to_save=[], training is pure low-rank LoRA — no full-rank parameters touched, tied weights preserved.
-
max_length=1024 instead of upstream's 300. Empirical: median episode length in the success-filtered dataset is 462 tokens, p75 is 814, p90 is 1373. At 300, ~64% of training sequences had their assistant turn truncated; with TRL's effective full-sequence loss, that's ~64% of training compute spent on prefixes that never reached the target. Bumping to 1024 covers ~82% of episodes uncut. 2048 would cover ~95% but costs ~4× attention memory; we left the headroom for iter 3.
-
eval_strategy="no" (no in-training eval). TRL's eval loop disables gradient checkpointing for eval batches. On 4× A10G this OOMed at the first epoch boundary (iter 2's first attempt crashed at step 2021/6063 trying to allocate +7.58 GB on top of 15.85 GB already in use). We measure model quality via the full clembench eval after training, so eval_loss during training adds nothing.
-
save_strategy="steps", save_steps=1000, save_total_limit=2. Crash-recovery insurance. With save_strategy="epoch" the first checkpoint wouldn't appear until ~1.5 h in; on shared infrastructure that's a long uninsured stretch.
-
DDP via accelerate with a small monkeypatch in train_sft.py: clemcore's huggingface backend hardcodes device_map="auto", which pipeline-shards the model across all visible GPUs and runs only one GPU at a time. Under accelerate (LOCAL_RANK set), we override AutoModelForCausalLM.from_pretrained to use device_map={"": LOCAL_RANK} so each rank holds a full model copy on its own GPU. Single-process pipeline-parallel iter 1: 4 h. 4-GPU DDP iter 2: 2 h 36 m.
-
Generation-config fix on the merged model. Qwen3.5-2B's text_config.eos_token_id is 248044 (<|endoftext|>), but its chat template terminates with <|im_end|> (248046). After SFT, the model emitted <|im_end|> correctly but model.generate() didn't stop there (wrong eos_token_id), causing the model to "continue" past its turn and hallucinate fake user/assistant exchanges. Fixed by setting eos_token_id: [248046, 248044] and pad_token_id: 248044 in generation_config.json of the uploaded model. This is a property of the model, not an eval-pipeline trick.
Apache 2.0 (inherited from Qwen3.5-2B and from the LoRA-only adapter on top).