CharlesCNorton
Image-level person classification on EUPE-ViT-B features with a single free parameter
f5498f9
Raw
History Blame Contribute Delete
3.27 kB
"""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
@torch.inference_mode()
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()))
@classmethod
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'])
@classmethod
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()}')