Connectionist temporal classification, or CTC, is a way to train a speech recognizer on audio paired with nothing but its transcript (Graves et al. 2006). The recognizer reads the audio as a sequence of short frames and makes a guess at every frame. The training data says that the whole clip reads "hello". It never says which frames are the "h". CTC adds up the probability of every way the frames could spell "hello", and it computes that sum exactly and fast.
Here is the idea in four steps, before any symbols. Add one extra symbol, the blank, which means "no new letter here". Let every frame emit either a letter or the blank. To read the text off the frames, merge each run of repeated symbols into one and then delete the blanks. The probability of a transcript is the total probability of all the frame-by-frame strings that read as it.
Not knowing which frames go with which label is the central problem of speech recognition, as §36.1 showed. An encoder network turns a clip into T frames. The transcript has U labels, far fewer. Even without any extra symbol, cutting the T frames into U consecutive pieces, one per label, can be done in (U−1T−1) ways, about 1.35×1018 for 300 frames and 11 words. The classical recognizer handled this with hidden Markov models, a pronunciation dictionary, and frame labels bootstrapped from an older recognizer. CTC needs none of that. It is why you can fine-tune a pretrained speech encoder with one linear layer and a list of transcripts, and it still powers some of the fastest recognizers on the 2026 leaderboards.
This section builds CTC from the ground up. We define the blank and the collapsing rule, count the alignments, and derive the forward algorithm that sums them in O(TU) time. We derive the gradient, implement both in log space, and check them against brute force and against PyTorch. Then we find how many frames a transcript needs, see why trained models put the blank on almost every frame, decode with greedy search and prefix beam search, and align words to time. We end with what CTC achieves at scale and the one assumption that limits it. The transducer of §36.3 removes that assumption by adding a small network that reads the labels already written.
Why we cannot just label the frames
An encoder is the network that reads the audio features and outputs one vector per frame. A linear layer turns each vector into logits, one score per symbol, and a softmax turns the scores into probabilities. Write pt for that distribution at frame t, for t=1,…,T, and pt(k) for its entry for symbol k. It is called a posterior, because it is the model's probability of each symbol after seeing the audio.
The frames here are encoder frames, not feature frames. The encoder starts by subsampling, merging several 10 ms feature frames into one (§36.1). So an encoder frame lasts Δenc=40 ms with 4x subsampling and 80 ms with 8x.
If we knew the label of every frame, training would be ordinary classification. Each frame would get a cross-entropy loss. Its gradient with respect to the logits is pt−ey, the predicted distribution minus the one-hot vector of the frame's target label y (§0.2).
The naive fix is to invent the frame labels. Stretch the transcript evenly over the frames, so that "hello" in 20 frames gives each letter 4 frames. Real speech does not cooperate. Vowels last longer than stops. A speaker pauses before one word and rushes through the next. The clip starts and ends in silence. An evenly stretched target tells the network that silence is "h" and that the middle of the "e" is "l", and the network learns those mistakes.
The classical answer was a loop (§36.1). Estimate the frame labels with a hidden Markov model, train on them, realign with the better model, and repeat. That loop needs a working recognizer before it can train one.
CTC does not pick one labeling at all. It treats the frame labels as hidden, gives every frame labeling that fits the transcript its probability under the model, and maximizes the total. Two things make this work. Frames must be allowed to carry no label, since most frames are silence or a transition between sounds. And the sum over labelings must be computed without listing them.
A blank symbol and a collapsing rule
Add a symbol ∅, the blank, to the vocabulary. A frame-level path is a string of T symbols, one per frame. For 10 frames, one path is
h h∅e∅l l∅l o.
To read off the transcript, first merge each run of identical symbols into one. That gives h∅e∅l∅l o. Then delete the blanks. That gives "hello".
The blank does two jobs.
Frames with nothing to say. Silence, breaths, and the transitions between sounds belong to no letter. The blank absorbs them, so a letter only has to claim the frames where the model is confident it is there.
Repeated letters. Without a blank, "ll" on two frames would merge into one "l", and no path could ever spell "hello". With the blank, l∅l keeps two copies apart, while l l is still one "l" that lasts two frames.
The order of the two steps matters. Deleting blanks first would turn l∅l into l l, which then merges into one "l". Merging first is what lets the blank separate repeats.
In plain words, an alignment says what each frame emits, and the collapse map turns it into the text it spells. Many alignments spell the same text, and B−1(y) collects all of them.
Warm-up
The collapse map
Write ctc_collapse(path, blank=0), the collapse map B. The input is a sequence of integer symbols, as a list or a NumPy array. Merge each run of equal symbols into one, then drop the blanks, and return the result as a tuple of plain Python ints. The blank is the symbol blank, which need not be 0.
Tests, verified solutions, and AI help, open once you add your API key.
The probability of a transcript is a sum over its alignments
The network's job is now to give a distribution over Vˉ at every frame. CTC makes one modeling assumption, and it is the one to remember. Once the audio is fixed, the symbol emitted at each frame is drawn independently of the symbols at the other frames. So a path's probability is a product of one softmax entry per frame.
In plain words, the probability of "hello" is the probability that the frames spell "hello" in any of the ways frames can spell it. Each way's probability is a product of one softmax entry per frame.
Conditional independence does not mean the frames ignore each other. The encoder is usually a Conformer, a stack of attention and convolution layers (§36.1), or a Transformer, and it reads the whole clip. So pt depends on all of X. What the assumption says is narrower. Once X is fixed, what frame t emits tells you nothing more about what frame t+1 emits. The model cannot reason "I wrote 'q' a moment ago, so 'u' is likely now" except through the sound itself. This is why CTC leans on external language models. An interviewer who asks "what does CTC assume?" wants this answer.
The sum also changes what decoding means. The most probable transcript is not always the transcript of the most probable path. The smallest possible example shows it.
The cell checks the collapse map and this example. It lists every path of a slightly bigger problem, three frames over {∅,a,b}, and groups the paths by the transcript they spell.
Python · cell 1
"ab" has five alignments in three frames, ab∅, a∅b, ∅ab, aab, and abb. "aa" has exactly one, a∅a, because the two copies must be kept apart by a blank.
Here is the same model at the size of a real recognizer. Everything CTC touches is one table of log-probabilities, one row per encoder frame.
The cell repeats the shape arithmetic with a random stand-in for the encoder output.
Python · cell 2
How many alignments there are
In §36.1 an alignment of U labels to T frames was a cut into U nonempty pieces, and there were (U−1T−1) of them. Blanks add many more. A run of blanks can sit before, between, or after the labels, and it can have any length, including zero. Counting the run lengths gives an exact formula.
∂ Proof of the count
An alignment of y consists of U runs of labels, the u-th of length ℓu≥1 and made of yu. They are interleaved with U+1 runs of blanks of lengths b0,…,bU≥0, before the first label, between labels, and after the last. Where yu=yu−1, the blank run between them must have bu−1≥1, or the two label runs would merge. Conversely, any such run lengths summing to T give one alignment, and different run lengths give different alignments, since the runs are maximal. So we count the solutions of ∑uℓu+∑ubu=T under these constraints.
Substitute ℓu′=ℓu−1≥0 for every label run and bu′=bu−1≥0 for the R constrained blank runs. That leaves 2U+1 nonnegative integers summing to T−U−R. By stars and bars there are (2U(T−U−R)+2U) solutions. ∎
Check it on the lists from the first cell. For "ab" in three frames, U=2, R=0, and (45)=5, the five paths we listed. For "aa", R=1 and (44)=1. The cell checks the formula against brute force and then evaluates it for a real clip.
Python · cell 3
Three seconds of speech, 75 encoder frames and 26 characters, already has about 1029 alignments. Nobody will sum them one by one. We need structure that a dynamic program can exploit, and the next section finds it.
The forward algorithm adds up every alignment
Every alignment of y walks through the same skeleton, the transcript with a blank slot before, between, and after its labels. This is the blank-extended label sequence
y′=(∅,y1,∅,y2,∅,…,∅,yU,∅),
of length 2U+1, indexed by s=1,…,2U+1. So y2u′=yu and every odd position is a blank. For "ab", y′=(∅,a,∅,b,∅).
An alignment is a walk through y′, one position per frame. At frame t the walk sits at some position s and emits ys′. From one frame to the next it makes one of three moves.
Stay at s. The symbol repeats, which the collapse merges.
Advance by one, from a blank to the next label or from a label to the following blank.
Skip by two, from a label straight to the next label, over the blank between them.
The skip is the only subtle move, and the two jobs of the blank decide when it is allowed. It is forbidden into a blank, because a jump from one blank slot to the next would pass over the label between them and drop it. It is also forbidden between equal labels, ys′=ys−2′, because a followed directly by a merges into one a. Between two equal labels the walk must pass through the blank.
Picture the walks on a grid called the trellis. Frames run along the horizontal axis and the 2U+1 positions of y′ run down the vertical axis. A valid walk starts in one of the first two rows (the leading blank or the first label). It moves right one column per frame and never moves up. It ends in one of the last two rows (the last label or the trailing blank).
The forward variableαt(s) is the total probability of all partial walks over frames 1 to t that are at position s at frame t,
αt(s)=walks over frames 1..tthat are at position s at frame t∑τ=1∏tpτ(ysτ′),
where sτ is the walk's position at frame τ. It includes the emission at frame t. The same kind of forward variable summed the ways to cut a word into tokens in §2.4 and the state paths of a hidden Markov model in §36.1. Here it runs on a new grid.
In plain words, the probability of reaching position s at frame t is this frame's probability of the symbol at s, times the total probability of having been at one of the two or three places you can arrive from.
∂ Proof of the forward recursion
Every alignment in B−1(y) corresponds to exactly one walk through y′ that starts at position 1 or 2, ends at position 2U or 2U+1, and moves by 0, 1, or 2 per frame under the skip rule.
In one direction, read the alignment's runs in order. Each label run sits at its even position 2u. Each blank run between labels u and u+1 sits at the odd position 2u+1, a leading blank run at position 1, and a trailing one at 2U+1. A label run followed directly by the next label's run is a move of 2. The alignment can only do that when the two labels differ, since equal labels with no blank between them would merge, contradicting B(a)=y.
In the other direction, any such walk emits a sequence that collapses back to y. It visits every even position in order, stays on each label for one contiguous run, and separates equal neighbors by a blank. So the sum over alignments is a sum over walks.
Now fix t≥2 and a position s, and split the walks that are at s at frame t by where they were at frame t−1. The allowed predecessors are s (stay), s−1 (advance by one), and s−2 (skip, only when skips holds). These sets of walks are disjoint and together contain every walk at s at frame t. A walk's probability is a product over frames, so the walks through predecessor r contribute αt−1(r)pt(ys′). Adding the three cases gives the recursion.
At t=1 a walk must start at position 1 or 2, which gives the initialization. At t=T the walk must have reached the last label or the trailing blank, and those two sets are disjoint, so pCTC is the sum of the last two entries. Each of the T columns costs O(1) per position, O(U) in all, and each column only needs the previous one. ∎
The cell runs the same recursion, written for clarity rather than speed, and checks it against enumeration.
Python · cell 4
The last line shows the problem with this version. A product of a thousand probabilities is far below the smallest positive float64, about 5×10−324, so it rounds to exactly zero. A 40-second clip at 40 ms per frame has a thousand frames, so this version fails on ordinary audio. The log-space version below fixes it.
The widget draws the trellis for a short word. With "sum over walks" each cell's shade is its occupancy, the share of pCTC carried by the walks through that cell, and a dashed cell is one that no walk can reach. With "best walk" it draws the single most likely walk, which we use for timestamps later in the section. The sharpness slider makes the synthetic frame posteriors flatter or more confident, and Sharpen sweeps it from 0 to 8, the way a model grows more confident as it trains.
Which walks spell this word, and where does their probability go?
Rows are the letters with blanks between and around them. Columns are frames.
Loading interactive figure…
In three frames "aa" has a single alignment, a∅a, so only its three cells are live and each carries all of pCTC. "hello" in five frames has no walk at all, so its loss is infinite, and the section on lengths below explains why. As Sharpen runs on "cat" in nine frames, the best walk's share climbs from under 2 percent to over 99 percent. That spiky picture, each letter on one frame and blanks everywhere else, is what a trained CTC model looks like, and the section on peaky outputs below shows where it comes from.
CTC is a hidden Markov model with a blank
A hidden Markov model (HMM) moves through hidden states one frame at a time and emits one frame from each state it visits (§36.1). Its forward algorithm is αt(j)=bj(xt)∑iαt−1(i)Pij, where Pij is the probability of moving from state i to state j and bj(xt) is how likely state j makes frame xt. Read the 2U+1 positions of y′ as states and the three moves as transitions, and the CTC forward algorithm is this recursion with three changes. The two starting states, positions 1 and 2, each get initial weight 1. Every allowed transition weight Pij is 1. And the emission bj(xt) is replaced by the network's posterior pt(yj′).
Working in log space
The fix for underflow is the one §0.4 gives for every likelihood. Store α^t(s)=logαt(s). Add log-probabilities where the recursion multiplies probabilities. Combine the two or three incoming terms with a stable log-sum-exp, the log of a sum of exponentials computed without overflow, LSE(u,v)=max(u,v)+log(1+e−∣u−v∣). Zero probability becomes −∞, which LSE handles, since LSE(−∞,v)=v and LSE(−∞,−∞)=−∞. The recursion becomes
Each frame updates all 2U+1 positions with a few vectorized operations, so the only Python loop is over t. The network's output layer is already a log-softmax, so log-probabilities are what you are given anyway. Writing this loss from scratch is a common interview coding question.
Interview
The CTC loss in log space
Write ctc_loss(log_probs, y, blank=0), the CTC loss computed by the forward algorithm in log space.
log_probs has shape (T, V+1) and holds logpt(k). Each row is already log-softmaxed.
y is a list of U label indices, none equal to blank.
Return −logpCTC(y∣X) as a float, or np.inf when no alignment exists.
Handle U=0, repeated labels, and any blank index. It must run T=1000 with U=120 in well under a second, so loop over frames only and vectorize over the 2U+1 positions.
import numpy as np
def ctc_loss(log_probs, y, blank=0):
# log_probs: (T, V+1) log-softmax outputs. y: labels, none equal to blank.
raise NotImplementedError
Tests, verified solutions, and AI help, open once you add your API key.
The gradient is the prediction minus a soft target
Training needs ∂LCTC/∂zt(k) for every frame and symbol. The answer has a simple shape. At each frame, the gradient is the model's prediction minus a soft target. The soft target is the model's own belief about what that frame emits, averaged over all the alignments of the transcript.
To compute that belief we run the same recursion backward. The backward variableβt(s) is the total probability of the partial walks over frames t,…,T that are at position s at frame t and end at position 2U or 2U+1. Like α, it includes the emission at frame t, which is the convention of Graves et al. (2006). Mirroring the forward argument,
where βT(s)=0 for s<2U, entries beyond 2U+1 are zero, and skips+2 is the skip rule applied to the move from s to s+2.
A complete walk that is at position s at frame t splits into a part over frames 1..t and a part over frames t..T. The two parts share frame t. So αt(s)βt(s) counts the emission pt(ys′) twice, and dividing once by it gives the total probability of the complete walks through (t,s). Every walk is at exactly one position at frame t, so for every t,
s=1∑2U+1pt(ys′)αt(s)βt(s)=pCTC(y∣X).
Dividing one term by pCTC gives the probability that the walk is at (t,s). Several positions carry the same symbol. All the odd ones are blanks, and a label that occurs twice in y has two positions. So we gather the positions by symbol.
∂ Proof of the gradient
Fix a frame t. Write P=pCTC(y∣X) and, for each position s, αt(s)=pt(ys′)At(s) and βt(s)=pt(ys′)Bt(s), where At(s) and Bt(s) collect the factors of the other frames only. By the identity above,
Summing over k and using the identity gives ∑kγt(k)=1, and nonnegativity is clear. Now chain through the log-softmax, logpt(j)=zt(j)−LSE(zt), whose derivative is ∂logpt(j)/∂zt(k)=1[j=k]−pt(k). The logits of frame t affect only the log-probabilities of frame t, so
In plain words, the gradient at each frame is what the model predicts minus what its own alignment posterior says the frame should emit. It has exactly the shape of the softmax cross-entropy gradient p−y of §0.2, with the one-hot target replaced by a soft target γt that the model computes for itself. The word2vec gradient of §1.5 had the same "expected minus observed" form.
This makes CTC training a gradual version of the classical recognizer's training loop, which guessed the frame labels with the current model and then retrained on the guess (§36.1). Here the forward-backward pass guesses where the labels are, as a soft distribution, and each gradient step moves the frame predictions a little toward that guess. The gradient vanishes when pt=γt at every frame, which makes it a stationary point of the loss.
Core
The CTC gradient by forward-backward
Write ctc_grad(logits, y, blank=0), the gradient of the CTC loss with respect to the logits. The input logits holds the pre-softmax scores, shape (T, V+1), and the output has the same shape. Assume the pair is feasible.
Compute logα and logβ in log space, form the occupancies γt, and return pt−γt for every frame. The tests compare against central differences, check that every row sums to zero, and run T=800.
Tests, verified solutions, and AI help, open once you add your API key.
From here on the section's cells use one reference implementation of the loss and the gradient. The cell checks the identity ∑sαt(s)βt(s)/pt(ys′)=pCTC at every frame of a long random problem, and the gradient against central differences on a small one.
Python · cell 5
The spread of logP across the 400 frames is about 10−12, rounding error, and the analytic gradient matches the numeric one to about 10−10. Every row of the gradient sums to zero.
CTC in PyTorch
torch.nn.functional.ctc_loss implements exactly this loss. Its interface has four details that trip people up.
The log-probabilities are laid out as (T, N, C) by default, time first, or (T, C) for one clip. Here N is the batch size and C=V+1 the number of symbols.
The blank is index 0 by default.
The default reduction="mean" divides each clip's loss by its target length and then averages over the batch. So each label of a short transcript weighs more than each label of a long one. For comparisons use reduction="sum" or "none".
A clip with no possible alignment gives +∞. The flag zero_infinity=True replaces it by zero, with a zero gradient.
The block below writes the forward recursion in torch, lets autograd differentiate it, and compares the loss and the logit gradient with the built-in. It also checks the reductions, the infeasible case, and a quirk of the built-in's backward pass that the pitfall after it explains.
🔥 In PyTorchfor a machine with PyTorch
The finite stand-in for −∞ in ctc_nll is deliberate. torch.logsumexp of a vector that is entirely −∞ returns −∞ but has an undefined gradient, so a literal −∞ in unreachable cells puts NaN into the backward pass.
Training runs on batches. Clips of different lengths are padded to a common T, transcripts are padded to a common length, and length arrays tell the loss which frames and labels are real. Most of the interface pitfalls above live here, so it is worth writing the batched loss once yourself.
Interview
Batched CTC loss over padded inputs
Write ctc_loss_batched(log_probs, targets, input_lengths, target_lengths, blank=0, reduction="mean"). It must behave like F.ctc_loss.
log_probs has shape (T, N, C), time first. It holds log-softmax outputs for N clips padded to T frames. Clip n uses only its first input_lengths[n] frames, at least 1.
targets has shape (N, S). Clip n uses only its first target_lengths[n] entries, none equal to blank. The padding after them can hold any integer.
With reduction="none" return the array of the N losses −logpCTC, with np.inf for an infeasible clip. With "sum" return their sum. With "mean" divide each loss by its target length (by 1 when that length is 0) and then average over the batch.
Loop over frames only, vectorizing over both the batch and the 2S+1 positions.
import numpy as np
def ctc_loss_batched(log_probs, targets, input_lengths, target_lengths, blank=0, reduction="mean"):
# log_probs: (T, N, C) log-softmax outputs, time first. targets: (N, S), padded.
raise NotImplementedError
Tests, verified solutions, and AI help, open once you add your API key.
How many frames a transcript needs
The last line of the torch block returned infinity. "hello" has five letters and got five frames, so why is there no alignment? The two l's need a blank between them, and that blank needs a frame of its own. So "hello" needs six frames.
In plain words, every label needs a frame of its own, and every adjacent repeat needs one more frame for the blank that keeps its two copies apart. The proof shows that this count is both necessary and enough.
∂ Proof of the length condition
Suppose an alignment of y exists. It has at least one frame for each of the U labels. For each of the R adjacent repeats it also has at least one blank frame between the two copies, or they would merge. These U+R frames are distinct, so T≥U+R.
Conversely, suppose T≥U+R. Take the path that writes y1, then for each u≥2 writes a blank if yu=yu−1 followed by yu, and then T−U−R trailing blanks. It has length T, collapses to y, and its probability is a product of positive numbers. ∎
The same condition is where the count (2UT+U−R) of the counting theorem becomes zero.
The rule gives the minimum length at a glance. "hello" needs 5+1=6 frames, as we found. "book" has U=4 and one repeat ("oo"), so it needs 5. "aaa" has U=3 and two repeats, so it also needs 5, and the only path that fits in 5 frames is a∅a∅a.
The condition ties a CTC model's frame rate to its vocabulary. A frame lasts Δenc, so a CTC model can emit at most 1/Δenc labels per second. That is 25 per second at 40 ms and 12.5 at 80 ms, and fewer when labels repeat. Fluent speech runs at about 15 characters per second, spaces included, and fast speech runs faster. So 80 ms frames are too coarse for characters.
This is what happened to the 10-second clip earlier, and to FastConformer as a whole. The encoder subsamples 8x, to 80 ms frames, to cut the cost of attention (Rekesh et al. 2023). Its authors found that with a character vocabulary most LibriSpeech training samples no longer met the condition. So they switched their CTC models to subword vocabularies of 128 to 1,024 tokens, learned with the SentencePiece tool, each covering one or more characters (§2.2 and §2.4 build such vocabularies). The transducer of §36.3 has no such constraint, because it can emit several labels on one frame.
Two practical rules follow. First, filter or fix the pairs of long transcript and short audio in a training set before CTC training. One infinite loss makes the batch loss infinite and the gradient NaN, and zero_infinity=True only hides such a pair from the gradient. Second, when you design a recognizer, choose the subsampling factor and the token vocabulary together, and check the condition on the fastest clips in the training data. Interviewers like this question because it connects the loss to a design decision.
Why trained CTC models are peaky
Look at the frame posteriors of any trained CTC model and you see the same picture. The blank has probability close to one on almost every frame. Each label appears as a sharp spike one or two frames wide, even when its sound lasts ten frames. Figure 1 of Graves et al. (2006) shows a trained network's outputs as such spikes separated by blanks, and their Figure 4 shows the spikes forming during training. The loss does not ask for this. Any alignment would do, including one in which every label holds all its frames. So why does training pick the spikes?
Start at the beginning of training. If the logits are all equal, then pt(k)=1/(V+1) for every frame and symbol, and every alignment has the same probability (V+1)−T. The occupancy is then a pure count that does not depend on the audio,
γt(k)=B−1(y){a∈B−1(y):at=k},
the fraction of alignments that put k at frame t. Blanks fill all the frames the labels do not need. So most alignments put a blank at most frames, and the blank is the most common symbol.
∂ Proof of the one-label occupancy
An alignment is fixed by the first and last frames of its a run, 1≤f≤l≤T, so there are T(T+1)/2 of them. Frame t is inside the run when f≤t≤l, which happens for t choices of f and T−t+1 choices of l. Summing, ∑tt(T−t+1)=T(T+1)(T+2)/6. So the average label share is (T+2)/(3T) and the blank share is 1−(T+2)/(3T)=2(T−1)/(3T). ∎
Now follow the first gradient step. The gradient is pt−γt with pt(∅)=1/(V+1). So gradient descent raises the blank's logit at every frame where γt(∅)>1/(V+1). With a real vocabulary 1/(V+1) is tiny, so when the clip has frames to spare that is every frame.
Then a feedback loop starts. A path's probability is a product over frames, so a higher blank probability everywhere makes the paths with many blank frames more probable than those with long label runs. The occupancy shifts further toward the blank, and the next step raises it again. Each label keeps only the frames where it is needed most and ends up firing on one or two of them. Zeyer et al. (2021) prove that for a simple model trained from a uniform start, gradient descent converges toward such peaky solutions.
Python · cell 6
At the start the blank's occupancy is between 0.45 and 0.82 at every frame, far above its probability of 0.25, so the first steps push the blank up everywhere. By step 10 the blank is the most probable symbol at all 30 frames, and the loss has fallen from 27.7 to 4.3. Only then do the labels claim frames, and they claim as few as they can. At step 300 each label holds two frames and the other 24 frames, 80 percent of them, are blank.
The research exercise below makes the counting argument exact at real scale, where the counts no longer fit in a float.
Research
Why CTC gets peaky, from counts
At a uniform start every alignment of y has the same probability. So γt(∅) is the fraction of alignments with a blank at frame t, whatever V is. Write two functions, with labels as positive ints and the blank as 0. Assume T≥U+R(y), so that at least one alignment exists.
uniform_blank_occupancy(T, y) returns the array of γt(∅) for t=1,…,T, of shape (T,).
first_step_blank_frames(T, y, V) returns the number of frames at which the first gradient step from uniform logits raises pt(∅).
The tests check the first function against the closed form for one label, against brute-force counting with repeated labels, and at T=3000 with U=150, where there are about 10428 alignments.
import numpy as np
def uniform_blank_occupancy(T, y):
# Return gamma_t(blank) for t = 1..T, shape (T,), when every p_t is uniform.
raise NotImplementedError
def first_step_blank_frames(T, y, V):
raise NotImplementedError
Tests, verified solutions, and AI help, open once you add your API key.
Decoding a transcript
At test time we want the most probable transcript, y^=argmaxypCTC(y∣X). The sum inside pCTC is what makes this hard. The number of candidate transcripts grows exponentially with T, and each candidate's probability is a sum over its own alignments. So neither listing the candidates nor a single best-path search gives the answer.
Greedy decoding and where it fails
The cheapest decoder takes the best symbol at every frame and collapses the result.
How often does this matter? The cell draws random posteriors over {∅,a,b} for seven frames. It finds the exact most probable transcript by summing all 37 paths, and counts how often greedy decoding misses it, at three levels of sharpness.
Python · cell 7
Greedy decoding misses about half the time on flat posteriors and about a tenth of the time on sharp ones. A trained CTC model is very sharp, because it is peaky. That is why greedy decoding is common for CTC in practice, and the FastConformer paper reports greedy WER throughout. The errors that remain sit exactly on the uncertain frames, where the sum over paths matters.
Prefix beam search
To compare transcripts we must score prefixes, not paths, and merge every path that spells the same prefix. A prefix is the start of a transcript, the labels written so far. The forward recursion over a prefix does the merging, if we split its probability by how its paths end. For a label prefix ℓ=(ℓ1,…,ℓn) and a frame t, let
In plain words,pb is the probability of the partial paths that spell ℓ by frame t and end in a blank, and pnb of those that end in a label, necessarily ℓn. We need the split because the next symbol behaves differently in the two cases. A copy of ℓn after a label merges into it, while after a blank it starts a new label.
∂ Proof of the prefix recursion
Split the partial paths by their last symbol. A path ending in a blank at frame t spells ℓ exactly when its first t−1 symbols spell ℓ, ending either way, which gives the first line.
A path ending in a label at frame t must end in ℓn. Its first t−1 symbols fall into one of three cases.
They spell ℓ and end in ℓn. The new frame extends that run, which merges.
They spell ℓ− and end in a blank. The new frame starts the label ℓn.
They spell ℓ− and end in a different label. The new frame starts ℓn directly, which is allowed only when that label differs from ℓn, or the two would merge.
A path that spells ℓ but ends in a blank cannot be extended by ℓn into ℓ, because that would spell ℓ followed by another ℓn. The three cases are disjoint and exhaustive. At t=T the two endings together are all the alignments of ℓ. ∎
Run this recursion for every prefix at once and you have exact decoding, but the number of prefixes grows exponentially. Prefix beam search keeps only the b most probable prefixes after each frame. Graves et al. (2006) introduced a related best-first prefix search, and Hannun et al. (2014) gave the beam form used today.
With an unbounded beam no prefix is ever pruned. The steps then compute the recursion of the theorem exactly, and the search returns the true argmaxypCTC(y∣X). With a finite beam it is an approximation, because a pruned prefix's paths are lost to all its extensions. A beam of 1 is not greedy decoding, since it still sums the paths of its prefix.
The widget runs prefix beam search on four frames over {∅,a,b}, with presets chosen to show where greedy decoding and narrow beams fail. Under each frame it lists the candidate prefixes with their pb and pnb, and Run fills in the beam one frame at a time.
Why can greedy decoding miss the transcript that prefix beam search finds?
Outlined bars are greedy's picks. Under each frame is the beam after that frame.
Loading interactive figure…
This is also where a language model enters. A language model gives a probability to every word sequence and is trained on text alone. Hannun et al. (2014) multiply in an n-gram language model's probability, raised to a weight, whenever a prefix is extended by a space. They also multiply by the number of words raised to a second weight, a bonus that keeps the search from favoring short transcripts. That is the decoder behind most CTC systems with a language model, and §36.5 derives the fused score and builds it. Prefix beam search is a classic speech coding interview question.
Interview
CTC prefix beam search
Write ctc_prefix_beam_search(probs, beam, blank=0). The input probs has shape (T, V+1), with strictly positive rows that sum to one. Return (labels, score), where labels is the best prefix as a tuple of ints and score is log(pb+pnb) of that prefix after the last frame.
Keep pb and pnb in log space, merge every way of reaching the same prefix, and after each frame keep the beam prefixes with the largest pb+pnb. The tests check the two-frame example, compare a very wide beam with the exact answer found by enumeration, check narrow beams against a reference search, and include a case where a beam of 1 and greedy decoding disagree. Ties do not occur in the tests.
production systems with a lexicon, biasing, or strict grammars
Forced alignment and timestamps
Sometimes the transcript is known and the question is where each word is. Subtitles need this, cutting long recordings into training segments needs it, and so do word timestamps on a recognizer's output. Finding the times is called forced alignment. Replace the sum in the forward recursion by a max and keep a pointer to the winning predecessor. You get the single most probable alignment of the known transcript, the Viterbi path on the same trellis. It is the max version of the forward algorithm, as Viterbi was in §2.4 and §36.1.
The frame spans become times by multiplying by the encoder frame duration. Frame t covers the interval [(t−1)Δenc,tΔenc). A word's span runs from its first token's first frame to its last token's last frame. The trellis widget above shows the Viterbi path when set to "best walk".
This is how NVIDIA's NeMo Forced Aligner produces token, word, and segment timestamps from CTC models. WhisperX (Bain et al. 2023) puts word times on Whisper's text the same way. It aligns the text against the frame posteriors of a wav2vec 2.0 model fine-tuned with CTC, and takes each word's start and end from its first and last unit.
The peaky caveat applies with full force. The Viterbi path of a peaky model puts each label on one or two frames, somewhere inside its sound. So a word's span is where the model fired, not where the word was spoken, and the blanks between words absorb the real word edges. Aligners therefore treat CTC spans as anchors and pad or post-process them. Models trained for timing sometimes add a label prior or use a different topology to reduce peakiness.
Core
CTC forced alignment
Write ctc_force_align(log_probs, y, blank=0). It takes log-probabilities of shape (T, V+1) and a transcript y, and returns (spans, score).
score is the log-probability of the best alignment in B−1(y).
spans[u] = (first, last) are the 0-indexed first and last frames of label u on that alignment.
Return (None, float("-inf")) when no alignment exists.
Vectorize over positions, since the tests align 300 labels to 2,000 frames.
import numpy as np
def ctc_force_align(log_probs, y, blank=0):
# Return (spans, score): spans[u] = (first_frame, last_frame) of label u on the best path.
raise NotImplementedError
Tests, verified solutions, and AI help, open once you add your API key.
CTC at real scale, and what it cannot do
CTC has been at the frontier twice, a decade apart. Two measures recur below. Word error rate (WER) is the share of reference words a recognizer gets wrong, counting substitutions, deletions, and insertions (§36.1). RTFx is the number of seconds of audio transcribed per second of compute, so higher is faster.
The speed is structural. CTC is non-autoregressive, meaning it does not produce one token at a time with each conditioned on the last. One encoder pass gives every frame's distribution, the frames are decoded independently, and greedy decoding is an argmax and a collapse. There is no decoder network to run once per token. The leaderboard paper's summary is that Conformer encoders with Transformer decoders give the best average WER, while CTC and token-and-duration transducers give far higher RTFx. That makes them the choice for batch and long-form transcription.
The price is the assumption in the definition box. Given X, CTC's outputs are conditionally independent, so no output can depend on another. Everything a language model would know, such as spelling, grammar, and which of two words that sound alike fits, must come from the encoder or from outside.
There are three families of fixes.
Add a language model at decoding time, through prefix beam search with fusion (§36.5).
Strengthen CTC inside the encoder. Intermediate CTC (Lee and Watanabe 2021) attaches extra CTC losses to intermediate encoder layers as a regularizer. It improves every model they test. Combined with stochastic depth, which randomly skips whole layers during training, a Conformer reaches 9.9 percent WER on the Wall Street Journal read-speech corpus (WSJ) with greedy decoding and no language model. Self-conditioned CTC (Nozaki and Komatsu 2021) also feeds each intermediate layer's predictions back into the next layer's input. So the final layer's output is conditioned on earlier guesses of the whole transcript. It reports more than 20 percent relative WER reduction over plain CTC on WSJ. On TED talks (TEDLIUM2) and Mandarin (AISHELL-1) it matches a strong autoregressive model with beam search while decoding at least 30 times faster.
Change the model. The transducer of §36.3 adds a prediction network that reads the labels emitted so far. That removes the independence while keeping the frame-by-frame sum over alignments. Attention encoder-decoders (§36.4) drop explicit alignments, and there CTC returns as an auxiliary loss that keeps attention moving forward through the audio. In streaming systems (§36.6), a fast CTC first pass is often rescored by a slower second pass.
CTC
How it handles alignment
sums over all monotone alignments with blanks, by the forward algorithm
Output dependence
none given X (conditionally independent frames)
Length condition
T≥U+R(y), at most one label per frame
Training cost
O(TU) per clip on top of the encoder, small memory
Decoding
greedy argmax, or prefix beam search, non-autoregressive
Streaming
yes, with a streaming encoder, since outputs come one per frame
Timestamps
from forced alignment, but peaky spikes are not word boundaries
Typical use
fast batch transcription, forced alignment, auxiliary loss for attention models
Hannun (2017). Sequence Modeling with CTC. A visual walk through alignments, the trellis, the loss, and beam search. With it, Hannun, Maas, Jurafsky, and Ng (2014), First-Pass Large Vocabulary Continuous Speech Recognition using Bi-Directional Recurrent DNNs, which introduced prefix beam search with pb and pnb and the language model hook. Read its Algorithm 1 critically, since the printed pseudocode overwrites scores that implementations accumulate, and its repeat case updates pnb from pb where pnb is meant.
PyTorch documentation. CTCLoss. The exact input layout, reduction semantics, and zero_infinity behavior.
Watch next
🎬 CMU Introduction to Deep Learning 11785, Spring 2026: Sequence to Sequence Models: CTC · Carnegie Mellon University Deep LearningA full university lecture that builds CTC from the alignment problem through the blank, the forward-backward trellis, and the gradient, at the same depth as this section.🎬 Recitation 8 | Decoding and Beam Search for Connectionist Temporal Classification (CTC) · Carnegie Mellon University Deep LearningA worked recitation on greedy decoding and CTC prefix beam search with blank and non-blank scores, the companion to the decoding part of this section.