panagiotis427's picture
Deploy current UI (code + fixture + figures; gradio 6.18)
b4e56df verified
Raw
History Blame Contribute Delete
6.64 kB
"""
Post-retrieval re-ranking using spatial and temporal coherence.
After :meth:`ChangeRetriever.score_all` returns per-pair cosine scores, a
:class:`Reranker` can optionally reorder the top-K results using two
strategies:
* ``diversity`` — greedy location-deduplication: prefers showing results from
different AOIs before returning to the same location. Improves result
coverage without any geographic model.
* ``coherence`` — geographic clustering: boosts pairs whose AOI centroid is
close to the top-1 result's location (haversine distance). Useful for
spatially coherent queries (e.g. "urban expansion in a specific city").
Both strategies are toggleable via the Gradio UI and the CLI ``--rerank``
flag; passing ``strategy=None`` disables re-ranking entirely.
"""
from __future__ import annotations
import json
from math import asin, cos, radians, sin, sqrt
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import numpy as np
from src.datasets.base import PairKey
RERANK_STRATEGIES = ("diversity", "coherence")
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6371.0
dlat = radians(lat2 - lat1)
dlon = radians(lon2 - lon1)
a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2
return 2 * R * asin(sqrt(max(0.0, min(1.0, a))))
class Reranker:
"""Re-rank retrieval results using spatial coherence or diversity.
Parameters
----------
metadata_path:
Path to ``aoi_metadata.json``. Each key is a ``location_id`` with at
least ``lat_c`` and ``lon_c`` fields.
"""
def __init__(self, metadata_path: str | Path) -> None:
with open(metadata_path) as fh:
self._meta: Dict[str, dict] = json.load(fh)
# ------------------------------------------------------------------
def _centroid(self, location_id: str) -> Optional[Tuple[float, float]]:
m = self._meta.get(location_id)
if m is None:
return None
lat, lon = m.get("lat_c"), m.get("lon_c")
if lat is None or lon is None:
return None
return float(lat), float(lon)
# ------------------------------------------------------------------
def rerank(
self,
scores: np.ndarray,
pairs: List[PairKey],
top_k: int,
strategy: str = "diversity",
geo_weight: float = 0.3,
) -> np.ndarray:
"""Return an array of *top_k* indices into *pairs*, re-ranked.
Parameters
----------
scores:
Per-pair retrieval scores (higher = better). May contain ``-inf``
for pairs masked by a geographic filter.
pairs:
Ordered list of :class:`PairKey` aligned with *scores*.
top_k:
Number of results to return.
strategy:
``"diversity"`` or ``"coherence"``.
geo_weight:
Weight of the geographic term in ``"coherence"`` mode (0–1).
Returns
-------
np.ndarray
Integer indices into *pairs*, length ≤ *top_k*.
"""
if strategy == "diversity":
return self._diversity(scores, pairs, top_k)
if strategy == "coherence":
return self._coherence(scores, pairs, top_k, geo_weight)
raise ValueError(f"Unknown rerank strategy {strategy!r}; use one of {RERANK_STRATEGIES}")
# ------------------------------------------------------------------
def _diversity(
self, scores: np.ndarray, pairs: List[PairKey], top_k: int
) -> np.ndarray:
"""Greedy location-diversity re-ranking.
Iterates pairs in descending score order. Each new unique location is
preferred over a repeat; repeats are deferred to fill remaining slots.
"""
order = list(np.argsort(-scores, kind="stable"))
seen_locs: set = set()
result: List[int] = []
deferred: List[int] = []
for i in order:
if not np.isfinite(scores[i]):
continue
loc = pairs[i].location_id
if loc not in seen_locs:
result.append(i)
seen_locs.add(loc)
else:
deferred.append(i)
if len(result) >= top_k:
break
for i in deferred:
if len(result) >= top_k:
break
result.append(i)
return np.array(result[:top_k], dtype=int)
def _coherence(
self,
scores: np.ndarray,
pairs: List[PairKey],
top_k: int,
geo_weight: float,
) -> np.ndarray:
"""Geographic-coherence re-ranking.
Boosts pairs geographically close to the top-1 result's centroid.
Normalized cosine score and proximity are combined linearly:
``combined = (1 - w) * norm_score + w * proximity``
"""
finite_mask = np.isfinite(scores)
if not finite_mask.any():
return np.array([], dtype=int)
# Anchor = highest-scoring finite pair
masked = np.where(finite_mask, scores, -np.inf)
top1_idx = int(np.argmax(masked))
anchor = self._centroid(pairs[top1_idx].location_id)
if anchor is None:
# No metadata for top-1 → fall back to default ordering
return np.argsort(-scores, kind="stable")[:top_k]
a_lat, a_lon = anchor
max_dist_km = 5_000.0 # normalise proximity over half the globe
prox = np.zeros(len(pairs), dtype=np.float32)
for i, p in enumerate(pairs):
if not finite_mask[i]:
continue
c = self._centroid(p.location_id)
if c is None:
prox[i] = 0.0
else:
dist = _haversine_km(a_lat, a_lon, c[0], c[1])
prox[i] = 1.0 - min(dist / max_dist_km, 1.0)
# Normalise finite cosine scores to [0, 1]
finite_scores = scores[finite_mask]
s_min, s_max = float(finite_scores.min()), float(finite_scores.max())
span = s_max - s_min if s_max > s_min else 1.0
norm_scores = np.where(finite_mask, (scores - s_min) / span, 0.0)
combined = (1.0 - geo_weight) * norm_scores + geo_weight * prox
# Mask out -inf pairs so they never appear
combined = np.where(finite_mask, combined, -np.inf)
return np.argsort(-combined, kind="stable")[:top_k]