基於CTC的語音識別基礎與實現

項目地址: github.com/Diamondfan/C

0、語音識別過程

首先明確語音識別的任務是怎樣的。輸入input是音頻wav文件,保存的一般是經過抽樣量化編碼之後數字信號,也就是每個樣點的值,即我們經常看到的波形序列(圖1的cat的波形)。輸出是文字序列,代表這段音頻的內容。很顯然,按照現在對深度學習任務的劃分,這是一個Sequence-to-Sequence的問題。也可以理解為是一個序列標註的問題。該問題與機器翻譯,連續手寫數字體識別類似,可以劃分到一類。

圖1 語音識別過程組成部分

但是語音識別的問題遠沒有這麼簡單。想像一下如果人在聽一句話的時候,如果這句話具有很強的領域性,在沒有相關領域的知識情況下,可能很難得到這句話正確的內容。比如某個詞你沒有學過,你可能能複述發音,但是是無法書寫出來並且理解的。所以只有wav文件的信息是不夠的,需要語言學的先驗知識,所以語言模型(Language Model)在語音識別的過程中是必不可少的,而對wav作為輸入得到的模型叫做聲學模型(Acoustic Model)。另外在傳統的語音識別過程中,聲學模型的輸出單元一般為音素或者是音素的狀態,而語言模型一般是詞級別的語言模型,兩者的聯合解碼(也就是一般的測試推斷過程)時需要知道每個詞(word)是由哪些音素(phoneme)組成的,也就是這個詞是怎麼發音的。所以中間需要一個發音詞典,一般也被叫做音素模型(Phoneme Model)。

這是對語音識別直觀的理解上分析的結果,從公式中得到的結果也是一樣的。假設輸入的音頻序列為 O = {O_{1}, O_{2}, ..., O_{n}} , 輸出為文本序列為 W = {w_{1}, w_{2}, ..., w_{n}} 。我們的目的是構建一個模型,使得 prod_{n}P(W_{n}|O_{n}) 最大,也就是訓練集中n個樣本的後驗概率最大,為什麼是後驗呢,因為人在說話的時候是想好了W,然後產生O的,所以W是因,O是果。單獨拿出一個樣本的後驗概率使用貝葉斯公式可以得到:

P(W|O) = frac{P(O|W) * P(W)}{P(O)}

通常在進行解碼(或者說推斷)的時候輸入O是保持不變的,目的是找到一個W使得後驗概率最大,所以我們忽略分母項。再看分子,P(W)是輸出詞序列的概率,用語言模型來刻畫,P(O|W)為似然概率,用聲學模型來刻畫。所以語音識別問題用公式來表示就是:

W^{*} = argmax_{W}{ P(O|W)*P(W)}

看到這裡,有人會問PM模型去哪兒了。我們接著分析,因為輸入的音頻一幀大概只有20-30ms,其信息不能覆蓋一個詞,甚至一個字元覆蓋不了,所以一般輸出的建模單元為音素或者音素的狀態,這裡以音素為例,假設音素序列為 Q = {q_{1}, q_{2}, .. ,q{n}} ,那麼聲學模型部分的概率可以進一步化簡為:

P(O|W) = sum_{Q}{P(O, Q|W)} = sum_{Q}{P(O|Q, W) * P(Q|W)} approx sum_{Q}{P(O|Q) * P(Q|W)}

化簡後的P(O|Q)就是真正意義的聲學模型,P(Q|W)表示在詞序列條件下的音素序列的概率,這不就是發音詞典嘛。所以整個過程又通過公式的方式理順了。

下文要講的CTC模型只是對P(O|Q)進行建模,通過貝葉斯公式,很容易把P(O|Q)與判別模型的CTC網路的輸出聯繫起來。這個就不多說了。

1、基於CTC的聲學模型

1.1 什麼是CTC

網上關於CTC Loss的介紹有很多,這裡就不再詳細的介紹。想要仔細探究公式及原理的可以去看Alex Graves的《Supervised Sequence Labelling with Recurrent Neural Networks》一書,很容易下載得到。當然他的很多論文也介紹了CTC, 但是都沒有此書介紹的詳細。

