Eesen的CTC框架
- 准备输入feature:利用kaldi的工具提取
- 准备输出label数据
- phone text根据字典变为phone序列
- charactor/grapheme 单词拆分成,增加space
- 利用kaldi nnet1框架,实现CTC损失函数,直接训练神经网络的声学模型
- extract_net_output得到feature通过网络的输出
- 构建 TLG解码图,T可以是phone或者char
- 利用lattice decoder解码输出,kaldi中HCLG fst的input是transition-id,eesen中是token-id.
其中第3步和第4步也可以使用其他框架实现, 我实现了一个pytorch版本,可以复现eesen中的wsj数据集上的结果。
Eesen中CTC的训练
Eesen中CTC的实现和原始论文的推导很接近,没做什么优化,很适合用来学习理解CTC。
但是Eesen的CTC实现在梯度的计算上,没有直接使用论文中最后得到softmax前的a值的导数公式,而是先求出softmax后的y值的导数,再求出a值的导数,因此和原论文的公式并不相同。
本文主要对此进行解释。
src/net/ctc-loss.cc
文件中Ctc::Eval和Ctc::EvalParallel都是计算CTC损失对softmax前的a值的导数,区别是前者计算单个样本,后者计算一组(Batch)样本。
下面仅对Ctc::Eval进行说明
CTC的前向后向计算
在首尾和字符间插入blank,(l
变为l'
),如abbc变为-a-b-b-c-,长度n
变为L=2n+1
前后向算法是在log域上进行计算的,对NN的softmax输出值取log
计算前向alpha和后向beta值
ComputeCtcAlpha和ComputeCtcBeta分别计算前向和后向值,算法类似,本文只讨论前者。ComputeCtcAlpha会调用_compute_ctc_alpha_one_sequence(位于/src/gpucompute/cuda-kernels.cu
)完成实际计算。
参考论文公式
\[\alpha_{t}(s)=\left\{\begin{array}{ll}
{\bar{\alpha}_{t}(s) y_{1_{s}^{\prime}}^{t}} & {\text { if } \mathrm{l}_{s}^{\prime}=b \text { or } \mathrm{l}_{s-2}^{\prime}=\mathrm{l}_{s}^{\prime}} \\
{\left(\bar{\alpha}_{t}(s)+\alpha_{t-1}(s-2)\right) y_{1_{s}^{\prime}}^{t}} & {\text { otherwise }}
\end{array}\right.\]
其中
\[\bar{\alpha}_{t}(s) \stackrel{\text { def }}{=} \alpha_{t-1}(s)+\alpha_{t-1}(s-1)\]
很容易理解上述代码逻辑。
这里涉及CTC里针对CTC前向计算的cuda kernel的设计,但是和CTC的算法本身无关,这里先跳过,本文后面章节会介绍cuda相关的知识
计算条件似然函数
接着根据公式
\(Z=p(\mathbf{l} | \mathbf{x})=\alpha(T,L)+\alpha(T,L-1)\)
计算当前模型下条件似然的值
这里alpha和beta都是log域的量.已知\(\log{(a)}\)和\(\log{(b)}\),求\(\log{(a+b)}\)
\(\begin{aligned}
log(a + b) &= log(a) + log(1 + \exp(\log(b)-\log(a))), &\mbox{if}\ &b < a \\
&= log(b) + log(1 + \exp(\log(a)-\log(b))), &\mbox{if}\ &a < b
\end{aligned}\)
所以pzx = tmp1 + log(1 + ExpA(tmp2 - tmp1))
等价于log(exp(tmp1)+exp(tmp2))
.
CTC的反向传播导数
t时刻的输出softmax函数为
\[p(y_k^t) = \frac{\exp(a_k^t)}{\sum_{k'}^K{\exp{a_{k'}^t}}}\]
原始论文中公式(16)直接有a值的导数公式:
\[\frac{\partial -\ln p(\mathbf{l}|\mathbf{x})}{\partial a_{k}^{t}} =y_{k}^{t}-\frac{1}{y_{k}^{t} p(\mathbf{l} | \mathbf{x})} \sum_{s \in l a b(\mathbf{z}, k)} \hat{\alpha}_{t}(s) \hat{\beta}_{t}(s)\]
但是eesen中没有使用该公式,而是先计算y值(softmax之后)的导数,再计算a值(softmax之前)的导数.
softmax后y值的导数
梯度法求的是极小值,因此最大化似然等价于最小化似然的负数,从而目标是求-p(l|x)
对a的导数。整体代码如下:
一步步看,先看
首先计算softmax后y值的导数,保存在ctc_err_中。ctc_err_是一个T行K列的矩阵, T是序列长度,K是output个数, 则ctc_err_矩阵中第t行k列的值为
\[\begin{aligned}
E_{k}^{t} & =\frac{\partial -\ln (p(\mathbf{l} | \mathbf{x}))}{\partial y_{k}^{t}} \\
& =-\frac{1}{p(\mathbf{l} | \mathbf{x})} \frac{\partial p(\mathbf{l} | \mathbf{x})}{\partial y_{k}^{t}} \\
\end{aligned}\]
已知论文中的公式
\[\frac{\partial p(\mathbf{l} | \mathbf{x})}{\partial y_{k}^{t}}=\frac{1}{(y_{k}^{t})^2} \sum_{s \in lab(1, k)} \alpha_{t}(s) \beta_{t}(s)\]
所以
\[\begin{aligned}
E_{k}^{t}
& =- \frac{1}{p(\mathbf{l} | \mathbf{x})} \frac{1}{(y_{k}^{t})^2} \sum_{s \in lab(1, k)} \alpha_{t}(s) \beta_{t}(s) \\
& =- \exp{\left\{\log\left\{\sum_{s \in lab(1, k)} \alpha_{t}(s) \beta_{t}(s))\right\}-\log{p(\mathbf{l} | \mathbf{x})} -2\log{y_{k}^{t}} \right\}} \\
\end{aligned}\]
ComputeCtcError会调用_compute_ctc_error_one_sequence(位于/src/gpucompute/cuda-kernels.cu
),其计算过程和上式最后一行完全一致。
softmax前a值的导数
接着计算softmax之前a值的导数。我们先推导出计算公式:
\[\frac{\partial -\ln p(\mathbf{l}|\mathbf{x})}{\partial a_{k}^{t}}
= \sum_{k^{\prime}}^{K}{\frac{\partial -\ln p(\mathbf{l}|\mathbf{x})}{\partial y_{k^{\prime}}^{t}} \frac{\partial y_{k^{\prime}}^{t}}{\partial a_{k}^{t}}}
=\sum_{k^{\prime}}^{K} {E_{k^{\prime}}^{t} \cdot \frac{\partial y_{k^{\prime}}^{t}}{\partial a_{k}^{t}}}\]
其中softmax的输入y对输出a的导数
\[\frac{\partial y_{k^{\prime}}^{t}}{\partial a_{k}^{t}} = y_{k^{\prime}}^{t} \frac{ \partial \ln y_{k^{\prime}}^{t}}{\partial a_{k}^{t}} = y_{k^{\prime}}^{t} (\delta_{k k^{\prime}} - y_{k}^{t} )\]
从而
\[\begin{aligned}
\frac{\partial -\ln p(\mathbf{l}|\mathbf{x})}{\partial a_{k}^{t}}
& = \sum_{k^{\prime}}^{K} {E_{k^{\prime}}^{t} y_{k^{\prime}}^{t} \cdot (\delta_{k k^{\prime}} - y_{k}^{t} )} \\
& =\sum_{k^{\prime}}^{K} {E_{k^{\prime}}^{t} y_{k^{\prime}}^{t} \delta_{k k^{\prime}}} - \sum_{k^{\prime}}^{K} {E_{k^{\prime}}^{t} y_{k^{\prime}}^{t} y_{k}^{t} } \\
& = E_{k}^{t} y_{k}^{t} - \sum_{k^{\prime}}^{K} {E_{k^{\prime}}^{t} y_{k^{\prime}}^{t} y_{k}^{t} } \\
& = E_{k}^{t} y_{k}^{t} - (\sum_{k^{\prime}}^{K} {E_{k^{\prime}}^{t} y_{k^{\prime}}^{t} ) y_{k}^{t} }
\end{aligned}\]
回到代码
ctc_err_.MulElements(net_out);
对两个相同大小的矩阵做elementwise乘法,得到一个矩阵,其第t行第k列的值是 \(E_{k}^{t} y_{k}^{t}\)
row_sum.AddColSumMat(1.0, ctc_err_, 0.0);
将上述的矩阵的列相加,得到一个T*1 的矩阵(后者叫T维列向量),其第t行的值是 \(\sum_{k^{\prime}}^{K} {E_{k^{\prime}}^{t} y_{k^{\prime}}^{t} }\)
MulRowsVec对net_out_tmp
中的第n行里的每个值,都乘上row_sum的第n维值。从而net_out_tmp计算后得到了一个矩阵,其第t行k列的值是 \((\sum_{k^{\prime}}^{K} {E_{k^{\prime}}^{t} y_{k^{\prime}}^{t} ) y_{k}^{t} }\)
diff是一个T*K的矩阵,其第t行k列的值为,即
\[E_{k}^{t} y_{k}^{t} - (\sum_{k^{\prime}}^{K} {E_{k^{\prime}}^{t} y_{k^{\prime}}^{t} ) y_{k}^{t} }\]
关于Cuda计算的介绍
Cuda的计算架构里有Grid/Block/Thread概念。
- 一个Grid里有多个Block。
- 一个Block里有多个Thread。
- 每个Cuda Kernel在一个Thread上计算。Thread之间是并行的。
- Grid和Block的布局可以是1,2,3维的。
设计一个算法的cuda版本,需要考虑如何把算法分配到Thread上并行计算。
Eesen里的CTC里的Alpha
其中
- exp_len_labels是在标注序列的首尾及各字符之间插入blank扩展后的序列。原序列长度
n
,则新序列长度为L=2n+1
- alpha_是一个num_frames行,exp_len_labels列的矩阵。每一行对应一个时间帧t。
ComputeCtcAlpha里的cuda
其中CU1DBLOCK定义为256
n_blocks用于确定一个Grid里Block的个数
假设exp_len_labels的长度为L,则n_blocks=L/256.
所以cuda_compute_ctc_alpha的一个Grid里有L/256
个Block, 一个Block里有256个Thread
现在再看最终调用的_compute_ctc_alpha_one_sequence«<Gr, Bl»>()的完整代码。
Cuda里计算t时刻CTC Alpha的划分如下图,代码中的i对应图中纵坐标(从上往下),row(即t)对应横坐标。每个block对应图中的一个框,其中有256个thread。每个thread计算一个圆圈上的统计量。
结合图看代码:
- index_alpha_*记录前向统计量矩阵中的index,比如index_alpha_rm1_im1表示,r是row的意思,也就是时间帧t,其中m是minus的意思,即(t-1,i-1)对应的alpha矩阵中的index
- index_prob记录当前thread(t时刻,expand-label第i个位置)的label在网络输出的prob矩阵中的index
if (i < dim)
: 这里dim即label的长度L,因为L不一定是Blocksize(256)的整数倍,所以最后一个block里的i可能会大于L,因此if (i < dim)
是必要的。
另外,因为CTC本身的性质,t时刻只能计算到t*2位置的label的alpha值,所以这里if (i < dim)
可以改为 if (i < 2*row + 1 && i < dim)
,减小一些的计算量。不过因为一般T都远大于L,所以减少的计算量也不多。
Eesen中的CTC的解码[待更新]
解码图构建
T的构建
L的构建
解码优化