| """ |
| Evaluate TELEN with full benchmarks. |
| |
| Metrics: NDCG@3, NDCG@5, NDCG@10, MRR@3, MRR@5, MRR@10 |
| |
| Baselines: |
| - BM25 (lexical retrieval) |
| - Frozen PhoBERT (vinai/phobert-base-v2) |
| - multilingual-E5-base (intfloat/multilingual-e5-base) |
| - BGE-M3 (BAAI/bge-m3) |
| - DEk21 (huyydangg/DEk21_hcmute_embedding) |
| - TELEN (ours) |
| |
| Usage: |
| python eval.py |
| """ |
| import sys; sys.path.insert(0, ".") |
| sys.stdout.reconfigure(encoding='utf-8') |
| import warnings; warnings.filterwarnings("ignore") |
| import random, numpy as np, torch, torch.nn.functional as F |
| from tqdm import tqdm |
| from collections import defaultdict |
| from sentence_transformers import SentenceTransformer |
| from transformers import AutoModel, AutoTokenizer |
| from pyvi import ViTokenizer |
|
|
| from src.telern.config import TELENConfig |
| from src.telern.model import create_model |
| from src.telern.evaluate import ( |
| BM25Baseline, FrozenPhoBERT, prepare_test_data, |
| build_test_queries, build_test_corpus, compute_metrics, evaluate_bm25, |
| ) |
|
|
| SEED = 42; random.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED) |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
| config = TELENConfig() |
|
|
| def wseg(text): |
| return ViTokenizer.tokenize(text.replace("_", " ")) |
|
|
| def evaluate_model(name, encode_fn, queries, corpus, corpus_ids, corpus_law_ids, corpus_encode_fn=None): |
| """Generic evaluation for any embedding model.""" |
| if corpus_encode_fn is None: |
| corpus_encode_fn = encode_fn |
| print(f"\n [{name}] Encoding corpus ({len(corpus)} docs)...") |
| c_embs = [] |
| for i in range(0, len(corpus), 64): |
| batch = [d["text"] for d in corpus[i:i+64]] |
| embs = corpus_encode_fn(batch) |
| if isinstance(embs, np.ndarray): embs = torch.tensor(embs) |
| c_embs.append(embs.cpu()) |
| c_embs = torch.cat(c_embs, dim=0) |
|
|
| print(f" [{name}] Evaluating {len(queries)} queries...") |
| all_m = defaultdict(list) |
| for q in tqdm(queries, desc=f" {name}"): |
| q_emb = encode_fn([q["query_text"]]) |
| if isinstance(q_emb, np.ndarray): q_emb = torch.tensor(q_emb) |
| sim = F.cosine_similarity(q_emb.cpu(), c_embs).numpy() |
| rel = np.array([1.0 if corpus_law_ids[j]==q["law_id"] else 0.0 for j in range(len(corpus))]) |
| si = sim.argsort()[::-1]; sr = rel[si] |
| for j,cid in enumerate(corpus_ids): |
| if cid==q["query_id"]: |
| p=np.where(si==j)[0]; sr=np.delete(sr,p[0]) if len(p)>0 else None; break |
| for k in [3,5,10]: |
| for mn,mv in compute_metrics(sr[:k],[k]).items(): all_m[mn].append(mv) |
| return {n: np.mean(v) for n,v in all_m.items()} |
|
|
| |
| test_df = prepare_test_data(config) |
| queries = build_test_queries(test_df, max_queries=300) |
| corpus = build_test_corpus(test_df) |
| corpus_ids = [d["article_id"] for d in corpus] |
| corpus_law_ids = [d["law_id"] for d in corpus] |
| train_df = test_df[test_df["year"] <= config.meta.train_split_year] |
| print(f"Test: {len(queries)} queries, {len(corpus)} docs, {test_df['law_id'].nunique()} laws") |
|
|
| results = {} |
|
|
| |
| print("\n[1/6] BM25") |
| results["BM25"] = evaluate_bm25(queries, corpus) |
|
|
| |
| print("\n[2/6] Frozen PhoBERT") |
| phobert = FrozenPhoBERT() |
| results["PhoBERT"] = evaluate_model("PhoBERT", lambda texts: phobert.encode(texts, batch_size=64), queries, corpus, corpus_ids, corpus_law_ids) |
|
|
| |
| print("\n[3/6] DEk21 (legal SOTA)") |
| dek21 = SentenceTransformer("huyydangg/DEk21_hcmute_embedding", device=device) |
| results["DEk21"] = evaluate_model("DEk21", lambda texts: dek21.encode([wseg(t) for t in texts], batch_size=64, show_progress_bar=False, normalize_embeddings=True, convert_to_tensor=True), queries, corpus, corpus_ids, corpus_law_ids) |
|
|
| |
| print("\n[4/6] multilingual-E5-base") |
| e5_tokenizer = AutoTokenizer.from_pretrained("intfloat/multilingual-e5-base") |
| e5_model = AutoModel.from_pretrained("intfloat/multilingual-e5-base").to(device) |
| e5_model.eval() |
| def e5_encode(texts, prefix="query: "): |
| prefixed = [prefix + t for t in texts] |
| enc = e5_tokenizer(prefixed, padding=True, truncation=True, max_length=512, return_tensors="pt") |
| with torch.no_grad(): |
| hidden = e5_model(input_ids=enc["input_ids"].to(device), attention_mask=enc["attention_mask"].to(device)).last_hidden_state |
| mask = enc["attention_mask"].unsqueeze(-1).float().to(device) |
| pooled = (hidden * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1e-9) |
| return F.normalize(pooled, p=2, dim=1) |
| results["multilingual-e5"] = evaluate_model("mE5", |
| lambda texts: e5_encode(texts), |
| queries, corpus, corpus_ids, corpus_law_ids, |
| corpus_encode_fn=lambda texts: e5_encode(texts, prefix="passage: ")) |
| del e5_model, e5_tokenizer; torch.cuda.empty_cache() |
|
|
| |
| print("\n[5/6] BAAI/bge-m3") |
| bge_tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-m3") |
| bge_model = AutoModel.from_pretrained("BAAI/bge-m3").to(device) |
| bge_model.eval() |
| def bge_encode(texts, add_prefix=True): |
| if add_prefix: |
| texts = ["Represent this sentence for searching relevant passages: " + t for t in texts] |
| enc = bge_tokenizer(texts, padding=True, truncation=True, max_length=512, return_tensors="pt") |
| with torch.no_grad(): |
| hidden = bge_model(input_ids=enc["input_ids"].to(device), attention_mask=enc["attention_mask"].to(device)).last_hidden_state |
| cls_emb = hidden[:, 0, :] |
| return F.normalize(cls_emb, p=2, dim=1) |
| results["bge-m3"] = evaluate_model("BGE-M3", |
| lambda texts: bge_encode(texts, add_prefix=True), |
| queries, corpus, corpus_ids, corpus_law_ids, |
| corpus_encode_fn=lambda texts: bge_encode(texts, add_prefix=False)) |
| del bge_model, bge_tokenizer; torch.cuda.empty_cache() |
|
|
| |
| print("\n[6/6] TELEN (Ours)") |
| telen = create_model(config).to(device) |
| ckpt = torch.load(config.output_dir + "/telen_best.pt", map_location=device, weights_only=False) |
| telen.hypernetwork.load_state_dict(ckpt["hypernetwork"]) |
| telen.state_encoder.load_state_dict(ckpt["state_encoder"]) |
| telen.base_projection.load_state_dict(ckpt["base_projection"]) |
| telen.attn_query.data.copy_(ckpt["attn_query"]) |
| if len(train_df) > 0: telen.build_graph(train_df) |
|
|
| def telen_encode(texts): |
| with torch.no_grad(): |
| return telen(texts, use_stochastic=False)["embeddings"].cpu() |
|
|
| results["TELEN"] = evaluate_model("TELEN", telen_encode, queries, corpus, corpus_ids, corpus_law_ids) |
|
|
| |
| print("\n" + "=" * 75) |
| print("BENCHMARK RESULTS") |
| print("=" * 75) |
| h = f"{'Method':<15}" |
| for m in [3,5,10]: h += f" {'NDCG@'+str(m):>10} {'MRR@'+str(m):>10}" |
| print(h); print("-"*len(h)) |
| for name in ["BM25", "PhoBERT", "multilingual-e5", "bge-m3", "DEk21", "TELEN"]: |
| display = {"multilingual-e5": "mE5-base", "bge-m3": "BGE-M3"}.get(name, name) |
| r = f"{display:<15}" |
| for m in [3,5,10]: r += f" {results[name][f'ndcg@{m}']:>10.4f} {results[name][f'mrr@{m}']:>10.4f}" |
| print(r) |
|
|
| print("\n--- Relative Improvement over Baselines ---") |
| for baseline in ["PhoBERT", "multilingual-e5", "bge-m3", "DEk21"]: |
| display = {"multilingual-e5": "mE5-base", "bge-m3": "BGE-M3"}.get(baseline, baseline) |
| print(f" TELEN vs {display}:") |
| for m in [3,5,10]: |
| ni = (results["TELEN"][f"ndcg@{m}"] / max(results[baseline][f"ndcg@{m}"], 1e-6) - 1) * 100 |
| mi = (results["TELEN"][f"mrr@{m}"] / max(results[baseline][f"mrr@{m}"], 1e-6) - 1) * 100 |
| print(f" NDCG@{m}: {ni:+.1f}% MRR@{m}: {mi:+.1f}%") |
| print("Done!") |
|
|