簡單介紹一下CTC所解決的問題。在Sequence-to-Sequence的任務中,以輸入輸出的長度不同我們會遇到以下幾種情況:

  • 輸入長度 = 輸出長度 : 比如詞性標註等,這類問題很好解決,直接每個時刻softmax計算交叉熵 作為損失函數
  • 輸入長度 ? 輸出長度 : 比如機器翻譯問題,解決辦法是decoder一個輸出一個輸出的往外蹦,同樣可以用sotfmax然後計算交叉熵
  • 輸入長度 > 輸出長度 : 這種情況當然也是上述情況的一種,但是這種情況還有另外一種解決辦法,就是使用CTC-LOSS

語音識別問題的輸入長度是遠大於輸出長度的,這是因為語音信號的非平穩性決定的,我們只能做短時傅里葉變換,這就造成了一個句子會有很多幀,即很多時刻。而標籤(輸出詞序列)中的一個詞往往對應了好幾幀。最後輸出的長度會遠小於輸入的長度。CTC就是為了解決這個問題。

CTC是怎麼做的呢?如果不考慮標籤的話,使用RNN,每幀語音都會有一個輸出,比如輸入是200幀,輸出有20個詞。這樣就會得到200個輸出序列,這200個輸出序列如何與標籤的20詞計算loss的呢?首先,在多對少的映射中,我們很容易想到應該會有很多重複的詞,把這些詞去掉就行了,然後因為幀長很短,有些幀的輸出沒有任何意義,可能只包含靜音。所以CTC增加了一個blank標籤,也就是每幀softmax的時候增加一個類別。最後CTC的映射規則就出來了,200->20,去blank+去重。規則出來了,就可以設計演算法進行loss的計算了。其計算過程類似於HMM中的前向後向演算法,詳細的演算法過程可以翻看參考文獻中的論文。這裡只介紹Loss function和LSTM每個時刻的輸出關係。

假設輸出序列為 π = {π_{1}, π_{2}, ... , π_{n}} ,ground truth即標籤為序列為 l = {l_{1}, l_{2}, ... , l_{m}} ,m<n, l與π的映射規則為 π = F(l) , 每幀的輸出概率用y表示。 y_{π_{t}}^{t} 表示第t時刻輸出 π_{t} 的概率。則輸出序列為π的概率和網路的輸出概率之間的關係為: p(π|x) = prod_{t=1}^{T}{y_{π_{t}}^{t}} ,映射到標籤的概率為: p(l|x) = sum_{πin F^{-1}(l)}{p(π|x)} 。即所有能映射到l序列的π序列之和。p(l|x)就是單個樣本的loss。這樣就建立了loss和網路輸出之間的關係。可以看到,這個loss的計算和loss對輸出值導數的計算是比較麻煩的,所以就有了前向後向演算法。這裡就不詳細介紹了。

綜上,CTC只是一個解決輸出長度小於輸入長度的損失函數(loss function)的計算問題的一種方法。

圖2 CTC前向後向計算

1.2 如何在pytorch中實現CTC Loss

在pytorch中官方是沒有實現CTC-loss的,要寫一個自己的loss在pytorch中也很好實現,只要使用Variable的輸出進行運算即可,這樣得到的loss也是Variable類型,同時還保存了其梯度。但是CTCloss的計算需要將每一個輸出神經元值進行單獨的計算,使用前向後向演算法來計算最終的loss。所以無法得到一個Variable變數的loss,這個時候就需要在torch.autograd.Function上寫一個派生類,複寫forward函數和backward函數,完成數據的前向傳播和梯度的反向傳播過程。而因為效率的影響,一般會在C或者C++中實現計算過程。然後使用pytorch中的torch.utils.ffi.create_extension對C代碼進行編程生成動態鏈接庫,最後使用python調用計算過程,完成整個loss的實現。

如果沒有明白的話,沒有關係。百度的warp-ctc幫我們實現了這CTC的計算過程

