ctc decoder

本文主要对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不知其解,觉得没有意义,毕竟

\frac{e^{x_{i}-Max(x_{i})}}{\sum_{}^{}{e^{x_{i}-Max(x_{i})}}}=\frac{e^{x_{i}}/e^{Max(x_{i})}}{\sum_{}^{}{(e^{x_{i}}/e^{Max(x_{i})})}}=\frac{e^{x_{i}}/e^{Max(x_{i})}}{(\sum_{}^{}{e^{x_{i}})/e^{Max(x_{i})}}}=\frac{e^{x_{i}}}{\sum_{}^{}{e^{x_{i}}}}

可以看出,两者其实是等价的,当初就这个问题我特意问了作者softmax和序列最大值无关,大意是这样:

因为 \lim_{x \rightarrow \infty}{\frac{e^{x}}{x}}=\infty ,当 x 过大时,很容易导致 e^{x} overflow,所以为了数值稳定性就需要保持输入不是那么大,一个比较不错的方法就是减掉 Max(x_{i}) ,很明显

0<e^{x_{i}-Max(x_{i})}\leq1

至于会不会underflow则留待以后再讨论吧。


3.logSumExp()的作用

有很多代码用到了logSumExp(),其作用用数学公式可以表达为:

y= lg(\sum_{n}^{i}{e^{x_{i}}})

CTC实现代码中的一些图形化解释中分析了beam decode中logY = np.log(y)的作用,那么在prefix beam decode中,关键的数学步骤也可以这样简化:

假设有两条路径的概率分别为 (x_{1},x_{2},...,x_{T}) ,(y_{1},y_{2},...,y_{T}) ,其中经过many-to-one map后(x_{1},x_{2},...,x_{r}) 和(y_{1},y_{2},...,y_{r}) 的路径相同,且(x_{r+1},x_{r+2},...,x_{T}) 和(y_{r+1},y_{r+2},...,y_{T})相同,那么两条路径可以化为一条路径,总概率为:

p=x_{1}\times x_{2} \times ... \times x_{r} \times ... x_{T} + y_{1}\times y_{2} \times ... \times y_{r} \times ... y_{T} \\ =(x_{1}\times x_{2} \times ... \times x_{r}+ y_{1}\times y_{2} \times ... \times y_{r}) \times y_{r+1} \times...\times y_{T}

那么求对数后变为:

lg(p)=lg(x_{1}\times x_{2} \times ... \times x_{r}+ y_{1}\times y_{2} \times ... \times y_{r}) +lg(y_{r+1})...lg(y_{T})

那么问题就变为求解 lg(x_{1}\times x_{2} \times ... \times x_{r}+ y_{1}\times y_{2} \times ... \times y_{r}) 的问题,这里就使用一个技巧:

lg(x_{1}\times x_{2} \times ... \times x_{r}+ y_{1}\times y_{2} \times ... \times y_{r})= \\ lg(e^{ln(x_{1}\times x_{2} \times ... \times x_{r})}+e^{ln(y_{1}\times y_{2} \times ... \times y_{r})})= \\ lg(e^{ln(x_{1})+ln(x_{2})+...+ln(x_{r})}+e^{ln(y_{1})+ln(y_{2})+...+ln(y_{r})})

其作用大体就是这么回事,用于将不同路径合在一起.举例来说:

newProbabilityNoBlank = logSumExp(newProbabilityNoBlank, probabilityNoBlank + p)

这里newProbabilityNoBlank大致相当于 ln(x_{1})+ln(x_{2})+...+ln(x_{r}),probabilityNoBlank 相当于 ln(y_{1})+ln(y_{2})+...+ln(y_{r-1}),p相当于 ln(y_{r}) .


以下是三种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

基本原理就是将每个时间 t 内最大概率的 k 取出即可。

下面通过一个例子来阐述:

y 的分布如下:

图片1 y分布

那么greedy search的结果为:

例如当 t=1 时,在序列 \left[ 0.25, 0.4, 0.35 \right] 中得到最大概率为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

基本原理是通过 t_{i-1} 中 beamSize个序列,每个序列分别连接 t_{i} 中 V 个节点,得到 beamSize 个新序列及对应的score,然后按照score从大到小的顺序选出前beamSize个序列,依次推进即可。

这里先分析下代码,注意有这么一句代码:

logY = np.log(y)

为什么要先进行对数呢,这其实是一个防止underflow的技巧。

因为最终得到的概率的形式是 p=y_{1}\times y_{2} \times ... \times y_{n} ,而 0\leq y_{i} \leq 1 ,所以如果非常多的小数连乘,到具体的数值计算步骤中,会导致underflow,概率直接为0了,所以使用下面的技巧

log(p)=log(y_{1}\times y_{2} \times ... \times y_{n})=log(y_{1})+log(y_{2})+...+log(y_{n})

改乘为加可以完美的解决这个问题。

 

我调用的代码是:

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 t=1

只会将两个最大的节点放进路劲中去。

5.2 t=2

这里每个路径都会和下一个时间点组成新的路径,因此一共有 beamSize\times V=2\times3=6个新路径

根据score取得最大的两个路径(次大的两个路径相等,这里舍弃掉一个)(感谢评论提醒,程序选的是另外一条,当初忘了看了)

5.3 t=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却会将一部分舍去,这导致了很多有用的信息被舍弃了。

比如 t=2 中, [0,2] 和 [2, 0] 经过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后的),比如 B(A-C)=*C ,这里 - 表示Blank,那么这里的*特指尾部是Blank的*,表示为 *_{Blank} ,即 B(A-C)=*_{Blank}C 。同理 B(AAC)=*_{NoBlank}C 。

那么任意字符串*与一个新的字符结合有多少种情况呢,一共有这样5种情况(为了简便,令*最后一个字符为 A ):

*_{Blank}+Blank=*_{Blank}

*_{Blank}+Char=(*+Char)_{NoBlank}

*_{NoBlank}+Blank=*_{Blank}

*_{NoBlank}+Char_{NoA}=(*+Char_{NoA})_{Blank}

*_{NoBlank}+A=*_{NoBlank}

弄懂了这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大小为时间 t、维度1大小为字符集大小C+1的二维矩阵,那么一个时间复杂度最高的decode方法是什么呢?对每条路径进行解码,经过many-to-one map后把相同的路径概率相加,最后取最高概率的路径即可。这种路径有 (C+1)^t 条,每条路径要计算 t 次乘法,即时间复杂度为 O(t\cdot C^t) ,这个时间复杂度是不能忍的。

greedy search因为只选取最高的那个路径,路径只有1条,即时间复杂度为 O(t) ,这个时间复杂度是最少的。但是因为没有考虑到many-to-one map的操作,所以一般认为这个search用处不大(虽然在项目中我发现greedy search和没有字典加持的prefix beam search的效果是一样的)。另外一个不好的地方在于这种search方法不支持字典。

而beam search因为每次都是选取beamSize条路径,所以时间复杂度为 O(t\times beamSize\times  C\times log(beamSize\times  C)) (我是这么算的, T(i+1)=T(i)+beamSize\times(C+1)\times log(beamSize\times(C+1)) ,一个需要考虑的点在于得到 beamSize\times(C+1) 条路径后需要使用排序法得到score排名最高的前 beamSize 条路径,当然,严格来说,排序时间复杂度可以减少到 T(i+1)=T(i)+beamSize\times(C+1)\times log(beamSize) )。这个复杂度还是可以接受的。

prefix beam search的时间复杂度比较复杂,我认为时间消耗和beam search相近,不同的在于将merge beam的过程不一样,而且这个merge算法不一样消耗时间也不同,所以时间复杂度也是 O(beamSize\times t\times C) 。

 

【已完结】

参考文献:https://zhuanlan.zhihu.com/p/39266552

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值