← All tutorials

On this page

1 The block in PyTorch 2 Training a Conformer 3 Streaming with chunked attention 4 References

Conformer in PyTorch: Training and Streaming

The block as PyTorch classes, CTC training, and chunked attention for live audio

Filterbank frames (B, T, 80)Subsample 4×, 10 ms to 40 ms framesConformerBlock × 16, width 256, 4 headsFFN, ½ stepMHSAConvFFN, ½ stepLayerNormConformer (M), 30.7M parametershead = nn.Linear, CTC loss21133
Figure 1. Each stage as its PyTorch class; numbers are this page's sections.

The Conformer is a speech recognition encoder. Each block passes every frame through a half-strength feed-forward layer, self-attention for long-range context, a convolution module for local detail, a second half feed-forward layer and a LayerNorm, the two halves forming a macaron around the middle; The Conformer Block explains why. Here those five lines become four PyTorch classes, and a CTC head and loss train them on every batch. A chunk mask in the attention then lets the same classes run on live audio at a chosen latency.

1 The block in PyTorch

The block piece by piece, starting with the macaron FFN. Each half is a two-layer MLP whose residual is scaled by 0.5, so two of them add up to one full FFN.

import torch.nn as nn
P = 0.1  # dropout rate

class FFN(nn.Module):
    def __init__(self, d):
        super().__init__()
        self.net = nn.Sequential(
            nn.LayerNorm(d),
            nn.Linear(d, 4 * d),
            nn.SiLU(),  # Swish
            nn.Dropout(P),
            nn.Linear(4 * d, d),
            nn.Dropout(P))

    def forward(self, x):
        # half-step residual
        return x + self.net(x) / 2
  1. 1LayerNorm, expand 4×, Swish, dropout, project back. Dropout zeroes a random share P of values in training only; a higher rate fights overfitting, a lower one fits faster.
  2. 2Half-step residual: only half the output is added.

Next, self-attention. MHSA wraps PyTorch's built-in nn.MultiheadAttention with a pre-norm and a residual. It leaves out the relative-position bonus, which is in Attention with relative positions (The Conformer Block). Its mask m is True where a key is hidden, PyTorch's convention.

MHA = nn.MultiheadAttention

class MHSA(nn.Module):
    def __init__(self, d, n):
        super().__init__()
        self.ln = nn.LayerNorm(d)
        self.attn = MHA(
            d, n, dropout=P,
            batch_first=True)

    # m: True = key hidden
    def forward(self, x, m=None):
        q = self.ln(x)
        kw = dict(attn_mask=m)
        att = self.attn
        o, _ = att(q, q, q, **kw)
        return x + o
  1. 1Pre-norm and PyTorch multi-head attention.
  2. 2Query, key and value are all the normed frames q, which makes it self-attention; m hides masked keys, and the output is added back as a residual.