github.com/baidu-resear

同樣,也有人幫我們完成了warp-ctc與pytorch的binding工作。如果想自己體驗一下與pytorch的binding過程,也可以換別的方式進行。詳細的過程看README文件即可。

github.com/SeanNaren/wa

1.3 網路結構

考慮到輸入是一個序列,對時間的建模是必不可少的,所以RNN的結構必須有,這也是能夠替代HMM的一個很重要的模型。輸出需要映射到輸出的類別上,所以至少一層的前饋層是需要的。鑒於語音信號的多樣性,前端使用CNN進行特徵的預處理也是可選的。所以網路結構選定為 CNN -》 RNN -》 FC -》 CTC。輸入是語音的頻譜圖,輸出是音素的類別或者是字的類別,決定於自己要構建一個什麼級別的聲學模型。結構圖如下圖所示(圖示為一個character-level的AM):

圖3 Spectrum->CNN->LSTM->FC->CTC

這裡僅僅解釋一下在實現過程中的兩個類(在ctc_model.py中)。

62 class BatchRNN(nn.Module):n 63 """n 64 Add BatchNorm before rnn to generate a batchrnn layern 65 """n 66 def __init__(self, input_size, hidden_size, rnn_type=nn.LSTM,n 67 bidirectional=False, batch_norm=True, dropout=0.1):n 68 super(BatchRNN, self).__init__()n 69 self.input_size = input_sizen 70 self.hidden_size = hidden_sizen 71 self.bidirectional = bidirectionaln 72 self.batch_norm = SequenceWise(nn.BatchNorm1d(input_size)) if batch_norm else Nonen 73 self.rnn = rnn_type(input_size=input_size, hidden_size=hidden_size,n 74 bidirectional=bidirectional, dropout = dropout, bias=False)n 75 n 76 def forward(self, x):n 77 if self.batch_norm is not None:n 78 x = self.batch_norm(x)n 79 x, _ = self.rnn(x)n 80 return x n

這個類的目的是在RNN的層間加入Batch Normalization。

17 class SequenceWise(nn.Module):n 18 def __init__(self, module):n 19 super(SequenceWise, self).__init__()n 20 self.module = modulen 21 n 22 def forward(self, x):n 23 n 24 two kinds of inputs: n 25 when add cnn, the inputs are regular matrixn 26 when only use lstm, the inputs are PackedSequencen 27 n 28 try:n 29 x, batch_size_len = x.data, x.batch_sizesn 30 #print(x)n 31 #x.data: sum(x_len) * num_featuresn 32 x = self.module(x)n 33 x = nn.utils.rnn.PackedSequence(x, batch_size_len)n 34 except:n 35 t, n = x.size(0), x.size(1)n 36 x = x.view(t*n, -1)n 37 x = self.module(x)n 38 x = x.view(t, n, -1)n 39 return xn 40 n 41 def __repr__(self):n 42 tmpstr = self.__class__.__name__ + (nn 43 tmpstr += self.module.__repr__()n 44 tmpstr += )n 45 return tmpstrn

這個類的目的是為了考慮加入CNN和不加入CNN的時候輸入的數據格式是不同的。不加入CNN的話,輸入的數據是pack成packedSequence輸入到RNN中的。

2、語言模型

目前使用kenlm(github.com/kpu/kenlm)訓練bi-gram語言模型。bi-gram表示當前時刻的輸出概率只與前一個時刻有關。即

P(X_{n} | X_{n-1},...,X_{1}) = P(X_{n} | X_{n-1})

當然,更為準確的是忽略馬爾科夫假設,使用RNNLM可以很好地解決這個問題。pytorch官網中也有關於RNNLM的example:

github.com/pytorch/exam

後續的計劃也打算將2-gram替換為RNNLM。語言模型結構如下圖所示,一個LSTM就能搞定,輸入是一個SOS起始符號+sequence,輸出是從第二個詞開始到末尾加上EOS終止符。既然輸入是文本的話,就少不了對文本的表示。就是在nlp中常用的詞向量。也就是說語言模型的輸入是每個詞的詞向量表示,輸出仍然是分類問題。

