本文主要对CTC 原理及实现中的代码进行解释。
1.np.random.seed(1111)
请参见本专栏文章numpy中random.seed()的妙用
2.softmax的实现
代码是这样的:
def softmax(logits):
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
很多人包括我一开始对减掉max_value不知其解,觉得没有意义,毕竟
可以看出,两者其实是等价的,当初就这个问题我特意问了作者softmax和序列最大值无关,大意是这样:
因为 ,当 过大时,很容易导致 overflow,所以为了数值稳定性就需要保持输入不是那么大,一个比较不错的方法就是减掉 ,很明显
至于会不会underflow则留待以后再讨论吧。
3.logSumExp()的作用
有很多代码用到了logSumExp(),其作用用数学公式可以表达为:
在CTC实现代码中的一些图形化解释中分析了beam decode中logY = np.log(y)的作用,那么在prefix beam decode中,关键的数学步骤也可以这样简化:
假设有两条路径的概率分别为 , ,其中经过many-to-one map后 和 的路径相同,且 和相同,那么两条路径可以化为一条路径,总概率为:
那么求对数后变为:
那么问题就变为求解 的问题,这里就使用一个技巧:
其作用大体就是这么回事,用于将不同路径合在一起.举例来说:
newProbabilityNoBlank = logSumExp(newProbabilityNoBlank, probabilityNoBlank + p)
这里newProbabilityNoBlank大致相当于 ,probabilityNoBlank 相当于 ,p相当于 .
以下是三种ctc decoder的比较,实现代码在我的github里compareCTCDecoder
4.greedy search
代码为:
rawRs = np.argmax(y, axis=1)
maxNumber = y[xrange(y.shape[0]), rawRs]
score = np.multiply.accumulate(maxNumber)[-1]
rs = removeBlank(rawRs, black)
return rawRs, rs, score
基本原理就是将每个时间 内最大概率的 取出即可。
下面通过一个例子来阐述:
的分布如下:
图片1 y分布
那么greedy search的结果为:
例如当 时,在序列 中得到最大概率为0.4,依次找到各时间内的最大概率即可。
5.beam search
代码为:
T, V = y.shape
logY = np.log(y)
beam = [([], 0)]
for t in range(T): # for every timestep
newBeam = []
for prefix, score in beam:
for i in range(V): # for every state
newPrefix = prefix + [i]
# log(a * b) = log(a) + log(b)
newScore = score + logY[t, i]
newBeam.append((newPrefix, newScore))
# sort by the score
newBeam.sort(key=lambda x: x[1], reverse=True)
beam = newBeam[:beamSize]
return beam
基本原理是通过 中 个序列,每个序列分别连接 中 个节点,得到 个新序列及对应的score,然后按照score从大到小的顺序选出前个序列,依次推进即可。
这里先分析下代码,注意有这么一句代码:
logY = np.log(y)
为什么要先进行对数呢,这其实是一个防止underflow的技巧。
因为最终得到的概率的形式是 ,而 ,所以如果非常多的小数连乘,到具体的数值计算步骤中,会导致underflow,概率直接为0了,所以使用下面的技巧
改乘为加可以完美的解决这个问题。
我调用的代码是:
print ('beam decode:')
beam = beamDecode(y, beamSize=2)
for string, score in beam:
print ('\tB(%s) = %s, score is %.4f' % (string, removeBlank(string), np.exp(score)))
这里,设置 为2,这是具体的执行步骤:
5.1
只会将两个最大的节点放进路劲中去。
5.2
这里每个路径都会和下一个时间点组成新的路径,因此一共有 个新路径
根据score取得最大的两个路径(次大的两个路径相等,这里舍弃掉一个)(感谢评论提醒,程序选的是另外一条,当初忘了看了)
5.3
新的路径也有6条
根据score取得最大的两个路径
最终会得到两条路径,在程序里打印出来的结果为:
# B([1, 0, 1]) = [1, 1], score is 0.0800
# B([1, 1, 1]) = [1], score is 0.0700
可以看出,分析和程序运行的结果是一致的。
6.prefix beam search
代码基本脉络与beam search一致,最主要的一方面是基于如下的考虑:
有许多不同的路径在many-to-one map的过程中是相同的,但beam search却会将一部分舍去,这导致了很多有用的信息被舍弃了。
比如 中, 和 经过many-to-one map后相同,虽然两者的概率都不高,但两者加起来的概率很高,如果忽略这一点而直接舍弃掉他们是很不明智的一种做法。这种朴素的想法就催生了prefix beam search。基本的思想是将记录prefix的时候不在记录raw sequence,而是记录去掉blank和duplicate(具体步骤较复杂,会同时保留duplicate的和没duplicate得序列)。
具体较复杂,不过读者弄懂beam search后再想想prefix beam search的流程不是很难,主要弄懂probabilityWithBlank和probabilityNoBlank分别代表最后一个字符是空格和最后一个字符不是空格的概率即可。
有个小伙伴表示这里看不懂,抱歉也没时间弄图了,这里解释一下吧。
令*表示任意字符串(经过many-to-one map后的),比如 ,这里 表示Blank,那么这里的*特指尾部是Blank的*,表示为 ,即 。同理 。
那么任意字符串*与一个新的字符结合有多少种情况呢,一共有这样5种情况(为了简便,令*最后一个字符为 ):
弄懂了这5种情况的意思应该就明白了。
6.1 probabilityWithBlank和probabilityNoBlank
大致思想就是将每个路径的情况分为尾部为blank和尾部不为blank的情况,例如,当t=2时,many-to-one map后为[1]的序列有三种:[1,0],[0,1],[1,1],其中[1,0]是尾部待blank的情况,[0,1]和[1,1]的情况,为了说明这有什么用,那么假设t=3时label为1,那么新的序列就两者情况:
1.[1,0]+[1]=[1,0,1]->[1,0,1]
2.[0,1]+[1]=[0,1,1]->[1]
3.[1,1]+[1]=[1,1,1]->[1]
后两者是新的尾部不为blank的序列,可见尾部不为blank在新序列产生的时候是可以算作一种情况的,这就是为什么要分为blank和尾部不为blank的情况.
6.2 keep current prefix的作用
有一段代码开始也是困扰了我很久:
# keep current prefix
if i == endT:
newProbabilityWithBlank, newProbabilityNoBlank = newBeam[prefix]
newProbabilityNoBlank = logSumExp(newProbabilityNoBlank, probabilityNoBlank + p)
newBeam[prefix] = (newProbabilityWithBlank, newProbabilityNoBlank)
后来我想明白了,这是因为beam中prefix的最后一个label并不一定是many-to-one map之前的label,换言之,many-to-one map之前,最后一个label是blank,比如下面的情况:
1.[1,0]
2.[0,1]
经过many-to-one map后都为[1],如果t=3,那么新的序列第一种情况应该是[1,1],第二种情况是[1],这就对应了一个是keep current prefix,一个加了新的label进来.
7.三种decode的比较
直接上结果吧。
将所有路径都打印出来再统计的结果为raw decode,结果显示top path为string=(2, 1) score=0.2185。
greedy decode的结果是[1, 1],score is 0.0800,与raw decode结果不符,可以看到这个算法的局限性。
beam decode的结果是[1, 1], score is 0.0800,与raw decode结果不符,可以看到这个算法的局限性。
prefix beam decode的结果是[1, 2], score is 0.1200,与raw decode的结果相符,可以已经完美考虑到路径many-to-one map后相同的情况,推荐优先使用这种算法。至于score不同的原因,即prefix beam decode的score is 0.1200,和raw decde的0.2185有些许差距,编程发现,这其实是一个小问题,因为我把beamSize设为2了,将其设为3就能得到0.2185的score了.
8.我对decode方法的理解
在计算机领域我认为大部分的算法的出发点都是在降低时间复杂度,甚至为此会提高一点空间复杂度。从时间复杂度的角度思考decode方法能带给我们不一样的思考方式。
CTC的输入是维度0大小为时间 、维度1大小为字符集大小C+1的二维矩阵,那么一个时间复杂度最高的decode方法是什么呢?对每条路径进行解码,经过many-to-one map后把相同的路径概率相加,最后取最高概率的路径即可。这种路径有 条,每条路径要计算 次乘法,即时间复杂度为 ,这个时间复杂度是不能忍的。
greedy search因为只选取最高的那个路径,路径只有1条,即时间复杂度为 ,这个时间复杂度是最少的。但是因为没有考虑到many-to-one map的操作,所以一般认为这个search用处不大(虽然在项目中我发现greedy search和没有字典加持的prefix beam search的效果是一样的)。另外一个不好的地方在于这种search方法不支持字典。
而beam search因为每次都是选取条路径,所以时间复杂度为 (我是这么算的, ,一个需要考虑的点在于得到 条路径后需要使用排序法得到score排名最高的前 条路径,当然,严格来说,排序时间复杂度可以减少到 )。这个复杂度还是可以接受的。
prefix beam search的时间复杂度比较复杂,我认为时间消耗和beam search相近,不同的在于将merge beam的过程不一样,而且这个merge算法不一样消耗时间也不同,所以时间复杂度也是 。
【已完结】