alshami-dev commited on
Commit
0b86da8
·
verified ·
1 Parent(s): 706817d

First Update to the App

Browse files
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ models/comparison/failure_analysis_comparison.png filter=lfs diff=lfs merge=lfs -text
37
+ models/comparison/failure_analysis_resnet_vs_simple.png filter=lfs diff=lfs merge=lfs -text
app.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from src.app import build_app
2
+
3
+ # Entry point for Hugging Face Spaces
4
+ app = build_app()
5
+
6
+ if __name__ == "__main__":
7
+ # Note: Hugging Face handles the port and sharing automatically.
8
+ # We do NOT use share=True here.
9
+ app.launch()
config.yaml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ device: "auto" # "cuda", "cpu", or "auto"
2
+ classes: ["glass", "paper", "cardboard", "plastic", "metal", "trash"]
3
+ split: [0.70, 0.15, 0.15]
4
+ img_size: [224, 224]
5
+ batch_size: 32
6
+ epochs: 25
7
+ patience: 5
8
+ lr: 0.001
9
+ random_seed: 42
models/best_model.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b45931b554f896f70e5cfdc7e4cbeff59370a530ca77bda3c16a1f38bb1b5157
3
+ size 44799288
models/comparison/DeepCNN/confusion_matrix.png ADDED
models/comparison/ResNet18/confusion_matrix.png ADDED
models/comparison/SimpleCNN/confusion_matrix.png ADDED
models/comparison/accuracy_comparison.png ADDED
models/comparison/failure_analysis_comparison.png ADDED

Git LFS Details

  • SHA256: 7dcc3a66c9946bc6562ea13a273b22fe4b1a9aaed6d41f8e846c2bd5612f80e6
  • Pointer size: 131 Bytes
  • Size of remote file: 571 kB
models/comparison/failure_analysis_resnet_vs_simple.png ADDED

Git LFS Details

  • SHA256: 33e5f05a90412678d3da3fcc29f21af58c3e11b8e778ae7fdb5c5ae9a876073e
  • Pointer size: 131 Bytes
  • Size of remote file: 442 kB
models/deepcnn_best.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a1e5ddb49a5ae181e3279e737d8a44d2c777a24829a6a2d7ef0c5036400a6744
3
+ size 13244572
models/plots/deepcnn_history.png ADDED
models/plots/resnet18_history.png ADDED
models/plots/simplecnn_history.png ADDED
models/plots/training_history.png ADDED
models/resnet18_best.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:acb3a9e380f3fa3c5efdd41b9762e0bd3302477beefa9b52ccbd4ebf07568ae0
3
+ size 44799666
models/simplecnn_best.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:63d5d6122d38837fb7fede63147cc3b49d98bb83b3e454c151daa673c0a440de
3
+ size 60814
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Production Requirements for Hugging Face Spaces
2
+ torch>=2.0.0
3
+ torchvision
4
+ numpy>=1.24.0
5
+ scikit-learn>=1.3.0
6
+ Pillow
7
+ requests>=2.31.0
8
+ pyyaml
9
+ gradio
src/__init__.py ADDED
File without changes
src/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (166 Bytes). View file
 
src/__pycache__/comparison.cpython-312.pyc ADDED
Binary file (8 kB). View file
 
src/__pycache__/dataset.cpython-312.pyc ADDED
Binary file (9.2 kB). View file
 
src/__pycache__/evaluate.cpython-312.pyc ADDED
Binary file (4.82 kB). View file
 
src/__pycache__/model.cpython-312.pyc ADDED
Binary file (6.14 kB). View file
 
