ProCreations's picture
download
raw
12.3 kB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
KALM embedding (bi-encoder) evaluation for agent recommendation.
This mirrors the BGE/EasyRec evaluation entry points while using a
Sentence-Transformers bi-encoder (e.g., KaLM-Embedding/KaLM-embedding-
multilingual-mini-instruct-v2.5) to score query/agent pairs via dot
product or cosine similarity (when normalized).
Example:
python infer_KALM_aligned_with_dnn.py \
--data_root /path/to/benchmark \
--model_dir /path/to/st-biencoder \
--exp_name infer_kalm \
--device cuda:0 \
--encode_batch 64 \
--eval_cand_size 1000 \
--pos_topk 0 \
--sample_per_part 200 \
--ks 10 \
--max_len 384
"""
from __future__ import annotations
import argparse
import inspect
import os
from collections import Counter, defaultdict
from contextlib import nullcontext
from typing import Dict, Iterable, List, Tuple
import numpy as np
import torch
from sentence_transformers import SentenceTransformer
from tqdm.auto import tqdm
from agent_rec.config import EVAL_TOPK, POS_TOPK, POS_TOPK_BY_PART
from agent_rec.rerank_eval_utils import (
accumulate_metrics,
build_agent_text_cache,
finalize_metrics,
metric_template,
metrics_from_hits,
prepare_eval_items,
sample_qids_by_part,
select_eval_qids,
topk_hits_from_scores,
)
from agent_rec.run_common import bootstrap_run
from utils import print_metrics_table
def parse_ks(arg: str) -> Tuple[int, ...]:
return tuple(sorted({int(x) for x in arg.split(",") if x.strip()}))
def part_count_str(qids: Iterable[str], qid_to_part: Dict[str, str]) -> str:
"""Compact PartI/PartII/PartIII count string for DNN-style split logging."""
c = Counter(qid_to_part.get(qid, "Unknown") for qid in qids)
known = ["PartI", "PartII", "PartIII"]
keys = known + sorted(k for k in c if k not in known)
return ", ".join(f"{k}={c.get(k, 0)}" for k in keys if c.get(k, 0) > 0)
def encode_texts(
model: SentenceTransformer,
texts: Iterable[str],
*,
batch_size: int,
device: str,
normalize: bool,
use_amp: bool,
) -> np.ndarray:
encode_kwargs = dict(
batch_size=batch_size,
convert_to_numpy=True,
device=device,
show_progress_bar=True,
normalize_embeddings=normalize,
)
supports_use_amp = "use_amp" in inspect.signature(model.encode).parameters
if supports_use_amp:
encode_kwargs["use_amp"] = use_amp
return model.encode(list(texts), **encode_kwargs)
autocast_ctx = (
torch.cuda.amp.autocast(dtype=torch.float16) if use_amp and torch.cuda.is_available() else nullcontext()
)
with autocast_ctx:
return model.encode(list(texts), **encode_kwargs)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--data_root", type=str, required=True)
ap.add_argument(
"--exp_name",
type=str,
default="infer_kalm_embedding",
help="Cache/log dir name; no training cache is saved but kept for naming consistency.",
)
ap.add_argument("--model_dir", type=str, required=True, help="Sentence-Transformers bi-encoder directory")
ap.add_argument("--device", type=str, default="cuda:0")
ap.add_argument("--encode_batch", type=int, default=64)
ap.add_argument("--eval_cand_size", type=int, default=1000)
ap.add_argument(
"--pos_topk",
type=int,
default=0,
help="Positive cutoff per query. 0 = use per-part defaults (POS_TOPK_BY_PART).",
)
ap.add_argument("--ks", type=str, default=str(EVAL_TOPK))
ap.add_argument("--max_len", type=int, default=384)
ap.add_argument("--use_amp", type=int, default=1, help="1 to enable autocast on CUDA during encoding")
ap.add_argument("--normalize", type=int, default=1, help="1 to L2-normalize embeddings before scoring")
ap.add_argument("--seed", type=int, default=1234, help="Global seed for data prep and negatives.")
ap.add_argument("--split_seed", type=int, default=42, help="Seed for stratified eval split to match baselines.")
ap.add_argument("--valid_ratio", type=float, default=0.2, help="Portion of qids (with rankings) used for eval.")
ap.add_argument(
"--sample_per_part",
type=int,
default=200,
help=(
"Eval qids sampled per part after the DNN-style stratified valid split. "
"The default 200 is intentional for balanced KALM evaluation; 0 disables this extra sampling."
),
)
ap.add_argument("--max_eval", type=int, default=0, help="Max number of eval queries after sampling. 0 = use all.")
ap.add_argument(
"--report_overall",
type=int,
default=1,
help="1 to also print an overall table in addition to DNN-style per-Part tables.",
)
args = ap.parse_args()
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
os.environ.setdefault("OMP_NUM_THREADS", "1")
os.environ.setdefault("MKL_NUM_THREADS", "1")
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
np.random.seed(args.seed)
torch.manual_seed(args.seed)
ks = parse_ks(args.ks)
if not ks:
raise ValueError("--ks must provide at least one integer (e.g., 5,10,50)")
if args.sample_per_part < 0:
raise ValueError("--sample_per_part must be >= 0")
if args.eval_cand_size <= 0:
raise ValueError("--eval_cand_size must be > 0")
# 1) Data/bootstrap
boot = bootstrap_run(
data_root=args.data_root,
exp_name=args.exp_name,
topk=EVAL_TOPK,
seed=args.seed,
with_tools=True,
)
# Match DNN's evaluation split semantics as closely as possible:
# first build the same stratified validation split by Part, then optionally
# apply KALM's intentional balanced per-Part subsampling for efficiency.
valid_qids = select_eval_qids(
boot.qids_in_rank,
seed=args.split_seed,
valid_ratio=args.valid_ratio,
qid_to_part=boot.bundle.qid_to_part,
)
train_count = len(boot.qids_in_rank) - len(valid_qids)
print(f"[split] train={train_count} valid={len(valid_qids)}")
print(f"[split] valid parts: {part_count_str(valid_qids, boot.bundle.qid_to_part)}")
if args.sample_per_part > 0:
eval_qids = sample_qids_by_part(
valid_qids,
qid_to_part=boot.bundle.qid_to_part,
per_part=args.sample_per_part,
seed=args.seed,
)
print(
f"[eval-sample] sample_per_part={args.sample_per_part}; "
f"eval={len(eval_qids)}; parts: {part_count_str(eval_qids, boot.bundle.qid_to_part)}"
)
else:
eval_qids = list(valid_qids)
print(f"[eval-sample] disabled; using all valid qids: eval={len(eval_qids)}")
agent_text_cache = build_agent_text_cache(boot.bundle.all_agents, boot.tools or {})
items = prepare_eval_items(
eval_qids=eval_qids,
all_questions=boot.bundle.all_questions,
all_agents=boot.bundle.all_agents,
tools=boot.tools or {},
all_rankings=boot.bundle.all_rankings,
a_ids=boot.a_ids,
seed=args.seed,
cand_size=args.eval_cand_size,
pos_topk=None if args.pos_topk <= 0 else args.pos_topk,
qid_to_part=boot.bundle.qid_to_part,
agent_text_cache=agent_text_cache,
)
if args.max_eval and len(items) > args.max_eval:
# Kept for smoke tests only. Full/official comparison should leave this as 0.
items = items[: args.max_eval]
pos_desc = "POS_TOPK_BY_PART" if args.pos_topk <= 0 else str(args.pos_topk)
print(
f"Prepared {len(items)} eval items "
f"(valid_ratio={args.valid_ratio}, split_seed={args.split_seed}, "
f"seed={args.seed}, cand_size={args.eval_cand_size}, pos_topk={pos_desc})."
)
if args.pos_topk <= 0:
print(f"[pos] using per-part positives: {POS_TOPK_BY_PART} (default={POS_TOPK})")
# 2) Model
device = torch.device(args.device if torch.cuda.is_available() else "cpu")
if device.type == "cpu" and args.device != "cpu":
print(f"[warn] CUDA not available, running on CPU instead of {args.device}.")
model = SentenceTransformer(args.model_dir, device=str(device), trust_remote_code=True)
if hasattr(model, "max_seq_length"):
model.max_seq_length = args.max_len
if torch.cuda.is_available():
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
use_amp = bool(args.use_amp) and device.type == "cuda"
normalize = bool(args.normalize)
# 3) Pre-encode corpus and queries
agent_vecs = encode_texts(
model,
(agent_text_cache.get(aid, "") for aid in boot.a_ids),
batch_size=args.encode_batch,
device=str(device),
normalize=normalize,
use_amp=use_amp,
)
aid_to_vec = {aid: agent_vecs[i] for i, aid in enumerate(boot.a_ids)}
q_texts = {it.qid: it.qtext for it in items}
q_vecs_arr = encode_texts(
model,
[q_texts[qid] for qid in q_texts.keys()],
batch_size=args.encode_batch,
device=str(device),
normalize=normalize,
use_amp=use_amp,
)
q_vecs = {qid: q_vecs_arr[i] for i, qid in enumerate(q_texts.keys())}
# 4) Evaluation
# DNN reports validation metrics by Part. We keep that reporting structure,
# while also retaining KALM's optional overall aggregation.
agg = metric_template(ks)
scored = 0
skipped = 0
part_aggs: Dict[str, Dict[int, Dict[str, float]]] = {}
part_counts = defaultdict(int)
ref_k = 10 if 10 in ks else max(ks)
items_by_part: Dict[str, List] = defaultdict(list)
for it in items:
items_by_part[boot.bundle.qid_to_part.get(it.qid, "Unknown")].append(it)
ordered_parts = ["PartI", "PartII", "PartIII"] + sorted(
part for part in items_by_part if part not in {"PartI", "PartII", "PartIII"}
)
for part in ordered_parts:
part_items = items_by_part.get(part, [])
if not part_items:
continue
part_agg = metric_template(ks)
part_scored = 0
part_skipped = 0
desc = f"Valid {part} (KALM Embedding, top{ref_k})"
pbar = tqdm(part_items, desc=desc, dynamic_ncols=True)
for it in pbar:
qv = q_vecs.get(it.qid)
cand_vecs: List[np.ndarray] = []
cand_ids: List[str] = []
for aid in it.cand_ids:
vec = aid_to_vec.get(aid)
if vec is None:
continue
cand_ids.append(aid)
cand_vecs.append(vec)
if qv is None or not cand_vecs:
skipped += 1
part_skipped += 1
pbar.set_postfix({"done": part_scored, "skipped": part_skipped})
continue
scores = np.matmul(np.vstack(cand_vecs), qv)
_, bin_hits = topk_hits_from_scores(scores, cand_ids, it.rel_set, ks)
per_k = metrics_from_hits(bin_hits, len(it.rel_set), ks)
accumulate_metrics(part_agg, per_k, ks)
accumulate_metrics(agg, per_k, ks)
part_scored += 1
scored += 1
ref = part_agg[ref_k]
denom = max(part_scored, 1)
pbar.set_postfix(
{
"done": part_scored,
"skipped": part_skipped,
f"P@{ref_k}": f"{(ref['P'] / denom):.4f}",
f"nDCG@{ref_k}": f"{(ref['nDCG'] / denom):.4f}",
f"MRR@{ref_k}": f"{(ref['MRR'] / denom):.4f}",
}
)
part_aggs[part] = part_agg
part_counts[part] = part_scored
if part_scored > 0:
m_part = finalize_metrics(part_agg, part_scored, ks)
print_metrics_table(f"Validation {part} (KALM Embedding)", m_part, ks=ks, filename=args.exp_name)
if bool(args.report_overall):
metrics = finalize_metrics(agg, scored, ks)
print_metrics_table("KALM Embedding eval", metrics, ks=ks, filename=args.exp_name)
if skipped:
print(f"[warn] skipped {skipped} eval items because query/candidate vectors were missing.")
if __name__ == "__main__":
main()

Xet Storage Details

Size:
12.3 kB
·
Xet hash:
7ca51442a87b35a37c515f497dbe3b97a5e2c56fa21f542f26f9c227be3652ee

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.