← Back to Learning Hub

Aligning and Voting Across Transcription Engines

AlignmentEnsemblesIntermediate16 min

By: Anacodic Team

TL;DR — Two engines that fail in different places combine into one better than either. Three engines each misreading a different word score 1.52%, 3.03% and 3.03% CER; a plurality vote scores 0.00%, eliminating all five errors. The vote only works after alignment: engines disagree about how many tokens a line holds, and voting by position on unequal sequences scores 8.77% — worse than the best single engine, which was already perfect. Align first, then vote. The method stops working when the engines share a confusion: three engines that all read rn as m produce a 3-0 consensus on the wrong answer and the ensemble is exactly as wrong as its best input. Independence of errors is the whole assumption, and it is the one thing nobody measures.


1. Simple explanation

Run two transcription engines over the same scanned page and you get two texts that differ. The usual response is to keep the better-looking one and discard the other, which throws away the most useful signal available.

Where the two agree, the reading is probably right — systems built differently, trained differently and failing differently do not usually invent the same wrong characters. Where they disagree, something on the page is hard, and you now know which line to look at. With three engines you can go further and take the majority, recovering positions that any individual engine got wrong.

Analogy — three people transcribing the same muffled voicemail. Each writes down what they heard. Where all three wrote the same thing, you can be fairly confident. Where one wrote Hastings and the other two wrote Hasting, you take the majority and move on. But if all three are listening to the same crackle in the same place, they will all mishear it the same way, agree unanimously, and be confidently wrong together — and no amount of counting votes will tell you. The value of the third listener depends entirely on their ear being different, not on them being present.


2. Diagram

  INDEPENDENT ERRORS                     CORRELATED ERRORS
  (each engine wrong elsewhere)          (all share one confusion)

  A: ...aproved... works  ...march       A: ...approved... works  ...rnarch
  B: ...approved.. vvorks ...march       B: ...aproved...  works  ...rnarch
  C: ...approved.. works  ...rnarch      C: ...approved... vvorks ...rnarch
       ────┬───    ──┬──   ──┬──                                  ───┬───
       2 of 3      2 of 3  2 of 3                                  3 of 3
       recover     recover recover                               all wrong

  best engine 1.52%  ->  voted 0.00%     best engine 3.03%  ->  voted 3.03%
  └──── 5 errors eliminated ────┘        └──── no gain whatsoever ────┘

The mechanism has no notion of truth, only of agreement. Everything it buys you comes from the engines failing in different places.


3. How it works

3.1 Plurality voting over aligned positions

If three engines each get most characters right and their mistakes fall in different places, then at any given position at least two are usually correct. Take the majority and you recover positions no single engine got right. When all three disagree there is no majority, and the rule falls back to a designated anchor engine rather than choosing arbitrarily.

3.2 Alignment must come first

The naive implementation zips the token sequences together, which silently assumes they are the same length. Real engines violate that immediately: they drop words on damaged text, split one word into two when a gap is misread, and merge two into one when a space is lost.

The moment token counts differ, position k in engine A is no longer the same word as position k in engine B, and everything after the first discrepancy is compared against the wrong thing. Measured below: 8.77% error from an ensemble whose best member scored 0.00%.

The fix is to align the sequences using the same dynamic programming that underlies edit distance, but with whole tokens as the symbols and an explicit gap penalty. Pick one engine as the anchor, align every other engine to it, and vote only at anchor-aligned positions. The anchor fixes the output's length and word order; the others contribute substitutions only.

3.3 The anchor is a real decision

The anchor determines the output's structure and every position where no majority forms, so it should be the most reliable engine — not the newest or the most interesting. If you do not know which is most reliable, you are not ready to build this layer.

3.4 Where the method stops applying

Voting improves accuracy only to the extent that the engines' errors are independent. Shape confusions — rn/m, w/vv, c/e, 1/l — are driven by the typography and resolution of the source, not by the architecture of the recogniser, so any system reading the same degraded image is prone to the same mistake. Systematic errors are precisely the ones most likely to be shared, which means the gain is bounded by how differently your engines fail. Two variants of one model with different seeds buy almost nothing.


4. The math

4.1 Alignment score

Global alignment over tokens, maximising a similarity score with a gap penalty:

    S[i][j] = max( S[i-1][j-1] + match_score(a_i, b_j),     # pair them
                   S[i-1][j]   + gap,                        # a_i unmatched
                   S[i][j-1]   + gap )                       # b_j unmatched

    match_score = +1.0 if tokens equal else -0.5
    gap         = -1.0        (near the mismatch cost is a sane default)

Then the vote at each anchor position:

    winner = most_common(tokens aligned to that position)
    output = winner if count(winner) >= 2 else anchor_token

4.2 Worked example — independent errors

  reference: the council approved the drainage works on the north road in march
                             ^                  ^                          ^
  engine A:  aproved  (pos 2)      CER 1/66  = 0.0152
  engine B:  vvorks   (pos 5)      CER 2/66  = 0.0303
  engine C:  rnarch   (pos 11)     CER 2/66  = 0.0303
                                   ---------------------
                                   5 character errors total

  position  2: A=aproved   B=approved  C=approved  -> approved  (2 of 3)
  position  5: A=works     B=vvorks    C=works     -> works     (2 of 3)
  position 11: A=march     B=march     C=rnarch    -> march     (2 of 3)

  voted CER = 0 / 66 = 0.0000        all 5 errors eliminated

