Modal Research Exchange

you are

LoRA SFT judge vs zero-shot base judge on PubMedQA answer grading

Succeeded no result Modal runner Qwen/Qwen3.5-4B SFT 1.5 GPU-h budget started 2d ago 19m 21s
Redo
Pick where the agent works.

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). Held-out loss 0.710 -> 0.268 on 1000 examples.

Full summary

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). Held-out loss 0.710 -> 0.268 on 1000 examples. Baseline arm: arm zero-shot base model (no LoRA), eval_loss 0.7103, judge_acc 0.541, judge_acc_se 0.01576, acc_on_correct_pairs 0.982, acc_on_incorrect_pairs 0.1, invalid_rate 0, frac_pred_correct 0.941, n 1000. Also measured: judge_acc 0.85, judge_acc_se 0.01129, judge_acc_gap_points 30.9, verdict validated. Wall time 18.1 min. Success criterion from the spec: 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.

No result No result: the run finished without measuring the effect.

Result

No arm results yet.

train_loss_first
0.8029
train_loss_last
0.01785
train_loss_min
0.0001048
eval_loss_before
0.7103
eval_loss_after
0.2683
steps
150
n_examples
9,600
n_eval
1,000
lora_rank
32
lr
0.0001
judge_acc
0.85
judge_acc_se
0.01129
judge_acc_gap_points
30.9
wall_seconds
1,084.2
Changes from the spec 0

Not recorded: this run didn't report its changes from the spec, so they are unknown.

Checks ยท activation unverified ยท evaluator unknown ยท feasibility unknown
Custom implementation correctness: unverified
Correctness preflight was not recorded.
Validates executor-reported evidence; autonomous training calls are not blocked by this server.
Treatment activation: unverified
The spec has no activation gate or experiment features (it predates them), so whether the treatment engaged is unknown.
Initialization / optimizer verification: unverified
Initialization and optimizer evidence was not required by this saved spec.
Research decision: unknown
No prespecified decision rule was recorded.
Adaptation batches across paired arms: unknown (not verified)
The spec does not declare whether this check applies, and the run did not verify it. The run did not report adaptation batch manifests.
Evaluator comparability: unknown
Evaluator provenance was not recorded.
Generator reward audit
Generator reward audit: Unknown whether this run needed a teacher reward audit: its spec does not declare whether a teacher or task generator is trained. Nothing was recorded, so reward contributions are unknown.
Feasibility projection: unknown
Projected costs, not measured GPU usage.
Measured feasibility was not recorded for this run.
Generated task audit: unverified: no audit recorded
Answer agreement with recorded independent solutions does not establish semantic correctness. Normalized exact overlap does not detect paraphrases.
Spec exp_f7d448d306

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.

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.

PubMedQA (pqa_artificial for train, pqa_labeled for eval) 150 steps batch 64 LoRA r32 lr 0.0001 90 min wall budget
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.
Baseline
Base Qwen3.5-4B with no LoRA, prompted zero-shot with the identical grading prompt, renderer, decoding and 1000 eval pairs.
Full 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"
}
Script
"""LoRA SFT judge vs zero-shot base judge on PubMedQA answer grading (Spindle, stock Tinker SDK).

Reads TINKER_BASE_URL, TINKER_API_KEY, MODAL_PROXY_TOKEN_ID and
MODAL_PROXY_TOKEN_SECRET. Turns PubMedQA into a grading task: given
(question, abstract context, candidate yes/no/maybe), output "correct" or
"incorrect". Trains a LoRA on grading pairs from pqa_artificial, then compares
greedy-decoded judge accuracy of the LoRA and the zero-shot base model on 1000
balanced grading pairs from the expert-labeled pqa_labeled split. Both arms use
the same prompt, chat template and decoding. Prints one final line
`RESULT {...}` with the metrics.
"""

import json
import math
import os
import random
import re
import time

import tinker
from datasets import load_dataset
from tinker import types

# CONFIG-START
CONFIG = {
    "base_model": "Qwen/Qwen3.5-4B",
    "lora_rank": 32,
    "dataset": "qiaojin/PubMedQA",
    "split": "pqa_artificial/train",      # train pairs: <config>/<split>
    "eval_split": "pqa_labeled/train",     # eval pairs: expert-labeled, 1000 rows
    "n_examples": 9600,
    "n_eval": 1000,
    "steps": 150,
    "batch_size": 64,
    "max_seq_len": 1024,
    "lr": 0.0001,
    "optimizer": "adam",
    "max_wall_seconds": 2700,
    "max_new_tokens": 8,
    "seed": 0
}
# CONFIG-END

