abinazebinoy commited on
Commit
1b2897e
·
1 Parent(s): 6b2e6ab

feat: add EfficientNet training script — reads manifest.csv, trains binary classifier

Browse files
Files changed (1) hide show
  1. scripts/train_embedding.py +276 -0
scripts/train_embedding.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Train OwnEmbeddingModel on manifest.csv dataset.
3
+
4
+ Usage:
5
+ python scripts/train_embedding.py
6
+ python scripts/train_embedding.py --epochs 10 --batch 32 --limit 50000
7
+
8
+ What this does:
9
+ 1. Reads manifest.csv
10
+ 2. Loads images in batches
11
+ 3. Trains EfficientNet-B0 binary classifier (real=0, AI=1)
12
+ 4. Saves best model to data/reference/own_embedding_model.pt
13
+ 5. Logs accuracy/loss to tensorboard (optional)
14
+ """
15
+ import sys
16
+ import csv
17
+ import time
18
+ import random
19
+ import argparse
20
+ import logging
21
+ from pathlib import Path
22
+ from typing import List, Tuple
23
+
24
+ import torch
25
+ import torch.nn as nn
26
+ from torch.utils.data import Dataset, DataLoader
27
+ from torchvision import transforms
28
+ from PIL import Image
29
+
30
+ sys.path.insert(0, str(Path(__file__).parents[1]))
31
+ from backend.services.own_detector.model import OwnEmbeddingModel, MODEL_PATH
32
+
33
+ logging.basicConfig(
34
+ level=logging.INFO,
35
+ format="%(asctime)s %(levelname)s %(message)s",
36
+ datefmt="%H:%M:%S",
37
+ )
38
+ logger = logging.getLogger(__name__)
39
+
40
+ ROOT = Path(__file__).parents[1]
41
+ MANIFEST_PATH = ROOT / "data" / "manifest.csv"
42
+
43
+ # ── Image transform ────────────────────────────────────────────────────────
44
+ TRAIN_TRANSFORM = transforms.Compose([
45
+ transforms.Resize((224, 224)),
46
+ transforms.RandomHorizontalFlip(),
47
+ transforms.ColorJitter(brightness=0.2, contrast=0.2),
48
+ transforms.ToTensor(),
49
+ transforms.Normalize(mean=[0.485, 0.456, 0.406],
50
+ std=[0.229, 0.224, 0.225]),
51
+ ])
52
+
53
+ VAL_TRANSFORM = transforms.Compose([
54
+ transforms.Resize((224, 224)),
55
+ transforms.ToTensor(),
56
+ transforms.Normalize(mean=[0.485, 0.456, 0.406],
57
+ std=[0.229, 0.224, 0.225]),
58
+ ])
59
+
60
+
61
+ # ── Dataset ────────────────────────────────────────────────────────────────
62
+
63
+ class ImageManifestDataset(Dataset):
64
+ """Reads images listed in manifest.csv."""
65
+
66
+ def __init__(self, rows: List[dict], transform, root: Path):
67
+ self.rows = rows
68
+ self.transform = transform
69
+ self.root = root
70
+
71
+ def __len__(self):
72
+ return len(self.rows)
73
+
74
+ def __getitem__(self, idx):
75
+ row = self.rows[idx]
76
+ label = 1 if row["label"] == "ai" else 0
77
+
78
+ # Fix Windows backslash paths
79
+ img_path = self.root / row["path"].replace("\\", "/")
80
+
81
+ try:
82
+ img = Image.open(img_path).convert("RGB")
83
+ return self.transform(img), torch.tensor(label, dtype=torch.float32)
84
+ except Exception:
85
+ # Return black image on failure — rare corrupt file
86
+ blank = torch.zeros(3, 224, 224)
87
+ return blank, torch.tensor(label, dtype=torch.float32)
88
+
89
+
90
+ # ── Load manifest ──────────────────────────────────────────────────────────
91
+
92
+ def load_manifest(limit: int = 0) -> Tuple[List[dict], List[dict]]:
93
+ """Read manifest.csv, return (train_rows, val_rows)."""
94
+ if not MANIFEST_PATH.exists():
95
+ logger.error(f"manifest.csv not found at {MANIFEST_PATH}")
96
+ sys.exit(1)
97
+
98
+ train_rows, val_rows = [], []
99
+
100
+ with open(MANIFEST_PATH, newline="", encoding="utf-8") as f:
101
+ reader = csv.DictReader(f)
102
+ for row in reader:
103
+ # Only use images that exist on disk
104
+ img_path = ROOT / row["path"].replace("\\", "/")
105
+ if not img_path.exists():
106
+ continue
107
+ if row["split"] == "train":
108
+ train_rows.append(row)
109
+ elif row["split"] in ("val", "test"):
110
+ val_rows.append(row)
111
+
112
+ # Balance real vs AI in training set
113
+ real_train = [r for r in train_rows if r["label"] == "real"]
114
+ ai_train = [r for r in train_rows if r["label"] == "ai"]
115
+
116
+ logger.info(f"Train — real: {len(real_train)}, AI: {len(ai_train)}")
117
+ logger.info(f"Val — {len(val_rows)} images")
118
+
119
+ # Balance by taking equal numbers of each
120
+ min_count = min(len(real_train), len(ai_train))
121
+ if limit > 0:
122
+ min_count = min(min_count, limit // 2)
123
+
124
+ random.shuffle(real_train)
125
+ random.shuffle(ai_train)
126
+
127
+ balanced_train = real_train[:min_count] + ai_train[:min_count]
128
+ random.shuffle(balanced_train)
129
+
130
+ logger.info(f"Balanced train set: {len(balanced_train)} images "
131
+ f"({min_count} real + {min_count} AI)")
132
+
133
+ # Cap val set too
134
+ if limit > 0:
135
+ val_rows = val_rows[:limit // 4]
136
+
137
+ return balanced_train, val_rows
138
+
139
+
140
+ # ── Training loop ──────────────────────────────────────────────────────────
141
+
142
+ def train(args):
143
+ device = "cuda" if torch.cuda.is_available() else "cpu"
144
+ logger.info(f"Device: {device}")
145
+
146
+ # Load data
147
+ train_rows, val_rows = load_manifest(limit=args.limit)
148
+
149
+ if len(train_rows) == 0:
150
+ logger.error(
151
+ "No training images found on disk. "
152
+ "Run download scripts first to get images locally."
153
+ )
154
+ sys.exit(1)
155
+
156
+ train_ds = ImageManifestDataset(train_rows, TRAIN_TRANSFORM, ROOT)
157
+ val_ds = ImageManifestDataset(val_rows, VAL_TRANSFORM, ROOT)
158
+
159
+ train_loader = DataLoader(
160
+ train_ds, batch_size=args.batch,
161
+ shuffle=True, num_workers=0, pin_memory=False,
162
+ )
163
+ val_loader = DataLoader(
164
+ val_ds, batch_size=args.batch,
165
+ shuffle=False, num_workers=0,
166
+ )
167
+
168
+ logger.info(f"Train batches: {len(train_loader)} "
169
+ f"| Val batches: {len(val_loader)}")
170
+
171
+ # Model
172
+ model = OwnEmbeddingModel(freeze_backbone=args.freeze_backbone)
173
+ model = model.to(device)
174
+
175
+ # Loss and optimiser
176
+ criterion = nn.BCELoss()
177
+ optimizer = torch.optim.AdamW(
178
+ model.parameters(), lr=args.lr, weight_decay=1e-4
179
+ )
180
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
181
+ optimizer, T_max=args.epochs
182
+ )
183
+
184
+ best_val_acc = 0.0
185
+ MODEL_PATH.parent.mkdir(parents=True, exist_ok=True)
186
+
187
+ for epoch in range(1, args.epochs + 1):
188
+ # ── Train ──────────────────────────────────────────────────────
189
+ model.train()
190
+ train_loss, train_correct, train_total = 0.0, 0, 0
191
+ t0 = time.time()
192
+
193
+ for batch_idx, (images, labels) in enumerate(train_loader):
194
+ images = images.to(device)
195
+ labels = labels.to(device).unsqueeze(1)
196
+
197
+ optimizer.zero_grad()
198
+ _, probs = model(images)
199
+ loss = criterion(probs, labels)
200
+ loss.backward()
201
+ optimizer.step()
202
+
203
+ train_loss += loss.item()
204
+ preds = (probs > 0.5).float()
205
+ train_correct += (preds == labels).sum().item()
206
+ train_total += labels.size(0)
207
+
208
+ if (batch_idx + 1) % 50 == 0:
209
+ logger.info(
210
+ f" Epoch {epoch} batch {batch_idx+1}/{len(train_loader)} "
211
+ f"loss={loss.item():.4f}"
212
+ )
213
+
214
+ train_acc = train_correct / train_total * 100
215
+ train_loss = train_loss / len(train_loader)
216
+
217
+ # ── Validate ───────────────────────────────────────────────────
218
+ model.eval()
219
+ val_correct, val_total = 0, 0
220
+ val_loss = 0.0
221
+
222
+ with torch.no_grad():
223
+ for images, labels in val_loader:
224
+ images = images.to(device)
225
+ labels = labels.to(device).unsqueeze(1)
226
+ _, probs = model(images)
227
+ loss = criterion(probs, labels)
228
+ val_loss += loss.item()
229
+ preds = (probs > 0.5).float()
230
+ val_correct += (preds == labels).sum().item()
231
+ val_total += labels.size(0)
232
+
233
+ val_acc = val_correct / val_total * 100 if val_total > 0 else 0
234
+ val_loss = val_loss / len(val_loader) if len(val_loader) > 0 else 0
235
+
236
+ elapsed = time.time() - t0
237
+ logger.info(
238
+ f"Epoch {epoch}/{args.epochs} "
239
+ f"| train_loss={train_loss:.4f} train_acc={train_acc:.1f}% "
240
+ f"| val_loss={val_loss:.4f} val_acc={val_acc:.1f}% "
241
+ f"| {elapsed:.0f}s"
242
+ )
243
+
244
+ scheduler.step()
245
+
246
+ # Save best model
247
+ if val_acc > best_val_acc:
248
+ best_val_acc = val_acc
249
+ torch.save(model.state_dict(), MODEL_PATH)
250
+ logger.info(f" ✅ Best model saved (val_acc={val_acc:.1f}%)")
251
+
252
+ logger.info(f"Training complete. Best val accuracy: {best_val_acc:.1f}%")
253
+ logger.info(f"Model saved to: {MODEL_PATH}")
254
+
255
+
256
+ # ── Entry point ────────────────────────────────────────────────────────────
257
+
258
+ if __name__ == "__main__":
259
+ parser = argparse.ArgumentParser(description="Train OwnEmbeddingModel")
260
+ parser.add_argument("--epochs", type=int, default=5,
261
+ help="Number of training epochs (default: 5)")
262
+ parser.add_argument("--batch", type=int, default=32,
263
+ help="Batch size (default: 32)")
264
+ parser.add_argument("--lr", type=float, default=1e-4,
265
+ help="Learning rate (default: 0.0001)")
266
+ parser.add_argument("--limit", type=int, default=0,
267
+ help="Limit images per class, 0=use all")
268
+ parser.add_argument("--freeze-backbone", action="store_true",
269
+ help="Freeze EfficientNet backbone, train head only")
270
+ args = parser.parse_args()
271
+
272
+ logger.info("=== VeriFile-X Embedding Detector Training ===")
273
+ logger.info(f"Epochs: {args.epochs} | Batch: {args.batch} | LR: {args.lr}")
274
+ logger.info(f"Limit: {args.limit if args.limit else 'all'}")
275
+
276
+ train(args)