Build complete image classification systems using PyTorch, implementing data augmentation, transfer learning, and fine-tuning pretrained models for custom datasets.
Image classification is the fundamental task of assigning a label to an image from a predefined set of categories. It serves as the foundation for many computer vision applications including medical diagnosis, autonomous vehicles, content moderation, and wildlife monitoring.
In this lesson, we'll build production-ready image classification systems using modern deep learning techniques. We'll explore data augmentation to artificially expand training data, leverage transfer learning to achieve excellent results with limited data, and learn to fine-tune pretrained models for domain-specific tasks.
Whether you're classifying dog breeds, detecting diseases in medical images, or categorizing products, the techniques covered here form the basis of successful real-world applications.
Proper data loading and preprocessing are essential for training effective classifiers. PyTorch provides powerful tools through torchvision for handling image datasets.
Dataset Structure and Loading:
1import torch
2from torch.utils.data import Dataset, DataLoader
3from torchvision import datasets, transforms
4from PIL import Image
5import os
6
7# Standard ImageFolder dataset
8# Expects: root/class1/image1.jpg, root/class2/image2.jpg, etc.
9def create_dataloaders(data_dir, batch_size=32, image_size=224):
10 """Create training and validation dataloaders."""
11
12 # Training transforms with augmentation
13 train_transform = transforms.Compose([
14 transforms.RandomResizedCrop(image_size),
15 transforms.RandomHorizontalFlip(),
16 transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),
17 transforms.ToTensor(),
18 transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
19 ])
20
21 # Validation transforms (no augmentation)
22 val_transform = transforms.Compose([
23 transforms.Resize(256),
24 transforms.CenterCrop(image_size),
25 transforms.ToTensor(),
26 transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
27 ])
28
29 # Load datasets
30 train_dataset = datasets.ImageFolder(
31 os.path.join(data_dir, 'train'),
32 transform=train_transform
33 )
34
35 val_dataset = datasets.ImageFolder(
36 os.path.join(data_dir, 'val'),
37 transform=val_transform
38 )
39
40 # Create dataloaders
41 train_loader = DataLoader(
42 train_dataset,
43 batch_size=batch_size,
44 shuffle=True,
45 num_workers=4,
46 pin_memory=True
47 )
48
49 val_loader = DataLoader(
50 val_dataset,
51 batch_size=batch_size,
52 shuffle=False,
53 num_workers=4,
54 pin_memory=True
55 )
56
57 return train_loader, val_loader, train_dataset.classes
58
59# Custom dataset for non-standard formats
60class CustomImageDataset(Dataset):
61 """Custom dataset for images with CSV labels."""
62
63 def __init__(self, csv_file, img_dir, transform=None):
64 import pandas as pd
65 self.labels_df = pd.read_csv(csv_file)
66 self.img_dir = img_dir
67 self.transform = transform
68
69 def __len__(self):
70 return len(self.labels_df)
71
72 def __getitem__(self, idx):
73 img_name = self.labels_df.iloc[idx, 0]
74 img_path = os.path.join(self.img_dir, img_name)
75
76 image = Image.open(img_path).convert('RGB')
77 label = self.labels_df.iloc[idx, 1]
78
79 if self.transform:
80 image = self.transform(image)
81
82 return image, labelHandling Imbalanced Datasets:
1from torch.utils.data import WeightedRandomSampler
2import numpy as np
3
4def create_balanced_sampler(dataset):
5 """Create sampler for class-imbalanced datasets."""
6
7 # Count samples per class
8 targets = [s[1] for s in dataset.samples]
9 class_counts = np.bincount(targets)
10
11 # Compute weights
12 class_weights = 1.0 / class_counts
13 sample_weights = [class_weights[t] for t in targets]
14
15 # Create sampler
16 sampler = WeightedRandomSampler(
17 weights=sample_weights,
18 num_samples=len(sample_weights),
19 replacement=True
20 )
21
22 return sampler
23
24# Usage
25# sampler = create_balanced_sampler(train_dataset)
26# train_loader = DataLoader(train_dataset, batch_size=32, sampler=sampler)Data augmentation artificially expands training data by applying random transformations, improving model generalization and reducing overfitting.
Standard Augmentation Techniques:
1from torchvision import transforms
2import torchvision.transforms.functional as TF
3import random
4
5# Comprehensive augmentation pipeline
6strong_augmentation = transforms.Compose([
7 # Spatial transforms
8 transforms.RandomResizedCrop(224, scale=(0.6, 1.0)),
9 transforms.RandomHorizontalFlip(p=0.5),
10 transforms.RandomVerticalFlip(p=0.1),
11 transforms.RandomRotation(degrees=15),
12 transforms.RandomAffine(degrees=0, translate=(0.1, 0.1), scale=(0.9, 1.1)),
13
14 # Color transforms
15 transforms.ColorJitter(
16 brightness=0.3,
17 contrast=0.3,
18 saturation=0.3,
19 hue=0.1
20 ),
21 transforms.RandomGrayscale(p=0.1),
22
23 # To tensor and normalize
24 transforms.ToTensor(),
25 transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
26
27 # Tensor-level augmentation
28 transforms.RandomErasing(p=0.2, scale=(0.02, 0.2))
29])
30
31# Task-specific augmentation
32medical_augmentation = transforms.Compose([
33 # Medical images often need different augmentation
34 transforms.Resize(256),
35 transforms.RandomCrop(224),
36 transforms.RandomHorizontalFlip(),
37 transforms.RandomRotation(10),
38 # Less aggressive color augmentation
39 transforms.ColorJitter(brightness=0.1, contrast=0.1),
40 transforms.ToTensor(),
41 transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
42])Advanced Augmentation with Albumentations:
1import albumentations as A
2from albumentations.pytorch import ToTensorV2
3import cv2
4import numpy as np
5
6# Albumentations pipeline (more options than torchvision)
7albumentation_transform = A.Compose([
8 A.RandomResizedCrop(224, 224, scale=(0.8, 1.0)),
9 A.HorizontalFlip(p=0.5),
10 A.OneOf([
11 A.GaussNoise(var_limit=(10.0, 50.0)),
12 A.GaussianBlur(blur_limit=3),
13 A.MotionBlur(blur_limit=3),
14 ], p=0.3),
15 A.OneOf([
16 A.OpticalDistortion(distort_limit=0.05),
17 A.GridDistortion(num_steps=5, distort_limit=0.05),
18 A.ElasticTransform(alpha=1, sigma=50, alpha_affine=50),
19 ], p=0.3),
20 A.CoarseDropout(max_holes=8, max_height=16, max_width=16, p=0.3),
21 A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
22 ToTensorV2()
23])
24
25# Wrapper for PyTorch Dataset
26class AlbumentationsDataset(torch.utils.data.Dataset):
27 def __init__(self, image_paths, labels, transform=None):
28 self.image_paths = image_paths
29 self.labels = labels
30 self.transform = transform
31
32 def __len__(self):
33 return len(self.image_paths)
34
35 def __getitem__(self, idx):
36 image = cv2.imread(self.image_paths[idx])
37 image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
38 label = self.labels[idx]
39
40 if self.transform:
41 augmented = self.transform(image=image)
42 image = augmented['image']
43
44 return image, labelMixUp and CutMix Augmentation:
1def mixup_data(x, y, alpha=0.2):
2 """Apply MixUp augmentation."""
3 if alpha > 0:
4 lam = np.random.beta(alpha, alpha)
5 else:
6 lam = 1
7
8 batch_size = x.size(0)
9 index = torch.randperm(batch_size).to(x.device)
10
11 mixed_x = lam * x + (1 - lam) * x[index, :]
12 y_a, y_b = y, y[index]
13
14 return mixed_x, y_a, y_b, lam
15
16def mixup_criterion(criterion, pred, y_a, y_b, lam):
17 """Compute MixUp loss."""
18 return lam * criterion(pred, y_a) + (1 - lam) * criterion(pred, y_b)
19
20# Usage in training loop
21# mixed_x, y_a, y_b, lam = mixup_data(inputs, labels)
22# outputs = model(mixed_x)
23# loss = mixup_criterion(criterion, outputs, y_a, y_b, lam)Transfer learning leverages knowledge from models trained on large datasets to achieve excellent results on smaller, domain-specific datasets.
Loading and Modifying Pretrained Models:
1import torch
2import torch.nn as nn
3from torchvision import models
4
5def create_classifier(model_name, num_classes, pretrained=True, freeze_backbone=True):
6 """Create classifier from pretrained model."""
7
8 if model_name == 'resnet50':
9 model = models.resnet50(weights='IMAGENET1K_V2' if pretrained else None)
10 in_features = model.fc.in_features
11 model.fc = nn.Linear(in_features, num_classes)
12
13 elif model_name == 'efficientnet_b0':
14 model = models.efficientnet_b0(weights='IMAGENET1K_V1' if pretrained else None)
15 in_features = model.classifier[1].in_features
16 model.classifier[1] = nn.Linear(in_features, num_classes)
17
18 elif model_name == 'vit_b_16':
19 model = models.vit_b_16(weights='IMAGENET1K_V1' if pretrained else None)
20 in_features = model.heads.head.in_features
21 model.heads.head = nn.Linear(in_features, num_classes)
22
23 elif model_name == 'convnext_tiny':
24 model = models.convnext_tiny(weights='IMAGENET1K_V1' if pretrained else None)
25 in_features = model.classifier[2].in_features
26 model.classifier[2] = nn.Linear(in_features, num_classes)
27
28 else:
29 raise ValueError(f"Unknown model: {model_name}")
30
31 # Freeze backbone if requested
32 if freeze_backbone and pretrained:
33 for param in model.parameters():
34 param.requires_grad = False
35
36 # Unfreeze classifier
37 if model_name == 'resnet50':
38 for param in model.fc.parameters():
39 param.requires_grad = True
40 elif model_name == 'efficientnet_b0':
41 for param in model.classifier.parameters():
42 param.requires_grad = True
43 elif model_name == 'vit_b_16':
44 for param in model.heads.parameters():
45 param.requires_grad = True
46 elif model_name == 'convnext_tiny':
47 for param in model.classifier.parameters():
48 param.requires_grad = True
49
50 return model
51
52# Usage
53model = create_classifier('efficientnet_b0', num_classes=10, freeze_backbone=True)
54print(f"Trainable params: {sum(p.numel() for p in model.parameters() if p.requires_grad):,}")
55print(f"Total params: {sum(p.numel() for p in model.parameters()):,}")Feature Extraction Mode:
1class FeatureExtractor:
2 """Extract features using pretrained model."""
3
4 def __init__(self, model_name='resnet50'):
5 if model_name == 'resnet50':
6 self.model = models.resnet50(weights='IMAGENET1K_V2')
7 self.model.fc = nn.Identity()
8 self.feature_dim = 2048
9 elif model_name == 'efficientnet_b0':
10 self.model = models.efficientnet_b0(weights='IMAGENET1K_V1')
11 self.model.classifier = nn.Identity()
12 self.feature_dim = 1280
13
14 self.model.eval()
15
16 self.transform = transforms.Compose([
17 transforms.Resize(256),
18 transforms.CenterCrop(224),
19 transforms.ToTensor(),
20 transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
21 ])
22
23 def extract(self, images):
24 """Extract features from batch of images."""
25 if not isinstance(images, torch.Tensor):
26 images = torch.stack([self.transform(img) for img in images])
27
28 with torch.no_grad():
29 features = self.model(images)
30
31 return features
32
33# Use extracted features with simple classifier
34class LinearClassifier(nn.Module):
35 def __init__(self, feature_dim, num_classes):
36 super().__init__()
37 self.fc = nn.Sequential(
38 nn.Linear(feature_dim, 512),
39 nn.ReLU(),
40 nn.Dropout(0.3),
41 nn.Linear(512, num_classes)
42 )
43
44 def forward(self, x):
45 return self.fc(x)A complete training pipeline includes proper training loops, learning rate scheduling, and comprehensive evaluation metrics.
Complete Training Pipeline:
1import torch
2import torch.nn as nn
3import torch.optim as optim
4from torch.optim.lr_scheduler import CosineAnnealingLR, OneCycleLR
5from tqdm import tqdm
6
7class ImageClassifier:
8 """Complete image classification training system."""
9
10 def __init__(self, model, device='cuda'):
11 self.model = model.to(device)
12 self.device = device
13 self.history = {'train_loss': [], 'train_acc': [], 'val_loss': [], 'val_acc': []}
14
15 def train_epoch(self, train_loader, criterion, optimizer):
16 """Train for one epoch."""
17 self.model.train()
18 running_loss = 0.0
19 correct = 0
20 total = 0
21
22 for inputs, labels in tqdm(train_loader, desc='Training'):
23 inputs = inputs.to(self.device)
24 labels = labels.to(self.device)
25
26 optimizer.zero_grad()
27 outputs = self.model(inputs)
28 loss = criterion(outputs, labels)
29 loss.backward()
30 optimizer.step()
31
32 running_loss += loss.item() * inputs.size(0)
33 _, predicted = outputs.max(1)
34 total += labels.size(0)
35 correct += predicted.eq(labels).sum().item()
36
37 epoch_loss = running_loss / total
38 epoch_acc = correct / total
39
40 return epoch_loss, epoch_acc
41
42 @torch.no_grad()
43 def validate(self, val_loader, criterion):
44 """Validate the model."""
45 self.model.eval()
46 running_loss = 0.0
47 correct = 0
48 total = 0
49
50 for inputs, labels in tqdm(val_loader, desc='Validation'):
51 inputs = inputs.to(self.device)
52 labels = labels.to(self.device)
53
54 outputs = self.model(inputs)
55 loss = criterion(outputs, labels)
56
57 running_loss += loss.item() * inputs.size(0)
58 _, predicted = outputs.max(1)
59 total += labels.size(0)
60 correct += predicted.eq(labels).sum().item()
61
62 val_loss = running_loss / total
63 val_acc = correct / total
64
65 return val_loss, val_acc
66
67 def fit(self, train_loader, val_loader, epochs=10, lr=1e-3):
68 """Complete training procedure."""
69
70 criterion = nn.CrossEntropyLoss()
71 optimizer = optim.AdamW(
72 filter(lambda p: p.requires_grad, self.model.parameters()),
73 lr=lr, weight_decay=0.01
74 )
75 scheduler = CosineAnnealingLR(optimizer, T_max=epochs)
76
77 best_acc = 0.0
78
79 for epoch in range(epochs):
80 print(f"\nEpoch {epoch+1}/{epochs}")
81
82 train_loss, train_acc = self.train_epoch(train_loader, criterion, optimizer)
83 val_loss, val_acc = self.validate(val_loader, criterion)
84
85 scheduler.step()
86
87 # Log history
88 self.history['train_loss'].append(train_loss)
89 self.history['train_acc'].append(train_acc)
90 self.history['val_loss'].append(val_loss)
91 self.history['val_acc'].append(val_acc)
92
93 print(f"Train Loss: {train_loss:.4f}, Train Acc: {train_acc:.4f}")
94 print(f"Val Loss: {val_loss:.4f}, Val Acc: {val_acc:.4f}")
95
96 # Save best model
97 if val_acc > best_acc:
98 best_acc = val_acc
99 torch.save(self.model.state_dict(), 'best_model.pth')
100 print(f"New best model saved! Acc: {best_acc:.4f}")
101
102 return self.historyComprehensive Evaluation:
1from sklearn.metrics import classification_report, confusion_matrix
2import numpy as np
3
4@torch.no_grad()
5def evaluate_model(model, test_loader, class_names, device='cuda'):
6 """Comprehensive model evaluation."""
7 model.eval()
8 all_preds = []
9 all_labels = []
10 all_probs = []
11
12 for inputs, labels in tqdm(test_loader, desc='Evaluating'):
13 inputs = inputs.to(device)
14 outputs = model(inputs)
15 probs = torch.softmax(outputs, dim=1)
16 _, preds = outputs.max(1)
17
18 all_preds.extend(preds.cpu().numpy())
19 all_labels.extend(labels.numpy())
20 all_probs.extend(probs.cpu().numpy())
21
22 all_preds = np.array(all_preds)
23 all_labels = np.array(all_labels)
24 all_probs = np.array(all_probs)
25
26 # Classification report
27 print("\nClassification Report:")
28 print(classification_report(all_labels, all_preds, target_names=class_names))
29
30 # Confusion matrix
31 cm = confusion_matrix(all_labels, all_preds)
32 print("\nConfusion Matrix:")
33 print(cm)
34
35 # Per-class accuracy
36 per_class_acc = cm.diagonal() / cm.sum(axis=1)
37 print("\nPer-class Accuracy:")
38 for name, acc in zip(class_names, per_class_acc):
39 print(f" {name}: {acc:.4f}")
40
41 return {
42 'predictions': all_preds,
43 'labels': all_labels,
44 'probabilities': all_probs,
45 'confusion_matrix': cm
46 }Build an end-to-end image classification system with transfer learning, data augmentation, and comprehensive evaluation.
1"""
2Complete Image Classification System
3End-to-end pipeline for training and evaluating image classifiers.
4"""
5
6import torch
7import torch.nn as nn
8import torch.optim as optim
9from torch.utils.data import DataLoader
10from torchvision import datasets, transforms, models
11import numpy as np
12from collections import defaultdict
13
14class ImageClassificationSystem:
15 """Complete system for image classification."""
16
17 def __init__(self, num_classes, model_name='efficientnet_b0', device=None):
18 self.device = device or torch.device('cuda' if torch.cuda.is_available() else 'cpu')
19 self.num_classes = num_classes
20 self.model_name = model_name
21 self.model = self._create_model()
22 self.history = defaultdict(list)
23
24 def _create_model(self):
25 """Create model with pretrained weights."""
26 if self.model_name == 'efficientnet_b0':
27 model = models.efficientnet_b0(weights='IMAGENET1K_V1')
28 in_features = model.classifier[1].in_features
29 model.classifier[1] = nn.Linear(in_features, self.num_classes)
30 elif self.model_name == 'resnet50':
31 model = models.resnet50(weights='IMAGENET1K_V2')
32 in_features = model.fc.in_features
33 model.fc = nn.Linear(in_features, self.num_classes)
34 else:
35 raise ValueError(f"Unknown model: {self.model_name}")
36
37 return model.to(self.device)
38
39 def get_transforms(self, is_training=True, image_size=224):
40 """Get appropriate transforms."""
41 if is_training:
42 return transforms.Compose([
43 transforms.RandomResizedCrop(image_size, scale=(0.8, 1.0)),
44 transforms.RandomHorizontalFlip(),
45 transforms.ColorJitter(0.2, 0.2, 0.2, 0.1),
46 transforms.ToTensor(),
47 transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
48 transforms.RandomErasing(p=0.1)
49 ])
50 else:
51 return transforms.Compose([
52 transforms.Resize(image_size + 32),
53 transforms.CenterCrop(image_size),
54 transforms.ToTensor(),
55 transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
56 ])
57
58 def freeze_backbone(self, freeze=True):
59 """Freeze or unfreeze backbone."""
60 if self.model_name == 'efficientnet_b0':
61 for name, param in self.model.named_parameters():
62 if 'classifier' not in name:
63 param.requires_grad = not freeze
64 elif self.model_name == 'resnet50':
65 for name, param in self.model.named_parameters():
66 if 'fc' not in name:
67 param.requires_grad = not freeze
68
69 trainable = sum(p.numel() for p in self.model.parameters() if p.requires_grad)
70 total = sum(p.numel() for p in self.model.parameters())
71 print(f"Trainable: {trainable:,} / {total:,} ({trainable/total*100:.1f}%)")
72
73 def train_step(self, batch, criterion, optimizer):
74 """Single training step."""
75 self.model.train()
76 inputs, labels = batch
77 inputs = inputs.to(self.device)
78 labels = labels.to(self.device)
79
80 optimizer.zero_grad()
81 outputs = self.model(inputs)
82 loss = criterion(outputs, labels)
83 loss.backward()
84 optimizer.step()
85
86 _, preds = outputs.max(1)
87 acc = (preds == labels).float().mean()
88
89 return loss.item(), acc.item()
90
91 @torch.no_grad()
92 def eval_step(self, batch, criterion):
93 """Single evaluation step."""
94 self.model.eval()
95 inputs, labels = batch
96 inputs = inputs.to(self.device)
97 labels = labels.to(self.device)
98
99 outputs = self.model(inputs)
100 loss = criterion(outputs, labels)
101
102 _, preds = outputs.max(1)
103 acc = (preds == labels).float().mean()
104
105 return loss.item(), acc.item()
106
107 def train(self, train_loader, val_loader, epochs=10, lr=1e-3, warmup_epochs=2):
108 """Complete training procedure."""
109 criterion = nn.CrossEntropyLoss()
110 optimizer = optim.AdamW(
111 filter(lambda p: p.requires_grad, self.model.parameters()),
112 lr=lr, weight_decay=0.01
113 )
114
115 # Learning rate scheduler
116 scheduler = optim.lr_scheduler.OneCycleLR(
117 optimizer,
118 max_lr=lr,
119 epochs=epochs,
120 steps_per_epoch=len(train_loader)
121 )
122
123 best_acc = 0.0
124
125 for epoch in range(epochs):
126 # Training
127 train_losses, train_accs = [], []
128 for batch in train_loader:
129 loss, acc = self.train_step(batch, criterion, optimizer)
130 train_losses.append(loss)
131 train_accs.append(acc)
132 scheduler.step()
133
134 # Validation
135 val_losses, val_accs = [], []
136 for batch in val_loader:
137 loss, acc = self.eval_step(batch, criterion)
138 val_losses.append(loss)
139 val_accs.append(acc)
140
141 # Log metrics
142 train_loss = np.mean(train_losses)
143 train_acc = np.mean(train_accs)
144 val_loss = np.mean(val_losses)
145 val_acc = np.mean(val_accs)
146
147 self.history['train_loss'].append(train_loss)
148 self.history['train_acc'].append(train_acc)
149 self.history['val_loss'].append(val_loss)
150 self.history['val_acc'].append(val_acc)
151
152 print(f"Epoch {epoch+1}/{epochs}")
153 print(f" Train Loss: {train_loss:.4f}, Train Acc: {train_acc:.4f}")
154 print(f" Val Loss: {val_loss:.4f}, Val Acc: {val_acc:.4f}")
155
156 # Save best
157 if val_acc > best_acc:
158 best_acc = val_acc
159 self.save_model('best_model.pth')
160
161 return self.history
162
163 @torch.no_grad()
164 def predict(self, images):
165 """Make predictions."""
166 self.model.eval()
167 if not isinstance(images, torch.Tensor):
168 transform = self.get_transforms(is_training=False)
169 images = torch.stack([transform(img) for img in images])
170 images = images.to(self.device)
171 outputs = self.model(images)
172 probs = torch.softmax(outputs, dim=1)
173 return probs.cpu()
174
175 def save_model(self, path):
176 """Save model weights."""
177 torch.save(self.model.state_dict(), path)
178
179 def load_model(self, path):
180 """Load model weights."""
181 self.model.load_state_dict(torch.load(path, map_location=self.device))
182
183
184# Example usage
185print("Creating classification system...")
186system = ImageClassificationSystem(num_classes=10, model_name='efficientnet_b0')
187system.freeze_backbone(freeze=True)
188
189# Create dummy data for testing
190dummy_images = torch.randn(4, 3, 224, 224)
191dummy_labels = torch.tensor([0, 1, 2, 3])
192
193# Test forward pass
194system.model.eval()
195with torch.no_grad():
196 outputs = system.model(dummy_images.to(system.device))
197 probs = torch.softmax(outputs, dim=1)
198
199print(f"\nTest output shape: {outputs.shape}")
200print(f"Probabilities sum: {probs.sum(dim=1)}")
201print("\nTop predictions per sample:")
202for i in range(4):
203 top_prob, top_class = probs[i].max(dim=0)
204 print(f" Sample {i}: Class {top_class.item()}, Prob: {top_prob.item():.4f}")This complete classification system demonstrates production-ready practices including transfer learning with backbone freezing, proper data augmentation, learning rate scheduling with warmup, model checkpointing, and a clean interface for training and inference. The modular design makes it easy to customize for specific datasets and requirements.
In the next lesson, we'll move beyond classification to object detection, learning how models like YOLO, SSD, and Faster R-CNN locate and classify multiple objects within images.
Your progress is saved in this browser, on this device only.