Skip to the content.

Blog Post 5.2: Model Evaluation – Qualität messen

Lesezeit: ~12 Minuten | Level: Intermediate
Serie: Self-Hosted LLMs für Datensouveränität | Code: GitHub

Hinweis: Dieser Post ist optional und vertieft die Evaluation-Methodik aus Post 5. Du kannst direkt zu Post 6 (vLLM Deployment) springen, wenn du mit deinem Training zufrieden bist.


TL;DR – Für eilige Leser

Das Problem: Training Loss = 0.35 sieht gut aus. Validation Loss = 0.35 zeigt kein Overfitting. Aber sind die generierten Antworten tatsächlich gut? Metrics alleine sagen wenig über Output Quality.

Die Lösung: Multi-modale Evaluation kombiniert quantitative Metrics (Loss, Perplexity) mit qualitativer Bewertung (Manual Inspection, Baseline Comparison). So bekommst du ein vollständiges Bild der Model-Performance.

Das Ergebnis:

Key Learnings:


Inhaltsverzeichnis


Das Problem: Ist Loss = 0.35 gut?

Du hast in Post 5 ein LoRA Model trainiert. Die Metriken sehen vielversprechend aus:

Training Loss:    0.48 → 0.35  (gefallen über 1 Epoche)
Validation Loss:  0.35          (kein Overfitting)
Perplexity:       1.42          (niedrig = gut)

Aber hier ist die zentrale Frage: Sind die generierten Antworten tatsächlich gut?

Metrics wie Loss und Perplexity messen nur, wie gut das Model die nächsten Tokens vorhersagt. Sie sagen nichts über:

Ein Model kann niedrigen Loss haben, aber trotzdem schlechte Antworten produzieren. Deshalb ist qualitative Evaluation essentiell.

Was wir behandeln:

  1. Manual Inspection: 15 Samples stratified nach Question Type selbst lesen und bewerten
  2. Baseline Comparison: Fine-tuned Model gegen Mistral-Instruct vergleichen
  3. Lessons Learned: Best Practices für reproduzierbare, faire Evaluation

Was wir bewusst nicht behandeln:


Teil 1: Manual Inspection – Outputs selbst bewerten

Warum Manual Inspection?

Metrics alleine reichen nicht. Selbst bei perfektem Loss könnte das Model:

Manual Inspection ist der Ground Truth – du liest 15-20 Outputs selbst und bewertest sie nach klaren Kriterien. Das ist zeitaufwendig (20-30 Minuten), aber zeigt sofort echte Probleme.

Stratified Sampling – Repräsentative Coverage

Das Problem mit Random Sampling:

# Naiver Ansatz
samples = random.sample(eval_data, 15)
# Risiko: Könnte nur "factual" Questions erwischen
# → Bias in Evaluation

Wenn du zufällig 15 Samples ziehst, könntest du nur eine Question Type treffen (z.B. nur factual Questions). Das gibt dir kein vollständiges Bild.

Die Lösung: Stratified Sampling

Stratifiziere nach einer wichtigen Dimension – in unserem Fall question_type:

def stratified_sample(data: List[Dict], num_samples: int, 
                     stratify_key: str) -> List[Dict]:
    """
    Sample stratifiziert nach Question Type.
    Stellt sicher, dass alle Types gleichmäßig vertreten sind.
    """
    # Gruppiere nach stratify_key
    grouped = defaultdict(list)
    for item in data:
        key = item.get(stratify_key, 'unknown')
        grouped[key].append(item)
    
    # Sample gleichmäßig aus jeder Gruppe
    samples_per_group = num_samples // len(grouped)
    samples = []
    for group_items in grouped.values():
        samples.extend(random.sample(
            group_items, 
            min(samples_per_group, len(group_items))
        ))
    
    return samples[:num_samples]

Unser Sampling:

Das gibt uns repräsentative Coverage über alle Question Types.

Code-Referenz: Vollständige Implementierung in scripts/inspect_model_response.py

Response Generation – Outputs erstellen

