Wav2vec2 / WavLM audio classifier stuck at chance (33%) — only training the head

I’m fine-tuning facebook/wav2vec2-base (also tried microsoft/wavlm-base-plus) for a 3-class audio classification problem: classifying short /s/ and /z/ phoneme clips as Normal, Lateral, or Interdental (a speech-therapy “lisp type” task). Clips are cut with Montreal Forced Aligner.

Data

  • 1057 clips total. Imbalanced: Lateral 580, Normal 243, Interdental 234.
  • Clips are very short fricatives: median 0.16s, max 0.49s, 16 kHz mono.
  • 5-fold StratifiedGroupKFold grouped by source recording (no speaker leakage).

Result: ~32% accuracy on held-out test (chance for 3 classes). Confusion matrix shows the model predicting the majority class (Lateral) for almost everything:

Setup (the parts I suspect):
model = AutoModelForAudioClassification.from_pretrained(MODEL, num_labels=3, …)
model.freeze_base_model() # only the classification head trains

TrainingArguments(
learning_rate=1e-3,
per_device_train_batch_size=16,
num_train_epochs=20,
warmup_ratio=0.1, weight_decay=0.01,
fp16=True, …)

feature extraction: every clip padded/truncated to 1.0s

fe(arr, sampling_rate=16000, max_length=16000,
truncation=True, padding=‘max_length’) # no attention_mask passed

What I’ve already considered / questions:

  1. freeze_base_model() freezes the whole backbone so only a linear head trains on frozen self-supervised features. For a subtle articulation difference, is linear-probing realistic, or do I need to unfreeze the transformer encoder (freeze_feature_encoder() only)?
  2. learning_rate=1e-3 — is that far too high for wav2vec2 fine-tuning? I’ve seen 1e-4 / 3e-5 recommended.
  3. My clips are ~0.16s but I pad to 1.0s (~84% zeros) and don’t pass an attention_mask. How much does that hurt, and should I use dynamic padding to longest-in-batch instead?
  4. Class imbalance (Lateral 2.4×) — best practice here: weighted CrossEntropy, a weighted sampler, or both?

Any guidance on which of these is the main culprit would help. Happy to share more code.

Hmm… it looks like this may be one of those problems that is not unsolvable, but is deeper than it first appears:


Direct answers to your four suspected points:

  1. Linear probing / head-only training: I would not expect head-only training to be reliable for this task. It is worth testing as a baseline, but for Normal vs Lateral vs Interdental /s,z/, I would try at least freeze_feature_encoder() only, or unfreezing the last few transformer layers.

  2. Learning rate: 1e-3 may be reasonable for a newly initialized head, but it is probably too high if you unfreeze the encoder. If you fine-tune encoder layers, I would use separate parameter groups: lower LR for the backbone, higher LR for the classifier head.

  3. Padding / attention mask: yes, I would treat the current fixed 1.0s padding as a major suspect. The issue is not only “should I pass attention_mask?”, because Wav2Vec2 attention-mask behavior is checkpoint-dependent. The bigger issue is that the actual fricative may be a small fraction of the pooled sequence.

  4. Class imbalance: I would handle it, but not first. Since the largest class is 580/1057, an always-Lateral baseline would be around 55%, so a ~33% result suggests something deeper than ordinary class imbalance.

So my first debugging order would be:

tiny-subset overfit
→ label / fold / trainable-parameter sanity checks
→ padding + pooling ablation
→ partial unfreezing / layer selection
→ acoustic baseline
→ then class weighting or sampling

Why I think this is more than class imbalance

Your class counts are:

class count
Lateral 580
Normal 243
Interdental 234
total 1057

If the model truly predicted only the majority class on the whole distribution, the accuracy should be closer to 55%, not 33%. Fold-level distributions can change this, of course, but the shape still makes me suspicious of something else:

  • label mapping mismatch,
  • metric/confusion-matrix axis issue,
  • only the classifier head being trainable,
  • hidden states being pooled in a way that washes out the useful frames,
  • padding dominating the representation,
  • or the frozen final representation not linearly separating this contrast.

Class imbalance may still hurt macro-F1 and minority recall, but I would not make it the first explanation.

Why the current setup is a hard case

The task itself does not look impossible. Similar sibilant / sigmatism-style classification problems have been studied before, often with MFCCs, log-Mel features, spectral-band energy, CNNs, or other acoustic features. So I would not conclude from the 33% result that the labels are necessarily impossible or that Wav2Vec2/WavLM are useless here.

But the current setup combines several hard conditions:

issue why it matters
median clip is ~0.16s the useful fricative signal may only be a few feature frames
all clips padded/truncated to 1.0s the pooled representation may be dominated by padding / near-silence behavior
no attention_mask default sequence pooling may average over frames that are not really useful
freeze_base_model() only the classification head adapts, not the SSL representation
default pooled sequence classifier a very local acoustic contrast becomes one global vector
3-way subtle articulation labels Normal / Lateral / Interdental may require fine spectral/phonetic cues

So I would treat the current result as a sign that the pipeline needs ablation, not as a final verdict on the task.

