← All tutorials

On this page

The alignment problem: which frame said what Connectionist Temporal Classification (CTC) Training with forward-backward Decoding with beam search CTC beats HMM systems without frame labels What breaks in practice Beyond CTC References

CTC Loss

The alignment problem: which frame said what

If I knew the label of every frame (a frame is one short slice of the audio), I could score each frame against its label and average. I don't. The target tokens (a token is one unit of output, a letter on this page) are far fewer than the frames, and nothing says which frames belong to which token:

1000 frames (10 seconds of audio)word 1word 2...word 20???20 words of text
About 1000 frames for 10 seconds of typical speech, maybe 20 words of text, and no frame-to-label alignment.

So I need a loss that needs no frame-to-label alignment and can collapse many frame predictions into a shorter token sequence. That loss is Connectionist Temporal Classification (CTC).

PartWhat it answers
CTChow blanks and collapsing turn frames into text
The losswhy it adds up every path that collapses to the target
The forward tablehow the sum is computed without listing paths
Forward-backwardwhere each frame's gradient comes from
Beam searchhow to read out one transcript at inference
Results and pitfallsdoes it work, and what breaks in practice

Connectionist Temporal Classification (CTC)

CTC adds a blank token to the vocabulary: a frame that outputs blank outputs no symbol. Each of the T frames, a feature vector such as the MFCCs on the audio features page, then gets one probability per symbol:

Each frame turns its raw scores into probabilities over the V tokens plus the blank.
pt(k) = exp(zt,k)Σj exp(zt,j)
This is softmax. zt,k is the model's raw score for symbol k at frame t. exp(z) means e raised to the power z, which makes every score positive, and dividing by the sum over all V+1 symbols j makes the probabilities pt(k) add up to 1. V is the number of tokens; the +1 is the blank.

To read a transcription off the frames, merge repeated tokens, then remove blanks:

from itertools import groupby

def collapse(path, blank="_"):
    # merge repeats, drop blanks
    merged = [s for s, _ in
              groupby(path)]
    return [s for s in merged
            if s != blank]

collapse(list("cc_att"))
# ['c', 'a', 't']

10 input frames, one prediction per frame

c
c
_
_
a
a
_
t
t
_
B(π): merge repeats, drop blanks
c
a
t

3 output tokens

π is one path: the prediction picked at each frame. B(π) is that path after the collapse rule is applied to it. The blank token soaks up the extra frames. Ten noisy frames collapse to three real tokens, so the model never needs to know where each letter starts or ends.

Training never trusts one path. It adds up every path that collapses to the ground truth y (the known correct transcript), then takes the negative log:

One path's probability, every path that collapses to the truth added up, then the loss.
P(π|x) = ∏t pt(πt)
Pick one alignment path, multiply the model's per-frame probabilities along it. ∏, the product sign, means multiply over t, over every frame, and the bar in P(π|x) reads given: the probability of the path given the input x. π is one path: the prediction picked at each frame. pt(πt) is frame t's probability for the symbol πt the path picks at that frame.
P(y|x) = Σπ∈A(y) P(π|x)
Do that for every path that collapses to the ground truth and add them up. A(y) is the set of all paths that collapse to y, every path π with B(π) = y, and ∈ means is one of. Σ, sigma, means add over every such path. y is the ground truth and x is the input, so P(y|x) is the total probability of the ground truth given the input.
L = −log P(y|x)
L is the CTC loss, the negative log of that total probability: a total near 1 gives a loss near 0, and a small total gives a large loss, so the loss punishes the model most for the transcripts it rated unlikely.

The path c c _ a t t is one term of that sum.

probability 01

One path carries only a share of P("cat"|x); the loss sums every path that collapses to "cat".

The loss sums over every alignment

The catch is the count. Each frame spreads its probability over every symbol, so the alignments explode:

Every frame multiplies the choices, so the paths cannot be listed.
paths = (V+1)T
This counts every frame sequence: T frames, each picking one of V+1 symbols. With 50 symbols per frame and 100 frames that is 50100, astronomically many, and only the ones that collapse to the ground truth count.

For the toy target "cat" with 6 frames, 84 paths collapse to "cat"; their probabilities add up to the model's total belief in "cat" over every timing. No single alignment is ever chosen during training.

Here is that count of valid paths for four words as the number of frames grows.

Every extra frame multiplies the timings; a double letter needs a blank, so "too" needs 4 frames.

It is the same negative-log trick as categorical cross-entropy, one level up:

Same negative-log trick as cross-entropy, one level up.
CE = −log p(correct class)
CE, cross-entropy, scores one prediction by the negative log of the probability it gave the correct class.
L = −log P(y|x)
CTC takes the same negative log, of the total over every alignment. Mass on alignments of the right transcript pushes the total toward 1 and the loss toward 0; mass leaking onto wrong transcripts grows the loss.

In PyTorch the whole loss is one call; the rest of this page is what that call computes:

import torch

# frames, batch, symbols
T, N, C = 6, 1, 4
# per-frame log-probabilities
x = torch.randn(T, N, C)
log_probs = x.log_softmax(2)
# targets c, a, t
y = torch.tensor([[1, 2, 3]])
# blank is index 0
# "sum" gives -log P(y|x)
ctc = torch.nn.CTCLoss(
    blank=0, reduction="sum")
input_lengths = torch.tensor([T])
target_lengths = torch.tensor([3])
loss = ctc(
    log_probs, y,
    input_lengths, target_lengths)

CTC never lists the alignments. It sums them with dynamic programming, reusing smaller sums: the forward-backward algorithm, one sweep from the first frame and one from the last.

Dynamic programming for CTC

A valid alignment is any frame sequence that collapses to the ground truth. Count those paths first, then swap the counts for probabilities.

So CTC builds an extended target:

CTC puts a blank between every two characters and one at each end.
l′ = (_, y1, _, y2, …, yU, _)
y1 to yU are the U target tokens and _ is the blank, also between every two tokens the dots skip over. l′s is the symbol in column s, counting from 1.
S = 2U + 1
S is the number of columns, twice the target length plus one: "cat" has U = 3, so _ c _ a _ t _ has 7 columns. Blanks are guard rails: they allow variable timing and separate repeated letters.

Aligning the 6 frames to the 7 columns, each frame can stay on its column, step forward by 1, or skip forward by 2 over a blank between different symbols.

That gives a lattice: node (t, s) means frame t has matched up to column s, 6 rows by 7 columns. A path through this grid is one alignment, and each cell of the DP table holds the probability (or count) of every path reaching it.

Figure 3 of Graves et al. (ICML 2006). doi:10.1145/1143844.1143891

extended target →
time (frames) → Figure 3 from Graves et al. (2006): the forward-backward lattice for the labelling CAT. Black circles are labels, white circles are blanks, and arrows show the allowed transitions across time steps 1 through T.

Figure 3. … Black circles represent labels, and white circles represent blanks. … From Graves et al. (2006).

Each cell is reached by staying, stepping or skipping

Of the three moves only the skip has a rule: it may jump over a blank only between two different letters, so a blank never skips to the next blank and a double letter keeps its blank.

A skip is blocked from a blank, and between two copies of the same letter.

The forward algorithm fills each cell with the ways to reach it, using exactly those moves.

Each cell adds the paths that flow in from the row above, times this frame's probability.
αt(s) = pt(l′s) · (αt−1(s)
αt(s), alpha, is the total probability of every path that reaches column s at frame t: this frame's probability pt(l′s) for the column's symbol, times what flows in from frame t − 1. Stay: a path already in column s stays there.
+ αt−1(s−1)
Step: a path moves on from the column before.
+ αt−1(s−2))
Skip: a path jumps two columns, counted only when l′s ≠ l′s−2 (≠ means differs from). Blanks sit in every odd column, so this one test blocks a blank-to-blank skip and a skip between two copies of the same letter. Columns outside the grid count as 0.
α1(1) = p1(_)
A path can start on the leading blank, so frame 1 seeds column 1 with the blank's probability.
α1(2) = p1(y1)
It can also start on the first letter y1, so column 2 is seeded too; every other column starts at 0.
P(y|x) = αT(S) + αT(S − 1)
A path ends on the trailing blank or on the last letter, so the total adds those two cells of the last frame T. S = 2U + 1 is the number of columns.

ext is the extended target from the card above, and col[s] is the column of probs that holds ext[s] (blank first, then the letters). The listings count frames and columns from 0, as Python does; the equations count from 1.

import numpy as np

def ctc_forward(probs, ext, col):
    T, S = len(probs), len(ext)
    alpha = np.zeros((T, S))
    first = probs[0]
    alpha[0, :2] = first[col[:2]]
    for t in range(1, T):
        prev = alpha[t-1]
        # stay
        a = prev.copy()
        # step
        a[1:] += prev[:-1]
        # skip
        for s in range(2, S):
            if ext[s] != ext[s-2]:
                a[s] += prev[s-2]
        a *= probs[t, col]
        alpha[t] = a
    total = alpha[-1, -2:].sum()
    return alpha, total

Build ext and col once, before the call:

ext = ["_"] + [x for ch in target
               for x in (ch, "_")]
