CharlesCNorton
Image-level person classification on EUPE-ViT-B features with a single free parameter
f5498f9 | """The classifier as a fused Linear with fixed ternary weights and one free bias. | |
| model = FusedClassifier.from_hub() | |
| score, present = model(image_tensor) | |
| Constructed without a backbone the module still exposes `head`, the same | |
| decision applied to an already-pooled vector. | |
| """ | |
| import argparse | |
| import json | |
| import sys | |
| from pathlib import Path | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| sys.path.insert(0, str(Path(__file__).resolve().parent)) # repo root, for `common` | |
| from common import BACKBONE, pool # noqa: E402 | |
| HERE = Path(__file__).resolve().parent | |
| class FusedClassifier(nn.Module): | |
| """Backbone -> 40-dim slice -> ternary linear head -> binary decision. | |
| `retained_dims` indexes the 768-D pooled vector; `retained_weight` is +1 on | |
| the person-positive positions and -1 on the person-negative ones. The | |
| threshold is the only free parameter. | |
| """ | |
| def __init__(self, backbone, pos_dims, neg_dims, threshold): | |
| super().__init__() | |
| self.backbone = backbone | |
| retained = list(pos_dims) + list(neg_dims) | |
| self.register_buffer('retained_dims', torch.tensor(retained, dtype=torch.long)) | |
| w = torch.zeros(1, len(retained)) | |
| w[0, :len(pos_dims)] = 1.0 | |
| w[0, len(pos_dims):] = -1.0 | |
| self.register_buffer('retained_weight', w) | |
| self.threshold = nn.Parameter(torch.tensor(float(threshold))) | |
| def head(self, pooled): | |
| """(..., 768) pooled vector -> (score, present), no backbone involved.""" | |
| retained = pooled.index_select(-1, self.retained_dims) | |
| score = F.linear(retained, self.retained_weight).squeeze(-1) | |
| return score, score > self.threshold | |
| def forward(self, x): | |
| """x: (B, 3, 768, 768) normalized. Returns (score (B,), present (B,)).""" | |
| if self.backbone is None: | |
| raise RuntimeError('constructed without a backbone; use .head(pooled)') | |
| dev = 'cuda' if x.is_cuda else 'cpu' | |
| with torch.autocast(dev, dtype=torch.bfloat16): | |
| out = self.backbone.forward_features(x) | |
| return self.head(pool(out['x_norm_patchtokens'].float())) | |
| def from_config(cls, backbone=None, classifier_json=None): | |
| c = json.loads(Path(classifier_json or HERE / 'classifier.json').read_text()) | |
| return cls(backbone, c['pos_dims'], c['neg_dims'], c['threshold']) | |
| def from_hub(cls, repo_or_path=None, classifier_json=None): | |
| from common.models import load_backbone | |
| return cls.from_config(load_backbone(repo_or_path or BACKBONE), classifier_json) | |
| if __name__ == '__main__': | |
| ap = argparse.ArgumentParser(description=__doc__) | |
| ap.add_argument('--classifier', type=Path, default=HERE / 'classifier.json') | |
| args = ap.parse_args() | |
| m = FusedClassifier.from_hub(classifier_json=args.classifier).eval() | |
| n_all = sum(p.numel() for p in m.parameters()) | |
| n_backbone = sum(p.numel() for p in m.backbone.parameters()) | |
| print(f'total params: {n_all:,}') | |
| print(f'backbone params: {n_backbone:,}') | |
| print(f'head params: {n_all - n_backbone} ' | |
| f'(one learnable threshold; weights are fixed buffers)') | |
| print(f'retained dims: {m.retained_dims.numel()}') | |