File size: 19,684 Bytes
30e823f
 
 
 
 
 
 
 
 
 
5426088
 
 
30e823f
 
 
a387cee
30e823f
 
0234749
 
30e823f
 
 
 
 
 
 
 
 
 
 
a387cee
 
30e823f
 
 
 
 
a387cee
30e823f
 
 
a387cee
 
 
 
30e823f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a387cee
3c17edf
 
 
a387cee
 
3c17edf
 
 
 
 
 
 
a387cee
 
 
 
 
 
 
 
ab1f630
 
 
 
 
a387cee
 
 
 
ab1f630
 
 
 
 
 
 
 
 
 
a387cee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ab1f630
 
 
a387cee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c17edf
 
 
0234749
 
 
 
a387cee
 
 
 
 
 
 
 
 
 
 
 
 
3c17edf
 
 
 
 
30e823f
 
 
a387cee
30e823f
 
 
 
a387cee
30e823f
 
 
da4b641
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30e823f
da4b641
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30e823f
 
da4b641
a387cee
 
30e823f
a387cee
 
 
 
 
 
 
 
 
da4b641
30e823f
 
 
a387cee
30e823f
 
 
 
 
 
ab1f630
 
 
30e823f
 
 
ab1f630
 
3c17edf
 
 
 
 
 
 
a387cee
 
 
 
 
 
 
30e823f
 
da4b641
 
30e823f
 
 
 
 
 
 
a387cee
30e823f
a387cee
 
30e823f
 
a387cee
30e823f
 
 
a387cee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da4b641
 
 
 
 
 
 
a387cee
30e823f
 
da4b641
 
 
 
 
 
a387cee
 
 
 
30e823f
da4b641
 
 
 
 
 
 
30e823f
a387cee
30e823f
 
 
 
 
 
a387cee
 
 
3c17edf
 
 
 
 
 
 
30e823f
da4b641
 
 
30e823f
da4b641
 
30e823f
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
import argparse
import json
import sys
import time
from collections import defaultdict
from datetime import date
from pathlib import Path
from typing import Any

REPO_ROOT = Path(__file__).resolve().parents[2]
PACKAGE_PARENT = REPO_ROOT.parent
if str(PACKAGE_PARENT) not in sys.path:
    sys.path.insert(0, str(PACKAGE_PARENT))

from rusBeIR.beir.datasets.data_loader_hf import HFDataLoader
from rusBeIR.beir.retrieval.evaluation import EvaluateRetrieval
from rusBeIR.beir.retrieval.search.base import BaseSearch


DATASETS_PATH = REPO_ROOT/"leaderboard"/ "data"/"datasets.json"
DEFAULT_OUTPUT = REPO_ROOT/"leaderboard"/"data"/"results.jsonl"


def load_datasets() -> list[dict[str, Any]]:
    with DATASETS_PATH.open("r", encoding="utf-8") as file:
        return json.load(file)


def select_device(requested: str) -> str:
    if requested != "auto":
        return requested
    try:
        import torch

        return "cuda" if torch.cuda.is_available() else "cpu"
    except Exception:
        return "cpu"


def safe_name(value: str) -> str:
    return value.replace("/", "__").replace(":", "_")


def raw_results_path(results_dir: Path, model_name: str, dataset: dict[str, Any]) -> Path:
    return results_dir/f"results_{model_name}_{dataset['name']}_{dataset['split']}.json"


def merge_metrics(*metric_groups: dict[str, float]) -> dict[str, float]:
    merged: dict[str, float] = {}
    for group in metric_groups:
        merged.update(group)
    return merged


def average_metrics(per_dataset: dict[str, dict[str, float]]) -> dict[str, float]:
    values_by_metric: dict[str, list[float]] = defaultdict(list)
    for metrics in per_dataset.values():
        for key, value in metrics.items():
            values_by_metric[key].append(float(value))
    return {
        key: round(sum(values) / len(values), 5)
        for key, values in sorted(values_by_metric.items())
        if values
    }