src/app.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ import gradio as gr
5
+ import torch
6
+ import yaml
7
+ from torchvision import transforms
8
+
9
+ # Add project root to sys.path
10
+ sys.path.append(str(Path(__file__).parent.parent))
11
+
12
+ from src.model import ResNet18Transfer # noqa: E402
13
+
14
+ # ── Config ───────────────────────────────────────────────────────────────────
15
+
16
+
17
+ def load_config(config_path="config.yaml"):
18
+ with open(config_path, "r") as f:
19
+ return yaml.safe_load(f)
20
+
21
+
22
+ config = load_config()
23
+ CLASSES = config["classes"]
24
+
25
+
26
+ def get_device(cfg_device):
27
+ if cfg_device == "auto":
28
+ return "cuda" if torch.cuda.is_available() else "cpu"
29
+ return cfg_device
30
+
31
+
32
+ DEVICE = get_device(config["device"])
33
+
34
+ # ── Model ─────────────────────────────────────────────────────────────────────
35
+
36
+ model = ResNet18Transfer(num_classes=len(CLASSES), pretrained=False)
37
+ model_path = "models/resnet18_best.pth"
38
+
39
+ try:
40
+ model.load_state_dict(torch.load(model_path, map_location=DEVICE, weights_only=True))
41
+ print(f"Loaded model from {model_path}")
42
+ except FileNotFoundError:
43
+ # Fallback to general best_model if specific name is missing
44
+ alt_path = "models/best_model.pth"
45
+ if Path(alt_path).exists():
46
+ model.load_state_dict(torch.load(alt_path, map_location=DEVICE, weights_only=True))
47
+ print(f"Loaded model from {alt_path}")
48
+ else:
49
+ print("Warning: Model checkpoints not found. Using untrained model.")
50
+
51
+ model.to(DEVICE)
52
+ model.eval()
53
+
54
+ # ── Transform ─────────────────────────────────────────────────────────────────
55
+
56
+ transform = transforms.Compose(
57
+ [
58
+ transforms.Resize((224, 224)),
59
+ transforms.ToTensor(),
60
+ transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
61
+ ]
62
+ )
63
+
64
+ # ── Translations ──────────────────────────────────────────────────────────────
65
+
66
+ TRANSLATIONS = {
67
+ "en": {
68
+ "title": "🗑️ Trash Classifier Pro",
69
+ "description": "Enterprise-grade waste classification powered by Deep Learning.", # noqa: E501
70
+ "input_label": "Waste Image Upload",
71
+ "output_label": "Classification Analysis",
72
+ "btn_lang": "🇩🇪 Deutsch",
73
+ "btn_classify": "🔍 Run Analysis",
74
+ "no_image": "⚠️ Please upload an image first.",
75
+ "info_header": "Information Hub",
76
+ "model_details": (
77
+ "### Model Information\n"
78
+ "- **Architecture:** ResNet18 (Transfer Learning)\n"
79
+ "- **Accuracy:** 92.4% on test set\n"
80
+ "- **Framework:** PyTorch 2.x\n"
81
+ "- **Backend:** CPU/GPU automated switching"
82
+ ),
83
+ "instructions": (
84
+ "### How to use\n"
85
+ "1. Upload a clear photo of an item.\n"
86
+ "2. The model will analyze texture and shape.\n"
87
+ "3. View the confidence scores and recycling tips."
88
+ ),
89
+ "tips_header": "Recycling Tip",
90
+ "tips": {
91
+ "glass": "Glass is 100% recyclable. Please remove caps and rinse containers.", # noqa: E501
92
+ "paper": "Avoid recycling paper contaminated with food (like pizza boxes).", # noqa: E501
93
+ "cardboard": "Flatten boxes to save space in the recycling bin.",
94
+ "plastic": "Check the recycling code. Rinse to avoid contamination.",
95
+ "metal": "Aluminum and steel cans are highly valuable for recycling.",
96
+ "trash": "This item belongs in general waste. Check local disposal rules.", # noqa: E501
97
+ },
98
+ "class_names": {
99
+ "glass": "Glass",
100
+ "paper": "Paper",
101
+ "cardboard": "Cardboard",
102
+ "plastic": "Plastic",
103
+ "metal": "Metal",
104
+ "trash": "General Waste",
105
+ },
106
+ },
107
+ "de": {
108
+ "title": "🗑️ Müll-Klassifikator Pro",
109
+ "description": "Professionelle Abfallklassifizierung basierend auf Deep Learning.", # noqa: E501
110
+ "input_label": "Müllbild hochladen",
111
+ "output_label": "Klassifikations-Analyse",
112
+ "btn_lang": "🇬🇧 English",
113
+ "btn_classify": "🔍 Analyse starten",
114
+ "no_image": "⚠️ Bitte zuerst ein Bild hochladen.",
115
+ "info_header": "Informationszentrum",
116
+ "model_details": (
117
+ "### Modell-Informationen\n"
118
+ "- **Architektur:** ResNet18 (Transfer Learning)\n"
119
+ "- **Genauigkeit:** 92,4% auf dem Test-Set\n"
120
+ "- **Framework:** PyTorch 2.x\n"
121
+ "- **Backend:** Automatische CPU/GPU Umschaltung"
122
+ ),
123
+ "instructions": (
124
+ "### Anleitung\n"
125
+ "1. Lade ein scharfes Foto eines Gegenstands hoch.\n"
126
+ "2. Das Modell analysiert Textur und Form.\n"
127
+ "3. Sieh dir die Konfidenzwerte und Recycling-Tipps an."
128
+ ),
129
+ "tips_header": "Recycling-Tipp",
130
+ "tips": {
131
+ "glass": "Glas ist zu 100% recycelbar. Bitte Deckel entfernen und Behälter ausspülen.", # noqa: E501
132
+ "paper": "Vermeide das Recycling von verschmutztem Papier (z.B. Pizzakartons).", # noqa: E501
133
+ "cardboard": "Kartons flachdrücken, um Platz in der Tonne zu sparen.",
134
+ "plastic": "Prüfe den Recycling-Code. Ausspülen verhindert Kontamination.", # noqa: E501
135
+ "metal": "Alu- und Stahlmüll ist sehr wertvoll für das Recycling.",
136
+ "trash": "Dieser Gegenstand gehört in den Restmüll. Prüfe lokale Regeln.", # noqa: E501
137
+ },
138
+ "class_names": {
139
+ "glass": "Glas",
140
+ "paper": "Papier",
141
+ "cardboard": "Pappe",
142
+ "plastic": "Plastik",
143
+ "metal": "Metall",
144
+ "trash": "Restmüll",
145
+ },
146
+ },
147
+ }
148
+
149
+
150
+ # ── Inference ─────────────────────────────────────────────────────────────────
151
+
152
+
153
+ def predict(image, lang="en"):
154
+ t = TRANSLATIONS[lang]
155
+
156
+ if image is None:
157
+ return {}, t["no_image"]
158
+
159
+ img_tensor = transform(image).unsqueeze(0).to(DEVICE)
160
+
161
+ with torch.no_grad():
162
+ outputs = model(img_tensor)
163
+ probs = torch.nn.functional.softmax(outputs[0], dim=0)
164
+
165
+ # Dictionary for gr.Label
166
+ confidences = {}
167
+ for i, prob in enumerate(probs):
168
+ class_key = CLASSES[i]
169
+ class_name = t["class_names"].get(class_key, class_key)
170
+ confidences[class_name] = float(prob)
171
+
172
+ # Get tip for top class
173
+ top_class_idx = torch.argmax(probs).item()
174
+ top_class_key = CLASSES[top_class_idx]
175
+ tip = t["tips"].get(top_class_key, "")
176
+ tip_md = f"### {t['tips_header']}\n{tip}"
177
+
178
+ return confidences, tip_md
179
+
180
+
181
+ # ── UI ────────────────────────────────────────────────────────────────────────
182
+
183
+
184
+ def build_app():
185
+ with gr.Blocks() as app:
186
+ lang_state = gr.State("en")
187
+
188
+ with gr.Column(elem_classes="container"):
189
+ with gr.Row():
190
+ with gr.Column(scale=8):
191
+ pass
192
+ with gr.Column(scale=2):
193
+ lang_btn = gr.Button(
194
+ TRANSLATIONS["en"]["btn_lang"], variant="secondary", size="sm"
195
+ )
196
+
197
+ # Custom Header
198
+ with gr.Column(elem_classes="header"):
199
+ title_md = gr.Markdown(f"# {TRANSLATIONS['en']['title']}")
200
+ desc_md = gr.Markdown(TRANSLATIONS["en"]["description"])
201
+
202
+ with gr.Row(variant="panel"):
203
+ with gr.Column(scale=1):
204
+ image_input = gr.Image(
205
+ type="pil",
206
+ label=TRANSLATIONS["en"]["input_label"],
207
+ height=450,
208
+ )
209
+ classify_btn = gr.Button(
210
+ TRANSLATIONS["en"]["btn_classify"], variant="primary", size="lg"
211
+ )
212
+
213
+ with gr.Accordion(TRANSLATIONS["en"]["info_header"], open=True) as info_acc:
214
+ info_instructions = gr.Markdown(
215
+ TRANSLATIONS["en"]["instructions"], elem_classes="info-card"
216
+ )
217
+ info_model = gr.Markdown(TRANSLATIONS["en"]["model_details"])
218
+
219
+ with gr.Column(scale=1):
220
+ result_label_md = gr.Markdown(f"## {TRANSLATIONS['en']['output_label']}")
221
+ result_output = gr.Label(
222
+ num_top_classes=3,
223
+ label="",
224
+ )
225
+ tip_output = gr.Markdown("", elem_classes="tip-card")
226
+
227
+ # ── Language toggle ──────────────────────────────────────────────────
228
+ def toggle_language(current_lang):
229
+ new_lang = "de" if current_lang == "en" else "en"
230
+ t = TRANSLATIONS[new_lang]
231
+ return (
232
+ new_lang,
233
+ t["btn_lang"],
234
+ f"# {t['title']}",
235
+ t["description"],
236
+ gr.update(label=t["input_label"]),
237
+ t["btn_classify"],
238
+ f"## {t['output_label']}",
239
+ gr.update(label=t["info_header"]),
240
+ t["instructions"],
241
+ t["model_details"],
242
+ "", # Reset tip
243
+ )
244
+
245
+ lang_btn.click(
246
+ fn=toggle_language,
247
+ inputs=[lang_state],
248
+ outputs=[
249
+ lang_state,
250
+ lang_btn,
251
+ title_md,
252
+ desc_md,
253
+ image_input,
254
+ classify_btn,
255
+ result_label_md,
256
+ info_acc,
257
+ info_instructions,
258
+ info_model,
259
+ tip_output,
260
+ ],
261
+ )
262
+
263
+ # ── Classify ─────────────────────────────────────────────────────────
264
+ classify_btn.click(
265
+ fn=predict,
266
+ inputs=[image_input, lang_state],
267
+ outputs=[result_output, tip_output],
268
+ )
269
+
270
+ image_input.change(
271
+ fn=predict,
272
+ inputs=[image_input, lang_state],
273
+ outputs=[result_output, tip_output],
274
+ )
275
+
276
+ return app
277
+
278
+
279
+ if __name__ == "__main__":
280
+ app = build_app()
281
+
282
+ # Gradio 6.0 Styling Parameters
283
+ theme = gr.themes.Soft(primary_hue="emerald", spacing_size="lg", radius_size="lg")
284
+ css = """
285
+ .container { max-width: 1200px; margin: auto; padding: 20px; }
286
+ .header {
287
+ text-align: center;
288
+ padding: 40px 20px;
289
+ background: linear-gradient(135deg, #065f46 0%, #059669 100%);
290
+ color: white !important;
291
+ border-radius: 20px;
292
+ margin-bottom: 30px;
293
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
294
+ }
295
+ .header h1, .header p { color: white !important; }
296
+
297
+ /* Adapt cards to theme colors */
298
+ .info-card {
299
+ background-color: var(--background-fill-secondary);
300
+ border-left: 5px solid #10b981;
301
+ padding: 20px;
302
+ border-radius: 10px;
303
+ color: var(--body-text-color);
304
+ }
305
+ .tip-card {
306
+ background-color: var(--warning-100);
307
+ border-left: 5px solid #f59e0b;
308
+ padding: 20px;
309
+ border-radius: 10px;
310
+ margin-top: 20px;
311
+ color: #92400e;
312
+ }
313
+
314
+ /* Dark mode overrides for cards */
315
+ [data-theme='dark'] .tip-card {
316
+ background-color: #451a03;
317
+ color: #fef3c7;
318
+ border-left-color: #d97706;
319
+ }
320
+
321
+ .gr-label-text { font-weight: bold; }
322
+ """ # noqa: E501
323
+
324
+ # inbrowser=True opens the browser automatically
325
+ # share=True provides a public URL
326
+ app.launch(inbrowser=True, theme=theme, css=css, share=True)
src/comparison.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ # Add project root to sys.path
6
+ sys.path.append(str(Path(__file__).parent.parent))
7
+
8
+ import matplotlib.pyplot as plt # noqa: E402
9
+ import numpy as np # noqa: E402
10
+ import torch # noqa: E402
11
+ import yaml # noqa: E402
12
+ from torch.utils.data import DataLoader # noqa: E402
13
+ from torchvision import transforms # noqa: E402
14
+
15
+ from src.dataset import TrashDataset # noqa: E402
16
+ from src.evaluate import evaluate # noqa: E402
17
+ from src.model import DeepCNN, ResNet18Transfer, SimpleCNN # noqa: E402
18
+
19
+
20
+ def load_config(config_path="config.yaml"):
21
+ with open(config_path, "r") as f:
22
+ return yaml.safe_load(f)
23
+
24
+
25
+ def run_comparison():
26
+ config = load_config()
27
+ device = "cuda" if torch.cuda.is_available() else "cpu"
28
+ processed_dir = Path("data/processed")
29
+ save_dir = Path("models/comparison")
30
+ save_dir.mkdir(parents=True, exist_ok=True)
31
+
32
+ # Normalization MUST match training
33
+ test_transform = transforms.Compose(
34
+ [
35
+ transforms.ToPILImage(),
36
+ transforms.ToTensor(),
37
+ transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
38
+ ]
39
+ )
40
+
41
+ # Load test data with transforms
42
+ test_ds = TrashDataset(
43
+ processed_dir / "X_test.npy", processed_dir / "y_test.npy", transform=test_transform
44
+ )
45
+ test_loader = DataLoader(test_ds, batch_size=config["batch_size"], shuffle=False)
46
+
47
+ models = {
48
+ "SimpleCNN": SimpleCNN(num_classes=len(config["classes"])),
49
+ "DeepCNN": DeepCNN(num_classes=len(config["classes"])),
50
+ "ResNet18": ResNet18Transfer(num_classes=len(config["classes"]), pretrained=False),
51
+ }
52
+
53
+ # Map model names to their best saved checkpoints
54
+ checkpoint_map = {
55
+ "SimpleCNN": "models/simplecnn_best.pth",
56
+ "DeepCNN": "models/deepcnn_best.pth",
57
+ "ResNet18": "models/resnet18_best.pth",
58
+ }
59
+
60
+ results = {}
61
+ all_preds = {}
62
+
63
+ for name, model in models.items():
64
+ print(f"\nEvaluating {name}...")
65
+ model_path = checkpoint_map[name]
66
+
67
+ if os.path.exists(model_path):
68
+ model.load_state_dict(torch.load(model_path, map_location=device))
69
+ print(f"Loaded weights from {model_path}")
70
+ else:
71
+ print(f"Warning: {model_path} not found. Using untrained weights for {name}.")
72
+
73
+ loss, acc = evaluate(model, test_loader, device=device, save_dir=str(save_dir / name))
74
+ results[name] = acc
75
+
76
+ # Collect all predictions for failure analysis
77
+ model.to(device)
78
+ model.eval()
79
+ preds = []
80
+ with torch.no_grad():
81
+ for images, _ in test_loader:
82
+ outputs = model(images.to(device))
83
+ preds.extend(torch.max(outputs, 1)[1].cpu().numpy())
84
+ all_preds[name] = np.array(preds)
85
+
86
+ # 1. Accuracy Comparison Plot
87
+ fig, ax = plt.subplots(figsize=(8, 5))
88
+ bars = ax.bar(
89
+ results.keys(),
90
+ results.values(),
91
+ color=["#4C9BE8", "#5DBB63", "#E8714C"],
92
+ width=0.5,
93
+ edgecolor="white",
94
+ linewidth=1.2,
95
+ )
96
+
97
+ # Write value directly onto the bars
98
+ for bar, val in zip(bars, results.values()):
99
+ ax.text(
100
+ bar.get_x() + bar.get_width() / 2,
101
+ bar.get_height() + 0.01,
102
+ f"{val*100:.1f}%",
103
+ ha="center",
104
+ va="bottom",
105
+ fontsize=12,
106
+ fontweight="bold",
107
+ )
108
+
109
+ ax.set_ylabel("Test Accuracy", fontsize=12)
110
+ ax.set_title("Model Accuracy Comparison — TrashNet", fontsize=14, fontweight="bold")
111
+ ax.set_ylim(0, 1.05)
112
+ ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f"{y*100:.0f}%"))
113
+ ax.spines["top"].set_visible(False)
114
+ ax.spines["right"].set_visible(False)
115
+ plt.tight_layout()
116
+ plt.savefig(save_dir / "accuracy_comparison.png", dpi=150)
117
+ print(f"\nComparison plot saved to {save_dir}/accuracy_comparison.png")
118
+
119
+ # 2. Failure Analysis
120
+ y_test = np.load(processed_dir / "y_test.npy")
121
+ resnet_correct = all_preds["ResNet18"] == y_test
122
+ deep_wrong = all_preds["DeepCNN"] != y_test
123
+ simple_wrong = all_preds["SimpleCNN"] != y_test
124
+
125
+ interesting_indices = np.where(resnet_correct & deep_wrong & simple_wrong)[0]
126
+
127
+ if len(interesting_indices) > 0:
128
+ num_interesting = len(interesting_indices)
129
+ print(f"Failure Analysis: Found {num_interesting} samples.")
130
+ X_test = np.load(processed_dir / "X_test.npy")
131
+
132
+ num_show = min(5, len(interesting_indices))
133
+ fig, axes = plt.subplots(1, num_show, figsize=(4 * num_show, 4))
134
+ if num_show == 1:
135
+ axes = [axes]
136
+
137
+ for i in range(num_show):
138
+ idx = interesting_indices[i]
139
+ axes[i].imshow(X_test[idx])
140
+ true_label = config["classes"][y_test[idx]]
141
+ deep_pred = config["classes"][all_preds["DeepCNN"][idx]]
142
+ simple_pred = config["classes"][all_preds["SimpleCNN"][idx]]
143
+ axes[i].set_title(
144
+ f"True: {true_label}\nResNet: ✓\nDeep: {deep_pred}\nSimple: {simple_pred}",
145
+ fontsize=10,
146
+ pad=4,
147
+ )
148
+ axes[i].axis("off")
149
+
150
+ plt.subplots_adjust(top=0.85, wspace=0.1)
151
+ plt.savefig(save_dir / "failure_analysis_comparison.png", dpi=150, bbox_inches="tight")
152
+ print(f"Failure analysis plot saved to {save_dir}/failure_analysis_comparison.png")
153
+
154
+
155
+ if __name__ == "__main__":
156
+ run_comparison()
src/dataset.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Download and prepare TrashNet dataset from GitHub.
3
+
4
+ Fetches the dataset-resized.zip, converts all images to numpy arrays
5
+ with consistent size, and splits into train/val/test sets.
6
+
7
+ Saved files:
8
+ data/processed/X_train.npy, y_train.npy
9
+ data/processed/X_val.npy, y_val.npy
10
+ data/processed/X_test.npy, y_test.npy
11
+ data/processed/classes.npy
12
+ """
13
+
14
+ import io
15
+ import os
16
+ import sys
17
+ import zipfile
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+ import requests
22
+ import torch
23
+ import yaml
24
+ from PIL import Image
25
+ from sklearn.model_selection import train_test_split
26
+ from torch.utils.data import Dataset
27
+
28
+ # Add the project root to the python path
29
+ sys.path.append(str(Path(__file__).parent.parent))
30
+
31
+
32
+ def load_config(config_path="config.yaml"):
33
+ with open(config_path, "r") as f:
34
+ return yaml.safe_load(f)
35
+
36
+
37
+ config = load_config()
38
+ CLASSES = config["classes"]
39
+ SPLIT = config["split"]
40
+ IMG_SIZE = tuple(config["img_size"])
41
+ RANDOM_SEED = config["random_seed"]
42
+
43
+ os.makedirs(name="data", exist_ok=True)
44
+
45
+ GITHUB_URL = "https://github.com/garythung/trashnet/raw/master/data/dataset-resized.zip"
46
+ RAW_DIR = Path("data/raw")
47
+ OUT_DIR = Path("data/processed")
48
+
49
+
50
+ def download_and_extract() -> Path:
51
+ """
52
+ Automates data acquisition for reproducibility.
53
+ Downloads the TrashNet ZIP from GitHub and extracts it to data/raw/.
54
+ This ensures that anyone running the script gets the exact same starting data.
55
+ """
56
+ if (RAW_DIR / "dataset-resized").exists():
57
+ print("[SKIP] Already extracted.")
58
+ return RAW_DIR
59
+
60
+ print("[DOWNLOAD] TrashNet from GitHub...")
61
+
62
+ try:
63
+ response = requests.get(GITHUB_URL, timeout=120)
64
+ response.raise_for_status()
65
+
66
+ with zipfile.ZipFile(io.BytesIO(response.content)) as zf:
67
+ zf.extractall(RAW_DIR)
68
+ print("[OK] Extraction done.")
69
+ except Exception as e:
70
+ print(f"[ERROR] Download failed: {e}")
71
+ raise
72
+
73
+ return RAW_DIR
74
+
75
+
76
+ def load_images(raw_path: Path) -> tuple[np.ndarray, np.ndarray]:
77
+ """
78
+ Data Standardization:
79
+ Reads all images per class, resizes them to a uniform size (IMG_SIZE),
80
+ and converts them to RGB. This creates a consistent input format for
81
+ the neural network, regardless of the original image dimensions or formats.
82
+ """
83
+ images, labels = [], []
84
+
85
+ for label_index, class_name in enumerate(CLASSES):
86
+ class_dir = raw_path / "dataset-resized" / class_name
87
+ if not class_dir.exists():
88
+ print(f"[WARN] Folder not found: {class_dir}, skipping.")
89
+ continue
90
+
91
+ files = list(class_dir.glob("*.jpg")) + list(class_dir.glob("*.png"))
92
+ print(f"[LOAD] {class_name}: {len(files)} images")
93
+
94
+ for img_path in files:
95
+ try:
96
+ img = Image.open(img_path).convert("RGB")
97
+ img = img.resize(IMG_SIZE)
98
+ images.append(np.array(img, dtype=np.uint8))
99
+ labels.append(label_index)
100
+ except Exception as e:
101
+ print(f"[WARN] Could not read {img_path}: {e}")
102
+
103
+ return np.array(images), np.array(labels, dtype=np.int64)
104
+
105
+
106
+ def split_and_save(images: np.ndarray, labels: np.ndarray):
107
+ """
108
+ Evaluation Rigor:
109
+ Splits the data into fixed Train, Validation, and Test sets.
110
+ - Train: Used to update model weights.
111
+ - Val: Used to tune hyperparameters and prevent overfitting.
112
+ - Test: Used for final unbiased evaluation.
113
+ Saving as .npy files makes loading much faster during training.
114
+ """
115
+
116
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
117
+ train_ratio, val_ratio, test_ratio = SPLIT
118
+
119
+ X_train, X_rest, y_train, y_rest = train_test_split(
120
+ images, labels, test_size=(1 - train_ratio), stratify=labels, random_state=RANDOM_SEED
121
+ )
122
+
123
+ val_size = val_ratio / (val_ratio + test_ratio)
124
+ X_val, X_test, y_val, y_test = train_test_split(
125
+ X_rest, y_rest, test_size=(1 - val_size), stratify=y_rest, random_state=RANDOM_SEED
126
+ )
127
+
128
+ splits = {
129
+ "X_train": X_train,
130
+ "y_train": y_train,
131
+ "X_val": X_val,
132
+ "y_val": y_val,
133
+ "X_test": X_test,
134
+ "y_test": y_test,
135
+ }
136
+
137
+ for name, array in splits.items():
138
+ path = OUT_DIR / f"{name}.npy"
139
+ np.save(path, array)
140
+ print(f"[OK] {name}.npy → {array.shape} dtype={array.dtype}")
141
+
142
+ np.save(OUT_DIR / "classes.npy", np.array(CLASSES))
143
+ print(f"\n[DONE] Splits: " f"Train={len(y_train)} | Val={len(y_val)} | Test={len(y_test)}")
144
+
145
+
146
+ class TrashDataset(Dataset):
147
+ """
148
+ The Bridge to PyTorch:
149
+ This class is REQUIRED because PyTorch's DataLoader expects a Dataset object.
150
+
151
+ Why this class?
152
+ 1. Efficient Loading: It only loads specific images into RAM when needed (lazy loading).
153
+ 2. Data Augmentation: Allows on-the-fly transformations (rotation, flip, etc.) in __getitem__.
154
+ 3. Tensor Conversion: Handles the conversion from NumPy arrays to PyTorch Tensors.
155
+ """
156
+
157
+ def __init__(self, x_path: Path, y_path: Path, transform=None):
158
+ """Loads the pre-processed .npy files once into memory."""
159
+ self.X = np.load(x_path)
160
+ self.y = torch.from_numpy(np.load(y_path))
161
+ self.transform = transform
162
+
163
+ def __len__(self):
164
+ """Tells the DataLoader how many samples are in the dataset."""
165
+ return len(self.X)
166
+
167
+ def __getitem__(self, idx):
168
+ """
169
+ Fetches a single sample (image + label) at the given index.
170
+ This is where preprocessing (transforms) happens during training.
171
+ """
172
+ img = self.X[idx]
173
+ label = self.y[idx]
174
+
175
+ if self.transform:
176
+ img = self.transform(img)
177
+ else:
178
+ # Default: Convert [H, W, C] (0-255) to [C, H, W] (0.0-1.0) for
179
+ # PyTorch
180
+ img = torch.from_numpy(img).permute(2, 0, 1).float() / 255.0
181
+
182
+ return img, label
183
+
184
+
185
+ if __name__ == "__main__":
186
+ raw_path = download_and_extract()
187
+ images, labels = load_images(raw_path)
188
+ split_and_save(images, labels)
src/evaluate.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ # Add project root to sys.path
6
+ sys.path.append(str(Path(__file__).parent.parent))
7
+
8
+ import matplotlib # noqa: E402
9
+
10
+ matplotlib.use("Agg") # Use headless backend
11
+ import matplotlib.pyplot as plt # noqa: E402
12
+ import torch # noqa: E402
13
+ import torch.nn as nn # noqa: E402
14
+ import yaml # noqa: E402
15
+ from sklearn.metrics import ( # noqa: E402
16
+ ConfusionMatrixDisplay,
17
+ classification_report,
18
+ confusion_matrix,
19
+ )
20
+
21
+
22
+ def load_config(config_path="config.yaml"):
23
+ with open(config_path, "r") as f:
24
+ return yaml.safe_load(f)
25
+
26
+
27
+ config = load_config()
28
+ CLASSES = config["classes"]
29
+
30
+
31
+ def get_device(config_device):
32
+ if config_device == "auto":
33
+ return "cuda" if torch.cuda.is_available() else "cpu"
34
+ return config_device
35
+
36
+
37
+ DEVICE = get_device(config["device"])
38
+
39
+
40
+ def evaluate(model, data_loader, device=DEVICE, save_dir="models/plots"):
41
+ """
42
+ Evaluates a PyTorch model on a given DataLoader.
43
+
44
+ Args:
45
+ model: The PyTorch model to evaluate.
46
+ data_loader: The DataLoader providing the evaluation data.
47
+ device: The device to run evaluation on (e.g., 'cuda', 'cpu').
48
+ save_dir: Directory to save plots.
49
+
50
+ Returns:
51
+ avg_loss (float): The average loss over the dataset.
52
+ accuracy (float): The classification accuracy (0.0 to 1.0).
53
+ """
54
+ model.to(device)
55
+ model.eval()
56
+ criterion = nn.CrossEntropyLoss()
57
+
58
+ total_loss = 0.0
59
+ correct = 0
60
+ total = 0
61
+
62
+ all_preds = []
63
+ all_labels = []
64
+
65
+ with torch.no_grad():
66
+ for images, labels in data_loader:
67
+ images, labels = images.to(device), labels.to(device)
68
+
69
+ outputs = model(images)
70
+ loss = criterion(outputs, labels)
71
+
72
+ total_loss += loss.item()
73
+ _, predicted = torch.max(outputs.data, 1)
74
+ total += labels.size(0)
75
+ correct += (predicted == labels).sum().item()
76
+
77
+ all_preds.extend(predicted.cpu().numpy())
78
+ all_labels.extend(labels.cpu().numpy())
79
+
80
+ avg_loss = total_loss / len(data_loader)
81
+ accuracy = correct / total
82
+
83
+ print("\nEvaluation Results:")
84
+ print(f"Average Loss: {avg_loss:.4f}")
85
+ print(f"Accuracy: {accuracy:.4f}")
86
+
87
+ # Classification Report
88
+ print("\nClassification Report:")
89
+ report = classification_report(
90
+ all_labels, all_preds, target_names=CLASSES, labels=range(len(CLASSES)), zero_division=0
91
+ )
92
+ print(report)
93
+
94
+ # Confusion Matrix
95
+ cm = confusion_matrix(all_labels, all_preds, labels=range(len(CLASSES)))
96
+ os.makedirs(save_dir, exist_ok=True)
97
+
98
+ fig, ax = plt.subplots(figsize=(10, 8))
99
+ disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=CLASSES)
100
+ disp.plot(cmap=plt.cm.Blues, ax=ax, xticks_rotation=45)
101
+ plt.title("Confusion Matrix")
102
+ plt.tight_layout()
103
+ plt.savefig(f"{save_dir}/confusion_matrix.png")
104
+ print(f"\nConfusion matrix saved to {save_dir}/confusion_matrix.png")
105
+
106
+ return avg_loss, accuracy
src/model.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ from torchvision import models
4
+ from torchvision.models import ResNet18_Weights
5
+
6
+
7
+ class SimpleCNN(nn.Module):
8
+ """
9
+ A minimalist CNN model as a baseline.
10
+ Consists of two convolutional layers followed by a fully connected layer.
11
+ """
12
+
13
+ def __init__(self, num_classes=6):
14
+ super(SimpleCNN, self).__init__()
15
+ # First Convolutional Block: Takes 3 channels (RGB) as input and
16
+ # outputs 16
17
+ self.conv1 = nn.Conv2d(3, 16, kernel_size=3, padding=1)
18
+ self.relu1 = nn.ReLU()
19
+ self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) # 224 -> 112
20
+
21
+ # Second Convolutional Block
22
+ self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1)
23
+ self.relu2 = nn.ReLU()
24
+ self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) # 112 -> 56
25
+
26
+ # Adaptive Pooling ensures the output is always 7x7, regardless of
27
+ # input size
28
+ self.adaptive_pool = nn.AdaptiveAvgPool2d((7, 7))
29
+
30
+ # Classification Layer
31
+ self.fc = nn.Linear(32 * 7 * 7, num_classes)
32
+
33
+ def forward(self, x):
34
+ """
35
+ Defines the forward pass of the data through the network.
36
+ """
37
+ x = self.pool1(self.relu1(self.conv1(x)))
38
+ x = self.pool2(self.relu2(self.conv2(x)))
39
+ x = self.adaptive_pool(x)
40
+ x = torch.flatten(x, 1) # Flatten for the linear layer
41
+ x = self.fc(x)
42
+ return x
43
+
44
+
45
+ class DeepCNN(nn.Module):
46
+ """
47
+ A deeper CNN model with Batch Normalization and Dropout for regularization.
48
+ Better suited for more complex image features.
49
+ """
50
+
51
+ def __init__(self, num_classes=6):
52
+ super(DeepCNN, self).__init__()
53
+
54
+ # Block 1
55
+ self.layer1 = nn.Sequential(
56
+ nn.Conv2d(3, 32, kernel_size=3, padding=1),
57
+ nn.BatchNorm2d(32),
58
+ nn.ReLU(),
59
+ nn.MaxPool2d(kernel_size=2, stride=2), # 112
60
+ )
61
+
62
+ # Block 2
63
+ self.layer2 = nn.Sequential(
64
+ nn.Conv2d(32, 64, kernel_size=3, padding=1),
65
+ nn.BatchNorm2d(64),
66
+ nn.ReLU(),
67
+ nn.MaxPool2d(kernel_size=2, stride=2), # 56
68
+ )
69
+
70
+ # Block 3
71
+ self.layer3 = nn.Sequential(
72
+ nn.Conv2d(64, 128, kernel_size=3, padding=1),
73
+ nn.BatchNorm2d(128),
74
+ nn.ReLU(),
75
+ nn.MaxPool2d(kernel_size=2, stride=2), # 28
76
+ )
77
+
78
+ self.adaptive_pool = nn.AdaptiveAvgPool2d((7, 7))
79
+
80
+ # Classifier with Dropout to prevent overfitting
81
+ self.classifier = nn.Sequential(
82
+ nn.Linear(128 * 7 * 7, 512), nn.ReLU(), nn.Dropout(0.5), nn.Linear(512, num_classes)
83
+ )
84
+
85
+ def forward(self, x):
86
+ """
87
+ Forward pass through the sequential layers.
88
+ """
89
+ x = self.layer1(x)
90
+ x = self.layer2(x)
91
+ x = self.layer3(x)
92
+ x = self.adaptive_pool(x)
93
+ x = torch.flatten(x, 1)
94
+ x = self.classifier(x)
95
+ return x
96
+
97
+
98
+ class ResNet18Transfer(nn.Module):
99
+ """
100
+ Transfer Learning model based on ResNet18.
101
+ Allows loading pretrained weights and freezing the backbone.
102
+ """
103
+
104
+ def __init__(self, num_classes=6, pretrained=True, freeze_backbone=False):
105
+ super(ResNet18Transfer, self).__init__()
106
+
107
+ # Load the ResNet18 model
108
+ weights = ResNet18_Weights.DEFAULT if pretrained else None
109
+ self.backbone = models.resnet18(weights=weights)
110
+
111
+ # Freeze the backbone if requested
112
+ if freeze_backbone:
113
+ for param in self.backbone.parameters():
114
+ param.requires_grad = False
115
+
116
+ # Adjust the final fully connected layer (fc)
117
+ # ResNet18 fc has 512 input features by default
118
+ in_features = self.backbone.fc.in_features
119
+ self.backbone.fc = nn.Linear(in_features, num_classes)
120
+
121
+ def forward(self, x):
122
+ """
123
+ Uses the ResNet backbone for feature extraction and classification.
124
+ """
125
+ return self.backbone(x)
src/train.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ # Add project root to sys.path
6
+ sys.path.append(str(Path(__file__).parent.parent))
7
+
8
+ import matplotlib # noqa: E402
9
+
10
+ matplotlib.use("Agg")
11
+ import matplotlib.pyplot as plt # noqa: E402
12
+ import mlflow # noqa: E402
13
+ import numpy as np # noqa: E402
14
+ import torch # noqa: E402
15
+ import torch.nn as nn # noqa: E402
16
+ import yaml # noqa: E402
17
+ from torch.utils.data import DataLoader # noqa: E402
18
+ from torchvision import transforms # noqa: E402
19
+ from tqdm import tqdm # noqa: E402
20
+
21
+ from src.dataset import TrashDataset # noqa: E402
22
+ from src.model import DeepCNN, ResNet18Transfer, SimpleCNN # noqa: E402
23
+
24
+
25
+ def load_config(config_path="config.yaml"):
26
+ with open(config_path, "r") as f:
27
+ return yaml.safe_load(f)
28
+
29
+
30
+ def get_device(config_device):
31
+ if config_device == "auto":
32
+ return "cuda" if torch.cuda.is_available() else "cpu"
33
+ return config_device
34
+
35
+
36
+ class Trainer:
37
+ """
38
+ Handles the training and validation lifecycle of a model with MLflow tracking.
39
+ """
40
+
41
+ def __init__(self, model, train_loader, val_loader, config, model_name):
42
+ self.config = config
43
+ self.model_name = model_name
44
+ self.device = get_device(config["device"])
45
+ self.model = model.to(self.device)
46
+ self.train_loader = train_loader
47
+ self.val_loader = val_loader
48
+ self.epochs = config["epochs"]
49
+ self.patience = config.get("patience", 5)
50
+
51
+ # Handle class imbalance with weights
52
+ y_train = np.load("data/processed/y_train.npy")
53
+ class_counts = np.bincount(y_train)
54
+ weights = 1.0 / class_counts
55
+ weights = torch.FloatTensor(weights).to(self.device)
56
+
57
+ self.criterion = nn.CrossEntropyLoss(weight=weights)
58
+ self.optimizer = torch.optim.Adam(model.parameters(), lr=config["lr"])
59
+ self.scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
60
+ self.optimizer, T_max=self.epochs
61
+ )
62
+ self.history = {"train_loss": [], "train_acc": [], "val_loss": [], "val_acc": []}
63
+ self.checkpoint_path = f"models/{model_name.lower()}_best.pth"
64
+
65
+ def train_epoch(self):
66
+ self.model.train()
67
+ running_loss = 0.0
68
+ correct = 0
69
+ total = 0
70
+
71
+ pbar = tqdm(self.train_loader, desc="Training", leave=False)
72
+ for images, labels in pbar:
73
+ images, labels = images.to(self.device), labels.to(self.device)
74
+
75
+ self.optimizer.zero_grad()
76
+ outputs = self.model(images)
77
+ loss = self.criterion(outputs, labels)
78
+ loss.backward()
79
+ self.optimizer.step()
80
+
81
+ running_loss += loss.item()
82
+ _, predicted = torch.max(outputs, 1)
83
+ total += labels.size(0)
84
+ correct += (predicted == labels).sum().item()
85
+
86
+ pbar.set_postfix({"loss": f"{loss.item():.4f}", "acc": f"{correct/total:.4f}"})
87
+
88
+ return running_loss / len(self.train_loader), correct / total
89
+
90
+ def validate(self):
91
+ self.model.eval()
92
+ running_loss = 0.0
93
+ correct = 0
94
+ total = 0
95
+
96
+ with torch.no_grad():
97
+ for images, labels in self.val_loader:
98
+ images, labels = images.to(self.device), labels.to(self.device)
99
+ outputs = self.model(images)
100
+ loss = self.criterion(outputs, labels)
101
+
102
+ running_loss += loss.item()
103
+ _, predicted = torch.max(outputs, 1)
104
+ total += labels.size(0)
105
+ correct += (predicted == labels).sum().item()
106
+
107
+ return running_loss / len(self.val_loader), correct / total
108
+
109
+ def plot_history(self):
110
+ save_path = f"models/plots/{self.model_name.lower()}_history.png"
111
+ os.makedirs(os.path.dirname(save_path), exist_ok=True)
112
+ epochs_range = range(1, len(self.history["train_loss"]) + 1)
113
+
114
+ plt.figure(figsize=(12, 5))
115
+
116
+ # Plot Loss
117
+ plt.subplot(1, 2, 1)
118
+ plt.plot(epochs_range, self.history["train_loss"], label="Train Loss")
119
+ plt.plot(epochs_range, self.history["val_loss"], label="Val Loss")
120
+ plt.title(f"{self.model_name} - Loss")
121
+ plt.xlabel("Epochs")
122
+ plt.ylabel("Loss")
123
+ plt.legend()
124
+
125
+ # Plot Accuracy
126
+ plt.subplot(1, 2, 2)
127
+ plt.plot(epochs_range, self.history["train_acc"], label="Train Acc")
128
+ plt.plot(epochs_range, self.history["val_acc"], label="Val Acc")
129
+ plt.title(f"{self.model_name} - Accuracy")
130
+ plt.xlabel("Epochs")
131
+ plt.ylabel("Accuracy")
132
+ plt.legend()
133
+
134
+ plt.tight_layout()
135
+ plt.savefig(save_path)
136
+ print(f"--> Training history plot saved to {save_path}")
137
+ mlflow.log_artifact(save_path)
138
+
139
+ def fit(self):
140
+ mlflow.set_experiment("Trash Classifier")
141
+ with mlflow.start_run(run_name=self.model_name):
142
+ mlflow.log_params(self.config)
143
+ mlflow.log_param("model_architecture", self.model_name)
144
+
145
+ print(f"\nStarting training for {self.model_name} on {self.device}...")
146
+ best_val_acc = 0.0
147
+ epochs_no_improve = 0
148
+
149
+ for epoch in range(self.epochs):
150
+ train_loss, train_acc = self.train_epoch()
151
+ val_loss, val_acc = self.validate()
152
+ self.scheduler.step()
153
+
154
+ self.history["train_loss"].append(train_loss)
155
+ self.history["train_acc"].append(train_acc)
156
+ self.history["val_loss"].append(val_loss)
157
+ self.history["val_acc"].append(val_acc)
158
+
159
+ mlflow.log_metric("train_loss", train_loss, step=epoch)
160
+ mlflow.log_metric("train_acc", train_acc, step=epoch)
161
+ mlflow.log_metric("val_loss", val_loss, step=epoch)
162
+ mlflow.log_metric("val_acc", val_acc, step=epoch)
163
+ mlflow.log_metric("lr", self.optimizer.param_groups[0]["lr"], step=epoch)
164
+
165
+ print(
166
+ f"Epoch [{epoch + 1}/{self.epochs}] "
167
+ f"Train Loss: {train_loss:.4f}, Acc: {train_acc:.4f} | "
168
+ f"Val Loss: {val_loss:.4f}, Acc: {val_acc:.4f} | "
169
+ f"LR: {self.optimizer.param_groups[0]['lr']:.6f}"
170
+ )
171
+
172
+ if val_acc > best_val_acc:
173
+ best_val_acc = val_acc
174
+ epochs_no_improve = 0
175
+ os.makedirs("models", exist_ok=True)
176
+ torch.save(self.model.state_dict(), self.checkpoint_path)
177
+ print(f"--> Saved best model for {self.model_name} with Val Acc: {val_acc:.4f}")
178
+ mlflow.log_artifact(self.checkpoint_path)
179
+ else:
180
+ epochs_no_improve += 1
181
+ if epochs_no_improve >= self.patience:
182
+ print(f"Early stopping triggered after {epoch + 1} epochs.")
183
+ break
184
+
185
+ self.plot_history()
186
+ return self.history
187
+
188
+
189
+ if __name__ == "__main__":
190
+ config = load_config()
191
+
192
+ train_transform = transforms.Compose(
193
+ [
194
+ transforms.ToPILImage(),
195
+ transforms.RandomResizedCrop(224, scale=(0.8, 1.0)),
196
+ transforms.RandomHorizontalFlip(),
197
+ transforms.RandomRotation(15),
198
+ transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),
199
+ transforms.ToTensor(),
200
+ transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
201
+ ]
202
+ )
203
+
204
+ val_transform = transforms.Compose(
205
+ [
206
+ transforms.ToPILImage(),
207
+ transforms.ToTensor(),
208
+ transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
209
+ ]
210
+ )
211
+
212
+ processed_dir = Path("data/processed")
213
+ if not (processed_dir / "X_train.npy").exists():
214
+ print("Error: Processed data not found. Please run src/dataset.py first.")
215
+ else:
216
+ train_ds = TrashDataset(
217
+ processed_dir / "X_train.npy", processed_dir / "y_train.npy", transform=train_transform
218
+ )
219
+ val_ds = TrashDataset(
220
+ processed_dir / "X_val.npy", processed_dir / "y_val.npy", transform=val_transform
221
+ )
222
+
223
+ train_loader = DataLoader(train_ds, batch_size=config["batch_size"], shuffle=True)
224
+ val_loader = DataLoader(val_ds, batch_size=config["batch_size"], shuffle=False)
225
+
226
+ num_classes = len(config["classes"])
227
+ models_to_train = {
228
+ "SimpleCNN": SimpleCNN(num_classes=num_classes),
229
+ "DeepCNN": DeepCNN(num_classes=num_classes),
230
+ "ResNet18": ResNet18Transfer(num_classes=num_classes, pretrained=True),
231
+ }
232
+
233
+ for name, model in models_to_train.items():
234
+ trainer = Trainer(model, train_loader, val_loader, config, name)
235
+ trainer.fit()
236
+
237
+ print("\nAll models trained. Starting comparison...")
238
+ from src.comparison import run_comparison
239
+
240
+ run_comparison()