Chương 7·Bài 7.3

Speech recognition

Speech recognition chuyển lời nói thành chữ. Khó khăn chính là hai chuỗi lệch độ dài: ba giây âm thanh ở 16 kHz là 48 000 số, còn câu nói chỉ có năm từ, và dữ liệu không cho biết từ nào ứng với đoạn âm nào. Bài này học CTC, loss huấn luyện được khi chưa biết vị trí từng chữ; cách chấm bằng tỉ lệ lỗi từ (WER); và lỗi hay gặp khi dùng language model sửa câu cho trôi chảy nhưng lại làm sai lời người nói.

Một chuỗi âm, một chuỗi chữ, chưa có alignment

Automatic speech recognition (ASR) nhận âm thanh và trả transcript. Nó khác audio classification ở bài 7.2: đáp án là một chuỗi, dài ngắn tùy câu. Đầu vào XX có thể là waveform hoặc đặc trưng thời gian-tần số; sau encoder ta có TT hidden states, còn transcript là UU token. Token có thể là ký tự, subword hoặc từ, tùy model. Thường TT lớn hơn UU nhiều, và không frame nào được hứa là chứa đúng một chữ.

Alignment (căn chỉnh) là cách gắn các bước âm với những token ấy. Bản ghi thật thường chỉ có transcript toàn clip, không có ranh giới chính xác cho từng token, nên không thể train thẳng một bộ phân loại theo frame. Có hai cách vượt qua:

  • CTC không chọn một alignment mà cộng xác suất của mọi alignment hợp lệ.
  • Encoder-decoder sinh token nối tiếp và dùng attention để tự tìm đoạn âm cần đọc cho từng token.

Hai hướng giải quyết chung một khó khăn nhưng khác dạng output và loss; chương ASR của Jurafsky và Martin trình bày cả hai.

CTC cho mỗi frame một nhãn rồi gộp; encoder-decoder sinh từng token.CTCemem__họchọchọc_AIAI__em học AIencoder-decoderhidden của encoder<sot>emhọcAI<eot>
CTC cho mỗi frame một nhãn rồi gộp; encoder-decoder sinh từng token.

Hỏi nhanh

Đội có 2000 clip kèm transcript, nhưng không có mốc thời gian của từng ký tự. Một bạn đề xuất chia đều các frame cho các ký tự, rồi train cross-entropy theo frame. Cách này sai ở đâu?

Người nói ngừng giữa câu, nên chia đều thời gian cho các từ sẽ lệch.lời thậtemhọcAIhômnaychia đềuemhọcAIhômnayngừng0 s1 s2 s3 s
Người nói ngừng giữa câu, nên chia đều thời gian cho các từ sẽ lệch.

CTC: thêm blank để biểu diễn khoảng chưa xuất chữ

Giả sử bảng nhãn chỉ có chữ a. CTC thêm một nhãn blank, ký hiệu _, nên mỗi frame có hai xác suất. Một path như a a _ a được biến thành transcript bằng gộp các nhãn giống nhau liền kề trong path, rồi bỏ blank. Nó thành aa; còn a a a thành a. Blank ở giữa cho phép biểu diễn hai chữ giống nhau liên tiếp, nên không được xóa blank trước khi gộp.

Python
import numpy as np

def ctc_collapse(path, blank=0):
    result = []
    for step, token in enumerate(path):
        different_from_previous = step == 0 or token != path[step - 1]
        if token != blank and different_from_previous:
            result.append(token)
    return tuple(result)

vocabulary = {0: "_", 1: "a"}
for path in [(1, 1, 0, 1), (1, 1, 1)]:
    transcript = "".join(vocabulary[token] for token in ctc_collapse(path))
    print(" ".join(vocabulary[token] for token in path), "->", transcript)

Đó là quy tắc đọc path, chưa phải toàn bộ cách train CTC. Mỗi transcript có thể nhận nhiều path khác nhau. Với xác suất frame pt(c)p_t(c), xác suất một path π\pi là tích ∏tpt(πt)\prod_t p_t(\pi_t); xác suất transcript yy là tổng trên mọi path collapse về yy:

P(y∣X)=∑π:B(π)=y∏t=1Tpt(πt),L=−log⁡P(y∣X).P(y\mid X)=\sum_{\pi:\mathcal B(\pi)=y}\prod_{t=1}^{T}p_t(\pi_t),\qquad \mathcal L=-\log P(y\mid X).

