Spaces:
Running
Running
| """ | |
| Evaluate the custom Kumbh model in isolation. | |
| Runs the model on a held-out test set and computes: | |
| - BLEU score (n-gram overlap) | |
| - ROUGE-L score (longest common subsequence) | |
| - Keyword recall (domain-specific term coverage) | |
| - Response quality (non-empty, sufficient length) | |
| - Per-language breakdown | |
| Usage: | |
| python evaluate_model.py # evaluate with defaults | |
| python evaluate_model.py --samples 50 # test on 50 samples | |
| python evaluate_model.py --languages en hi mr # specific languages only | |
| python evaluate_model.py --output eval_results.json # save results | |
| """ | |
| import argparse | |
| import json | |
| import logging | |
| import os | |
| import random | |
| import re | |
| import sys | |
| import time | |
| from collections import Counter, defaultdict | |
| from pathlib import Path | |
| logging.basicConfig(level=logging.INFO, format="%(message)s") | |
| log = logging.getLogger(__name__) | |
| ROOT = Path(__file__).parent | |
| DATA_DIR = ROOT / "data" / "synthetic_qa" | |
| # ── Metrics (no extra dependencies) ────────────────────────────────────────── | |
| def ngrams(tokens: list[str], n: int) -> list[tuple]: | |
| return [tuple(tokens[i : i + n]) for i in range(len(tokens) - n + 1)] | |
| def bleu_score(reference: str, hypothesis: str, max_n: int = 4) -> float: | |
| """Simplified BLEU (no brevity penalty for simplicity).""" | |
| ref_tokens = reference.lower().split() | |
| hyp_tokens = hypothesis.lower().split() | |
| if not hyp_tokens or not ref_tokens: | |
| return 0.0 | |
| scores = [] | |
| for n in range(1, max_n + 1): | |
| ref_ng = Counter(ngrams(ref_tokens, n)) | |
| hyp_ng = Counter(ngrams(hyp_tokens, n)) | |
| overlap = sum((hyp_ng & ref_ng).values()) | |
| total = max(sum(hyp_ng.values()), 1) | |
| scores.append(overlap / total) | |
| # Geometric mean | |
| product = 1.0 | |
| for s in scores: | |
| product *= max(s, 1e-10) | |
| return product ** (1.0 / len(scores)) | |
| def rouge_l(reference: str, hypothesis: str) -> float: | |
| """ROUGE-L using longest common subsequence.""" | |
| ref_tokens = reference.lower().split() | |
| hyp_tokens = hypothesis.lower().split() | |
| if not ref_tokens or not hyp_tokens: | |
| return 0.0 | |
| m, n = len(ref_tokens), len(hyp_tokens) | |
| # Optimized LCS length (O(m*n) but fine for short texts) | |
| prev = [0] * (n + 1) | |
| for i in range(1, m + 1): | |
| curr = [0] * (n + 1) | |
| for j in range(1, n + 1): | |
| if ref_tokens[i - 1] == hyp_tokens[j - 1]: | |
| curr[j] = prev[j - 1] + 1 | |
| else: | |
| curr[j] = max(curr[j - 1], prev[j]) | |
| prev = curr | |
| lcs_len = prev[n] | |
| precision = lcs_len / n if n else 0 | |
| recall = lcs_len / m if m else 0 | |
| if precision + recall == 0: | |
| return 0.0 | |
| return 2 * precision * recall / (precision + recall) | |
| def keyword_recall(reference: str, hypothesis: str) -> float: | |
| """Check how many important keywords from reference appear in hypothesis.""" | |
| # Extract keywords (nouns, proper nouns, numbers — simple heuristic) | |
| stop_words = { | |
| "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", | |
| "have", "has", "had", "do", "does", "did", "will", "would", "could", | |
| "should", "may", "might", "can", "shall", "of", "in", "to", "for", | |
| "with", "on", "at", "by", "from", "it", "its", "this", "that", | |
| "and", "or", "but", "not", "no", "if", "as", "so", "than", "then", | |
| "also", "very", "just", "about", "more", "most", "some", "any", | |
| } | |
| ref_words = set(reference.lower().split()) - stop_words | |
| hyp_words = set(hypothesis.lower().split()) - stop_words | |
| # Keep only meaningful words (3+ chars) | |
| ref_keywords = {w for w in ref_words if len(w) >= 3} | |
| if not ref_keywords: | |
| return 1.0 | |
| matched = ref_keywords & hyp_words | |
| return len(matched) / len(ref_keywords) | |
| # ── Load test data ─────────────────────────────────────────────────────────── | |
| def load_test_set(languages: list[str], samples_per_lang: int, seed: int = 42) -> list[dict]: | |
| """Load a stratified sample from QA pairs as a held-out test set.""" | |
| random.seed(seed) | |
| test_samples = [] | |
| for lang in languages: | |
| qa_file = DATA_DIR / f"{lang}_qa_pairs.jsonl" | |
| if not qa_file.exists(): | |
| log.warning(f" No QA file for language '{lang}', skipping") | |
| continue | |
| pairs = [] | |
| with open(qa_file) as f: | |
| for line in f: | |
| line = line.strip() | |
| if line: | |
| pairs.append(json.loads(line)) | |
| # Take a random sample (from the end of the file = less likely to be in training) | |
| if len(pairs) > samples_per_lang: | |
| # Use last 30% as test pool (simulates held-out split) | |
| test_pool = pairs[int(len(pairs) * 0.7) :] | |
| sample = random.sample(test_pool, min(samples_per_lang, len(test_pool))) | |
| else: | |
| sample = pairs | |
| test_samples.extend(sample) | |
| log.info(f" Loaded {len(sample)} test samples for '{lang}'") | |
| return test_samples | |
| # ── Run evaluation ─────────────────────────────────────────────────────────── | |
| def evaluate( | |
| samples: list[dict], | |
| model_backend: str = "auto", | |
| show_examples: bool = True, | |
| ) -> dict: | |
| """Run the model on test samples and compute metrics.""" | |
| # Import model | |
| sys.path.insert(0, str(ROOT)) | |
| os.environ["ENHANCE_RESPONSES"] = "false" # Disable enhancement for isolated eval | |
| from api.services.llm import LLMService | |
| from api.services.rag import RAGService | |
| llm = LLMService() | |
| rag = RAGService() | |
| log.info(f"\n Model backend: {llm.backend} ({llm.model_name})") | |
| log.info(f" Enhancement: DISABLED (isolated evaluation)") | |
| log.info(f" Samples: {len(samples)}\n") | |
| results = { | |
| "model": llm.model_name, | |
| "backend": llm.backend, | |
| "total_samples": len(samples), | |
| "per_language": defaultdict(lambda: { | |
| "count": 0, "bleu": [], "rouge_l": [], "keyword_recall": [], | |
| "non_empty": 0, "avg_length": [], | |
| }), | |
| "examples": [], | |
| } | |
| for i, sample in enumerate(samples): | |
| query = sample["instruction"] | |
| reference = sample["output"] | |
| language = sample.get("language", "en") | |
| context_input = sample.get("input", "") | |
| # Get RAG context (same as production pipeline) | |
| docs = rag.retrieve(query, language=language, top_k=3) | |
| context_texts = [d["text"] for d in docs] | |
| if context_input: | |
| context_texts = [context_input] + context_texts | |
| # Generate | |
| start = time.time() | |
| try: | |
| hypothesis = llm.generate( | |
| query=query, | |
| context=context_texts, | |
| language=language, | |
| max_tokens=200, | |
| ) | |
| except Exception as e: | |
| hypothesis = "" | |
| log.warning(f" Error on sample {i}: {e}") | |
| elapsed = time.time() - start | |
| # Compute metrics | |
| b = bleu_score(reference, hypothesis) | |
| r = rouge_l(reference, hypothesis) | |
| k = keyword_recall(reference, hypothesis) | |
| is_non_empty = len(hypothesis.strip()) > 10 | |
| lang_results = results["per_language"][language] | |
| lang_results["count"] += 1 | |
| lang_results["bleu"].append(b) | |
| lang_results["rouge_l"].append(r) | |
| lang_results["keyword_recall"].append(k) | |
| lang_results["non_empty"] += int(is_non_empty) | |
| lang_results["avg_length"].append(len(hypothesis.split())) | |
| # Store example | |
| if len(results["examples"]) < 10: | |
| results["examples"].append({ | |
| "query": query, | |
| "reference": reference[:200], | |
| "hypothesis": hypothesis[:200], | |
| "language": language, | |
| "bleu": round(b, 4), | |
| "rouge_l": round(r, 4), | |
| "time_s": round(elapsed, 2), | |
| }) | |
| progress = f"[{i+1}/{len(samples)}]" | |
| log.info(f" {progress} ({language}) BLEU={b:.3f} ROUGE-L={r:.3f} KW={k:.3f} | {elapsed:.1f}s") | |
| # Aggregate | |
| all_bleu, all_rouge, all_kw = [], [], [] | |
| summary_by_lang = {} | |
| for lang, lr in results["per_language"].items(): | |
| avg_bleu = sum(lr["bleu"]) / max(len(lr["bleu"]), 1) | |
| avg_rouge = sum(lr["rouge_l"]) / max(len(lr["rouge_l"]), 1) | |
| avg_kw = sum(lr["keyword_recall"]) / max(len(lr["keyword_recall"]), 1) | |
| avg_len = sum(lr["avg_length"]) / max(len(lr["avg_length"]), 1) | |
| response_rate = lr["non_empty"] / max(lr["count"], 1) | |
| all_bleu.extend(lr["bleu"]) | |
| all_rouge.extend(lr["rouge_l"]) | |
| all_kw.extend(lr["keyword_recall"]) | |
| summary_by_lang[lang] = { | |
| "samples": lr["count"], | |
| "avg_bleu": round(avg_bleu, 4), | |
| "avg_rouge_l": round(avg_rouge, 4), | |
| "avg_keyword_recall": round(avg_kw, 4), | |
| "response_rate": round(response_rate, 4), | |
| "avg_response_words": round(avg_len, 1), | |
| } | |
| results["overall"] = { | |
| "avg_bleu": round(sum(all_bleu) / max(len(all_bleu), 1), 4), | |
| "avg_rouge_l": round(sum(all_rouge) / max(len(all_rouge), 1), 4), | |
| "avg_keyword_recall": round(sum(all_kw) / max(len(all_kw), 1), 4), | |
| "total_evaluated": len(samples), | |
| } | |
| results["per_language"] = summary_by_lang | |
| return results | |
| def print_report(results: dict): | |
| """Pretty-print evaluation results.""" | |
| log.info("\n" + "=" * 65) | |
| log.info(" KUMBH MODEL EVALUATION REPORT") | |
| log.info("=" * 65) | |
| log.info(f" Model : {results['model']}") | |
| log.info(f" Backend : {results['backend']}") | |
| log.info(f" Samples : {results['total_samples']}") | |
| log.info("-" * 65) | |
| log.info("\n OVERALL METRICS:") | |
| o = results["overall"] | |
| log.info(f" BLEU Score : {o['avg_bleu']:.4f}") | |
| log.info(f" ROUGE-L Score : {o['avg_rouge_l']:.4f}") | |
| log.info(f" Keyword Recall : {o['avg_keyword_recall']:.4f}") | |
| log.info("\n PER-LANGUAGE BREAKDOWN:") | |
| log.info(f" {'Lang':<6} {'N':>4} {'BLEU':>8} {'ROUGE-L':>8} {'KW-Recall':>10} {'Resp%':>7} {'AvgWords':>9}") | |
| log.info(f" {'-'*6} {'-'*4} {'-'*8} {'-'*8} {'-'*10} {'-'*7} {'-'*9}") | |
| for lang, m in sorted(results["per_language"].items()): | |
| log.info( | |
| f" {lang:<6} {m['samples']:>4} {m['avg_bleu']:>8.4f} {m['avg_rouge_l']:>8.4f} " | |
| f"{m['avg_keyword_recall']:>10.4f} {m['response_rate']:>6.1%} {m['avg_response_words']:>9.1f}" | |
| ) | |
| if results.get("examples"): | |
| log.info("\n SAMPLE PREDICTIONS:") | |
| log.info("-" * 65) | |
| for ex in results["examples"][:5]: | |
| log.info(f" Q ({ex['language']}): {ex['query'][:80]}") | |
| log.info(f" Expected : {ex['reference'][:80]}...") | |
| log.info(f" Got : {ex['hypothesis'][:80]}...") | |
| log.info(f" BLEU={ex['bleu']:.4f} ROUGE-L={ex['rouge_l']:.4f} Time={ex['time_s']}s") | |
| log.info() | |
| log.info("=" * 65) | |
| if __name__ == "__main__": | |
| parser = argparse.ArgumentParser(description="Evaluate Kumbh model accuracy") | |
| parser.add_argument("--samples", type=int, default=20, help="Samples per language") | |
| parser.add_argument("--languages", nargs="+", default=["en", "hi", "mr"], | |
| help="Languages to evaluate") | |
| parser.add_argument("--output", type=str, default=None, help="Save results to JSON") | |
| parser.add_argument("--seed", type=int, default=42, help="Random seed") | |
| args = parser.parse_args() | |
| log.info("Loading test set...") | |
| test_set = load_test_set(args.languages, args.samples, args.seed) | |
| if not test_set: | |
| log.error("No test samples found!") | |
| sys.exit(1) | |
| log.info(f"\nRunning evaluation on {len(test_set)} samples...") | |
| results = evaluate(test_set) | |
| print_report(results) | |
| if args.output: | |
| with open(args.output, "w") as f: | |
| json.dump(results, f, indent=2, ensure_ascii=False) | |
| log.info(f"\nResults saved to {args.output}") | |