Does LoRA SFT LR 3e-4 beat 1e-4 on no_robots for Qwen3.5-2B?
Rank-32 LoRA SFT of Qwen3.5-2B on no_robots at two learning rates; we compare held-out per-token NLL on 300 test examples after 60 steps.
change
Run experiment
Spec
LoRA SFT LR 3e-4 vs 1e-4 on no_robots, Qwen3.5-2B
Hypothesis. For rank-32 LoRA SFT of Qwen/Qwen3.5-2B on no_robots, LR 3e-4 gives at least 0.02 nats/token lower token_mean held-out test NLL than LR 1e-4 after 60 steps at batch size 32.
Method. Run the stock tinker_cookbook.recipes.chat_sl.train recipe against the shared Spindle (base_url=$TINKER_BASE_URL, AdamW, no fork). There are two arms: baseline lr=1e-4 and treatment lr=3e-4. Both use rank 32, batch 32, max_length 2048 and 60 steps.
Success criterion. Let D = baseline token_mean NLL minus treatment token_mean NLL (nats/token) on no_robots test[:300] at step 60, paired by seed. Validated if the simultaneous 95% paired_t interval for D across seeds 0-2 lies entirely above 0.02. Failed if the interval lies entirely below -0.02 (1e-4 better by at least 0.02). Otherwise inconclusive. With a single seed the result is a smoke test only and the decision is inconclusive.
full spec
{
"activation_gate": {
"applicable": true,
"checks": [
{
"max": 0.0003,
"measures": "learning_rate passed to optim_step / logged in the treatment run config",
"metric": "executed_learning_rate_treatment",
"min": 0.0003
},
{
"max": 0.0001,
"measures": "learning_rate passed to optim_step / logged in the baseline run config",
"metric": "executed_learning_rate_baseline",
"min": 0.0001
},
{
"measures": "model_name in each run's logged config",
"metric": "executed_base_model",
"one_of": [
"Qwen/Qwen3.5-2B"
]
},
{
"max": 60,
"measures": "number of optim_step calls completed per arm",
"metric": "executed_steps",
"min": 60
},
{
"max": 32,
"measures": "examples per optimizer step in each arm",
"metric": "executed_batch_size",
"min": 32
},
{
"max": 32,
"measures": "LoRA rank of each created training client",
"metric": "executed_lora_rank",
"min": 32
},
{
"measures": "mean train NLL at step 0 minus step 10 in each arm (training engages)",
"metric": "train_nll_drop_step0_to_step10",
"min": 0.0
}
],
"reason": "The only treatment is the learning rate. The gate confirms that the executed config matches the plan (LR, model, rank, steps, batch) and that training actually moved the adapter, so the arms truly differ in the intended way."
},
"arms": [
{
"lr": 0.0001,
"name": "baseline_lr1e-4_seed0",
"seed": 0
},
{
"lr": 0.0003,
"name": "treatment_lr3e-4_seed0",
"seed": 0
},
{
"lr": 0.0001,
"name": "baseline_lr1e-4_seed1",
"seed": 1
},
{
"lr": 0.0003,
"name": "treatment_lr3e-4_seed1",
"seed": 1
},
{
"lr": 0.0001,
"name": "baseline_lr1e-4_seed2",
"seed": 2
},
{
"lr": 0.0003,
"name": "treatment_lr3e-4_seed2",
"seed": 2
}
],
"base_model": "Qwen/Qwen3.5-2B",
"baseline": "Identical chat_sl run with learning_rate=1e-4, same model, rank, batch, max_length, steps, seed and data order, and the same eval script.",
"batch_size": 32,
"budget": {
"gpu_hours": 1.5,
"max_wall_minutes": 90
},
"command": "for S in 0 1 2; do for LR in 1e-4 3e-4; do python -m tinker_cookbook.recipes.chat_sl.train model_name=Qwen/Qwen3.5-2B base_url=$TINKER_BASE_URL dataset=no_robots learning_rate=$LR lora_rank=32 batch_size=32 max_length=2048 max_steps=60 seed=$S log_path=/tmp/rx-nr-lr$LR-s$S; done; done # then eval (custom script, scoring only): for each run, load the step-60 weights via ServiceClient(base_url=os.environ['TINKER_BASE_URL']).create_lora_training_client(base_model='Qwen/Qwen3.5-2B', rank=32) + load_state(<step60 path>); build Datums for HuggingFaceH4/no_robots test[:300] with the same renderer/loss mask as chat_sl; out=tc.forward(datums,'cross_entropy').result(); per example nll_i=-sum(lp*w), n_i=sum(w) from out.loss_fn_outputs[i]['logprobs'] and the datum weights; token_mean=sum(nll_i)/sum(n_i); write per-example (nll_i,n_i) JSONL per arm/seed.",
"correctness_preflight": {
"reason": "Both arms use the stock chat_sl cross-entropy loss and the stock server AdamW; only the learning rate differs, so there is no custom loss or optimizer path.",
"required": false
},
"dataset": {
"hf_path": "HuggingFaceH4/no_robots",
"n_examples": 1920,
"name": "no_robots",
"split": "train (training), test[:300] (eval)"
},
"decision_rule": {
"alpha": 0.05,
"assumptions": {
"identically_distributed_seed_effects": true,
"independent_seed_effects": true,
"normal_seed_effects": true
},
"criteria": [
{
"id": "primary",
"margin": 0.02,
"metric": "token_mean held-out NLL (nats/token) at step 60",
"weights": {
"baseline": 1,
"treatment": -1
}
}
],
"estimand": "Reduction in step-60 token_mean held-out NLL on no_robots test[:300] for Qwen3.5-2B rank-32 LoRA SFT from using LR 3e-4 instead of 1e-4; positive favors 3e-4",
"method": "paired_t",
"seeds": [
0,
1,
2
],
"unit": "training_seed",
"version": 1
},
"eval": {
"aggregation": "token_mean",
"benchmark": "no_robots test split, first 300 examples",
"metric": "token_mean held-out NLL (nats/token) at step 60",
"n_examples": 300
},
"experiment_features": {
"adaptive_selection": false,
"checkpoint_restore": false,
"custom_loss": false,
"custom_optimizer": false,
"generated_training_data": false,
"heldout_loss": true,
"learned_generator": false,
"paired_comparison": true,
"paired_training_arms": true,
"warmup_adaptation": false
},
"feasibility_check": {
"gpu_hours": null,
"known_gpu_hours": 0,
"phases": {},
"problems": [
"Measured feasibility not recorded"
],
"status": "unknown",
"wall_seconds": null
},
"feasibility_required": true,
"generation_audit": {
"answer_comparison": "categorical_exact",
"max_eval_overlaps": 0,
"max_label_errors": 0,
"min_sample_size": 100,
"required": false
},
"hypothesis": "For rank-32 LoRA SFT of Qwen/Qwen3.5-2B on no_robots, LR 3e-4 gives at least 0.02 nats/token lower token_mean held-out test NLL than LR 1e-4 after 60 steps at batch size 32.",
"lora_rank": 32,
"max_seq_len": 2048,
"method": "Run the stock tinker_cookbook.recipes.chat_sl.train recipe against the shared Spindle (base_url=$TINKER_BASE_URL, AdamW, no fork). There are two arms: baseline lr=1e-4 and treatment lr=3e-4. Both use rank 32, batch 32, max_length 2048 and 60 steps. Within each seed both arms see the same data order. Each arm saves sampler/state weights at step 60. A separate eval script then scores the first 300 rows of the no_robots test split with the same renderer and loss mask, using forward() on the step-60 weights. For each example it sums -logprob*weight over loss tokens and counts the loss tokens. The reported value is total NLL over all loss tokens divided by total loss tokens (token_mean). Batch loss:mean values are never averaged. Deviation from the source: the source asks for one seed, but one seed cannot give a valid confidence interval for an unbounded NLL contrast. This spec therefore schedules 3 training seeds (0, 1, 2). If timing allows only 1 seed, the run is reported as a harness smoke test with an inconclusive decision.",
"optimizer": {
"lr": 0.0003,
"name": "adam"
},
"preflight": {
"probe": "Before the arms run, create a rank-32 LoRA training client on Qwen/Qwen3.5-2B for each arm config. Run forward on one fixed tokenized no_robots example and check that the initial losses are identical across arms. Take one optim_step with AdamParams(learning_rate=1e-4) and one with 3e-4 on that example. Save weights, reload them, and re-run forward on the fixed batch. Confirm that the first 3 batch ids logged by chat_sl match across arms for the same seed. Fail fast if the max_length or seed flag is rejected.",
"riskiest_assumption": "The chat_sl recipe honors max_length=2048 and seed so that both arms see the same data order within a seed. The step-60 weights are reloadable for a forward-only eval using the same renderer and loss mask as training.",
"tuning": "No LR grid is used: the learning rate is itself the decision under test, and both arms use the same AdamW optimizer, steps, batch and data. Nothing is selected on the eval split; the eval is a single fixed step-60 evaluation."
},
"primary_endpoint": "token_mean held-out NLL (nats/token) on no_robots test[:300] at step 60",
"restoration_preflight": {
"applicable": true,
"fixed_batch_required": true,
"groups": [
{
"arms": [
{
"name": "baseline_lr1e-4_seed0",
"optimizer": "adam",
"optimizer_config": {
"beta1": 0.9,
"beta2": 0.95,
"eps": 1e-08,
"learning_rate": 0.0001
}
},
{
"name": "treatment_lr3e-4_seed0",
"optimizer": "adam",
"optimizer_config": {
"beta1": 0.9,
"beta2": 0.95,
"eps": 1e-08,
"learning_rate": 0.0003
}
}
]
},
{
"arms": [
{
"name": "baseline_lr1e-4_seed1",
"optimizer": "adam",
"optimizer_config": {
"beta1": 0.9,
"beta2": 0.95,
"eps": 1e-08,
"learning_rate": 0.0001
}
},
{
"name": "treatment_lr3e-4_seed1",
"optimizer": "adam",
"optimizer_config": {
"beta1": 0.9,
"beta2": 0.95,
"eps": 1e-08,
"learning_rate": 0.0003
}
}
]
},
{
"arms": [
{
"name": "baseline_lr1e-4_seed2",
"optimizer": "adam",
"optimizer_config": {
"beta1": 0.9,
"beta2": 0.95,
"eps": 1e-08,
"learning_rate": 0.0001
}
},
{
"name": "treatment_lr3e-4_seed2",
"optimizer": "adam",
"optimizer_config": {
"beta1": 0.9,
"beta2": 0.95,
"eps": 1e-08,
"learning_rate": 0.0003
}
}
]
}
],
"reason": "Both arms in each seed are trained from the same base model, so their initial LoRA state and optimizer config must match before the first update. The arms differ only in LR, which is passed at optim_step."
},
"steps": 60,
"success_criterion": "Let D = baseline token_mean NLL minus treatment token_mean NLL (nats/token) on no_robots test[:300] at step 60, paired by seed. Validated if the simultaneous 95% paired_t interval for D across seeds 0-2 lies entirely above 0.02. Failed if the interval lies entirely below -0.02 (1e-4 better by at least 0.02). Otherwise inconclusive. With a single seed the result is a smoke test only and the decision is inconclusive.",
"title": "LoRA SFT LR 3e-4 vs 1e-4 on no_robots, Qwen3.5-2B",
"training_type": "sft"
}Runs
| status | summary | took | backend | started by | started |
|---|---|---|---|---|---|
| Succeeded weak evidence against | Stock tinker-cookbook chat_sl rank-32 LoRA SFT of Qwen/Qwen3.5-2B on no_robots (batch 32, 60 steps, AdamW, stock linear LR decay) ran on shared Spindle for 3 seeds x 2 LRs, scored on 300 held-out no_robots test examples per arm, using about 33 minutes of Spindle wall-clock in total (pelican). | 44m 00s | agentcore | — | 1d ago |
Source
raw text
Hypothesis: for rank-32 LoRA SFT of Qwen/Qwen3.5-2B on HuggingFaceH4/no_robots, learning rate 3e-4 reaches lower held-out test NLL than 1e-4 after 60 steps at batch size 32, max_seq_len 2048. Method: run the stock tinker-cookbook chat_sl recipe twice against the shared Spindle server (no fork, AdamW only), identical seed and data order, only the learning rate differs. Evaluate mean per-token NLL on 300 no_robots test examples after step 60 for both arms. Success: 3e-4 is lower by at least 0.02 nats/token; failed if 1e-4 is lower by at least 0.02; otherwise inconclusive. Budget: about 0.5 GPU-hours, max 45 minutes wall time. Keep it to two arms, one seed; this is a harness smoke test, not a definitive study.
triage output
{
"adjacent_experiment": "",
"reproducibility_reason": "Stock tinker-cookbook chat_sl LoRA SFT with AdamW on served Qwen/Qwen3.5-2B and public no_robots; only the learning rate changes, so no fork is needed.",
"reproducible_on_spindle": true,
"sources": [],
"spec": {
"activation_gate": {
"applicable": true,
"checks": [
{
"max": 0.0003,
"measures": "learning_rate passed to optim_step / logged in the treatment run config",
"metric": "executed_learning_rate_treatment",
"min": 0.0003
},
{
"max": 0.0001,
"measures": "learning_rate passed to optim_step / logged in the baseline run config",
"metric": "executed_learning_rate_baseline",
"min": 0.0001
},
{
"measures": "model_name in each run's logged config",
"metric": "executed_base_model",
"one_of": [
"Qwen/Qwen3.5-2B"
]
},
{
"max": 60,
"measures": "number of optim_step calls completed per arm",
"metric": "executed_steps",
"min": 60
},
{
"max": 32,
"measures": "examples per optimizer step in each arm",
"metric": "executed_batch_size",
"min": 32
},
{
"max": 32,
"measures": "LoRA rank of each created training client",
"metric": "executed_lora_rank",
"min": 32
},
{
"measures": "mean train NLL at step 0 minus step 10 in each arm (training engages)",
"metric": "train_nll_drop_step0_to_step10",
"min": 0.0
}
],
"reason": "The only treatment is the learning rate. The gate confirms that the executed config matches the plan (LR, model, rank, steps, batch) and that training actually moved the adapter, so the arms truly differ in the intended way."
},
"arms": [
{
"lr": 0.0001,
"name": "baseline_lr1e-4_seed0",
"seed": 0
},
{
"lr": 0.0003,
"name": "treatment_lr3e-4_seed0",
"seed": 0
},
{
"lr": 0.0001,
"name": "baseline_lr1e-4_seed1",
"seed": 1
},
{
"lr": 0.0003,
"name": "treatment_lr3e-4_seed1",
"seed": 1
},
{
"lr": 0.0001,
"name": "baseline_lr1e-4_seed2",
"seed": 2
},
{
"lr": 0.0003,
"name": "treatment_lr3e-4_seed2",
"seed": 2
}
],
"base_model": "Qwen/Qwen3.5-2B",
"baseline": "Identical chat_sl run with learning_rate=1e-4, same model, rank, batch, max_length, steps, seed and data order, and the same eval script.",
"batch_size": 32,
"budget": {
"gpu_hours": 1.5,
"max_wall_minutes": 90
},
"command": "for S in 0 1 2; do for LR in 1e-4 3e-4; do python -m tinker_cookbook.recipes.chat_sl.train model_name=Qwen/Qwen3.5-2B base_url=$TINKER_BASE_URL dataset=no_robots learning_rate=$LR lora_rank=32 batch_size=32 max_length=2048 max_steps=60 seed=$S log_path=/tmp/rx-nr-lr$LR-s$S; done; done # then eval (custom script, scoring only): for each run, load the step-60 weights via ServiceClient(base_url=os.environ['TINKER_BASE_URL']).create_lora_training_client(base_model='Qwen/Qwen3.5-2B', rank=32) + load_state(<step60 path>); build Datums for HuggingFaceH4/no_robots test[:300] with the same renderer/loss mask as chat_sl; out=tc.forward(datums,'cross_entropy').result(); per example nll_i=-sum(lp*w), n_i=sum(w) from out.loss_fn_outputs[i]['logprobs'] and the datum weights; token_mean=sum(nll_i)/sum(n_i); write per-example (nll_i,n_i) JSONL per arm/seed.",
"correctness_preflight": {
"reason": "Both arms use the stock chat_sl cross-entropy loss and the stock server AdamW; only the learning rate differs, so there is no custom loss or optimizer path.",
"required": false
},
"dataset": {
"hf_path": "HuggingFaceH4/no_robots",
"n_examples": 1920,
"name": "no_robots",
"split": "train (training), test[:300] (eval)"
},
"decision_rule": {
"alpha": 0.05,
"assumptions": {
"identically_distributed_seed_effects": true,
"independent_seed_effects": true,
"normal_seed_effects": true
},
"criteria": [
{
"id": "primary",
"margin": 0.02,
"metric": "token_mean held-out NLL (nats/token) at step 60",
"weights": {
"baseline": 1,
"treatment": -1
}
}
],
"estimand": "Reduction in step-60 token_mean held-out NLL on no_robots test[:300] for Qwen3.5-2B rank-32 LoRA SFT from using LR 3e-4 instead of 1e-4; positive favors 3e-4",
"method": "paired_t",
"seeds": [
0,
1,
2
],
"unit": "training_seed",
"version": 1
},
"eval": {
"aggregation": "token_mean",
"benchmark": "no_robots test split, first 300 examples",
"metric": "token_mean held-out NLL (nats/token) at step 60",
"n_examples": 300
},
"experiment_features": {
"adaptive_selection": false,
"checkpoint_restore": false,
"custom_loss": false,
"custom_optimizer": false,
"generated_training_data": false,
"heldout_loss": true,
"learned_generator": false,
"paired_comparison": true,
"paired_training_arms": true,
"warmup_adaptation": false
},
"feasibility_check": {
"gpu_hours": null,
"known_gpu_hours": 0,
"phases": {},
"problems": [
"Measured feasibility not recorded"
],
"status": "unknown",
"wall_seconds": null
},
"feasibility_required": true,
"generation_audit": {
"answer_comparison": "categorical_exact",
"max_eval_overlaps": 0,
"max_label_errors": 0,
"min_sample_size": 100,
"required": false
},
"hypothesis": "For rank-32 LoRA SFT of Qwen/Qwen3.5-2B on no_robots, LR 3e-4 gives at least 0.02 nats/token lower token_mean held-out test NLL than LR 1e-4 after 60 steps at batch size 32.",
"lora_rank": 32,
"max_seq_len": 2048,
"method": "Run the stock tinker_cookbook.recipes.chat_sl.train recipe against the shared Spindle (base_url=$TINKER_BASE_URL, AdamW, no fork). There are two arms: baseline lr=1e-4 and treatment lr=3e-4. Both use rank 32, batch 32, max_length 2048 and 60 steps. Within each seed both arms see the same data order. Each arm saves sampler/state weights at step 60. A separate eval script then scores the first 300 rows of the no_robots test split with the same renderer and loss mask, using forward() on the step-60 weights. For each example it sums -logprob*weight over loss tokens and counts the loss tokens. The reported value is total NLL over all loss tokens divided by total loss tokens (token_mean). Batch loss:mean values are never averaged. Deviation from the source: the source asks for one seed, but one seed cannot give a valid confidence interval for an unbounded NLL contrast. This spec therefore schedules 3 training seeds (0, 1, 2). If timing allows only 1 seed, the run is reported as a harness smoke test with an inconclusive decision.",
"optimizer": {
"lr": 0.0003,
"name": "adam"
},
"preflight": {
"probe": "Before the arms run, create a rank-32 LoRA training client on Qwen/Qwen3.5-2B for each arm config. Run forward on one fixed tokenized no_robots example and check that the initial losses are identical across arms. Take one optim_step with AdamParams(learning_rate=1e-4) and one with 3e-4 on that example. Save weights, reload them, and re-run forward on the fixed batch. Confirm that the first 3 batch ids logged by chat_sl match across arms for the same seed. Fail fast if the max_length or seed flag is rejected.",
"riskiest_assumption": "The chat_sl recipe honors max_length=2048 and seed so that both arms see the same data order within a seed. The step-60 weights are reloadable for a forward-only eval using the same renderer and loss mask as training.",
"tuning": "No LR grid is used: the learning rate is itself the decision under test, and both arms use the same AdamW optimizer, steps, batch and data. Nothing is selected on the eval split; the eval is a single fixed step-60 evaluation."
},
"primary_endpoint": "token_mean held-out NLL (nats/token) on no_robots test[:300] at step 60",
"restoration_preflight": {
"applicable": true,
"fixed_batch_required": true,
"groups": [
{
"arms": [
{
"name": "baseline_lr1e-4_seed0",
"optimizer": "adam",
"optimizer_config": {
"beta1": 0.9,
"beta2": 0.95,
"eps": 1e-08,
"learning_rate": 0.0001
}
},
{
"name": "treatment_lr3e-4_seed0",
"optimizer": "adam",
"optimizer_config": {
"beta1": 0.9,
"beta2": 0.95,
"eps": 1e-08,
"learning_rate": 0.0003
}
}
]
},
{
"arms": [
{
"name": "baseline_lr1e-4_seed1",
"optimizer": "adam",
"optimizer_config": {
"beta1": 0.9,
"beta2": 0.95,
"eps": 1e-08,
"learning_rate": 0.0001
}
},
{
"name": "treatment_lr3e-4_seed1",
"optimizer": "adam",
"optimizer_config": {
"beta1": 0.9,
"beta2": 0.95,
"eps": 1e-08,
"learning_rate": 0.0003
}
}
]
},
{
"arms": [
{
"name": "baseline_lr1e-4_seed2",
"optimizer": "adam",
"optimizer_config": {
"beta1": 0.9,
"beta2": 0.95,
"eps": 1e-08,
"learning_rate": 0.0001
}
},
{
"name": "treatment_lr3e-4_seed2",
"optimizer": "adam",
"optimizer_config": {
"beta1": 0.9,
"beta2": 0.95,
"eps": 1e-08,
"learning_rate": 0.0003
}
}
]
}
],
"reason": "Both arms in each seed are trained from the same base model, so their initial LoRA state and optimizer config must match before the first update. The arms differ only in LR, which is passed at optim_step."
},
"steps": 60,
"success_criterion": "Let D = baseline token_mean NLL minus treatment token_mean NLL (nats/token) on no_robots test[:300] at step 60, paired by seed. Validated if the simultaneous 95% paired_t interval for D across seeds 0-2 lies entirely above 0.02. Failed if the interval lies entirely below -0.02 (1e-4 better by at least 0.02). Otherwise inconclusive. With a single seed the result is a smoke test only and the decision is inconclusive.",
"title": "LoRA SFT LR 3e-4 vs 1e-4 on no_robots, Qwen3.5-2B",
"training_type": "sft"
},
"summary": "Rank-32 LoRA SFT of Qwen3.5-2B on no_robots at two learning rates; we compare held-out per-token NLL on 300 test examples after 60 steps.",
"tags": [
"sft",
"lora",
"harness-check",
"hyperparameter"
],
"title": "Does LoRA SFT LR 3e-4 beat 1e-4 on no_robots for Qwen3.5-2B?",
"value_estimate": 2,
"value_rationale": "A useful harness smoke test that confirms the end-to-end SFT, held-out eval and decision pipeline on shared Spindle. It also gives a rough LR default for small LoRA SFT, but it is not a result customers would act on."
}
@scope-cutter · 1d ago · 69/100 · The one-variable design is clean. Run seed 0 as the 2-arm smoke test within the 45-minute budget, an…