Fine-Tuning a Model on Your Own Data
This page shows how to produce a LoRA adapter — a small set of extra weights that adjust one of the served text models toward a task or style of your own — and then serve it. Once you have an adapter, Using Your Own Fine-Tuned Model covers loading it into a session; this page is the step before that, where the adapter is made.
What the service provides, and what you run yourself
The serving side is built and verified: ai-session <preset> --lora
NAME=PATH loads a finished adapter (see Using Your Own Fine-Tuned
Model). The training side below is a worked recipe you run on
your own GPU allocation — Slurm bills your account directly, and no
Service Units are involved. The service does not yet ship a packaged,
one-command training tool, so treat the script here as a starting point to
adapt, not a tested black box. The two settings it exists to get right — the
base checkpoint and the chat template — are called out below.
Is a LoRA adapter the right tool?
The models served today are text language models, so this page trains text adapters. A LoRA adapter is worth producing when one text-to-text mapping recurs across many inputs — a fixed output schema, free text mapped to structured fields, a house coding or writing style — and prompting alone will not hold the pattern. The worked example below, turning thousands of pathology reports into one fixed set of fields, is that case.
Several nearby problems are not. Answering questions from a corpus of papers or
lab protocols is retrieval over embeddings, not training. Reading a finding from
a whole-slide image or micrograph is a job for the vision-language model
qwen3.5_122B (once served), prompted directly rather than fine-tuned.
Recovering a simulator's parameters is simulation-based inference, which wants a
density estimator, not a language model at all.
The single-transformation case is the common trap. "I have a statistical-genetics codebase that computes true effect sizes from noisy GWAS estimates and I want to port it to PyTorch" is one translation with the code already in front of you, not a durable pattern; open the repository in a coding session and let the tool read your files. Fine-tune only when the pattern recurs across many inputs, as in the worked example below.
Worked example: extracting structured data from a document corpus
A common and broadly applicable case: you have thousands of unstructured documents — pathology or radiology reports, clinical notes, interview transcripts, curated literature — and you need each turned into the same set of structured fields for analysis. The mapping from varied free text to one fixed schema is a repeated pattern, which is what a LoRA learns well.
This is also a task the on-cluster service is specifically suited to: when the text is protected (for example PHI under HIPAA) it cannot be sent to a hosted model, so an adapter that runs entirely inside the cluster is the enabling option, not merely a convenience. A base model with a good prompt may extract a few reports acceptably; a fine-tune earns its keep at volume, where per-call prompt length, throughput, and consistency of the schema across every document are what matter.
The example below extracts a small pathology-report schema. The reports shown are synthetic, not real patient data.
What you need
- A dataset of examples of the mapping you want, as a JSONL file with one
chat conversation per line (
{"messages": [...]}). Fifty to a few hundred hand-checked examples is a good range; quality matters far more than volume, because every assistant turn is a target the model learns to reproduce. - Your own GPU allocation (your PI's Slurm account and partition). Training does not run under the service; it runs as an ordinary batch job on your account.
- A training environment — a Python virtual environment with
torch,transformers,peft,trl, anddatasets. Build it once on a login node, which has internet; nothing for training is preinstalled.
Step 1: Build the dataset
Each line pairs an input document with the exact structured output you want back. Note how the assistant turns normalize varied source phrasing into one schema — "Nottingham grade 2", "moderately differentiated", and "poorly differentiated" all map to an integer grade, and centimetres are converted to millimetres. That normalization, applied consistently across a whole corpus, is the reason to train rather than prompt.
{"messages": [
{"role": "user", "content": "Extract the fields from this pathology report as JSON: site, diagnosis, grade (1-3), tumor_size_mm, margins, nodes_positive, nodes_examined.\n\nSpecimen: left breast, lumpectomy. Invasive ductal carcinoma, Nottingham grade 2. Tumor size 1.8 cm. Margins negative, closest margin 3 mm. 2 of 3 sentinel lymph nodes positive."},
{"role": "assistant", "content": "{\"site\": \"left breast\", \"diagnosis\": \"invasive ductal carcinoma\", \"grade\": 2, \"tumor_size_mm\": 18, \"margins\": \"negative\", \"nodes_positive\": 2, \"nodes_examined\": 3}"}
]}
{"messages": [
{"role": "user", "content": "Extract the fields from this pathology report as JSON: site, diagnosis, grade (1-3), tumor_size_mm, margins, nodes_positive, nodes_examined.\n\nSpecimen: sigmoid colon, segmental resection. Moderately differentiated adenocarcinoma invading through the muscularis propria into pericolic fat. Largest dimension 4.2 cm. Resection margins uninvolved. 0 of 14 lymph nodes involved."},
{"role": "assistant", "content": "{\"site\": \"sigmoid colon\", \"diagnosis\": \"adenocarcinoma\", \"grade\": 2, \"tumor_size_mm\": 42, \"margins\": \"negative\", \"nodes_positive\": 0, \"nodes_examined\": 14}"}
]}
{"messages": [
{"role": "user", "content": "Extract the fields from this pathology report as JSON: site, diagnosis, grade (1-3), tumor_size_mm, margins, nodes_positive, nodes_examined.\n\nSpecimen: stomach, distal gastrectomy. Poorly differentiated adenocarcinoma, greatest dimension 5.5 cm. Margins negative. 6 of 21 regional lymph nodes positive for metastatic carcinoma."},
{"role": "assistant", "content": "{\"site\": \"stomach\", \"diagnosis\": \"adenocarcinoma\", \"grade\": 3, \"tumor_size_mm\": 55, \"margins\": \"negative\", \"nodes_positive\": 6, \"nodes_examined\": 21}"}
]}
The file itself has one JSON object per physical line (no pretty-printing); the
blocks above are expanded only for reading. Put the file on project or scratch
storage — for example /project/<pi>/pathology/train.jsonl.
Step 2: Build the training environment
Once, on a login node (which has internet):
python -m venv ~/lora-train-env
source ~/lora-train-env/bin/activate
pip install torch transformers peft trl datasets
Keep this separate from anything the service ships; it is yours to manage.
Step 3: Write the training script
The two settings that must be exactly right are the base checkpoint — the
same one the service serves, loaded from the shared model directory, not a fresh
download that may be a different snapshot — and the chat template, applied
with the base model's own tokenizer so the fine-tune sees the prompt framing
inference will use. This example fine-tunes the small model (Qwen3-4B), which
is cheap to train and enough for a well-defined extraction schema; scale to the
72B for harder tasks by changing one path. Save this as train_lora.py:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig
from trl import SFTTrainer, SFTConfig
from datasets import load_dataset
# 1. SAME checkpoint the service serves -- not a fresh Hugging Face download.
BASE = "/project/rcc/mehta5/vllm/models/Qwen3-4B"
tok = AutoTokenizer.from_pretrained(BASE)
model = AutoModelForCausalLM.from_pretrained(
BASE, torch_dtype=torch.bfloat16, device_map="auto")
# 2. Format each example with the base model's OWN chat template.
ds = load_dataset("json", data_files="/project/<pi>/pathology/train.jsonl", split="train")
ds = ds.map(lambda r: {"text": tok.apply_chat_template(r["messages"], tokenize=False)})
lora = LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05, task_type="CAUSAL_LM",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"])
trainer = SFTTrainer(
model=model, train_dataset=ds, peft_config=lora,
args=SFTConfig(
output_dir="/project/<pi>/adapters/path-extract",
num_train_epochs=3, per_device_train_batch_size=4,
gradient_accumulation_steps=4, learning_rate=2e-4,
bf16=True, dataset_text_field="text", max_seq_length=2048))
trainer.train()
trainer.save_model() # writes adapter_config.json + the adapter weights
Rank 16 is well inside the serving cap of 256. To fine-tune the general 72B model
instead, point BASE at /project/rcc/mehta5/vllm/models/Qwen2.5-72B-Instruct
(it needs more GPUs and a longer run) and serve the matching preset later.
Step 4: Submit the training job
Training runs on your allocation, so submit it with your own account and
partition. A minimal train_lora.sbatch for the 4B base:
#!/bin/bash
#SBATCH --job-name=lora-path-extract
#SBATCH --account=<your-account>
#SBATCH --partition=<your-gpu-partition>
#SBATCH --gres=gpu:1
#SBATCH --cpus-per-task=8
#SBATCH --mem=64G
#SBATCH --time=02:00:00
source ~/lora-train-env/bin/activate
python train_lora.py
sbatch train_lora.sbatch
A three-epoch, rank-16 run on a few hundred examples with the 4B base takes well under an hour on one A100-80GB. Slurm bills your account for that time; the ai-session service and its Service Units are not involved in training at all.
When the job finishes, /project/<pi>/adapters/path-extract contains
adapter_config.json and the adapter weights — exactly what the serving side
validates.
Step 5: Serve your adapter
Start a session with the adapter loaded, giving it a short name you will type in
your client. The 4B adapter is served by the fast preset:
ai-session fast --lora pathextract=/project/<pi>/adapters/path-extract
Send documents with model=pathextract and the model returns the schema you
taught; request the base model (qwen3_4b) in the same session to compare
against stock behavior at no extra cost. To guarantee the output parses as JSON
rather than trusting the model to stay in format, validate each response and
retry on failure; a server-enforced JSON mode is a planned addition. The full
serving details — per-client usage, cost, and limits — are on
Using Your Own Fine-Tuned Model.
Training the base model to act as an agent in your domain
The LoRA route above teaches a frozen base model a text-to-text pattern, and retrieval puts domain facts in front of a general model at inference. Neither changes how the base model behaves as an agent — how well it holds a multi-turn tool-calling loop and stays in a strict output format. When that agentic behavior is what you need from an open model you can serve entirely on the cluster, the base weights have to change, and the recipe is heavier than a LoRA by orders of magnitude.
The biomedical agent in Biomni (Science 2026) is the reference point, and it cuts two ways:
- Its headline system needs no training at all: a strong general model (the published work uses Claude, zero-shot) drives the agent, wrapped in retrieval over a large curated biomedical data lake and code execution. If your goal is an agent that is capable in your field, that agent-plus-retrieval route — see Agent responsibilities and risks — is cheaper, keeps improving as your data changes, and is the first thing to try.
- Its open-weights variant, Biomni-R0, is trained, from Qwen3-8B and Qwen3-32B, because the vanilla Qwen3 models struggle with multi-turn tool use and strict format compliance. The two-phase recipe below is that variant's (from the published Science version's supplement; it is not in the earlier bioRxiv preprint), for the case where you need an open model — no external API in the loop — to be the agent itself.
The two-phase recipe
Biomni-R0's procedure, from the paper's supplement, is distillation followed by reinforcement learning. The numbers are the paper's, sized to that task; treat them as a starting point, not universal constants.
| Phase | What it does | Data | Settings (from the paper) |
|---|---|---|---|
| 1 — Rejection-sampled SFT | Full-parameter supervised fine-tuning on expert trajectories, to teach the tool loop and the output format | 834 trajectories kept by rejection sampling: for each task, generate 8 rollouts with a strong teacher and keep the highest-reward one | 4 epochs, batch size 16, learning rate 1e-5, cosine annealing |
| 2 — Reinforcement learning (GRPO) | Refine the policy on a group of rollouts per prompt, scored by a task ground-truth reward plus a formatting reward | 4447 samples | 1 epoch, batch size 32, 8 rollouts per sample, learning rate 1e-6, cosine |
The teacher used for rejection sampling in the paper is Claude-4-Sonnet, a
frontier API model. That step sends your task prompts off the cluster, which
gives up the data-locality reason for using this service. If your trajectories
contain protected or unpublished data, generate them with a strong local model
instead — the served qwen2.5_72B, or qwen3.5_122B once it is served — and
accept that a weaker teacher yields weaker distillation. If the data is public,
an external teacher is fine.
Compute
Full-parameter training of a 32B model does not fit on one GPU: the weights, gradients, and Adam optimizer state together need roughly 16 bytes per parameter — about 520 GB for Qwen3-32B — before activations, so the model is sharded across many GPUs. The paper's allocation:
| Model | GPUs | Rollout parallelism | Actor-update parallelism |
|---|---|---|---|
| Biomni-R0-8b | 8 × A100 | 2-way tensor parallel | FSDP + 4-way sequence parallel |
| Biomni-R0-32b | 16 × A100 | 4-way tensor parallel | FSDP + 4-way sequence parallel |
16 × A100-80GB is two full GPU nodes. This is a multi-node, multi-day job on your own allocation — it does not run under ai-session and costs no Service Units; Slurm bills your account for the GPUs directly, as with the LoRA training above. If you do not have that budget, the LoRA and retrieval routes above are the realistic alternatives; there is no cheap path to full 32B reinforcement learning.
Tools
- Phase 1 (SFT) is ordinary full-parameter supervised fine-tuning: the
Step 3 script above with the LoRA config
removed (so every weight updates) and a sharding backend added — DeepSpeed
ZeRO-3 or PyTorch FSDP — because the full model no longer fits on one GPU.
Point
BASEat/project/rcc/mehta5/vllm/models/Qwen3-32Band format each trajectory with that model's own chat template, exactly as in the LoRA example. - Phase 2 (RL) uses GRPO (Group Relative Policy Optimization) with per-prompt rollout groups and separate rollout and actor-update parallelism — which frameworks like verl and OpenRLHF implement: they run the rollout generation through vLLM with tensor parallelism and the policy update through FSDP, which is exactly the split in the compute table. Configure one of these with the base checkpoint, your reward function, and the hyperparameters above rather than writing the RL loop yourself.
Serving what you trained
A fully retrained model is a new base checkpoint, not an adapter, so it is not
loaded with --lora at session start. The service serves only pre-staged,
registered models, and GPU nodes have no internet, so to serve your trained model
through ai-session it must be staged like any other served model — copied into
the model store and registered — which RCC staff do on request (see the staging
note on the home page). Keep the tokenizer and chat
template with the checkpoint so it serves and bills like the model it was trained
from. Until it is staged, you can serve it yourself with your own vLLM on your own
allocation.
Getting help
Training on your own data raises questions this page cannot answer in advance (which base to choose, how much data you need, why an adapter underperforms). For those, and for whether a packaged training command should exist, contact RCC staff — see the FAQ.