col = [vocab.index(ch)
       for ch in ext]

The forward algorithm

Each cell of the forward table adds what flows in from its three neighbors in the row above, times this frame's probability. The same forward recursion runs a hidden Markov model; the HMM page builds it from scratch.

Six rows of local sums hold the total over all 84 alignments without listing a single one.

Training with forward-backward

The total is not enough for training. Training needs a gradient at every frame, the correction that compares where the credit should go with what the model predicted. That needs each frame's share of the credit for each target position, the posterior probability. The forward pass gives the prefix mass up to a cell, the backward pass the suffix mass from it to the end, and joining them gives the posterior:

_c_a_t_forward:prefix massup to herebackward:suffix massto the end
Rows are frames, columns the extended target. Every full path through the circled cell is a forward prefix joined to a backward suffix.
The backward recursion is the forward one run from the last frame back.
βt(s) = pt(l′s) · (βt+1(s)
βt(s), beta, is the total probability of every way to finish the path from column s at frame t to the end. Like α, it includes this frame's probability pt(l′s). Stay: the path stays in column s at frame t + 1.
+ βt+1(s+1)
Step: the path moves on to the next column.
+ βt+1(s+2))
Skip: counted only when l′s ≠ l′s+2, the same skip rule looking forward; any column past S counts as 0.
βT(S) = pT(_)
At the last frame T a path can finish on the trailing blank, column S.
βT(S−1) = pT(yU)
It can also finish on the last letter yU, column S − 1; every other βT(s) is 0.

The backward listing is the forward one run from the last frame back, with the same ext and col.

def ctc_backward(probs, ext, col):
    T, S = len(probs), len(ext)
    beta = np.zeros((T, S))
    last = probs[-1]
    beta[-1, -2:] = last[col[-2:]]
    for t in range(T - 2, -1, -1):
        nxt = beta[t+1]
        # stay
        b = nxt.copy()
        # step
        b[:-1] += nxt[1:]
        # skip
        for s in range(S - 2):
            if ext[s] != ext[s+2]:
                b[s] += nxt[s+2]
        b *= probs[t, col]
        beta[t] = b
    return beta

The posterior shows where the credit goes

The path toy showed one alignment; training never picks one. Untrained, the posterior says "everywhere, a little". Trained, it says "right here, a lot". That sharpening is learning.

The posterior gives each cell its share of the credit, and the gradient follows from it.
γt(s) = αt(s) βt(s)pt(l′s) · P(y|x)
γt(s), gamma, is the share of all probability that passes through column s at frame t. αt(s) is the prefix mass up to this cell and βt(s) the suffix mass from it to the end. Each already includes this frame's probability pt(l′s), so dividing by it undoes the double count; dividing by P(y|x), total in the listing, turns the mass into a share.
sharet(k) = Σs∈lab(k) γt(s)
lab(k) is the set of columns of l′ that hold symbol k, and ∈ means is one of. In _ c _ a _ t _ the blank sits in four columns, so its share adds four cells; each letter has one column.
∂L∂zt,k = pt(k) − sharet(k)
This is how much the loss L = −log P(y|x) changes when the raw score zt,k moves up a little: the prediction minus the share. The share is where the credit should go and the prediction is where it went, so gradient descent moves each prediction toward its share. Graves et al. write the same result (their eq. 16) with rescaled α and β to avoid underflow, products of many small probabilities becoming too small for the computer to store; the scale cancels.

On an untrained toy table for "cat" (8 frames, each predicting 0.28 for the blank and 0.24 for every letter), each frame still gets its own share, and so its own push.

Each frame's prediction is pushed toward its share of the credit.

alpha, total = ctc_forward(
    probs, ext, col)
beta = ctc_backward(
    probs, ext, col)
# the card's fraction, all (t, s)
post = alpha * beta / (
    probs[:, col] * total)

share = np.zeros_like(probs)
for s, k in enumerate(col):
    # columns of one symbol add up
    share[:, k] += post[:, s]
# the card's dL/dz
grad = probs - share
credit 0max

Credit is spread over every valid path, then concentrates as the model learns (the softmax tables are illustrative toys).

At inference the model must return one output sequence. Greedy decoding takes the best symbol per frame and throws away probability mass. Beam search keeps several candidate sequences alive and merges the paths that collapse to the same answer. The version on this page is the CTC prefix beam search of Hannun et al. (2014); Graves et al. used a different prefix search.

Beam search merges hypotheses

On the toy table below, greedy sees exactly one path to "ab". Fifteen paths collapse to "ab"; beam search with beam width 3 (candidates kept per frame) merges fourteen of them and prunes (drops) the last.

