notrito commited on
Commit
e942d15
·
verified ·
1 Parent(s): 9cceb31

Upload folder using huggingface_hub

Browse files
Files changed (12) hide show
  1. .gitignore +36 -0
  2. README.md +210 -0
  3. config.json +14 -0
  4. main.py +193 -0
  5. notebooks/smoker-detection.ipynb +0 -0
  6. requirements.txt +27 -0
  7. src/__init__.py +86 -0
  8. src/dataset.py +183 -0
  9. src/evaluate.py +211 -0
  10. src/model.py +179 -0
  11. src/train.py +195 -0
  12. 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