smad-audio-classifier / crnn_model.py
duclvQ's picture
Add inference package
bddc68f verified
Raw
History Blame Contribute Delete
6.26 kB
"""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
# Imported from the training project when it is present, with literal
# fallbacks so this file also works on its own inside the inference package,
# where the rest of the project is not shipped. The fallbacks are asserted
# against the real values by tests/test_crnn_model.py.
try:
import smad_config as config
NUM_CLASSES = config.NUM_CLASSES
except ImportError: # standalone inference
NUM_CLASSES = 4
try:
from mel_features import N_MELS
except ImportError: # standalone inference
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__()
# Same standardisation contract as TinyAudioTransformer: statistics
# live in state_dict so predict.py cannot feed a different scale than
# the model trained on. Defaults are the identity.
self.register_buffer("feat_mean", torch.zeros(n_mels))
self.register_buffer("feat_std", torch.ones(n_mels))
# MaxPool2d((a, b)) pools height by a and width by b, and the input is
# laid out (freq, time) -- so every block halves frequency, while only
# the earlier ones halve time. Frequency detail is being folded into
# the channel dimension anyway, whereas the GRU downstream needs
# whatever temporal resolution it can get.
# Derived from len(channels) rather than fixed, so the depth stays a
# free parameter: a hardcoded 4-tuple silently mismatched the block
# count when a test built a 2-block model.
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)
# GRU by default, LSTM available for comparison. The two differ in
# whether a separate cell state carries information alongside the
# hidden state; on 50 time steps per clip that extra memory has little
# room to pay for its third of extra parameters, but that is a claim
# worth measuring rather than assuming.
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)
# mean AND max over time, concatenated: mean describes the clip as a
# whole, max fires on the single best moment. For "is there a voice
# anywhere in these 4 seconds" a brief clear phrase should count even
# when the rest of the clip is only background, which mean-pooling
# alone would dilute.
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
# (B, T, F) -> (B, 1, F, T): convolutions want frequency as the
# "height" axis so a kernel spans neighbouring bins and frames alike.
x = x.transpose(1, 2).unsqueeze(1)
x = self.conv(x) # (B, C, F', T')
b, c, f, t = x.shape
x = x.permute(0, 3, 1, 2).reshape(b, t, c * f) # (B, T', C*F')
x = self.dropout(x)
x, _ = self.rnn(x) # (B, T', 2*hidden)
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)