4.3 Worked example — correlated errors

  all three engines read "march" as "rnarch"

  position 11: A=rnarch  B=rnarch  C=rnarch  -> rnarch  (3 of 3, unanimous)

  best single engine 0.0303   ->   voted 0.0303      no gain

The vote is most confident exactly where it is wrong.


5. Real code

from collections import Counter

def levenshtein(a, b):
    if len(a) < len(b):
        a, b = b, a
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
        prev = cur
    return prev[-1]

def cer(ref, hyp):
    return levenshtein(list(ref), list(hyp)) / max(1, len(ref))

def vote(hyps):
    """Positional plurality vote. Assumes equal token counts — see section 3.2."""
    out = []
    for slot in zip(*(h.split() for h in hyps)):
        tok, n = Counter(slot).most_common(1)[0]
        out.append(tok if n >= 2 else slot[0])   # no majority -> trust the anchor
    return " ".join(out)

REF = "the council approved the drainage works on the north road in march"
DIVERSE = ["the council aproved the drainage works on the north road in march",
           "the council approved the drainage vvorks on the north road in march",
           "the council approved the drainage works on the north road in rnarch"]
SIMILAR = ["the council approved the drainage works on the north road in rnarch",
           "the council aproved the drainage works on the north road in rnarch",
           "the council approved the drainage vvorks on the north road in rnarch"]

best_d, vote_d = min(cer(REF, h) for h in DIVERSE), cer(REF, vote(DIVERSE))
best_s, vote_s = min(cer(REF, h) for h in SIMILAR), cer(REF, vote(SIMILAR))
print(f"diverse : best engine {best_d:.4f} -> voted {vote_d:.4f}")
print(f"similar : best engine {best_s:.4f} -> voted {vote_s:.4f}")

# Unequal token counts: B drops a word, C splits one.
A = "the council approved the drainage works on the north road"
B = "the council approved drainage works on the north road"
C = "the council appro ved the drainage works on the north road"
pos = cer(A, vote([A, B, C]))
print(f"unequal token counts, positional vote {pos:.4f} "
      f"vs best single {min(cer(A, h) for h in (A, B, C)):.4f}")

assert round(best_d, 4) == 0.0152 and round(vote_d, 4) == 0.0000
assert round(best_s, 4) == 0.0303 and round(vote_s, 4) == 0.0303
assert round(pos, 4) == 0.0877
print("asserts passed")

# Output:
#   diverse : best engine 0.0152 -> voted 0.0000
#   similar : best engine 0.0303 -> voted 0.0303
#   unequal token counts, positional vote 0.0877 vs best single 0.0000
#   asserts passed

6. Real-world example

A team digitising a run of printed registers had two transcriptions of every page: the text layer shipped with the scans, and the output of a recogniser they ran themselves. They built a voting layer, saw plausible output, and shipped it.

The regression appeared weeks later in a downstream index, and the cause was the assumption in §3.2. Their recogniser occasionally dropped a line on damaged pages. The vote was positional, so a single dropped line at the top of a page shifted every subsequent line onto the wrong partner — comparing line 5 against line 6 for the rest of the page. The output was fluent and confidently wrong, and because both inputs were real transcriptions of the same page, nothing looked obviously broken to a reviewer skimming it.

Two things came out of the post-mortem, both worth stealing.

They aligned before voting, using an anchor sequence, which turned a dropped line into an abstention at one position instead of a shift through everything after it.

They measured error independence before trusting the ensemble. On twenty hand-corrected lines they counted, for each pair of engines, the positions where both were wrong and wrong in the same way. The shared fraction was much larger than expected, because both systems were reading the same degraded print and both stumbled on the same letter shapes. That number is the hard floor under the ensemble: those errors cannot be voted away, no matter how the counting is tuned. Knowing it beforehand would have redirected the effort from tuning the voting rule to making the second engine genuinely different — a different architecture, a different image scale — which is where the remaining accuracy actually was.


7. Interview questions companies actually ask

Q1. Why can an ensemble of transcription engines beat the best individual engine? Because errors that are independently distributed rarely coincide. If each engine is wrong in a different place, then at any given position the other two are usually right, and a majority vote recovers positions no single engine got right. The gain comes entirely from independence, not from average accuracy — three engines at 97% that fail in the same places produce a vote no better than one of them.

Q2. You add a third engine and accuracy barely moves. What do you check first? Whether its errors overlap the existing two. Count, on held-out ground truth, the positions where two engines are both wrong and wrong identically; that set is a floor the vote cannot break through. A third engine that is a re-seeded variant of an existing one will share nearly all its failure modes and add almost nothing, so the fix is structural diversity — a different architecture, different training data, or even the same engine at a different image scale — rather than more voters.

