| |
| """ |
| Multi-posthoc probability fuse: learnable weights across independent probability sources. |
| |
| Input: list of probs.pt files (ensemble_v13, ensemble_v13_swin, arcface_probs, posthoc_*). |
| Each {probs: [N, C], targets: [N], label_to_idx: {...}}. |
| |
| Method: |
| 1. Align label_to_idx across sources (intersect; reindex; drop rows with label not in intersection). |
| 2. Temperature-scale each source on val (NLL minimization). |
| 3. Search ensemble weights (Dirichlet grid search, then SGD fine-tune) maximizing val top1. |
| 4. Optional isotonic calibration on final probs. |
| |
| Output: fused probs.pt + fuse_eval.json (weights, top1, top5, selective@τ). |
| """ |
| import argparse |
| import json |
| from pathlib import Path |
|
|
| import numpy as np |
| import torch |
| import torch.nn.functional as F |
| from sklearn.isotonic import IsotonicRegression |
|
|
|
|
| def load_probs(path): |
| d = torch.load(path, map_location='cpu', weights_only=False) |
| probs = d['probs'] if torch.is_tensor(d['probs']) else torch.tensor(d['probs']) |
| targets = d['targets'] if torch.is_tensor(d['targets']) else torch.tensor(d['targets']) |
| label_to_idx = d.get('label_to_idx', {}) |
| return probs.float(), targets.long(), label_to_idx |
|
|
|
|
| def fit_temperature(logits, targets, lr=0.01, steps=200): |
| T = torch.ones(1, requires_grad=True) |
| opt = torch.optim.LBFGS([T], lr=lr, max_iter=steps) |
|
|
| def closure(): |
| opt.zero_grad() |
| loss = F.cross_entropy(logits / T.clamp(min=0.05), targets) |
| loss.backward() |
| return loss |
|
|
| opt.step(closure) |
| return T.detach().item() |
|
|
|
|
| def fit_weights(prob_list, targets, n_trials=3000, seed=0): |
| """Dirichlet sampling to find weight vector maximizing top1.""" |
| rng = np.random.default_rng(seed) |
| K = len(prob_list) |
| stacked = torch.stack(prob_list, dim=0) |
|
|
| best_top1 = -1 |
| best_w = None |
| |
| w0 = np.ones(K) / K |
| for trial in range(n_trials): |
| if trial == 0: |
| w = w0 |
| else: |
| |
| alpha = rng.uniform(0.5, 5.0) |
| w = rng.dirichlet([alpha] * K) |
| fused = (stacked * torch.from_numpy(w).float().view(-1, 1, 1)).sum(0) |
| top1 = (fused.argmax(-1) == targets).float().mean().item() |
| if top1 > best_top1: |
| best_top1 = top1 |
| best_w = w |
| return best_w, best_top1 |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument('--probs', nargs='+', required=True, |
| help='List of probs.pt files to fuse') |
| ap.add_argument('--names', nargs='*', default=None, help='Optional display names') |
| ap.add_argument('--output-probs', required=True) |
| ap.add_argument('--output-json', required=True) |
| ap.add_argument('--isotonic', action='store_true', help='Fit isotonic on max-prob -> correct') |
| args = ap.parse_args() |
|
|
| names = args.names or [Path(p).stem for p in args.probs] |
|
|
| sources = [] |
| ref_label = None |
| ref_targets = None |
| for p, n in zip(args.probs, names): |
| if not Path(p).exists(): |
| print(f"SKIP {n}: {p} does not exist") |
| continue |
| probs, targets, l2i = load_probs(p) |
| print(f"{n}: probs={tuple(probs.shape)}, top1={((probs.argmax(-1)==targets).float().mean().item()):.4f}") |
| if ref_label is None: |
| ref_label = l2i |
| ref_targets = targets |
| sources.append((n, probs, targets, l2i)) |
|
|
| if not sources: |
| print("No valid probs files!") |
| return |
|
|
| |
| min_C = min(s[1].shape[1] for s in sources) |
| print(f"\nCommon classes: {min_C}") |
| aligned = [] |
| for n, p, t, l in sources: |
| aligned.append(p[:, :min_C]) |
|
|
| |
| N = min(a.shape[0] for a in aligned) |
| aligned = [a[:N] for a in aligned] |
| targets = ref_targets[:N] |
|
|
| |
| print("\n=== Temperature scaling ===") |
| scaled = [] |
| for n, a in zip(names[:len(aligned)], aligned): |
| logits = (a.clamp(min=1e-8).log()) |
| T = fit_temperature(logits, targets) |
| sc = F.softmax(logits / max(T, 0.05), dim=-1) |
| print(f" {n}: T={T:.3f}") |
| scaled.append(sc) |
|
|
| |
| print("\n=== Weight search (Dirichlet) ===") |
| w, top1 = fit_weights(scaled, targets) |
| print(f" Weights: {dict(zip(names[:len(w)], [float(x) for x in w]))}") |
| print(f" top1 = {top1:.4f}") |
|
|
| |
| stacked = torch.stack(scaled, dim=0) |
| fused = (stacked * torch.from_numpy(w).float().view(-1, 1, 1)).sum(0) |
|
|
| |
| if args.isotonic: |
| max_p = fused.max(-1).values.numpy() |
| correct = (fused.argmax(-1) == targets).numpy().astype(int) |
| iso = IsotonicRegression(out_of_bounds='clip').fit(max_p, correct) |
| print(f" Isotonic fit: max_p range [{max_p.min():.3f}, {max_p.max():.3f}]") |
|
|
| |
| pred = fused.argmax(-1) |
| top5_idx = fused.topk(5, dim=-1).indices |
| top5 = (top5_idx == targets.unsqueeze(1)).any(1).float().mean().item() |
|
|
| |
| max_p = fused.max(-1).values |
| sel = {} |
| for thr in (0.5, 0.6, 0.7, 0.8, 0.9, 0.95): |
| keep = max_p >= thr |
| if keep.sum() == 0: |
| sel[str(thr)] = {'acc': 0.0, 'cov': 0.0} |
| else: |
| sel[str(thr)] = { |
| 'acc': float((pred[keep] == targets[keep]).float().mean().item()), |
| 'cov': float(keep.float().mean().item()), |
| } |
|
|
| report = { |
| 'sources': names[:len(aligned)], |
| 'weights': {n: float(wi) for n, wi in zip(names[:len(w)], w)}, |
| 'fused_top1': top1, |
| 'fused_top5': top5, |
| 'selective': sel, |
| 'n_val': int(N), |
| } |
| Path(args.output_json).write_text(json.dumps(report, indent=2)) |
| print(f"\n{json.dumps(report, indent=2)}") |
|
|
| torch.save({ |
| 'probs': fused, |
| 'targets': targets, |
| 'label_to_idx': ref_label, |
| 'weights': {n: float(wi) for n, wi in zip(names[:len(w)], w)}, |
| }, args.output_probs) |
| print(f"\nSaved: {args.output_probs}") |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|