Fine-Tuned Model Evaluation: Benchmarks, Accuracy Regression, and RLHF Testing
Fine-tuning a language model improves it on your specific task — but at a cost. Fine-tuned models can regress on capabilities the base model had, forget general knowledge, or overfit to training data in ways that hurt generalization. This guide covers how to evaluate fine-tuned models systematically: benchmark datasets, accuracy regression testing, RLHF evaluation, and setting up automated evaluation pipelines.
The Fine-Tuning Evaluation Problem
When you fine-tune a model, you're trading some general capability for domain-specific performance. The evaluation challenge is measuring both sides of this trade:
Things you want to improve:
- Task-specific accuracy (your target domain)
- Format compliance (following your output format)
- Tone and style (matching your requirements)
Things you must not regress:
- General reasoning ability
- Instruction following on related tasks
- Safety and refusal behaviors
- Basic factual knowledge
Without systematic evaluation, you won't know what you broke until users complain.
Pre-Training Baselines
Before fine-tuning, record baselines on the base model. These become your regression targets:
# scripts/record_baseline.py
"""
Run this BEFORE fine-tuning. Records base model performance.
Save the output — you'll compare against it after fine-tuning.
"""
import json
import openai
from datetime import datetime
client = openai.OpenAI()
BASE_MODEL = "gpt-4o-mini" # or your base model
def evaluate_on_benchmark(model: str, benchmark: list[dict]) -> dict:
"""Evaluate model on a benchmark dataset."""
correct = 0
results = []
for case in benchmark:
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": case.get("system", "")},
{"role": "user", "content": case["input"]},
],
temperature=0,
)
actual = response.choices[0].message.content.strip()
expected = case["expected"]
is_correct = evaluate_response(actual, expected, case.get("eval_type", "exact"))
results.append({
"id": case["id"],
"input": case["input"],
"expected": expected,
"actual": actual,
"correct": is_correct,
})
if is_correct:
correct += 1
return {
"model": model,
"timestamp": datetime.utcnow().isoformat(),
"accuracy": correct / len(benchmark),
"n": len(benchmark),
"failures": [r for r in results if not r["correct"]],
}
def evaluate_response(actual: str, expected: str, eval_type: str) -> bool:
if eval_type == "exact":
return actual.strip().lower() == expected.strip().lower()
elif eval_type == "contains":
return expected.lower() in actual.lower()
elif eval_type == "json_match":
try:
actual_json = json.loads(actual)
expected_json = json.loads(expected)
return all(actual_json.get(k) == v for k, v in expected_json.items())
except (json.JSONDecodeError, AttributeError):
return False
return FalseDomain-Specific Benchmark Dataset
Create a benchmark that reflects your actual use case:
# evals/domain_benchmark.py
"""
Domain-specific benchmark for a customer support classifier fine-tune.
Target: classify support tickets into categories.
"""
DOMAIN_BENCHMARK = [
# Happy path — common categories
{
"id": "cls-001",
"input": "I can't log in to my account, the password reset email never arrived",
"expected": "account_access",
"eval_type": "exact",
"category": "account",
},
{
"id": "cls-002",
"input": "I was charged twice this month, please refund the duplicate",
"expected": "billing",
"eval_type": "exact",
"category": "billing",
},
{
"id": "cls-003",
"input": "The test runner hangs after 60 seconds and never completes",
"expected": "bug_report",
"eval_type": "exact",
"category": "bugs",
},
# Edge cases — ambiguous inputs
{
"id": "cls-004",
"input": "It's not working",
"expected": "unclassified", # Insufficient info
"eval_type": "exact",
"category": "edge_cases",
},
# Out-of-distribution — should refuse or classify as general
{
"id": "cls-005",
"input": "How do I make pasta?",
"expected": "out_of_scope",
"eval_type": "exact",
"category": "ood",
},
]
# General capability benchmark — should NOT regress after fine-tuning
GENERAL_CAPABILITY_BENCHMARK = [
{
"id": "gen-001",
"input": "What is 47 + 83?",
"expected": "130",
"eval_type": "contains",
"category": "arithmetic",
},
{
"id": "gen-002",
"input": "Translate to French: The cat is on the mat",
"expected": "Le chat est sur le tapis",
"eval_type": "contains",
"category": "translation",
},
{
"id": "gen-003",
"input": "What is the capital of Japan?",
"expected": "Tokyo",
"eval_type": "contains",
"category": "factual",
},
]Detecting Catastrophic Forgetting
The biggest risk in fine-tuning is catastrophic forgetting — the model loses general capabilities. Test for it explicitly:
# tests/finetuning/test_catastrophic_forgetting.py
import pytest
from evals.domain_benchmark import GENERAL_CAPABILITY_BENCHMARK
REGRESSION_TOLERANCE = 0.05 # Allow at most 5% regression on general capabilities
def test_no_catastrophic_forgetting(base_model_scores: dict, finetuned_model_scores: dict):
"""
Fine-tuned model must not regress more than 5% on general capabilities.
Parametrized by model scores computed during evaluation.
"""
base_accuracy = base_model_scores["general"]["accuracy"]
ft_accuracy = finetuned_model_scores["general"]["accuracy"]
regression = base_accuracy - ft_accuracy
assert regression <= REGRESSION_TOLERANCE, (
f"Catastrophic forgetting detected!\n"
f"Base model accuracy: {base_accuracy:.1%}\n"
f"Fine-tuned accuracy: {ft_accuracy:.1%}\n"
f"Regression: {regression:.1%} (max allowed: {REGRESSION_TOLERANCE:.1%})\n"
f"Failures: {finetuned_model_scores['general']['failures']}"
)
def test_domain_accuracy_improved(base_model_scores: dict, finetuned_model_scores: dict):
"""Fine-tuning must improve domain task accuracy."""
base_domain = base_model_scores["domain"]["accuracy"]
ft_domain = finetuned_model_scores["domain"]["accuracy"]
assert ft_domain > base_domain, (
f"Fine-tuning didn't improve domain accuracy:\n"
f"Base: {base_domain:.1%}, Fine-tuned: {ft_domain:.1%}"
)
improvement = ft_domain - base_domain
print(f"Domain accuracy improvement: +{improvement:.1%}")
def test_edge_case_handling_maintained(base_model_scores: dict, finetuned_model_scores: dict):
"""Edge case handling must not regress — model should still identify ambiguous inputs."""
base_edge = base_model_scores.get("edge_cases", {}).get("accuracy", 0)
ft_edge = finetuned_model_scores.get("edge_cases", {}).get("accuracy", 0)
assert ft_edge >= base_edge - REGRESSION_TOLERANCE, (
f"Edge case handling regressed:\n"
f"Base: {base_edge:.1%}, Fine-tuned: {ft_edge:.1%}"
)RLHF Evaluation
If you're using RLHF (Reinforcement Learning from Human Feedback) or DPO (Direct Preference Optimization), you need to evaluate preference alignment — does the model produce outputs humans prefer?
Building a Preference Evaluation Dataset
# evals/preference_benchmark.py
"""
Preference dataset for evaluating RLHF fine-tuning.
Each case has two responses; the model should prefer "chosen" over "rejected".
"""
PREFERENCE_BENCHMARK = [
{
"id": "pref-001",
"prompt": "A user is frustrated that a test keeps failing",
"chosen": "I understand your frustration. Let's diagnose this systematically — can you share the test output? A few common causes are timeout settings, flaky selectors, or environment differences.",
"rejected": "Tests fail sometimes. Did you check the documentation?",
"preference_reason": "chosen is empathetic, specific, and actionable",
},
{
"id": "pref-002",
"prompt": "Explain what HelpMeTest does in one sentence",
"chosen": "HelpMeTest monitors your web application 24/7 by running plain-English tests continuously, alerting you before users notice issues.",
"rejected": "HelpMeTest is a testing platform that helps you test your applications.",
"preference_reason": "chosen is specific and value-focused; rejected is generic",
},
]
def evaluate_preference_alignment(model: str, preference_dataset: list[dict]) -> dict:
"""
Evaluate how often the model's output is more similar to 'chosen' than 'rejected'.
Uses semantic similarity to compare model output to chosen/rejected.
"""
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
encoder = SentenceTransformer('all-MiniLM-L6-v2')
correct = 0
for case in preference_dataset:
# Get model response
response = get_model_response(model, case["prompt"])
# Encode all three
embeddings = encoder.encode([response, case["chosen"], case["rejected"]])
sim_to_chosen = cosine_similarity([embeddings[0]], [embeddings[1]])[0][0]
sim_to_rejected = cosine_similarity([embeddings[0]], [embeddings[2]])[0][0]
if sim_to_chosen > sim_to_rejected:
correct += 1
return {
"preference_alignment": correct / len(preference_dataset),
"n": len(preference_dataset),
}Automated Win Rate Evaluation
Use an LLM judge to compare outputs at scale:
def compute_win_rate(
base_model: str,
finetuned_model: str,
test_prompts: list[str],
judge_model: str = "gpt-4o"
) -> dict:
"""
Compare outputs from base model and fine-tuned model.
Judge model decides which is better.
"""
wins = {"base": 0, "finetuned": 0, "tie": 0}
for prompt in test_prompts:
base_response = get_model_response(base_model, prompt)
ft_response = get_model_response(finetuned_model, prompt)
judge_prompt = f"""You are comparing two AI responses to determine which is better.
PROMPT: {prompt}
RESPONSE A: {base_response}
RESPONSE B: {ft_response}
Which response is better? Consider: accuracy, helpfulness, clarity, tone.
Answer ONLY: "A", "B", or "TIE"."""
verdict = get_model_response(judge_model, judge_prompt).strip()
if verdict == "B":
wins["finetuned"] += 1
elif verdict == "A":
wins["base"] += 1
else:
wins["tie"] += 1
total = len(test_prompts)
return {
"win_rate_finetuned": wins["finetuned"] / total,
"win_rate_base": wins["base"] / total,
"tie_rate": wins["tie"] / total,
"wins": wins,
"total": total,
}
def test_finetuned_has_positive_win_rate():
"""Fine-tuned model should win more often than base on domain prompts."""
domain_prompts = [case["prompt"] for case in PREFERENCE_BENCHMARK]
win_rates = compute_win_rate("gpt-4o-mini", "ft:gpt-4o-mini:org:model:id", domain_prompts)
assert win_rates["win_rate_finetuned"] > win_rates["win_rate_base"], (
f"Fine-tuned model doesn't outperform base:\n"
f"Fine-tuned win rate: {win_rates['win_rate_finetuned']:.1%}\n"
f"Base win rate: {win_rates['win_rate_base']:.1%}"
)Full Evaluation Pipeline
Tie it together in a runnable script:
# scripts/evaluate_finetuned_model.py
"""
Full evaluation pipeline for a fine-tuned model.
Run after every fine-tuning job before deploying.
"""
import argparse
import json
from pathlib import Path
from evals.domain_benchmark import DOMAIN_BENCHMARK, GENERAL_CAPABILITY_BENCHMARK
from evals.preference_benchmark import PREFERENCE_BENCHMARK, evaluate_preference_alignment, compute_win_rate
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--base-model", required=True)
parser.add_argument("--finetuned-model", required=True)
parser.add_argument("--output", default="eval_results.json")
args = parser.parse_args()
print(f"Evaluating: {args.finetuned_model}")
print(f"vs baseline: {args.base_model}")
print("="*60)
# 1. Domain accuracy
print("\n[1/4] Domain accuracy...")
base_domain = evaluate_on_benchmark(args.base_model, DOMAIN_BENCHMARK)
ft_domain = evaluate_on_benchmark(args.finetuned_model, DOMAIN_BENCHMARK)
print(f" Base: {base_domain['accuracy']:.1%}")
print(f" Fine-tuned: {ft_domain['accuracy']:.1%} ({ft_domain['accuracy'] - base_domain['accuracy']:+.1%})")
# 2. General capability (catastrophic forgetting check)
print("\n[2/4] General capability...")
base_general = evaluate_on_benchmark(args.base_model, GENERAL_CAPABILITY_BENCHMARK)
ft_general = evaluate_on_benchmark(args.finetuned_model, GENERAL_CAPABILITY_BENCHMARK)
print(f" Base: {base_general['accuracy']:.1%}")
print(f" Fine-tuned: {ft_general['accuracy']:.1%} ({ft_general['accuracy'] - base_general['accuracy']:+.1%})")
# 3. Preference alignment
print("\n[3/4] Preference alignment...")
ft_preference = evaluate_preference_alignment(args.finetuned_model, PREFERENCE_BENCHMARK)
print(f" Preference alignment: {ft_preference['preference_alignment']:.1%}")
# 4. Head-to-head win rate
print("\n[4/4] Win rate (fine-tuned vs base)...")
domain_prompts = [c["input"] for c in DOMAIN_BENCHMARK[:20]] # Use subset for cost
win_rates = compute_win_rate(args.base_model, args.finetuned_model, domain_prompts)
print(f" Fine-tuned wins: {win_rates['win_rate_finetuned']:.1%}")
print(f" Base wins: {win_rates['win_rate_base']:.1%}")
print(f" Ties: {win_rates['tie_rate']:.1%}")
# Summary
print("\n" + "="*60)
print("EVALUATION SUMMARY")
issues = []
if ft_domain["accuracy"] <= base_domain["accuracy"]:
issues.append(f"❌ Domain accuracy did not improve: {ft_domain['accuracy']:.1%}")
regression = base_general["accuracy"] - ft_general["accuracy"]
if regression > 0.05:
issues.append(f"❌ Catastrophic forgetting: -{regression:.1%} on general tasks")
if win_rates["win_rate_finetuned"] < 0.5:
issues.append(f"❌ Negative win rate: fine-tuned wins only {win_rates['win_rate_finetuned']:.1%}")
if issues:
print("FAILED - Do not deploy")
for issue in issues:
print(f" {issue}")
else:
print("✅ PASSED - Safe to deploy")
print(f" Domain accuracy: {base_domain['accuracy']:.1%} → {ft_domain['accuracy']:.1%}")
print(f" General regression: -{regression:.1%} (within 5% threshold)")
print(f" Win rate: {win_rates['win_rate_finetuned']:.1%}")
if __name__ == "__main__":
main()CI Integration
# .github/workflows/evaluate-finetuned.yml
name: Fine-Tuned Model Evaluation
on:
workflow_dispatch:
inputs:
finetuned_model_id:
description: 'OpenAI fine-tuned model ID'
required: true
base_model:
description: 'Base model to compare against'
default: 'gpt-4o-mini'
jobs:
evaluate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- run: pip install -r requirements-eval.txt
- name: Run full evaluation
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
python scripts/evaluate_finetuned_model.py \
--base-model ${{ github.event.inputs.base_model }} \
--finetuned-model ${{ github.event.inputs.finetuned_model_id }} \
--output results/eval_${{ github.run_id }}.json
- name: Upload evaluation results
uses: actions/upload-artifact@v4
with:
name: eval-results-${{ github.run_id }}
path: results/Key Takeaways
- Record baselines before fine-tuning — you can't detect regression without a reference point
- Test general capabilities explicitly — catastrophic forgetting is real and common
- Use domain benchmarks with edge cases — OOD handling and ambiguous inputs reveal overfitting
- RLHF/DPO evaluation requires preference data — win rate and preference alignment metrics
- Run automated win rate comparisons using LLM-as-judge at scale
- Never deploy a fine-tuned model without checking that domain accuracy improved AND general capabilities didn't regress by more than your tolerance
- Integrate evaluation into CI as a gate before deployment — not a post-hoc review