Creating comparison data - https://colab.research.google.com/github/argilla-io/argilla/blob/v1.11.0/docs/_source/guides/llms/examples/train-reward-model-rlhf.ipynb
Reward Bench - https://huggingface.co/papers/2403.13787
Relevant Resource : huyechip article in RLHF
This document explains the project end-to-end: what it does, why it is designed that way, how to run it, what each part of the code does, and how to reproduce and evaluate results. It is written as a plain, human-readable guide you can include in your submission. The project implements a small RLHF-style feedback loop: generate or use preference pairs (chosen vs rejected), train a reward model on those preferences, and demonstrate measurable improvement via reranking or lightweight policy updates. The assessment brief this project follows is summarized in the provided assignment.
Goal
- Demonstrate the core idea of RLHF: learn a reward function from preferences and use it to improve model outputs.
- Use synthetic or public preference data to train a reward model.
- Show before/after metrics and produce artifacts (notebooks, trained checkpoint, Dockerized scoring service, README).
Main steps
- Prepare a dataset of (prompt, chosen, rejected) preference pairs (synthetic generator or public dataset).
- Build a reward model that scores (prompt, response) pairs.
- Train the reward model using a pairwise preference loss.
- Evaluate the reward model (pairwise accuracy, correlation) and demonstrate improvement by reranking candidate outputs.
- Save checkpoint(s) and provide a simple API + Dockerfile to serve the model.
Accepted formats
- A JSONL file with lines like:
{"prompt":"Why did the service crash?",
"chosen":"Database connection leak leading to exhaustion of connections.",
"rejected":"Aliens tampered with the server."}Two practical approaches
-
Synthetic generator (recommended to demonstrate understanding):
- Start from templated prompts and a set of correct answers.
- Create distractors by lexical swaps, plausible-but-wrong causes, or LLM-generated alternatives.
- Optionally inject label noise (flip ~5–10% of labels) to mimic realistic human disagreement.
-
Public preference datasets (acceptable):
- Use small subsets of HelpSteer, OpenAssistant or RewardBench if you prefer real annotation. If you use these, document the source and any preprocessing you did.
Suggested dataset size for the internship: 500–2,000 prompts with 2–4 candidates each (gives a few thousand pairwise comparisons). Keep a held-out validation subset (10–20%) for model selection.
Design choices
- Keep the
Datasetsimple (return raw strings) and perform tokenization in acollate_fn. This allows faster batch tokenization and easy tokenizer swaps.
Example Dataset (returns raw strings)
class PreferenceDataset(torch.utils.data.Dataset):
def __init__(self, rows):
self.rows = rows
def __len__(self):
return len(self.rows)
def __getitem__(self, idx):
r = self.rows[idx]
return {"prompt": r["prompt"], "chosen": r["chosen"], "rejected": r["rejected"]}Example collate_fn (batch tokenization)
class PreferenceCollator:
def __init__(self, tokenizer, max_len=128):
self.tokenizer = tokenizer
self.max_len = max_len
def __call__(self, batch):
prompts = [b["prompt"] for b in batch]
chosen = [b["chosen"] for b in batch]
rejected = [b["rejected"] for b in batch]
chosen_enc = self.tokenizer([p+" "+c for p,c in zip(prompts, chosen)],
padding=True, truncation=True, max_length=self.max_len, return_tensors="pt")
rejected_enc = self.tokenizer([p+" "+r for p,r in zip(prompts, rejected)],
padding=True, truncation=True, max_length=self.max_len, return_tensors="pt")
return {
"chosen_input_ids": chosen_enc["input_ids"],
"chosen_mask": chosen_enc["attention_mask"],
"rejected_input_ids": rejected_enc["input_ids"],
"rejected_mask": rejected_enc["attention_mask"]
}DataLoader example
dataloader = DataLoader(dataset, batch_size=32, shuffle=True, collate_fn=collator, num_workers=4, pin_memory=True)Goal
- Map (prompt, response) → scalar reward.
Recommended architecture
- Encoder backbone (pretrained transformer encoder, e.g.
distilbert-base-uncased). - Simple head: a linear layer on top of the [CLS] embedding that outputs a single scalar (no activation).
Example class
class RewardModel(nn.Module):
def __init__(self, model_name="distilbert-base-uncased"):
super().__init__()
self.encoder = AutoModel.from_pretrained(model_name)
hidden = self.encoder.config.hidden_size
self.head = nn.Linear(hidden, 1)
def forward(self, input_ids, attention_mask):
out = self.encoder(input_ids=input_ids, attention_mask=attention_mask, return_dict=True)
cls = out.last_hidden_state[:,0,:]
score = self.head(cls).squeeze(-1)
return scoreNotes
- The reward model does not generate text.
- You can freeze most encoder layers to speed up training (see optimizations below). Optionally unfreeze top layers later if needed.
Core loss
- For a pair (chosen, rejected), the model produces two scalars
s_chosenands_rejected. Use the logistic pairwise loss:
L = -log(sigmoid(s_chosen - s_rejected))
- This encourages
s_chosen > s_rejected.
Implementation
def preference_loss(chosen_scores, rejected_scores):
return -torch.log(torch.sigmoid(chosen_scores - rejected_scores)).mean()This is the Bradley–Terry / pairwise logistic loss; DPO, PPO preference losses are built on similar pairwise or preference-derived objectives.
Optimizations included
- Freeze encoder parameters initially (train head only).
- Mixed precision (AMP) for GPUs.
- Learning rate scheduler with warmup.
- tqdm progress bar and average loss reporting.
- DataLoader
num_workersandpin_memorysettings.
Core training code
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")
model = RewardModel().to(device)
# freeze encoder (optional)
for p in model.encoder.parameters():
p.requires_grad = False
optimizer = torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4)
num_epochs = 3
num_training_steps = len(dataloader) * num_epochs
scheduler = get_linear_schedule_with_warmup(optimizer, num_warmup_steps=100, num_training_steps=num_training_steps)
scaler = torch.cuda.amp.GradScaler()
for epoch in range(num_epochs):
model.train()
epoch_loss = 0.0
loop = tqdm(dataloader, desc=f"Epoch {epoch+1}")
for batch in loop:
optimizer.zero_grad()
with torch.cuda.amp.autocast():
chosen_scores = model(batch["chosen_input_ids"].to(device), batch["chosen_mask"].to(device))
rejected_scores = model(batch["rejected_input_ids"].to(device), batch["rejected_mask"].to(device))
loss = preference_loss(chosen_scores, rejected_scores)
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
scheduler.step()
epoch_loss += loss.item()
loop.set_postfix(loss=loss.item())
print(f"Epoch {epoch+1} average loss: {epoch_loss/len(dataloader):.4f}")
# save checkpoint when desirable
torch.save(model.state_dict(), f"checkpoints/rm_epoch{epoch+1}.pt")Notes
- If you do not have a GPU, remove AMP and use a smaller batch size.
- To fine-tune the encoder later, unfreeze top N layers (or all layers) and continue training with a lower learning rate.
Primary evaluation goals
- Measure how well the reward model reproduces labeled preferences (pairwise accuracy).
- Demonstrate that using the reward model improves selection of answers (selection accuracy / win-rate).
- Show correlation between reward scores and ground-truth quality (Spearman rank correlation), optionally report loss curves.
How to compute pairwise accuracy
- For each validation pair, compute
s_chosenands_rejected. Ifs_chosen > s_rejected, that counts as a correct prediction.
correct = (chosen_scores > rejected_scores).sum().item()
accuracy = correct / len(validation_pairs)How to compute selection accuracy (reranking demo)
- For each prompt in test set, obtain K candidate responses (either from your synthetic dataset or by sampling from the base generator).
- Baseline selection: choose the first / highest-prob candidate.
- Reranking selection: compute reward scores for each candidate and choose the argmax.
- Compute fraction where chosen candidate equals ground-truth best (or compute pairwise comparisons vs ground-truth best).
- Report delta: rerank_accuracy - baseline_accuracy, and report counts and confidence intervals if you want statistical rigor.
Spearman correlation
- For prompts with more than two candidates and a ground-truth ranking (or similarity scores), compute Spearman rank correlation between reward scores and ground-truth ranking.
Logging
- Log per-epoch training & validation loss, pairwise accuracy, and examples of before/after selections (qualitative). A short table with 10 examples of baseline vs rerank output is powerful.
What to save
- Model weights (
model.save_pretrainedortorch.save(state_dict)). - Tokenizer artifacts.
- Training config (hyperparameters, seed, optimizer, scheduler settings) as
config.json. - A small sample of the dataset used (train/val split) and the exact random seed.
Reproducibility tips
- Set seeds for
random,numpy, andtorch. - Record the environment (Python version, PyTorch/transformers versions).
- Provide a
requirements.txtand arun.shscript that reproduces basic steps.
Serving
- Provide a small FastAPI (or Flask) app that loads the reward model and exposes a
/scoreendpoint that acceptspromptandresponseand returns a scalar score.
Example serve.py
from fastapi import FastAPI
from pydantic import BaseModel
from transformers import AutoTokenizer
import torch
class Req(BaseModel):
prompt: str
response: str
app = FastAPI()
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")
model = RewardModel()
model.load_state_dict(torch.load("checkpoints/rm_best.pt", map_location="cpu"))
model.eval()
@app.post("/score")
def score(req: Req):
enc = tokenizer(req.prompt + " " + req.response, return_tensors="pt", truncation=True, padding=True)
with torch.no_grad():
s = model(enc["input_ids"], enc["attention_mask"]).item()
return {"score": float(s)}0_data_generation.ipynb
- Show how you generated synthetic feedback (templates or LLM prompts).
- Print sample prompt/candidate pairs and label distribution.
1_train_reward_model.ipynb
- Dataset loading and collate usage.
- Model definition.
- Training loop (with progress bars) and training curves.
- Save best checkpoint.
2_evaluate_and_demo.ipynb
- Pairwise accuracy and validation metrics.
- Reranking demo: show before/after selections for example prompts.
- Optional: reward-weighted fine-tuning or a short DPO example.
Preference loss:
def preference_loss(chosen_scores, rejected_scores):
return -torch.log(torch.sigmoid(chosen_scores - rejected_scores)).mean()Training checkpoint save:
torch.save({"model_state": model.state_dict(),
"optimizer_state": optimizer.state_dict(),
"scheduler_state": scheduler.state_dict()},
"checkpoints/rm_epochX.pt")Pairwise accuracy:
with torch.no_grad():
chosen_s = model(chosen_input_ids, chosen_mask)
rejected_s = model(rejected_input_ids, rejected_mask)
acc = (chosen_s > rejected_s).float().mean().item()