A candidate prefix g keeps two totals, and each frame adds to them in one of four ways.
Pt−1(g) = pt−1b(g) + pt−1nb(g)
Prefix g keeps two totals: pb, its paths ending in a blank, and pnb, its paths ending in a letter. P is their sum, and P(g) always means last frame's total. Superscript t−1 means last frame; superscript t on the left of the next lines means this frame's new total, which starts at 0, and += adds to it because several old prefixes can land on the same new one.
ptb(g) += P(g) · pt(_)
A blank keeps g as it is. pt(_) is this frame's blank probability.
ptnb(g) += pt−1nb(g) · pt(c)
Repeating g's last letter c with no blank between merges into g, so g stays g.
ptnb(g+c) += pt−1b(g) · pt(c)
After a blank the same letter counts again, so g grows to g+c, g with c added at the end.
ptnb(g+c) += P(g) · pt(c)
Any other letter c extends g from both totals. After every frame only the beam width's best prefixes survive.

Each card line is one branch of the listing. Start from beams = {"": (1.0, 0.0)}, call beam_step once per frame, and read off the prefix with the largest pb + pnb.

import collections

def beam_step(beams, p_t, vocab,
              width=3):
    # g -> [p_b, p_nb] at frame t
    n = collections.defaultdict(
        lambda: [0.0, 0.0])
    letters = list(
        enumerate(vocab))[1:]
    # old totals at frame t-1
    for g, v in beams.items():
        pb, pnb = v
        P = pb + pnb
        n[g][0] += P * p_t[0]
        for k, c in letters:
            q, gc = p_t[k], g + c
            if g and c == g[-1]:
                n[g][1] += pnb * q
                n[gc][1] += pb * q
            else:
                n[gc][1] += P * q
    rank = lambda kv: sum(kv[1])
    best = sorted(
        n.items(), key=rank,
        reverse=True)
    return dict(best[:width])
frame_ (blank)ab
t10.200.550.25
t20.200.500.30
t30.250.150.60
t40.250.150.60

Fixed illustrative table. Collapse rule is standard CTC: merge repeats first, then drop blanks.

Beam search adds up the paths that collapse to the same answer; that merged mass is why it beats greedy.

CTC beats HMM systems without frame labels

Graves et al. (2006) trained a bidirectional LSTM with CTC on TIMIT, a corpus of read English sentences. An LSTM is a recurrent network with a gated memory; bidirectional means one direction reads forward and one backward, so each frame sees both sides. Each frame is 26 numbers:

TIMIT frames: 26 numbers eachxxxxxbidirectional LSTMforward, 100 blocksbackward, 100 blockssoftmax per frame: 61 phonemes + blankCTC loss
Each frame holds 12 MFCCs (mel-frequency cepstral coefficients, a compact summary of the frame's spectrum) plus log energy, plus their first derivatives: 26 numbers per frame. A block is one memory unit. From Graves et al., ICML 2006, section 5 (literature, not run here).

The score is label error rate (LER), the edit distance between output and target divided by the target length, on the TIMIT test set. Both are means over 5 runs ± standard error. An HMM (hidden Markov model) hybrid is the older system, a network scoring each frame for an HMM.

30.51 ± 0.19 % LER
CTC with prefix search
prefix search is the paper's decoder; like beam search it keeps the most likely label prefixes
31.57 ± 0.06 % LER
Best bidirectional LSTM/HMM hybrid in the paper
a bidirectional LSTM inside an HMM system, trained with frame-level labels

The difference is significant (p < 0.01, Table 1 caption), and CTC got there with no HMM and no frame-level labels.

What breaks in practice

Beyond CTC

CTC treats each frame's prediction as independent of the others once the audio is given, so it cannot use what it already output. RNN-T (recurrent neural network transducer) adds a prediction network that reads the previous outputs, so the model learns how likely each token is after the ones before it alongside what the audio says:

CTCaudioencodertokeneach frame decides aloneRNN-Taudioencoderpreviousoutputspredictionnetworktokenfed backacoustics
CTC decides each frame alone. RNN-T feeds every output back through a prediction network.

References

  1. Graves, A., et al. (2006). Connectionist Temporal Classification: Labelling Unsegmented Sequence Data with Recurrent Neural Networks. ICML.
  2. Graves, A. (2012). Sequence Transduction with Recurrent Neural Networks. ICML 2012 Workshop on Representation Learning. arXiv:1211.3711.
  3. Hannun, A. Y., et al. (2014). First-Pass Large Vocabulary Continuous Speech Recognition using Bi-Directional Recurrent DNNs. arXiv:1408.2873.