LABELS = ["yes", "no", "maybe"]
PROMPT = (
    "Context: {ctx}\nQuestion: {question}\nCandidate answer: {cand}\n"
    "Is the candidate answer correct? Reply correct or incorrect."
)

STARTED = time.monotonic()


def elapsed():
    return time.monotonic() - STARTED


def chat_prompt_ids(tokenizer, text):
    # Thinking is disabled so the first generated word is the verdict; the same
    # template is used for training and for both eval arms.
    ids = tokenizer.apply_chat_template(
        [{"role": "user", "content": text}],
        tokenize=True,
        add_generation_prompt=True,
        enable_thinking=False,
    )
    if hasattr(ids, "keys"):
        ids = ids["input_ids"]
    return list(ids)


def build_prompt_ids(tokenizer, question, contexts, cand):
    """Prompt token ids; the abstract context is truncated so that prompt + verdict fit max_seq_len."""
    ctx = " ".join(contexts)
    limit = CONFIG["max_seq_len"] - 16
    ids = chat_prompt_ids(tokenizer, PROMPT.format(ctx=ctx, question=question, cand=cand))
    if len(ids) <= limit:
        return ids
    overhead = len(chat_prompt_ids(tokenizer, PROMPT.format(ctx="", question=question, cand=cand)))
    budget = max(limit - overhead - 4, 32)
    ctx_ids = tokenizer.encode(ctx, add_special_tokens=False)[:budget]
    ctx = tokenizer.decode(ctx_ids)
    ids = chat_prompt_ids(tokenizer, PROMPT.format(ctx=ctx, question=question, cand=cand))
    return ids[-limit:] if len(ids) > limit else ids


def load_split(spec):
    config_name, split = spec.split("/")
    return load_dataset(CONFIG["dataset"], config_name, split=split)