Why I think padding/pooling is a primary suspect

The key question is not only “is the model good enough?” but:

Which time frames actually reach the classifier, and how are they pooled?

Wav2Vec2/WavLM sequence-classification models pool a time sequence into one vector and then classify that vector. WavLM’s docs describe WavLMForSequenceClassification as a model with “a linear layer over the pooled output”:

WavLM · Hugging Face

Wav2Vec2’s default convolution strides are:

(5, 2, 2, 2, 2, 2, 2)

Their product is 320. At 16 kHz, this means roughly one feature frame per 20 ms:

16000 samples/sec / 320 ≈ 50 feature frames/sec

So a rough scale estimate is:

waveform length samples rough feature-frame count
0.16s ~2,560 ~8
1.0s ~16,000 ~50

The exact number depends on kernels and implementation details, but the scale is the important part. If the useful /s,z/ segment is only around 8 feature frames, and the model pools over a 1-second padded sequence, the useful fricative information can be diluted.

There is also a related Transformers issue about Wav2Vec2’s post-convolution attention mask:

Return updated attention mask from Wav2Vec 2.0 · Issue #25307 · huggingface/transformers · GitHub

That issue is not necessarily your exact bug, but it highlights a real downstream-modeling detail: after the convolutional frontend changes the time length, pooling last_hidden_state safely may require a feature-vector-level mask, not just the original waveform-level idea of padding.

So I would not only ask:

Should I pass attention_mask?

I would ask:

How much padded or non-informative time is being pooled into my classifier vector?

Possible ablations:

experiment purpose
fixed 1.0s padding, current setup reproduce current result
dynamic padding reduce unnecessary padding
shorter max_length test whether 1.0s is too long
attention mask on/off where appropriate check checkpoint-dependent behavior
custom mask-aware pooling avoid pooling padded feature frames
central-frame pooling focus on the fricative itself
mean + std pooling retain distributional information
max pooling preserve salient local activation
attention pooling learn which frames matter

One caution: I would not give the simple advice “just pass attention_mask” without checking the checkpoint. The Wav2Vec2 docs note that attention-mask behavior depends on the feature extractor norm type. For example, group-norm checkpoints such as facebook/wav2vec2-base have a specific warning around attention_mask usage:

Wav2Vec2 · Hugging Face

So the safer recommendation is not one magic flag. It is an ablation around padding length, dynamic padding, pooling, and masks.

Why head-only training may be too weak here

If freeze_base_model() freezes the SSL backbone, then the task becomes:

frozen Wav2Vec2/WavLM representation + trainable classification head

That can be useful as a linear-probe baseline. But it is a strong assumption for this task.

It assumes that the frozen final pooled representation already linearly separates:

Normal vs Lateral vs Interdental /s,z/

For a subtle articulation task, that may not be true.

Frozen SSL features are a real and useful evaluation paradigm. SUPERB-style benchmarks often freeze a shared speech model and train task-specific downstream heads:

https://sls.csail.mit.edu/publications/2021/JeffLai_Interspeech_2021.pdf

But this case is more delicate than a broad audio or intent classification problem:

  • the clip is very short,
  • the contrast is fine-grained,
  • the useful acoustic information may be local,
  • the final layer may not be the best layer for phonetic detail,
  • and the default sequence classifier compresses the whole sequence into one vector.

So I would treat head-only training as a baseline, not as the main expected solution.

I would try a small ladder:

setup what it tests
head-only current linear-probe baseline
freeze_feature_encoder() only freeze convolutional frontend, adapt transformer layers
unfreeze last 2–4 transformer layers limited task adaptation
full fine-tune upper bound, but more overfitting risk
use_weighted_layer_sum=True learn a mixture of model layers
selected-layer probing check whether middle layers contain better phonetic cues

The Wav2Vec2 docs mention use_weighted_layer_sum in the sequence-classification configuration path:

Wav2Vec2 · Hugging Face

This is relevant because phonetic/acoustic information is often layer-dependent. Some probing papers suggest that phones, tones, speaker information, accent information, or prosodic information can appear differently across layers:

[2408.13678] A layer-wise analysis of Mandarin and English suprasegmentals in SSL speech models
[2506.10855] Analyzing the relationships between pretraining language, phonetic, tonal, and speaker information in self-supervised speech models
[2306.06524] What Can an Accent Identifier Learn? Probing Phonetic and Prosodic Information in a Wav2vec2-based Accent Identification Model

So if the final frozen pooled representation fails, that does not necessarily mean Wav2Vec2/WavLM contains no useful information. It may mean the useful information is in another layer, or that it needs some task adaptation.

Why I would add an MFCC/log-Mel baseline

I would add a non-SSL acoustic baseline, not because it is necessarily better, but because it answers a different question:

Are these labels acoustically separable at all?

For sibilant / sigmatism-type classification, older work uses fairly direct acoustic features such as MFCCs, spectral-band energy, and related speech features:

https://www.cstr.ed.ac.uk/downloads/publications/2012/Cassia_WOCCI12.pdf

There is also work on sibilant consonant classification with MFCC/log-Mel features and neural classifiers:

https://dspace.rcaap.pt/entities/publication/c0cc15ad-cf57-4a4c-bfd4-423d2e2894f5

So I would compare against something simple:

baseline what it tells you
MFCC + logistic regression are the labels linearly/semi-linearly separable?
MFCC + SVM classic acoustic baseline
log-Mel + small CNN local time-frequency pattern baseline
spectral centroid/bandwidth/band energy + classifier interpretable sibilant cue baseline

Interpretation:

result likely meaning
MFCC/log-Mel baseline works, SSL head-only fails task is likely real, SSL extraction path is weak
both fail check labels, segment boundaries, class definitions, speaker/source split
SSL only works after unfreezing/pooling changes frozen pooled representation was too restrictive
tiny subset cannot overfit pipeline bug or severe representation/pooling issue

This baseline would make the Wav2Vec2/WavLM result easier to interpret.

Minimal debugging checklist

I would do the checks in this order.

1. Print label mappings and examples

print(model.config.label2id)
print(model.config.id2label)

for i in range(10):
    ex = train_dataset[i]
    print(i, ex["label"])

Also print some predictions as both ids and labels:

pred_id = logits.argmax(-1).item()
print("pred_id:", pred_id)
print("pred_label:", model.config.id2label[pred_id])
print("gold_id:", gold_id)
print("gold_label:", model.config.id2label[gold_id])

This catches surprisingly many classification failures.

2. Print per-fold class counts

from collections import Counter

print("train:", Counter(train_labels))
print("valid:", Counter(valid_labels))

If possible, also print source-recording and speaker counts:

print("train sources:", len(set(train_sources)))
print("valid sources:", len(set(valid_sources)))

3. Check trainable parameters

trainable = [(n, p.numel()) for n, p in model.named_parameters() if p.requires_grad]
total_trainable = sum(n for _, n in trainable)

print("trainable parameter count:", total_trainable)
for name, n in trainable[:80]:
    print(name, n)

If only the classifier is trainable, that is expected with head-only probing, but it should be an explicit known condition.

4. Tiny subset overfit

Use maybe 10–20 examples per class, no fancy early stopping, and evaluate on the same subset.

Expected result:

outcome interpretation
train accuracy goes near 100% pipeline can learn; now debug generalization
train accuracy stays near chance debug labels, loss, pooling, freezing, LR, preprocessing

This is the fastest high-value test.

5. Padding/pooling inspection

Print actual durations and padded lengths:

import numpy as np

lengths = [len(x["input_values"]) for x in train_dataset]
print(np.min(lengths), np.median(lengths), np.max(lengths))

If all inputs are forced to exactly 16000 samples while the useful clips are much shorter, test dynamic padding or shorter max_length.

6. Metric check

Do not rely only on accuracy. Use:

  • macro-F1
  • balanced accuracy
  • per-class recall
  • raw confusion matrix
  • majority baseline

A simple majority baseline is important here because the largest class is ~55% of the dataset.

A small ablation table I would run

I would keep the first pass small:

run backbone padding pooling purpose
A head-only fixed 1s default reproduce current result
B head-only dynamic default isolate padding amount
C head-only dynamic custom mask-aware / central isolate pooling
D feature encoder frozen only dynamic default test partial adaptation
E feature encoder frozen only dynamic custom pooling adaptation + better pooling
F last 2–4 layers unfrozen dynamic custom pooling limited fine-tune
G log-Mel small CNN no SSL CNN pooling acoustic sanity baseline

I would not start by running dozens of model names. I would first find out whether the failure is caused by:

data/labels,
padding,
pooling,
freezing,
layer choice,
or generalization.

Only after that would I spend time comparing more pretrained checkpoints.

Learning-rate and imbalance notes

For the learning rate, I would separate head LR and encoder LR.

A rough starting point:

parameters rough LR range to try
classifier head only 1e-3 may be reasonable to test
transformer encoder unfrozen around 1e-5 to 3e-5 as a safer starting range
mixed head + encoder separate parameter groups

The HF audio classification guide uses 3e-5 in its Wav2Vec2 audio classification example:

Audio classification · Hugging Face

That does not mean 3e-5 is the one correct value here. But it is a useful reminder that if you unfreeze the encoder, 1e-3 is probably too aggressive as a single LR for everything.

For class imbalance, I would try it after the sanity checks:

method note
weighted CE simple first option
weighted sampler useful, but do not combine blindly with weighted CE
macro-F1 better than accuracy for imbalanced classes
balanced accuracy helps reveal per-class recall
raw confusion matrix still necessary

I would probably avoid changing sampler, loss weighting, pooling, and freeze strategy all in one run. Otherwise the result becomes hard to interpret.

My current best guess

My best guess is that this is not one single bug. It is a stack of smaller issues pointing in the same direction:

  1. the target contrast is local and spectral,
  2. the clips are extremely short,
  3. fixed 1s padding may dilute the signal,
  4. default sequence-classification pooling may be too coarse,
  5. the final frozen representation may not be the best layer for this contrast,
  6. the classifier head alone may have too little ability to adapt,
  7. class imbalance then makes collapse easier to observe.

