Files
Mini-Nav/scripts/prepare_cam_retrieval_dataset.py
SikongJueluo 1ff9a5f18b feat(retrieval-benchmark): add support for external pre-prepared CAM retrieval datasets with recall@k metric
- Add just recipes for preparing CIFAR10/100 hash artifacts and running benchmarks
- Add CAM_RETRIEVAL_DATASET env var support in Makefile
- Add load_retrieval_dataset_npz() to load pre-prepared retrieval datasets
- Add label_hits counter and recall@k metric for retrieval evaluation
- Rename macro_recall to retrieval_recall to clarify semantics
2026-05-22 21:07:10 +08:00

235 lines
7.8 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import json
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
import numpy as np
import torch
import typer
from datasets import load_dataset
from rich.progress import track
from torch.utils.data import DataLoader
PROJECT_ROOT = Path(__file__).resolve().parents[1]
MINI_NAV_ROOT = PROJECT_ROOT / "mini-nav"
if str(MINI_NAV_ROOT) not in sys.path:
sys.path.insert(0, str(MINI_NAV_ROOT))
from compressors.model_loader import get_dino_dim, load_dino_model, load_hash_compressor # noqa: E402
from utils import get_device # noqa: E402
DEFAULT_COMPRESSOR_PATH = Path("outputs/hash_compressor.pt")
DEFAULT_OUTPUT_ROOT = Path("outputs/cam_retrieval_benchmark/datasets")
@dataclass(frozen=True)
class PreparedArtifact:
npz_path: Path
metadata_path: Path
num_rows: int
num_queries: int
def dataset_config(dataset: str) -> tuple[str, str]:
if dataset == "cifar10":
return "uoft-cs/cifar10", "label"
if dataset == "cifar100":
return "uoft-cs/cifar100", "fine_label"
raise ValueError("dataset must be cifar10 or cifar100")
def stratified_indices(labels: list[int], *, total: int, seed: int) -> list[int]:
if total <= 0:
raise ValueError("total must be greater than 0")
if total > len(labels):
raise ValueError("total cannot exceed number of labels")
rng = np.random.default_rng(seed)
by_label: dict[int, list[int]] = {}
for idx, label in enumerate(labels):
by_label.setdefault(int(label), []).append(idx)
label_ids = sorted(by_label)
base = total // len(label_ids)
remainder = total % len(label_ids)
selected: list[int] = []
for offset, label in enumerate(label_ids):
want = base + (1 if offset < remainder else 0)
candidates = list(by_label[label])
if want > len(candidates):
raise ValueError(f"not enough examples for label {label}: need {want}, have {len(candidates)}")
chosen = rng.choice(candidates, size=want, replace=False).tolist()
selected.extend(int(i) for i in chosen)
selected.sort()
return selected
def pack_bits_to_words_le(bits: np.ndarray, *, hash_bits: int) -> np.ndarray:
if bits.ndim != 2:
raise ValueError("bits must have shape [N, hash_bits]")
if bits.shape[1] != hash_bits:
raise ValueError(f"expected {hash_bits} bits, got {bits.shape[1]}")
if hash_bits % 64 != 0:
raise ValueError("hash_bits must be divisible by 64")
words = np.zeros((bits.shape[0], hash_bits // 64), dtype=np.uint64)
bits_u8 = bits.astype(np.uint8, copy=False)
for word_idx in range(hash_bits // 64):
word = np.zeros(bits.shape[0], dtype=np.uint64)
for bit in range(64):
word |= bits_u8[:, word_idx * 64 + bit].astype(np.uint64) << np.uint64(bit)
words[:, word_idx] = word
return words
def words_le_to_int(words: np.ndarray) -> int:
value = 0
for idx, word in enumerate(words.tolist()):
value |= int(word) << (64 * idx)
return value
@torch.no_grad()
def encode_dataset_bits(
dataset,
*,
img_column: str,
batch_size: int,
dino_model: str,
compressor_path: Path,
hash_bits: int,
) -> np.ndarray:
if not compressor_path.exists():
raise FileNotFoundError(f"missing HashCompressor weights: {compressor_path}")
device = get_device()
processor, dino = load_dino_model(dino_model)
compressor = load_hash_compressor(
input_dim=get_dino_dim(dino_model),
hash_bits=hash_bits,
compressor_path=str(compressor_path),
).to(device)
compressor.eval()
loader = DataLoader(dataset.with_format("torch"), batch_size=batch_size, shuffle=False, num_workers=4)
all_bits: list[np.ndarray] = []
for batch in track(loader, description="Encoding CIFAR hash bits"):
images = batch[img_column]
inputs = processor(images=images, return_tensors="pt").to(device)
tokens = dino(**inputs).last_hidden_state
bits = compressor.encode(tokens).detach().cpu().numpy().astype(np.uint8)
all_bits.append(bits)
return np.concatenate(all_bits, axis=0)
def prepare_artifact(
*,
dataset_name: Literal["cifar10", "cifar100"],
num_rows: int,
max_queries: int,
compressor_path: Path,
output_root: Path,
dino_model: str = "facebook/dinov2-large",
hash_bits: int = 512,
batch_size: int = 64,
seed: int = 20260522,
) -> PreparedArtifact:
hf_id, label_column = dataset_config(dataset_name)
loaded = load_dataset(hf_id)
train = loaded["train"]
test = loaded["test"] if "test" in loaded else loaded["validation"]
row_indices = stratified_indices([int(x) for x in train[label_column]], total=num_rows, seed=seed)
query_indices = stratified_indices([int(x) for x in test[label_column]], total=max_queries, seed=seed + 1)
row_subset = train.select(row_indices)
query_subset = test.select(query_indices)
row_bits = encode_dataset_bits(
row_subset,
img_column="img",
batch_size=batch_size,
dino_model=dino_model,
compressor_path=compressor_path,
hash_bits=hash_bits,
)
query_bits = encode_dataset_bits(
query_subset,
img_column="img",
batch_size=batch_size,
dino_model=dino_model,
compressor_path=compressor_path,
hash_bits=hash_bits,
)
rows_words = pack_bits_to_words_le(row_bits, hash_bits=hash_bits)
queries_words = pack_bits_to_words_le(query_bits, hash_bits=hash_bits)
row_labels = np.array([int(x) for x in row_subset[label_column]], dtype=np.int64)
query_labels = np.array([int(x) for x in query_subset[label_column]], dtype=np.int64)
output_root.mkdir(parents=True, exist_ok=True)
stem = f"{dataset_name}_hash{hash_bits}_rows{num_rows}_queries{max_queries}"
npz_path = output_root / f"{stem}.npz"
metadata_path = output_root / f"{stem}.json"
np.savez_compressed(
npz_path,
rows_words=rows_words,
row_labels=row_labels,
queries_words=queries_words,
query_labels=query_labels,
)
metadata = {
"dataset": dataset_name,
"hf_id": hf_id,
"label_column": label_column,
"compressor_path": str(compressor_path),
"dino_model": dino_model,
"hash_bits": hash_bits,
"bit_source": "HashCompressor.encode(tokens)",
"bit_values": "{0,1} int32",
"num_rows": int(num_rows),
"num_queries": int(max_queries),
"rows_source_split": "train",
"queries_source_split": "test",
"seed": int(seed),
}
metadata_path.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return PreparedArtifact(npz_path=npz_path, metadata_path=metadata_path, num_rows=num_rows, num_queries=max_queries)
def main(
dataset: Literal["cifar10", "cifar100"] = typer.Option("cifar100"),
num_rows: int = typer.Option(512, min=5),
max_queries: int = typer.Option(128, min=1),
compressor_path: Path = typer.Option(DEFAULT_COMPRESSOR_PATH),
output_root: Path = typer.Option(DEFAULT_OUTPUT_ROOT),
dino_model: str = typer.Option("facebook/dinov2-large"),
hash_bits: int = typer.Option(512),
batch_size: int = typer.Option(64, min=1),
seed: int = typer.Option(20260522),
) -> None:
artifact = prepare_artifact(
dataset_name=dataset,
num_rows=num_rows,
max_queries=max_queries,
compressor_path=compressor_path,
output_root=output_root,
dino_model=dino_model,
hash_bits=hash_bits,
batch_size=batch_size,
seed=seed,
)
print(f"CAM_RETRIEVAL_DATASET={artifact.npz_path}")
print(f"CAM_RETRIEVAL_METADATA={artifact.metadata_path}")
if __name__ == "__main__":
typer.run(main)