目录
简介
在语音识别中,我们的数据集是音频文件和其对应的文本,不幸的是,音频文件和文本很难再单词的单位上对齐。除了语言识别,在OCR,机器翻译中,都存在类似的Sequence to Sequence结构,同样也需要在预处理操作时进行对齐,但是这种对齐有时候是非常困难的。如果不使用对齐而直接训练模型时,由于人的语速的不同,或者字符间距离的不同,导致模型很难收敛。
CTC(Connectionist Temporal Classification)是一种避开输入与输出手动对齐的一种方式,是非常适合语音识别或者OCR这种应用的。
图1:CTC用于OCR(左)和语音识别(右)
给定输入序列 以及对应的标签数据
例如语音识别中的音频文件和文本文件。我们的工作是找到
到
的一个映射,这种对时序数据进行分类的算法叫做Temporal Classification。
这种对时序数据进行分类的算法叫做Temporal Classification。
对比传统的分类方法,时序分类有如下难点:
和
的长度都是变化的;
-
和
的长度是不相等的;
- 对于一个端到端的模型,我们并不希望手动设计
和
的之间的对齐。
CTC提供了解决方案,对于一个给定的输入序列 ,CTC给出所有可能的
的输出分布。根据这个分布,我们可以输出最可能的结果或者给出某个输出的概率。
损失函数:给定输入序列 ,我们希望最大化
的后验概率
,
是可导的,测试:给定一个训练好的模型和输入序列
,我们希望输出概率最高的
:
算法详解
给定输入,CTC输出每个可能输出及其条件概率,问题的关键是CTC的输出概率是如何考虑
和
之间是如何对齐的,这种对齐也是构建损失函数的基础。所以,首先我们分析CTC的对齐方式,然后我们在分析CTC的损失函数的构造。
1.1 对齐
需要注意的是,CTC本身是不需要对齐的,但是我们需要知道的输出路径和最终输出结果的对应关系,因为在CTC中,多个输出路径可能对应一个输出结果,举例来理解。例如在OCR的任务中,输入
是含有“CAT”的图片,输出
是文本[C, A, T]。将
分割成若干个时间片,每个时间片得到一个输出,一个最简答的解决方案是合并连续重复出现的字母,如图2.
图2:CTC的一种原始对齐策略
这个问题有两个缺点:
- 几乎不可能将
的每个时间片都和输出Y对应上,例如OCR中字符的间隔,语音识别中的停顿
- 不能处理有连续重复字符出现的情况,例如单词"HELLO",按照上面的算法,输出的是“”HELO"而非"HELLO"
为了解决上面的问题,CTC引入了空白字符,例如OCR中的字符间距,语音识别中的停顿都可以表示为
。所以,CTC的对齐涉及去除重复字母和去除
两部分,如图3
图三:CTC的对齐策略
这种对齐方式有三个特征
与Y之间的时间片映射是单调的,即如果
向前移动一个时间片,Y保持不动或者也向前移动一个时间片
- X与Y之间的映射是多对一的,即多个输出可能对应一个映射,反之则不成立,这样就有了特征三
- X的长度大于或者等于Y的长度。
1.2 损失函数
CTC的时间片的输出和输出序列的映射如图4:
图4:CTC的流程
也就是说,对应标签Y,其关于输入X的后验概率可以表示为所有映射为Y的路径之和,我们的目标就是最大化Y关于x=y的后验概率。假设每个时间片的输出是独立的,则路径的后延概率是每个时间片概率的累积,公式及其详细含义如图5.
图5:CTC的公式及其详细含义
上面的CTC算法存在性能问题,对于一个时间片长度为T的N分类任务,所有可能的路径数为,,在很多情况下,这几乎是一个宇宙级别的数字,用于计算Loss几乎是不现实的。在CTC中采用了动态规划的思想来对查找路径进行剪枝,算法的核心思想是如果路径
和
在时间片t之前的输出均相等,我们就可以提前合并他们,,如图6。
图6:CTC的动态规划计算输出路径
其中,横轴的单位是X的时间片,纵轴的单位是Y插入的序列Z。例如对于单词“ZOO”,插入
后为:
我们用表示路径中已经合并的在横轴单位为t,纵轴单位为s的节点。根据CTC对齐方式的三个特征,输入有9个时间片,标签内容为"ZOO",
的所有可能的合法路径如下图所示
图7:CTC中单词ZOO的所有合法路径
上图分成两种情况
Case1:
现在,我们已经可以高效的计算损失函数,下一步的工作便是计算梯度用于训练模型。由于的计算只涉及加法和乘法,因此其一定是可导函数,进而我们可以使用SGD优化模型。
对于数据集D,模型的优化目标是最小化负对数似然
1.3 预测
当我们训练好一个RNN模型时,给定一个输入序列X,
我们需要找到最可能的输出,也就是求解
Y* = argmaxp(Y|X)
求解最可能的输出有两种方案,一种是Greedy Search,第二种是beam search
1.3.1 Greedy Search
每个时间片均取该时间片概率最高的节点作为输出:
这个方法最大的缺点是忽略了一个输出可能对应多个对齐方式.
代码
def remove_blank(labels, blank=0):
import numpy as np
# 求每一列(即每个时刻)中最大值对应的softmax值
def softmax(logits):
# 注意这里求e的次方时,次方数减去max_value其实不影响结果,因为最后可以化简成教科书上softmax的定义
# 次方数加入减max_value是因为e的x次方与x的极限(x趋于无穷)为无穷,很容易溢出,所以为了计算时不溢出,就加入减max_value项
# 次方数减去max_value后,e的该次方数总是在0到1范围内。
max_value = np.max(logits, axis=1, keepdims=True)
exp = np.exp(logits - max_value)
exp_sum = np.sum(exp, axis=1, keepdims=True)
dist = exp / exp_sum
return dist
def remove_blank(labels, blank=0):
new_labels = []
# 合并相同的标签
previous = None
for l in labels:
if l != previous:
new_labels.append(l)
previous = l
# 删除blank
new_labels = [l for l in new_labels if l != blank]
return new_labels
def insert_blank(labels, blank=0):
new_labels = [blank]
for l in labels:
new_labels += [l, blank]
return new_labels
def greedy_decode(y, blank=0):
# 按列取最大值,即每个时刻t上最大值对应的下标
raw_rs = np.argmax(y, axis=1)
# 移除blank,值为0的位置表示这个位置是blank
rs = remove_blank(raw_rs, blank)
return raw_rs, rs
np.random.seed(11)
y_test = softmax(np.random.random([30,10]))
label_have_blank, label_no_blank = greedy_decode(y_test)
print(label_have_blank)
print(label_no_blank)
1.3.2 Beam Search
Beam Search是寻找全局最优值和Greedy Search在查找时间和模型精度的一个折中。一个简单的beam search在每个时间片计算所有可能假设的概率,并从中选出最高的几个作为一组。然后再从这组假设的基础上产生概率最高的几个作为一组假设,依次进行,直到达到最后一个时间片,下图是beam search的宽度为3的搜索过程,红线为选中的假设
代码
import numpy as np
# 求每一列(即每个时刻)中最大值对应的softmax值
def softmax(logits):
# 注意这里求e的次方时,次方数减去max_value其实不影响结果,因为最后可以化简成教科书上softmax的定义
# 次方数加入减max_value是因为e的x次方与x的极限(x趋于无穷)为无穷,很容易溢出,所以为了计算时不溢出,就加入减max_value项
# 次方数减去max_value后,e的该次方数总是在0到1范围内。
max_value = np.max(logits, axis=1, keepdims=True)
exp = np.exp(logits - max_value)
exp_sum = np.sum(exp, axis=1, keepdims=True)
dist = exp / exp_sum
return dist
def remove_blank(labels, blank=0):
new_labels = []
# 合并相同的标签
previous = None
for l in labels:
if l != previous:
new_labels.append(l)
previous = l
# 删除blank
new_labels = [l for l in new_labels if l != blank]
return new_labels
def insert_blank(labels, blank=0):
new_labels = [blank]
for l in labels:
new_labels += [l, blank]
return new_labels
def beam_decode(y, beam_size=10):
# y是个二维数组,记录了所有时刻的所有项的概率
T, V = y.shape
# 将所有的y中值改为log是为了防止溢出,因为最后得到的p是y1..yn连乘,且yi都在0到1之间,可能会导致下溢出
# 改成log(y)以后就变成连加了,这样就防止了下溢出
log_y = np.log(y)
# 初始的beam
beam = [([], 0)]
# 遍历所有时刻t
for t in range(T):
# 每个时刻先初始化一个new_beam
new_beam = []
# 遍历beam
for prefix, score in beam:
# 对于一个时刻中的每一项(一共V项)
for i in range(V):
# 记录添加的新项是这个时刻的第几项,对应的概率(log形式的)加上新的这项log形式的概率(本来是乘的,改成log就是加)
new_prefix = prefix + [i]
new_score = score + log_y[t, i]
# new_beam记录了对于beam中某一项,将这个项分别加上新的时刻中的每一项后的概率
new_beam.append((new_prefix, new_score))
# 给new_beam按score排序
new_beam.sort(key=lambda x: x[1], reverse=True)
# beam即为new_beam中概率最大的beam_size个路径
beam = new_beam[:beam_size]
return beam
np.random.seed(11)
y_test = softmax(np.random.random([30, 10]))
beam_chosen = beam_decode(y_test, beam_size=100)
for beam_string, beam_score in beam_chosen[:20]:
print(remove_blank(beam_string), beam_score)
1.3.3 prefix Beam search
最简单的decode方式当然是最近拿每个frame的最大概率的token,但实际应用中这种方法字错率会颇高,且无法和语言模型结合。要与语言模型结合,必须有多个candidate,但也不能够穷尽每个frame每个token的组合,故有beam search。但beam search的candidate会有很多相同的部分,所以有了Prefix beam search。
缀束搜索(Prefix Beam Search)方法,可以在搜索过程中不断的合并相同的前缀。
具体较复杂,不过读者弄懂beam search后再想想prefix beam search的流程不是很难,主要弄懂probabilityWithBlank和probabilityNoBlank分别代表最后一个字符是空格和最后一个字符不是空格的概率即可。
import numpy as np
from collections import defaultdict
ninf = float("-inf")
# 求每一列(即每个时刻)中最大值对应的softmax值
def softmax(logits):
# 注意这里求e的次方时,次方数减去max_value其实不影响结果,因为最后可以化简成教科书上softmax的定义
# 次方数加入减max_value是因为e的x次方与x的极限(x趋于无穷)为无穷,很容易溢出,所以为了计算时不溢出,就加入减max_value项
# 次方数减去max_value后,e的该次方数总是在0到1范围内。
max_value = np.max(logits, axis=1, keepdims=True)
exp = np.exp(logits - max_value)
exp_sum = np.sum(exp, axis=1, keepdims=True)
dist = exp / exp_sum
return dist
def remove_blank(labels, blank=0):
new_labels = []
# 合并相同的标签
previous = None
for l in labels:
if l != previous:
new_labels.append(l)
previous = l
# 删除blank
new_labels = [l for l in new_labels if l != blank]
return new_labels
def insert_blank(labels, blank=0):
new_labels = [blank]
for l in labels:
new_labels += [l, blank]
return new_labels
def _logsumexp(a, b):
'''
np.log(np.exp(a) + np.exp(b))
'''
if a < b:
a, b = b, a
if b == ninf:
return a
else:
return a + np.log(1 + np.exp(b - a))
def logsumexp(*args):
'''
from scipy.special import logsumexp
logsumexp(args)
'''
res = args[0]
for e in args[1:]:
res = _logsumexp(res, e)
return res
def prefix_beam_decode(y, beam_size=10, blank=0):
T, V = y.shape
log_y = np.log(y)
# 最后一个字符是blank与最后一个字符为non-blank两种情况
beam = [(tuple(), (0, ninf))]
# 对于每一个时刻t
for t in range(T):
# 当我使用普通的字典时,用法一般是dict={},添加元素的只需要dict[element] =value即可,调用的时候也是如此
# dict[element] = xxx,但前提是element字典里,如果不在字典里就会报错
# defaultdict的作用是在于,当字典里的key不存在但被查找时,返回的不是keyError而是一个默认值
# dict =defaultdict( factory_function)
# 这个factory_function可以是list、set、str等等,作用是当key不存在时,返回的是工厂函数的默认值
# 这里就是(ninf, ninf)是默认值
new_beam = defaultdict(lambda: (ninf, ninf))
# 对于beam中的每一项
for prefix, (p_b, p_nb) in beam:
for i in range(V):
# beam的每一项都加上时刻t中的每一项
p = log_y[t, i]
# 如果i中的这项是blank
if i == blank:
# 将这项直接加入路径中
new_p_b, new_p_nb = new_beam[prefix]
new_p_b = logsumexp(new_p_b, p_b + p, p_nb + p)
new_beam[prefix] = (new_p_b, new_p_nb)
continue
# 如果i中的这一项不是blank
else:
end_t = prefix[-1] if prefix else None
# 判断之前beam项中的最后一个元素和i的元素是不是一样
new_prefix = prefix + (i,)
new_p_b, new_p_nb = new_beam[new_prefix]
# 如果不一样,则将i这项加入路径中
if i != end_t:
new_p_nb = logsumexp(new_p_nb, p_b + p, p_nb + p)
else:
new_p_nb = logsumexp(new_p_nb, p_b + p)
new_beam[new_prefix] = (new_p_b, new_p_nb)
# 如果一样,保留现有的路径,但是概率上要加上新的这个i项的概率
if i == end_t:
new_p_b, new_p_nb = new_beam[prefix]
new_p_nb = logsumexp(new_p_nb, p_nb + p)
new_beam[prefix] = (new_p_b, new_p_nb)
# 给新的beam排序并取前beam_size个
beam = sorted(new_beam.items(), key=lambda x: logsumexp(*x[1]), reverse=True)
beam = beam[:beam_size]
return beam
np.random.seed(11)
y_test = softmax(np.random.random([30, 16]))
beam_test = prefix_beam_decode(y_test, beam_size=100)
for beam_string, beam_score in beam_test[:20]:
print(remove_blank(beam_string), beam_score)
CTC的特征
- 条件独立:CTC的一个非常不合理的假设是其假设每个时间片都是相互独立的,这是一个非常不好的假设。在OCR或者语音识别中,各个时间片之间是含有一些语义信息的,所以如果能够在CTC中加入语言模型的话效果应该会有提升
- 单调对齐:CTC的另外一个约束是输入X与输出Y之间的单调对齐,OCR和语音识别中,这种约束是成立的。但是在一些场景中例如机器翻译,这个约束便无效了。
- 多对一映射:CTC的又一个约束是输入序列X的长度大于标签数据Y的长度,但是对于Y的长度大于X的长度的场景,CTC便失效了。
参考知识