150 lines
5.7 KiB
Python
150 lines
5.7 KiB
Python
from __future__ import annotations
|
|
|
|
"""Weighted InfoNCE loss for GTA-UAV cross-view geo-localization.
|
|
|
|
Adapted from Game4Loc (https://github.com/Yux1angJi/GTA-UAV).
|
|
Uses per-sample label smoothing based on positive_weights (IoU/distance)
|
|
to handle partial overlap between drone and satellite crops.
|
|
|
|
Standard InfoNCE assumes strict 1-to-1 pairs and treats all non-diagonal
|
|
entries as negatives. In GTA-UAV, multiple satellite crops can validly
|
|
match one drone image (partial IoU overlap), causing false negatives.
|
|
WeightedInfoNCE softens this with adaptive label smoothing per sample.
|
|
"""
|
|
|
|
import math
|
|
|
|
import torch
|
|
import torch.nn as nn
|
|
import torch.nn.functional as F
|
|
|
|
|
|
class WeightedInfoNCELoss(nn.Module):
|
|
"""Weighted InfoNCE with adaptive per-sample label smoothing.
|
|
|
|
+ Note: NOT @gin.configurable. All parameters arrive explicitly from
|
|
+ train() via TrainingConfig (loss_type='weighted' branch).
|
|
|
|
For each sample i, eps_i = 1 - (1 - base_smoothing) / (1 + exp(-k * w_i))
|
|
where w_i is the positive weight (e.g. IoU with matched satellite crop).
|
|
Higher weight → lower eps → sharper target (strong positive).
|
|
Lower weight → higher eps → softer target (weak/semi-positive).
|
|
|
|
Args:
|
|
temperature_init: Initial temperature (or learnable logit_scale).
|
|
learnable_temperature: If True, temperature is learnable (CLIP-style).
|
|
label_smoothing: Base label smoothing (used when no weights provided).
|
|
k: Sigmoid steepness for weight → eps mapping.
|
|
tau_min: Min clamp for learnable temperature.
|
|
tau_max: Max clamp for learnable temperature.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
temperature_init: float = 0.07,
|
|
learnable_temperature: bool = True,
|
|
label_smoothing: float = 0.1,
|
|
k: float = 5.0,
|
|
tau_min: float = 0.01,
|
|
tau_max: float = 0.1,
|
|
) -> None:
|
|
super().__init__()
|
|
self.label_smoothing = label_smoothing
|
|
self.k = k
|
|
self.tau_min = tau_min
|
|
self.tau_max = tau_max
|
|
self.learnable_temperature = learnable_temperature
|
|
|
|
if learnable_temperature:
|
|
self.logit_scale = nn.Parameter(
|
|
torch.tensor(math.log(1.0 / temperature_init))
|
|
)
|
|
else:
|
|
self.logit_scale = None
|
|
self.temperature = temperature_init
|
|
|
|
@property
|
|
def current_temperature(self) -> float:
|
|
if self.logit_scale is not None:
|
|
tau = 1.0 / self.logit_scale.exp().clamp(
|
|
min=1.0 / self.tau_max, max=1.0 / self.tau_min,
|
|
).item()
|
|
return tau
|
|
return self.temperature
|
|
|
|
def _compute_eps(self, positive_weights: torch.Tensor | None, n: int) -> torch.Tensor | list[float]:
|
|
"""Compute per-sample label smoothing from positive weights."""
|
|
if positive_weights is not None:
|
|
# Higher weight → lower eps (sharper, stronger positive).
|
|
return 1.0 - (1.0 - self.label_smoothing) / (1.0 + torch.exp(-self.k * positive_weights))
|
|
return [self.label_smoothing] * n
|
|
|
|
def _weighted_loss(
|
|
self,
|
|
sim_matrix: torch.Tensor,
|
|
eps_all: torch.Tensor | list[float],
|
|
) -> torch.Tensor:
|
|
"""Weighted InfoNCE: per-sample interpolation between hard and uniform targets.
|
|
|
|
For each row i:
|
|
L_i = (1-eps_i) * [-sim[i,i] + logsumexp(sim[i,:])]
|
|
+ eps_i * [-mean(sim[i,:]) + logsumexp(sim[i,:])]
|
|
"""
|
|
n = sim_matrix.shape[0]
|
|
total_loss = torch.tensor(0.0, device=sim_matrix.device)
|
|
for i in range(n):
|
|
eps = eps_all[i] if isinstance(eps_all, list) else eps_all[i]
|
|
logsumexp = torch.logsumexp(sim_matrix[i, :], dim=0)
|
|
total_loss += (1 - eps) * (-sim_matrix[i, i] + logsumexp)
|
|
total_loss += eps * (-sim_matrix[i, :].mean() + logsumexp)
|
|
return total_loss / n
|
|
|
|
def forward(
|
|
self,
|
|
embeddings: dict[str, torch.Tensor],
|
|
epoch: int = 0,
|
|
total_epochs: int = 1,
|
|
positive_weights: torch.Tensor | None = None,
|
|
queue_negatives: torch.Tensor | None = None,
|
|
) -> dict[str, torch.Tensor]:
|
|
"""Compute weighted InfoNCE loss.
|
|
|
|
Args:
|
|
embeddings: Dict with 'query' [B,D], 'gallery' [B,D], 'gate_q', 'gate_g'.
|
|
positive_weights: Per-sample weight [B] (e.g. IoU with matched sat crop).
|
|
queue_negatives: Extra negatives [Q,D] from memory bank (not used with weighted loss).
|
|
"""
|
|
query = embeddings["query"].float()
|
|
gallery = embeddings["gallery"].float()
|
|
|
|
# Temperature.
|
|
if self.learnable_temperature:
|
|
clamped = self.logit_scale.float().clamp(
|
|
min=math.log(1.0 / self.tau_max),
|
|
max=math.log(1.0 / self.tau_min),
|
|
)
|
|
logit_scale = clamped.exp()
|
|
tau = 1.0 / logit_scale
|
|
else:
|
|
logit_scale = 1.0 / self.temperature
|
|
tau = self.temperature
|
|
|
|
sim_q2g = logit_scale * query @ gallery.t()
|
|
sim_g2q = sim_q2g.t()
|
|
|
|
eps = self._compute_eps(positive_weights, query.shape[0])
|
|
|
|
loss_q2g = self._weighted_loss(sim_q2g, eps)
|
|
loss_g2q = self._weighted_loss(sim_g2q, eps)
|
|
total = (loss_q2g + loss_g2q) / 2
|
|
|
|
gate_q = embeddings.get("gate_q", 1.0)
|
|
gate_g = embeddings.get("gate_g", 1.0)
|
|
|
|
return {
|
|
"total": total,
|
|
"temperature": tau if isinstance(tau, torch.Tensor) else torch.tensor(tau, device=total.device),
|
|
"gate_q": torch.tensor(gate_q, device=total.device) if not isinstance(gate_q, torch.Tensor) else gate_q.detach(),
|
|
"gate_g": torch.tensor(gate_g, device=total.device) if not isinstance(gate_g, torch.Tensor) else gate_g.detach(),
|
|
}
|