The problem in brief
You must build a convolutional neural network that reads a small grey-scale photo of a face (48 x 48 pixels) and says which of seven emotions it shows: angry, disgust, fear, happy, neutral, sad or surprise. The data is the classic FER2013 dataset, given as image folders: 28 709 training faces and 7 178 test faces, each labelled by its folder. The deliverable is a notebook that trains the model and reports, on the test folder, the balanced accuracy and the macro F1-score; the statement insists on class imbalance and on robustness to hard, subtle or partly hidden expressions.
The test labels are public (they are the folder names), so we report our final numbers on the official test set. Every choice (architecture, number of epochs, the threshold correction described below) was made on a 10 % validation split carved out of the training folder; the test set was used only once per model, to report.
How you are scored
Two metrics, both insensitive to how frequent a class is. With the recall of class (the share of its faces that are recognised) and its precision:
Balanced accuracy is the plain accuracy you would measure if every emotion were equally frequent. Macro F1 also punishes false alarms, through the precision. The statement gives no threshold: the submission is the reported numbers, so there is no "expected" score to compare with. For reference, plain accuracy is also reported.
First look at the data

- Tiny, varied images. The faces are roughly centred but vary in pose, lighting, age, glasses, hands in front of the face, and even stock-photo watermarks. Some labels are debatable (is the second "sad" face sad or thoughtful?): FER2013 is known for noisy labels, so nobody gets close to 100 %.
- Strong imbalance. Happy has 7 215 training faces, disgust only 436, 16 times fewer. A model that ignores disgust loses one seventh of both metrics.
- Natural confusions. Fear, surprise and sad share open eyes or mouths, and angry and disgust both frown. Expect errors there.
A first baseline
Before any network, a linear model on pixels: project the 2 304 pixel values on their first 200 principal components and fit a logistic regression. It reaches 37.3 % accuracy but only 0.299 balanced accuracy and 0.291 macro F1: it mostly predicts happy and neutral. Reweighting classes (class_weight="balanced") lifts balanced accuracy to 0.345 but costs accuracy. Pixels are a poor representation: a smile shifted by 3 pixels looks like a completely different vector.
The key idea
Two ideas do almost all the work, one for each difficulty of the task.
1. A CNN with data augmentation, for the images. A convolution looks for the same small pattern (an edge, a corner of the mouth) everywhere in the image, and pooling makes the answer tolerant to small shifts. That is exactly what a lopsided smile needs. But with only 26 000 training faces, a 4.7-million-parameter network memorises them quickly. Augmentation fights this: at every step each face is randomly mirrored, shifted by up to 5 %, rotated by up to 10 degrees and rescaled by up to 10 %. A mirrored smile is still a smile, so the label is unchanged, and the network sees a slightly different face each epoch. The learning curves show the effect: without augmentation the training loss falls twice as fast but validation lags behind; with augmentation the training loss stays high (the task is harder) and validation keeps improving for 25 epochs.

2. Logit adjustment, for the metric. A network trained with cross-entropy estimates , which includes the class prior : since happy is 16 times more frequent than disgust, a borderline face is pulled towards happy. Balanced accuracy, however, is the accuracy under a uniform prior. By Bayes' rule, changing the prior from to uniform means dividing each probability by , that is subtracting from each log-probability:
is the exact Bayes correction; we choose on the validation split (it gives , slightly under 1 because the network is a bit over-confident). No retraining is needed: it is a one-line change at prediction time. It raised the recall of disgust on the test set from 29 % to 68 %.
The full solution
- Load the image folders into arrays (28 709 x 48 x 48), normalise the pixels, and keep 10 % of the training faces aside as a validation set.
- Network (VGG-style): four blocks of two 3 x 3 convolutions with batch normalisation and ReLU, then max-pooling (48 to 24 to 12 to 6 to 3 pixels, 64 to 512 channels), then dropout 0.4, a 256-unit dense layer, dropout 0.4 and 7 outputs.
- Training: 25 epochs of AdamW (weight decay , batch 128) with a one-cycle learning-rate schedule up to , cross-entropy with label smoothing 0.1 (useful with noisy labels), augmentation on the GPU. About 11 minutes on an Apple M-series GPU.
- Test-time augmentation: average the log-probabilities of each face and of its mirror image.
- Logit adjustment: subtract (training class shares) before the argmax.
lt = logits_of(model, x_test) # log-probabilities, mirror-averaged
pred = (lt - tau * np.log(prior)).argmax(1) # tau = 0.8, chosen on validation
The adjustment is a trade-off, and the confusion matrices show it. Disgust jumps from 29 % to 68 % recall and surprise improves, while happy, angry and sad each lose a few points, because some of their faces are now given to the rare classes. Balanced accuracy, which weighs each recall equally, gains 4.3 points. Macro F1 drops slightly (0.639 to 0.626): the new disgust predictions include more false alarms, and F1 penalises the precision lost. Pick the decision rule for the metric you are judged on: use the adjusted rule for balanced accuracy, the plain argmax for macro F1.

Before the adjustment, 43 % of disgusted faces were called angry: the network had learned that a frown is "probably angry", which is true on average but not for the rare class. Fear remains the weakest emotion (43 % recall), mostly confused with sad and neutral, and a fifth of the sad faces are taken for neutral: these are the pairs that also confuse human annotators of FER2013.
Results
Official FER2013 test folder (7 178 faces); all choices made on the validation split.
| approach | accuracy | balanced accuracy | macro F1 |
|---|---|---|---|
| PCA-200 + logistic regression | 0.373 | 0.299 | 0.291 |
| same, class-balanced weights | 0.325 | 0.345 | 0.289 |
| CNN, no augmentation, 10 epochs | 0.638 | 0.548 | 0.549 |
| same + flip TTA + logit adjustment () | 0.638 | 0.621 | 0.579 |
| CNN + augmentation, 25 epochs | 0.681 | 0.612 | 0.626 |
| + flip test-time augmentation | 0.691 | 0.624 | 0.639 |
| + logit adjustment, (final for balanced accuracy) | 0.679 | 0.667 | 0.626 |
The final model reaches 69.1 % accuracy, 0.667 balanced accuracy (with logit adjustment) and 0.639 macro F1 (without it), against 0.30 for the pixel baseline.
What to remember
- Convolutions plus augmentation beat raw pixels by a mile on images: weight sharing and pooling give tolerance to small shifts, and augmentation multiplies a small dataset by encoding invariances you know are true (a mirrored face keeps its emotion).
- A training loss that falls fast is not good news by itself. Compare it with the validation curve; augmentation keeps the two close and lets you train longer.
- Match the decision rule to the metric. A classifier trained on imbalanced data predicts with the training prior; for balanced accuracy, subtract from the logits (logit adjustment), with tuned on validation. It costs nothing and needs no retraining.
- Different metrics can disagree. Logit adjustment raised balanced accuracy by 4 points but lowered macro F1 by 1: report both and know which one you optimise.
- Choose on validation, report on test once. The test labels are in plain sight here; using them to pick or the epoch count would inflate the numbers.