每個時刻的輸出概率也很明確,表示的是 P(w_{n}|w_{n-1}, ..w_{1}, start)。這個輸出概率也就是語言模型的概率。

圖4 RNN for language model

3、解碼(測試test過程)

以上均為訓練過程,訓練完了聲學模型和語言模型就到了測試的過程。也就是本節的解碼過程,也可以稱為推斷過程。與傳統的深度學習任務不同,語音識別的解碼是一個很複雜的搜索過程。用公式表示為: w^{*} = argmax_{w}{(logP(w|o)} + λlogP_{LM}(w) + γlen(w))

λ是語言模型的權重,λ越大表示越依賴語言模型。正常的想法是遍歷所有可能詞序列找到概率最大的那個座位輸出結果,但是計算量太大。所以就有了各種優化的演算法。比如WFST,Beam Search等。

下面由易到難分別介紹幾種解碼(測試)的方法。

  • Greedy Decode : 也被稱為best-path-decode, 將每個時刻輸出概率最大的類別作為推斷結果,然後進行前文所提到的去重複去blank的映射規則得到最後的結果,即為測試結果。這個方法是最簡單的,也是最不耗時的,沒有搜索的過程 當然結果也是最粗糙的。
  • Beam Search Decode: 考慮到best-path得到的概率並不一定最大的,於是我們就要用到維特比解碼的動態規劃演算法,考慮到演算法的效率,加上剪枝操作,所以稱為Beam Search Decode。演算法的詳細過程參見文獻[3].具體思想就是在上一時刻的剪枝結果上加上本時刻的輸出判斷所有可能出現的序列的概率,保留前Beam=n個結果,繼續下一個時刻,最後得到概率最大的輸出序列即為解碼的結果。
  • Beam Search with LM and PM:加入LM和PM的解碼將充分的結合語言學和語音學的先驗知識。文獻【3】中也講到了如何加入詞級語言模型進行解碼。本項目曾用python實現,但是效率太低,一句話解碼超過20s。目前比較好的做法是使用WFST構建加權有限狀態機進行解碼。該方法目前在kaldi系統中應用比較成熟。目前尚未在項目中實現。另外一個做法是可以使用字級別的聲學模型和字級別的語言模型,這樣不需要PM的參與,能夠使得Beam Search演算法有更高的效率和準確率。本項目中也是使用這種方法。使用的語言模型和聲學模型的輸出單元是統一級別的。

4、趨勢

語音識別框架的趨勢是End-to-End,也就是未來希望是不需要進行聯合解碼,直接聲學模型和語言模型是一塊訓練,一塊識別的。這部分的工作研究也有很多,有興趣的可以讀一讀相關的論文。

RNN-Tranducer:

Graves A. Sequence Transduction with Recurrent Neural Networks[J]. Computer Science, 2012, 58(3):235-242.

Sequence-to-Sequence:

arxiv.org/abs/1712.0176

5、參考文獻

[1]Graves A. Supervised Sequence Labelling with Recurrent Neural Networks[M]. Springer Berlin Heidelberg, 2012.

[2]Graves A, Gomez F. Connectionist temporal classification:labelling unsegmented sequence data with recurrent neural networks[C]// International Conference on Machine Learning. ACM, 2006:369-376.

[3]Graves A, Jaitly N. Towards end-to-end speech recognition with recurrent neural networks[C]// International Conference on Machine Learning. 2014:1764-1772.


推薦閱讀:

AssemblyAI 想讓人人都能做定製化語音識別,雖然他們只有三個人
kaldi triphone decision tree 訓練生成的tree結構是怎樣的?
研一剛接觸語音識別,怎麼運用kaldi工具箱做一個baseline?
聽力時,在距離音源較近的位置用較低的聲音播放,或在距離較遠的位置播放較大的聲音,分辨能力是否近似?
誰給講講語音識別中的CTC方法的基本原理?

TAG:语音识别 | PyTorch | 深度学习DeepLearning |