Für jedes Sample generieren wir eine Antwort mit kontrollierten Parametern:

def generate_answer(model, tokenizer, prompt, 
                   max_new_tokens=128, temperature=0.3):
    """
    Generate answer from fine-tuned model.
    """
    inputs = tokenizer(prompt, return_tensors='pt').to(model.device)
    
    with torch.no_grad():
        outputs = model.generate(
            **inputs,
            max_new_tokens=max_new_tokens,
            temperature=0.3,      # Niedrig für deterministische Outputs
            top_p=0.9,
            do_sample=True,
            eos_token_id=tokenizer.eos_token_id,
        )
    
    # Decode nur neue Tokens (ohne Input-Prompt)
    generated_tokens = outputs[0][len(inputs['input_ids'][0]):]
    answer = tokenizer.decode(generated_tokens, skip_special_tokens=True)
    
    # Post-processing: Entferne Continuation Markers
    for marker in ["\n\nQuestion:", "\n[INST]"]:
        pos = answer.find(marker)
        if pos != -1:
            answer = answer[:pos]
    
    return answer.strip()

Wichtige Generation Parameters:

Bewertungskriterien – Was macht eine gute Antwort aus?

Für jedes Sample prüfen wir:

### Manual Assessment
- [ ] Faktisch korrekt (keine Hallucinations)
- [ ] Vollständig (beantwortet die Frage komplett)
- [ ] Passende Länge (nicht zu kurz, nicht zu lang)
- [ ] Stoppt korrekt (kein endloses Generieren)

**Notes:** [Freie Kommentare zu Stil, Besonderheiten]

Beispiel Sample:

Question: What is the default TTL for SNS mobile platforms?

Reference Answer: 
The default Time to Live (TTL) for all mobile platforms in SNS is 4 weeks.

Generated Answer: 
The default TTL for all mobile platforms in SNS is 4 weeks.

Assessment:
✓ Faktisch korrekt
✓ Vollständig
✓ Passende Länge (11 words)
✓ Stoppt korrekt

Notes: Perfekte Antwort, kompakt und präzise.

Ergebnisse der Manual Inspection

Aus 15 bewerteten Samples:

Metric Value Interpretation
Total Samples 15 Stratified by question_type
Faktisch korrekt 15/15 (100%) Keine Hallucinations ✓
Vollständig 12/15 (80%) 3 waren etwas kurz
Passende Länge 13/15 (87%) 2 zu knapp
Stoppt korrekt 13/15 (87%) 2 generierten weiter (siehe Post 5.3)

Qualitative Patterns:

Das Continuation-Problem (2/15 Samples) war ein wichtiger Fund. Es zeigte ein subtiles Bug im Tokenizer-Setup, das wir später gefixed haben. Mehr dazu in Post 5.3 (Debugging Story).

Screenshot von sample_responses.md mit Manual Assessment Screenshot von sample_responses.md mit Manual Assessment


Teil 2: Baseline Comparison – Objektiver Kontext

Warum Baseline Comparison?

Manual Inspection zeigt: Das Model funktioniert. Aber ist es besser als Alternativen?

Ohne Baseline:

"Unser Model ist gut" – subjektiv, kein Vergleichspunkt

Mit Baseline:

"Unser Model ist 6% besser in Stopping Behavior und konsistenter 
im Stil als Mistral-Instruct" – objektiv, messbar

Baseline Comparison gibt dir:

Baseline Model Auswahl

Wir vergleichen gegen Mistral-7B-Instruct-v0.2:

Warum dieses Model?

Was wir vergleichen:

  1. Stopping Behavior: Wie oft stoppt das Model korrekt?
  2. Response Length: Wie lang sind die Antworten im Durchschnitt?
  3. Context Adherence: Bleibt das Model beim Context oder fügt externes Wissen hinzu?
  4. Style Consistency: Wie konsistent ist der Output-Stil?

Vergleichs-Setup

