| """Per-dataset + combined re-ID evaluation and intra-subject similarity report. |
| |
| Two things, over the loaded (known+unknown) dataset pairs, using embeddings already in the DB: |
| |
| 1. INTRA-SUBJECT SIMILARITY — for every dog, cosine between all pairs of its OWN photos |
| (min / max / avg). Answers "do photos of the same dog read as wildly different?". A random |
| different-dog baseline is sampled per dataset for context (intra should sit well above it). |
| |
| 2. RETRIEVAL / MATCHING QUALITY — for each found (unknown) dog, rank it against the gallery of |
| known dogs with the production dog-level score (max cosine over photo pairs) and report |
| recall@1/5/10 (+ counts), mean/median rank, MRR, and true-vs-best-wrong score separation. |
| Evaluated for EACH dataset separately and then COMBINED (one shared gallery — the other |
| dataset's dogs act as extra distractors). Identity is namespaced per source so colliding folder |
| numbers across datasets (both have "Dog0") never cross-match. |
| |
| This measures the MODEL (pure embedding retrieval over the full gallery). It intentionally does NOT |
| apply the production ZIP-radius / breed gates — on this synthetic data each dog's found+known share |
| a ZIP, so a ZIP gate would inflate recall as an artifact rather than reflect the model. |
| |
| Dataset pairs are auto-detected by name: "<base> known" + "<base> unknown". Reuses embeddings for |
| the most-embedded model unless --model is given. |
| |
| Usage (from backend/): |
| python -m scripts.eval_datasets |
| python -m scripts.eval_datasets --model hf-embed --out-dir eval_out --seed 0 |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import csv |
| from pathlib import Path |
|
|
| import numpy as np |
| from sqlalchemy import func, select |
|
|
| from app.db import SessionLocal |
| from app.models import Dataset, Embedding, KnownDog, Picture, UnknownDog |
| from app.models.base import SubjectType |
|
|
|
|
| |
| def _known_folder(name: str | None) -> str | None: |
| return name[3:] if name and name.startswith("Dog") else None |
|
|
|
|
| def _found_folder(desc: str | None) -> str | None: |
| if desc and desc.lower().startswith("test found dog "): |
| return desc.split()[-1] |
| return None |
|
|
|
|
| def _base_name(name: str) -> tuple[str, str] | None: |
| """('Foo known'|'Foo unknown') -> ('Foo', 'known'|'unknown'); else None.""" |
| low = name.lower() |
| for suffix, kind in ((" known", "known"), (" unknown", "unknown")): |
| if low.endswith(suffix): |
| return name[: -len(suffix)].strip(), kind |
| return None |
|
|
|
|
| def _pick_model(db, override: str | None) -> tuple[str, str]: |
| rows = db.execute( |
| select(Embedding.model_name, Embedding.model_version, func.count()) |
| .group_by(Embedding.model_name, Embedding.model_version) |
| .order_by(func.count().desc()) |
| ).all() |
| if not rows: |
| raise SystemExit("No embeddings in the DB.") |
| if override: |
| for mn, mv, _ in rows: |
| if override in mn: |
| return mn, mv |
| raise SystemExit(f"No embeddings match --model {override!r}. Available: {[r[0] for r in rows]}") |
| return rows[0][0], rows[0][1] |
|
|
|
|
| def _vectors_for_dataset(db, subject_type, dataset_id, model_name, model_version): |
| """{subject_id: (n_photos x dim) float32} for one dataset's dogs (active model only).""" |
| subj = KnownDog if subject_type == SubjectType.known else UnknownDog |
| ids = [i for (i,) in db.execute(select(subj.id).where(subj.dataset_id == dataset_id))] |
| if not ids: |
| return {} |
| rows = db.execute( |
| select(Picture.subject_id, Embedding.vector) |
| .join(Picture, Embedding.picture_id == Picture.id) |
| .where( |
| Picture.subject_type == subject_type, |
| Picture.subject_id.in_(ids), |
| Embedding.model_name == model_name, |
| Embedding.model_version == model_version, |
| ) |
| ).all() |
| out: dict[int, list[np.ndarray]] = {} |
| for sid, blob in rows: |
| out.setdefault(sid, []).append(np.frombuffer(blob, dtype=np.float32)) |
| return {sid: np.vstack(v).astype(np.float32) for sid, v in out.items()} |
|
|
|
|
| |
| def _intra_stats(mat: np.ndarray) -> tuple[float, float, float] | None: |
| """min/max/avg cosine over all distinct photo pairs of one dog (vectors are L2-normalized).""" |
| n = mat.shape[0] |
| if n < 2: |
| return None |
| sims = mat @ mat.T |
| iu = np.triu_indices(n, k=1) |
| pair = sims[iu] |
| return float(pair.min()), float(pair.max()), float(pair.mean()) |
|
|
|
|
| |
| def _eval_retrieval(gallery: dict, queries: dict): |
| """gallery/queries: {identity_key: [(dog_label, mat), ...]}. Returns metrics dict. |
| |
| Each query dog is scored against every gallery dog (dog-level max cosine over photo pairs); |
| rank of the query's own identity is recorded. Gallery may hold several dogs per identity |
| (shouldn't here) — any same-identity dog counts as correct. |
| """ |
| gal_rows, gal_owner, owner_identity = [], [], [] |
| for ident, dogs in gallery.items(): |
| for _label, mat in dogs: |
| oidx = len(owner_identity) |
| owner_identity.append(ident) |
| for v in mat: |
| gal_rows.append(v) |
| gal_owner.append(oidx) |
| if not gal_rows: |
| return None |
| G = np.vstack(gal_rows).astype(np.float32) |
| gal_owner = np.asarray(gal_owner) |
| n_owners = len(owner_identity) |
| owner_ident_arr = np.asarray(owner_identity, dtype=object) |
|
|
| ranks, true_scores, best_wrong, skipped = [], [], [], 0 |
| gallery_idents = set(owner_identity) |
| for ident, dogs in queries.items(): |
| if ident not in gallery_idents: |
| skipped += 1 |
| continue |
| for _label, q in dogs: |
| sims = q @ G.T |
| per_gal = sims.max(axis=0) |
| dog_scores = np.full(n_owners, -1.0, dtype=np.float32) |
| np.maximum.at(dog_scores, gal_owner, per_gal) |
| order = np.argsort(-dog_scores) |
| ordered_idents = owner_ident_arr[order] |
| correct_mask = ordered_idents == ident |
| rank = int(np.argmax(correct_mask)) + 1 |
| ranks.append(rank) |
| true_scores.append(float(dog_scores[order[rank - 1]])) |
| wrong = dog_scores[order][~correct_mask] |
| best_wrong.append(float(wrong.max()) if wrong.size else -1.0) |
|
|
| if not ranks: |
| return None |
| r = np.asarray(ranks) |
| ts = np.asarray(true_scores) |
| bw = np.asarray(best_wrong) |
| n = len(r) |
| return { |
| "n_queries": n, |
| "skipped_no_gallery": skipped, |
| "n_gallery_dogs": n_owners, |
| "recall@1": int(np.sum(r <= 1)), |
| "recall@5": int(np.sum(r <= 5)), |
| "recall@10": int(np.sum(r <= 10)), |
| "mean_rank": float(r.mean()), |
| "median_rank": float(np.median(r)), |
| "mrr": float(np.mean(1.0 / r)), |
| "true_mean": float(ts.mean()), |
| "true_min": float(ts.min()), |
| "bestwrong_mean": float(bw.mean()), |
| "margin_mean": float(np.mean(ts - bw)), |
| "pct_margin_pos": float(np.mean(ts > bw) * 100), |
| } |
|
|
|
|
| def _print_retrieval(title: str, m: dict) -> None: |
| n = m["n_queries"] |
| print(f"\n### {title}") |
| print(f" queries: {n} found dogs | gallery: {m['n_gallery_dogs']} known dogs" |
| f" | skipped (no counterpart): {m['skipped_no_gallery']}") |
| for k in (1, 5, 10): |
| c = m[f"recall@{k}"] |
| print(f" recall@{k:<2} {c/n*100:6.2f}% ({c}/{n})") |
| print(f" mean rank {m['mean_rank']:.2f} median {m['median_rank']:.0f} MRR {m['mrr']:.4f}") |
| print(f" score: true mean {m['true_mean']:.4f} (min {m['true_min']:.4f}) " |
| f"best-wrong mean {m['bestwrong_mean']:.4f} " |
| f"margin {m['margin_mean']:+.4f} (true>wrong {m['pct_margin_pos']:.1f}%)") |
|
|
|
|
| def run(model_override: str | None, out_dir: str, seed: int) -> None: |
| db = SessionLocal() |
| rng = np.random.default_rng(seed) |
| try: |
| model_name, model_version = _pick_model(db, model_override) |
| print(f"Model: {model_name}/{model_version}") |
|
|
| |
| pairs: dict[str, dict[str, int]] = {} |
| for d in db.execute(select(Dataset)).scalars(): |
| parsed = _base_name(d.name) |
| if parsed: |
| base, kind = parsed |
| pairs.setdefault(base, {})[kind] = d.id |
| pairs = {b: v for b, v in pairs.items() if "known" in v and "unknown" in v} |
| if not pairs: |
| raise SystemExit("No '<base> known' + '<base> unknown' dataset pairs found.") |
| print("Dataset pairs:", ", ".join(f"{b} (known #{v['known']}, unknown #{v['unknown']})" |
| for b, v in pairs.items())) |
|
|
| out = Path(out_dir) |
| out.mkdir(parents=True, exist_ok=True) |
|
|
| |
| |
| per_pair_gallery: dict[str, dict] = {} |
| per_pair_query: dict[str, dict] = {} |
| intra_rows: list[dict] = [] |
| inter_baseline: dict[str, float] = {} |
|
|
| for base, v in pairs.items(): |
| kv = _vectors_for_dataset(db, SubjectType.known, v["known"], model_name, model_version) |
| uv = _vectors_for_dataset(db, SubjectType.unknown, v["unknown"], model_name, model_version) |
| kname = dict(db.execute(select(KnownDog.id, KnownDog.name).where(KnownDog.dataset_id == v["known"])).all()) |
| udesc = dict(db.execute(select(UnknownDog.id, UnknownDog.description).where(UnknownDog.dataset_id == v["unknown"])).all()) |
|
|
| gal: dict = {} |
| for sid, mat in kv.items(): |
| folder = _known_folder(kname.get(sid)) |
| if folder is None: |
| continue |
| gal.setdefault(f"{base}:{folder}", []).append((f"{base}/Dog{folder}", mat)) |
| qry: dict = {} |
| for sid, mat in uv.items(): |
| folder = _found_folder(udesc.get(sid)) |
| if folder is None: |
| continue |
| qry.setdefault(f"{base}:{folder}", []).append((f"{base}/found{folder}", mat)) |
| per_pair_gallery[base] = gal |
| per_pair_query[base] = qry |
|
|
| |
| for kind, vecs, names in (("known", kv, kname), ("unknown", uv, udesc)): |
| for sid, mat in vecs.items(): |
| st = _intra_stats(mat) |
| intra_rows.append({ |
| "dataset": base, "kind": kind, "dog_id": sid, |
| "identity": names.get(sid), "n_photos": int(mat.shape[0]), |
| "min": None if st is None else round(st[0], 4), |
| "max": None if st is None else round(st[1], 4), |
| "avg": None if st is None else round(st[2], 4), |
| }) |
|
|
| |
| all_photos, all_owner = [], [] |
| for oi, (sid, mat) in enumerate(kv.items()): |
| for row in mat: |
| all_photos.append(row) |
| all_owner.append(oi) |
| if len(all_photos) > 2: |
| P = np.vstack(all_photos).astype(np.float32) |
| own = np.asarray(all_owner) |
| sample = min(5000, len(P) * 4) |
| ia = rng.integers(0, len(P), sample) |
| ib = rng.integers(0, len(P), sample) |
| diff = own[ia] != own[ib] |
| if diff.any(): |
| cos = np.sum(P[ia[diff]] * P[ib[diff]], axis=1) |
| inter_baseline[base] = float(cos.mean()) |
|
|
| |
| intra_csv = out / "intra_subject_similarity.csv" |
| with intra_csv.open("w", newline="", encoding="utf-8") as fh: |
| w = csv.DictWriter(fh, fieldnames=["dataset", "kind", "dog_id", "identity", |
| "n_photos", "min", "max", "avg"]) |
| w.writeheader() |
| w.writerows(intra_rows) |
|
|
| |
| print("\n" + "=" * 70) |
| print("INTRA-SUBJECT SIMILARITY (same dog, photo-to-photo cosine)") |
| print("=" * 70) |
| for base in pairs: |
| rows = [r for r in intra_rows if r["dataset"] == base and r["avg"] is not None] |
| singles = sum(1 for r in intra_rows if r["dataset"] == base and r["avg"] is None) |
| if not rows: |
| continue |
| avgs = np.array([r["avg"] for r in rows]) |
| mins = np.array([r["min"] for r in rows]) |
| print(f"\n### {base} ({len(rows)} dogs with >=2 photos, {singles} single-photo skipped)") |
| print(f" per-dog AVG pairwise sim : mean {avgs.mean():.4f} " |
| f"p10 {np.percentile(avgs,10):.4f} min {avgs.min():.4f}") |
| print(f" per-dog MIN pairwise sim : mean {mins.mean():.4f} " |
| f"p10 {np.percentile(mins,10):.4f} min {mins.min():.4f}") |
| for thr in (0.5, 0.7): |
| lo = int(np.sum(mins < thr)) |
| print(f" dogs with a photo-pair < {thr}: {lo} ({lo/len(rows)*100:.1f}%) " |
| f"(their photos read quite differently)") |
| if base in inter_baseline: |
| print(f" different-dog baseline (sampled): {inter_baseline[base]:.4f} " |
| f"<- intra AVG should sit well above this") |
|
|
| |
| print("\n" + "=" * 70) |
| print("RETRIEVAL / MATCHING QUALITY (found dog -> ranked known gallery)") |
| print("=" * 70) |
| summary_rows = [] |
| for base in pairs: |
| m = _eval_retrieval(per_pair_gallery[base], per_pair_query[base]) |
| if m: |
| _print_retrieval(base, m) |
| summary_rows.append({"scope": base, **m}) |
|
|
| combined_gal: dict = {} |
| combined_qry: dict = {} |
| for base in pairs: |
| for k, v in per_pair_gallery[base].items(): |
| combined_gal.setdefault(k, []).extend(v) |
| for k, v in per_pair_query[base].items(): |
| combined_qry.setdefault(k, []).extend(v) |
| mc = _eval_retrieval(combined_gal, combined_qry) |
| if mc: |
| _print_retrieval("COMBINED (both datasets, shared gallery)", mc) |
| summary_rows.append({"scope": "COMBINED", **mc}) |
|
|
| |
| ret_csv = out / "retrieval_summary.csv" |
| if summary_rows: |
| with ret_csv.open("w", newline="", encoding="utf-8") as fh: |
| w = csv.DictWriter(fh, fieldnames=list(summary_rows[0].keys())) |
| w.writeheader() |
| w.writerows(summary_rows) |
|
|
| print(f"\nWrote per-dog stats -> {intra_csv}") |
| print(f"Wrote retrieval table -> {ret_csv}") |
| finally: |
| db.close() |
|
|
|
|
| def main() -> None: |
| ap = argparse.ArgumentParser(description="Per-dataset + combined re-ID & intra-subject metrics.") |
| ap.add_argument("--model", default=None, help="Embedding model name substring (default: most-embedded)") |
| ap.add_argument("--out-dir", default="eval_out", help="Directory for CSV outputs") |
| ap.add_argument("--seed", type=int, default=0, help="RNG seed for the different-dog baseline sample") |
| args = ap.parse_args() |
| run(args.model, args.out_dir, args.seed) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|