Spaces:
Sleeping
Sleeping
| """ | |
| evaluation/evaluate_model.py | |
| ============================== | |
| PHASE 5: Evaluation β How Good Is Your Fine-Tuned Model? | |
| CONCEPT: WHY EVALUATE? | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| Training loss decreasing doesn't mean your model is actually useful. | |
| You need to measure REAL quality: | |
| - Does it give correct answers? | |
| - Does it stay on-topic? | |
| - Does it hallucinate? | |
| - Is it faster or slower than the base model? | |
| EVALUATION METRICS USED: | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 1. ROUGE-L (Recall-Oriented Understudy for Gisting Evaluation) | |
| - Measures word overlap between generated and reference answers | |
| - ROUGE-L: longest common subsequence (order matters) | |
| - Range: 0β1 (1 = perfect match) | |
| - Good for: Measuring if key information is present | |
| - Limitation: Misses semantically equivalent phrasings | |
| 2. BLEU (Bilingual Evaluation Understudy) | |
| - Originally for machine translation | |
| - Measures n-gram precision (how many n-grams in the output appear in reference) | |
| - Range: 0β1 | |
| - Limitation: Poor for long answers; prefers short precise outputs | |
| 3. BERTScore | |
| - Uses BERT embeddings to measure semantic similarity | |
| - Unlike ROUGE, understands synonyms and paraphrases | |
| - "tuition fees" β "payment amount" β high BERTScore, low ROUGE | |
| - Range: 0β1 (F1 score of precision/recall in embedding space) | |
| - Best metric for conversational/descriptive answers | |
| 4. PERPLEXITY | |
| - Measures how surprised the model is by the text | |
| - Lower = model finds the text natural/familiar | |
| - Fine-tuned model should have lower perplexity on domain text | |
| - Formula: exp(-1/N Γ Ξ£ log P(token_i)) | |
| 5. LATENCY | |
| - Time to generate a response (seconds) | |
| - Important for production deployment | |
| 6. HALLUCINATION RATE | |
| - % of answers containing information NOT in the reference | |
| - We approximate this by checking if key facts from the reference | |
| are present in the generated answer | |
| """ | |
| import json | |
| import time | |
| from pathlib import Path | |
| from typing import Optional | |
| try: | |
| import torch | |
| TORCH_AVAILABLE = True | |
| except ImportError: | |
| TORCH_AVAILABLE = False | |
| try: | |
| from rouge_score import rouge_scorer as _rouge_scorer_mod | |
| ROUGE_AVAILABLE = True | |
| except ImportError: | |
| ROUGE_AVAILABLE = False | |
| try: | |
| from bert_score import score as _bert_score_fn | |
| BERTSCORE_AVAILABLE = True | |
| except ImportError: | |
| BERTSCORE_AVAILABLE = False | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # EVALUATION DATASET | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| EVAL_QUESTIONS = [ | |
| { | |
| "question": "What are the minimum entry requirements for Computer Science at MUST?", | |
| "reference": "UACE with at least 2 principal passes including Mathematics. UCE with at least 5 passes including Mathematics and English. Minimum aggregate of 15 points at A-Level.", | |
| "category": "admissions", | |
| "key_facts": ["2 principal passes", "Mathematics", "UCE", "15 points"], | |
| }, | |
| { | |
| "question": "How much are tuition fees per semester for Computer Science?", | |
| "reference": "UGX 1,800,000 to 2,200,000 per semester for private sponsorship students.", | |
| "category": "fees", | |
| "key_facts": ["1,800,000", "2,200,000", "semester", "private"], | |
| }, | |
| { | |
| "question": "What is the minimum attendance requirement?", | |
| "reference": "Students must attend at least 75% of all lectures for each course. Below 75% results in being barred from sitting the final exam.", | |
| "category": "policies", | |
| "key_facts": ["75%", "barred", "final exam"], | |
| }, | |
| { | |
| "question": "What are the graduation requirements?", | |
| "reference": "Complete 120-135 credit units, minimum GPA of 2.0, submit and pass the final year project, complete 8-week industrial training, clear all fees and library dues.", | |
| "category": "graduation", | |
| "key_facts": ["120", "GPA", "2.0", "project", "industrial training"], | |
| }, | |
| { | |
| "question": "What is First Class Honours?", | |
| "reference": "First Class Honours is the highest academic classification, awarded for a Cumulative GPA of 4.4 to 5.0.", | |
| "category": "graduation", | |
| "key_facts": ["4.4", "5.0", "highest", "First Class"], | |
| }, | |
| { | |
| "question": "Can I defer my studies?", | |
| "reference": "Yes, deferment is allowed for medical reasons, financial hardship, pregnancy, family emergency. Maximum deferment is 2 consecutive semesters. Requires formal approval from the Academic Registrar.", | |
| "category": "policies", | |
| "key_facts": ["medical", "financial hardship", "2 consecutive", "Academic Registrar"], | |
| }, | |
| { | |
| "question": "What programming languages are taught in Computer Science?", | |
| "reference": "Year 1: Python and C. Year 2: Java, SQL, JavaScript. Year 3: PHP, R or MATLAB. Year 4: Advanced Python, Kotlin/Swift for mobile.", | |
| "category": "courses", | |
| "key_facts": ["Python", "Java", "SQL", "JavaScript"], | |
| }, | |
| { | |
| "question": "How many times can I retake a failed exam?", | |
| "reference": "Maximum 3 attempts per course. Failing a core course 3 times typically results in discontinuation. Supplementary exams available for scores 40-49%. Maximum grade for supplementary is 50%.", | |
| "category": "policies", | |
| "key_facts": ["3 attempts", "supplementary", "40-49", "50%"], | |
| }, | |
| ] | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # METRIC CALCULATORS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def calculate_rouge(prediction: str, reference: str) -> dict: | |
| """ | |
| Calculates ROUGE-1, ROUGE-2, and ROUGE-L scores. | |
| ROUGE-1: Unigram (single word) overlap | |
| ROUGE-2: Bigram (two-word phrase) overlap | |
| ROUGE-L: Longest common subsequence (captures order) | |
| """ | |
| if not ROUGE_AVAILABLE: | |
| print(" β οΈ rouge_score not installed. Run: pip install rouge-score") | |
| return {"rouge1": None, "rouge2": None, "rougeL": None} | |
| scorer = _rouge_scorer_mod.RougeScorer(["rouge1", "rouge2", "rougeL"], use_stemmer=True) | |
| scores = scorer.score(reference, prediction) | |
| return { | |
| "rouge1": round(scores["rouge1"].fmeasure, 4), | |
| "rouge2": round(scores["rouge2"].fmeasure, 4), | |
| "rougeL": round(scores["rougeL"].fmeasure, 4), | |
| } | |
| def calculate_bert_score(predictions: list, references: list) -> dict: | |
| """ | |
| Calculates BERTScore for a batch of predictions vs references. | |
| BERTScore uses contextual embeddings (BERT) to measure semantic similarity. | |
| This is better than ROUGE for conversational text because it understands | |
| synonyms and paraphrases. | |
| For example: | |
| Prediction: "You need excellent grades" | |
| Reference: "You must achieve high marks" | |
| ROUGE: Low (few exact word matches) | |
| BERTScore: High (same semantic meaning) | |
| """ | |
| if not BERTSCORE_AVAILABLE: | |
| print(" β οΈ bert_score not installed. Run: pip install bert-score") | |
| return {"precision": None, "recall": None, "f1": None} | |
| P, R, F1 = _bert_score_fn( | |
| predictions, | |
| references, | |
| lang="en", | |
| model_type="distilbert-base-uncased", # Fast, decent quality | |
| verbose=False, | |
| ) | |
| return { | |
| "precision": round(P.mean().item(), 4), | |
| "recall": round(R.mean().item(), 4), | |
| "f1": round(F1.mean().item(), 4), | |
| } | |
| def calculate_hallucination_rate(predictions: list, eval_items: list) -> float: | |
| """ | |
| Approximates hallucination rate by checking if key facts are present. | |
| This is a simplified metric. Production systems use NLI (Natural Language | |
| Inference) models to check factual consistency. | |
| Our approach: for each answer, check what % of key_facts are present. | |
| A "hallucination" is present if a key fact is completely absent but the | |
| answer mentions something plausible but wrong. | |
| Since we can't automatically detect wrong-but-plausible facts, | |
| we use key_fact RECALL as a proxy for non-hallucination: | |
| - High recall = model mentions the important facts β probably accurate | |
| - Low recall = model avoided or missed facts β may have made things up | |
| """ | |
| total_recall = 0.0 | |
| if not predictions or not eval_items: | |
| return 0.0 # No predictions β no hallucinations to measure | |
| for prediction, item in zip(predictions, eval_items): | |
| key_facts = item.get("key_facts", []) | |
| if not key_facts: | |
| continue | |
| prediction_lower = prediction.lower() | |
| found = sum(1 for fact in key_facts if fact.lower() in prediction_lower) | |
| recall = found / len(key_facts) | |
| total_recall += recall | |
| # Return estimated hallucination rate = 1 - average recall | |
| evaluated = sum(1 for item in eval_items if item.get("key_facts")) | |
| avg_recall = total_recall / evaluated if evaluated > 0 else 1.0 | |
| hallucination_rate = 1.0 - avg_recall | |
| return round(hallucination_rate, 4) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MODEL INFERENCE | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def generate_answers(model_path: str, questions: list, model_label: str = "model") -> tuple: | |
| """ | |
| Generates answers for all eval questions using the specified model. | |
| Returns: | |
| (answers, latencies) β lists of strings and seconds | |
| """ | |
| if not TORCH_AVAILABLE: | |
| print("β οΈ torch not installed: pip install torch") | |
| return [], [] | |
| try: | |
| from transformers import AutoModelForCausalLM, AutoTokenizer | |
| except ImportError: | |
| print("β οΈ transformers not installed: pip install transformers") | |
| return [], [] | |
| print(f"\nπ€ Generating answers with {model_label}...") | |
| print(f" Model: {model_path}") | |
| # Load model | |
| tokenizer = AutoTokenizer.from_pretrained(model_path) | |
| model = AutoModelForCausalLM.from_pretrained( | |
| model_path, | |
| torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32, | |
| device_map="auto" if torch.cuda.is_available() else "cpu", | |
| ) | |
| model.eval() | |
| device = "cuda" if torch.cuda.is_available() else "cpu" | |
| answers = [] | |
| latencies = [] | |
| system_prompt = "You are CampusGPT Uganda, a helpful university assistant." | |
| for i, question in enumerate(questions): | |
| messages = [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": question}, | |
| ] | |
| # Format with chat template | |
| input_text = tokenizer.apply_chat_template( | |
| messages, | |
| tokenize=False, | |
| add_generation_prompt=True, | |
| ) | |
| inputs = tokenizer(input_text, return_tensors="pt").to(device) | |
| # Time the generation | |
| start_time = time.time() | |
| with torch.no_grad(): | |
| outputs = model.generate( | |
| **inputs, | |
| max_new_tokens=300, | |
| temperature=0.3, | |
| do_sample=True, | |
| pad_token_id=tokenizer.eos_token_id, | |
| ) | |
| latency = time.time() - start_time | |
| # Decode | |
| new_tokens = outputs[0][inputs["input_ids"].shape[1]:] | |
| answer = tokenizer.decode(new_tokens, skip_special_tokens=True) | |
| answers.append(answer.strip()) | |
| latencies.append(round(latency, 2)) | |
| print(f" [{i+1}/{len(questions)}] Generated in {latency:.1f}s") | |
| # Clean up memory | |
| del model | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| return answers, latencies | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # FULL EVALUATION | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def evaluate_model( | |
| model_path: str, | |
| model_label: str = "CampusGPT Fine-tuned", | |
| eval_questions: list = None, | |
| ) -> dict: | |
| """ | |
| Runs the complete evaluation suite for one model. | |
| """ | |
| if eval_questions is None: | |
| eval_questions = EVAL_QUESTIONS | |
| questions = [q["question"] for q in eval_questions] | |
| references = [q["reference"] for q in eval_questions] | |
| # Generate answers | |
| answers, latencies = generate_answers(model_path, questions, model_label) | |
| if not answers: | |
| return {} | |
| # Calculate metrics | |
| print("\nπ Calculating metrics...") | |
| # ROUGE scores (per question, then averaged) β returns None values if rouge_score not installed | |
| rouge_scores = [calculate_rouge(pred, ref) for pred, ref in zip(answers, references)] | |
| def _safe_avg(scores, key): | |
| vals = [s[key] for s in scores if s.get(key) is not None] | |
| return round(sum(vals) / len(vals), 4) if vals else None | |
| avg_rouge = { | |
| "rouge1": _safe_avg(rouge_scores, "rouge1"), | |
| "rouge2": _safe_avg(rouge_scores, "rouge2"), | |
| "rougeL": _safe_avg(rouge_scores, "rougeL"), | |
| } | |
| # BERTScore | |
| bert_scores = calculate_bert_score(answers, references) | |
| # Hallucination rate | |
| hallucination_rate = calculate_hallucination_rate(answers, eval_questions) | |
| # Latency | |
| avg_latency = round(sum(latencies) / len(latencies), 2) | |
| results = { | |
| "model": model_label, | |
| "model_path": model_path, | |
| "num_questions": len(questions), | |
| "metrics": { | |
| "rouge1": avg_rouge["rouge1"], | |
| "rouge2": avg_rouge["rouge2"], | |
| "rougeL": avg_rouge["rougeL"], | |
| "bertscore_f1": bert_scores["f1"], | |
| "bertscore_precision": bert_scores["precision"], | |
| "bertscore_recall": bert_scores["recall"], | |
| "hallucination_rate": hallucination_rate, | |
| "avg_latency_seconds": avg_latency, | |
| }, | |
| "per_question": [ | |
| { | |
| "question": q, | |
| "answer": a, | |
| "reference": r, | |
| "rouge": rs, | |
| "latency": lt, | |
| } | |
| for q, a, r, rs, lt in zip(questions, answers, references, rouge_scores, latencies) | |
| ], | |
| } | |
| return results | |
| def compare_models(base_model_path: str, finetuned_model_path: str): | |
| """ | |
| Runs a side-by-side comparison: Base Model vs Fine-tuned Model. | |
| This is the key experiment that validates fine-tuning worked. | |
| """ | |
| print("π¬ CampusGPT Model Evaluation") | |
| print("=" * 60) | |
| print("Comparing: Base Model vs Fine-tuned CampusGPT") | |
| print() | |
| # Evaluate base model | |
| base_results = evaluate_model(base_model_path, "Base Model (no fine-tuning)") | |
| # Evaluate fine-tuned model | |
| ft_results = evaluate_model(finetuned_model_path, "CampusGPT (fine-tuned)") | |
| if not base_results or not ft_results: | |
| print("β Evaluation failed β check model paths") | |
| return | |
| # ββ Print comparison report βββββββββββββββββββββββββββββββββββββββββββββββ | |
| print("\n" + "=" * 60) | |
| print("π EVALUATION REPORT") | |
| print("=" * 60) | |
| metrics = ["rouge1", "rouge2", "rougeL", "bertscore_f1", "hallucination_rate", "avg_latency_seconds"] | |
| better_is = {"hallucination_rate": "lower", "avg_latency_seconds": "lower"} # Others: higher | |
| print(f"\n{'Metric':<25} {'Base Model':>15} {'Fine-tuned':>15} {'Change':>10}") | |
| print("-" * 65) | |
| for metric in metrics: | |
| base_val = base_results["metrics"].get(metric) | |
| ft_val = ft_results["metrics"].get(metric) | |
| if base_val is None or ft_val is None: | |
| print(f"{metric:<25} {'N/A':>15} {'N/A':>15} {'(dep missing)':>10}") | |
| continue | |
| change = ft_val - base_val | |
| direction = better_is.get(metric, "higher") | |
| if direction == "higher": | |
| indicator = "β " if change > 0.01 else ("π΄" if change < -0.01 else "β‘οΈ") | |
| else: | |
| indicator = "β " if change < -0.01 else ("π΄" if change > 0.01 else "β‘οΈ") | |
| print(f"{metric:<25} {base_val:>15.4f} {ft_val:>15.4f} {indicator} {change:>+.4f}") | |
| # ββ Per-question breakdown ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| print("\n\nπ PER-QUESTION BREAKDOWN") | |
| print("-" * 60) | |
| for i, (base_q, ft_q) in enumerate(zip(base_results["per_question"], ft_results["per_question"]), 1): | |
| print(f"\nQ{i}: {base_q['question'][:60]}...") | |
| print(f" Base: {base_q['answer'][:100]}...") | |
| print(f" Fine-tuned: {ft_q['answer'][:100]}...") | |
| rougeL_base = base_q['rouge'].get('rougeL') | |
| rougeL_ft = ft_q['rouge'].get('rougeL') | |
| if rougeL_base is not None and rougeL_ft is not None: | |
| print(f" ROUGE-L: Base={rougeL_base:.3f} | Fine-tuned={rougeL_ft:.3f}") | |
| else: | |
| print(" ROUGE-L: N/A (install rouge-score)") | |
| # ββ Save report βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _safe_diff(a, b): | |
| if a is None or b is None: | |
| return None | |
| return round(b - a, 4) | |
| report = { | |
| "base_model": base_results, | |
| "finetuned_model": ft_results, | |
| "summary": { | |
| "rouge_improvement": _safe_diff( | |
| base_results["metrics"].get("rougeL"), | |
| ft_results["metrics"].get("rougeL"), | |
| ), | |
| "bertscore_improvement": _safe_diff( | |
| base_results["metrics"].get("bertscore_f1"), | |
| ft_results["metrics"].get("bertscore_f1"), | |
| ), | |
| "hallucination_reduction": _safe_diff( | |
| ft_results["metrics"].get("hallucination_rate"), | |
| base_results["metrics"].get("hallucination_rate"), | |
| ), | |
| } | |
| } | |
| report_path = "evaluation/evaluation_report.json" | |
| Path("evaluation").mkdir(exist_ok=True) | |
| with open(report_path, "w") as f: | |
| json.dump(report, f, indent=2, default=str) | |
| print(f"\n\nβ Full report saved to: {report_path}") | |
| # Print summary | |
| print("\nπ― SUMMARY") | |
| for k, v in report["summary"].items(): | |
| val_str = f"{v:+.4f}" if isinstance(v, float) else "N/A (install deps)" | |
| print(f" {k}: {val_str}") | |
| r_imp = report["summary"]["rouge_improvement"] | |
| b_imp = report["summary"]["bertscore_improvement"] | |
| if r_imp is not None and b_imp is not None and r_imp > 0 and b_imp > 0: | |
| print("\n β Fine-tuning improved the model!") | |
| elif r_imp is None: | |
| print("\n βΉοΈ Install rouge-score and bert-score for full comparison: pip install rouge-score bert-score") | |
| else: | |
| print("\n β οΈ Results are mixed. Consider more training epochs or more data.") | |
| if __name__ == "__main__": | |
| import sys | |
| if len(sys.argv) >= 3: | |
| base = sys.argv[1] | |
| finetuned = sys.argv[2] | |
| compare_models(base, finetuned) | |
| elif len(sys.argv) == 2: | |
| # Evaluate single model | |
| results = evaluate_model(sys.argv[1], "CampusGPT") | |
| if results: | |
| print(json.dumps(results["metrics"], indent=2)) | |
| else: | |
| print("Usage:") | |
| print(" python evaluation/evaluate_model.py <base_model_path> <finetuned_model_path>") | |
| print(" python evaluation/evaluate_model.py <single_model_path>") | |
| print("\nExample:") | |
| print(" python evaluation/evaluate_model.py unsloth/Qwen2.5-3B-Instruct models/campusgpt_merged") | |