def compare_models(sample, fine_tuned_model, baseline_model, tokenizer):
    """
    Generate answers from both models and compare.
    """
    prompt = sample['prompt_inference']
    
    # Generate from both models
    ft_answer = generate_answer(fine_tuned_model, tokenizer, prompt)
    bl_answer = generate_answer(baseline_model, tokenizer, prompt)
    
    # Compare
    comparison = {
        'question': sample['question'],
        'reference': sample['reference_answer'],
        'fine_tuned_answer': ft_answer,
        'baseline_answer': bl_answer,
        'fine_tuned_length': len(ft_answer.split()),
        'baseline_length': len(bl_answer.split()),
    }
    
    return comparison

Wichtig: Beide Models bekommen identische Prompts mit identischen Generation-Parametern (temperature, top_p, max_new_tokens). Nur so ist der Vergleich fair.

Code-Referenz: Vollständige Implementierung in scripts/compare_with_baseline.py

Vergleichs-Ergebnisse

Aus 20 verglichenen Samples:

Dimension Fine-tuned Mistral-Instruct Winner
Stopping Behavior 20/20 (100%)* 18/20 (90%) Fine-tuned
Avg. Response Length 45 words 78 words Depends on use case
Context Adherence Strikt – nur aus Context Manchmal externes Wissen Fine-tuned (für RAG)
Style Consistency 100% konsistent kompakt Variabel Fine-tuned

*Nach Re-Training mit pad_token Fix – initial waren es 13/15

Key Insights:

  1. Fine-tuning funktioniert wirklich: Das Model verhält sich fundamental anders als Mistral-Instruct
  2. Stil-Kontrolle: Fine-tuned Model ist konsistent kompakt, Instruct variiert stark (manchmal kurz, manchmal ausführlich)
  3. Context Adherence: Fine-tuned bleibt strikt beim Context, Instruct fügt manchmal externes Wissen hinzu
  4. Trade-off erkennbar: Kompakt vs. Ausführlich – beide haben Vor- und Nachteile

Beispiel-Vergleich

Question: What is the default TTL for mobile platforms in SNS?

Reference Answer:
The default Time to Live (TTL) for all mobile platforms in SNS is 4 weeks.

Fine-tuned Answer (45 characters):
The default TTL for all mobile platforms in SNS is 4 weeks.

Mistral-Instruct Answer (247 characters):
The default Time to Live (TTL) for mobile platforms in Amazon Simple 
Notification Service (SNS) is 4 weeks (28 days). This applies to all 
mobile platforms including Apple (APNS), Google (FCM), Amazon (ADM), 
and others. This TTL determines how long the notification will be 
retained by the push notification service if the device is offline.

Analysis:
- Fine-tuned: Präzise, kompakt, direkt aus Context
- Instruct: Ausführlich, fügt Kontext hinzu (Platform-Namen)
- Für RAG: Fine-tuned besser (bleibt bei Context)
- Für Standalone Q&A: Instruct besser (mehr Kontext)

Lesson: Es gibt kein “absolut besseres” Model – es kommt auf deinen Use Case an:

Screenshot von comparison_report.md mit Side-by-Side Outputs Screenshot von sample_responses.md mit Manual Assessment


Teil 3: Was wir nicht gemacht haben – Intrinsic Metrics

Der Plan: Token-Level Evaluation

Zusätzlich zu Manual Inspection und Baseline Comparison hatten wir Intrinsic Metrics geplant:

Was sind Intrinsic Metrics?

Warum sind sie nützlich?

Was wir vorbereitet haben

Framework erstellt mit Token-Metriken und Loss-Analyse:

# Framework aus evaluate_intrinsic.py (vereinfacht)
class TokenMetrics:
    """Calculate top-k accuracy for token predictions."""
    
    def calculate_top_k_accuracy(self, logits, labels, k=5):
        # Top-k predictions
        top_k_preds = torch.topk(logits, k, dim=-1).indices
        # Check if true label in top-k
        correct = (top_k_preds == labels.unsqueeze(-1)).any(dim=-1)
        return correct.float().mean()

