The problem in brief
A company runs acoustic monitoring stations with an Audio Spectrogram Transformer (AST) that recognises 16 everyday and wildlife sounds (dog, rooster, rain, keyboard typing...). Two new customers want 13 more classes: logging and poaching sounds (axe, chainsaw, hand saw, generator, engine, helicopter, gunshot, firework, crackling fire) and four singing insect species. You must turn the deployed 16-class model into a single 29-class model, starting from its checkpoint, with only a few minutes of single-GPU training, and without forgetting the old classes. Only a small leftover sample of the old data is available (296 training clips, as few as 3 per class), plus 624 training clips for the new classes.
This was a practice task of the IOAI 2026 at-home round: the hidden test set was never published, so all our numbers are on the official validation splits provided with the data (195 old clips, 168 new clips), which no model selection touched except where stated.
How you are scored
On a 29-way prediction, accuracy is measured separately on clips whose true class is old and on clips whose true class is new, and the two are averaged:
Forgetting the old classes costs exactly as much as not learning the new ones. The deployed model scores (it cannot output a new class). There is no published threshold or leaderboard for this practice task.
First look at the data
- Very little old data. The retained old set is tiny and uneven: Sheep and Keyboard Typing have 3 training clips each, Thunderstorm 62. You cannot relearn the old classes from it; you can only use it to remind the model.
- The audio is not what the model expects. Despite the notebook's description, the clips are stored at 44.1 or 48 kHz in stereo; AST was trained on 16 kHz mono, so every clip must be downmixed and resampled (the notebook does it with
librosa.load(sr=16000); we usescipy.signal.resample_poly). - The encoder is huge, the head tiny. AST has 86 M parameters, and the 16-class head (a LayerNorm and one linear layer) has 13.8 K. Each clip becomes a 768-number pooled embedding (average of the two summary tokens), and the head is just a linear classifier on top of it.
Running the frozen encoder once over all 1 283 clips takes about 8 minutes on 2 CPU threads; after that, every experiment on the head takes seconds. That makes it cheap to explore.
A first baseline
The obvious move: add 13 rows to the head and train on the new clips only. The new rows learn well (80.8 % on new classes), but the score falls to 0.404, below the deployed model: old accuracy collapses to 0 %. This is catastrophic forgetting in its purest form. The cross-entropy loss only ever sees new labels, so the cheapest way to lower it is to push all old logits down, and the old classes never win again.
Freezing the 16 old rows of the head does not save them either (score 0.682, old accuracy 55 %). The old rows stay intact, but the new rows learn to produce large logits, larger than the old ones, on every clip, including old ones. Forgetting is not only about weights changing; it is about the competition between classes in the softmax.
The key idea
Two things fight forgetting, and they act on different parts of the problem.
1. Replay. Mix the old training clips back into every batch, with class-balanced sampling so that a class with 3 clips is seen as often as one with 60. Now the loss also punishes the model when it predicts a new class for a dog. Score: 0.779, with old and new accuracies balanced (0.79 and 0.77). But 296 old clips are too few to pin down 16 classes, and the old accuracy is far below the original 0.98.
2. Knowledge distillation (KD). We do have something much richer than the old labels: the deployed model itself. On every training clip, old or new, it gives a full probability distribution over the 16 old classes, which encodes what it learned from the data we no longer have. We ask the new model's 16 old logits to reproduce it:
where are the new model's 29 logits, the frozen model's 16 logits, softens both distributions (so that the "dark knowledge" in the small probabilities, such as "this rain sounds a bit like sea waves", is transmitted), and the factor keeps the gradient size comparable to the cross-entropy. This is the idea behind Learning without Forgetting (Li and Hoiem, 2016).
Why does it work so well here? Because the KD term is applied to new clips too. On a chainsaw clip, the old model says "mostly Frog, a bit of Rain"; KD asks the new model to keep that relative pattern among the old logits, while cross-entropy pushes the new Chainsaw logit above all of them. The two losses no longer fight: one constrains the shape of the old logits, the other only needs to raise one new logit. Every clip becomes a reminder of the old behaviour, which multiplies the effective size of the replay set.
The weight matters a lot: gives 0.827, gives 0.869, and gives 0.875, with old accuracy back at 0.981, as good as the original model, while new accuracy stays at 0.77.