def retrieve(model, corpus: dict[str, dict[str, str]], queries: dict[str, str], top_k: int,
             query_batch_size: int, num_workers: int, dense_backend: str = "exact", faiss_index_dir: str | Path = None,
             faiss_index_name: str = "index", rebuild_faiss_index: bool = False, corpus_batch_size: int = 50000,
             faiss_device: str = "auto", faiss_gpu_id: int = 0) -> dict[str, dict[str, float]]:
    if isinstance(model, BaseSearch):
        return EvaluateRetrieval(retriever=model, k_values=[top_k]).retrieve(corpus, queries)

    if dense_backend == "faiss":
        return model.retrieve_faiss(queries=queries, corpus=corpus, top_n=top_k, query_batch_size=query_batch_size,
                                    corpus_batch_size=corpus_batch_size, index_dir=faiss_index_dir, 
                                    index_name=faiss_index_name, rebuild_index=rebuild_faiss_index,
                                    faiss_device=faiss_device, faiss_gpu_id=faiss_gpu_id)  

    return model.retrieve(queries, corpus, top_n=top_k, data_batch_size=query_batch_size, num_workers=num_workers)


def build_dense_model(args: argparse.Namespace, device: str):
    from rusBeIR.retrieval.models.dense.DenseHFModels import DenseHFModels

    class ConfigurableDenseModel(DenseHFModels):
        def __init__(self, model_name: str, maxlen: int, batch_size: int, device: str, pooling_method: str,
                     query_prefix: str, passage_prefix: str, model_sep: str, padding_side: str | None,
                     model_loader: str):
            self.model_loader = model_loader
            super().__init__(model_name=model_name, maxlen=maxlen, batch_size=batch_size, device=device,
                             model_sep=model_sep, padding_side=padding_side)
            self.pooling_method = pooling_method
            self.query_prefix = query_prefix
            self.passage_prefix = passage_prefix

        def load_model(self, model_name: str, device: str = "cuda"):
            if self.model_loader == "t5-encoder":
                from transformers import AutoTokenizer, T5EncoderModel

                model = T5EncoderModel.from_pretrained(model_name).to(device)
                tokenizer = AutoTokenizer.from_pretrained(model_name)
                model.eval()
                return model, tokenizer
            return super().load_model(model_name, device)

        def encode_queries(self, queries, pooling_method=None, prefix=None):
            return super().encode_queries(queries,
                pooling_method=pooling_method or self.pooling_method,
                prefix=self.query_prefix if prefix is None else prefix)

        def encode_corpus(self, corpus, pooling_method=None, prefix=None):
            return super().encode_corpus(corpus,
                pooling_method=pooling_method or self.pooling_method,
                prefix=self.passage_prefix if prefix is None else prefix)

    return ConfigurableDenseModel(
        model_name=args.model_id,
        maxlen=args.maxlen,
        batch_size=args.batch_size,
        device=device,
        pooling_method=args.pooling,
        query_prefix=args.query_prefix,
        passage_prefix=args.passage_prefix,
        model_sep=args.model_sep,
        padding_side=args.padding_side,
        model_loader=args.model_loader,
    )


def build_model(args: argparse.Namespace):
    if args.model_type == "dense":
        device = select_device(args.device)
        return build_dense_model(args, device), device

    if args.model_type == "reranker":
        from rusBeIR.beir.reranking import Rerank
        from rusBeIR.beir.reranking.models import CrossEncoder

        device = select_device(args.device)
        cross_encoder = CrossEncoder(args.model_id, device=device)
        return Rerank(cross_encoder, batch_size=args.rerank_batch_size, max_length=args.rerank_max_length), device

    if args.sparse_model == "bm25s":
        from rusBeIR.retrieval.models.sparse.bm25s import BM25s

        return BM25s(method=args.bm25_method, k1=args.bm25_k1, b=args.bm25_b), "cpu"

    if args.sparse_model == "tfidf":
        from rusBeIR.retrieval.models.sparse.tfidf import TfidfSearch

        return TfidfSearch(
            lowercase=not args.no_lowercase,
            ngram_range=(args.tfidf_min_ngram, args.tfidf_max_ngram),
            max_features=args.tfidf_max_features,
        ), "cpu"
    
    raise SystemExit(f"Unknown sparse model: {args.sparse_model}")


