Does LoRA SFT make Qwen3.5-4B a better PubMedQA grader?
Qwen3.5-4B is LoRA-tuned to label PubMedQA candidate answers CORRECT or INCORRECT; we compare its grading accuracy on 2000 expert-labeled items with the untuned base.
change
Spec
LoRA-SFT PubMedQA grader vs untuned Qwen3.5-4B judge
Hypothesis. LoRA SFT on 4,800 PubMedQA-artificial grading items raises Qwen3.5-4B grading accuracy on balanced expert-labeled PubMedQA grading items by more than 3 points over the untuned base model under the same prompt and scoring, with the lower confidence bound above that margin.
Method. Grading items are built deterministically from PubMedQA, with no model-generated data. Each item is (question, abstract contexts, candidate decision in {yes, no, maybe}) with target CORRECT if the candidate equals final_decision, else INCORRECT. Each question gets one correct candidate and one wrong candidate, drawn with seed 0 from the remaining options, so the classes are 50/50 by construction.
Success criterion. Treatment minus baseline grading accuracy on the 2000 paired eval items exceeds 0.03, with the paired Hoeffding 95% lower bound above 0.03 (half-width about 0.06 at n=2000, so an observed gain of roughly 0.09 or more is needed). Otherwise the result is not validated, or failed if the point estimate is <= 0.
full spec
{
"activation_gate": {
"applicable": true,
"checks": [
{
"measures": "model name reported by the created training/sampling client for both arms",
"metric": "executed_base_model",
"one_of": [
"Qwen/Qwen3.5-4B"
]
},
{
"max": 0.51,
"measures": "fraction of the 2000 eval items whose gold verdict is CORRECT",
"metric": "eval_correct_label_fraction",
"min": 0.49
},
{
"max": 0,
"measures": "count of eval PMIDs present in the training items",
"metric": "train_eval_pmid_overlap",
"min": 0
},
{
"measures": "relative drop in mean verdict-token train NLL from step 0 to step 20 of the treatment pilot",
"metric": "verdict_token_loss_drop_pilot",
"min": 0.1
},
{
"max": 0.95,
"measures": "fraction of the first 200 eval items the base model labels CORRECT",
"metric": "base_predicted_correct_fraction",
"min": 0.05
}
],
"reason": "Several things must hold before the comparison means anything. The executed model and arms must match the spec. The eval must be balanced and disjoint from training. SFT must actually move the verdict-token loss. The base judge must not be degenerate: if it always answers one class, its 50% is trivial and has to be flagged."
},
"arms": [
{
"name": "base_untuned",
"seed": 0,
"trained": false
},
{
"lr": 0.0001,
"name": "lora_sft_grader",
"seed": 0,
"steps": 150,
"trained": true
}
],
"base_model": "Qwen/Qwen3.5-4B",
"baseline": "Untuned Qwen/Qwen3.5-4B with the same prompt, renderer, eval items and logprob scoring.",
"batch_size": 32,
"budget": {
"gpu_hours": 1.5,
"max_wall_minutes": 120
},
"command": "python grader_sft.py # tinker SDK; nothing custom except dataset construction and logprob eval\nimport os, random, tinker; from datasets import load_dataset\nfrom tinker_cookbook import renderers; from tinker_cookbook.tokenizer_utils import get_tokenizer\nM='Qwen/Qwen3.5-4B'; svc=tinker.ServiceClient(base_url=os.environ['TINKER_BASE_URL'])\ntok=get_tokenizer(M); r=renderers.get_renderer('qwen3', tok)\ndef items(ds, n, rng):\n out=[]\n for ex in list(ds)[:n]:\n g=ex['final_decision']; w=rng.choice([o for o in ['yes','no','maybe'] if o!=g])\n for c,lab in [(g,'CORRECT'),(w,'INCORRECT')]:\n p=f\"Context: {' '.join(ex['context']['contexts'])}\\nQuestion: {ex['question']}\\nCandidate answer: {c}\\nIs the candidate answer correct? Reply CORRECT or INCORRECT.\"\n out.append((ex['pubid'],p,lab))\n return out\nrng=random.Random(0)\ntrain=items(load_dataset('qiaojin/PubMedQA','pqa_artificial',split='train').shuffle(seed=0),2400,rng)\nev=items(load_dataset('qiaojin/PubMedQA','pqa_labeled',split='train'),1000,rng)\nassert not {a for a,_,_ in train}&{a for a,_,_ in ev}\nrandom.Random(0).shuffle(train)\ndef datum(p,lab): # weights only on verdict tokens\n return renderers.build_supervised_example_datum(r,[{'role':'user','content':p},{'role':'assistant','content':lab}],max_length=1024) # last-assistant-only weights\ntc=svc.create_lora_training_client(base_model=M, rank=32)\nfor s in range(150):\n b=[datum(p,l) for _,p,l in train[s*32:(s+1)*32]]\n tc.forward_backward(b,'cross_entropy').result(); tc.optim_step(tinker.AdamParams(learning_rate=1e-4)).result()\nsc_t=tc.save_weights_and_get_sampling_client('pubmedqa-grader'); sc_b=svc.create_sampling_client(base_model=M)\ndef verdict_lp(sc,p,lab): # summed logprob of the verdict tokens\n pre=r.build_generation_prompt([{'role':'user','content':p}]); comp=tok.encode(lab,add_special_tokens=False)\n mi=tinker.ModelInput.from_ints(pre.to_ints()+comp); lp=sc.compute_logprobs(mi).result(); return sum(lp[-len(comp):])\nfor name,sc in [('baseline',sc_b),('treatment',sc_t)]:\n acc=[int((verdict_lp(sc,p,'CORRECT')>verdict_lp(sc,p,'INCORRECT'))==(l=='CORRECT')) for _,p,l in ev]\n print(name, sum(acc)/len(acc)) # save per-item 0/1 for the paired Hoeffding rule",
"dataset": {
"hf_path": "qiaojin/PubMedQA",
"n_examples": 4800,
"name": "PubMedQA (pqa_artificial train, pqa_labeled eval)",
"split": "pqa_artificial:train (2400 questions -> 4800 grading items); eval pqa_labeled:train (1000 questions -> 2000 items)"
},
"decision_rule": {
"alpha": 0.05,
"criteria": [
{
"bounds": [
0,
1
],
"id": "primary",
"margin": 0.03,
"metric": "accuracy",
"weights": {
"baseline": -1,
"treatment": 1
}
}
],
"estimand": "Per-item grading accuracy on balanced PubMedQA pqa_labeled grading items: LoRA-SFT Qwen3.5-4B minus untuned Qwen3.5-4B, higher is better; inference conditional on the seed-0 checkpoint",
"method": "paired_hoeffding",
"seeds": [
0
],
"unit": "fixed_checkpoint_items",
"version": 1
},
"eval": {
"aggregation": null,
"benchmark": "PubMedQA pqa_labeled balanced grading set (1 correct + 1 incorrect candidate per question)",
"metric": "grading accuracy (argmax logprob of CORRECT vs INCORRECT vs gold verdict)",
"n_examples": 2000
},
"experiment_features": {
"adaptive_selection": false,
"checkpoint_restore": false,
"custom_loss": false,
"custom_optimizer": false,
"generated_training_data": false,
"heldout_loss": false,
"learned_generator": false,
"paired_comparison": true,
"paired_training_arms": false,
"warmup_adaptation": false
},
"feasibility": {
"config": {
"arms": [
"base_untuned",
"lora_sft_grader"
],
"base_model": "Qwen/Qwen3.5-4B",
"batch_size": 32,
"max_seq_len": 1024,
"n_eval": 200,
"steps": 20
},
"effective_config": {
"arms": [
"base_untuned",
"lora_sft_grader"
],
"base_model": "Qwen/Qwen3.5-4B",
"batch_size": 32,
"max_seq_len": 1024,
"n_eval": 2000,
"steps": 150
},
"generation": null,
"measurements": null,
"primary_endpoint": "pubmedqa_labeled_grading_accuracy",
"recovery": null,
"source": null
},
"feasibility_check": {
"generation_applicability": {
"check": "generation_audit",
"enforce": false,
"features": {
"generated_training_data": false
},
"reasons": [
"spec.generation_audit opts out and no feature it covers is true"
],
"status": "not_applicable"
},
"generation_counts": null,
"gpu_hours": null,
"known_gpu_hours": 0,
"phases": {},
"problems": [
"Re-projection required: lora_rank changed or was not recorded",
"Re-projection required: dataset changed or was not recorded",
"Re-projection required: steps changed or was not recorded",
"Re-projection required: n_examples changed or was not recorded",
"Re-projection required: n_eval changed or was not recorded",
"Pilot evidence source not recorded",
"Representative setup timing not recorded",
"Representative training timing not recorded",
"Representative evaluation timing not recorded",
"Positive recovery reserve 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": "LoRA SFT on 4,800 PubMedQA-artificial grading items raises Qwen3.5-4B grading accuracy on balanced expert-labeled PubMedQA grading items by more than 3 points over the untuned base model under the same prompt and scoring, with the lower confidence bound above that margin.",
"lora_rank": 32,
"max_seq_len": 1024,
"method": "Grading items are built deterministically from PubMedQA, with no model-generated data. Each item is (question, abstract contexts, candidate decision in {yes, no, maybe}) with target CORRECT if the candidate equals final_decision, else INCORRECT. Each question gets one correct candidate and one wrong candidate, drawn with seed 0 from the remaining options, so the classes are 50/50 by construction. Training data is 2,400 questions from pqa_artificial (whose labels are auto-derived yes/no), giving 4,800 items. The eval set is all 1,000 expert-labeled pqa_labeled questions, giving 2,000 items. pqa_artificial and pqa_labeled are disjoint by PMID, and this is checked. The treatment is LoRA rank 32 on Qwen/Qwen3.5-4B, trained with a tinker SDK forward_backward + optim_step loop using the stock cross_entropy loss. Loss weight sits only on the verdict tokens. AdamW at lr 1e-4, batch 32, 150 steps (about 1 epoch). The baseline is the untuned Qwen3.5-4B with the identical renderer and prompt. Both are scored by logprob: the predicted verdict is whichever of the 'CORRECT' and 'INCORRECT' completions has the higher summed logprob, computed via forward/compute_logprobs, so there is no sampling noise and no parse failures. The majority-class rate (50%) is reported, along with accuracy on the subsets where the gold label is 'maybe'. Nothing is custom server-side and there is no Spindle fork. correctness_preflight: {\"required\": false, \"reason\": \"stock tinker cross_entropy loss and AdamW; no custom loss or optimizer implementation\"}. Heidi's DPO, batch-size and filtering claims are out of scope for this first node.",
"optimizer": {
"lr": 0.0001,
"name": "adam"
},
"primary_endpoint": "pubmedqa_labeled_grading_accuracy",
"steps": 150,
"success_criterion": "Treatment minus baseline grading accuracy on the 2000 paired eval items exceeds 0.03, with the paired Hoeffding 95% lower bound above 0.03 (half-width about 0.06 at n=2000, so an observed gain of roughly 0.09 or more is needed). Otherwise the result is not validated, or failed if the point estimate is <= 0.",
"title": "LoRA-SFT PubMedQA grader vs untuned Qwen3.5-4B judge",
"training_type": "sft"
}previous versions (4)
spec
{
"base_model": "Qwen/Qwen3.5-4B",
"baseline": "Base Qwen3.5-4B with no LoRA, graded zero-shot on the same 1,000 eval pairs with the identical prompt template, renderer and greedy decoding.",
"batch_size": 64,
"budget": {
"gpu_hours": 1.5,
"max_wall_minutes": 90
},
"command": "# custom part: PubMedQA grading-pair data builder + eval loop; training uses stock tinker SDK calls\nimport os, random, tinker, datasets\nfrom tinker_cookbook import renderers\nfrom tinker_cookbook.supervised.data import conversation_to_datum\nfrom tinker_cookbook.renderers import TrainOnWhat\nrandom.seed(0)\nsc = tinker.ServiceClient(base_url=os.environ[\"TINKER_BASE_URL\"])\ntc = sc.create_lora_training_client(base_model=\"Qwen/Qwen3.5-4B\", rank=32)\nr = renderers.get_renderer(\"qwen3\", tc.get_tokenizer())\nOPTS = [\"yes\", \"no\", \"maybe\"]\ndef pair(row, cand):\n ctx = \" \".join(row[\"context\"][\"contexts\"])\n p = f\"Question: {row['question']}\\nAbstract: {ctx}\\nCandidate answer: {cand}\\nIs the candidate answer correct? Reply CORRECT or INCORRECT.\"\n return [{\"role\": \"user\", \"content\": p}, {\"role\": \"assistant\", \"content\": \"CORRECT\" if cand == row[\"final_decision\"] else \"INCORRECT\"}]\ntrain = datasets.load_dataset(\"qiaojin/PubMedQA\", \"pqa_artificial\", split=\"train\").shuffle(seed=0).select(range(9600))\nconvs = []\nfor i, row in enumerate(train):\n gold = row[\"final_decision\"]\n cand = gold if i % 2 == 0 else random.choice([o for o in OPTS if o != gold])\n convs.append(pair(row, cand))\nfor step in range(150):\n batch = [conversation_to_datum(c, r, 1024, TrainOnWhat.LAST_ASSISTANT_MESSAGE) for c in convs[step*64:(step+1)*64]]\n tc.forward_backward(batch, loss_fn=\"cross_entropy\").result()\n tc.optim_step(tinker.AdamParams(learning_rate=1e-4)).result()\nlora = tc.save_weights_and_get_sampling_client(name=\"pubmedqa-judge\")\nbase = sc.create_sampling_client(base_model=\"Qwen/Qwen3.5-4B\")\n# eval: pqa_labeled first 500 questions x {gold, random wrong}; greedy, max_tokens=8; score both `lora` and `base`; log to /tmp/rx-pubmedqa-judge",
"dataset": {
"hf_path": "qiaojin/PubMedQA",
"n_examples": 9600,
"name": "PubMedQA pqa_artificial grading pairs (train); pqa_labeled (eval)",
"split": "pqa_artificial/train (first 9,600 after seed-0 shuffle); pqa_labeled/train 500 questions for eval"
},
"eval": {
"benchmark": "PubMedQA pqa_labeled expert-labeled grading pairs (500 questions x {gold, one wrong} answer)",
"metric": "grading accuracy (verdict matches label, greedy decoding)",
"n_examples": 1000
},
"hypothesis": "A Qwen3.5-4B LoRA trained for 150 steps on PubMedQA grading pairs beats the base model's zero-shot grading accuracy on 1,000 expert-labeled grading pairs by at least 5 points.",
"lora_rank": 32,
"max_seq_len": 1024,
"method": "Grading pairs are built from qiaojin/PubMedQA pqa_artificial. Each prompt has a question, its abstract context and a candidate answer (yes, no or maybe). The target is a single word: CORRECT if the candidate matches final_decision, else INCORRECT. Classes are balanced 50/50 by pairing each item's gold answer with one wrong answer. The treatment trains a rank-32 LoRA with cross-entropy on the verdict token only (renderer qwen3, AdamW lr 1e-4) for 150 steps at batch 64, using a short tinker SDK loop. For the eval, 500 questions from the expert-labeled pqa_labeled config are each paired with their gold answer and one random wrong answer, giving 1,000 balanced items. Both arms grade these with the same prompt template and greedy decoding (max 8 tokens). Accuracy is the share of parsed verdicts that match the label, and unparseable outputs count as wrong. The only difference between arms is the trained LoRA.",
"optimizer": {
"lr": 0.0001,
"name": "adam"
},
"steps": 150,
"success_criterion": "Validated if LoRA judge accuracy minus base accuracy is at least 5.0 points on the 1,000 pairs (about 3 binomial SEs). Failed if the gap is under 2 points. Anything in between is inconclusive and calls for a rerun with a second seed.",
"title": "LoRA SFT judge vs base Qwen3.5-4B on PubMedQA answer grading",
"training_type": "sft"
}spec
{
"base_model": "Qwen/Qwen3.5-4B",
"baseline": "Base Qwen/Qwen3.5-4B with no LoRA, scored eval-only on the same 1,000 grading items with the identical judge prompt, renderer, greedy decoding and verdict parser.",
"batch_size": 32,
"budget": {
"gpu_hours": 1.5,
"max_wall_minutes": 90
},
"command": "python - <<'EOF'\n# Custom part: dataset builder for PubMedQA grading pairs and the eval loop. Training is the stock tinker SDK SFT loop.\nimport os, random, tinker\nfrom datasets import load_dataset\nfrom tinker_cookbook import renderers\nfrom tinker_cookbook.tokenizer_utils import get_tokenizer\nM='Qwen/Qwen3.5-4B'\nsc=tinker.ServiceClient(base_url=os.environ['TINKER_BASE_URL'])\ntok=get_tokenizer(M); r=renderers.get_renderer('qwen3',tok)\nLABELS=['yes','no','maybe']\ndef items(ds):\n for ex in ds:\n ctx=' '.join(ex['context']['contexts'])[:5000]; gold=ex['final_decision']\n wrong=random.choice([l for l in LABELS if l!=gold])\n for ans,v in [(gold,'CORRECT'),(wrong,'INCORRECT')]:\n p=f'Context: {ctx}\\nQuestion: {ex[\"question\"]}\\nCandidate answer: {ans}. {ex[\"long_answer\"][:300] if ans==gold else \"\"}\\nIs the candidate answer correct? Reply CORRECT or INCORRECT.'\n yield [{'role':'user','content':p}], v\nrandom.seed(0)\ntrain=list(items(load_dataset('qiaojin/PubMedQA','pqa_artificial',split='train').shuffle(seed=0).select(range(2400))))\nevald=list(items(load_dataset('qiaojin/PubMedQA','pqa_labeled',split='train').shuffle(seed=0).select(range(500))))\ntc=sc.create_lora_training_client(base_model=M,rank=32)\ndef datum(msgs,v):\n return renderers.build_supervised_example_datum(r,msgs+[{'role':'assistant','content':v}],max_length=2048) # loss on assistant verdict only\nfor step in range(150):\n b=[datum(*train[(step*32+i)%len(train)]) for i in range(32)]\n tc.forward_backward(b,loss_fn='cross_entropy').result()\n tc.optim_step(tinker.AdamParams(learning_rate=1e-4)).result()\nlora=tc.save_weights_and_get_sampling_client(name='rx-bio-judge')\nbase=sc.create_sampling_client(base_model=M)\nsp=tinker.SamplingParams(max_tokens=8,temperature=0.0,stop=r.get_stop_sequences())\nfor name,c in [('base',base),('lora',lora)]:\n ok=0\n for msgs,v in evald:\n out=c.sample(prompt=r.build_generation_prompt(msgs),num_samples=1,sampling_params=sp).result()\n txt=tok.decode(out.sequences[0].tokens).strip().upper()\n ok+= (txt.startswith('INCORRECT') and v=='INCORRECT') or (txt.startswith('CORRECT') and v=='CORRECT')\n print(name,'acc',ok/len(evald))\nEOF",
"dataset": {
"hf_path": "qiaojin/PubMedQA",
"n_examples": 4800,
"name": "PubMedQA grading pairs (train from pqa_artificial, eval from expert-labeled pqa_labeled)",
"split": "pqa_artificial/train for training; pqa_labeled/train (500 held-out questions) for eval"
},
"eval": {
"benchmark": "PubMedQA pqa_labeled grading items (expert labels)",
"metric": "judge verdict accuracy vs expert-derived CORRECT/INCORRECT labels",
"n_examples": 1000
},
"hypothesis": "A Qwen3.5-4B judge LoRA-tuned on 4.8k PubMedQA grading pairs beats the base Qwen3.5-4B, given the same judge prompt, by at least 5 points absolute accuracy on 1,000 grading items built from pqa_labeled.",
"lora_rank": 32,
"max_seq_len": 2048,
"method": "Build grading items from PubMedQA. Each item is (abstract context, question, candidate answer in yes/no/maybe plus a one-line rationale), and the target verdict is CORRECT or INCORRECT. For each question, pair the gold final_decision (CORRECT) with one randomly chosen wrong label (INCORRECT), which keeps the classes balanced. Training items come from pqa_artificial train, whose labels are heuristic. Eval items come from the 1,000 expert-labeled pqa_labeled questions: take 500 questions and make 2 items each, giving 1,000 items with no overlap with training. The judge prompt and Qwen3.5 chat renderer are identical for both arms. The treatment trains a rank-32 LoRA with cross-entropy on the verdict tokens only, using AdamW for 150 steps at batch 32. Both arms are then evaluated by greedy sampling a one-word verdict at temperature 0 and parsing CORRECT/INCORRECT. Unparseable outputs count as wrong. Report accuracy, per-class accuracy, and the rate of unparseable outputs. The only difference between arms is the LoRA SFT.",
"optimizer": {
"lr": 0.0001,
"name": "adam"
},
"steps": 150,
"success_criterion": "Validated if LoRA judge accuracy is at least 5.0 points above base on the 1,000 items. The 95% CI is about \u00b13 points at n=1000, so the gap must also clear 3 points on a paired bootstrap. Failed if the gap is under 3 points or the LoRA's unparseable rate is above 2%.",
"title": "LoRA SFT judge vs base Qwen3.5-4B on PubMedQA expert-label grading",
"training_type": "sft"
}spec
{
"base_model": "Qwen/Qwen3.5-4B",
"baseline": "Base Qwen3.5-4B with no LoRA, using the same judge prompt, renderer, greedy decoding and the same 1000 eval pairs.",
"batch_size": 32,
"budget": {
"gpu_hours": 1.5,
"max_wall_minutes": 90
},
"command": "python judge_sft.py # custom: PubMedQA->grading-pair dataset builder; training otherwise stock tinker SDK: sc=tinker.ServiceClient(base_url=os.environ['TINKER_BASE_URL']); tc=sc.create_lora_training_client(base_model='Qwen/Qwen3.5-4B', rank=32); for step in range(150): tc.forward_backward(batch_datums(step, bs=32), loss_fn='cross_entropy').result(); tc.optim_step(tinker.AdamParams(learning_rate=1e-4)).result(); path=tc.save_weights_for_sampler(name='bio-judge').result().path; eval: sample greedy verdicts from sc.create_sampling_client(model_path=path) and from sc.create_sampling_client(base_model='Qwen/Qwen3.5-4B') on the 1000 pqa_labeled pairs",
"dataset": {
"hf_path": "qiaojin/PubMedQA",
"n_examples": 4800,
"name": "PubMedQA (pqa_artificial for training, pqa_labeled for expert-label eval)",
"split": "pqa_artificial/train (train), pqa_labeled/train (eval)"
},
"eval": {
"benchmark": "PubMedQA pqa_labeled grading pairs (balanced correct/flipped candidates, fixed seed)",
"metric": "judge grading accuracy (CORRECT/INCORRECT exact match vs construction label)",
"n_examples": 1000
},
"hypothesis": "A Qwen3.5-4B LoRA judge trained on PubMedQA grading pairs beats the base Qwen3.5-4B, prompted the same way, by at least 5 points of grading accuracy on 1000 held-out expert-labeled PubMedQA items.",
"lora_rank": 32,
"max_seq_len": 1024,
"method": "Turn PubMedQA into a grading task. Each item gives the judge a question, the abstract context and a candidate answer (a yes/no/maybe verdict plus the long answer). The judge must output CORRECT or INCORRECT. For training, take pqa_artificial train items. Half get their true label as the candidate (target CORRECT) and half get a flipped label (target INCORRECT). For eval, apply the same balanced construction with a fixed seed to all 1000 expert-labeled pqa_labeled items, where flipped candidates draw the wrong verdict from {yes, no, maybe}. Train a rank-32 LoRA on Qwen3.5-4B with cross-entropy on the verdict token only, using AdamW at 1e-4 for 150 steps of batch 32. Both arms use the same renderer, prompt and greedy decoding at eval. The only difference is the LoRA weights.",
"optimizer": {
"lr": 0.0001,
"name": "adam"
},
"steps": 150,
"success_criterion": "Validated if the LoRA judge's grading accuracy is at least 5 points above the base model's on the 1000 pairs (binomial SE is about 1.5 pts). Failed if the gap is under 2 points. In between counts as inconclusive, and the next step is to rerun with 2 more seeds.",
"title": "LoRA SFT judge vs base Qwen3.5-4B on PubMedQA answer grading",
"training_type": "sft"
}spec
{
"base_model": "Qwen/Qwen3.5-4B",
"baseline": "Base Qwen3.5-4B with no LoRA, prompted zero-shot with the identical grading prompt, renderer, decoding and 1000 eval pairs.",
"batch_size": 64,
"budget": {
"gpu_hours": 1.5,
"max_wall_minutes": 90
},
"command": "python - <<'EOF'\n# Custom part: PubMedQA -> grading-pair data builder; training loop is stock tinker SDK\nimport os, random, tinker\nfrom datasets import load_dataset\nfrom tinker_cookbook import renderers\nfrom tinker_cookbook.tokenizer_utils import get_tokenizer\nfrom tinker_cookbook.supervised.data import conversation_to_datum\nM='Qwen/Qwen3.5-4B'\nrenderer=renderers.get_renderer('qwen3', get_tokenizer(M))\ndef pair(ex):\n gold=ex['final_decision']; ok=random.random()<0.5\n cand=gold if ok else random.choice([l for l in ['yes','no','maybe'] if l!=gold])\n ctx=' '.join(ex['context']['contexts'])\n return [{'role':'user','content':f'Context: {ctx}\\nQuestion: {ex[\"question\"]}\\nCandidate answer: {cand}\\nIs the candidate answer correct? Reply correct or incorrect.'},\n {'role':'assistant','content':'correct' if ok else 'incorrect'}]\ntrain=load_dataset('qiaojin/PubMedQA','pqa_artificial',split='train').shuffle(seed=0).select(range(9600))\nsc=tinker.ServiceClient(base_url=os.environ['TINKER_BASE_URL'])\ntc=sc.create_lora_training_client(base_model=M, rank=32)\nfor step in range(150):\n batch=[conversation_to_datum(pair(train[step*64+i]), renderer, 1024) for i in range(64)]\n tc.forward_backward(batch, loss_fn='cross_entropy').result()\n tc.optim_step(tinker.AdamParams(learning_rate=1e-4)).result()\nprint(tc.save_weights_for_sampler(name='pubmedqa-judge').result().path)\nEOF\n# Eval: same grading prompt on 1000 pairs from load_dataset('qiaojin/PubMedQA','pqa_labeled'), sampled greedily from the saved LoRA and from the base model",
"dataset": {
"hf_path": "qiaojin/PubMedQA",
"n_examples": 9600,
"name": "PubMedQA (pqa_artificial for train, pqa_labeled for eval)",
"split": "pqa_artificial/train"
},
"eval": {
"benchmark": "PubMedQA pqa_labeled grading pairs (balanced correct/incorrect)",
"metric": "judge accuracy vs gold verdict",
"n_examples": 1000
},
"hypothesis": "A Qwen3.5-4B LoRA judge fine-tuned on PubMedQA pqa_artificial grades candidate answers on expert-labeled pqa_labeled at least 10 points more accurately than the same base model used zero-shot.",
"lora_rank": 32,
"max_seq_len": 1024,
"method": "Turn PubMedQA into a grading task. Each example is (question, abstract context, candidate answer yes/no/maybe), and the target output is 'correct' or 'incorrect'. Build the pairs from gold labels, half with the gold answer and half with a randomly chosen wrong label. The treatment arm trains a rank-32 LoRA on about 9.6k such pairs from pqa_artificial (150 steps x batch 64) with the standard SFT cross-entropy, computed on the assistant turn only. Both arms are evaluated on 1000 balanced grading pairs built from the expert-labeled pqa_labeled split, using the same prompt and renderer. Greedy decoding goes through a sampling client, and the first output token is parsed as the verdict. This is the minimal check that Spindle can produce a usable bio grader before moving to Latch Bio's private data.",
"optimizer": {
"lr": 0.0001,
"name": "adam"
},
"steps": 150,
"success_criterion": "Validated if LoRA judge accuracy minus base judge accuracy is at least 10 points. The standard error is about 1.5 points per arm at n=1000, so gaps under 3 points count as noise. Failed if the gap is under 3 points.",
"title": "LoRA SFT judge vs zero-shot base judge on PubMedQA answer grading",
"training_type": "sft"
}Runs
| status | summary | took | backend | started by | started |
|---|---|---|---|---|---|
| Succeeded weak evidence for | Rank-32 LoRA SFT of Qwen/Qwen3.5-4B (150 steps, bs 32, lr 1e-4, stock cross_entropy/AdamW on 4800 pqa_artificial grading items) raised balanced pqa_labeled grading accuracy from 0.7185 (untuned) to 0.8335 on the same 2000 items under one frozen evaluator. | 1h 13m | Devin ↗ | — | 1d ago |
| Error no result | Could not start the Devin session: Devin API error 400: {"detail":"Prompt is too long. | 0s | Devin | — | 1d ago |
| Succeeded no result | Train loss 0.803 -> 0.018 over 150 steps on 9600 examples of qiaojin/PubMedQA (Qwen/Qwen3.5-4B, LoRA rank 32, adam lr 0.0001). | 19m 21s | runner | — | 2d ago |
Source
raw text
Arjun Banerjee from latch bio has expressed interest in a joint blogpost similar to https://fireworks.ai/customers/heidi-health-ambient-ai-scribe-fine-tuning where he will post-train a reward model on spindle to make synthetic evals for bio.
links triage read (1)
brief
method: Heidi trained open models in two stages. First came SFT, described as imitating foundation-model behavior to learn content style. Then came preference-based fine-tuning, called both DPO and 'Reinforcement Fine-Tuning (RFT)' on the page, which uses preference signals. The preference data came from raw side-by-side (SBS) evaluator choices, which the page says are noisy because evaluators sometimes pick responses that still contain hallucinations. It was cleaned by (a) using frontier models to generate synthetic rewrites as 'golden standard' targets and (b) using an LLM-as-a-Judge to pre-evaluate datasets against safeguard metrics such as Template Adherence and Details Omission. Effective batch size was scaled to roughly 1.5 million tokens using gradient accumulation (Fireworks supports up to 24 steps). Not stated: the base model(s), model sizes, LoRA vs full fine-tuning, learning rate, beta, epochs, dataset size, the judge model, the filtering thresholds, and how the DPO pairs were built from the rewrites and SBS signals. The page is a customer case study, not a paper.
evidence: All results are from Heidi's internal side-by-side evaluation on its clinical note dataset, which is not public. Headline claims: latency fell from 25 seconds to 7 seconds (Figure 1, labelled '3.5X Lower Latency'); POC to production took 4 weeks; the SFT model outperformed 'the Gemini Flash tier of models'; the RFT/DPO model's outputs were preferred over 'the Gemini Pro tier (frontier) models'. The only quantitative quality ablation is that raising the effective batch size from a standard 64k to 768k tokens improved win rate over proprietary models from 48.0% to 51.3%. Not stated: the number of eval examples, the raters (human or LLM), confidence intervals, the exact proprietary models compared, and any ablation of the filtering steps. The page also says the data-filtering strategy was 'confirmed' by experiments but gives no numbers for it. It claims savings ('huge cost savings') without figures.
reproduction: Nothing on the page can be reproduced directly: the data, base model, eval and hyperparameters are all unstated. The page's methodology could be tested on an open task. The linked idea proposes a related but different experiment: LoRA SFT of Qwen3.5-4B as a PubMedQA answer grader or reward model for synthetic bio evals, with latch bio. A minimal test would need the following. Data: PubMedQA (labeled and/or artificial splits) with (question, context, candidate answer) items and gold correctness labels, plus candidate answers from some generator model, some correct and some wrong. Training: LoRA SFT of Qwen3.5-4B to output a verdict or score. Compare against the untuned base model used as a judge, and optionally a larger judge as a reference. Eval: held-out PubMedQA grading accuracy, F1, and agreement with gold labels or with a stronger LLM judge, ideally also checking transfer to another bio QA set. Optional DPO arm: build preference pairs from correct vs incorrect answers, with and without LLM-judge filtering, to test the 'curate aggressively' claim. Batch-size arm: compare small vs large effective batch with gradient accumulation. Compute for a 4B LoRA is small (a single GPU, hours). Gaps: Heidi's task is generative note writing judged by SBS preference, while the idea is a classification-style grader, so the latency and frontier-beating results do not carry over. The 64k to 768k token batch sizes are probably scaled differently for LoRA. The Spindle/Tinker batch and gradient-accumulation controls should be checked before a batch-size ablation. Fireworks-specific infrastructure (24-step accumulation, hosted RFT) is not available in Spindle.
context: The page is a vendor customer story used as a template for a joint blog post: the idea is for latch bio (Arjun Banerjee) to post-train a reward model/grader on Spindle to build synthetic evals for bio, in the style of this Heidi x Fireworks case study. It sits alongside the common 'fine-tuned open model beats frontier on a narrow domain task' narrative, and the SFT-then-DPO recipe with LLM-judge filtering. It relates to the idea only loosely: Heidi trains a generator with SFT/DPO, whereas the idea trains a judge/reward model. The Modal training team might care because it is a marketing-ready, domain-expert collaboration that would exercise Spindle's SFT/DPO and LoRA workflow, and it speaks to the data-quality and large-batch lessons.
caveats: This is marketing copy with no paper, code, or data, and the numbers are internal and unauditable. The 48.0% to 51.3% win-rate gain is small, with no sample size or confidence interval given, so it may be within noise. Crossing 50% against 'proprietary models' is not clearly the same as beating frontier models. The headline 'beats frontier' claim rests on a single internal SBS eval. The page mixes up DPO and 'RFT', which are different things, and does not say which was run. The latency gain may owe as much to model size and serving as to fine-tuning. There is no ablation isolating filtering vs batch size vs algorithm. The page never says whether training was LoRA or full-parameter, so the batch-size finding may not transfer to LoRA. Using frontier-model rewrites and LLM judges creates a risk of circularity if the eval uses similar judges. The page is dated JUL 20, 2026, and is not truncated. For the PubMedQA idea specifically, nothing here shows that SFT improves a grader, so that claim would need its own baseline, such as the untuned Qwen3.5-4B and a strong LLM judge. PubMedQA is also small and its labels are noisy and skewed toward 'yes', so a majority-class baseline must be reported.
triage output
{
"adjacent_experiment": "",
"reproducibility_reason": "LoRA SFT of a served Qwen3.5-4B on public PubMedQA verdict data, run as a short tinker SDK loop with stock cross-entropy, then logprob-based grading eval of the trained and base models.",
"reproducible_on_spindle": true,
"sources": [
{
"brief": {
"caveats": "This is marketing copy with no paper, code, or data, and the numbers are internal and unauditable. The 48.0% to 51.3% win-rate gain is small, with no sample size or confidence interval given, so it may be within noise. Crossing 50% against 'proprietary models' is not clearly the same as beating frontier models. The headline 'beats frontier' claim rests on a single internal SBS eval. The page mixes up DPO and 'RFT', which are different things, and does not say which was run. The latency gain may owe as much to model size and serving as to fine-tuning. There is no ablation isolating filtering vs batch size vs algorithm. The page never says whether training was LoRA or full-parameter, so the batch-size finding may not transfer to LoRA. Using frontier-model rewrites and LLM judges creates a risk of circularity if the eval uses similar judges. The page is dated JUL 20, 2026, and is not truncated. For the PubMedQA idea specifically, nothing here shows that SFT improves a grader, so that claim would need its own baseline, such as the untuned Qwen3.5-4B and a strong LLM judge. PubMedQA is also small and its labels are noisy and skewed toward 'yes', so a majority-class baseline must be reported.",
"claim": "Heidi fine-tuned open models on Fireworks with SFT and then DPO/preference-based 'RFT' and reports that the result beats frontier proprietary models on its internal clinical-note side-by-side eval, while cutting latency from 25s to 7s. The page's stated lessons are to filter preference data aggressively with synthetic rewrites and LLM judges, and to use very large effective batch sizes (about 1M+ tokens) for DPO.",
"context": "The page is a vendor customer story used as a template for a joint blog post: the idea is for latch bio (Arjun Banerjee) to post-train a reward model/grader on Spindle to build synthetic evals for bio, in the style of this Heidi x Fireworks case study. It sits alongside the common 'fine-tuned open model beats frontier on a narrow domain task' narrative, and the SFT-then-DPO recipe with LLM-judge filtering. It relates to the idea only loosely: Heidi trains a generator with SFT/DPO, whereas the idea trains a judge/reward model. The Modal training team might care because it is a marketing-ready, domain-expert collaboration that would exercise Spindle's SFT/DPO and LoRA workflow, and it speaks to the data-quality and large-batch lessons.",
"evidence": "All results are from Heidi's internal side-by-side evaluation on its clinical note dataset, which is not public. Headline claims: latency fell from 25 seconds to 7 seconds (Figure 1, labelled '3.5X Lower Latency'); POC to production took 4 weeks; the SFT model outperformed 'the Gemini Flash tier of models'; the RFT/DPO model's outputs were preferred over 'the Gemini Pro tier (frontier) models'. The only quantitative quality ablation is that raising the effective batch size from a standard 64k to 768k tokens improved win rate over proprietary models from 48.0% to 51.3%. Not stated: the number of eval examples, the raters (human or LLM), confidence intervals, the exact proprietary models compared, and any ablation of the filtering steps. The page also says the data-filtering strategy was 'confirmed' by experiments but gives no numbers for it. It claims savings ('huge cost savings') without figures.",
"method": "Heidi trained open models in two stages. First came SFT, described as imitating foundation-model behavior to learn content style. Then came preference-based fine-tuning, called both DPO and 'Reinforcement Fine-Tuning (RFT)' on the page, which uses preference signals. The preference data came from raw side-by-side (SBS) evaluator choices, which the page says are noisy because evaluators sometimes pick responses that still contain hallucinations. It was cleaned by (a) using frontier models to generate synthetic rewrites as 'golden standard' targets and (b) using an LLM-as-a-Judge to pre-evaluate datasets against safeguard metrics such as Template Adherence and Details Omission. Effective batch size was scaled to roughly 1.5 million tokens using gradient accumulation (Fireworks supports up to 24 steps). Not stated: the base model(s), model sizes, LoRA vs full fine-tuning, learning rate, beta, epochs, dataset size, the judge model, the filtering thresholds, and how the DPO pairs were built from the rewrites and SBS signals. The page is a customer case study, not a paper.",
"reproduction": "Nothing on the page can be reproduced directly: the data, base model, eval and hyperparameters are all unstated. The page's methodology could be tested on an open task. The linked idea proposes a related but different experiment: LoRA SFT of Qwen3.5-4B as a PubMedQA answer grader or reward model for synthetic bio evals, with latch bio. A minimal test would need the following. Data: PubMedQA (labeled and/or artificial splits) with (question, context, candidate answer) items and gold correctness labels, plus candidate answers from some generator model, some correct and some wrong. Training: LoRA SFT of Qwen3.5-4B to output a verdict or score. Compare against the untuned base model used as a judge, and optionally a larger judge as a reference. Eval: held-out PubMedQA grading accuracy, F1, and agreement with gold labels or with a stronger LLM judge, ideally also checking transfer to another bio QA set. Optional DPO arm: build preference pairs from correct vs incorrect answers, with and without LLM-judge filtering, to test the 'curate aggressively' claim. Batch-size arm: compare small vs large effective batch with gradient accumulation. Compute for a 4B LoRA is small (a single GPU, hours). Gaps: Heidi's task is generative note writing judged by SBS preference, while the idea is a classification-style grader, so the latency and frontier-beating results do not carry over. The 64k to 768k token batch sizes are probably scaled differently for LoRA. The Spindle/Tinker batch and gradient-accumulation controls should be checked before a batch-size ablation. Fireworks-specific infrastructure (24-step accumulation, hosted RFT) is not available in Spindle.",
"title": "Heidi x Fireworks: Bridging the gap in frontier model performance"
},
"error": null,
"full_text": true,
"kind": "page",
"title": "Heidi x Fireworks: Bridging the gap in frontier model performance",
"url": "https://fireworks.ai/customers/heidi-health-ambient-ai-scribe-fine-tuning"
}
],
"spec": {
"activation_gate": {
"applicable": true,
"checks": [
{
"measures": "model name reported by the created training/sampling client for both arms",
"metric": "executed_base_model",
"one_of": [
"Qwen/Qwen3.5-4B"
]
},
{
"max": 0.51,
"measures": "fraction of the 2000 eval items whose gold verdict is CORRECT",
"metric": "eval_correct_label_fraction",
"min": 0.49
},
{
"max": 0,
"measures": "count of eval PMIDs present in the training items",
"metric": "train_eval_pmid_overlap",
"min": 0
},
{
"measures": "relative drop in mean verdict-token train NLL from step 0 to step 20 of the treatment pilot",
"metric": "verdict_token_loss_drop_pilot",
"min": 0.1
},
{
"max": 0.95,
"measures": "fraction of the first 200 eval items the base model labels CORRECT",
"metric": "base_predicted_correct_fraction",
"min": 0.05
}
],
"reason": "Several things must hold before the comparison means anything. The executed model and arms must match the spec. The eval must be balanced and disjoint from training. SFT must actually move the verdict-token loss. The base judge must not be degenerate: if it always answers one class, its 50% is trivial and has to be flagged."
},
"arms": [
{
"name": "base_untuned",
"seed": 0,
"trained": false
},
{
"lr": 0.0001,
"name": "lora_sft_grader",
"seed": 0,
"steps": 150,
"trained": true
}
],
"base_model": "Qwen/Qwen3.5-4B",
"baseline": "Untuned Qwen/Qwen3.5-4B with the same prompt, renderer, eval items and logprob scoring.",
"batch_size": 32,
"budget": {
"gpu_hours": 1.5,
"max_wall_minutes": 120
},
"command": "python grader_sft.py # tinker SDK; nothing custom except dataset construction and logprob eval\nimport os, random, tinker; from datasets import load_dataset\nfrom tinker_cookbook import renderers; from tinker_cookbook.tokenizer_utils import get_tokenizer\nM='Qwen/Qwen3.5-4B'; svc=tinker.ServiceClient(base_url=os.environ['TINKER_BASE_URL'])\ntok=get_tokenizer(M); r=renderers.get_renderer('qwen3', tok)\ndef items(ds, n, rng):\n out=[]\n for ex in list(ds)[:n]:\n g=ex['final_decision']; w=rng.choice([o for o in ['yes','no','maybe'] if o!=g])\n for c,lab in [(g,'CORRECT'),(w,'INCORRECT')]:\n p=f\"Context: {' '.join(ex['context']['contexts'])}\\nQuestion: {ex['question']}\\nCandidate answer: {c}\\nIs the candidate answer correct? Reply CORRECT or INCORRECT.\"\n out.append((ex['pubid'],p,lab))\n return out\nrng=random.Random(0)\ntrain=items(load_dataset('qiaojin/PubMedQA','pqa_artificial',split='train').shuffle(seed=0),2400,rng)\nev=items(load_dataset('qiaojin/PubMedQA','pqa_labeled',split='train'),1000,rng)\nassert not {a for a,_,_ in train}&{a for a,_,_ in ev}\nrandom.Random(0).shuffle(train)\ndef datum(p,lab): # weights only on verdict tokens\n return renderers.build_supervised_example_datum(r,[{'role':'user','content':p},{'role':'assistant','content':lab}],max_length=1024) # last-assistant-only weights\ntc=svc.create_lora_training_client(base_model=M, rank=32)\nfor s in range(150):\n b=[datum(p,l) for _,p,l in train[s*32:(s+1)*32]]\n tc.forward_backward(b,'cross_entropy').result(); tc.optim_step(tinker.AdamParams(learning_rate=1e-4)).result()\nsc_t=tc.save_weights_and_get_sampling_client('pubmedqa-grader'); sc_b=svc.create_sampling_client(base_model=M)\ndef verdict_lp(sc,p,lab): # summed logprob of the verdict tokens\n pre=r.build_generation_prompt([{'role':'user','content':p}]); comp=tok.encode(lab,add_special_tokens=False)\n mi=tinker.ModelInput.from_ints(pre.to_ints()+comp); lp=sc.compute_logprobs(mi).result(); return sum(lp[-len(comp):])\nfor name,sc in [('baseline',sc_b),('treatment',sc_t)]:\n acc=[int((verdict_lp(sc,p,'CORRECT')>verdict_lp(sc,p,'INCORRECT'))==(l=='CORRECT')) for _,p,l in ev]\n print(name, sum(acc)/len(acc)) # save per-item 0/1 for the paired Hoeffding rule",
"dataset": {
"hf_path": "qiaojin/PubMedQA",
"n_examples": 4800,
"name": "PubMedQA (pqa_artificial train, pqa_labeled eval)",
"split": "pqa_artificial:train (2400 questions -> 4800 grading items); eval pqa_labeled:train (1000 questions -> 2000 items)"
},
"decision_rule": {
"alpha": 0.05,
"criteria": [
{
"bounds": [
0,
1
],
"id": "primary",
"margin": 0.03,
"metric": "accuracy",
"weights": {
"baseline": -1,
"treatment": 1
}
}
],
"estimand": "Per-item grading accuracy on balanced PubMedQA pqa_labeled grading items: LoRA-SFT Qwen3.5-4B minus untuned Qwen3.5-4B, higher is better; inference conditional on the seed-0 checkpoint",
"method": "paired_hoeffding",
"seeds": [
0
],
"unit": "fixed_checkpoint_items",
"version": 1
},
"eval": {
"aggregation": null,
"benchmark": "PubMedQA pqa_labeled balanced grading set (1 correct + 1 incorrect candidate per question)",
"metric": "grading accuracy (argmax logprob of CORRECT vs INCORRECT vs gold verdict)",
"n_examples": 2000
},
"experiment_features": {
"adaptive_selection": false,
"checkpoint_restore": false,
"custom_loss": false,
"custom_optimizer": false,
"generated_training_data": false,
"heldout_loss": false,
"learned_generator": false,
"paired_comparison": true,
"paired_training_arms": false,
"warmup_adaptation": false
},
"feasibility": {
"config": {
"arms": [
"base_untuned",
"lora_sft_grader"
],
"base_model": "Qwen/Qwen3.5-4B",
"batch_size": 32,
"max_seq_len": 1024,
"n_eval": 200,
"steps": 20
},
"effective_config": {
"arms": [
"base_untuned",
"lora_sft_grader"
],
"base_model": "Qwen/Qwen3.5-4B",
"batch_size": 32,
"max_seq_len": 1024,
"n_eval": 2000,
"steps": 150
},
"generation": null,
"measurements": null,
"primary_endpoint": "pubmedqa_labeled_grading_accuracy",
"recovery": null,
"source": null
},
"feasibility_check": {
"generation_applicability": {
"check": "generation_audit",
"enforce": false,
"features": {
"generated_training_data": false
},
"reasons": [
"spec.generation_audit opts out and no feature it covers is true"
],
"status": "not_applicable"
},
"generation_counts": null,
"gpu_hours": null,
"known_gpu_hours": 0,
"phases": {},
"problems": [
"Re-projection required: lora_rank changed or was not recorded",
"Re-projection required: dataset changed or was not recorded",
"Re-projection required: steps changed or was not recorded",
"Re-projection required: n_examples changed or was not recorded",
"Re-projection required: n_eval changed or was not recorded",
"Pilot evidence source not recorded",
"Representative setup timing not recorded",
"Representative training timing not recorded",
"Representative evaluation timing not recorded",
"Positive recovery reserve 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": "LoRA SFT on 4,800 PubMedQA-artificial grading items raises Qwen3.5-4B grading accuracy on balanced expert-labeled PubMedQA grading items by more than 3 points over the untuned base model under the same prompt and scoring, with the lower confidence bound above that margin.",
"lora_rank": 32,
"max_seq_len": 1024,
"method": "Grading items are built deterministically from PubMedQA, with no model-generated data. Each item is (question, abstract contexts, candidate decision in {yes, no, maybe}) with target CORRECT if the candidate equals final_decision, else INCORRECT. Each question gets one correct candidate and one wrong candidate, drawn with seed 0 from the remaining options, so the classes are 50/50 by construction. Training data is 2,400 questions from pqa_artificial (whose labels are auto-derived yes/no), giving 4,800 items. The eval set is all 1,000 expert-labeled pqa_labeled questions, giving 2,000 items. pqa_artificial and pqa_labeled are disjoint by PMID, and this is checked. The treatment is LoRA rank 32 on Qwen/Qwen3.5-4B, trained with a tinker SDK forward_backward + optim_step loop using the stock cross_entropy loss. Loss weight sits only on the verdict tokens. AdamW at lr 1e-4, batch 32, 150 steps (about 1 epoch). The baseline is the untuned Qwen3.5-4B with the identical renderer and prompt. Both are scored by logprob: the predicted verdict is whichever of the 'CORRECT' and 'INCORRECT' completions has the higher summed logprob, computed via forward/compute_logprobs, so there is no sampling noise and no parse failures. The majority-class rate (50%) is reported, along with accuracy on the subsets where the gold label is 'maybe'. Nothing is custom server-side and there is no Spindle fork. correctness_preflight: {\"required\": false, \"reason\": \"stock tinker cross_entropy loss and AdamW; no custom loss or optimizer implementation\"}. Heidi's DPO, batch-size and filtering claims are out of scope for this first node.",
"optimizer": {
"lr": 0.0001,
"name": "adam"
},
"primary_endpoint": "pubmedqa_labeled_grading_accuracy",
"steps": 150,
"success_criterion": "Treatment minus baseline grading accuracy on the 2000 paired eval items exceeds 0.03, with the paired Hoeffding 95% lower bound above 0.03 (half-width about 0.06 at n=2000, so an observed gain of roughly 0.09 or more is needed). Otherwise the result is not validated, or failed if the point estimate is <= 0.",
"title": "LoRA-SFT PubMedQA grader vs untuned Qwen3.5-4B judge",
"training_type": "sft"
},
"summary": "Qwen3.5-4B is LoRA-tuned to label PubMedQA candidate answers CORRECT or INCORRECT; we compare its grading accuracy on 2000 expert-labeled items with the untuned base.",
"tags": [
"sft",
"reward-model",
"eval",
"bio",
"lora",
"data"
],
"title": "Does LoRA SFT make Qwen3.5-4B a better PubMedQA grader?",
"value_estimate": 3,
"value_rationale": "This seeds a customer-facing joint blog post with latch bio and exercises Spindle's SFT and logprob eval paths. The Heidi page is unauditable marketing, though, and a 4B grader on PubMedQA verdicts is a narrow proxy for the synthetic bio evals they actually want."
}