The full solution
-
Embed once. Load each clip as 16 kHz mono, compute AST's 1024 x 128 log-mel input, run the frozen encoder, keep the 768-number pooled embedding and the deployed head's 16 logits.
-
Grow the head. New
Linear(768, 29): rows 0 to 15 copied from the deployed head (so the model starts exactly where it was), rows 16 to 28 initialised small, with bias equal to the mean old bias. The LayerNorm before it is kept. -
Train the head for 60 epochs with AdamW (learning rate , weight decay ), batches of 32 drawn from old and new training clips with class-balanced sampling, label smoothing 0.1, plus the KD term with , .
loss = F.cross_entropy(z, y, label_smoothing=0.1) kd = F.kl_div(F.log_softmax(z[:, :16] / T, -1), F.softmax(z_frozen / T, -1), reduction="batchmean") loss = loss + 10 * T * T * kd -
Put the new head back on the untouched encoder and save one 29-class model. Training takes about one minute on a CPU, far inside the 10-minute GPU budget.
Did fine-tuning the encoder help? We also cached the input of encoder layer 10 for every clip and fine-tuned the last two transformer layers plus the head (learning rate for the layers, same replay and KD , 4 epochs, 15 minutes on CPU). It reached 0.868 (old 0.974, new 0.762): no better than the head alone. With about 45 clips per new class, the frozen AudioSet features are already good enough, and updating 14 M more parameters mostly adds variance and a little forgetting. On the contest GPU one could afford more epochs and augmentation, but the evidence here says the head is where the gains are.
Where are the remaining errors? The hard new classes are the short, impulsive or noisy ones: Gunshot (47 %, often taken for Axe), Crackling Fire and Vehicle Engine (both often taken for Rain), Firework (taken for Thunderstorm). Two of the four insects are easy (Tettigonia viridissima 100 %), but Gryllus campestris is confused with Tettigonia. These are genuinely similar sounds, and each new class has only 15 validation clips, so a single clip moves a class's accuracy by 7 points.

Results
Validation splits provided with the task (official test labels are hidden). Head-only results are the mean of 3 seeds; the standard deviation over seeds is 0.003 to 0.009.
| approach | old acc. | new acc. | score |
|---|---|---|---|
| deployed 16-class model | 0.979 | 0.000 | 0.490 |
| new head, new clips only | 0.000 | 0.808 | 0.404 |
| same, old head rows frozen | 0.554 | 0.810 | 0.682 |
| replay old + new clips | 0.790 | 0.768 | 0.779 |
| replay, class-balanced | 0.791 | 0.762 | 0.777 |
| replay + KD, | 0.882 | 0.772 | 0.827 |
| replay + KD, | 0.974 | 0.764 | 0.869 |
| replay + KD , old rows frozen | 0.979 | 0.758 | 0.869 |
| replay + KD , 150 epochs | 0.971 | 0.754 | 0.863 |
| replay + KD, (final) | 0.981 | 0.770 | 0.876 |
| fine-tune last 2 encoder layers + head, KD 10 (1 seed) | 0.974 | 0.762 | 0.868 |
The final model reaches 0.876 on validation, against 0.490 for the deployed model, while keeping the old classes at their original accuracy.
What to remember
- Catastrophic forgetting is a softmax competition. Training only on new labels pushes old logits down; even frozen old weights lose because the new logits grow. You must show the model old examples or old behaviour.
- The old model is your best data. Distilling its outputs on every clip, including new ones, preserves what it knew far better than a small replay set (old accuracy 0.79 with replay alone, 0.98 with KD).
- Cache the frozen part. When the encoder is frozen, compute its features once; dozens of experiments then cost seconds, not hours.
- Fine-tuning more is not automatically better. With tens of examples per class, a strong pretrained encoder plus a well-trained head often beats partial fine-tuning, which adds variance and forgetting.
- Check the input pipeline. The audio was 44.1/48 kHz stereo while the model expects 16 kHz mono: one silent mismatch like this can cost more than any training trick.
Note on the code. The full
solution.pybelow was assembled from scripts that did run, but it was not re-run end to end after assembly.