def evaluate_dataset(model, model_name: str, dataset: dict[str, Any], k_values: list[int], text_type: str,
                     query_batch_size: int, num_workers: int, raw_results_dir: Path | None,
                     model_type: str, first_stage_results_dir: Path | None,
                     first_stage_model_name: str | None, rerank_top_k: int | None,
                     dense_backend: str, faiss_index_dir: Path | None, rebuild_faiss_index: bool,
                     corpus_batch_size: int, faiss_device: str, faiss_gpu_id: int) -> dict[str, float]:
    
    corpus, queries, qrels = HFDataLoader(hf_repo=dataset["hf_repo"], hf_repo_qrels=dataset["qrels_repo"],
        streaming=False, keep_in_memory=False, text_type=text_type).load(split=dataset["split"])

    if model_type == "reranker":
        if first_stage_results_dir is None or first_stage_model_name is None:
            raise ValueError("Reranker evaluation requires first-stage results.")

        first_stage_path = raw_results_path(first_stage_results_dir, first_stage_model_name, dataset)
        if not first_stage_path.exists():
            raise FileNotFoundError(f"First-stage results not found: {first_stage_path}")

        with first_stage_path.open("r", encoding="utf-8") as file:
            first_stage_results = json.load(file)

        results = model.rerank(corpus, queries, first_stage_results, top_k=rerank_top_k or max(k_values))
    else:
        results = retrieve(model=model, corpus=corpus, queries=queries, top_k=max(k_values), query_batch_size=query_batch_size,
                           num_workers=num_workers, dense_backend=dense_backend, faiss_index_dir=faiss_index_dir, faiss_index_name=f"{model_name}_{dataset['name']}_{dataset['split']}",
                           rebuild_faiss_index=rebuild_faiss_index, corpus_batch_size=corpus_batch_size,
                           faiss_device=faiss_device, faiss_gpu_id=faiss_gpu_id)


    if raw_results_dir is not None:
        raw_results_dir.mkdir(parents=True, exist_ok=True)
        output_path = raw_results_path(raw_results_dir, model_name, dataset)
        with output_path.open("w", encoding="utf-8") as file:
            json.dump(results, file, ensure_ascii=False)

    retriever = EvaluateRetrieval(k_values=k_values)
    ndcg, map_scores, recall, precision = retriever.evaluate(qrels=qrels, results=results, k_values=k_values)
    mrr = retriever.evaluate_custom(qrels, results, k_values, "mrr")
    return merge_metrics(ndcg, map_scores, recall, precision, mrr)

def read_jsonl(path: Path) -> list[dict[str, Any]]:
    if not path.exists():
        return[]
    
    records = []
    with path.open("r", encoding="utf-8") as file:
        for line_n, line in enumerate(file, start=1):
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            try:
                records.append(json.loads(line))
            except json.JSONDecodeError as error:
                raise ValueError(f"Invalid JSON in {path} at line {line_n}: {error}")
    return records


def find_existing_record(path: Path, model_id: str) -> dict[str, Any] | None:
    for record in reversed(read_jsonl(path)):
        if record.get("model_id") == model_id:
            return record
    return None