So I would phrase the diagnosis like this:

This still looks worth pursuing, but I would not expect class weighting alone to fix it. First prove that the labels are learnable with a tiny subset and an acoustic baseline. Then change the SSL extraction path: less padding, better pooling, layer selection, and partial fine-tuning.

Most relevant links

Thank you for the detailed explanation—it was very helpful. We went through the debugging steps you suggested and made several changes to our pipeline.

Here’s what we’ve verified so far:

  1. We confirmed there is no label mapping mismatch. we checked labels2id and it came as :{‘Interdental’: 0, ‘Lateral’: 1, ‘Normal’: 2}

  2. We also verified that there are no issues with the metrics or confusion matrix computation.

  3. We reduced the maximum padding length from 1.0 s to 0.3 s, since most of our clips are around 0.16 s long:
    PAD_LEN = 4800 # 0.3s @ 16 kHz

  4. Instead of training only the classification head, we unfroze the transformer encoder for fine-tuning : model.freeze_feature_encoder() # freeze the CNN; train the transformer + head

  5. We now pass the attention mask during training.:
    audio = [librosa.load(p, sr=16000, mono=True)[0] for p in batch[‘path’]]

    out = fe(audio, sampling_rate=16000, return_attention_mask=True)

Additionally, based on your suggestion of using an acoustic baseline, we wanted to verify whether the classes are acoustically separable before continuing with Wav2Vec2.

We trained an MFCC/log-Mel + SVM baseline using the same 5-fold grouped cross-validation protocol and obtained:

  • Accuracy: 90.7% ± 2.1%
  • Macro-F1: 89.4% ± 2.2%
  • Majority-class baseline: 50.5%

This gave us confidence that the classes are indeed acoustically separable, so the signal is real and Wav2Vec2 should, in principle, be able to learn this task.

After making the changes above, we retrained the model using 5-fold cross-validation and evaluated it on an unseen test set. The accuracy improved from ~33% to ~40%, so the modifications definitely helped, although there is still significant room for improvement.
The out-of-fold confusion matrix also shows :

  • Interdental: Precision 0.335, Recall 0.071, F1 0.118
  • Lateral: Precision 0.393, Recall 0.880, F1 0.543
  • Normal: Precision 0.571, Recall 0.148, F1 0.235

You mentioned that the default pooled sequence representation might not be ideal for a task like ours. Could you elaborate a bit more on that?

Specifically, I’d like to understand:
How does the default pooling in Wav2Vec2ForSequenceClassification work internally?

Since our acoustic baseline performs well while Wav2Vec2 still struggles,

I’d really appreciate any insights or recommendations on which pooling strategy would be the best starting point for this kind of task. Thanks again for your help!

Hm. It seems to have improved quite a bit. From here, I’d probably think about it like this:


The MFCC/log-Mel + SVM result is the key new information.

If the same grouped 5-fold setup gives around:

Accuracy: 90.7% ± 2.1%
Macro-F1: 89.4% ± 2.2%
Majority baseline: 50.5%

then I would now treat the classes as acoustically separable. So the remaining question is probably not:

Is there any signal in the labels?

but rather:

Why is the Wav2Vec2/WavLM sequence classifier not preserving or exposing the same local acoustic information that the MFCC/log-Mel baseline can use?

So yes, I think your changes helped. But from here, I would focus less on the backbone name and more on the pooling / aggregation head.

Direct answer: how the default pooling works

In Wav2Vec2ForSequenceClassification / WavLMForSequenceClassification, the rough path is:

raw waveform
→ SSL encoder
→ hidden states: [batch, time, hidden]
→ optional weighted layer sum
→ projector
→ time pooling
→ classifier

The important part is the time pooling. In the Transformers implementation, after projection, the default sequence-classification head does roughly this:

if attention_mask is None:
    pooled_output = hidden_states.mean(dim=1)
else:
    padding_mask = self._get_feature_vector_attention_mask(
        hidden_states.shape[1],
        attention_mask,
    )
    hidden_states[~padding_mask] = 0.0
    pooled_output = hidden_states.sum(dim=1) / padding_mask.sum(dim=1).view(-1, 1)

So the default classifier is basically using mean pooling over time, or masked mean pooling if an attention mask is passed.

Relevant source files:

That default is a reasonable generic baseline, but your task is not a broad utterance-level classification task. It is a short sibilant/fricative articulation classification task. A plain average over time may be too lossy.

My short recommendation

I would try pooling heads in this order:

priority pooling/head why
1 masked mean + std pooling smallest change from default mean pooling; preserves temporal variation
2 central / segment-frame pooling focuses on the target fricative instead of all frames
3 attention pooling lets the model learn which frames matter
4 small TDNN/CNN head + statistic pooling gives the head local temporal pattern capacity
5 weighted layer sum + better pooling tests whether another layer contains better phonetic cues

If I had to pick only one first experiment, I would try:

same setup as your current Wav2Vec2/WavLM run
but replace default masked mean pooling
with masked mean + std pooling

Mean pooling asks:

What is the average hidden representation?

Mean + std pooling asks:

What is the average hidden representation, and how does it vary over time?

For a fricative/sibilant task, that second part may matter a lot.

Why mean pooling may be too weak here

Your acoustic baseline result is important because MFCC/log-Mel features preserve fairly direct local spectral information.

The Wav2Vec2/WavLM sequence-classification head, however, compresses the hidden sequence into one vector. With default pooling, local frame-level differences can be smoothed away.

For example, the model may see hidden states shaped like:

[batch, time, hidden]

Then default pooling turns that into:

[batch, hidden]

by averaging over time.

That can work well for tasks where the whole utterance carries the label. But for your case, the discriminative cue may be something like:

  • spectral concentration,
  • turbulence/noise shape,
  • lateralized energy distribution,
  • short local cue in the fricative,
  • difference between the central part and the edges of the segment.

A single mean vector may not preserve enough of that.

The fact that MFCC/log-Mel + SVM gets around 90% suggests that the signal is real and relatively accessible in acoustic-feature space. The Wav2Vec2/WavLM issue now looks more like:

good signal exists
but the SSL sequence classifier is not aggregating it well

rather than:

the labels are not learnable
Pooling option 1: masked mean + std pooling

This is the first thing I would try.

Instead of using only:

mean = hidden_states.mean(dim=1)

use:

pooled = concat(mean, std)

Very rough implementation:

def masked_mean_std_pooling(hidden_states, padding_mask=None, eps=1e-8):
    # hidden_states: [B, T, H]
    # padding_mask: [B, T], True for valid frames

    if padding_mask is None:
        mean = hidden_states.mean(dim=1)
        std = hidden_states.std(dim=1)
        return torch.cat([mean, std], dim=-1)

    mask = padding_mask.unsqueeze(-1).to(hidden_states.dtype)  # [B, T, 1]
    lengths = mask.sum(dim=1).clamp(min=1.0)                   # [B, 1]

    mean = (hidden_states * mask).sum(dim=1) / lengths

    var = ((hidden_states - mean.unsqueeze(1)) ** 2 * mask).sum(dim=1) / lengths
    std = torch.sqrt(var + eps)

    return torch.cat([mean, std], dim=-1)

Then the classifier input size becomes double:

projected_hidden_size → 2 * projected_hidden_size

Why this is a good first test:

reason explanation
close to default easy ablation against current pooling
still simple no extra attention module yet
preserves variation useful if articulation differs in local temporal/spectral shape
known pattern statistic pooling is common in speaker-style heads

There is a related precedent inside Transformers itself: the Wav2Vec2/WavLM XVector models use TDNN layers followed by statistic pooling, i.e. mean + std pooling.

Again, this does not mean XVector is the right model for your task. But it does make statistic pooling a natural first alternative to plain mean pooling.

Relevant source areas:

Pooling option 2: central / segment-frame pooling

Since the clips are already centered around a target phone, another useful test is to pool only the central part of the hidden sequence.

Crude example:

def central_pooling(hidden_states, center_fraction=0.5):
    # hidden_states: [B, T, H]
    B, T, H = hidden_states.shape

    width = max(1, int(T * center_fraction))
    start = max(0, (T - width) // 2)
    end = start + width

    return hidden_states[:, start:end].mean(dim=1)

You can also combine this with std:

central mean + central std

This tests a simple hypothesis:

Is the model failing because it averages over too much non-target context?

Even with the new 0.3s padding limit, your median clip is ~0.16s, so the exact pooled region may still matter.

Possible variants:

variant what it tests
full masked mean generic utterance-level summary
central 50% mean more target-focused summary
central 50% mean + std target-focused statistic pooling
central fixed N frames stable phone-centered representation
max over central frames preserve strong local activations

This is not elegant, but it is a good diagnostic.

Pooling option 3: attention pooling

Attention pooling lets the model learn which frames matter.

Minimal example:

class AttentionPooling(torch.nn.Module):
    def __init__(self, hidden_size):
        super().__init__()
        self.score = torch.nn.Linear(hidden_size, 1)

    def forward(self, hidden_states, padding_mask=None):
        # hidden_states: [B, T, H]
        scores = self.score(hidden_states).squeeze(-1)  # [B, T]

        if padding_mask is not None:
            scores = scores.masked_fill(~padding_mask, -1e9)

        weights = torch.softmax(scores, dim=-1)         # [B, T]
        pooled = torch.sum(hidden_states * weights.unsqueeze(-1), dim=1)
        return pooled

This can be stronger than mean pooling because it can learn:

these frames matter more than those frames

But I would try it after mean+std, because attention pooling adds more flexibility and can overfit more easily on a small dataset.

Potential diagnostics:

check why
inspect attention weights see whether it focuses on the target segment
compare to central pooling distinguish learned focus from simple center bias
use dropout reduce overfitting
keep folds identical make the ablation interpretable
Pooling option 4: small TDNN/CNN head over hidden states

Because MFCC/log-Mel + SVM works well, the discriminative cue may be local and spectral. A small temporal head over SSL hidden states may help more than a pure pooling change.

Conceptually:

hidden states [B, T, H]
→ small temporal CNN / TDNN
→ nonlinearity
→ statistic pooling
→ classifier

Rough shape:

class TemporalConvPoolingHead(torch.nn.Module):
    def __init__(self, hidden_size, channels=256, num_labels=3):
        super().__init__()

        self.conv = torch.nn.Sequential(
            torch.nn.Conv1d(hidden_size, channels, kernel_size=3, padding=1),
            torch.nn.ReLU(),
            torch.nn.Dropout(0.1),
            torch.nn.Conv1d(channels, channels, kernel_size=3, padding=1),
            torch.nn.ReLU(),
        )

        self.classifier = torch.nn.Linear(channels * 2, num_labels)

    def forward(self, hidden_states, padding_mask=None):
        # hidden_states: [B, T, H]
        x = hidden_states.transpose(1, 2)   # [B, H, T]
        x = self.conv(x)
        x = x.transpose(1, 2)               # [B, T, C]

        pooled = masked_mean_std_pooling(x, padding_mask)
        logits = self.classifier(pooled)
        return logits

This is still much lighter than redesigning the whole model, but it gives the classifier local temporal pattern capacity.

I would only try this after simpler pooling ablations, because it adds more moving parts.

Important implementation detail: feature-vector mask

If you build a custom pooling head, do not directly use the waveform-level attention_mask as if it had the same time dimension as last_hidden_state.

The waveform attention mask is over:

input samples

The hidden state sequence is over:

post-convolution feature frames

So you need a feature-vector-level mask.

The built-in sequence classifier does this internally with:

self._get_feature_vector_attention_mask(
    hidden_states.shape[1],
    attention_mask,
)

This is the same kind of issue discussed here:

https://github.com/huggingface/transformers/issues/25307

For quick experiments, you can use the model’s helper method if available. Just be aware that internal/private helper methods can be version-sensitive.

Pseudo-shape:

outputs = model.wav2vec2(
    input_values=input_values,
    attention_mask=attention_mask,
    output_hidden_states=True,
    return_dict=True,
)

hidden_states = outputs.last_hidden_state  # [B, T_feat, H]

padding_mask = model._get_feature_vector_attention_mask(
    hidden_states.shape[1],
    attention_mask,
)

Then use padding_mask for your custom pooling.

Small custom head sketch

This is only a rough sketch, not drop-in final code. The point is the architecture:

SSL hidden sequence
→ projector
→ masked mean + std pooling
→ classifier
import torch
import torch.nn as nn
from transformers import AutoModel, AutoConfig

class SSLMeanStdClassifier(nn.Module):
    def __init__(self, model_name, num_labels, classifier_hidden=256):
        super().__init__()

        self.config = AutoConfig.from_pretrained(model_name)
        self.backbone = AutoModel.from_pretrained(model_name)

        hidden_size = self.config.hidden_size

        self.projector = nn.Linear(hidden_size, classifier_hidden)
        self.classifier = nn.Linear(classifier_hidden * 2, num_labels)

    def _masked_mean_std(self, hidden_states, padding_mask=None, eps=1e-8):
        if padding_mask is None:
            mean = hidden_states.mean(dim=1)
            std = hidden_states.std(dim=1)
            return torch.cat([mean, std], dim=-1)

        mask = padding_mask.unsqueeze(-1).to(hidden_states.dtype)
        lengths = mask.sum(dim=1).clamp(min=1.0)

        mean = (hidden_states * mask).sum(dim=1) / lengths
        var = ((hidden_states - mean.unsqueeze(1)) ** 2 * mask).sum(dim=1) / lengths
        std = torch.sqrt(var + eps)

        return torch.cat([mean, std], dim=-1)

    def forward(self, input_values, attention_mask=None, labels=None):
        outputs = self.backbone(
            input_values=input_values,
            attention_mask=attention_mask,
            return_dict=True,
        )

        hidden_states = outputs.last_hidden_state
        hidden_states = self.projector(hidden_states)

        padding_mask = None

        # This is version/model dependent. For Wav2Vec2/WavLM-style models,
        # you need the feature-vector-level attention mask, not the raw mask.
        if attention_mask is not None and hasattr(self.backbone, "_get_feature_vector_attention_mask"):
            padding_mask = self.backbone._get_feature_vector_attention_mask(
                hidden_states.shape[1],
                attention_mask,
            )

        pooled = self._masked_mean_std(hidden_states, padding_mask)
        logits = self.classifier(pooled)

        loss = None
        if labels is not None:
            loss = nn.CrossEntropyLoss()(logits, labels)

        return {"loss": loss, "logits": logits}

For Wav2Vec2/WavLM wrappers, the helper method may live on a nested module depending on whether you are using AutoModel, AutoModelForAudioClassification, or a subclass. So I would treat this as an architecture sketch.

How I would interpret the new results

The new result is encouraging:

result interpretation
MFCC/log-Mel + SVM ~90% the classes are acoustically separable
Wav2Vec2 improved from ~33% to ~40% the earlier changes helped
still high Lateral recall / low Interdental + Normal recall SSL classifier is still collapsing subtle class cues
acoustic baseline much stronger than SSL classifier representation aggregation is likely the next bottleneck

So I would not go back to:

maybe the labels are impossible

The acoustic baseline argues against that.

Instead, I would ask:

What does MFCC/log-Mel preserve that the Wav2Vec2/WavLM pooled representation is losing?

My guess: local spectral shape and frame-level variation.

Minimal ablation plan from here

I would keep the next round small and controlled:

run backbone pooling head purpose
A current Wav2Vec2/WavLM default masked mean reproduce current result
B same masked mean + std test statistic pooling
C same central mean + std test target-focused pooling
D same attention pooling test learned frame weighting
E same small TDNN/CNN + stat pooling test local temporal pattern head
F same use_weighted_layer_sum=True + best pooling test layer selection

I would not change all variables at once.

First comparison:

default masked mean pooling
vs
masked mean + std pooling

Same:

folds
padding length
fine-tuning setup
learning rates
metrics

If mean+std improves Interdental/Normal recall, the default pooling was probably too lossy.

If mean+std does not help, then I would try central pooling or a small TDNN/CNN head.

Tiny-subset overfit is still useful

Since the pipeline changed, I would still repeat the tiny-subset test.

Example:

20 examples/class
same examples for train and eval
no early stopping
goal: near-100% train accuracy

Interpretation:

tiny-subset result meaning
cannot overfit training/head/pooling issue still exists
can overfit, but CV poor generalization / data size / split / regularization issue
can overfit only with custom pooling default pooling is likely the bottleneck
can overfit with MFCC/log-Mel but not SSL SSL representation/head path is the issue

This is still one of the fastest tests for separating “pipeline cannot learn” from “model does not generalize.”

Current best guess

At this point my best guess is:

The task is real and acoustically separable, but the default Wav2Vec2/WavLM sequence-classification head is too coarse for this particular local fricative classification problem.

So my next concrete recommendation would be:

  1. keep the 0.3s max length for now,
  2. keep freeze_feature_encoder() rather than freeze_base_model(),
  3. use a much lower LR for the backbone than for the head,
  4. replace default mean pooling with masked mean + std pooling,
  5. then test central pooling / attention pooling / TDNN head,
  6. only then tune class weighting or sampling.

Class weights may still help minority recall, but the MFCC/log-Mel result makes me think the next big gain is more likely to come from how the SSL hidden sequence is pooled, not from the loss alone.

Relevant links

Thanks again for taking the time to write such detailed replies over the past week. I’ve been gradually implementing the changes you suggested, and they’ve been really helpful in narrowing down where the issue actually is.

One thing I’m still trying to understand is how to verify that the model is actually learning acoustic/phonetic cues rather than channel or recording characteristics. I’ve seen a few people mention models accidentally learning microphones, speakers, or recording conditions instead of the target phonetics, and that’s become my biggest concern.

Do you have any recommendations for analyzing the learned representations?

My task is quite specific: classifying isolated /s/ and /z/ phonemes into Normal, Interdental, and Lateral lisp. After MFA alignment, I cut each target phoneme into ~0.3 s clips. Acoustically, interdental often resembles a weak /θ/-like production with more diffuse hissing, while lateral tends to have a “slushy”/“sh”-like quality. Since these are very local articulation differences, do you think Wav2Vec2 should be capable of learning them with sufficient data and the right pooling head.

If you happen to have the time and are comfortable with it, I’d genuinely appreciate the opportunity to share a few sample clips from my dataset. I feel that listening to a handful of examples would make the nature of the problem much clearer than I can explain in text, and I’d value any insights you might have. If that’s easier, you can reach me at nukillapraveen@gmail.com. Either way, thank you again for all the time and thoughtful guidance you’ve shared—it has genuinely helped move this project forward.

Hmm… I probably don’t have enough time to personally help with the full analysis​:sweat_smile:, but for now, how about something like this:


I put together a small public Colab-style notebook and a few supporting notes that may help you run the next diagnostics on your side.

The notebook is not meant to reproduce your /s,z/ dataset. It uses a public short-audio dataset only as a low-cost probe to test the narrower implementation question:

If we keep Wav2Vec2 frozen, can different pooling strategies over hidden states produce meaningfully different downstream classification results?

That is the part I think is most relevant to your current situation, because your MFCC/log-Mel + SVM baseline suggests that the target labels are acoustically separable, while the Wav2Vec2/WavLM sequence-classification path is still much weaker.

Main notebook

What it does:

public Speech Commands subset
→ frozen facebook/wav2vec2-base hidden states
→ several pooling methods
→ lightweight sklearn logistic probes
→ accuracy / macro-F1 / balanced accuracy
→ experiment_pack.zip with results and artifacts

The included pooling methods are:

masked_mean
masked_mean_std
central_mean
central_mean_std
masked_max

On the public probe run, the exact ranking was not the main point, but the notebook did show that pooling choices can change downstream probe results. That supports treating pooling as an experimental variable rather than just an implementation detail.

Supporting notes

I split the notes into separate files so each one has a narrow role.

1. Notebook engineering README

https://huggingface.co/datasets/John6666/forum3/blob/main/wav2vec2_pooling_probe/README_FOR_REBUILDING_AND_EXTENDING_PUBLIC.md

This explains what the notebook is, what it is not, how it is structured, what public resources it uses, and how to rebuild or extend it.

2. Target-data adaptation guide

https://huggingface.co/datasets/John6666/forum3/blob/main/wav2vec2_pooling_probe/TARGET_DATA_ADAPTATION_GUIDE_PUBLIC.md

This is probably the most directly useful file if you want to adapt the notebook to your own /s,z/ clips.

The intended idea is:

replace the Speech Commands loading cell
with your own audio paths / arrays + labels
keep the rest of the pipeline unchanged at first
run the same pooling comparison table

I would not change the model, pooling, folds, and labels all at once. First make the same diagnostic table on your target data.

3. Pooling result decision guide

https://huggingface.co/datasets/John6666/forum3/blob/main/wav2vec2_pooling_probe/POOLING_RESULT_DECISION_GUIDE_PUBLIC.md

This is a “if the result looks like X, suspect Y” guide.

For example:

target-data result likely next interpretation
masked_mean_std beats masked_mean temporal variation matters
central pooling beats full pooling target-phone focus matters
masked_max beats mean salient local activations may matter
all pooling methods remain weak try layerwise probes / different SSL layers / better heads
train probe strong but CV weak generalization or split stress-test issue
speaker/source probe strong nuisance cue may be available

4. Next experiment roadmap

https://huggingface.co/datasets/John6666/forum3/blob/main/wav2vec2_pooling_probe/NEXT_EXPERIMENT_ROADMAP_PUBLIC.md

This gives a suggested order for the next experiments.

My suggested order would be roughly:

1. repeat tiny-subset overfit after the current pipeline changes
2. run the target-data pooling table
3. run layerwise frozen probes
4. run speaker/source/session nuisance probes
5. try attention pooling
6. try TDNN/CNN + statistic pooling
7. try weighted layer sum or selected-layer mixtures
8. tune class weighting/sampling after representation extraction is sane

5. Theory / implementation synthesis

https://huggingface.co/datasets/John6666/forum3/blob/main/wav2vec2_pooling_probe/THEORY_AND_IMPLEMENTATION_SYNTHESIS_PUBLIC.md

This is the longer background note. It summarizes the reasoning behind the pooling hypothesis, the default Wav2Vec2/WavLM sequence-classification pooling behavior, feature-vector masks, layer selection, and nuisance-cue checks.

What I would run on your actual data first

I would try to produce this table on your own grouped split:

pooling accuracy macro-F1 balanced accuracy Lateral recall Normal recall Interdental recall
masked mean
masked mean + std
central mean
central mean + std
masked max

Keep everything else as fixed as possible:

same folds
same 0.3s max length
same labels
same backbone
same feature-extractor freeze setting
same classifier/probe protocol
same metrics

The goal is not to find the final model immediately. The goal is to answer:

Is default masked mean pooling the bottleneck?

If one of the alternative pooling methods improves minority-class recall, especially Normal or Interdental recall, that would be a strong sign that the default sequence-classification pooling was too lossy for this task.

One important interpretation caveat

The public notebook uses Speech Commands, not your articulation dataset.

So I would not transfer the public notebook ranking directly. For example, if masked_max wins on Speech Commands, that does not mean masked_max will win on /s,z/ articulation data.

The safer interpretation is narrower:

Pooling choice can materially affect downstream probe performance over frozen Wav2Vec2 hidden states, so the target /s,z/ dataset deserves its own pooling comparison table.

That is the main reason I think this notebook is useful here.

After the pooling table

If pooling changes help, I would then try slightly more expressive heads:

hidden states
→ small TDNN/CNN over time
→ statistic pooling
→ classifier

If pooling changes do not help, I would move to:

layerwise probes
weighted layer sum
WavLM comparison
speaker/source/session nuisance probes

The nuisance-probe part seems important because SSL speech representations can contain multiple kinds of information: phonetic content, speaker information, channel/session information, prosody, etc. So once the model starts improving, I would also check whether it is using the intended articulation cue or an easier correlated cue.

A useful probe table later might be:

probe target input interpretation
articulation label pooled SSL embedding target-task separability
speaker ID pooled SSL embedding speaker information availability
source recording ID pooled SSL embedding source/recording shortcut risk
session/mic ID, if available pooled SSL embedding channel/session shortcut risk
duration/RMS/SNR metadata vs predictions low-level shortcut risk

Bottom line

Your MFCC/log-Mel + SVM result makes the task look real and acoustically separable. The remaining issue looks more like:

how to expose the relevant local acoustic cue
from the Wav2Vec2/WavLM hidden sequence

rather than:

whether the labels contain any signal

So I would use the notebook as a diagnostic template:

run the same pooling comparison on your data
inspect per-class recall
then decide whether to move toward statistic pooling, central pooling, attention pooling, TDNN/CNN heads, layerwise probes, or nuisance-cue checks