| """A small CNN+BiGRU classifier (CRNN), trained end-to-end from scratch. |
| |
| The third architecture tried on this task, after the frozen-WavLM head |
| (model.py) and the from-scratch transformer (transformer_model.py). It exists |
| to test a specific hypothesis about why the transformer plateaued: measured |
| on the test split, that model reached 80.1% while its train loss kept falling |
| to 0.108 -- it had capacity to spare and was spending it memorising, not |
| generalising. A transformer has almost no built-in assumptions about its |
| input, so with 40k clips it has to *learn* that a spectrogram has local |
| time-frequency structure. A CRNN is told that up front: |
| |
| - 2D convolutions over (frequency x time) share weights across both axes, |
| so a harmonic stack or an onset is recognised the same wherever it lands |
| in the clip. The transformer has to learn that invariance from data. |
| - A bidirectional GRU then reads the conv output along time, which is a |
| cheaper way to model sequence structure than all-pairs attention when |
| the useful context is mostly local. |
| |
| Whether that trade is worth it here is an empirical question -- hence the |
| identical training loop, seed and data as the transformer, so the only thing |
| that differs is the architecture. |
| """ |
|
|
| import torch |
| from torch import nn |
|
|
| |
| |
| |
| |
| try: |
| import smad_config as config |
|
|
| NUM_CLASSES = config.NUM_CLASSES |
| except ImportError: |
| NUM_CLASSES = 4 |
|
|
| try: |
| from mel_features import N_MELS |
| except ImportError: |
| N_MELS = 80 |
|
|
|
|
| class ConvBlock(nn.Module): |
| """Conv -> BatchNorm -> ReLU -> MaxPool, the standard CRNN unit. |
| |
| BatchNorm rather than the transformer's LayerNorm: convolutions share |
| weights across positions, so normalising per-channel over the batch is |
| the matching choice, and it also removes any dependence on the input's |
| absolute dB offset. |
| """ |
|
|
| def __init__(self, in_ch, out_ch, pool): |
| super().__init__() |
| self.block = nn.Sequential( |
| nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1, bias=False), |
| nn.BatchNorm2d(out_ch), |
| nn.ReLU(inplace=True), |
| nn.MaxPool2d(pool), |
| ) |
|
|
| def forward(self, x): |
| return self.block(x) |
|
|
|
|
| class TinyAudioCRNN(nn.Module): |
| """(B, T, N_MELS) log-mel spectrogram -> 4 class logits.""" |
|
|
| def __init__(self, n_mels=N_MELS, channels=(32, 64, 128, 128), rnn_hidden=128, |
| dropout=0.2, num_classes=NUM_CLASSES, rnn_type="gru"): |
| super().__init__() |
| |
| |
| |
| self.register_buffer("feat_mean", torch.zeros(n_mels)) |
| self.register_buffer("feat_std", torch.ones(n_mels)) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| pools = [(2, 2)] * (len(channels) - 1) + [(2, 1)] |
| blocks, in_ch = [], 1 |
| for out_ch, pool in zip(channels, pools): |
| blocks.append(ConvBlock(in_ch, out_ch, pool)) |
| in_ch = out_ch |
| self.conv = nn.Sequential(*blocks) |
|
|
| freq_out = n_mels |
| for freq_pool, _ in pools: |
| freq_out //= freq_pool |
| if freq_out < 1: |
| raise ValueError( |
| f"{len(channels)} conv blocks pool {n_mels} mel bins down to nothing") |
| rnn_in = channels[-1] * freq_out |
|
|
| self.dropout = nn.Dropout(dropout) |
| |
| |
| |
| |
| |
| rnn_cls = {"gru": nn.GRU, "lstm": nn.LSTM}[rnn_type.lower()] |
| self.rnn = rnn_cls(rnn_in, rnn_hidden, num_layers=1, batch_first=True, |
| bidirectional=True) |
| |
| |
| |
| |
| |
| self.classifier = nn.Linear(rnn_hidden * 2 * 2, num_classes) |
|
|
| def set_normalization(self, mean, std): |
| with torch.no_grad(): |
| self.feat_mean.copy_(torch.as_tensor(mean, dtype=self.feat_mean.dtype)) |
| self.feat_std.copy_(torch.as_tensor(std, dtype=self.feat_std.dtype).clamp_min(1e-5)) |
|
|
| def forward(self, x): |
| x = (x - self.feat_mean) / self.feat_std |
| |
| |
| x = x.transpose(1, 2).unsqueeze(1) |
| x = self.conv(x) |
| b, c, f, t = x.shape |
| x = x.permute(0, 3, 1, 2).reshape(b, t, c * f) |
| x = self.dropout(x) |
| x, _ = self.rnn(x) |
| pooled = torch.cat([x.mean(dim=1), x.max(dim=1).values], dim=-1) |
| return self.classifier(self.dropout(pooled)) |
|
|
|
|
| def count_parameters(model): |
| return sum(p.numel() for p in model.parameters() if p.requires_grad) |
|
|