def load_examples(tokenizer, spec, n, rng, balanced):
    """Return n grading pairs: (prompt_ids, verdict_text, gold_label, candidate).

    balanced=True gives exactly half correct / half incorrect (used for eval);
    otherwise each pair is correct with probability 0.5 (training, as in the spec)."""
    ds = load_split(spec).shuffle(seed=CONFIG["seed"])
    n = min(n, len(ds))
    ds = ds.select(range(n))
    if balanced:
        flags = [i < n // 2 for i in range(n)]
        rng.shuffle(flags)
    else:
        flags = [rng.random() < 0.5 for _ in range(n)]
    examples = []
    for row, ok in zip(ds, flags):
        gold = row["final_decision"].strip().lower()
        cand = gold if ok else rng.choice([l for l in LABELS if l != gold])
        ids = build_prompt_ids(tokenizer, row["question"], row["context"]["contexts"], cand)
        examples.append((ids, "correct" if ok else "incorrect", gold, cand))
    return examples


def make_datum(tokenizer, prompt_ids, response):
    """Loss weights cover only the assistant (verdict) tokens.

    Returns the Datum and its number of loss tokens."""
    response_ids = tokenizer.encode(response, add_special_tokens=False) + [tokenizer.eos_token_id]
    tokens = (prompt_ids + response_ids)[: CONFIG["max_seq_len"] + 1]
    weights = ([0.0] * len(prompt_ids) + [1.0] * len(response_ids))[: len(tokens)]
    datum = types.Datum(
        model_input=types.ModelInput.from_ints(tokens[:-1]),
        loss_fn_inputs={"target_tokens": tokens[1:], "weights": weights[1:]},
    )
    return datum, sum(weights[1:])


def mean_loss(output, batch):
    loss_tokens = sum(n for _, n in batch)
    return output.metrics["loss:sum"] / max(loss_tokens, 1.0)


def eval_loss(training, batch, chunk=64):
    total, tokens = 0.0, 0.0
    for i in range(0, len(batch), chunk):
        part = batch[i : i + chunk]
        output = training.forward([d for d, _ in part], "cross_entropy").result()
        total += output.metrics["loss:sum"]
        tokens += sum(n for _, n in part)
    return total / max(tokens, 1.0)


def optim_params():
    if CONFIG["optimizer"] == "dimuon":
        return types.DimuonParams(learning_rate=CONFIG["lr"])
    return types.AdamParams(learning_rate=CONFIG["lr"])


def parse_verdict(text):
    text = re.sub(r"<think>.*?</think>", "", text, flags=re.S)
    text = text.replace("<think>", "").replace("</think>", "").strip().lower()
    m = re.match(r"[^a-z]*([a-z]+)", text)
    if not m:
        return None
    word = m.group(1)
    return word if word in ("correct", "incorrect") else None


def judge_eval(sampler, tokenizer, examples, chunk=200):
    """Greedy-decode each grading prompt and parse the first word as the verdict."""
    params = types.SamplingParams(max_tokens=CONFIG["max_new_tokens"], temperature=0.0)
    verdicts = []
    for i in range(0, len(examples), chunk):
        part = examples[i : i + chunk]
        futures = [
            sampler.sample(
                prompt=types.ModelInput.from_ints(ids), num_samples=1, sampling_params=params
            )
            for ids, _, _, _ in part
        ]
        for fut in futures:
            seq = fut.result().sequences[0]
            verdicts.append(parse_verdict(tokenizer.decode(seq.tokens, skip_special_tokens=True)))
    n = len(examples)
    correct = sum(v == ex[1] for v, ex in zip(verdicts, examples))
    invalid = sum(v is None for v in verdicts)
    pos = [(v, ex) for v, ex in zip(verdicts, examples) if ex[1] == "correct"]
    neg = [(v, ex) for v, ex in zip(verdicts, examples) if ex[1] == "incorrect"]
    acc = correct / max(n, 1)
    return {
        "judge_acc": acc,
        "judge_acc_se": math.sqrt(acc * (1 - acc) / max(n, 1)),
        "acc_on_correct_pairs": sum(v == "correct" for v, _ in pos) / max(len(pos), 1),
        "acc_on_incorrect_pairs": sum(v == "incorrect" for v, _ in neg) / max(len(neg), 1),
        "invalid_rate": invalid / max(n, 1),
        "frac_pred_correct": sum(v == "correct" for v in verdicts) / max(n, 1),
        "n": n,
    }


def main():
    rng = random.Random(CONFIG["seed"])
    service = tinker.ServiceClient(
        base_url=os.environ["TINKER_BASE_URL"],
        api_key=os.environ["TINKER_API_KEY"],
        default_headers_overrides={
            "Modal-Key": os.environ["MODAL_PROXY_TOKEN_ID"],
            "Modal-Secret": os.environ["MODAL_PROXY_TOKEN_SECRET"],
        },
    )
    training = service.create_lora_training_client(
        base_model=CONFIG["base_model"], rank=CONFIG["lora_rank"]
    )
    tokenizer = training.get_tokenizer()
    print(f"training client ready for {CONFIG['base_model']} after {elapsed():.0f}s", flush=True)

    eval_examples = load_examples(tokenizer, CONFIG["eval_split"], CONFIG["n_eval"], rng, balanced=True)
    train_examples = load_examples(tokenizer, CONFIG["split"], CONFIG["n_examples"], rng, balanced=False)
    train_data = [make_datum(tokenizer, ids, v) for ids, v, _, _ in train_examples]
    eval_data = [make_datum(tokenizer, ids, v) for ids, v, _, _ in eval_examples]
    print(
        f"loaded {len(train_data)} train / {len(eval_data)} eval grading pairs after {elapsed():.0f}s "
        f"(train frac correct {sum(v == 'correct' for _, v, _, _ in train_examples) / max(len(train_examples), 1):.3f})",
        flush=True,
    )

    eval_before = eval_loss(training, eval_data) if eval_data else None
    print(f"eval loss before training: {eval_before}", flush=True)

    # Baseline arm: zero-shot base model, identical prompt/template/decoding/eval pairs.
    t0 = time.monotonic()
    base_sampler = service.create_sampling_client(base_model=CONFIG["base_model"])
    base_metrics = judge_eval(base_sampler, tokenizer, eval_examples)
    base_eval_seconds = time.monotonic() - t0
    print(f"base judge: {json.dumps(base_metrics)} ({base_eval_seconds:.0f}s)", flush=True)

    # Reserve time for the LoRA eval inside the wall-time budget.
    reserve = 1.5 * base_eval_seconds + 120
    losses = []
    for step in range(CONFIG["steps"]):
        if elapsed() > CONFIG["max_wall_seconds"] - reserve:
            print(f"stopping at step {step}: wall-time budget reached", flush=True)
            break
        start = step * CONFIG["batch_size"] % len(train_data)
        batch = (train_data + train_data)[start : start + CONFIG["batch_size"]]
        step_started = time.monotonic()
        output = training.forward_backward([d for d, _ in batch], "cross_entropy").result()
        training.optim_step(optim_params()).result()
        loss = mean_loss(output, batch)
        if not math.isfinite(loss):
            raise RuntimeError(f"step {step}: loss is {loss}")
        losses.append(loss)
        print(f"step {step}: train loss {loss:.4f} ({time.monotonic() - step_started:.1f}s)", flush=True)

    eval_after = eval_loss(training, eval_data) if eval_data else None
    print(f"eval loss after training: {eval_after}", flush=True)

    lora_sampler = training.save_weights_and_get_sampling_client(name="pubmedqa-judge")
    lora_metrics = judge_eval(lora_sampler, tokenizer, eval_examples)
    print(f"LoRA judge: {json.dumps(lora_metrics)}", flush=True)

    gap = 100.0 * (lora_metrics["judge_acc"] - base_metrics["judge_acc"])
    if gap >= 10.0:
        verdict = "validated"
    elif gap < 3.0:
        verdict = "failed"
    else:
        verdict = "inconclusive"

    result = {
        "train_loss_first": losses[0] if losses else None,
        "train_loss_last": losses[-1] if losses else None,
        "train_loss_min": min(losses) if losses else None,
        "eval_loss_before": eval_before,
        "eval_loss_after": eval_after,
        "steps": len(losses),
        "n_examples": len(train_data),
        "n_eval": len(eval_data),
        "base_model": CONFIG["base_model"],
        "lora_rank": CONFIG["lora_rank"],
        "lr": CONFIG["lr"],
        "optimizer": CONFIG["optimizer"],
        "dataset": CONFIG["dataset"],
        "judge_acc": lora_metrics["judge_acc"],
        "judge_acc_se": lora_metrics["judge_acc_se"],
        "lora_judge": lora_metrics,
        "baseline": {
            "arm": "zero-shot base model (no LoRA)",
            "eval_loss": eval_before,
            **base_metrics,
        },
        "judge_acc_gap_points": gap,
        "verdict": verdict,
        "wall_seconds": round(elapsed(), 1),
    }
    print("RESULT " + json.dumps(result), flush=True)


if __name__ == "__main__":
    main()
Evidence

Unknown: this run has no recorded evidence bundle.

All metrics
train_loss_first
0.8029
train_loss_last
0.01785
train_loss_min
0.0001048
eval_loss_before
0.7103
eval_loss_after
0.2683
steps
150
n_examples
9,600
n_eval
1,000
lora_rank
32
lr
0.0001
judge_acc
0.85
judge_acc_se
0.01129
judge_acc_gap_points
30.9
wall_seconds
1,084.2
base_model
Qwen/Qwen3.5-4B
optimizer
adam
dataset
qiaojin/PubMedQA
lora_judge
{'judge_acc': 0.85, 'judge_acc_se': 0.011291589790636216, 'acc_on_correct_pairs': 0.81, 'acc_on_incorrect_pairs': 0.89, 'invalid_rate': 0.0, 'frac_pred_correct': 0.46, 'n': 1000}
baseline
{'arm': 'zero-shot base model (no LoRA)', 'eval_loss': 0.7102990943645127, 'judge_acc': 0.541, 'judge_acc_se': 0.015758140753274162, 'acc_on_correct_pairs': 0.982, 'acc_on_incorrect_pairs': 0.1, 'invalid_rate': 0.0, 'frac_pred_correct': 0.941, 'n': 1000}
verdict
validated
Artifacts
command
python experiment_run_04595af10e.py
config
{'base_model': 'Qwen/Qwen3.5-4B', 'lora_rank': 32, 'dataset': 'qiaojin/PubMedQA', 'split': 'pqa_artificial/train', 'n_examples': 4096, 'n_eval': 256, 'steps': 150, 'batch_size': 64, 'max_seq_len': 1024, 'lr': 0.0001, 'optimizer': 'adam', 'max_wall_seconds': 2700}
modal_call_id
fc-01M6FN0T0YRW78MF8YRMM5RDTX
script_source
Claude (claude-opus-5-5) adapted the reference script
Log
Log
script source: Claude (claude-opus-5-5) adapted the reference scriptrunning /tmp/rx-run_04595af10e-9aws2268/experiment_run_04595af10e.py (timeout 3540s)[transformers] PyTorch was not found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.training client ready for Qwen/Qwen3.5-4B after 156sGenerating train split:   0%|          | 0/1000 [00:00<?, ? examples/s]Generating train split: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 1000/1000 [00:00<00:00, 22082.26 examples/s]Generating train split:   0%|          | 0/211269 [00:00<?, ? examples/s]Generating train split:   6%|โ–Œ         | 13000/211269 [00:00<00:01, 113750.01 examples/s]Generating train split:  17%|โ–ˆโ–‹        | 35000/211269 [00:00<00:01, 169356.75 examples/s]Generating train split:  27%|โ–ˆโ–ˆโ–‹       | 57000/211269 [00:00<00:00, 189632.89 examples/s]Generating train split:  40%|โ–ˆโ–ˆโ–ˆโ–‰      | 84000/211269 [00:00<00:00, 175765.16 examples/s]Generating train split:  51%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ     | 108000/211269 [00:00<00:00, 165132.12 examples/s]Generating train split:  63%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–Ž   | 133000/211269 [00:00<00:00, 163116.87 examples/s]Generating train split:  71%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–  | 151000/211269 [00:00<00:00, 164859.78 examples/s]Generating train split:  81%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ– | 172000/211269 [00:01<00:00, 174029.16 examples/s]Generating train split:  93%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–Ž| 197000/211269 [00:01<00:00, 169439.48 examples/s]Generating train split: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 211269/211269 [00:01<00:00, 169813.86 examples/s]loaded 9600 train / 1000 eval grading pairs after 180s (train frac correct 0.505)eval loss before training: 0.7102990943645127Sampling is paused for sampler sample-f44429125c9fa3e1dbd53b437138f0f5. Reason: starting a sampler for Qwen/Qwen3.5-4B (33s so far). The first request for a model starts GPUs and loads weights, which can take several minutesSampling is paused for sampler sample-f44429125c9fa3e1dbd53b437138f0f5. Reason: starting a sampler for Qwen/Qwen3.5-4B (1m 03s so far). The first request for a model starts GPUs and loads weights, which can take several minutesbase judge: {"judge_acc": 0.541, "judge_acc_se": 0.015758140753274162, "acc_on_correct_pairs": 0.982, "acc_on_incorrect_pairs": 0.1, "invalid_rate": 0.0, "frac_pred_correct": 0.941, "n": 1000} (170s)step 0: train loss 0.8029 (77.4s)step 1: train loss 0.8935 (9.9s)step 2: train loss 0.4596 (2.3s)step 3: train loss 0.1663 (2.3s)step 4: train loss 0.1183 (2.3s)step 5: train loss 0.0694 (14.1s)step 6: train loss 0.0408 (2.5s)step 7: train loss 0.1566 (2.4s)step 8: train loss 0.0177 (2.2s)step 9: train loss 0.0093 (2.3s)step 10: train loss 0.0972 (2.2s)step 11: train loss 0.0003 (2.3s)step 12: train loss 0.0649 (2.4s)step 13: train loss 0.2276 (2.2s)step 14: train loss 0.1747 (2.3s)step 15: train loss 0.1498 (2.2s)step 16: train loss 0.0436 (2.2s)step 17: train loss 0.0092 (2.3s)step 18: train loss 0.0216 (2.5s)step 19: train loss 0.0079 (2.2s)step 20: train loss 0.0050 (2.3s)step 21: train loss 0.0154 (2.3s)step 22: train loss 0.0127 (2.2s)step 23: train loss 0.0118 (2.4s)step 24: train loss 0.0193 (2.2s)step 25: train loss 0.0183 (2.3s)step 26: train loss 0.0150 (2.3s)step 27: train loss 0.0246 (2.3s)step 28: train loss 0.0116 (2.3s)step 29: train loss 0.0344 (2.4s)step 30: train loss 0.0102 (2.2s)step 31: train loss 0.0078 (2.3s)step 32: train loss 0.0127 (2.3s)step 33: train loss 0.0028 (2.3s)step 34: train loss 0.0713 (2.4s)step 35: train loss 0.0478 (2.4s)step 36: train loss 0.0184 (2.2s)step 37: train loss 0.0478 (2.3s)step 38: train loss 0.0631 (2.2s)step 39: train loss 0.0658 (2.2s)step 40: train loss 0.0066 (2.3s)step 41: train loss 0.0029 (2.2s)step 42: train loss 0.0044 (2.5s)step 43: train loss 0.0153 (2.2s)step 44: train loss 0.0026 (2.2s)step 45: train loss 0.0078 (2.4s)step 46: train loss 0.0040 (2.3s)step 47: train loss 0.0090 (2.3s)step 48: train loss 0.0495 (2.2s)step 49: train loss 0.0106 (2.3s)step 50: train loss 0.0250 (2.2s)step 51: train loss 0.0035 (2.3s)step 52: train loss 0.0016 (2.2s)step 53: train loss 0.0385 (2.3s)step 54: train loss 0.0226 (2.2s)step 55: train loss 0.0019 (2.2s)step 56: train loss 0.0048 (2.2s)step 57: train loss 0.0329 (2.3s)step 58: train loss 0.0039 (2.2s)step 59: train loss 0.0442 (2.2s)step 60: train loss 0.0204 (2.4s)step 61: train loss 0.0525 (2.2s)step 62: train loss 0.0035 (2.2s)step 63: train loss 0.0141 (2.2s)step 64: train loss 0.0319 (2.3s)step 65: train loss 0.0556 (2.3s)step 66: train loss 0.0257 (2.3s)step 67: train loss 0.0043 (2.3s)step 68: train loss 0.0625 (2.3s)step 69: train loss 0.0130 (2.2s)step 70: train loss 0.0145 (2.2s)step 71: train loss 0.0109 (2.2s)step 72: train loss 0.0056 (2.2s)step 73: train loss 0.0428 (2.2s)step 74: train loss 0.0338 (2.3s)step 75: train loss 0.0176 (2.3s)step 76: train loss 0.0066 (2.2s)step 77: train loss 0.0057 (2.3s)step 78: train loss 0.0116 (2.3s)step 79: train loss 0.0055 (2.2s)step 80: train loss 0.0100 (2.3s)step 81: train loss 0.0107 (2.2s)step 82: train loss 0.0674 (2.2s)step 83: train loss 0.0271 (2.2s)step 84: train loss 0.0238 (2.2s)step 85: train loss 0.0870 (2.3s)step 86: train loss 0.0022 (2.2s)step 87: train loss 0.0155 (2.2s)step 88: train loss 0.0264 (2.2s)step 89: train loss 0.0052 (2.2s)step 90: train loss 0.0337 (2.2s)step 91: train loss 0.0374 (2.3s)step 92: train loss 0.0484 (2.2s)step 93: train loss 0.0119 (2.3s)step 94: train loss 0.0040 (2.2s)step 95: train loss 0.0335 (2.2s)step 96: train loss 0.0035 (2.2s)step 97: train loss 0.0099 (2.3s)step 98: train loss 0.0229 (2.2s)step 99: train loss 0.0164 (2.2s)step 100: train loss 0.0110 (2.2s)step 101: train loss 0.0047 (2.3s)step 102: train loss 0.0236 (2.2s)step 103: train loss 0.0230 (2.2s)step 104: train loss 0.0390 (2.2s)step 105: train loss 0.0161 (2.3s)step 106: train loss 0.0046 (2.2s)step 107: train loss 0.0231 (2.3s)step 108: train loss 0.0273 (2.2s)step 109: train loss 0.0311 (2.2s)step 110: train loss 0.0127 (2.2s)step 111: train loss 0.0117 (2.2s)step 112: train loss 0.0229 (2.5s)step 113: train loss 0.0109 (2.2s)step 114: train loss 0.0487 (2.2s)step 115: train loss 0.0202 (2.2s)step 116: train loss 0.0054 (2.2s)step 117: train loss 0.0375 (2.2s)step 118: train loss 0.0588 (2.2s)step 119: train loss 0.0236 (2.3s)step 120: train loss 0.0336 (2.3s)step 121: train loss 0.0061 (2.2s)step 122: train loss 0.0135 (2.3s)step 123: train loss 0.0073 (2.2s)step 124: train loss 0.0056 (2.2s)step 125: train loss 0.0106 (2.2s)step 126: train loss 0.0121 (2.3s)step 127: train loss 0.0110 (2.2s)step 128: train loss 0.0046 (2.2s)step 129: train loss 0.0048 (2.2s)step 130: train loss 0.0084 (2.2s)step 131: train loss 0.0005 (2.2s)step 132: train loss 0.0059 (2.2s)step 133: train loss 0.0354 (2.2s)step 134: train loss 0.0129 (2.2s)step 135: train loss 0.0001 (2.3s)step 136: train loss 0.0258 (2.2s)step 137: train loss 0.0094 (2.2s)step 138: train loss 0.0012 (2.2s)step 139: train loss 0.0333 (2.4s)step 140: train loss 0.0327 (2.2s)step 141: train loss 0.0541 (2.2s)step 142: train loss 0.0014 (2.2s)step 143: train loss 0.0195 (2.2s)step 144: train loss 0.0010 (2.2s)step 145: train loss 0.0455 (2.2s)step 146: train loss 0.0309 (2.2s)step 147: train loss 0.0143 (2.2s)step 148: train loss 0.0168 (2.2s)step 149: train loss 0.0179 (2.2s)eval loss after training: 0.2683080751047255Sampling is paused for sampler sample-06ceb432d9055d077b4d4c16d69d6f6a. Reason: starting a sampler for Qwen/Qwen3.5-4B (33s so far). The first request for a model starts GPUs and loads weights, which can take several minutesSampling is paused for sampler sample-06ceb432d9055d077b4d4c16d69d6f6a. Reason: starting a sampler for Qwen/Qwen3.5-4B (1m 03s so far). The first request for a model starts GPUs and loads weights, which can take several minutesLoRA judge: {"judge_acc": 0.85, "judge_acc_se": 0.011291589790636216, "acc_on_correct_pairs": 0.81, "acc_on_incorrect_pairs": 0.89, "invalid_rate": 0.0, "frac_pred_correct": 0.46, "n": 1000}RESULT {"train_loss_first": 0.8028512270393549, "train_loss_last": 0.017853043140178215, "train_loss_min": 0.00010479391566775575, "eval_loss_before": 0.7102990943645127, "eval_loss_after": 0.2683080751047255, "steps": 150, "n_examples": 9600, "n_eval": 1000, "base_model": "Qwen/Qwen3.5-4B", "lora_rank": 32, "lr": 0.0001, "optimizer": "adam", "dataset": "qiaojin/PubMedQA", "judge_acc": 0.85, "judge_acc_se": 0.011291589790636216, "lora_judge": {"judge_acc": 0.85, "judge_acc_se": 0.011291589790636216, "acc_on_correct_pairs": 0.81, "acc_on_incorrect_pairs": 0.89, "invalid_rate": 0.0, "frac_pred_correct": 0.46, "n": 1000}, "baseline": {"arm": "zero-shot base model (no LoRA)", "eval_loss": 0.7102990943645127, "judge_acc": 0.541, "judge_acc_se": 0.015758140753274162, "acc_on_correct_pairs": 0.982, "acc_on_incorrect_pairs": 0.1, "invalid_rate": 0.0, "frac_pred_correct": 0.941, "n": 1000}, "judge_acc_gap_points": 30.899999999999995, "verdict": "validated", "wall_seconds": 1084.2}
Details
run
run_04595af10e
backend
Modal runner
experiment
exp_f7d448d306
created
Nov 3, 02:50 UTC
started
Nov 3, 02:50 UTC
finished
Nov 3, 03:09 UTC
duration
19m 21s
GPU-h budget
1.5
wall budget
90 min
base model
Qwen/Qwen3.5-4B
training
SFT
dataset
PubMedQA (pqa_artificial for train, pqa_labeled for eval)
Other runs of this idea

Submit an idea

Most ideas come in from Slack threads. Use this when yours is not in one yet; the triage agent picks it up.