Upload folder using huggingface_hub
Browse files- .gitignore +36 -0
- README.md +210 -0
- config.json +14 -0
- main.py +193 -0
- notebooks/smoker-detection.ipynb +0 -0
- requirements.txt +27 -0
- src/__init__.py +86 -0
- src/dataset.py +183 -0
- src/evaluate.py +211 -0
- src/model.py +179 -0
- src/train.py +195 -0
- src/utils.py +206 -0
.gitignore
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
env/
|
| 8 |
+
venv/
|
| 9 |
+
.venv/
|
| 10 |
+
*.egg-info/
|
| 11 |
+
|
| 12 |
+
# Jupyter
|
| 13 |
+
.ipynb_checkpoints/
|
| 14 |
+
*.ipynb_checkpoints
|
| 15 |
+
|
| 16 |
+
# Model weights
|
| 17 |
+
*.pth
|
| 18 |
+
*.pt
|
| 19 |
+
best_model.pth
|
| 20 |
+
|
| 21 |
+
# Data
|
| 22 |
+
data/
|
| 23 |
+
*.csv
|
| 24 |
+
*.jpg
|
| 25 |
+
*.png
|
| 26 |
+
*.jpeg
|
| 27 |
+
|
| 28 |
+
# IDE
|
| 29 |
+
.vscode/
|
| 30 |
+
.idea/
|
| 31 |
+
*.swp
|
| 32 |
+
*.swo
|
| 33 |
+
|
| 34 |
+
# OS
|
| 35 |
+
.DS_Store
|
| 36 |
+
Thumbs.db
|
README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
tags:
|
| 4 |
+
- image-classification
|
| 5 |
+
- pytorch
|
| 6 |
+
- resnet
|
| 7 |
+
- lora
|
| 8 |
+
- computer-vision
|
| 9 |
+
- smoking-detection
|
| 10 |
+
datasets:
|
| 11 |
+
- sujaykapadnis/smoking
|
| 12 |
+
metrics:
|
| 13 |
+
- accuracy
|
| 14 |
+
- f1
|
| 15 |
+
library_name: pytorch
|
| 16 |
+
pipeline_tag: image-classification
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
# Smoker Detection with LoRA Fine-Tuning
|
| 20 |
+
|
| 21 |
+
Fine-tuned ResNet34 model using LoRA (Low-Rank Adaptation) for binary smoking detection in images.
|
| 22 |
+
|
| 23 |
+
## Model Description
|
| 24 |
+
|
| 25 |
+
This model uses parameter-efficient fine-tuning with LoRA on a pretrained ResNet34 to classify images as "Smoker" or "Non-Smoker". By training only 2.14% of parameters, it achieves 89.73% test accuracy while preserving ImageNet knowledge.
|
| 26 |
+
|
| 27 |
+
- **Model Type:** ResNet34 + LoRA adapters
|
| 28 |
+
- **Task:** Binary Image Classification
|
| 29 |
+
- **Framework:** PyTorch
|
| 30 |
+
- **License:** MIT
|
| 31 |
+
|
| 32 |
+
## Performance
|
| 33 |
+
|
| 34 |
+
| Split | Accuracy | F1-Score (Smoking) |
|
| 35 |
+
|-------|----------|-------------------|
|
| 36 |
+
| Validation | 94.44% | - |
|
| 37 |
+
| Test | 89.73% | 89.96% |
|
| 38 |
+
|
| 39 |
+
**Efficiency:**
|
| 40 |
+
- Trainable parameters: 465K (2.14% of model)
|
| 41 |
+
- Training time: ~15 minutes on Kaggle T4 GPU
|
| 42 |
+
|
| 43 |
+
## Usage
|
| 44 |
+
|
| 45 |
+
### Installation
|
| 46 |
+
```bash
|
| 47 |
+
pip install torch torchvision pillow
|
| 48 |
+
Load Model
|
| 49 |
+
pythonimport torch
|
| 50 |
+
import torch.nn as nn
|
| 51 |
+
from torchvision import models
|
| 52 |
+
from torchvision.models import ResNet34_Weights
|
| 53 |
+
from PIL import Image
|
| 54 |
+
import torchvision.transforms as transforms
|
| 55 |
+
|
| 56 |
+
# Define LoRA Layer
|
| 57 |
+
class LoRALayer(nn.Module):
|
| 58 |
+
def __init__(self, original_layer, rank=8):
|
| 59 |
+
super().__init__()
|
| 60 |
+
self.original_layer = original_layer
|
| 61 |
+
self.rank = rank
|
| 62 |
+
|
| 63 |
+
out_channels = original_layer.out_channels
|
| 64 |
+
in_channels = original_layer.in_channels
|
| 65 |
+
kernel_size = original_layer.kernel_size
|
| 66 |
+
|
| 67 |
+
self.lora_A = nn.Parameter(
|
| 68 |
+
torch.randn(rank, in_channels, *kernel_size) * 0.01
|
| 69 |
+
)
|
| 70 |
+
self.lora_B = nn.Parameter(
|
| 71 |
+
torch.zeros(out_channels, rank, 1, 1)
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
self.original_layer.weight.requires_grad = False
|
| 75 |
+
if self.original_layer.bias is not None:
|
| 76 |
+
self.original_layer.bias.requires_grad = False
|
| 77 |
+
|
| 78 |
+
def forward(self, x):
|
| 79 |
+
original_output = self.original_layer(x)
|
| 80 |
+
lora_output = nn.functional.conv2d(
|
| 81 |
+
x, self.lora_A,
|
| 82 |
+
stride=self.original_layer.stride,
|
| 83 |
+
padding=self.original_layer.padding
|
| 84 |
+
)
|
| 85 |
+
lora_output = nn.functional.conv2d(lora_output, self.lora_B)
|
| 86 |
+
return original_output + lora_output
|
| 87 |
+
|
| 88 |
+
def apply_lora_to_model(model, rank=8):
|
| 89 |
+
for param in model.parameters():
|
| 90 |
+
param.requires_grad = False
|
| 91 |
+
|
| 92 |
+
for param in model.fc.parameters():
|
| 93 |
+
param.requires_grad = True
|
| 94 |
+
|
| 95 |
+
for block in model.layer3:
|
| 96 |
+
if hasattr(block, 'conv1'):
|
| 97 |
+
block.conv1 = LoRALayer(block.conv1, rank=rank)
|
| 98 |
+
if hasattr(block, 'conv2'):
|
| 99 |
+
block.conv2 = LoRALayer(block.conv2, rank=rank)
|
| 100 |
+
|
| 101 |
+
for block in model.layer4:
|
| 102 |
+
if hasattr(block, 'conv1'):
|
| 103 |
+
block.conv1 = LoRALayer(block.conv1, rank=rank)
|
| 104 |
+
if hasattr(block, 'conv2'):
|
| 105 |
+
block.conv2 = LoRALayer(block.conv2, rank=rank)
|
| 106 |
+
|
| 107 |
+
return model
|
| 108 |
+
|
| 109 |
+
# Load model
|
| 110 |
+
model = models.resnet34(weights=ResNet34_Weights.IMAGENET1K_V1)
|
| 111 |
+
model.fc = nn.Linear(model.fc.in_features, 2)
|
| 112 |
+
model = apply_lora_to_model(model, rank=8)
|
| 113 |
+
|
| 114 |
+
# Load trained weights
|
| 115 |
+
model.load_state_dict(torch.load('best_model.pth', map_location='cpu'))
|
| 116 |
+
model.eval()
|
| 117 |
+
|
| 118 |
+
# Preprocessing
|
| 119 |
+
transform = transforms.Compose([
|
| 120 |
+
transforms.Resize((224, 224)),
|
| 121 |
+
transforms.ToTensor(),
|
| 122 |
+
transforms.Normalize(
|
| 123 |
+
mean=[0.485, 0.456, 0.406],
|
| 124 |
+
std=[0.229, 0.224, 0.225]
|
| 125 |
+
)
|
| 126 |
+
])
|
| 127 |
+
|
| 128 |
+
# Inference
|
| 129 |
+
def predict(image_path):
|
| 130 |
+
image = Image.open(image_path).convert('RGB')
|
| 131 |
+
image_tensor = transform(image).unsqueeze(0)
|
| 132 |
+
|
| 133 |
+
with torch.no_grad():
|
| 134 |
+
outputs = model(image_tensor)
|
| 135 |
+
probs = torch.softmax(outputs, dim=1)
|
| 136 |
+
confidence, predicted = torch.max(probs, 1)
|
| 137 |
+
|
| 138 |
+
classes = ['Non-Smoker', 'Smoker']
|
| 139 |
+
return classes[predicted.item()], confidence.item() * 100
|
| 140 |
+
|
| 141 |
+
# Example
|
| 142 |
+
prediction, confidence = predict('image.jpg')
|
| 143 |
+
print(f"{prediction} ({confidence:.1f}% confidence)")
|
| 144 |
+
Training Details
|
| 145 |
+
Dataset: 1,120 images from Kaggle Smoking Detection Dataset
|
| 146 |
+
|
| 147 |
+
Training: 716 images (64%)
|
| 148 |
+
Validation: 180 images (16%)
|
| 149 |
+
Test: 224 images (20%)
|
| 150 |
+
|
| 151 |
+
Hyperparameters:
|
| 152 |
+
|
| 153 |
+
Learning Rate: 1e-4
|
| 154 |
+
Optimizer: AdamW (weight decay: 1e-4)
|
| 155 |
+
Batch Size: 32
|
| 156 |
+
Epochs: 15
|
| 157 |
+
LoRA Rank: 8
|
| 158 |
+
|
| 159 |
+
Data Augmentation:
|
| 160 |
+
|
| 161 |
+
Random horizontal flip (p=0.5)
|
| 162 |
+
Random rotation (±10°)
|
| 163 |
+
Color jitter (brightness, contrast, saturation)
|
| 164 |
+
|
| 165 |
+
What is LoRA?
|
| 166 |
+
LoRA (Low-Rank Adaptation) adds small trainable matrices to frozen pretrained weights:
|
| 167 |
+
Output = W_frozen × input + (B × A) × input
|
| 168 |
+
Where A and B are low-rank matrices (rank=8), adding only 2.14% trainable parameters while maintaining model capacity.
|
| 169 |
+
Benefits:
|
| 170 |
+
|
| 171 |
+
Prevents overfitting on small datasets
|
| 172 |
+
Preserves pretrained ImageNet features
|
| 173 |
+
Faster training and lower memory usage
|
| 174 |
+
Easier deployment (smaller checkpoint files)
|
| 175 |
+
|
| 176 |
+
Model Architecture
|
| 177 |
+
ResNet34 (21.7M parameters)
|
| 178 |
+
├── Frozen Layers (21.3M - 97.86%)
|
| 179 |
+
│ ├── conv1, layer1, layer2
|
| 180 |
+
│ └── Pretrained ImageNet weights
|
| 181 |
+
└── Trainable Layers (465K - 2.14%)
|
| 182 |
+
├── LoRA adapters on layer3 (6 blocks)
|
| 183 |
+
├── LoRA adapters on layer4 (3 blocks)
|
| 184 |
+
└── Classification head fc (512 → 2)
|
| 185 |
+
Limitations
|
| 186 |
+
|
| 187 |
+
Trained on limited dataset (1,120 images)
|
| 188 |
+
Low resolution images (250×250)
|
| 189 |
+
May not generalize to all smoking scenarios
|
| 190 |
+
Best for frontal/profile views with visible cigarettes
|
| 191 |
+
|
| 192 |
+
Citation
|
| 193 |
+
bibtex@misc{smoker-detection-lora,
|
| 194 |
+
author = {Noel Triguero},
|
| 195 |
+
title = {Smoker Detection with LoRA Fine-Tuning},
|
| 196 |
+
year = {2025},
|
| 197 |
+
publisher = {Hugging Face},
|
| 198 |
+
howpublished = {\url{https://huggingface.co/notrito/smoker-detection}}
|
| 199 |
+
}
|
| 200 |
+
References
|
| 201 |
+
|
| 202 |
+
LoRA Paper - Hu et al., 2021
|
| 203 |
+
Dataset - Sujay Kapadnis
|
| 204 |
+
Training Notebook
|
| 205 |
+
|
| 206 |
+
Contact
|
| 207 |
+
|
| 208 |
+
Author: Noel Triguero
|
| 209 |
+
Email: noel.triguero@gmail.com
|
| 210 |
+
Kaggle: notrito
|
config.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_type": "resnet34-lora",
|
| 3 |
+
"architecture": "ResNet34 with LoRA adapters",
|
| 4 |
+
"task": "image-classification",
|
| 5 |
+
"num_classes": 2,
|
| 6 |
+
"class_names": ["Non-Smoker", "Smoker"],
|
| 7 |
+
"lora_config": {
|
| 8 |
+
"rank": 8,
|
| 9 |
+
"target_layers": ["layer3", "layer4"]
|
| 10 |
+
},
|
| 11 |
+
"input_size": [224, 224],
|
| 12 |
+
"pretrained_weights": "ImageNet",
|
| 13 |
+
"framework": "PyTorch"
|
| 14 |
+
}
|
main.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Main training script for Smoker Detection with LoRA.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python train.py --data_path /path/to/data --epochs 15 --lr 1e-4 --rank 8
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import argparse
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
import torch
|
| 11 |
+
|
| 12 |
+
from src.model import get_model, apply_lora_to_model, count_parameters
|
| 13 |
+
from src.dataset import create_dataloaders
|
| 14 |
+
from src.train import train_model, get_optimizer_and_criterion
|
| 15 |
+
from src.evaluate import (
|
| 16 |
+
evaluate_model,
|
| 17 |
+
print_classification_report,
|
| 18 |
+
plot_confusion_matrix,
|
| 19 |
+
plot_training_history
|
| 20 |
+
)
|
| 21 |
+
from src.utils import set_seed, get_device, create_directories, print_dataset_info
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def parse_args():
|
| 25 |
+
"""Parse command line arguments."""
|
| 26 |
+
parser = argparse.ArgumentParser(description='Train Smoker Detection Model with LoRA')
|
| 27 |
+
|
| 28 |
+
# Data arguments
|
| 29 |
+
parser.add_argument('--data_path', type=str, default='/kaggle/input/smoking',
|
| 30 |
+
help='Path to dataset root directory')
|
| 31 |
+
|
| 32 |
+
# Model arguments
|
| 33 |
+
parser.add_argument('--rank', type=int, default=8,
|
| 34 |
+
help='LoRA rank (default: 8)')
|
| 35 |
+
parser.add_argument('--target_layers', nargs='+', default=['layer3', 'layer4'],
|
| 36 |
+
help='Layers to apply LoRA to (default: layer3 layer4)')
|
| 37 |
+
|
| 38 |
+
# Training arguments
|
| 39 |
+
parser.add_argument('--epochs', type=int, default=15,
|
| 40 |
+
help='Number of training epochs (default: 15)')
|
| 41 |
+
parser.add_argument('--batch_size', type=int, default=32,
|
| 42 |
+
help='Batch size (default: 32)')
|
| 43 |
+
parser.add_argument('--lr', type=float, default=1e-4,
|
| 44 |
+
help='Learning rate (default: 1e-4)')
|
| 45 |
+
parser.add_argument('--weight_decay', type=float, default=1e-4,
|
| 46 |
+
help='Weight decay (default: 1e-4)')
|
| 47 |
+
parser.add_argument('--img_size', type=int, default=224,
|
| 48 |
+
help='Image size (default: 224)')
|
| 49 |
+
parser.add_argument('--num_workers', type=int, default=2,
|
| 50 |
+
help='Number of data loading workers (default: 2)')
|
| 51 |
+
|
| 52 |
+
# Output arguments
|
| 53 |
+
parser.add_argument('--output_dir', type=str, default='results',
|
| 54 |
+
help='Directory to save outputs (default: results)')
|
| 55 |
+
parser.add_argument('--model_save_path', type=str, default='best_model.pth',
|
| 56 |
+
help='Path to save best model (default: best_model.pth)')
|
| 57 |
+
|
| 58 |
+
# Other arguments
|
| 59 |
+
parser.add_argument('--seed', type=int, default=42,
|
| 60 |
+
help='Random seed (default: 42)')
|
| 61 |
+
parser.add_argument('--no_cuda', action='store_true',
|
| 62 |
+
help='Disable CUDA even if available')
|
| 63 |
+
|
| 64 |
+
return parser.parse_args()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def main():
|
| 68 |
+
"""Main training function."""
|
| 69 |
+
args = parse_args()
|
| 70 |
+
|
| 71 |
+
# Setup
|
| 72 |
+
print("\n" + "="*60)
|
| 73 |
+
print("🚀 Smoker Detection Training with LoRA")
|
| 74 |
+
print("="*60 + "\n")
|
| 75 |
+
|
| 76 |
+
# Set seed for reproducibility
|
| 77 |
+
set_seed(args.seed)
|
| 78 |
+
|
| 79 |
+
# Create output directory
|
| 80 |
+
create_directories([args.output_dir])
|
| 81 |
+
|
| 82 |
+
# Get device
|
| 83 |
+
device = get_device()
|
| 84 |
+
if args.no_cuda:
|
| 85 |
+
device = torch.device('cpu')
|
| 86 |
+
print("CUDA disabled by user, using CPU")
|
| 87 |
+
|
| 88 |
+
# Data paths
|
| 89 |
+
data_path = Path(args.data_path)
|
| 90 |
+
train_path = data_path / 'Training' / 'Training'
|
| 91 |
+
val_path = data_path / 'Validation' / 'Validation'
|
| 92 |
+
test_path = data_path / 'Testing' / 'Testing'
|
| 93 |
+
|
| 94 |
+
# Create dataloaders
|
| 95 |
+
print("\n📦 Loading data...")
|
| 96 |
+
train_loader, val_loader, test_loader = create_dataloaders(
|
| 97 |
+
train_path=train_path,
|
| 98 |
+
val_path=val_path,
|
| 99 |
+
test_path=test_path,
|
| 100 |
+
batch_size=args.batch_size,
|
| 101 |
+
img_size=args.img_size,
|
| 102 |
+
num_workers=args.num_workers
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
# Print dataset info
|
| 106 |
+
print_dataset_info(train_loader, val_loader, test_loader)
|
| 107 |
+
|
| 108 |
+
# Create model
|
| 109 |
+
print("\n🏗️ Building model...")
|
| 110 |
+
model = get_model(num_classes=2, pretrained=True)
|
| 111 |
+
model = model.to(device)
|
| 112 |
+
|
| 113 |
+
# Apply LoRA
|
| 114 |
+
print(f"\n🔧 Applying LoRA (rank={args.rank})...")
|
| 115 |
+
num_lora_layers = apply_lora_to_model(
|
| 116 |
+
model,
|
| 117 |
+
target_layers=args.target_layers,
|
| 118 |
+
rank=args.rank
|
| 119 |
+
)
|
| 120 |
+
print(f"✅ LoRA applied to {num_lora_layers} convolutional layers")
|
| 121 |
+
|
| 122 |
+
# Count parameters
|
| 123 |
+
total_params, trainable_params, trainable_pct = count_parameters(model)
|
| 124 |
+
print(f"\n📊 Parameter Count:")
|
| 125 |
+
print(f" Total: {total_params:,}")
|
| 126 |
+
print(f" Trainable: {trainable_params:,} ({trainable_pct:.2f}%)")
|
| 127 |
+
print(f" Frozen: {total_params - trainable_params:,} ({100 - trainable_pct:.2f}%)")
|
| 128 |
+
|
| 129 |
+
# Get optimizer and criterion
|
| 130 |
+
print("\n⚙️ Setting up training...")
|
| 131 |
+
optimizer, criterion = get_optimizer_and_criterion(
|
| 132 |
+
model,
|
| 133 |
+
lr=args.lr,
|
| 134 |
+
weight_decay=args.weight_decay
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# Train model
|
| 138 |
+
print("\n" + "="*60)
|
| 139 |
+
history = train_model(
|
| 140 |
+
model=model,
|
| 141 |
+
train_loader=train_loader,
|
| 142 |
+
val_loader=val_loader,
|
| 143 |
+
criterion=criterion,
|
| 144 |
+
optimizer=optimizer,
|
| 145 |
+
device=device,
|
| 146 |
+
num_epochs=args.epochs,
|
| 147 |
+
save_path=args.model_save_path
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
# Plot training curves
|
| 151 |
+
print("\n📊 Plotting training history...")
|
| 152 |
+
fig = plot_training_history(
|
| 153 |
+
history,
|
| 154 |
+
save_path=f'{args.output_dir}/training_curves.png'
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# Evaluate on test set
|
| 158 |
+
print("\n" + "="*60)
|
| 159 |
+
print("🧪 Testing on held-out test set...")
|
| 160 |
+
print("="*60)
|
| 161 |
+
|
| 162 |
+
# Load best model
|
| 163 |
+
model.load_state_dict(torch.load(args.model_save_path))
|
| 164 |
+
|
| 165 |
+
# Get predictions
|
| 166 |
+
predictions, labels, test_acc = evaluate_model(
|
| 167 |
+
model, test_loader, device
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
# Print classification report
|
| 171 |
+
print_classification_report(predictions, labels)
|
| 172 |
+
|
| 173 |
+
# Plot confusion matrix
|
| 174 |
+
print("\n📊 Plotting confusion matrix...")
|
| 175 |
+
fig = plot_confusion_matrix(
|
| 176 |
+
predictions,
|
| 177 |
+
labels,
|
| 178 |
+
save_path=f'{args.output_dir}/confusion_matrix.png'
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
# Final summary
|
| 182 |
+
print("\n" + "="*60)
|
| 183 |
+
print("✅ Training Complete!")
|
| 184 |
+
print("="*60)
|
| 185 |
+
print(f"\n📁 Outputs saved to: {args.output_dir}/")
|
| 186 |
+
print(f" - Training curves: {args.output_dir}/training_curves.png")
|
| 187 |
+
print(f" - Confusion matrix: {args.output_dir}/confusion_matrix.png")
|
| 188 |
+
print(f" - Best model: {args.model_save_path}")
|
| 189 |
+
print(f"\n🎯 Final Test Accuracy: {test_acc:.2f}%\n")
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
if __name__ == '__main__':
|
| 193 |
+
main()
|
notebooks/smoker-detection.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deep Learning
|
| 2 |
+
torch>=2.0.0
|
| 3 |
+
torchvision>=0.15.0
|
| 4 |
+
|
| 5 |
+
# Data Processing
|
| 6 |
+
numpy>=1.24.0
|
| 7 |
+
pandas>=2.0.0
|
| 8 |
+
Pillow>=9.5.0
|
| 9 |
+
|
| 10 |
+
# Visualization
|
| 11 |
+
matplotlib>=3.7.0
|
| 12 |
+
seaborn>=0.12.0
|
| 13 |
+
|
| 14 |
+
# Metrics
|
| 15 |
+
scikit-learn>=1.3.0
|
| 16 |
+
|
| 17 |
+
# Progress bars
|
| 18 |
+
tqdm>=4.65.0
|
| 19 |
+
|
| 20 |
+
# Jupyter (optional, for notebooks)
|
| 21 |
+
jupyter>=1.0.0
|
| 22 |
+
ipywidgets>=8.0.0
|
| 23 |
+
|
| 24 |
+
# Configuration (optional)
|
| 25 |
+
pyyaml>=6.0
|
| 26 |
+
|
| 27 |
+
huggingface_hub
|
src/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Smoker Detection with LoRA Fine-Tuning
|
| 3 |
+
|
| 4 |
+
A parameter-efficient approach to binary image classification using
|
| 5 |
+
Low-Rank Adaptation (LoRA) on pretrained ResNet34.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from .model import (
|
| 9 |
+
LoRALayer,
|
| 10 |
+
get_model,
|
| 11 |
+
apply_lora_to_model,
|
| 12 |
+
count_parameters
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
from .dataset import (
|
| 16 |
+
SmokerDataset,
|
| 17 |
+
get_transforms,
|
| 18 |
+
create_dataloaders
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
from .train import (
|
| 22 |
+
train_one_epoch,
|
| 23 |
+
validate,
|
| 24 |
+
train_model,
|
| 25 |
+
get_optimizer_and_criterion
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
from .evaluate import (
|
| 29 |
+
evaluate_model,
|
| 30 |
+
print_classification_report,
|
| 31 |
+
plot_confusion_matrix,
|
| 32 |
+
plot_training_history,
|
| 33 |
+
get_predictions_with_confidence,
|
| 34 |
+
analyze_errors
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
from .utils import (
|
| 38 |
+
set_seed,
|
| 39 |
+
get_device,
|
| 40 |
+
save_checkpoint,
|
| 41 |
+
load_checkpoint,
|
| 42 |
+
visualize_samples,
|
| 43 |
+
print_dataset_info,
|
| 44 |
+
create_directories,
|
| 45 |
+
count_dataset_images
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
__version__ = '1.0.0'
|
| 49 |
+
__author__ = 'Your Name'
|
| 50 |
+
|
| 51 |
+
__all__ = [
|
| 52 |
+
# Model
|
| 53 |
+
'LoRALayer',
|
| 54 |
+
'get_model',
|
| 55 |
+
'apply_lora_to_model',
|
| 56 |
+
'count_parameters',
|
| 57 |
+
|
| 58 |
+
# Dataset
|
| 59 |
+
'SmokerDataset',
|
| 60 |
+
'get_transforms',
|
| 61 |
+
'create_dataloaders',
|
| 62 |
+
|
| 63 |
+
# Training
|
| 64 |
+
'train_one_epoch',
|
| 65 |
+
'validate',
|
| 66 |
+
'train_model',
|
| 67 |
+
'get_optimizer_and_criterion',
|
| 68 |
+
|
| 69 |
+
# Evaluation
|
| 70 |
+
'evaluate_model',
|
| 71 |
+
'print_classification_report',
|
| 72 |
+
'plot_confusion_matrix',
|
| 73 |
+
'plot_training_history',
|
| 74 |
+
'get_predictions_with_confidence',
|
| 75 |
+
'analyze_errors',
|
| 76 |
+
|
| 77 |
+
# Utils
|
| 78 |
+
'set_seed',
|
| 79 |
+
'get_device',
|
| 80 |
+
'save_checkpoint',
|
| 81 |
+
'load_checkpoint',
|
| 82 |
+
'visualize_samples',
|
| 83 |
+
'print_dataset_info',
|
| 84 |
+
'create_directories',
|
| 85 |
+
'count_dataset_images',
|
| 86 |
+
]
|
src/dataset.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Custom Dataset class for Smoker Detection.
|
| 3 |
+
Handles loading images and labels from folder structure.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from PIL import Image
|
| 9 |
+
import torch
|
| 10 |
+
from torch.utils.data import Dataset, DataLoader
|
| 11 |
+
from torchvision import transforms
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class SmokerDataset(Dataset):
|
| 15 |
+
"""
|
| 16 |
+
Custom Dataset for Smoker Detection.
|
| 17 |
+
|
| 18 |
+
Expects folder structure with images named:
|
| 19 |
+
- smoking_XXX.jpg for positive class
|
| 20 |
+
- notsmoking_XXX.jpg for negative class
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
folder_path: Path to folder containing images
|
| 24 |
+
transform: Optional torchvision transforms to apply
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(self, folder_path, transform=None):
|
| 28 |
+
self.folder_path = Path(folder_path)
|
| 29 |
+
self.transform = transform
|
| 30 |
+
|
| 31 |
+
# Get all image paths and labels
|
| 32 |
+
self.image_paths = []
|
| 33 |
+
self.labels = []
|
| 34 |
+
|
| 35 |
+
# Load smoking images (label = 1)
|
| 36 |
+
for img_path in self.folder_path.glob('smoking_*.jpg'):
|
| 37 |
+
self.image_paths.append(img_path)
|
| 38 |
+
self.labels.append(1)
|
| 39 |
+
|
| 40 |
+
# Load not smoking images (label = 0)
|
| 41 |
+
for img_path in self.folder_path.glob('notsmoking_*.jpg'):
|
| 42 |
+
self.image_paths.append(img_path)
|
| 43 |
+
self.labels.append(0)
|
| 44 |
+
|
| 45 |
+
# Verify dataset is not empty
|
| 46 |
+
if len(self.image_paths) == 0:
|
| 47 |
+
raise ValueError(f"No images found in {folder_path}")
|
| 48 |
+
|
| 49 |
+
print(f"Loaded {len(self.image_paths)} images from {folder_path.name}")
|
| 50 |
+
print(f" - Smoking: {sum(self.labels)}")
|
| 51 |
+
print(f" - Not Smoking: {len(self.labels) - sum(self.labels)}")
|
| 52 |
+
|
| 53 |
+
def __len__(self):
|
| 54 |
+
return len(self.image_paths)
|
| 55 |
+
|
| 56 |
+
def __getitem__(self, idx):
|
| 57 |
+
# Load image
|
| 58 |
+
img_path = self.image_paths[idx]
|
| 59 |
+
image = Image.open(img_path).convert('RGB')
|
| 60 |
+
label = self.labels[idx]
|
| 61 |
+
|
| 62 |
+
# Apply transforms
|
| 63 |
+
if self.transform:
|
| 64 |
+
image = self.transform(image)
|
| 65 |
+
|
| 66 |
+
return image, label
|
| 67 |
+
|
| 68 |
+
def get_class_distribution(self):
|
| 69 |
+
"""
|
| 70 |
+
Get the distribution of classes in the dataset.
|
| 71 |
+
|
| 72 |
+
Returns:
|
| 73 |
+
dict: {'smoking': count, 'not_smoking': count}
|
| 74 |
+
"""
|
| 75 |
+
smoking_count = sum(self.labels)
|
| 76 |
+
not_smoking_count = len(self.labels) - smoking_count
|
| 77 |
+
|
| 78 |
+
return {
|
| 79 |
+
'smoking': smoking_count,
|
| 80 |
+
'not_smoking': not_smoking_count,
|
| 81 |
+
'total': len(self.labels),
|
| 82 |
+
'balance': smoking_count / len(self.labels)
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def get_transforms(img_size=224, augment=True):
|
| 87 |
+
"""
|
| 88 |
+
Get image transformations for training or validation.
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
img_size: Target image size (default: 224 for ImageNet models)
|
| 92 |
+
augment: Whether to apply data augmentation
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
torchvision.transforms.Compose object
|
| 96 |
+
"""
|
| 97 |
+
# ImageNet normalization (standard for pretrained models)
|
| 98 |
+
mean = [0.485, 0.456, 0.406]
|
| 99 |
+
std = [0.229, 0.224, 0.225]
|
| 100 |
+
|
| 101 |
+
if augment:
|
| 102 |
+
# Training transforms with augmentation
|
| 103 |
+
transform = transforms.Compose([
|
| 104 |
+
transforms.Resize((img_size, img_size)),
|
| 105 |
+
transforms.RandomHorizontalFlip(p=0.5), # Smoking can occur on either side
|
| 106 |
+
transforms.RandomRotation(degrees=10), # Slight rotations for robustness
|
| 107 |
+
transforms.ColorJitter( # Lighting variations
|
| 108 |
+
brightness=0.2,
|
| 109 |
+
contrast=0.2,
|
| 110 |
+
saturation=0.2,
|
| 111 |
+
hue=0.1
|
| 112 |
+
),
|
| 113 |
+
transforms.ToTensor(),
|
| 114 |
+
transforms.Normalize(mean=mean, std=std)
|
| 115 |
+
])
|
| 116 |
+
else:
|
| 117 |
+
# Validation/Test transforms (no augmentation)
|
| 118 |
+
transform = transforms.Compose([
|
| 119 |
+
transforms.Resize((img_size, img_size)),
|
| 120 |
+
transforms.ToTensor(),
|
| 121 |
+
transforms.Normalize(mean=mean, std=std)
|
| 122 |
+
])
|
| 123 |
+
|
| 124 |
+
return transform
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def create_dataloaders(train_path, val_path, test_path, batch_size=32,
|
| 128 |
+
img_size=224, num_workers=2):
|
| 129 |
+
"""
|
| 130 |
+
Create DataLoaders for training, validation, and test sets.
|
| 131 |
+
|
| 132 |
+
Args:
|
| 133 |
+
train_path: Path to training data folder
|
| 134 |
+
val_path: Path to validation data folder
|
| 135 |
+
test_path: Path to test data folder
|
| 136 |
+
batch_size: Batch size for DataLoader (default: 32)
|
| 137 |
+
img_size: Image size for resizing (default: 224)
|
| 138 |
+
num_workers: Number of parallel workers for data loading (default: 2)
|
| 139 |
+
|
| 140 |
+
Returns:
|
| 141 |
+
tuple: (train_loader, val_loader, test_loader)
|
| 142 |
+
"""
|
| 143 |
+
# Get transforms
|
| 144 |
+
train_transforms = get_transforms(img_size=img_size, augment=True)
|
| 145 |
+
val_transforms = get_transforms(img_size=img_size, augment=False)
|
| 146 |
+
|
| 147 |
+
# Create datasets
|
| 148 |
+
train_dataset = SmokerDataset(train_path, transform=train_transforms)
|
| 149 |
+
val_dataset = SmokerDataset(val_path, transform=val_transforms)
|
| 150 |
+
test_dataset = SmokerDataset(test_path, transform=val_transforms)
|
| 151 |
+
|
| 152 |
+
# Create dataloaders
|
| 153 |
+
train_loader = DataLoader(
|
| 154 |
+
train_dataset,
|
| 155 |
+
batch_size=batch_size,
|
| 156 |
+
shuffle=True, # Randomize batch composition each epoch
|
| 157 |
+
num_workers=num_workers,
|
| 158 |
+
pin_memory=True # Faster GPU transfer
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
val_loader = DataLoader(
|
| 162 |
+
val_dataset,
|
| 163 |
+
batch_size=batch_size,
|
| 164 |
+
shuffle=False, # Keep order for reproducible evaluation
|
| 165 |
+
num_workers=num_workers,
|
| 166 |
+
pin_memory=True
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
test_loader = DataLoader(
|
| 170 |
+
test_dataset,
|
| 171 |
+
batch_size=batch_size,
|
| 172 |
+
shuffle=False,
|
| 173 |
+
num_workers=num_workers,
|
| 174 |
+
pin_memory=True
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
print("\n✅ DataLoaders created")
|
| 178 |
+
print(f" Training batches: {len(train_loader)}")
|
| 179 |
+
print(f" Validation batches: {len(val_loader)}")
|
| 180 |
+
print(f" Test batches: {len(test_loader)}")
|
| 181 |
+
print(f" Batch size: {batch_size}")
|
| 182 |
+
|
| 183 |
+
return train_loader, val_loader, test_loader
|
src/evaluate.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Evaluation functions for model testing and visualization.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
import numpy as np
|
| 7 |
+
import matplotlib.pyplot as plt
|
| 8 |
+
import seaborn as sns
|
| 9 |
+
from tqdm import tqdm
|
| 10 |
+
from sklearn.metrics import classification_report, confusion_matrix
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def evaluate_model(model, test_loader, device, class_names=['Not Smoking', 'Smoking']):
|
| 14 |
+
"""
|
| 15 |
+
Evaluate model on test set and return predictions and labels.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
model: PyTorch model
|
| 19 |
+
test_loader: DataLoader for test data
|
| 20 |
+
device: Device to evaluate on (cuda/cpu)
|
| 21 |
+
class_names: List of class names for reporting
|
| 22 |
+
|
| 23 |
+
Returns:
|
| 24 |
+
tuple: (all_predictions, all_labels, test_accuracy)
|
| 25 |
+
"""
|
| 26 |
+
model.eval()
|
| 27 |
+
all_preds = []
|
| 28 |
+
all_labels = []
|
| 29 |
+
|
| 30 |
+
print("🧪 Evaluating on Test Set...")
|
| 31 |
+
print(f" Test batches: {len(test_loader)}\n")
|
| 32 |
+
|
| 33 |
+
with torch.no_grad():
|
| 34 |
+
for images, labels in tqdm(test_loader, desc="Testing"):
|
| 35 |
+
images = images.to(device)
|
| 36 |
+
outputs = model(images)
|
| 37 |
+
_, predicted = outputs.max(1)
|
| 38 |
+
|
| 39 |
+
all_preds.extend(predicted.cpu().numpy())
|
| 40 |
+
all_labels.extend(labels.numpy())
|
| 41 |
+
|
| 42 |
+
# Calculate accuracy
|
| 43 |
+
test_acc = 100. * sum(p == l for p, l in zip(all_preds, all_labels)) / len(all_labels)
|
| 44 |
+
|
| 45 |
+
return all_preds, all_labels, test_acc
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def print_classification_report(predictions, labels, class_names=['Not Smoking', 'Smoking']):
|
| 49 |
+
"""
|
| 50 |
+
Print detailed classification metrics.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
predictions: List of predicted labels
|
| 54 |
+
labels: List of true labels
|
| 55 |
+
class_names: List of class names
|
| 56 |
+
"""
|
| 57 |
+
print(f"\n{'='*60}")
|
| 58 |
+
print(f"📊 TEST SET RESULTS")
|
| 59 |
+
print(f"{'='*60}")
|
| 60 |
+
|
| 61 |
+
# Overall accuracy
|
| 62 |
+
test_acc = 100. * sum(p == l for p, l in zip(predictions, labels)) / len(labels)
|
| 63 |
+
print(f"\n Overall Accuracy: {test_acc:.2f}%\n")
|
| 64 |
+
|
| 65 |
+
# Detailed report
|
| 66 |
+
print("\nDetailed Classification Report:")
|
| 67 |
+
print(classification_report(labels, predictions, target_names=class_names, digits=4))
|
| 68 |
+
print(f"{'='*60}")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def plot_confusion_matrix(predictions, labels, class_names=['Not Smoking', 'Smoking'],
|
| 72 |
+
save_path=None):
|
| 73 |
+
"""
|
| 74 |
+
Plot confusion matrix.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
predictions: List of predicted labels
|
| 78 |
+
labels: List of true labels
|
| 79 |
+
class_names: List of class names
|
| 80 |
+
save_path: Optional path to save the figure
|
| 81 |
+
|
| 82 |
+
Returns:
|
| 83 |
+
matplotlib figure
|
| 84 |
+
"""
|
| 85 |
+
cm = confusion_matrix(labels, predictions)
|
| 86 |
+
|
| 87 |
+
fig, ax = plt.subplots(figsize=(8, 6))
|
| 88 |
+
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
|
| 89 |
+
xticklabels=class_names, yticklabels=class_names,
|
| 90 |
+
cbar_kws={'label': 'Count'}, ax=ax)
|
| 91 |
+
ax.set_title('Confusion Matrix - Test Set', fontsize=14, fontweight='bold')
|
| 92 |
+
ax.set_ylabel('True Label')
|
| 93 |
+
ax.set_xlabel('Predicted Label')
|
| 94 |
+
plt.tight_layout()
|
| 95 |
+
|
| 96 |
+
if save_path:
|
| 97 |
+
plt.savefig(save_path, dpi=300, bbox_inches='tight')
|
| 98 |
+
print(f"Confusion matrix saved to {save_path}")
|
| 99 |
+
|
| 100 |
+
return fig
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def plot_training_history(history, save_path=None):
|
| 104 |
+
"""
|
| 105 |
+
Plot training and validation loss/accuracy curves.
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
history: Dictionary with keys 'train_loss', 'val_loss', 'train_acc', 'val_acc'
|
| 109 |
+
save_path: Optional path to save the figure
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
matplotlib figure
|
| 113 |
+
"""
|
| 114 |
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 5))
|
| 115 |
+
|
| 116 |
+
# Loss curves
|
| 117 |
+
ax1.plot(history['train_loss'], label='Train Loss', marker='o', linewidth=2)
|
| 118 |
+
ax1.plot(history['val_loss'], label='Val Loss', marker='s', linewidth=2)
|
| 119 |
+
ax1.set_xlabel('Epoch', fontsize=12)
|
| 120 |
+
ax1.set_ylabel('Loss', fontsize=12)
|
| 121 |
+
ax1.set_title('Training and Validation Loss', fontsize=14, fontweight='bold')
|
| 122 |
+
ax1.legend(fontsize=11)
|
| 123 |
+
ax1.grid(True, alpha=0.3)
|
| 124 |
+
|
| 125 |
+
# Accuracy curves
|
| 126 |
+
ax2.plot(history['train_acc'], label='Train Accuracy', marker='o', linewidth=2)
|
| 127 |
+
ax2.plot(history['val_acc'], label='Val Accuracy', marker='s', linewidth=2)
|
| 128 |
+
ax2.set_xlabel('Epoch', fontsize=12)
|
| 129 |
+
ax2.set_ylabel('Accuracy (%)', fontsize=12)
|
| 130 |
+
ax2.set_title('Training and Validation Accuracy', fontsize=14, fontweight='bold')
|
| 131 |
+
ax2.legend(fontsize=11)
|
| 132 |
+
ax2.grid(True, alpha=0.3)
|
| 133 |
+
|
| 134 |
+
plt.tight_layout()
|
| 135 |
+
|
| 136 |
+
if save_path:
|
| 137 |
+
plt.savefig(save_path, dpi=300, bbox_inches='tight')
|
| 138 |
+
print(f"Training history saved to {save_path}")
|
| 139 |
+
|
| 140 |
+
return fig
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def get_predictions_with_confidence(model, dataloader, device):
|
| 144 |
+
"""
|
| 145 |
+
Get predictions along with confidence scores.
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
model: PyTorch model
|
| 149 |
+
dataloader: DataLoader for data
|
| 150 |
+
device: Device to run inference on
|
| 151 |
+
|
| 152 |
+
Returns:
|
| 153 |
+
tuple: (predictions, confidences, labels)
|
| 154 |
+
"""
|
| 155 |
+
model.eval()
|
| 156 |
+
all_preds = []
|
| 157 |
+
all_confidences = []
|
| 158 |
+
all_labels = []
|
| 159 |
+
|
| 160 |
+
with torch.no_grad():
|
| 161 |
+
for images, labels in dataloader:
|
| 162 |
+
images = images.to(device)
|
| 163 |
+
outputs = model(images)
|
| 164 |
+
|
| 165 |
+
# Get softmax probabilities
|
| 166 |
+
probs = torch.softmax(outputs, dim=1)
|
| 167 |
+
confidences, predicted = probs.max(1)
|
| 168 |
+
|
| 169 |
+
all_preds.extend(predicted.cpu().numpy())
|
| 170 |
+
all_confidences.extend(confidences.cpu().numpy())
|
| 171 |
+
all_labels.extend(labels.numpy())
|
| 172 |
+
|
| 173 |
+
return np.array(all_preds), np.array(all_confidences), np.array(all_labels)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def analyze_errors(model, dataloader, device, dataset, num_samples=10):
|
| 177 |
+
"""
|
| 178 |
+
Analyze misclassified samples.
|
| 179 |
+
|
| 180 |
+
Args:
|
| 181 |
+
model: PyTorch model
|
| 182 |
+
dataloader: DataLoader for data
|
| 183 |
+
device: Device to run inference on
|
| 184 |
+
dataset: Original dataset to access images
|
| 185 |
+
num_samples: Number of error samples to display
|
| 186 |
+
|
| 187 |
+
Returns:
|
| 188 |
+
List of dictionaries with error information
|
| 189 |
+
"""
|
| 190 |
+
predictions, confidences, labels = get_predictions_with_confidence(model, dataloader, device)
|
| 191 |
+
|
| 192 |
+
# Find misclassified samples
|
| 193 |
+
errors = []
|
| 194 |
+
for idx, (pred, conf, label) in enumerate(zip(predictions, confidences, labels)):
|
| 195 |
+
if pred != label:
|
| 196 |
+
errors.append({
|
| 197 |
+
'index': idx,
|
| 198 |
+
'true_label': label,
|
| 199 |
+
'predicted_label': pred,
|
| 200 |
+
'confidence': conf,
|
| 201 |
+
'image_path': dataset.image_paths[idx]
|
| 202 |
+
})
|
| 203 |
+
|
| 204 |
+
print(f"\n🔍 Error Analysis:")
|
| 205 |
+
print(f" Total errors: {len(errors)}")
|
| 206 |
+
print(f" Error rate: {100 * len(errors) / len(labels):.2f}%")
|
| 207 |
+
|
| 208 |
+
# Sort by confidence (highest confidence errors are most interesting)
|
| 209 |
+
errors.sort(key=lambda x: x['confidence'], reverse=True)
|
| 210 |
+
|
| 211 |
+
return errors[:num_samples]
|
src/model.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LoRA (Low-Rank Adaptation) implementation for convolutional layers.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn as nn
|
| 7 |
+
import torch.nn.functional as F
|
| 8 |
+
from torchvision import models
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class LoRALayer(nn.Module):
|
| 12 |
+
"""
|
| 13 |
+
LoRA (Low-Rank Adaptation) wrapper for convolutional layers.
|
| 14 |
+
|
| 15 |
+
Args:
|
| 16 |
+
original_layer: The Conv2d layer to adapt
|
| 17 |
+
rank: LoRA rank (default=8)
|
| 18 |
+
- Lower rank (4): Fewer parameters, less overfitting risk, less capacity
|
| 19 |
+
- Medium rank (8-16): Balanced trade-off (recommended for most tasks)
|
| 20 |
+
- Higher rank (32+): More capacity but approaches full fine-tuning
|
| 21 |
+
|
| 22 |
+
For small datasets (<1000 images), rank=8 provides sufficient
|
| 23 |
+
adaptation capacity while keeping parameters low (~2% of original layer).
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(self, original_layer, rank=8):
|
| 27 |
+
super().__init__()
|
| 28 |
+
self.original_layer = original_layer
|
| 29 |
+
self.rank = rank
|
| 30 |
+
|
| 31 |
+
# Get dimensions from original layer
|
| 32 |
+
out_channels = original_layer.out_channels
|
| 33 |
+
in_channels = original_layer.in_channels
|
| 34 |
+
kernel_size = original_layer.kernel_size
|
| 35 |
+
|
| 36 |
+
# LoRA matrices: A (down-projection) and B (up-projection)
|
| 37 |
+
# A reduces dimensions: in_channels -> rank
|
| 38 |
+
# Initialized with small random values to break symmetry
|
| 39 |
+
self.lora_A = nn.Parameter(
|
| 40 |
+
torch.randn(rank, in_channels, *kernel_size) * 0.01
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# B expands dimensions: rank -> out_channels
|
| 44 |
+
# Initialized to zeros so LoRA starts as identity (preserves pretrained weights)
|
| 45 |
+
# This initialization strategy follows the original LoRA paper
|
| 46 |
+
self.lora_B = nn.Parameter(
|
| 47 |
+
torch.zeros(out_channels, rank, 1, 1)
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
# Freeze original weights (preserve ImageNet knowledge)
|
| 51 |
+
self.original_layer.weight.requires_grad = False
|
| 52 |
+
if self.original_layer.bias is not None:
|
| 53 |
+
self.original_layer.bias.requires_grad = False
|
| 54 |
+
|
| 55 |
+
def forward(self, x):
|
| 56 |
+
"""
|
| 57 |
+
Forward pass combining original frozen weights with LoRA adaptation.
|
| 58 |
+
|
| 59 |
+
Mathematical formulation:
|
| 60 |
+
output = W_frozen * x + (B * (A * x))
|
| 61 |
+
|
| 62 |
+
where * denotes convolution operation.
|
| 63 |
+
"""
|
| 64 |
+
# Original forward pass (frozen pretrained weights)
|
| 65 |
+
original_output = self.original_layer(x)
|
| 66 |
+
|
| 67 |
+
# LoRA adaptation pathway (low-rank decomposition)
|
| 68 |
+
# Step 1: Down-project with A (in_channels → rank)
|
| 69 |
+
lora_output = F.conv2d(
|
| 70 |
+
x,
|
| 71 |
+
self.lora_A,
|
| 72 |
+
stride=self.original_layer.stride,
|
| 73 |
+
padding=self.original_layer.padding
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Step 2: Up-project with B (rank → out_channels)
|
| 77 |
+
# These two sequential convolutions approximate a low-rank adaptation
|
| 78 |
+
lora_output = F.conv2d(lora_output, self.lora_B)
|
| 79 |
+
|
| 80 |
+
# Combine: W*x + (B*(A*x)) where * denotes convolution
|
| 81 |
+
return original_output + lora_output
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def get_model(num_classes=2, pretrained=True):
|
| 85 |
+
"""
|
| 86 |
+
Load ResNet34 with optional pretrained weights.
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
num_classes: Number of output classes
|
| 90 |
+
pretrained: Whether to load ImageNet pretrained weights
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
ResNet34 model
|
| 94 |
+
"""
|
| 95 |
+
if pretrained:
|
| 96 |
+
model = models.resnet34(weights=models.ResNet34_Weights.IMAGENET1K_V1)
|
| 97 |
+
else:
|
| 98 |
+
model = models.resnet34(weights=None)
|
| 99 |
+
|
| 100 |
+
# Modify last layer for classification
|
| 101 |
+
num_features = model.fc.in_features
|
| 102 |
+
model.fc = nn.Linear(num_features, num_classes)
|
| 103 |
+
|
| 104 |
+
return model
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def apply_lora_to_model(model, target_layers=['layer3', 'layer4'], rank=8):
|
| 108 |
+
"""
|
| 109 |
+
Apply LoRA adapters to specific layers in ResNet34.
|
| 110 |
+
|
| 111 |
+
Strategy: We target layer3 and layer4 (high-level feature extractors) because:
|
| 112 |
+
- layer1 & layer2: Extract low-level features (edges, textures) that are
|
| 113 |
+
universal across tasks → keep frozen, no adaptation needed
|
| 114 |
+
- layer3 & layer4: Extract high-level semantic features (objects, contexts)
|
| 115 |
+
that are task-specific → need slight adaptation for smoking detection
|
| 116 |
+
- fc: Brand new classifier head → fully trainable
|
| 117 |
+
|
| 118 |
+
This approach gives us the sweet spot:
|
| 119 |
+
- Full fine-tuning: 21.8M params (overfitting risk with small datasets)
|
| 120 |
+
- Only fc training: ~1K params (may underfit, features not adapted)
|
| 121 |
+
- LoRA on layer3+layer4: ~465K params (2.14% of model, balanced approach)
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
model: ResNet34 model
|
| 125 |
+
target_layers: List of layer names to apply LoRA to
|
| 126 |
+
rank: LoRA rank (default=8, adds ~2% params per adapted layer)
|
| 127 |
+
|
| 128 |
+
Returns:
|
| 129 |
+
Number of convolutional layers where LoRA was applied
|
| 130 |
+
"""
|
| 131 |
+
# Freeze ALL layers first (preserve ImageNet features)
|
| 132 |
+
for param in model.parameters():
|
| 133 |
+
param.requires_grad = False
|
| 134 |
+
|
| 135 |
+
# Unfreeze only the new classification head
|
| 136 |
+
for param in model.fc.parameters():
|
| 137 |
+
param.requires_grad = True
|
| 138 |
+
|
| 139 |
+
lora_count = 0
|
| 140 |
+
|
| 141 |
+
for layer_name in target_layers:
|
| 142 |
+
# Get the layer dynamically (e.g., model.layer3)
|
| 143 |
+
layer = getattr(model, layer_name)
|
| 144 |
+
|
| 145 |
+
# Iterate through all blocks in this layer
|
| 146 |
+
for block in layer:
|
| 147 |
+
# Find all Conv2d layers in this block dynamically
|
| 148 |
+
for name, module in block.named_modules():
|
| 149 |
+
if isinstance(module, nn.Conv2d):
|
| 150 |
+
# Get parent module and attribute name to replace it
|
| 151 |
+
parent = block
|
| 152 |
+
attr_names = name.split('.')
|
| 153 |
+
|
| 154 |
+
# Navigate to parent of the conv layer
|
| 155 |
+
for attr in attr_names[:-1]:
|
| 156 |
+
parent = getattr(parent, attr)
|
| 157 |
+
|
| 158 |
+
# Check if not already wrapped
|
| 159 |
+
current_module = getattr(parent, attr_names[-1])
|
| 160 |
+
if not isinstance(current_module, LoRALayer):
|
| 161 |
+
# Replace with LoRA-wrapped version
|
| 162 |
+
setattr(parent, attr_names[-1], LoRALayer(current_module, rank=rank))
|
| 163 |
+
lora_count += 1
|
| 164 |
+
|
| 165 |
+
return lora_count
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def count_parameters(model):
|
| 169 |
+
"""
|
| 170 |
+
Count total and trainable parameters in the model.
|
| 171 |
+
|
| 172 |
+
Returns:
|
| 173 |
+
tuple: (total_params, trainable_params, trainable_percentage)
|
| 174 |
+
"""
|
| 175 |
+
total_params = sum(p.numel() for p in model.parameters())
|
| 176 |
+
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 177 |
+
trainable_pct = 100. * trainable_params / total_params
|
| 178 |
+
|
| 179 |
+
return total_params, trainable_params, trainable_pct
|
src/train.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Training and validation functions for the smoker detection model.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn as nn
|
| 7 |
+
from tqdm import tqdm
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def train_one_epoch(model, train_loader, criterion, optimizer, device):
|
| 11 |
+
"""
|
| 12 |
+
Train the model for one epoch.
|
| 13 |
+
|
| 14 |
+
Args:
|
| 15 |
+
model: PyTorch model
|
| 16 |
+
train_loader: DataLoader for training data
|
| 17 |
+
criterion: Loss function
|
| 18 |
+
optimizer: Optimizer
|
| 19 |
+
device: Device to train on (cuda/cpu)
|
| 20 |
+
|
| 21 |
+
Returns:
|
| 22 |
+
tuple: (epoch_loss, epoch_accuracy)
|
| 23 |
+
"""
|
| 24 |
+
model.train()
|
| 25 |
+
running_loss = 0.0
|
| 26 |
+
correct = 0
|
| 27 |
+
total = 0
|
| 28 |
+
|
| 29 |
+
for images, labels in tqdm(train_loader, desc="Training", leave=False):
|
| 30 |
+
images, labels = images.to(device), labels.to(device)
|
| 31 |
+
|
| 32 |
+
# Zero gradients
|
| 33 |
+
optimizer.zero_grad()
|
| 34 |
+
|
| 35 |
+
# Forward pass
|
| 36 |
+
outputs = model(images)
|
| 37 |
+
loss = criterion(outputs, labels)
|
| 38 |
+
|
| 39 |
+
# Backward pass and optimization
|
| 40 |
+
loss.backward()
|
| 41 |
+
optimizer.step()
|
| 42 |
+
|
| 43 |
+
# Statistics
|
| 44 |
+
running_loss += loss.item()
|
| 45 |
+
_, predicted = outputs.max(1)
|
| 46 |
+
total += labels.size(0)
|
| 47 |
+
correct += predicted.eq(labels).sum().item()
|
| 48 |
+
|
| 49 |
+
epoch_loss = running_loss / len(train_loader)
|
| 50 |
+
epoch_acc = 100. * correct / total
|
| 51 |
+
|
| 52 |
+
return epoch_loss, epoch_acc
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def validate(model, val_loader, criterion, device):
|
| 56 |
+
"""
|
| 57 |
+
Validate the model.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
model: PyTorch model
|
| 61 |
+
val_loader: DataLoader for validation data
|
| 62 |
+
criterion: Loss function
|
| 63 |
+
device: Device to validate on (cuda/cpu)
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
tuple: (epoch_loss, epoch_accuracy)
|
| 67 |
+
"""
|
| 68 |
+
model.eval()
|
| 69 |
+
running_loss = 0.0
|
| 70 |
+
correct = 0
|
| 71 |
+
total = 0
|
| 72 |
+
|
| 73 |
+
with torch.no_grad():
|
| 74 |
+
for images, labels in tqdm(val_loader, desc="Validation", leave=False):
|
| 75 |
+
images, labels = images.to(device), labels.to(device)
|
| 76 |
+
|
| 77 |
+
# Forward pass
|
| 78 |
+
outputs = model(images)
|
| 79 |
+
loss = criterion(outputs, labels)
|
| 80 |
+
|
| 81 |
+
# Statistics
|
| 82 |
+
running_loss += loss.item()
|
| 83 |
+
_, predicted = outputs.max(1)
|
| 84 |
+
total += labels.size(0)
|
| 85 |
+
correct += predicted.eq(labels).sum().item()
|
| 86 |
+
|
| 87 |
+
epoch_loss = running_loss / len(val_loader)
|
| 88 |
+
epoch_acc = 100. * correct / total
|
| 89 |
+
|
| 90 |
+
return epoch_loss, epoch_acc
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def train_model(model, train_loader, val_loader, criterion, optimizer,
|
| 94 |
+
device, num_epochs=15, save_path='best_model.pth'):
|
| 95 |
+
"""
|
| 96 |
+
Complete training loop with validation and model checkpointing.
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
model: PyTorch model
|
| 100 |
+
train_loader: DataLoader for training data
|
| 101 |
+
val_loader: DataLoader for validation data
|
| 102 |
+
criterion: Loss function
|
| 103 |
+
optimizer: Optimizer
|
| 104 |
+
device: Device to train on (cuda/cpu)
|
| 105 |
+
num_epochs: Number of training epochs (default: 15)
|
| 106 |
+
save_path: Path to save best model (default: 'best_model.pth')
|
| 107 |
+
|
| 108 |
+
Returns:
|
| 109 |
+
dict: Training history with losses and accuracies
|
| 110 |
+
"""
|
| 111 |
+
best_val_acc = 0.0
|
| 112 |
+
history = {
|
| 113 |
+
'train_loss': [],
|
| 114 |
+
'train_acc': [],
|
| 115 |
+
'val_loss': [],
|
| 116 |
+
'val_acc': []
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
print("🚀 Starting training...")
|
| 120 |
+
print(f" Epochs: {num_epochs}")
|
| 121 |
+
print(f" Device: {device}")
|
| 122 |
+
print(f" Training batches: {len(train_loader)}")
|
| 123 |
+
print(f" Validation batches: {len(val_loader)}\n")
|
| 124 |
+
|
| 125 |
+
for epoch in range(num_epochs):
|
| 126 |
+
print(f"\nEpoch {epoch+1}/{num_epochs}")
|
| 127 |
+
print("-" * 60)
|
| 128 |
+
|
| 129 |
+
# Train
|
| 130 |
+
train_loss, train_acc = train_one_epoch(
|
| 131 |
+
model, train_loader, criterion, optimizer, device
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
# Validate
|
| 135 |
+
val_loss, val_acc = validate(
|
| 136 |
+
model, val_loader, criterion, device
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
# Save history
|
| 140 |
+
history['train_loss'].append(train_loss)
|
| 141 |
+
history['train_acc'].append(train_acc)
|
| 142 |
+
history['val_loss'].append(val_loss)
|
| 143 |
+
history['val_acc'].append(val_acc)
|
| 144 |
+
|
| 145 |
+
# Print results
|
| 146 |
+
print(f"\nResults:")
|
| 147 |
+
print(f" Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.2f}%")
|
| 148 |
+
print(f" Val Loss: {val_loss:.4f} | Val Acc: {val_acc:.2f}%")
|
| 149 |
+
|
| 150 |
+
# Save best model
|
| 151 |
+
if val_acc > best_val_acc:
|
| 152 |
+
best_val_acc = val_acc
|
| 153 |
+
torch.save(model.state_dict(), save_path)
|
| 154 |
+
print(f" ✅ New best model saved! (Val Acc: {val_acc:.2f}%)")
|
| 155 |
+
|
| 156 |
+
print("\n" + "="*60)
|
| 157 |
+
print(f"🎉 Training completed!")
|
| 158 |
+
print(f" Best validation accuracy: {best_val_acc:.2f}%")
|
| 159 |
+
print(f" Model saved to: {save_path}")
|
| 160 |
+
|
| 161 |
+
return history
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def get_optimizer_and_criterion(model, lr=1e-4, weight_decay=1e-4):
|
| 165 |
+
"""
|
| 166 |
+
Create optimizer and loss criterion with standard hyperparameters.
|
| 167 |
+
|
| 168 |
+
Args:
|
| 169 |
+
model: PyTorch model
|
| 170 |
+
lr: Learning rate (default: 1e-4, conservative for fine-tuning)
|
| 171 |
+
weight_decay: L2 regularization (default: 1e-4)
|
| 172 |
+
|
| 173 |
+
Returns:
|
| 174 |
+
tuple: (optimizer, criterion)
|
| 175 |
+
"""
|
| 176 |
+
# Loss function
|
| 177 |
+
criterion = nn.CrossEntropyLoss()
|
| 178 |
+
|
| 179 |
+
# Optimizer - only optimize trainable parameters
|
| 180 |
+
optimizer = torch.optim.AdamW(
|
| 181 |
+
filter(lambda p: p.requires_grad, model.parameters()),
|
| 182 |
+
lr=lr,
|
| 183 |
+
weight_decay=weight_decay
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
print("✅ Training configuration ready")
|
| 187 |
+
print(f" Loss: CrossEntropyLoss")
|
| 188 |
+
print(f" Optimizer: AdamW")
|
| 189 |
+
print(f" Learning rate: {lr}")
|
| 190 |
+
print(f" Weight decay: {weight_decay}")
|
| 191 |
+
|
| 192 |
+
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 193 |
+
print(f" Optimizing {trainable_params:,} parameters")
|
| 194 |
+
|
| 195 |
+
return optimizer, criterion
|
src/utils.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utility functions for the smoker detection project.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import random
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
import matplotlib.pyplot as plt
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def set_seed(seed=42):
|
| 14 |
+
"""
|
| 15 |
+
Set random seed for reproducibility.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
seed: Random seed value (default: 42)
|
| 19 |
+
"""
|
| 20 |
+
random.seed(seed)
|
| 21 |
+
np.random.seed(seed)
|
| 22 |
+
torch.manual_seed(seed)
|
| 23 |
+
if torch.cuda.is_available():
|
| 24 |
+
torch.cuda.manual_seed(seed)
|
| 25 |
+
torch.cuda.manual_seed_all(seed)
|
| 26 |
+
torch.backends.cudnn.deterministic = True
|
| 27 |
+
torch.backends.cudnn.benchmark = False
|
| 28 |
+
|
| 29 |
+
print(f"✅ Random seed set to {seed}")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def get_device():
|
| 33 |
+
"""
|
| 34 |
+
Get the device to use (cuda or cpu).
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
torch.device: Device to use for training/inference
|
| 38 |
+
"""
|
| 39 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 40 |
+
|
| 41 |
+
if torch.cuda.is_available():
|
| 42 |
+
print(f"✅ GPU available: {torch.cuda.get_device_name(0)}")
|
| 43 |
+
else:
|
| 44 |
+
print("⚠️ No GPU available, using CPU")
|
| 45 |
+
|
| 46 |
+
return device
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def save_checkpoint(model, optimizer, epoch, val_acc, path='checkpoint.pth'):
|
| 50 |
+
"""
|
| 51 |
+
Save model checkpoint.
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
model: PyTorch model
|
| 55 |
+
optimizer: Optimizer
|
| 56 |
+
epoch: Current epoch number
|
| 57 |
+
val_acc: Validation accuracy
|
| 58 |
+
path: Path to save checkpoint
|
| 59 |
+
"""
|
| 60 |
+
checkpoint = {
|
| 61 |
+
'epoch': epoch,
|
| 62 |
+
'model_state_dict': model.state_dict(),
|
| 63 |
+
'optimizer_state_dict': optimizer.state_dict(),
|
| 64 |
+
'val_acc': val_acc
|
| 65 |
+
}
|
| 66 |
+
torch.save(checkpoint, path)
|
| 67 |
+
print(f"Checkpoint saved to {path}")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def load_checkpoint(model, optimizer, path='checkpoint.pth'):
|
| 71 |
+
"""
|
| 72 |
+
Load model checkpoint.
|
| 73 |
+
|
| 74 |
+
Args:
|
| 75 |
+
model: PyTorch model
|
| 76 |
+
optimizer: Optimizer
|
| 77 |
+
path: Path to checkpoint file
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
tuple: (epoch, val_acc)
|
| 81 |
+
"""
|
| 82 |
+
checkpoint = torch.load(path)
|
| 83 |
+
model.load_state_dict(checkpoint['model_state_dict'])
|
| 84 |
+
optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
|
| 85 |
+
epoch = checkpoint['epoch']
|
| 86 |
+
val_acc = checkpoint['val_acc']
|
| 87 |
+
|
| 88 |
+
print(f"Checkpoint loaded from {path}")
|
| 89 |
+
print(f" Epoch: {epoch}, Val Acc: {val_acc:.2f}%")
|
| 90 |
+
|
| 91 |
+
return epoch, val_acc
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def visualize_samples(dataset, num_samples=8, class_names=['Not Smoking', 'Smoking']):
|
| 95 |
+
"""
|
| 96 |
+
Visualize random samples from the dataset.
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
dataset: SmokerDataset instance
|
| 100 |
+
num_samples: Number of samples to display
|
| 101 |
+
class_names: List of class names
|
| 102 |
+
|
| 103 |
+
Returns:
|
| 104 |
+
matplotlib figure
|
| 105 |
+
"""
|
| 106 |
+
# Get random indices
|
| 107 |
+
indices = random.sample(range(len(dataset)), num_samples)
|
| 108 |
+
|
| 109 |
+
# Calculate grid size
|
| 110 |
+
cols = 4
|
| 111 |
+
rows = (num_samples + cols - 1) // cols
|
| 112 |
+
|
| 113 |
+
fig, axes = plt.subplots(rows, cols, figsize=(16, 4*rows))
|
| 114 |
+
axes = axes.flatten() if num_samples > 1 else [axes]
|
| 115 |
+
|
| 116 |
+
for idx, ax in zip(indices, axes):
|
| 117 |
+
# Get image (without transform for visualization)
|
| 118 |
+
img_path = dataset.image_paths[idx]
|
| 119 |
+
from PIL import Image
|
| 120 |
+
img = Image.open(img_path)
|
| 121 |
+
label = dataset.labels[idx]
|
| 122 |
+
|
| 123 |
+
# Display
|
| 124 |
+
ax.imshow(img)
|
| 125 |
+
ax.set_title(f'{class_names[label]}\n{img.size[0]}x{img.size[1]}',
|
| 126 |
+
fontsize=10, fontweight='bold',
|
| 127 |
+
color='red' if label == 1 else 'green')
|
| 128 |
+
ax.axis('off')
|
| 129 |
+
|
| 130 |
+
# Hide extra subplots
|
| 131 |
+
for ax in axes[num_samples:]:
|
| 132 |
+
ax.axis('off')
|
| 133 |
+
|
| 134 |
+
plt.tight_layout()
|
| 135 |
+
return fig
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def print_dataset_info(train_loader, val_loader, test_loader):
|
| 139 |
+
"""
|
| 140 |
+
Print information about the datasets.
|
| 141 |
+
|
| 142 |
+
Args:
|
| 143 |
+
train_loader: Training DataLoader
|
| 144 |
+
val_loader: Validation DataLoader
|
| 145 |
+
test_loader: Test DataLoader
|
| 146 |
+
"""
|
| 147 |
+
print("\n" + "="*60)
|
| 148 |
+
print("📊 Dataset Information")
|
| 149 |
+
print("="*60)
|
| 150 |
+
|
| 151 |
+
train_size = len(train_loader.dataset)
|
| 152 |
+
val_size = len(val_loader.dataset)
|
| 153 |
+
test_size = len(test_loader.dataset)
|
| 154 |
+
total_size = train_size + val_size + test_size
|
| 155 |
+
|
| 156 |
+
print(f"\nDataset Splits:")
|
| 157 |
+
print(f" Training: {train_size:4d} images ({100*train_size/total_size:.1f}%)")
|
| 158 |
+
print(f" Validation: {val_size:4d} images ({100*val_size/total_size:.1f}%)")
|
| 159 |
+
print(f" Test: {test_size:4d} images ({100*test_size/total_size:.1f}%)")
|
| 160 |
+
print(f" Total: {total_size:4d} images")
|
| 161 |
+
|
| 162 |
+
print(f"\nBatch Information:")
|
| 163 |
+
print(f" Batch size: {train_loader.batch_size}")
|
| 164 |
+
print(f" Train batches: {len(train_loader)}")
|
| 165 |
+
print(f" Val batches: {len(val_loader)}")
|
| 166 |
+
print(f" Test batches: {len(test_loader)}")
|
| 167 |
+
print("="*60 + "\n")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def create_directories(dirs):
|
| 171 |
+
"""
|
| 172 |
+
Create directories if they don't exist.
|
| 173 |
+
|
| 174 |
+
Args:
|
| 175 |
+
dirs: List of directory paths to create
|
| 176 |
+
"""
|
| 177 |
+
for dir_path in dirs:
|
| 178 |
+
Path(dir_path).mkdir(parents=True, exist_ok=True)
|
| 179 |
+
print(f"✅ Directories created: {', '.join(dirs)}")
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def count_dataset_images(data_path):
|
| 183 |
+
"""
|
| 184 |
+
Count images in dataset folders.
|
| 185 |
+
|
| 186 |
+
Args:
|
| 187 |
+
data_path: Path to dataset root
|
| 188 |
+
|
| 189 |
+
Returns:
|
| 190 |
+
dict: Image counts per split
|
| 191 |
+
"""
|
| 192 |
+
data_path = Path(data_path)
|
| 193 |
+
counts = {}
|
| 194 |
+
|
| 195 |
+
for split in ['Training', 'Validation', 'Testing']:
|
| 196 |
+
folder = data_path / split / split
|
| 197 |
+
if folder.exists():
|
| 198 |
+
smoking = len(list(folder.glob('smoking_*.jpg')))
|
| 199 |
+
not_smoking = len(list(folder.glob('notsmoking_*.jpg')))
|
| 200 |
+
counts[split] = {
|
| 201 |
+
'smoking': smoking,
|
| 202 |
+
'not_smoking': not_smoking,
|
| 203 |
+
'total': smoking + not_smoking
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
return counts
|