B\mathcal B là hàm collapse; chữ BB này không phải batch size. Phép tích giả định các frame độc lập khi đã biết XX: mỗi ptp_t do encoder tính từ cả clip nên vẫn mang ngữ cảnh, nhưng nhãn chọn ở frame này không phụ thuộc nhãn đã chọn ở frame khác.

Khi TT lớn, liệt kê mọi path là quá chậm; mục sau cộng chúng bằng dynamic programming. Bài báo CTC gốc là nguồn cho mục tiêu và thuật toán này.

Hỏi nhanh

Path a _ a collapse thành gì?

Cộng đủ các path trên ba frame

Cho ba frame, bảng xác suất theo thứ tự _, a là (0.6,0.4)(0.6,0.4), (0.5,0.5)(0.5,0.5), (0.4,0.6)(0.4,0.6). Có 23=82^3=8 path. ___ cho chuỗi rỗng với xác suất 0.6⋅0.5⋅0.4=0.120.6\cdot0.5\cdot0.4=0.12; a_a cho aa với xác suất 0.4⋅0.5⋅0.6=0.120.4\cdot0.5\cdot0.6=0.12. Sáu path còn lại đều cho a, nên P(a)=1−0.12−0.12=0.76P(a)=1-0.12-0.12=0.76. Loss cho target a là −ln⁡0.76-\ln0.76, khoảng 0.2744.

Bảng này đóng vai output softmax của encoder sau ba frame. Code liệt kê đủ tám path và cộng xác suất theo transcript.

Python
from itertools import product

frame_probabilities = np.array([[0.6, 0.4], [0.5, 0.5], [0.4, 0.6]])
transcript_probabilities = {}
for path in product(range(2), repeat=3):
    probability = np.prod(frame_probabilities[np.arange(3), path])
    transcript = ctc_collapse(path)
    transcript_probabilities[transcript] = transcript_probabilities.get(transcript, 0) + probability
print(f"P(rỗng) = {transcript_probabilities[()]:.3f}")
print(f"P(a) = {transcript_probabilities[(1,)]:.3f}")
print(f"P(aa) = {transcript_probabilities[(1, 1)]:.3f}")
print(f"loss = {-np.log(transcript_probabilities[(1,)]):.4f}")
Tám path gộp thành ba transcript; “a” gom sáu path, xác suất 0.76.___0.12__a0.18_aa0.18_a_0.12aaa0.12a__0.08aa_0.08a_a0.12rỗng0.12a0.76aa0.12
Tám path gộp thành ba transcript; “a” gom sáu path, xác suất 0.76.

Mỗi hàng bên trái là một path cùng xác suất của nó; tám path đổ về ba transcript ở bên phải.

Greedy decoding lấy nhãn xác suất cao nhất ở từng frame rồi collapse. Nó tìm path có xác suất lớn nhất, nhưng transcript cần tìm là transcript có tổng xác suất lớn nhất, và một transcript gom nhiều path. Khi một transcript có nhiều path vừa phải còn transcript khác có một path trội, hai đáp án có thể khác nhau. Beam search giữ nhiều tiền tố transcript cùng lúc và cộng xác suất các path dẫn về cùng một tiền tố (prefix beam search), nên gần mục tiêu hơn greedy nhưng tốn thời gian hơn.

Hỏi nhanh

Có hai frame, mỗi frame có P(_)=0.6P(\_) = 0.6 và P(a)=0.4P(a) = 0.4. Greedy decoding cho transcript nào, và transcript nào có xác suất lớn nhất?

Path tốt nhất ra chuỗi rỗng, nhưng transcript “a” có tổng xác suất cao hơn.0.60.60.40.4_at = 1t = 2greedy chọn _ ở cả hai framerỗng__0.36aa_ + _a + aa0.64
Path tốt nhất ra chuỗi rỗng, nhưng transcript “a” có tổng xác suất cao hơn.
Mỗi khung 20 ms cho một ký tự hoặc blank; gộp ký tự lặp rồi bỏ blank.

Cộng mọi path bằng dynamic programming

Liệt kê phải duyệt CTC^T path: 8 path ở ví dụ trên, nhưng 3010030^{100} path với 30 nhãn và 100 frame. Cách cộng nhanh giống bài đếm đường đi trên lưới.

Chèn blank vào đầu, cuối và giữa mọi nhãn của target để được chuỗi mở rộng ℓ′=(_,y1,_,y2,…,yU,_)\ell'=(\_,y_1,\_,y_2,\dots,y_U,\_) dài 2U+12U+1. Mỗi path hợp lệ là một đường đi qua lưới gồm frame tt và vị trí ss trong ℓ′\ell'. Sang frame kế tiếp, đường đi có ba lựa chọn:

  • đứng yên (kéo dài nhãn hiện tại);
  • tiến một ô;
  • nhảy qua một blank để sang nhãn sau.

