Step 4: Add a Lightweight MT-Bench Judge (Optional but Recommended)
The evaluation harness is the machinery that transforms your test set from static data into actionable measurements. Unlike training loops that optimize parameters, evaluation loops hold the model constant and systematically probe its behavior. You're not trying to make the model better—you're trying to understand exactly what it can and cannot do.
The core workflow is straightforward but computationally intensive:
- Run each conversation through each model: Every conversation in your test set gets processed by both the base model and your fine-tuned model under identical conditions. Same prompt format, same sampling parameters, same context window management. This controlled comparison is what makes the results interpretable—any difference in output must be attributable to the training intervention, not experimental variance.
- Save the full outputs for later review: Raw transcripts are your ground truth. Automated metrics can guide your attention, but they can't replace actually reading what the model said. A model might score well on average while producing spectacularly broken responses in specific scenarios. Full transcripts let you debug those edge cases and understand the failure modes that aggregate statistics hide.
- Compute simple automated signals: You'll calculate metrics like response length distribution, refusal rate, and basic consistency heuristics. These aren't sophisticated NLP metrics—they're fast, interpretable proxies that help you prioritize which transcripts deserve manual inspection. A sudden spike in refusal rate tells you something changed. Whether that change is good or bad requires reading the actual refusals.
The evaluation harness deliberately avoids premature optimization. You could implement sophisticated semantic similarity metrics, or train a classifier to detect specific failure modes, or compute perplexity under various conditions. But all of that comes later, after you've established whether the basic conversational patterns work at all. Start simple, measure what matters, and add complexity only when simple metrics prove insufficient.
Create evaluate_mtbench.py
import jsonimport torchfrom transformers import AutoTokenizer, AutoModelForCausalLM BASE_MODEL = "TinyLlama/TinyLlama-1.1B-Chat-v1.0"TUNED_MODEL = "outputs/your_finetuned_model" def load_model(model_name): tok = AutoTokenizer.from_pretrained(model_name, use_fast=True) mdl = AutoModelForCausalLM.from_pretrained(model_name, device_map="auto") if tok.pad_token is None: tok.pad_token = tok.eos_token return tok, mdl def generate(mdl, tok, prompt, max_new_tokens=220, temperature=0.7): inputs = tok(prompt, return_tensors="pt").to(mdl.device) with torch.no_grad(): out = mdl.generate( **inputs, max_new_tokens=max_new_tokens, do_sample=True, temperature=temperature, top_p=0.9 ) return tok.decode(out[0], skip_special_tokens=True) def run_conversation(mdl, tok, turns): history = "" transcript = [] for user_turn in turns: prompt = history + f"\nUser: {user_turn}\nAssistant:" response = generate(mdl, tok, prompt) transcript.append({ "user": user_turn, "assistant": response }) history += f"\nUser: {user_turn}\nAssistant: {response}" return transcript def simple_refusal_flag(text): t = text.lower() refusal_markers = [ "i can't help", "i can't help", "i cannot help", "i can't assist", "i can't assist", "sorry, but", "i'm unable", "i am unable" ] return any(m in t for m in refusal_markers) def evaluate_mtbench(conversations, tok, mdl): results = [] for c in conversations: transcript = run_conversation(mdl, tok, c["turns"]) refusals = sum(simple_refusal_flag(t["assistant"]) for t in transcript) results.append({ "id": c["id"], "transcript": transcript, "refusal_count": refusals }) return results def main(): with open("data/mtbench_conversations.json", "r", encoding="utf-8") as f: conversations = json.load(f) base_tok, base_mdl = load_model(BASE_MODEL) tuned_tok, tuned_mdl = load_model(TUNED_MODEL) base_results = evaluate_mtbench(conversations, base_tok, base_mdl) tuned_results = evaluate_mtbench(conversations, tuned_tok, tuned_mdl) output = { "base_model": BASE_MODEL, "tuned_model": TUNED_MODEL, "base_results": base_results, "tuned_results": tuned_results } with open("outputs/mtbench_results.json", "w", encoding="utf-8") as f: json.dump(output, f, indent=2, ensure_ascii=False) print("Saved outputs/mtbench_results.json") if __name__ == "__main__": main()What this script actually does:
The run_conversation function is where multi-turn logic lives. It maintains a growing history string that accumulates each user turn and assistant response. This cumulative context is what makes the evaluation multi-turn—each response depends not just on the current question, but on everything said before. When the model generates a response at turn 4, it's seeing turns 1, 2, 3, and 4 all concatenated together. This is exactly how conversational models work in production, and exactly where they tend to fail in ways that single-turn evaluation never reveals.
The prompt format ("\nUser: {user_turn}\nAssistant:") is minimal but functional. Real production systems use more sophisticated chat templates with special tokens and role markers. If your model was trained with a specific chat template, you should use that exact format here. Template mismatch is a common source of evaluation bugs—the model performs worse not because training failed, but because you're feeding it prompts in a format it never saw during training.
The simple_refusal_flag function detects common refusal patterns. It's not exhaustive—models can refuse in creative ways that don't match these exact phrases. But it catches the most common patterns, and that's often enough to detect when a model has become overly cautious. If your fine-tuned model refuses 40% of requests while the base model refuses 5%, you've probably introduced an alignment tax that needs investigation. The refusal might be appropriate (you trained it to be safer), or it might be pathological (it refuses reasonable requests). The metric itself doesn't tell you which—it just flags that something changed.
Temperature is set to 0.7 with top-p sampling at 0.9. This configuration produces reasonably diverse outputs without going fully stochastic. For evaluation, you want some randomness (to see how the model behaves across different decoding paths) but not so much that results become unreproducible. If you run evaluation twice and get completely different transcripts, you can't tell whether observed differences reflect actual model changes or just sampling noise.
Output structure and what it enables:
The script generates a single JSON file containing complete results from both models. This structure makes comparison trivial—you can write a simple diff script, or just open the file and scroll between base and tuned sections. Each conversation is preserved with its original ID, so when you find a broken transcript, you can immediately trace it back to the specific test case that triggered the failure.
Refusal counts are aggregated at the conversation level, not globally. This matters because refusal patterns often cluster—a model might refuse one entire category of conversations while handling others normally. Global averages would hide this clustering. Per-conversation counts let you see the distribution and identify which types of conversations trigger excessive refusals.
What this script doesn't do (and why that's intentional):
It doesn't score quality. It doesn't compute semantic similarity to reference answers. It doesn't measure factual accuracy or fluency or coherence. It just runs the conversations and saves what happened. This is deliberate minimalism—scoring comes later, after you've verified that the basic mechanics work. Many evaluation projects fail because they jump straight to sophisticated metrics before establishing whether the model can complete basic conversations without crashing or refusing everything.
In a real MT-Bench setting, the next step would be scoring with a judge model. You can add that later, but even transcripts plus refusal rate can reveal dramatic differences. If your fine-tuned model can't follow multi-turn instructions that the base model handled easily, no amount of judge-model scoring will make that acceptable. Fix the obvious breaks first, then optimize for subtle quality differences.
When you run this script, expect it to take several minutes. You're doing 160+ forward passes (20 conversations × 4 turns average × 2 models), each with generation up to 220 tokens. On a consumer GPU, this might take 5-10 minutes total. That's fast enough for iteration but slow enough that you want to be thoughtful about what you're measuring. Don't run evaluation constantly during development—save it for checkpoints where you actually expect behavioral changes.