Now the convolution module, the class Conv, named after the Conv term of the block. Conv1d wants channels first, hence the transposes. The padding keeps the length only for an odd kernel k, so PyTorch ports use 31 where the paper lists 32. At the common 4× subsampling (40 ms frames), a 32-tap kernel spans 32 × 40 ms ≈ 1.3 s of audio; the paper found 32 best on the dev sets and 65 worse on dev and test-other (the paper's Table 7).

class Conv(nn.Module):
    def __init__(self, d, k):
        super().__init__()
        self.ln = nn.LayerNorm(d)
        pad = (k - 1) // 2
        self.net = nn.Sequential(
            nn.Conv1d(d, 2*d, 1),
            nn.GLU(dim=1),
            # depthwise conv
            nn.Conv1d(
                d, d, k,
                groups=d,
                padding=pad),
            nn.BatchNorm1d(d),
            nn.SiLU(),
            nn.Conv1d(d, d, 1),
            nn.Dropout(P))

    def forward(self, x):
        # Conv1d wants (B, d, T)
        h = self.ln(x)
        h = h.transpose(1, 2)
        h = self.net(h)
        h = h.transpose(1, 2)
        return x + h
  1. 1Pointwise convolution to twice the width, then GLU gating back to d.
  2. 2One length-k depthwise filter per channel, over time; groups=d is what keeps the channels apart.
  3. 3BatchNorm, Swish, pointwise convolution and dropout.
  4. 4Transposes to channels first and back, with a residual around the module.

ConformerBlock puts the three classes together; its forward runs the five lines from Inside the block (The Conformer Block) in order.

class ConformerBlock(nn.Module):
    def __init__(self, d, n, k):
        super().__init__()
        self.ffn1 = FFN(d)
        self.mhsa = MHSA(d, n)
        self.conv = Conv(d, k)
        self.ffn2 = FFN(d)
        self.ln = nn.LayerNorm(d)

    def forward(self, x, m=None):
        x = self.ffn1(x)
        x = self.mhsa(x, m)
        x = self.conv(x)
        x = self.ffn2(x)
        return self.ln(x)

# Conformer (M) sizes, odd kernel
blk = ConformerBlock(256, 4, 31)
  1. 1Two half-step feed-forwards around attention and convolution, and a final LayerNorm.
  2. 2The block order is feed-forward, attention, convolution, feed-forward, norm.
  3. 3One block at Conformer (M) width 256, 4 heads, kernel 31.

One linear layer gives each frame a score per vocabulary item, and softmax turns those scores (logits) into probabilities. A CTC loss (connectionist temporal classification) then aligns them with the text without anyone marking which frame belongs to which character: it lets frames emit a blank, collapses repeats, and adds up the probability of every frame-by-frame path that collapses to the right text.

import torch, torch.nn as nn
B, T, V = 8, 100, 32
enc_out = torch.randn(B, T, 256)
head = nn.Linear(256, V)
# (B, T, V) log-probabilities
lp = head(enc_out).log_softmax(-1)
# toy targets; 0 is the blank
tgt = torch.randint(1, V, (B, 20))
n = torch.full((B,), T)
tn = torch.full((B,), 20)
ctc = nn.CTCLoss(blank=0)
# CTCLoss wants (T, B, V)
lp = lp.transpose(0, 1)
loss = ctc(lp, tgt, n, tn)
  1. 1Encoder output to per-frame log-probabilities over a vocabulary of V tokens.
  2. 2Toy targets, the input lengths n and the target lengths tn.
  3. 3CTC loss, which wants time first.

2 Training a Conformer

Training teaches the encoder to transcribe: every batch runs filterbank frames through the front end and the encoder, scores the text with CTC, and updates every weight. torchaudio ships the block stack ready-made as torchaudio.models.Conformer. It takes frames that are already subsampled and projected to the model width, plus each utterance's valid length, and returns the encoded frames and their lengths; padded frames past a length are masked out of attention.

import torch, torchaudio
enc = torchaudio.models.Conformer(
    input_dim=256, num_heads=4,
    ffn_dim=1024, num_layers=16,
    depthwise_conv_kernel_size=31,
    dropout=0.1)
# 8 utterances, up to 400 frames
n = torch.randint(200, 401, (8,))
T = int(n.max())
x = torch.randn(8, T, 256)
out, n_out = enc(x, n)
  1. 1torchaudio's Conformer at Conformer (M) settings: width 256, 4 heads, 16 layers, kernel 31.
  2. 2A padded batch of 8 utterances with their true lengths.

In a training loop the encoder sits between the front end and the loss, and the whole path runs every batch (Figure 2); a transducer loss (RNN-T, which also feeds the tokens already output back in, see Beyond CTC) can take the place of CTC.

fbank batch (B, T, 80)front, Conv1d stride 41enc, Conformer × 162head, log-softmax2ctc, CTC loss3loss.backward()4opt.step()4sched.step()4next batch
Figure 2. One training step; the band numbers match the train_step listing.

One batch runs the front end, the encoder, and the CTC head and loss from Section 1, then backward, the optimizer step and the schedule step.

import torch.nn as nn

# 80 bands to width 256, 4x fewer
front = nn.Conv1d(
    80, 256, 4, stride=4)

def train_step(fb, n, tgt, tn):
    # Conv1d wants (B, 80, T)
    x = fb.transpose(1, 2)
    x = front(x).transpose(1, 2)
    n = n // 4
    out, n = enc(x, n)
    lp = head(out).log_softmax(-1)
    lp = lp.transpose(0, 1)
    loss = ctc(lp, tgt, n, tn)
    opt.zero_grad()
    loss.backward()
    opt.step()
    sched.step()
    return loss.item()
  1. 1The one-layer front end of Strided subsampling (The Conformer Block) with stride 4; ESPnet's Conv2dSubsampling uses two stride-2 convolutions.
  2. 2Forward through the front end, the torchaudio encoder with each length divided by 4, and the head's log-probabilities.
  3. 3CTC loss, which wants time first.
  4. 4zero_grad, backward, step: one optimizer update, then one schedule tick per batch.

The optimizer covers every trained module, the front end, the encoder and the head. train_loader is a DataLoader that yields filterbank batches padded to their longest utterance, with their lengths and padded targets.

params = [*front.parameters(),
          *enc.parameters(),
          *head.parameters()]
opt = torch.optim.Adam(params,
    lr=0.05 / 256**0.5,
    betas=(0.9, 0.98), eps=1e-9)
def warmup(s, w=10000):
    up = (s + 1) / w
    down = (w / (s + 1)) ** 0.5
    return min(up, down)
lrs = torch.optim.lr_scheduler
sched = lrs.LambdaLR(opt, warmup)
for batch in train_loader:
    train_step(*batch)
  1. 1Every trained module, the front end, encoder and CTC head.
  2. 2Adam at the paper's peak rate and betas.
  3. 3Warm-up for 10,000 steps, then a decay with the inverse square root of the step (schedules in Optimizers in Practice: AdamW, SAM, Schedules and Clipping).
  4. 4Each batch is (fb, n, tgt, tn), the filterbank frames, their lengths, the padded targets and their lengths; one training step per batch.

3 Streaming with chunked attention

To run in real time, attention becomes streaming attention: a mask limits what each query can see, so no frame waits on the future. Speech is ambiguous without a little right context, so each frame may peek a fixed number of frames ahead, paid for in latency.

In chunked emission, audio arrives in chunks of C frames, a frame reads every earlier frame plus keys up to the end of its own chunk and L look-ahead frames beyond it, and a chunk's tokens are emitted together once its last frame and those L frames arrive. A frame here is an encoder frame: the mask works inside the blocks, after the common 4× subsampling, so each frame is 40 ms of audio, four 10 ms filterbank frames. With chunks of 4 and look-ahead 2, a chunk's first frame waits for 3 more frames plus 2 look-ahead frames, 200 ms, and its last frame for the look-ahead only, 80 ms.

Two relations give what the look-ahead costs and what it buys.
waitmax = C − 1 + L
The longest wait, counted in 40 ms encoder frames, falls on the chunk's first frame, which waits for the other C − 1 frames of its chunk plus the L look-ahead frames, 5 frames for chunk 4 and look-ahead 2. Raising C lets each frame attend to more of its neighbors but makes tokens wait longer.
waitmin = L
The shortest wait, also counted in frames, falls on the chunk's last frame, which waits for the look-ahead only, 2 frames in that example. Raising L adds L frames of future context and L frames of delay to every token.

chunk_mask returns True where a query may read a key. MHSA takes the opposite, so the block gets ~allowed.

import torch

def chunk_mask(T, C, L):
    t = torch.arange(T)
    i, j = t[:, None], t[None, :]
    # end of i's chunk, plus L
    end = (i // C + 1) * C + L
    return j < end

allowed = chunk_mask(12, 4, 2)
m = ~allowed
  1. 1Frame i may see every key before the end of its chunk plus L look-ahead frames.
  2. 2The chunk-4, look-ahead-2 mask, inverted into the mask m that attention expects.

Look-ahead 0 is block-causal: no frame sees past its own chunk, yet tokens still wait up to C − 1 frames (Figure 3). Chunk 1 with look-ahead 0 is the plain causal mask, with no wait. A chunk as long as the utterance is ordinary offline attention.

04080120160200wait (ms)1st2nd3rd4thframe position in a chunk of 4look-ahead 21look-ahead 02

Figure 3. ① Look-ahead 2 waits 80 to 200 ms; ② look-ahead 0 saves 80 ms.

Chunk size and latency

Chunk size (frames per chunk):

Figure 4. Orange is the frame arriving now; green rows have emitted.

Audio heard (orange row)
Tokens emitted (green rows)

Streaming a Conformer needs three adjustments.

The third adjustment in code reuses chunk_mask and the Section 1 block blk.

import random, torch
T, L = 100, 0
x = torch.randn(8, T, 256)
# new chunk size per batch
sizes = [1, 2, 4, 8, 16, T]
C = random.choice(sizes)
allowed = chunk_mask(T, C, L)
out = blk(x, ~allowed)
  1. 1Each batch draws a chunk size, so each draw sets a different wait; C = T reads the whole utterance. The size list is a toy choice.
  2. 2The mask for that size, inverted into the mask m that MHSA expects. blk keeps Section 1's BatchNorm and centered padding; a streaming build swaps both, as the first two adjustments say.

What did later models change in the block itself? The Conformer Block ends on Branchformer and Squeezeformer. The CTC loss is taught in full in CTC Loss, the cost of attention in Transformer Cost and Efficiency, and the filterbank front end in MFCC and Other Audio Features.


4 References

  1. Gulati, A., et al. (2020). Conformer: Convolution-augmented Transformer for Speech Recognition. Interspeech. arXiv:2005.08100.
  2. Zhang, B., et al. (2021). WeNet: Production Oriented Streaming and Non-streaming End-to-End Speech Recognition Toolkit. arXiv:2102.01547.