def insert_jsonl_record(path: Path, record: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    records = [item for item in read_jsonl(path) if item.get("model_id") != record["model_id"]]
    records.append(record)

    tmp_path = path.with_name(f"{path.name}.tmp")
    with tmp_path.open("w", encoding="utf-8") as file:
        for item in records:
            file.write(json.dumps(item, ensure_ascii=False, sort_keys=True))
            file.write("\n")
    tmp_path.replace(path)


def build_result_record(args, record_model_id: str, model_name: str, organization: str,
                        hardware: str, started: float, per_dataset: dict[str, dict[str, float]]) -> dict[str, Any]:
    record = {
        "model_id": record_model_id,
        "model_name": model_name,
        "organization": organization,
        "type": args.model_type,
        "date": date.today().isoformat(),
        "verified": False,
        "hardware": hardware,
        "runtime_seconds": round(time.time() - started, 2),
        "source_url": args.source_url,
        "scores": {
            "average": average_metrics(per_dataset),
            "datasets": per_dataset,
        },
        "notes": args.notes,
    }
    if args.model_type == "reranker":
        record["base_model_id"] = args.first_stage_model_id
        record["rerank_top_k"] = args.rerank_top_k or max(args.k_values)
    return record


def parse_args():
    parser = argparse.ArgumentParser()
    parser.add_argument("--model-id", default="", help="Hugging Face model id for dense and reranker models. Defaults to sparse model name for sparse runs.")
    parser.add_argument("--model-name", default="", help="Display name. Defaults to the last segment of --model-id.")
    parser.add_argument("--model-type", default="dense", choices=["dense", "sparse", "reranker"])
    parser.add_argument("--sparse-model", default="bm25s", choices=["bm25s", "tfidf"], help="Sparse retriever to use when --model-type sparse.")
    parser.add_argument("--first-stage-model-id", "--base-model-id", dest="first_stage_model_id", default="",
                        help="Model id/name whose raw retrieval results will be reranked.")
    parser.add_argument("--first-stage-results-dir", "--base-results-dir", dest="first_stage_results_dir", type=Path,
                        default=None, help="Directory with first-stage raw result JSON files.")
    parser.add_argument("--rerank-top-k", type=int, default=None, help="Number of first-stage hits to rerank. Defaults to max(k-values).")
    parser.add_argument("--rerank-batch-size", type=int, default=32)
    parser.add_argument("--rerank-max-length", type=int, default=512)
    parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT, help="JSONL file to update with the leaderboard row.")
    parser.add_argument("--raw-results-dir", type=Path, default=None, help="Optional directory for raw retrieval results.")
    parser.add_argument("--datasets", nargs="*", default=None, help="Dataset names to evaluate. Defaults to all official datasets.")
    parser.add_argument("--limit-datasets", type=int, default=None, help="Evaluate only the first N selected datasets.")
    parser.add_argument("--text-type", choices=["processed_text", "text"], default="text")
    parser.add_argument("--k-values", nargs="+", type=int, default=[1, 3, 5, 10, 100])
    parser.add_argument("--device", default="auto", help="auto, cpu, cuda, cuda:0, etc.")
    parser.add_argument("--maxlen", type=int, default=512)
    parser.add_argument("--batch-size", type=int, default=128, help="Tokenizer/model batch size.")
    parser.add_argument("--query-batch-size", type=int, default=16, help="Query scoring batch size.")
    parser.add_argument("--num-workers", type=int, default=4)
    parser.add_argument("--pooling", choices=["average", "cls", "pooler", "last_token"], default="average")
    parser.add_argument("--model-loader", choices=["auto", "t5-encoder"], default="auto",
                        help="Model loader for dense encoders. Use t5-encoder for encoder-only T5 embedding models such as FRIDA.")
    parser.add_argument("--query-prefix", default="")
    parser.add_argument("--passage-prefix", default="")
    parser.add_argument("--model-sep", default="[SEP]")
    parser.add_argument("--padding-side", choices=["left", "right"], default=None,
                        help="Tokenizer padding side. Qwen3 Embedding models should use left padding with last_token pooling.")
    parser.add_argument("--dense-backend", choices=["exact", "faiss"], default="faiss")
    parser.add_argument("--faiss-index-dir", type=Path, default=Path("leaderboard/faiss_indexes"))
    parser.add_argument("--rebuild-faiss-index", action="store_true")
    parser.add_argument("--corpus-batch-size", type=int, default=50000)
    parser.add_argument("--faiss-device", choices=["auto", "cpu", "cuda"], default="auto",
                        help="Device for FAISS index/search. auto uses GPU when the installed FAISS build supports it.")
    parser.add_argument("--faiss-gpu-id", type=int, default=0)
    parser.add_argument("--bm25-method", default="lucene")
    parser.add_argument("--bm25-k1", type=float, default=None)
    parser.add_argument("--bm25-b", type=float, default=None)
    parser.add_argument("--tfidf-min-ngram", type=int, default=1)
    parser.add_argument("--tfidf-max-ngram", type=int, default=1)
    parser.add_argument("--tfidf-max-features", type=int, default=None)
    parser.add_argument("--no-lowercase", action="store_true")
    parser.add_argument("--source-url", default="")
    parser.add_argument("--notes", default="")
    parser.add_argument("--resume", action="store_true",
                        help="Reuse existing per-dataset scores for this model_id from --output and skip already computed datasets.")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    started = time.time()
    requested = set(args.datasets or [])
    datasets = [dataset
        for dataset in load_datasets()
        if dataset.get("official", True) and (not requested or dataset["name"] in requested)]
    
    if args.limit_datasets is not None:
        datasets = datasets[: args.limit_datasets]

    if not datasets:
        raise SystemExit("No datasets selected.")

    if args.model_type in {"dense", "reranker"} and not args.model_id:
        raise SystemExit("--model-id is required for dense and reranker models.")

    if args.model_type == "sparse" and not args.model_id:
        args.model_id = args.sparse_model

    first_stage_model_name = None
    if args.model_type == "reranker":
        if not args.first_stage_model_id:
            raise SystemExit("--first-stage-model-id is required for reranker evaluation.")
        if args.first_stage_results_dir is None:
            raise SystemExit("--first-stage-results-dir is required for reranker evaluation.")
        first_stage_model_name = safe_name(args.first_stage_model_id)
        missing_results = [
            raw_results_path(args.first_stage_results_dir, first_stage_model_name, dataset)
            for dataset in datasets
            if not raw_results_path(args.first_stage_results_dir, first_stage_model_name, dataset).exists()
        ]
        if missing_results:
            missing = "\n".join(str(path) for path in missing_results)
            raise SystemExit(f"Missing first-stage result files:\n{missing}")

    record_model_id = args.model_id
    if args.model_type == "reranker":
        record_model_id = f"{args.first_stage_model_id}+{args.model_id}"

    model_name = args.model_name or record_model_id.split("/")[-1]
    organization = args.model_id.split("/", 1)[0] if "/" in args.model_id else ""

    model, hardware = build_model(args)

    per_dataset: dict[str, dict[str, float]] = {}
    if args.resume:
        existing_record = find_existing_record(args.output, record_model_id)
        existing_scores = (existing_record or {}).get("scores", {}).get("datasets", {})
        if isinstance(existing_scores, dict):
            per_dataset.update(existing_scores)

    model_file_name = safe_name(args.model_id)
    if args.model_type == "reranker":
        model_file_name = f"{model_file_name}__rerank_{first_stage_model_name}"

    for dataset in datasets:
        dataset_name = dataset["name"]
        if args.resume and dataset_name in per_dataset:
            print(f"Skipping {dataset_name} ({dataset['split']}): already present in {args.output}", flush=True)
            continue

        print(f"Evaluating {dataset_name} ({dataset['split']})", flush=True)
        per_dataset[dataset_name] = evaluate_dataset(
            model=model,
            model_name=model_file_name,
            dataset=dataset,
            k_values=args.k_values,
            text_type=args.text_type,
            query_batch_size=args.query_batch_size,
            num_workers=args.num_workers,
            raw_results_dir=args.raw_results_dir,
            model_type=args.model_type,
            first_stage_results_dir=args.first_stage_results_dir,
            first_stage_model_name=first_stage_model_name,
            rerank_top_k=args.rerank_top_k,
            dense_backend=args.dense_backend,
            faiss_index_dir=args.faiss_index_dir,
            rebuild_faiss_index=args.rebuild_faiss_index,
            corpus_batch_size=args.corpus_batch_size,
            faiss_device=args.faiss_device,
            faiss_gpu_id=args.faiss_gpu_id,
        )
        checkpoint = build_result_record(args, record_model_id, model_name, organization, hardware, started, per_dataset)
        insert_jsonl_record(args.output, checkpoint)
        print(f"Saved checkpoint for {dataset_name} to {args.output}", flush=True)

    record = build_result_record(args, record_model_id, model_name, organization, hardware, started, per_dataset)
    insert_jsonl_record(args.output, record)
    print(json.dumps(record, ensure_ascii=False, indent=2), flush=True)


if __name__ == "__main__":
    main()