Image Classification with Transfer Learning Tutorial: ResNet, EfficientNet, ViT

# Klasifikasi Gambar dengan Transfer Learning: Tutorial Komprehensif ## Daftar Isi 1. [Pendahuluan](#pendahuluan) 2. [Prasyarat](#prasyarat) 3. [Memahami Transfer Learning](#memahami-transfer-learni...

By Ruby Abdullah · · tutorial
Transfer LearningImage ClassificationResNetEfficientNetViTPyTorch

Image Classification with Transfer Learning: A Comprehensive Tutorial

Table of Contents

  • Introduction
  • Prerequisites
  • Understanding Transfer Learning
  • Choosing a Pre-trained Model
  • Data Loading and Preparation
  • Fine-tuning with PyTorch
  • Training Loop Implementation
  • Evaluation and Metrics
  • Model Export and Deployment
  • Best Practices
  • Conclusion

  • Introduction

    Transfer learning is a machine learning technique where a model trained on a large dataset is repurposed for a different but related task. In computer vision, this typically involves taking a model pre-trained on ImageNet (1.4 million images, 1000 classes) and adapting it to your specific classification problem. This approach dramatically reduces the data and compute needed to achieve high accuracy.

    This tutorial covers the complete workflow: selecting a pre-trained model, preparing your data, fine-tuning with PyTorch, evaluating performance, and deploying the final model.


    Prerequisites

    pip install torch torchvision
    

    pip install timm # PyTorch Image Models - extensive model zoo

    pip install albumentations # Advanced augmentation

    pip install scikit-learn # Metrics

    pip install matplotlib seaborn # Visualization

    pip install onnx onnxruntime # Export and deployment

    System requirements:
    • Python 3.8 or higher
    • GPU with at least 6 GB VRAM (training), CPU is sufficient for inference
    • Basic understanding of neural networks and PyTorch

    import torch
    

    import torchvision

    import timm

    print(f"PyTorch: {torch.version}")

    print(f"Torchvision: {torchvision.version}")

    print(f"CUDA: {torch.cuda.isavailable()}")

    print(f"Timm: {timm.version}")

    print(f"Available timm models: {len(timm.listmodels())}")


    Understanding Transfer Learning

    Transfer learning works because the early layers of a CNN learn universal features (edges, textures, patterns) that apply to almost any vision task. Only the later layers become task-specific.

    There are two main strategies:

  • Feature Extraction: Freeze the entire pre-trained model and only train a new classification head. This is fast, requires minimal data, and works well when your task is similar to ImageNet.
  • Fine-tuning: Unfreeze some or all layers of the pre-trained model and train with a low learning rate. This allows the model to adapt its features to your specific domain and generally achieves better results, especially with more data.
  • # Strategy 1: Feature extraction
    

    def createfeatureextractor(modelname, numclasses):

    """Freeze all layers except the classification head."""

    model = timm.createmodel(modelname, pretrained=True, numclasses=numclasses)

    # Freeze all parameters

    for param in model.parameters():

    param.requiresgrad = False

    # Unfreeze the classification head

    if hasattr(model, 'classifier'):

    for param in model.classifier.parameters():

    param.requiresgrad = True

    elif hasattr(model, 'fc'):

    for param in model.fc.parameters():

    param.requiresgrad = True

    elif hasattr(model, 'head'):

    for param in model.head.parameters():

    param.requiresgrad = True

    return model

    Strategy 2: Full fine-tuning with discriminative learning rates

    def createfinetuningmodel(modelname, numclasses):

    """All layers trainable, but with different learning rates."""

    model = timm.createmodel(modelname, pretrained=True, numclasses=numclasses)

    # All parameters are trainable by default

    return model


    Choosing a Pre-trained Model

    import timm
    
    

    def comparemodels():

    """Compare popular models by size, speed, and accuracy."""

    modelsinfo = {

    "resnet50": {

    "params": "25.6M",

    "top1imagenet": 80.4,

    "speed": "fast",

    "usecase": "General purpose, good baseline"

    },

    "efficientnetb3": {

    "params": "12.2M",

    "top1imagenet": 82.0,

    "speed": "medium",

    "usecase": "Best accuracy-efficiency tradeoff"

    },

    "efficientnetv2s": {

    "params": "21.5M",

    "top1imagenet": 83.9,

    "speed": "medium",

    "usecase": "Improved EfficientNet, faster training"

    },

    "vitbasepatch16224": {

    "params": "86.6M",

    "top1imagenet": 84.5,

    "speed": "slow",

    "usecase": "Vision Transformer, best with large datasets"

    },

    "convnexttiny": {

    "params": "28.6M",

    "top1imagenet": 82.1,

    "speed": "medium",

    "usecase": "Modern CNN matching ViT performance"

    },

    }

    for name, info in modelsinfo.items():

    print(f"{name:30s} | Params: {info['params']:>8s} | "

    f"Top-1: {info['top1imagenet']:.1f}% | "

    f"Speed: {info['speed']:>6s} | {info['usecase']}")

    return modelsinfo

    comparemodels()

    Loading a Model with timm

    import timm
    
    

    def loadpretrainedmodel(modelname, numclasses, pretrained=True):

    """

    Load a pre-trained model and adapt it for your number of classes.

    timm automatically replaces the classification head.

    """

    model = timm.createmodel(

    modelname,

    pretrained=pretrained,

    numclasses=numclasses,

    )

    # Get model-specific preprocessing config

    dataconfig = timm.data.resolvemodeldataconfig(model)

    print(f"Model: {modelname}")

    print(f"Input size: {dataconfig['inputsize']}")

    print(f"Mean: {dataconfig['mean']}")

    print(f"Std: {dataconfig['std']}")

    print(f"Parameters: {sum(p.numel() for p in model.parameters()):,}")

    return model, dataconfig

    model, config = loadpretrainedmodel("efficientnetb3", numclasses=10)


    Data Loading and Preparation

    Directory Structure

    dataset/
    

    train/

    classa/

    img001.jpg

    img002.jpg

    classb/

    img003.jpg

    img004.jpg

    val/

    classa/

    img005.jpg

    classb/

    img006.jpg

    Creating Data Loaders

    import torch
    

    from torch.utils.data import DataLoader

    from torchvision import datasets

    import albumentations as A

    from albumentations.pytorch import ToTensorV2

    import numpy as np

    from PIL import Image

    class AlbumentationsDataset(torch.utils.data.Dataset):

    """Wraps a torchvision dataset with Albumentations transforms."""

    def init(self, rootdir, transform=None):

    self.dataset = datasets.ImageFolder(rootdir)

    self.transform = transform

    self.classes = self.dataset.classes

    def len(self):

    return len(self.dataset)

    def getitem(self, idx):

    image, label = self.dataset[idx]

    image = np.array(image)

    if self.transform:

    augmented = self.transform(image=image)

    image = augmented["image"]

    return image, label

    def createdataloaders(datadir, inputsize=224, batchsize=32, numworkers=4):

    """Create train and validation data loaders with proper augmentation."""

    traintransform = A.Compose([

    A.RandomResizedCrop(height=inputsize, width=inputsize, scale=(0.8, 1.0)),

    A.HorizontalFlip(p=0.5),

    A.VerticalFlip(p=0.1),

    A.Rotate(limit=15, p=0.3),

    A.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1, p=0.5),

    A.GaussNoise(varlimit=(10, 50), p=0.2),

    A.CoarseDropout(maxholes=8, maxheight=16, maxwidth=16, p=0.2),

    A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),

    ToTensorV2(),

    ])

    valtransform = A.Compose([

    A.Resize(height=int(inputsize 1.14), width=int(inputsize 1.14)),

    A.CenterCrop(height=inputsize, width=inputsize),

    A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),

    ToTensorV2(),

    ])

    traindataset = AlbumentationsDataset(f"{datadir}/train", transform=traintransform)

    valdataset = AlbumentationsDataset(f"{datadir}/val", transform=valtransform)

    trainloader = DataLoader(

    traindataset, batchsize=batchsize, shuffle=True,

    numworkers=numworkers, pinmemory=True, droplast=True,

    )

    valloader = DataLoader(

    valdataset, batchsize=batchsize, shuffle=False,

    numworkers=numworkers, pinmemory=True,

    )

    print(f"Training samples: {len(traindataset)}")

    print(f"Validation samples: {len(valdataset)}")

    print(f"Classes: {traindataset.classes}")

    return trainloader, valloader, traindataset.classes

    trainloader, valloader, classes = createdataloaders("./dataset", inputsize=300, batchsize=32)


    Fine-tuning with PyTorch

    Setting Up the Training Configuration

    import torch.nn as nn
    

    import torch.optim as optim

    from torch.optim.lrscheduler import CosineAnnealingLR

    def setuptraining(model, numepochs=30, lr=1e-4, weightdecay=1e-4):

    """Configure optimizer, loss function, and scheduler."""

    # Discriminative learning rates: lower for pre-trained layers,

    # higher for the new classification head

    paramgroups = []

    headparams = []

    backboneparams = []

    for name, param in model.namedparameters():

    if not param.requiresgrad:

    continue

    if "classifier" in name or "fc" in name or "head" in name:

    headparams.append(param)

    else:

    backboneparams.append(param)

    paramgroups = [

    {"params": backboneparams, "lr": lr 0.1}, # 10x lower LR for backbone

    {"params": headparams, "lr": lr}, # Full LR for head

    ]

    optimizer = optim.AdamW(paramgroups, weightdecay=weightdecay)

    scheduler = CosineAnnealingLR(

    optimizer,

    Tmax=numepochs,

    etamin=lr 0.01,

    )

    criterion = nn.CrossEntropyLoss(labelsmoothing=0.1)

    return optimizer, scheduler, criterion

    optimizer, scheduler, criterion = setuptraining(model, numepochs=30, lr=1e-4)


    Training Loop Implementation

    import time
    

    from collections import defaultdict

    class Trainer:

    """Complete training pipeline for image classification."""

    def init(self, model, optimizer, scheduler, criterion, device="cuda"):

    self.model = model.to(device)

    self.optimizer = optimizer

    self.scheduler = scheduler

    self.criterion = criterion

    self.device = device

    self.history = defaultdict(list)

    self.bestvalacc = 0.0

    def trainoneepoch(self, trainloader):

    """Train for one epoch."""

    self.model.train()

    runningloss = 0.0

    correct = 0

    total = 0

    for batchidx, (images, labels) in enumerate(trainloader):

    images = images.to(self.device)

    labels = labels.to(self.device)

    # Forward pass

    outputs = self.model(images)

    loss = self.criterion(outputs, labels)

    # Backward pass

    self.optimizer.zerograd()

    loss.backward()

    # Gradient clipping

    torch.nn.utils.clipgradnorm(self.model.parameters(), maxnorm=1.0)

    self.optimizer.step()

    # Track metrics

    runningloss += loss.item() images.size(0)

    , predicted = outputs.max(1)

    total += labels.size(0)

    correct += predicted.eq(labels).sum().item()

    epochloss = runningloss / total

    epochacc = 100.0 correct / total

    return epochloss, epochacc

    @torch.nograd()

    def validate(self, valloader):

    """Evaluate on the validation set."""

    self.model.eval()

    runningloss = 0.0

    correct = 0

    total = 0

    for images, labels in valloader:

    images = images.to(self.device)

    labels = labels.to(self.device)

    outputs = self.model(images)

    loss = self.criterion(outputs, labels)

    runningloss += loss.item() images.size(0)

    , predicted = outputs.max(1)

    total += labels.size(0)

    correct += predicted.eq(labels).sum().item()

    epochloss = runningloss / total

    epochacc = 100.0 correct / total

    return epochloss, epochacc

    def fit(self, trainloader, valloader, numepochs, savepath="bestmodel.pth"):

    """Complete training loop with validation and model saving."""

    print(f"{'Epoch':>6} | {'Train Loss':>10} | {'Train Acc':>9} | "

    f"{'Val Loss':>10} | {'Val Acc':>9} | {'LR':>10} | {'Time':>6}")

    print("-" 75)

    for epoch in range(numepochs):

    starttime = time.time()

    trainloss, trainacc = self.trainoneepoch(trainloader)

    valloss, valacc = self.validate(valloader)

    self.scheduler.step()

    currentlr = self.optimizer.paramgroups[0]["lr"]

    elapsed = time.time() - starttime

    # Log history

    self.history["trainloss"].append(trainloss)

    self.history["trainacc"].append(trainacc)

    self.history["valloss"].append(valloss)

    self.history["valacc"].append(valacc)

    print(f"{epoch + 1:>6} | {trainloss:>10.4f} | {trainacc:>8.2f}% | "

    f"{valloss:>10.4f} | {valacc:>8.2f}% | {currentlr:>10.6f} | "

    f"{elapsed:>5.1f}s")

    # Save best model

    if valacc > self.bestvalacc:

    self.bestvalacc = valacc

    torch.save({

    "epoch": epoch,

    "modelstatedict": self.model.statedict(),

    "optimizerstatedict": self.optimizer.statedict(),

    "valacc": valacc,

    "valloss": valloss,

    }, savepath)

    print(f" -> Saved best model (valacc: {valacc:.2f}%)")

    print(f"\nBest validation accuracy: {self.bestvalacc:.2f}%")

    return self.history

    Run training

    trainer = Trainer(model, optimizer, scheduler, criterion)

    history = trainer.fit(trainloader, valloader, numepochs=30)

    Visualizing Training History

    import matplotlib.pyplot as plt
    
    

    def plottraininghistory(history):

    """Plot training and validation metrics."""

    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

    # Loss plot

    ax1.plot(history["trainloss"], label="Train Loss")

    ax1.plot(history["valloss"], label="Val Loss")

    ax1.setxlabel("Epoch")

    ax1.setylabel("Loss")

    ax1.settitle("Loss over Epochs")

    ax1.legend()

    ax1.grid(True)

    # Accuracy plot

    ax2.plot(history["trainacc"], label="Train Accuracy")

    ax2.plot(history["valacc"], label="Val Accuracy")

    ax2.setxlabel("Epoch")

    ax2.setylabel("Accuracy (%)")

    ax2.settitle("Accuracy over Epochs")

    ax2.legend()

    ax2.grid(True)

    plt.tightlayout()

    plt.savefig("traininghistory.png", dpi=150)

    plt.close()

    plottraininghistory(history)


    Evaluation and Metrics

    from sklearn.metrics import classificationreport, confusionmatrix
    

    import seaborn as sns

    import numpy as np

    @torch.nograd()

    def evaluatemodel(model, valloader, classnames, device="cuda"):

    """Generate comprehensive evaluation metrics."""

    model.eval()

    allpredictions = []

    alllabels = []

    allprobabilities = []

    for images, labels in valloader:

    images = images.to(device)

    outputs = model(images)

    probabilities = torch.softmax(outputs, dim=1)

    , predicted = outputs.max(1)

    allpredictions.extend(predicted.cpu().numpy())

    alllabels.extend(labels.numpy())

    allprobabilities.extend(probabilities.cpu().numpy())

    allpredictions = np.array(allpredictions)

    alllabels = np.array(alllabels)

    # Classification report

    print("Classification Report:")

    print(classificationreport(alllabels, allpredictions,

    targetnames=classnames, digits=3))

    # Confusion matrix

    cm = confusionmatrix(alllabels, allpredictions)

    fig, ax = plt.subplots(figsize=(10, 8))

    sns.heatmap(cm, annot=True, fmt="d", cmap="Blues",

    xticklabels=classnames, yticklabels=classnames, ax=ax)

    ax.setxlabel("Predicted")

    ax.setylabel("True")

    ax.settitle("Confusion Matrix")

    plt.tightlayout()

    plt.savefig("confusionmatrix.png", dpi=150)

    plt.close()

    accuracy = (allpredictions == alllabels).mean() 100

    print(f"\nOverall Accuracy: {accuracy:.2f}%")

    return accuracy

    evaluatemodel(model, valloader, classes)


    Model Export and Deployment

    Export to ONNX

    def exporttoonnx(model, inputsize=224, outputpath="model.onnx"):
    

    """Export the model to ONNX format for cross-platform deployment."""

    model.eval()

    model.cpu()

    dummyinput = torch.randn(1, 3, inputsize, inputsize)

    torch.onnx.export(

    model,

    dummyinput,

    outputpath,

    exportparams=True,

    opsetversion=17,

    doconstantfolding=True,

    inputnames=["input"],

    outputnames=["output"],

    dynamicaxes={

    "input": {0: "batchsize"},

    "output": {0: "batchsize"},

    },

    )

    print(f"Model exported to {outputpath}")

    exporttoonnx(model, inputsize=300)

    ONNX Runtime Inference

    import onnxruntime as ort
    

    import cv2

    import numpy as np

    class ONNXClassifier:

    """Run inference using ONNX Runtime."""

    def init(self, modelpath, classnames, inputsize=224):

    self.session = ort.InferenceSession(

    modelpath,

    providers=["CUDAExecutionProvider", "CPUExecutionProvider"]

    )

    self.classnames = classnames

    self.inputsize = inputsize

    self.inputname = self.session.getinputs()[0].name

    def preprocess(self, imagepath):

    """Preprocess an image for inference."""

    image = cv2.imread(imagepath)

    image = cv2.cvtColor(image, cv2.COLORBGR2RGB)

    image = cv2.resize(image, (self.inputsize, self.inputsize))

    image = image.astype(np.float32) / 255.0

    mean = np.array([0.485, 0.456, 0.406])

    std = np.array([0.229, 0.224, 0.225])

    image = (image - mean) / std

    image = image.transpose(2, 0, 1) # HWC -> CHW

    image = np.expanddims(image, axis=0).astype(np.float32)

    return image

    def predict(self, imagepath, topk=5):

    """Run inference and return top-k predictions."""

    inputtensor = self.preprocess(imagepath)

    outputs = self.session.run(None, {self.inputname: inputtensor})

    logits = outputs[0][0]

    # Apply softmax

    explogits = np.exp(logits - np.max(logits))

    probabilities = explogits / explogits.sum()

    topindices = np.argsort(probabilities)[::-1][:topk]

    results = [

    {"class": self.classnames[i], "probability": float(probabilities[i])}

    for i in topindices

    ]

    return results

    Usage

    classifier = ONNXClassifier("model.onnx", classnames=classes, inputsize=300)

    predictions = classifier.predict("testimage.jpg")

    for pred in predictions:

    print(f"{pred['class']}: {pred['probability']:.4f}")

    FastAPI Deployment

    from fastapi import FastAPI, File, UploadFile
    

    import io

    from PIL import Image

    app = FastAPI()

    classifier = ONNXClassifier("model.onnx", classnames=classes, inputsize=300)

    @app.post("/classify")

    async def classifyimage(file: UploadFile = File(...)):

    """Classify an uploaded image."""

    contents = await file.read()

    image = np.array(Image.open(io.BytesIO(contents)).convert("RGB"))

    # Save temporarily and classify

    temppath = "/tmp/tempclassify.jpg"

    cv2.imwrite(temppath, cv2.cvtColor(image, cv2.COLORRGB2BGR))

    predictions = classifier.predict(temppath, top_k=5)

    return {"predictions": predictions}


    Best Practices

  • Start with feature extraction, then fine-tune. Begin by training only the head to establish a baseline. Then unfreeze layers gradually for fine-tuning to squeeze out more accuracy.
  • Use discriminative learning rates. Apply a lower learning rate to pre-trained layers (typically 10x lower) and a higher rate to the new head. This prevents catastrophic forgetting while still adapting features.
  • Apply label smoothing (0.1). This prevents overconfident predictions and acts as a regularizer, especially with smaller datasets.
  • Use cosine annealing or one-cycle learning rate schedule. These schedules produce consistently better results than step decay.
  • Always apply proper validation augmentation. Use only deterministic transforms (resize + center crop + normalize) for validation -- never random augmentation.
  • Monitor for overfitting. If the training accuracy climbs while validation accuracy plateaus, increase regularization: more augmentation, higher dropout, or weight decay.
  • Choose the right model for your constraints. EfficientNet-B0/B3 for edge deployment, ConvNeXt or ViT for server-side inference. More parameters do not always mean better results with limited data.
  • Use mixed precision training with torch.amp.autocast and GradScaler to reduce memory usage and speed up training by 30-50% on modern GPUs.

  • Conclusion

    Transfer learning is the most practical approach to image classification for the vast majority of real-world applications. By leveraging pre-trained models, you can achieve excellent accuracy with minimal data and compute. The workflow covered in this tutorial -- model selection, data preparation, fine-tuning with discriminative learning rates, rigorous evaluation, and ONNX export -- represents a production-ready pipeline applicable to any classification task.

    Key takeaways: start simple (feature extraction), measure carefully (proper validation), and iterate (gradual unfreezing, hyperparameter tuning). With the timm library providing access to hundreds of state-of-the-art architectures, the bottleneck is no longer model availability but rather data quality and training methodology.

    Related Articles

    bitsandbytes Tutorial: 4-bit and 8-bit Quantization for Large Language Models

    Tutorial bitsandbytes: Kuantisasi 4-bit dan 8-bit untuk Large Language Models Menjalankan Large Language Model (LLM) sep...

    Liger Kernel Tutorial: Efficient LLM Training with Triton Kernels

    Tutorial Lengkap Liger Kernel: Efisiensi Training LLM dengan Triton Kernels Penulis: Ruby Abdullah — PT RUBYTHALIB DATA ...

    LitGPT Tutorial: A Framework for Training and Fine-Tuning Large Language Models

    Tutorial LitGPT: Framework untuk Melatih dan Fine-Tuning Large Language Models LitGPT adalah framework open-source dari ...

    DeepSpeed: Training Huge Models on Limited GPUs with ZeRO and Offload

    DeepSpeed: Melatih Model Raksasa di GPU Terbatas dengan ZeRO dan Offload Halo temen-temen, kali ini kita masuk ke topik ...