Q3. Why must you align before voting, and what happens if you do not? Because engines disagree about how many tokens a line contains: they drop words, split them, and merge them. Voting by position assumes the sequences correspond one-to-one, so a single dropped token shifts every later position onto the wrong partner and the ensemble degrades below its best member — measurably, from 0% to 8.77% in the example above. The failure is silent, because the output remains fluent.

Q4. How do you pick the anchor engine, and why does it matter? The anchor fixes the output's length and word order and wins every position where no majority forms, so it should be the most reliable engine measured on held-out data. Choosing it by intuition undermines the whole layer, and if you cannot rank your engines by reliability you are not ready to build the ensemble — with two engines especially, the "vote" is really "trust the anchor except where it is silent."

Q5. With only two engines, what can you legitimately claim? Not automatic correction — every disagreement is 1-1 and the tie-break decides, so any improvement may be attributable entirely to the anchor. What two engines do give you is triage: agreement is a confidence signal requiring no ground truth, and the disagreements form a ranked worklist for human review. That is genuinely valuable, but reporting it as an accuracy gain without measuring against ground truth is overclaiming.

Q6. Should a language model adjudicate the disagreements? Only under hard constraints and only after a deterministic baseline is measured. The model must select among the supplied candidates or decline, never generate a new reading, because an unconstrained model asked to "correct" degraded text produces fluent output that is not on the page — and does so most confidently where the source is worst. Published results on model-based correction of historical transcription are mixed and language-dependent, and dedicated evaluations still frame overcorrection and hallucination as open problems.

Q7. What tuning knob matters most in the alignment, and how do you set it? The gap penalty. Too lenient and the aligner skips tokens to chase spurious matches further along; too harsh and it forces unrelated tokens into the same slot. A cost near the mismatch score is a reasonable default, and it should be checked on real disagreements rather than assumed — sweeping it and watching the resulting error rate takes minutes.


8. When to use / tradeoffs

Reach for consensus voting when:

  • you have three or more transcriptions produced by structurally different systems;
  • you can measure error overlap on at least a small set of hand-corrected lines;
  • the engines are individually good enough that most tokens are already right;
  • you need triage — which pages deserve a human — even before you need correction.

Do NOT use when:

SituationWhy it breaksUse instead
The engines are variants of one modelerrors are correlated; the majority is wrong togetherinvest in a structurally different engine first
Only two transcriptions existno majority; the tie-break is the anchoragreement as a confidence signal and a review worklist
You have no ground truth at allyou cannot tell a gain from a regressionproduce ~20 corrected lines before building anything
Token counts differ and you vote positionallyslots shift; output degrades below the best member, silentlyalign to an anchor, then vote
Errors are dominated by one shared confusionthe vote is unanimous and wronga targeted post-processing rule with a lexicon

Honest limits. The alignment here is pairwise-to-anchor, not a true multiple sequence alignment: aligning B and C to A independently can produce column assignments a simultaneous three-way alignment would not, which is usually negligible for three engines and stops being so as the count grows. The vote is unweighted, which is the honest default when you have no measured reliability, but a weighted vote does better where the weights come from held-out data rather than intuition. Everything here operates on tokens; character-level voting recovers cases where every engine gets a word partly wrong in different places, at the cost of far greater sensitivity to alignment error. And the numbers throughout come from short constructed lines chosen to make each mechanism visible — they demonstrate that the mechanisms behave as described and are not a benchmark. The only figure that matters for your data is the one you measure on your data.


  • Voting can beat the best engine, not merely the average: three engines at 1.52%, 3.03% and 3.03% voted to 0.00%, eliminating all five errors.
  • Align before you vote. Positional voting on unequal token counts scored 8.77% where the best single engine scored 0.00% — a silent regression producing fluent output.
  • The anchor fixes output structure and wins every tie; choose it by measured reliability, not by intuition.
  • Correlated errors defeat the method entirely. Three engines sharing one confusion voted 3-0 for the wrong reading and the ensemble matched its best input exactly.
  • Measure error overlap on ~20 corrected lines before building the layer; the identically-wrong positions are a hard floor no voting rule can break.
  • The boundary: the gain is bounded by how differently the engines fail. Re-seeded variants of one model buy almost nothing, and with only two transcriptions this is triage rather than correction.

Related:

Resources

  • Needleman, S. B. & Wunsch, C. D. (1970). "A general method applicable to the search for similarities in the amino acid sequence of two proteins." Journal of Molecular Biology, 48(3), 443–453. The global alignment algorithm used in §4.1.
  • Fiscus, J. G. (1997). "A post-processing system to yield reduced word error rates: Recognizer Output Voting Error Reduction (ROVER)." IEEE Workshop on Automatic Speech Recognition and Understanding, 347–354. The origin of aligned output voting; §3 is a simplification of it.
  • Dietterich, T. G. (2000). "Ensemble methods in machine learning." Multiple Classifier Systems, LNCS 1857, 1–15. Why error independence, rather than individual accuracy, sets the ceiling.
  • Python's difflib.SequenceMatcher is a practical starting point for alignment when you do not need control of the gap penalty.

Runnable notebook

Run it end to end — the mock model needs no API key; add your own key for the real Claude section.

Open In Colab