-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtrain_model.py
More file actions
339 lines (271 loc) · 12.8 KB
/
Copy pathtrain_model.py
File metadata and controls
339 lines (271 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
"""
emotiondetection training script
trains a 4-block vgg-style cnn on fer2013 with focal loss, random erasing,
cosine lr schedule with warm-up, and tta-based evaluation. designed to be
run three times with different seeds (42, 123, 456) and ensembled with
ensemble_predict.py for the final reported accuracy.
usage:
python train_model.py # default seed 42
python train_model.py --seed 123 # second ensemble member
python train_model.py --seed 456 # third ensemble member
what each run produces in models/:
emotion_cnn_v5b_seed{N}.keras trained weights
class_report_v5b_seed{N}.json per-class precision/recall/f1
training_history_v5b_seed{N}.json per-epoch metrics
training_curves_v5b_seed{N}.png train/val accuracy and loss
confusion_matrix_v5b_seed{N}.png test-set confusion matrix
preds_v5b_seed{N}.npy raw softmax outputs (used by ensemble)
ytrue_v5b.npy ground truth labels (only written once)
note: an earlier iteration combined focal loss with explicit class weights.
that turned out to over-correct (disgust precision crashed) so this version
uses focal loss alone with milder gamma/alpha. see the dissertation for
the full story.
"""
import os, json, argparse, random
import numpy as np
# parse args before importing tf so the seed can be set globally
parser = argparse.ArgumentParser()
parser.add_argument('--seed', type=int, default=42, help='random seed')
parser.add_argument('--epochs', type=int, default=100, help='max epochs')
args = parser.parse_args()
# set seeds before any tf state is created
SEED = args.seed
random.seed(SEED)
np.random.seed(SEED)
os.environ['PYTHONHASHSEED'] = str(SEED)
os.environ['TF_DETERMINISTIC_OPS'] = '0' # full determinism slows xla too much, not worth it here
import tensorflow as tf
tf.random.set_seed(SEED)
tf.keras.utils.set_random_seed(SEED)
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.metrics import classification_report, confusion_matrix
from tensorflow.keras import layers, models, callbacks
from tensorflow.keras.preprocessing.image import ImageDataGenerator
# polite memory growth so tf doesn't grab all the gpu vram on startup
for g in tf.config.list_physical_devices('GPU'):
try:
tf.config.experimental.set_memory_growth(g, True)
except RuntimeError:
pass
# config
IMG_SIZE = 48
BATCH = 64
EPOCHS = args.epochs
LR_PEAK = 1e-3
LR_MIN = 1e-6
WARMUP_EP = 5
MODEL_DIR = "models"
MODEL_PATH = os.path.join(MODEL_DIR, f"emotion_cnn_v5b_seed{SEED}.keras")
EMOTIONS = ['angry','disgust','fear','happy','sad','surprise','neutral']
NUM_CLASSES = len(EMOTIONS)
os.makedirs(MODEL_DIR, exist_ok=True)
# focal loss (lin et al. 2017)
def categorical_focal_loss(gamma=1.5, alpha=0.5):
"""multi-class focal loss for softmax outputs.
gamma controls how aggressively easy examples are down-weighted; alpha
is a global scale. paper defaults are gamma=2, alpha=0.25 but those
over-corrected when stacked with class weights in an earlier run, so
we use gentler values here with no class weights.
"""
def loss(y_true, y_pred):
eps = 1e-7
y_pred = tf.clip_by_value(y_pred, eps, 1.0 - eps)
ce = -y_true * tf.math.log(y_pred)
weight = alpha * tf.pow(1.0 - y_pred, gamma)
return tf.reduce_sum(weight * ce, axis=-1)
return loss
# random erasing (zhong et al. 2020)
def random_erasing(p=0.4, sl=0.02, sh=0.10, r1=0.3, r2=3.3):
"""returns a function suitable as preprocessing_function for ImageDataGenerator.
with probability p, blanks out a random rectangle of the image. defaults
here are dialed back for 48x48 input: max area is 10%, not 20%, because
larger erases at this resolution wipe out eyes/mouth entirely.
"""
def _erase(img):
if np.random.uniform() > p:
return img
H, W, C = img.shape
area = H * W
for _ in range(10):
target = np.random.uniform(sl, sh) * area
ratio = np.random.uniform(r1, r2)
h = int(round(np.sqrt(target * ratio)))
w = int(round(np.sqrt(target / ratio)))
if h < H and w < W:
y = np.random.randint(0, H - h)
x = np.random.randint(0, W - w)
img[y:y+h, x:x+w, :] = float(img.mean())
return img
return img
return _erase
def get_generators():
train_aug = ImageDataGenerator(
rescale=1./255,
horizontal_flip=True,
rotation_range=15,
width_shift_range=0.12,
height_shift_range=0.12,
zoom_range=0.12,
validation_split=0.15,
preprocessing_function=random_erasing(p=0.4),
)
test_aug = ImageDataGenerator(rescale=1./255)
train_gen = train_aug.flow_from_directory(
'data/train', target_size=(IMG_SIZE, IMG_SIZE),
color_mode='grayscale', class_mode='categorical',
batch_size=BATCH, subset='training', shuffle=True, seed=SEED)
val_gen = train_aug.flow_from_directory(
'data/train', target_size=(IMG_SIZE, IMG_SIZE),
color_mode='grayscale', class_mode='categorical',
batch_size=BATCH, subset='validation', shuffle=False, seed=SEED)
test_gen = test_aug.flow_from_directory(
'data/test', target_size=(IMG_SIZE, IMG_SIZE),
color_mode='grayscale', class_mode='categorical',
batch_size=BATCH, shuffle=False)
return train_gen, val_gen, test_gen
# model architecture
# 4 vgg-style blocks: 32 -> 64 -> 192 -> 384, then a 512/256 dense head.
# total ~3.5m params. last conv layer is the grad-cam target.
def build_model():
inp = tf.keras.Input(shape=(IMG_SIZE, IMG_SIZE, 1))
# block 1: 32 -> 32
x = layers.Conv2D(32, 3, padding='same', activation='relu')(inp)
x = layers.BatchNormalization()(x)
x = layers.Conv2D(32, 3, padding='same', activation='relu')(x)
x = layers.BatchNormalization()(x)
x = layers.MaxPooling2D()(x); x = layers.Dropout(0.25)(x)
# block 2: 64 -> 64
x = layers.Conv2D(64, 3, padding='same', activation='relu')(x)
x = layers.BatchNormalization()(x)
x = layers.Conv2D(64, 3, padding='same', activation='relu')(x)
x = layers.BatchNormalization()(x)
x = layers.MaxPooling2D()(x); x = layers.Dropout(0.25)(x)
# block 3: 192 -> 192 (widened from the original 128)
x = layers.Conv2D(192, 3, padding='same', activation='relu')(x)
x = layers.BatchNormalization()(x)
x = layers.Conv2D(192, 3, padding='same', activation='relu')(x)
x = layers.BatchNormalization()(x)
x = layers.MaxPooling2D()(x); x = layers.Dropout(0.30)(x)
# block 4: 384 (widened from the original 256). single conv here, target for grad-cam.
x = layers.Conv2D(384, 3, padding='same', activation='relu')(x)
x = layers.BatchNormalization()(x)
x = layers.MaxPooling2D()(x); x = layers.Dropout(0.30)(x)
# head
x = layers.Flatten()(x)
x = layers.Dense(512, activation='relu')(x)
x = layers.BatchNormalization()(x)
x = layers.Dropout(0.5)(x)
x = layers.Dense(256, activation='relu')(x)
x = layers.Dropout(0.4)(x)
out = layers.Dense(NUM_CLASSES, activation='softmax')(x)
return models.Model(inp, out)
# cosine lr schedule with linear warm-up
def cosine_warmup_schedule(epoch, lr):
if epoch < WARMUP_EP:
return LR_PEAK * (epoch + 1) / WARMUP_EP
progress = (epoch - WARMUP_EP) / max(1, EPOCHS - WARMUP_EP)
return LR_MIN + 0.5 * (LR_PEAK - LR_MIN) * (1 + np.cos(np.pi * progress))
# training
def train(model, train_gen, val_gen):
cb = [
callbacks.EarlyStopping(monitor='val_accuracy', patience=20,
restore_best_weights=True, verbose=1),
callbacks.LearningRateScheduler(cosine_warmup_schedule, verbose=0),
callbacks.ModelCheckpoint(MODEL_PATH, monitor='val_accuracy',
save_best_only=True, verbose=1),
]
return model.fit(train_gen, validation_data=val_gen,
epochs=EPOCHS, callbacks=cb, verbose=1)
# evaluation with test-time augmentation
# tta = predict on the original frame and on its horizontal flip, average the
# two softmax outputs. cheap, deterministic, gives ~1pp accuracy for free.
def evaluate_with_tta(model, test_gen):
print(f"\n[eval] running with tta (seed={SEED})...")
test_gen.reset()
preds_orig = model.predict(test_gen, verbose=0)
test_gen.reset()
flipped_preds = []
for i in range(len(test_gen)):
batch_x, _ = test_gen[i]
batch_flipped = batch_x[:, :, ::-1, :] # horizontal flip on width axis
flipped_preds.append(model.predict(batch_flipped, verbose=0))
preds_flip = np.concatenate(flipped_preds, axis=0)
n = min(len(preds_orig), len(preds_flip))
preds_avg = (preds_orig[:n] + preds_flip[:n]) / 2.0
y_pred = np.argmax(preds_avg, axis=1)
y_true = test_gen.classes[:n]
acc = float(np.mean(y_pred == y_true))
print(f"[eval] tta test accuracy: {acc*100:.2f}%")
class_names = [k for k, v in sorted(test_gen.class_indices.items(), key=lambda x: x[1])]
print(classification_report(y_true, y_pred, target_names=class_names, zero_division=0))
rpt = classification_report(y_true, y_pred, target_names=class_names,
zero_division=0, output_dict=True)
rpt['tta'] = True
rpt['seed'] = SEED
with open(os.path.join(MODEL_DIR, f'class_report_v5b_seed{SEED}.json'), 'w') as f:
json.dump(rpt, f, indent=2)
# save raw softmax outputs and ground truth so ensemble_predict.py can use them
np.save(os.path.join(MODEL_DIR, f'preds_v5b_seed{SEED}.npy'), preds_avg[:n])
np.save(os.path.join(MODEL_DIR, f'ytrue_v5b.npy'), y_true)
return acc, y_true, y_pred, class_names
def save_plots(history, acc, y_true, y_pred, class_names):
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle(f'EmotionDetection v5b (seed={SEED}) - Training Results', fontsize=14, fontweight='bold')
axes[0].plot(history.history['accuracy'], label='Train', linewidth=2)
axes[0].plot(history.history['val_accuracy'], label='Val', linewidth=2, linestyle='--')
axes[0].axhline(acc, color='red', linestyle=':', label=f'Test+TTA: {acc:.1%}')
axes[0].set_title('Accuracy'); axes[0].set_xlabel('Epoch')
axes[0].legend(); axes[0].grid(alpha=0.3)
axes[1].plot(history.history['loss'], label='Train', linewidth=2, color='orange')
axes[1].plot(history.history['val_loss'], label='Val', linewidth=2, linestyle='--', color='red')
axes[1].set_title('Loss'); axes[1].set_xlabel('Epoch')
axes[1].legend(); axes[1].grid(alpha=0.3)
plt.tight_layout()
plt.savefig(os.path.join(MODEL_DIR, f'training_curves_v5b_seed{SEED}.png'), dpi=150, bbox_inches='tight')
plt.close()
cm = confusion_matrix(y_true, y_pred)
cm_norm = cm.astype(float) / cm.sum(axis=1, keepdims=True)
fig, ax = plt.subplots(figsize=(9, 7))
sns.heatmap(cm_norm, annot=True, fmt='.2f', cmap='Blues',
xticklabels=class_names, yticklabels=class_names, ax=ax)
ax.set_xlabel('Predicted'); ax.set_ylabel('True')
ax.set_title(f'Normalised confusion matrix (v5b seed={SEED} with TTA)', fontsize=13, fontweight='bold')
plt.tight_layout()
plt.savefig(os.path.join(MODEL_DIR, f'confusion_matrix_v5b_seed{SEED}.png'), dpi=150, bbox_inches='tight')
plt.close()
hist = {k: [float(v) for v in vals] for k, vals in history.history.items()}
hist['test_accuracy_tta'] = float(acc)
hist['seed'] = SEED
with open(os.path.join(MODEL_DIR, f'training_history_v5b_seed{SEED}.json'), 'w') as f:
json.dump(hist, f, indent=2)
def main():
print("=" * 55)
print(f" emotiondetection - training (seed={SEED})")
print("=" * 55)
print(f" tensorflow: {tf.__version__}")
gpus = tf.config.list_physical_devices('GPU')
print(f" gpu: {bool(gpus)} ({len(gpus)} device(s))")
print("=" * 55 + "\n")
if not os.path.isdir('data/train') or not os.path.isdir('data/test'):
print("[error] data/train or data/test not found. see data/README.md")
return
train_gen, val_gen, test_gen = get_generators()
label_map = {str(v): k for k, v in train_gen.class_indices.items()}
with open(os.path.join(MODEL_DIR, 'class_labels.json'), 'w') as f:
json.dump(label_map, f, indent=2)
model = build_model()
model.compile(
optimizer=tf.keras.optimizers.Adam(LR_PEAK),
loss=categorical_focal_loss(gamma=1.5, alpha=0.5),
metrics=['accuracy'])
print(f"\n[model] total params: {model.count_params():,}")
print(f"[model] saving best to: {MODEL_PATH}")
history = train(model, train_gen, val_gen)
acc, y_true, y_pred, class_names = evaluate_with_tta(model, test_gen)
save_plots(history, acc, y_true, y_pred, class_names)
print("\n" + "=" * 55)
print(f" done (seed={SEED}) - tta test accuracy: {acc*100:.2f}%")
print("=" * 55)
if __name__ == "__main__":
main()