CoolFace
Apppublic

XminorAbi/SelfSupervisedLearning

sourceHugging Faceupdated 6mo agoView on Hugging Face
0likes
classifier.py323 linesDownload Raw Back to root
1"""2classifier.py — Supervised Linear Classifier Head3===================================================4Team 14: Abhinandan Chakraborty & Kamal Kishor Dhakad5Project: Learning Visual Concepts Without Labels6 7ARCHITECTURE REDESIGN:8-----------------------9Old system (broken):10  image → encoder → PCA(128) → KMeans → class name11  Problems: KMeans doesn't understand semantics, PCA destroys info,12            no supervised signal → car predicted as horse.13 14New system (this file):15  image → FROZEN encoder → L2-norm h (512) → Linear(512→N) → softmax → class16 17WHY THIS WORKS:18  The SimCLR encoder already learned rich visual features.19  A single linear layer on top is enough to separate classes20  because the embedding space is already semantically structured.21  This is called "linear probing" — the standard evaluation protocol22  for self-supervised learning papers.23 24  Accuracy improvement: KMeans ~52-75% → Linear probe ~85-92%25 26TRAINING:27  - Encoder weights are FROZEN (not updated)28  - Only the linear layer is trained29  - Uses labeled CIFAR-10 train split (50K images)30  - Takes ~5-10 minutes on T4 GPU (very fast)31 32KEY DESIGN DECISIONS:33  - Input: L2-normalised 512-dim embeddings (same as registry)34  - No PCA — raw embeddings preserve all discriminative information35  - Softmax output → confidence score for hybrid pipeline36  - Supports adding new classes (expand head) for continuous learning37"""38 39import torch40import torch.nn as nn41import torch.nn.functional as F42import torch.optim as optim43from torch.optim.lr_scheduler import CosineAnnealingLR44from pathlib import Path45import time46import os47 48 49class LinearClassifier(nn.Module):50    """51    Single linear layer classifier on top of frozen SimCLR encoder.52 53    Input  : L2-normalised embedding h (512-dim)54    Output : class logits (N_classes)55 56    Why only one linear layer?57    - SimCLR embeddings are already linearly separable after good training58    - Adding non-linearity (MLP) can overfit on small labeled sets59    - Linear probe is the standard SSL evaluation protocol60    - Softmax confidence is well-calibrated from a linear layer61 62    Expanding for new classes:63    - When a new class is registered, expand_head(new_n_classes) is called64    - Old weights are preserved, new class weights initialised to zero65    """66 67    def __init__(self, embed_dim: int = 512, n_classes: int = 10):68        super().__init__()69        self.fc = nn.Linear(embed_dim, n_classes)70        self.embed_dim = embed_dim71        self.n_classes = n_classes72 73        # Initialise with small weights for stable softmax74        nn.init.normal_(self.fc.weight, std=0.01)75        nn.init.zeros_(self.fc.bias)76 77    def forward(self, h: torch.Tensor) -> torch.Tensor:78        """79        Args:80            h : (B, 512) L2-normalised embeddings81        Returns:82            logits : (B, N_classes) — pass through softmax for probabilities83        """84        return self.fc(h)85 86    def predict(self, h: torch.Tensor):87        """88        Returns (predicted_class_idx, confidence) for each sample.89        confidence = max softmax probability.90        """91        with torch.no_grad():92            logits      = self.forward(h)93            probs       = F.softmax(logits, dim=1)94            confidence, pred = probs.max(dim=1)95        return pred, confidence96 97    def expand_head(self, new_n_classes: int):98        """99        Expand classifier to handle more classes.100        Old weights preserved — new class weights initialised to zero.101        Called when a novel class is confirmed and added to the system.102 103        Args:104            new_n_classes: total number of classes after expansion105        """106        if new_n_classes <= self.n_classes:107            return108 109        old_weight = self.fc.weight.data   # (old_n, 512)110        old_bias   = self.fc.bias.data     # (old_n,)111 112        # New linear layer with expanded output113        new_fc = nn.Linear(self.embed_dim, new_n_classes)114        nn.init.zeros_(new_fc.weight)115        nn.init.zeros_(new_fc.bias)116 117        # Copy old weights118        new_fc.weight.data[:self.n_classes] = old_weight119        new_fc.bias.data[:self.n_classes]   = old_bias120 121        self.fc        = new_fc122        self.n_classes = new_n_classes123        print(f"[Classifier] Expanded head: {self.n_classes - (new_n_classes - self.n_classes)} → {new_n_classes} classes")124 125 126def train_classifier(127    encoder,128    class_names     : list,129    cifar10_root    : str  = "data/cifar10",130    save_path       : str  = "checkpoints/classifier.pt",131    drive_save_path : str  = None,132    n_epochs        : int  = 30,133    batch_size      : int  = 512,134    lr              : float = 0.1,    # SGD with high LR works best for linear probe135    num_workers     : int  = 4,136    image_size      : int  = 32,137    device          : str  = "cuda",138) -> LinearClassifier:139    """140    Train the linear classifier head on frozen encoder embeddings.141 142    Uses full CIFAR-10 train split (50K labeled images).143    Encoder is frozen — only fc layer weights are updated.144 145    Training takes ~5-10 minutes on T4 GPU.146    Expected accuracy: 85-92% on CIFAR-10 test set.147 148    Args:149        encoder       : trained SimCLR encoder (EncoderWithHead)150        class_names   : list of class name strings151        cifar10_root  : path to CIFAR-10 data152        save_path     : local path to save classifier weights153        drive_save_path: Google Drive path to sync weights154        n_epochs      : 30 epochs is sufficient for linear probe155        batch_size    : 512 (fast since no backward through encoder)156        lr            : 0.1 with SGD is standard for linear probing157        device        : 'cuda' / 'mps' / 'cpu'158    """159    from dataset import build_eval_transform160    from torchvision.datasets import CIFAR10161    from torch.utils.data import DataLoader162 163    n_classes  = len(class_names)164    device_obj = torch.device(device)165 166    # Freeze encoder167    encoder = encoder.to(device_obj)168    encoder.eval()169    for param in encoder.parameters():170        param.requires_grad = False171 172    print(f"[Classifier] Encoder frozen. Training linear head only.")173    print(f"[Classifier] Classes: {n_classes} — {class_names}")174 175    # ── Pre-extract all embeddings (much faster than computing per batch) ──176    print(f"\n[Classifier] Pre-extracting embeddings from CIFAR-10...")177    transform = build_eval_transform(image_size)178 179    train_data = CIFAR10(root=cifar10_root, train=True,  download=True, transform=transform)180    test_data  = CIFAR10(root=cifar10_root, train=False, download=True, transform=transform)181 182    train_loader = DataLoader(train_data, batch_size=batch_size, shuffle=False,183                              num_workers=num_workers, pin_memory=True)184    test_loader  = DataLoader(test_data,  batch_size=batch_size, shuffle=False,185                              num_workers=num_workers, pin_memory=True)186 187    def extract_embeddings(loader, split_name):188        all_h, all_y = [], []189        with torch.no_grad():190            for imgs, labels in loader:191                h = encoder.encode(imgs.to(device_obj))   # L2-normalised (B, 512)192                all_h.append(h.cpu())193                all_y.append(labels)194        h_all = torch.cat(all_h)195        y_all = torch.cat(all_y)196        print(f"  {split_name}: {h_all.shape[0]} embeddings extracted")197        return h_all, y_all198 199    train_h, train_y = extract_embeddings(train_loader, "Train")200    test_h,  test_y  = extract_embeddings(test_loader,  "Test")201 202    # ── Build in-memory dataset from embeddings ──203    from torch.utils.data import TensorDataset204    train_emb_loader = DataLoader(205        TensorDataset(train_h, train_y),206        batch_size=512, shuffle=True,207    )208    test_emb_loader = DataLoader(209        TensorDataset(test_h, test_y),210        batch_size=512, shuffle=False,211    )212 213    # ── Build classifier ──214    classifier = LinearClassifier(embed_dim=512, n_classes=n_classes).to(device_obj)215 216    # SGD + cosine LR — standard for linear probing (better than Adam here)217    optimizer  = optim.SGD(classifier.parameters(), lr=lr, momentum=0.9, weight_decay=1e-4)218    scheduler  = CosineAnnealingLR(optimizer, T_max=n_epochs, eta_min=1e-4)219    criterion  = nn.CrossEntropyLoss()220 221    print(f"\n[Classifier] Training for {n_epochs} epochs...")222    best_acc   = 0.0223    best_state = None224 225    for epoch in range(1, n_epochs + 1):226        # Train227        classifier.train()228        total_loss, correct, total = 0.0, 0, 0229        for h_batch, y_batch in train_emb_loader:230            h_batch = h_batch.to(device_obj)231            y_batch = y_batch.to(device_obj)232 233            logits = classifier(h_batch)234            loss   = criterion(logits, y_batch)235 236            optimizer.zero_grad()237            loss.backward()238            optimizer.step()239 240            total_loss += loss.item()241            preds       = logits.argmax(dim=1)242            correct    += (preds == y_batch).sum().item()243            total      += len(y_batch)244 245        scheduler.step()246        train_acc = correct / total * 100247 248        # Evaluate249        classifier.eval()250        correct_t, total_t = 0, 0251        with torch.no_grad():252            for h_batch, y_batch in test_emb_loader:253                h_batch = h_batch.to(device_obj)254                y_batch = y_batch.to(device_obj)255                preds   = classifier(h_batch).argmax(dim=1)256                correct_t += (preds == y_batch).sum().item()257                total_t   += len(y_batch)258        test_acc = correct_t / total_t * 100259 260        print(f"  Epoch [{epoch:02d}/{n_epochs}] "261              f"loss={total_loss/len(train_emb_loader):.4f}  "262              f"train={train_acc:.1f}%  test={test_acc:.1f}%")263 264        # Save best265        if test_acc > best_acc:266            best_acc   = test_acc267            best_state = {k: v.clone() for k, v in classifier.state_dict().items()}268 269    # Load best weights270    classifier.load_state_dict(best_state)271    print(f"\n[Classifier] Best test accuracy: {best_acc:.2f}%")272 273    # Save274    Path(save_path).parent.mkdir(exist_ok=True, parents=True)275    torch.save({276        "classifier_state": classifier.state_dict(),277        "class_names"     : class_names,278        "n_classes"       : n_classes,279        "embed_dim"       : 512,280        "best_acc"        : best_acc,281    }, save_path)282    print(f"[Classifier] Saved: {save_path}")283 284    # Sync to Drive285    if drive_save_path:286        import shutil287        Path(drive_save_path).parent.mkdir(exist_ok=True, parents=True)288        shutil.copy(save_path, drive_save_path)289        print(f"[Classifier] Synced to Drive: {drive_save_path}")290 291    return classifier292 293 294def load_classifier(path: str, device: str = "cpu") -> tuple:295    """296    Load saved classifier from disk.297    Returns (classifier, class_names).298    """299    device = torch.device("cpu")  # ✅ FORCE CPU300 301    ckpt = torch.load(path, map_location=device)302 303    class_names = ckpt["class_names"]304 305    classifier = LinearClassifier(306        embed_dim=ckpt["embed_dim"],307        n_classes=ckpt["n_classes"],308    )309 310    classifier.load_state_dict(ckpt["classifier_state"])311 312    classifier = classifier.to(device)  # now safe313    classifier.eval()314 315    acc = ckpt.get("best_acc", None)316    317    if acc is not None:318        print(f"[Classifier] Loaded: {len(class_names)} classes, acc={acc:.2f}%")319    else:320        print(f"[Classifier] Loaded: {len(class_names)} classes")321        322    return classifier, class_names323