Bước nhảy chỉ được phép khi nhãn mới khác nhãn cũ; nếu không, hai nhãn giống nhau đứng sát nhau sẽ bị gộp làm một.

Gọi αt(s)\alpha_t(s) là tổng xác suất của mọi đoạn path dài tt dừng ở vị trí ss:

αt(s)=(αt−1(s)+αt−1(s−1)+[ℓs′≠_, ℓs′≠ℓs−2′] αt−1(s−2)) pt(ℓs′).\begin{aligned}\alpha_t(s)=\Big(&\alpha_{t-1}(s)+\alpha_{t-1}(s-1)\\&+\big[\ell'_s\neq\_,\ \ell'_s\neq\ell'_{s-2}\big]\,\alpha_{t-1}(s-2)\Big)\,p_t(\ell'_s).\end{aligned}

Dấu ngoặc vuông bằng 1 khi cả hai điều kiện đúng, bằng 0 nếu không. Path bắt đầu bằng blank đầu hoặc nhãn đầu, nên α1(1)=p1(_)\alpha_1(1)=p_1(\_), α1(2)=p1(y1)\alpha_1(2)=p_1(y_1), các ô khác bằng 0. Path phải dừng ở nhãn cuối hoặc blank cuối, nên P(y∣X)=αT(2U+1)+αT(2U)P(y\mid X)=\alpha_T(2U+1)+\alpha_T(2U). Lưới có T(2U+1)T(2U+1) ô, mỗi ô cộng tối đa ba số: thời gian O(TU)O(TU) thay cho CTC^T.

Python
def ctc_forward(frame_probabilities, target, blank=0):
    extended = [blank]
    for token in target:
        extended += [token, blank]
    alpha = np.zeros(len(extended))
    alpha[0] = frame_probabilities[0, blank]
    alpha[1] = frame_probabilities[0, extended[1]]
    history = [alpha]
    for t in range(1, len(frame_probabilities)):
        previous, alpha = alpha, np.zeros(len(extended))
        for s, label in enumerate(extended):
            total = previous[s]
            if s >= 1:
                total += previous[s - 1]
            if s >= 2 and label != blank and label != extended[s - 2]:
                total += previous[s - 2]
            alpha[s] = total * frame_probabilities[t, label]
        history.append(alpha)
    return alpha[-1] + alpha[-2], history

probability_a, history = ctc_forward(frame_probabilities, [1])
for t, row in enumerate(history, start=1):
    print(f"t={t}:", "  ".join(f"{value:.2f}" for value in row))
print(f"P(a) = {probability_a:.2f}")

Ba cột ứng với _ a _. Ở t=2t=2, ô a nhận 0.40.4 (đã ở a) cộng 0.60.6 (từ blank đầu), nhân p2(a)=0.5p_2(a)=0.5 được 0.50.5. Hàng cuối cộng hai ô cuối: 0.48+0.28=0.760.48+0.28=0.76, đúng tổng của sáu path đã liệt kê. Ô đầu hàng cuối, 0.120.12, là path toàn blank, tức P(roˆ˜ng)P(\text{rỗng}).

Quy hoạch động trên lưới α: hai ô cuối cộng ra 0.76.t = 1t = 2t = 3_a_0.600.400.000.300.500.200.120.480.280.48 + 0.28= 0.76
Quy hoạch động trên lưới α: hai ô cuối cộng ra 0.76.

Code trên nhân xác suất thường cho dễ đọc. Thư viện chạy cùng truy hồi trong log-space: lưu log⁡α\log\alpha và thay mỗi phép cộng bằng log⁡∑exp⁡\log\sum\exp (logsumexp), vì tích của hàng trăm xác suất nhỏ nhanh chóng vượt khỏi khoảng số mà máy biểu diễn được.

Hỏi nhanh

Một bạn viết đúng truy hồi ở trên, nhưng bằng xác suất thường, cho một clip 1000 frame, xác suất mỗi frame quanh 0.1. Loss in ra inf, dù target ngắn và lengths đủ. Nguyên nhân là gì, và sửa thế nào?

Tích xác suất nhỏ dần về 0 sau vài chục frame; log-space thì không.-400-300-200-100005001000số framefloat32float64log₁₀ của tíchlog α ≈ −2303 ở frame 1000vẫn biểu diễn được
Tích xác suất nhỏ dần về 0 sau vài chục frame; log-space thì không.

PyTorch có CTCLoss làm đúng phép cộng này trong log-space. Nó nhận log probabilities shape (T,B,C): thời gian trước, batch sau, khác Conv2d và nhiều model đặt batch trước. Ta dùng reduction='sum' để đối chiếu trực tiếp −log⁡P(a)-\log P(a); mặc định 'mean' còn chia loss mỗi mẫu cho độ dài target trước khi lấy trung bình batch.

Python
import torch

log_probabilities = torch.tensor(frame_probabilities, dtype=torch.float64).log()[:, None, :]
criterion = torch.nn.CTCLoss(blank=0, reduction="sum")
target = torch.tensor([1], dtype=torch.long)
loss = criterion(
    log_probabilities,
    target,
    torch.tensor([3]),
    torch.tensor([1]),
)
print(tuple(log_probabilities.shape))
print(f"CTCLoss = {loss.item():.4f}")
too_short_loss = criterion(
    log_probabilities[:2],
    torch.tensor([1, 1]),
    torch.tensor([2]),
    torch.tensor([2]),
)
print(f"target aa, 2 frame: inf = {bool(torch.isinf(too_short_loss))}")

Thêm một phép kiểm trên bảng ngẫu nhiên 12 frame, 4 nhãn, với target 1 2 2 3 có hai nhãn giống nhau đứng liền nhau, đúng trường hợp bước nhảy bị cấm:

Python
rng = np.random.default_rng(0)
random_logits = rng.normal(size=(12, 4))
random_probabilities = np.exp(random_logits) / np.exp(random_logits).sum(axis=1, keepdims=True)
random_target = [1, 2, 2, 3]
dp_loss = -np.log(ctc_forward(random_probabilities, random_target)[0])
torch_loss = criterion(
    torch.tensor(random_probabilities).log()[:, None, :],
    torch.tensor(random_target),
    torch.tensor([12]),
    torch.tensor([4]),
)
print(f"DP = {dp_loss:.6f}, CTCLoss = {torch_loss.item():.6f}")

Hai cách cho cùng một số đến sáu chữ số thập phân: truy hồi vừa viết tính đúng điều CTCLoss tính.

Dòng cuối của khối CTCLoss đầu tiên cho inf: target aa cần ít nhất ba bước, a _ a, vì hai a liền nhau cần một blank chen giữa. Tổng quát, target dài UU có rr cặp nhãn giống nhau đứng liền nhau cần ít nhất U+rU+r bước sau encoder; U≤TU\leq T là điều kiện cần nhưng chưa đủ. zero_infinity=True đặt các loss vô hạn về 0 để chúng không làm hỏng gradient, nhưng không sửa alignment thiếu bước.

Nếu nhiều mẫu rơi vào tình huống ấy, kiểm lengths sau encoder, tốc độ downsampling và tokenization. Tài liệu CTCLoss ghi rõ shape, lengths và ý nghĩa reduction.

Hỏi nhanh

Khối CTCLoss ở trên cho target aa với chỉ hai frame. Vì sao loss là inf?

Target “aa” cần blank chen giữa, nên 2 frame không đủ._a_a_T = 2: không tới ô cuối_a_a_T = 3: a _ akhung đứt: hai ô path phải dừng ở frame cuối
Target “aa” cần blank chen giữa, nên 2 frame không đủ.

Encoder-decoder: sinh từng token với ngữ cảnh đã sinh

Hướng thứ hai dùng encoder cho âm thanh và decoder sinh transcript từng token:

P(y∣X)=∏u=1UP(yu∣y<u,X).P(y\mid X)=\prod_{u=1}^{U}P(y_u\mid y_{<u},X).

y<uy_{<u} là các token trước vị trí uu. Khi train, decoder nhận transcript thật dịch phải một vị trí (teacher forcing, bài 6.5) và causal mask chặn mỗi vị trí đọc token phía sau; khi suy luận, nó nhận lại chính các token vừa sinh. Decoder đọc encoder bằng cross-attention, nên không cần xuất token ở từng frame và không dùng quy tắc blank của CTC. Câu kết thúc khi decoder sinh token hết câu, không phải khi hết frame.

Whisper là một Transformer encoder-decoder theo hướng này. Các bước của Whisper:

  1. Audio được resample về 16 kHz, cắt hoặc pad thành cửa sổ 30 giây, rồi đổi thành log-mel với cửa sổ 25 ms, bước 10 ms: 3000 frame.
  2. Hai lớp convolution đầu encoder, lớp thứ hai có stride 2, còn lại 1500 vị trí.
  3. Decoder bắt đầu bằng một dãy token đặc biệt chỉ định việc cần làm: <|startoftranscript|>, token ngôn ngữ như <|vi|>, rồi <|transcribe|> để chép lời hoặc <|translate|> để dịch sang tiếng Anh.

Processor đi kèm checkpoint làm phần đổi audio thành đặc trưng và đổi token thành chữ; thay nó bằng log-mel tự tính theo bài 7.1 sẽ lệch thang số so với lúc model được train.

Decoder mang kiến thức ngôn ngữ nên chọn được câu có nghĩa khi âm mờ, nhưng cũng có thể sinh chữ không có trong âm thanh. Với clip im lặng hoặc toàn nhiễu, nó vẫn sinh một chuỗi nào đó, đôi khi lặp một cụm nhiều lần. Model card Whisper ghi rõ hiện tượng hallucination này và chênh lệch chất lượng giữa các ngôn ngữ, miền dữ liệu; độ chính xác trên bản ghi tiếng Việt của một bài thi chỉ biết được khi chấm trên validation của chính bài đó.

Các model audio-language như Qwen2-Audio và Voxtral nhận thêm chỉ dẫn bằng chữ để trả lời về âm thanh. Chỉ dẫn “chép nguyên lời nói” và “tóm tắt clip” cho hai output khác hẳn nhau, nên prompt phải khớp task của đề. Processor, chat template và checkpoint đi theo từng kiến trúc và phiên bản thư viện: đổi tên model trong một script viết cho Whisper không làm script ấy chạy được với kiến trúc khác.

Hỏi nhanh

Với một clip tiếng Việt, Whisper trả về câu tiếng Anh trôi chảy, đúng ý người nói. WER so với reference tiếng Việt gần bằng 1. Nên kiểm gì trước?

Whisper đưa audio về 30 giây, 3000 frame log-mel, 1500 vị trí encoder.audio16 kHzpad / cắt30 slog-mel3000 frameencoder1500 vị trí<|startoftranscript|><|vi|><|transcribe|>em học AI hôm naydecoder bắt đầu bằng dãy token chọn ngôn ngữ và việc cần làm
Whisper đưa audio về 30 giây, 3000 frame log-mel, 1500 vị trí encoder.

WER: chấm transcript bằng edit distance

Một câu dự đoán có thể thiếu từ, thêm từ hoặc thay từ. Word error rate là

WER⁡=S+D+INref,\operatorname{WER}=\frac{S+D+I}{N_{\mathrm{ref}}},

với SS là số substitution, DD deletion, II insertion trong một alignment tối ưu, và NrefN_{\mathrm{ref}} là số token tham chiếu. Ta tìm tổng lỗi nhỏ nhất bằng Levenshtein distance, giống dynamic programming chuỗi trong lập trình thi đấu. Hàng biểu diễn prefix của reference, cột biểu diễn prefix của prediction; mỗi ô chọn xóa, chèn, thay hoặc giữ token.

Trong code dưới, token là phần tách bởi khoảng trắng sau khi đưa Unicode về dạng NFC và chuyển chữ thường. Bước NFC đã gặp ở bài 6.1: cùng một chữ có dấu như ọ có thể lưu theo hai cách, hiển thị giống hệt nhau nhưng so chuỗi thì khác. Tách khoảng trắng cũng không phải tách từ theo ngôn ngữ học: “lập trình” là một từ nhưng hai token.

Khi chấm thật, tokenizer và cách chuẩn hóa (chữ hoa, dấu câu, số, Unicode) phải theo đúng scorer của đề và áp dụng cho cả reference lẫn prediction; tự bỏ dấu tiếng Việt để điểm đẹp hơn là đổi metric.

Character error rate (CER) dùng cùng công thức trên ký tự; một từ sai một dấu bị CER phạt một ký tự, còn WER phạt cả từ. Câu đúng 10 từ, bản nhận dạng thay 1, thiếu 1, thừa 1 từ có WER 3/10=0.33/10 = 0.3. OCR được chấm cùng cách: “xin chào” đọc thành “xin chao” sai 1 trên 8 ký tự, CER 0.125. Với tiếng Việt, dấu thanh và dấu phụ chỉ là vài pixel nên OCR dễ nhầm, mà sai một dấu là đổi nghĩa.

Python
import unicodedata

def word_tokens(text):
    return unicodedata.normalize("NFC", text).lower().split()

def edit_distance(reference_tokens, predicted_tokens):
    previous = list(range(len(predicted_tokens) + 1))
    for row, reference_token in enumerate(reference_tokens, start=1):
        current = [row]
        for column, predicted_token in enumerate(predicted_tokens, start=1):
            deletion = previous[column] + 1
            insertion = current[column - 1] + 1
            substitution = previous[column - 1] + (reference_token != predicted_token)
            current.append(min(deletion, insertion, substitution))
        previous = current
    return previous[-1]

reference = word_tokens("em học AI hôm nay")
prediction = word_tokens("em học toán nay")
errors = edit_distance(reference, prediction)
print(f"lỗi = {errors}, token tham chiếu = {len(reference)}")
print(f"WER = {errors / len(reference):.3f}")
Một thay thế, một xóa trên 5 từ: WER 0.4.referencepredictionememhọchọcaitoánShômDnaynayWER = (S + D + I) / N = (1 + 1 + 0) / 5 = 0.4
Một thay thế, một xóa trên 5 từ: WER 0.4.

Tính tay: thay ai bằng toán, xóa hôm, giữ em học ... nay: hai lỗi trên năm token, WER 0.4. DP chỉ giữ hai hàng nên bộ nhớ O(∣prediction∣)O(|\text{prediction}|), thời gian O(NrefNpred)O(N_{\mathrm{ref}}N_{\mathrm{pred}}).

Muốn chỉ rõ từng lỗi, lưu backpointer để khôi phục một alignment tối ưu; có thể có nhiều alignment cùng tổng lỗi. Số lỗi chèn có thể nhiều hơn số từ tham chiếu, nên WER có thể lớn hơn 1; nó không phải accuracy nằm trong [0,1][0,1].

Bảng edit distance; đường tô màu là một cách ghép tối ưu.012341012321012321124322254332emhọctoánnayemhọcaihômnaypredictionreference← distance 2
Bảng edit distance; đường tô màu là một cách ghép tối ưu.

Hỏi nhanh

Reference có 4 token. Prediction giữ đúng cả 4 token ấy và chèn thêm 5 token. WER bằng bao nhiêu?

Hỏi nhanh

Reference là mai thi, prediction là thi đấu. Hai đội cài cùng một DP, nhưng phá hòa khác nhau khi lần backpointer. Một đội báo 2 substitution, đội kia báo 1 deletion và 1 insertion. WER trong hai báo cáo là bao nhiêu?

Gộp toàn tập, rồi đọc loại lỗi

Corpus WER cộng tổng số lỗi của các câu rồi chia tổng số token tham chiếu. Nó không bằng trung bình đơn giản WER từng câu. Một câu ngắn sai một từ và một câu dài đúng hết không được cân bằng nhau nếu metric là corpus WER.

Python
references = ["em học AI", "mai lớp em học lập trình thi đấu"]
predictions = ["em học", "mai lớp em học lập trình thi đấu"]
error_counts = [edit_distance(word_tokens(r), word_tokens(p)) for r, p in zip(references, predictions)]
lengths = [len(word_tokens(r)) for r in references]
corpus_wer = sum(error_counts) / sum(lengths)
mean_sentence_wer = np.mean([e / n for e, n in zip(error_counts, lengths)])
print(error_counts, lengths)
print(f"corpus WER = {corpus_wer:.3f}")
print(f"mean sentence WER = {mean_sentence_wer:.3f}")

Một lỗi trên 3+8=113+8=11 token cho khoảng 0.091; trung bình (1/3+0)/2(1/3+0)/2 khoảng 0.167. Corpus WER cân theo số token, còn trung bình câu cân mỗi câu như nhau nên câu ngắn nặng ký hơn. Đề ghi metric nào thì chọn model theo đúng metric đó.

Corpus WER 0.091; trung bình WER từng câu 0.167.emhọcAImailớpemhọclậptrìnhthiđấusai 1 / 3đúng 8 / 8corpus1/11 = 0.091trung bình câu(1/3 + 0)/2 = 0.167
Corpus WER 0.091; trung bình WER từng câu 0.167.

Hỏi nhanh

Validation có một câu 4 token và một câu 36 token. Model A sai 2 token ở câu ngắn, đúng hết câu dài. Model B đúng hết câu ngắn, sai 6 token ở câu dài. Corpus WER và trung bình WER từng câu chọn model nào?

Clip có reference rỗng, như đoạn im lặng hay chỉ có nhạc, làm WER của riêng câu đó chia cho 0. Corpus WER vẫn tính được khi tổng token reference dương: mọi từ model sinh cho clip ấy là insertion cộng vào tử số. Vì vậy không âm thầm bỏ các clip này khỏi validation; đó chính là chỗ decoder dễ sinh chữ không có trong âm thanh.

Ngoài điểm tổng, chia lỗi theo người nói, thời lượng, tiếng nền, tên riêng và số. Một model có WER thấp hơn vẫn có thể chép sai số phòng hay ngày giờ mà ứng dụng cần. Đọc vài alignment S/D/I là cách nối metric với lỗi thật trước khi đổi checkpoint hay tăng beam size.

Hỏi nhanh

Ba clip có tổng cộng 20 token reference và 2 lỗi. Clip thứ tư im lặng, reference rỗng, nhưng model sinh ra 4 từ “cảm ơn đã xem”. Corpus WER của cả bốn clip bằng bao nhiêu?

Nối ASR với language model: đo trước và sau

Có thể dùng language model thêm dấu câu hoặc sửa lỗi nghe, nhưng mục tiêu vẫn là giữ đúng nội dung bản ghi. Câu trôi chảy hơn không đồng nghĩa WER thấp hơn. Xét một trường hợp: reference và transcript gốc đều là mai thi ai; bước “sửa” đổi nó thành mai thi toán, một câu cũng rất tự nhiên. Đưa cả hai transcript qua cùng scorer:

Python
reference_text = "mai thi ai"
original_text = "mai thi ai"
edited_text = "mai thi toán"
reference_tokens = word_tokens(reference_text)
for name, text in [("gốc", original_text), ("sửa", edited_text)]:
    error_count = edit_distance(reference_tokens, word_tokens(text))
    print(f"{name}: WER = {error_count / len(reference_tokens):.3f}")

Hậu xử lý làm WER từ 0 lên 0.333 vì đổi tên môn.

Khi thử một prompt sửa ASR, giữ transcript gốc, lưu phiên bản prompt và model, rồi so cặp trước và sau trên cùng validation. Chỉ dẫn hẹp như “giữ nguyên từ, chỉ thêm dấu câu” hợp khi đó đúng là nhiệm vụ, nhưng vẫn phải kiểm vì model có thể không tuân thủ. Scorer bỏ dấu câu thì thêm dấu câu không làm WER tốt hơn.

Hỏi nhanh

Reference là phòng mười lăm. ASR ra phòng mười năm, rồi language model sửa thành phòng 15, đúng ý người nói. Scorer viết thường, tách theo khoảng trắng, và không chuẩn hóa số. WER trước và sau khi sửa là bao nhiêu?

Chạy Whisper trên dữ liệu thật

Script của chương đưa quy trình trên vào lệnh local_audio.py --task asr. Đầu vào là một thư mục checkpoint Whisper có sẵn trên máy và một tệp JSON liệt kê dữ liệu; mỗi bản ghi gồm ID, đường dẫn WAV, reference, nhóm (người nói hoặc phiên thu) và split. Hai bước:

  1. --check kiểm từng file, loại model, processor, sample rate và hash mà không nạp trọng số.
  2. --run nạp model bằng local_files_only=True, chép lời từng clip validation trên CPU với task="transcribe" và ngôn ngữ đã chọn, rồi ghi tệp kết quả receipt.json: transcript, số lỗi và số token reference của từng clip, cờ clip im lặng, thời gian suy luận và corpus WER.

Scorer của script là chữ thường cộng tách khoảng trắng; đề chuẩn hóa khác thì sửa scorer trước khi so model. Đọc kết quả theo thứ tự: corpus WER, rồi các clip nhiều lỗi nhất và các clip im lặng có chữ, rồi thời gian suy luận mỗi clip để ước lượng thời gian chạy hết tập test.

Script chỉ nhận clip không quá 30 giây, đúng một cửa sổ của Whisper, và dừng kèm thông báo khi gặp clip dài hơn. Clip dài cần chia đoạn: cắt theo khoảng lặng bằng voice activity detection (VAD), hoặc cắt cửa sổ có phần chồng nhau, giữ timestamp của từng đoạn rồi mới ghép transcript. Nối chuỗi mù các đoạn có phần chồng sẽ chép hai lần các từ trong vùng chồng; cắt không chồng lại dễ cắt đôi một từ ở ranh giới. Sau khi ghép, đọc alignment quanh các ranh giới trên validation.

Hỏi nhanh

Trên một clip 30 giây, Whisper chép mất 6 giây. Test có 1500 clip, mỗi clip 3 giây. Một bạn ước thời gian là 1500⋅6⋅3/30=9001500\cdot 6\cdot 3/30 = 900 giây. Ước lượng này sai ở đâu?

Clip 3 giây vẫn được pad lên 30 giây, nên thời gian chạy giảm ít.clip 30 s1500 vị tríclip 3 spad 27 s1500 vị tríencoder làm cùng một lượng tính; chỉ decoder ngắn đi
Clip 3 giây vẫn được pad lên 30 giây, nên thời gian chạy giảm ít.

Khi đi thi

  • Viết scorer local cho ra đúng điểm trên ví dụ của đề: chữ hoa, dấu câu, số được chuẩn hóa thế nào.
  • Đo thời gian trên vài clip giống test rồi nhân lên; Whisper pad mọi clip lên 30 giây.
  • Train CTC thì kiểm độ dài trước: mỗi mẫu cần đủ frame sau encoder, loss inf là lỗi dữ liệu.

Tóm tắt

  • ASR nối chuỗi âm dài với transcript ngắn khi chưa có alignment. CTC cộng mọi alignment có blank; encoder-decoder sinh từng token theo ngữ cảnh, theo task và ngôn ngữ đã chỉ định.
  • Collapse gộp nhãn lặp liền kề rồi mới bỏ blank. Path tốt nhất của greedy chưa chắc cho transcript có tổng xác suất lớn nhất.
  • Truy hồi α\alpha trên chuỗi mở rộng dài 2U+12U+1 cộng mọi path trong O(TU)O(TU) và chạy trong log-space; target cần ít nhất U+rU+r bước sau encoder.
  • WER là edit distance chia số token reference, có thể lớn hơn 1. Normalization theo đúng scorer; corpus WER gộp lỗi và token, khác trung bình từng câu, và không bỏ clip im lặng.
  • Hậu xử lý bằng language model và chunking clip dài đều phải được chấm trước và sau trên cùng validation.

Kiểm tra cuối bài

Câu 1

Đề yêu cầu transcript cho từng clip. Baseline của đội chỉ trả nhãn có tiếng nói hoặc không tiếng nói cho mỗi clip. Phần nào chưa đáp ứng nhiệm vụ?

Câu 2

CTC path là a a _ b b _ b, trong đó _ là blank. Gộp các nhãn lặp liền nhau trước, rồi bỏ blank, thì được transcript nào?

Câu 3

Train một model CTC từ đầu. Sau 50 epoch, loss vẫn dao động mạnh, và thỉnh thoảng có batch cho inf. Nên chẩn đoán theo thứ tự nào trước?

Phỏng theoNOAI 2026 Singapore · Vòng sơ loại · Câu 282

Câu 4

Log-mel có 100 frame mỗi giây. Đội tăng tổng stride của encoder từ 4 lên 8 để train nhanh hơn. Một clip 2.4 giây có transcript 29 ký tự, trong đó có 2 chỗ hai ký tự giống nhau đứng liền nhau. Với stride 8, CTC còn train được trên clip này không?

Phỏng theoCMU 10-601 · Exam 3 Practice · Q1.1(b)

Câu 5

Model trả về logits shape (B, T, C) = (4, 50, 30), chưa qua softmax. Đối số đầu tiên đúng của nn.CTCLoss là gì?

loss = ctc(X, targets, input_lengths, target_lengths)

Câu 6

Reference có 12 token. Alignment tối ưu có 1 substitution, 2 deletion và 3 insertion. WER bằng bao nhiêu?

Câu 7

Reference là em học ai, prediction là em hoc ai, thiếu dấu nặng. Tính WER theo từ, và CER theo ký tự Unicode dạng NFC, kể cả dấu cách.

Câu 8

Một clip có 8 giây nói rồi 20 giây im lặng. Whisper chép đúng phần nói, rồi lặp lại câu cuối thêm ba lần. Nên xử lý thế nào?

Câu 9

Sau khi hậu xử lý bằng language model, câu trôi chảy hơn, nhưng tên riêng và số đôi khi bị đổi. Đánh giá thế nào mới kiểm đúng mục tiêu của ASR?

Câu 10

Tập test có 1500 clip. Trên 100 clip validation có cùng phân bố độ dài, model mất tổng cộng 400 giây trên đúng máy thi. Còn 90 phút. Chạy hết test cần khoảng bao lâu, và nên làm gì?

Đọc thêm

Tải gói recipe của chương - gồm các module phụ và hướng dẫn chuẩn bị asset.