class LossAnalyzer:
    """Analyze loss by position and frequency."""
    
    def loss_by_position(self, losses, sequence_length):
        # Group losses by position in sequence
        # Shows: Sind frühe oder späte Tokens schwieriger?
        pass
    
    def loss_by_frequency(self, losses, token_ids, vocab_freq):
        # Group losses by token frequency
        # Shows: Sind rare oder common Tokens schwieriger?
        pass

Code-Referenz: Framework in scripts/evaluate_intrinsic.py

Warum wir es nicht fertig gemacht haben

Das technische Problem:

Beim Versuch die Metriken zu berechnen, stießen wir auf ein Label Masking Issue:

# Was wir sahen
labels = batch['labels']
print(labels)
# Output: tensor([-100, -100, -100, ..., -100])  # Alle -100!

Alle Labels waren -100 (PyTorch’s Ignore-Index). Das verhinderte jede Token-Level-Metrik-Berechnung.

Root Cause (Vermutung): Der DataCollatorForLanguageModeling maskiert wahrscheinlich mehr als erwartet. Debugging würde tiefer ins DataCollator-Verhalten und Label-Generation-Logic erfordern.

Unsere Entscheidung:

Wir haben bewusst entschieden, nicht weiterzudebuggen, weil:

  1. Manual Inspection + Baseline Comparison gaben bereits klares Bild der Model Quality
  2. Debugging würde 2-3 Stunden dauern für unsicheren Mehrwert
  3. Token-Level Metrics sind “nice to have”, nicht “must have” für unseren Use Case
  4. Zeit besser investiert in nächste Phase (Production Deployment)

Das Learning

Nicht alle geplanten Metriken sind nötig.

In der Theorie klingen Token-Level Intrinsic Metrics toll. In der Praxis:

Best Practice: Start mit einfachen Metriken (Loss, Manual Inspection). Erweitere nur wenn du konkreten Mehrwert siehst. Nicht blind alle möglichen Metriken implementieren.


Lessons Learned – Best Practices für LLM Evaluation

1. Multi-Modale Evaluation ist essentiell

Niemals nur eine Metrik nutzen:

❌ Nur Loss: "Loss = 0.35" → Sagt nichts über Output Quality
❌ Nur Manual: "Sieht gut aus" → Nicht skalierbar, subjektiv
❌ Nur Metrics: "Perplexity = 1.42" → Missed Continuation-Problem

✓ Kombination: Loss + Manual Inspection + Baseline Comparison
→ Vollständiges, objektives Bild

Die drei Säulen:

  1. Quantitativ (Loss, Perplexity): Schnell, skalierbar, aber oberflächlich
  2. Qualitativ (Manual Inspection): Langsam, aber zeigt echte Probleme
  3. Vergleichend (Baseline): Gibt Kontext, zeigt relativen Wert

2. Stratified Sampling verhindert Bias

Random Sampling ist gefährlich:

Ohne Stratification könntest du nur eine Subset des Evaluation-Sets treffen:

Stratify nach wichtigen Dimensionen:

# Good Practice
samples = stratified_sample(eval_data, n=15, stratify_key='question_type')

3. Baseline gibt objektiven Kontext

Ohne Baseline = Subjektive Einschätzung:

“Unser Model ist gut” – relativ zu was?

Mit Baseline = Objektive Messung:

“Unser Model ist 10% besser in Stopping Behavior, aber 30% kürzer in Response Length als Mistral-Instruct.”

Baseline-Auswahl-Kriterien:

4. Manual Inspection vor Automatisierung

Häufiger Fehler:

Direkt mit automatisierten Metriken (BLEU, ROUGE, LLM-as-Judge) starten ohne qualitative Insights.

Besserer Ansatz:

  1. Erst Manual Inspection (15-20 Samples)
    • Verstehe was “gute” vs. “schlechte” Outputs sind
    • Finde Patterns die Metriken nicht zeigen
    • Definiere Bewertungskriterien
  2. Dann Design Automated Metrics
    • Basierend auf qualitativen Insights
    • Mit klaren Success-Kriterien
    • Validiert gegen Manual Assessment

