Files
wehub-resource-sync 542cfa195c
CI / Frontend build (push) Failing after 9m6s
CI / Plugin validate (push) Failing after 9m27s
CI / Python lint (push) Failing after 16m1s
CI / Tests (push) Successful in 18m0s
Deploy / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:27 +08:00

160 lines
4.7 KiB
Python

#!/usr/bin/env python3
"""Evaluation: Recall@K and MRR for query-chunk retrieval."""
import json
import logging
import faiss
import numpy as np
import torch
from PIL import Image
from training.dataset import DOC_INSTRUCTION, QUERY_INSTRUCTION
from training.model import pool_and_normalize
logger = logging.getLogger(__name__)
def _chunk(lst, n):
for i in range(0, len(lst), n):
yield lst[i : i + n]
def run_eval(
model,
processor,
eval_jsonl: str,
device: str,
batch_size: int = 16,
max_pairs: int = 200,
) -> tuple[float, float, float]:
"""Embed eval queries + images, compute Recall@1, Recall@10, MRR.
Args:
max_pairs: Cap eval set size for speed. Use 0 for no limit.
"""
model.eval()
pairs = []
with open(eval_jsonl) as f:
for line in f:
item = json.loads(line)
pairs.append((item["query"], item["chunk_path"]))
if max_pairs > 0 and len(pairs) > max_pairs:
pairs = pairs[:max_pairs]
q_embs_list = []
i_embs_list = []
with torch.no_grad():
for batch_pairs in _chunk(pairs, batch_size):
# Filter out bad images
valid = []
for query, path in batch_pairs:
try:
img = Image.open(path).convert("RGB")
valid.append((query, img))
except Exception as e:
logger.warning(f"Eval: skipping bad image {path}: {e}")
if not valid:
continue
queries, images = zip(*valid)
# Query embeddings
q_messages = [
[
{
"role": "system",
"content": [{"type": "text", "text": QUERY_INSTRUCTION}],
},
{"role": "user", "content": [{"type": "text", "text": q}]},
]
for q in queries
]
q_texts = [
processor.apply_chat_template(
m, tokenize=False, add_generation_prompt=True
)
for m in q_messages
]
q_inputs = processor(text=q_texts, return_tensors="pt", padding=True)
q_inputs = {
k: v.to(device) if hasattr(v, "to") else v for k, v in q_inputs.items()
}
q_out = model(**q_inputs, output_hidden_states=True)
q_emb = pool_and_normalize(
q_out.hidden_states[-1], q_inputs["attention_mask"]
)
q_embs_list.append(q_emb.cpu().float().numpy())
# Image embeddings
i_messages = [
[
{
"role": "system",
"content": [{"type": "text", "text": DOC_INSTRUCTION}],
},
{"role": "user", "content": [{"type": "image", "image": img}]},
]
for img in images
]
i_texts = [
processor.apply_chat_template(
m, tokenize=False, add_generation_prompt=True
)
for m in i_messages
]
i_inputs = processor(
text=i_texts,
images=list(images),
return_tensors="pt",
padding=True,
device=device,
)
i_inputs = {
k: v.to(device) if hasattr(v, "to") else v for k, v in i_inputs.items()
}
i_out = model(**i_inputs, output_hidden_states=True)
i_emb = pool_and_normalize(
i_out.hidden_states[-1], i_inputs["attention_mask"]
)
i_embs_list.append(i_emb.cpu().float().numpy())
if not q_embs_list:
logger.warning("No valid eval pairs found")
return 0.0, 0.0, 0.0
q_embs = np.vstack(q_embs_list).astype(np.float32)
i_embs = np.vstack(i_embs_list).astype(np.float32)
n = q_embs.shape[0]
d = q_embs.shape[1]
# FAISS inner-product search (embeddings are L2-normalized → IP = cosine)
index = faiss.IndexFlatIP(d)
index.add(i_embs)
k = min(100, n)
_, indices = index.search(q_embs, k)
# Each query's correct match is at the same index (diagonal)
recall_1 = 0.0
recall_10 = 0.0
mrr = 0.0
for i in range(n):
retrieved = indices[i].tolist()
if i in retrieved[:1]:
recall_1 += 1
if i in retrieved[:10]:
recall_10 += 1
if i in retrieved:
rank = retrieved.index(i) + 1
mrr += 1.0 / rank
recall_1 /= n
recall_10 /= n
mrr /= n
model.train()
return recall_1, recall_10, mrr