Files
Mini-Nav/scripts/prepare_cam_retrieval_dataset.py
SikongJueluo b5a40819cc feat: vectorize CAM retrieval with NumPy and add multi-worker support
- Replace scalar hamming distance with NumPy bitwise_count for batch retrieval
- Add ThreadPoolExecutor-based multi-worker query parallelism
- Improve missing dataset error message with generation command hint
- Increase DEFAULT_MAX_QUERIES from 128 to 8192 for meaningful throughput tests
2026-06-07 20:45:20 +08:00

236 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")
DEFAULT_MAX_QUERIES = 8192
@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(DEFAULT_MAX_QUERIES, 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)