Warum? Automatisierte Metriken messen was du ihnen sagst zu messen. Wenn du nicht verstehst was “gut” ist, misst du die falschen Dinge.

5. Response Length Control ist wichtig

max_new_tokens Parameter verhindert zwei Probleme:

Problem 1: Endlose Generation

# Ohne max_new_tokens
answer = model.generate(...)  # Könnte 1000+ Tokens generieren

Problem 2: Unfaire Vergleiche

# Mit unterschiedlichen Limits
ft_answer = generate(..., max_new_tokens=128)   # 50 words
bl_answer = generate(..., max_new_tokens=512)   # 200 words
# → Unfairer Vergleich!

Best Practice:

max_new_tokens = 2 * avg_reference_length  # z.B. 128 für ~50-word Referenzen

6. Generation Parameters konsistent halten

Für reproduzierbare Evaluation:

generation_config = {
    "max_new_tokens": 128,
    "temperature": 0.3,      # Niedrig für Determinismus
    "top_p": 0.9,
    "do_sample": True,       # Für etwas Diversity
    "eos_token_id": tokenizer.eos_token_id,
}

Warum diese Werte?

Dokumentiere alles:

evaluation_config = {
    "model_checkpoint": "models/standard_r8_qkvo",
    "eval_dataset": "data/eval.jsonl",
    "num_samples": 15,
    "sampling_strategy": "stratified",
    "stratify_key": "question_type",
    "generation_config": generation_config,
    "baseline_model": "mistralai/Mistral-7B-Instruct-v0.2",
}

Speichere diese Config im Experiment-Tracking (MLflow aus Post 5.1) oder in einem separaten File.


Code & Ressourcen

Scripts für eigene Evaluation

Alle Evaluation-Scripts sind im GitHub Repository verfügbar:

Manual Inspection:

python scripts/inspect_model_response.py \
  --model_path models/standard_r8_qkvo \
  --eval_data data/eval.jsonl \
  --num_samples 15 \
  --stratify_key question_type \
  --output sample_responses.md

Generiert Markdown-Report mit allen Samples und Manual Assessment Template.

Baseline Comparison:

python scripts/compare_with_baseline.py \
  --fine_tuned_model models/standard_r8_qkvo \
  --baseline_model mistralai/Mistral-7B-Instruct-v0.2 \
  --eval_data data/eval.jsonl \
  --num_samples 20 \
  --output comparison_report.md

Generiert Side-by-Side Vergleich für alle Samples.

Intrinsic Metrics (Framework):

# Framework vorhanden, aber nicht vollständig funktional
python scripts/evaluate_intrinsic.py \
  --model_path models/standard_r8_qkvo \
  --eval_data data/eval.jsonl

Hinweis: Intrinsic Metrics haben ein Label-Masking-Issue. Nutze Manual Inspection + Baseline Comparison stattdessen.


Fazit

Loss und Perplexity sind nur der Anfang. Erst Manual Inspection und Baseline Comparison zeigen, ob dein fine-tuned Model wirklich funktioniert.

Was wir erreicht haben:

Key Takeaways:

  1. Nie nur Metrics – Kombination aus quantitativ + qualitativ ist essentiell
  2. Stratified Sampling – Verhindert Bias, gibt repräsentative Coverage
  3. Baseline gibt Kontext – “Gut” ist relativ, nicht absolut
  4. Nicht alle Metriken nötig – Fokussiere auf das, was echten Insight bringt

Im nächsten Post erfährst du die komplette Story hinter dem Continuation-Problem: Warum 2 von 15 Samples nicht stoppten, wie wir das Problem debugged haben, und welchen subtilen Bug im Tokenizer-Setup wir gefunden haben. Eine echte Debugging-Journey mit Root Cause Analysis.


← Post 5.1: LoRA Training | Serienübersicht | Post 5.3: LoRA Training →