telen / src /telern /evaluate.py
haidang2405's picture
Update new weights
47bb500 verified
Raw
History Blame Contribute Delete
16.2 kB
"""
Evaluation for TELEN: NDCG@k and MRR@k.
Metrics:
- NDCG@3, NDCG@5, NDCG@10
- MRR@3, MRR@5, MRR@10
Baselines:
- BM25 (lexical)
- Frozen PhoBERT + mean pooling
- TELEN (ours)
Evaluation setup:
- Query = article title + first 100 chars of text
- Relevant = other articles from the SAME law
- Corpus = all articles from test years (held-out)
"""
import math
import random
from collections import defaultdict
from pathlib import Path
from typing import List, Dict, Tuple
import numpy as np
import pandas as pd
import torch
import torch.nn.functional as F
from tqdm import tqdm
from sklearn.feature_extraction.text import TfidfVectorizer
from transformers import AutoModel, AutoTokenizer
from .config import TELENConfig, DATA_DIR
from .model import TELEN, create_model as create_telen
from ..data import load_raw_data, extract_metadata, clean_data
# ═══════════════════════════════════════════════════════════
# Metrics
# ═══════════════════════════════════════════════════════════
def dcg_at_k(scores: np.ndarray, k: int) -> float:
"""Discounted Cumulative Gain at k."""
scores = np.asarray(scores)[:k]
if len(scores) == 0:
return 0.0
discounts = np.log2(np.arange(2, len(scores) + 2))
return np.sum((2.0**scores - 1) / discounts)
def ndcg_at_k(scores: np.ndarray, k: int) -> float:
"""Normalized DCG at k."""
ideal = np.sort(scores)[::-1]
dcg_val = dcg_at_k(scores, k)
idcg_val = dcg_at_k(ideal, k)
return dcg_val / idcg_val if idcg_val > 0 else 0.0
def mrr_at_k(scores: np.ndarray, k: int) -> float:
"""Mean Reciprocal Rank at k."""
scores = np.asarray(scores)[:k]
for rank, s in enumerate(scores, start=1):
if s > 0:
return 1.0 / rank
return 0.0
def compute_metrics(
relevance_scores: np.ndarray, k_values: List[int] = [3, 5, 10]
) -> Dict[str, float]:
"""Compute NDCG@k and MRR@k from relevance scores."""
metrics = {}
for k in k_values:
metrics[f"ndcg@{k}"] = ndcg_at_k(relevance_scores, k)
metrics[f"mrr@{k}"] = mrr_at_k(relevance_scores, k)
return metrics
# ═══════════════════════════════════════════════════════════
# Evaluation
# ═══════════════════════════════════════════════════════════
def prepare_test_data(config: TELENConfig):
"""Prepare test data from held-out years."""
print("Loading data...")
df = load_raw_data(str(DATA_DIR / "train-00000-of-00001.parquet"))
df = extract_metadata(df)
df = clean_data(df, min_text_len=10)
# Test split: articles from test years
test_years = range(config.meta.val_split_year + 1, 2025)
test_df = df[df["year"].isin(test_years)].reset_index(drop=True)
print(f" Test set: {len(test_df)} articles from {test_df['law_id'].nunique()} laws")
return test_df
def build_test_queries(test_df: pd.DataFrame, max_queries: int = 500) -> List[Dict]:
"""Build query set from test articles."""
# Group by law_id
law_groups = test_df.groupby("law_id")
queries = []
for law_id, group in law_groups:
articles = group.to_dict("records")
if len(articles) < 3: # Need at least 1 query + 2 relevant
continue
# Use each article as a potential query
for article in articles[:2]: # Max 2 queries per law
queries.append({
"query_id": article["id"],
"query_text": f"{article['title']}: {article['text'][:500]}",
"query_full": article["text"],
"law_id": law_id,
})
if len(queries) > max_queries:
queries = random.sample(queries, max_queries)
print(f" Queries: {len(queries)}")
return queries
def build_test_corpus(test_df: pd.DataFrame) -> List[Dict]:
"""Build corpus of all test articles for retrieval."""
corpus = []
for _, row in test_df.iterrows():
corpus.append({
"article_id": row["id"],
"text": f"{row['title']}: {row['text'][:500]}",
"law_id": row["law_id"],
})
print(f" Corpus: {len(corpus)} documents")
return corpus
def evaluate_telen(
model: TELEN,
queries: List[Dict],
corpus: List[Dict],
batch_size: int = 64,
) -> Dict[str, float]:
"""
Evaluate TELEN on retrieval metrics.
For each query, rank all corpus documents by cosine similarity.
Relevance = article is from the same law.
"""
device = next(model.parameters()).device
model.eval()
# Encode corpus
print(" Encoding corpus...")
corpus_embeddings = []
corpus_ids = [doc["article_id"] for doc in corpus]
corpus_law_ids = [doc["law_id"] for doc in corpus]
for i in tqdm(range(0, len(corpus), batch_size), desc=" Corpus"):
batch = corpus[i:i + batch_size]
texts = [doc["text"] for doc in batch]
with torch.no_grad():
result = model(texts, use_stochastic=False)
corpus_embeddings.append(result["embeddings"].cpu())
corpus_embeddings = torch.cat(corpus_embeddings, dim=0) # [N_corpus, d]
print(f" Corpus embeddings: {corpus_embeddings.shape}")
# Evaluate each query
all_metrics = defaultdict(list)
print(" Evaluating queries...")
for query in tqdm(queries, desc=" Queries"):
# Encode query
with torch.no_grad():
result = model([query["query_text"]], use_stochastic=False)
query_emb = result["embeddings"].cpu() # [1, d]
# Cosine similarity with all corpus
sim = F.cosine_similarity(
query_emb, corpus_embeddings
).numpy() # [N_corpus]
# Build relevance scores (1.0 if same law, 0.0 otherwise)
relevance = np.array([
1.0 if corpus_law_ids[i] == query["law_id"] else 0.0
for i in range(len(corpus))
])
# Rank by similarity and compute metrics
sorted_idx = sim.argsort()[::-1]
sorted_relevance = relevance[sorted_idx]
# Remove the query itself from results
query_idx_in_corpus = None
for i, cid in enumerate(corpus_ids):
if cid == query["query_id"]:
query_idx_in_corpus = i
break
if query_idx_in_corpus is not None:
# Remove self-match
mask = sorted_idx != query_idx_in_corpus
sorted_relevance = sorted_relevance[mask]
# Compute metrics
for k in [3, 5, 10]:
metrics = compute_metrics(sorted_relevance[:k], [k])
for metric_name, value in metrics.items():
all_metrics[metric_name].append(value)
# Average over queries
results = {name: np.mean(scores) for name, scores in all_metrics.items()}
return results
# ═══════════════════════════════════════════════════════════
# Baselines
# ═══════════════════════════════════════════════════════════
class BM25Baseline:
"""Simple BM25 implementation using TF-IDF as approximation."""
def __init__(self):
self.vectorizer = TfidfVectorizer(
max_features=10000,
ngram_range=(1, 2),
sublinear_tf=True,
)
def fit(self, corpus: List[Dict]):
self.corpus = corpus
self.doc_texts = [doc["text"] for doc in corpus]
self.doc_ids = [doc["article_id"] for doc in corpus]
self.doc_law_ids = [doc["law_id"] for doc in corpus]
self.tfidf_matrix = self.vectorizer.fit_transform(self.doc_texts)
def search(self, query_text: str, k: int = 100) -> np.ndarray:
query_vec = self.vectorizer.transform([query_text])
scores = (self.tfidf_matrix @ query_vec.T).toarray().flatten()
sorted_idx = scores.argsort()[::-1]
return sorted_idx
def evaluate_bm25(queries: List[Dict], corpus: List[Dict]) -> Dict[str, float]:
"""Evaluate BM25 baseline."""
print(" Building BM25 index...")
bm25 = BM25Baseline()
bm25.fit(corpus)
all_metrics = defaultdict(list)
print(" Evaluating queries...")
for query in tqdm(queries, desc=" Queries"):
sorted_idx = bm25.search(query["query_text"], k=100)
# Remove self
doc_ids = bm25.doc_ids
query_idx = None
for i, did in enumerate(doc_ids):
if did == query["query_id"]:
query_idx = i
break
relevance = np.array([
1.0 if bm25.doc_law_ids[i] == query["law_id"] else 0.0
for i in sorted_idx
])
if query_idx is not None:
pos = np.where(sorted_idx == query_idx)[0]
if len(pos) > 0:
relevance = np.delete(relevance, pos[0])
for k in [3, 5, 10]:
valid_rel = relevance[:k]
metrics = compute_metrics(valid_rel, [k])
for name, val in metrics.items():
all_metrics[name].append(val)
return {name: np.mean(scores) for name, scores in all_metrics.items()}
class FrozenPhoBERT:
"""Frozen PhoBERT with mean pooling baseline."""
def __init__(self, model_name: str = "vinai/phobert-base-v2"):
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModel.from_pretrained(model_name)
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
self.model = self.model.to(self.device)
self.model.eval()
def encode(self, texts: List[str], batch_size: int = 64) -> torch.Tensor:
embeddings = []
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
encoded = self.tokenizer(
batch, padding=True, truncation=True,
max_length=256, return_tensors="pt",
)
input_ids = encoded["input_ids"].to(self.device)
attention_mask = encoded["attention_mask"].to(self.device)
with torch.no_grad():
outputs = self.model(input_ids=input_ids, attention_mask=attention_mask)
hidden = outputs.last_hidden_state
# Mean pooling
mask = attention_mask.unsqueeze(-1).float()
pooled = (hidden * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1e-9)
pooled = F.normalize(pooled, p=2, dim=1)
embeddings.append(pooled.cpu())
return torch.cat(embeddings, dim=0)
def evaluate_frozen_phobert(
queries: List[Dict], corpus: List[Dict]
) -> Dict[str, float]:
"""Evaluate frozen PhoBERT baseline."""
print(" Loading frozen PhoBERT...")
encoder = FrozenPhoBERT()
print(" Encoding corpus...")
corpus_texts = [doc["text"] for doc in corpus]
corpus_embeddings = encoder.encode(corpus_texts)
corpus_ids = [doc["article_id"] for doc in corpus]
corpus_law_ids = [doc["law_id"] for doc in corpus]
all_metrics = defaultdict(list)
print(" Evaluating queries...")
query_texts = [q["query_text"] for q in queries]
query_embeddings = encoder.encode(query_texts)
for i, query in enumerate(tqdm(queries, desc=" Queries")):
query_emb = query_embeddings[i:i+1]
sim = F.cosine_similarity(query_emb, corpus_embeddings).numpy()
relevance = np.array([
1.0 if corpus_law_ids[j] == query["law_id"] else 0.0
for j in range(len(corpus))
])
sorted_idx = sim.argsort()[::-1]
sorted_relevance = relevance[sorted_idx]
# Remove self
for j, cid in enumerate(corpus_ids):
if cid == query["query_id"]:
mask = sorted_idx != j
sorted_relevance = sorted_relevance[mask]
break
for k in [3, 5, 10]:
metrics = compute_metrics(sorted_relevance[:k], [k])
for name, val in metrics.items():
all_metrics[name].append(val)
return {name: np.mean(scores) for name, scores in all_metrics.items()}
# ═══════════════════════════════════════════════════════════
# Main evaluation entry point
# ═══════════════════════════════════════════════════════════
def run_full_evaluation(
config: TELENConfig = None,
checkpoint_path: str = None,
):
"""Run complete evaluation with all baselines and TELEN."""
if config is None:
config = TELENConfig()
random.seed(config.seed)
np.random.seed(config.seed)
print("=" * 60)
print("TELEN Evaluation")
print("=" * 60)
# Prepare test data
test_df = prepare_test_data(config)
queries = build_test_queries(test_df, max_queries=300)
corpus = build_test_corpus(test_df)
k_values = [3, 5, 10]
results = {}
# --- Baseline 1: BM25 ---
print("\n" + "=" * 40)
print("[1/3] BM25 Baseline")
print("=" * 40)
results["BM25"] = evaluate_bm25(queries, corpus)
for m in k_values:
print(f" NDCG@{m}: {results['BM25'][f'ndcg@{m}']:.4f} | MRR@{m}: {results['BM25'][f'mrr@{m}']:.4f}")
# --- Baseline 2: Frozen PhoBERT ---
print("\n" + "=" * 40)
print("[2/3] Frozen PhoBERT Baseline")
print("=" * 40)
results["PhoBERT"] = evaluate_frozen_phobert(queries, corpus)
for m in k_values:
print(f" NDCG@{m}: {results['PhoBERT'][f'ndcg@{m}']:.4f} | MRR@{m}: {results['PhoBERT'][f'mrr@{m}']:.4f}")
# --- TELEN ---
print("\n" + "=" * 40)
print("[3/3] TELEN (Ours)")
print("=" * 40)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = create_telen(config)
model = model.to(device)
# Load checkpoint if provided
if checkpoint_path and Path(checkpoint_path).exists():
print(f" Loading checkpoint: {checkpoint_path}")
ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False)
model.hypernetwork.load_state_dict(ckpt["hypernetwork"])
model.state_encoder.load_state_dict(ckpt["state_encoder"])
model.base_projection.load_state_dict(ckpt["base_projection"])
model.attn_query.data.copy_(ckpt["attn_query"])
# Rebuild graph
model.build_graph(test_df[test_df["year"] <= config.meta.train_split_year])
results["TELEN"] = evaluate_telen(model, queries, corpus)
for m in k_values:
print(f" NDCG@{m}: {results['TELEN'][f'ndcg@{m}']:.4f} | MRR@{m}: {results['TELEN'][f'mrr@{m}']:.4f}")
# --- Summary ---
print("\n" + "=" * 60)
print("SUMMARY")
print("=" * 60)
header = f"{'Method':<20}"
for m in k_values:
header += f" {'NDCG@'+str(m):>12} {'MRR@'+str(m):>12}"
print(header)
print("-" * len(header))
for method in ["BM25", "PhoBERT", "TELEN"]:
row = f"{method:<20}"
for m in k_values:
row += f" {results[method][f'ndcg@{m}']:>12.4f} {results[method][f'mrr@{m}']:>12.4f}"
print(row)
# Relative improvement
print("\n--- Improvement over PhoBERT ---")
for m in k_values:
ndcg_imp = (results["TELEN"][f"ndcg@{m}"] / max(results["PhoBERT"][f"ndcg@{m}"], 1e-6) - 1) * 100
mrr_imp = (results["TELEN"][f"mrr@{m}"] / max(results["PhoBERT"][f"mrr@{m}"], 1e-6) - 1) * 100
print(f" NDCG@{m}: {ndcg_imp:+.1f}% | MRR@{m}: {mrr_imp:+.1f}%")
return results
if __name__ == "__main__":
run_full_evaluation()