WeNet 更新:支持热词增强
在语音识别的实际应用中,对于常用的词汇识别效果比较好,但是对于一些特有的人名、歌名、地名或者某个领域的专有词汇,例如人名宋星辰 、歌名国际歌 、地名丽泽商务区 以及语音识别专业词汇解码器 ,可能存在识别准确率不高的情况。对于这些专有词汇,通过在 WeNet 中使用热词增强方案,添加热词可以显著提升识别的准确率。
近期,WeNet 的更新支持了两种解码器上热词增强,包括 CTC Prefix Beam Search 和 WFST Beam Search。
热词增强
热词增强在论文中也叫 Context Biasing 或者 Contextual Biasing,相当于是把一些先验的知识加入到了语音识别系统中。WeNet 在解码过程中维护一个 Context Graph 中的状态。通过子图中的状态计算热词的得分,然后通过浅融合 (Shallow Fusion) 的形式在束搜索的过程中进行加分。
声学得分和热词得分的加权公式如下:
Context Graph
假如我们需要对下述热词进行增强:
王思
欧阳唯一
唯品会
指定热词中每个字的得分为 3,则 Context Graph 的构图如下图所示:

在解码过程中,当匹配到对应前缀时,就会得到相应的分数奖励。为了防止前缀相同,但不是完全匹配的现象。我们加了一条回退弧,通过这条弧移除之前奖励的分数。例如“欧阳修”不能完全匹配任意热词,回退弧则移除“欧阳”两个字得到的 6 分奖励。
为了易于确定匹配到的热词的起始点,对于每个前缀,WeNet 只记录一个状态。即在同一时刻,只能进行一个热词的匹配,只有该热词匹配成功或者失败后才能开始匹配其他热词。
使用该策略,“欧阳唯品会”则无法成功匹配“唯品会”。因为“欧阳唯品”的“品”字导致“欧阳唯一”匹配失败,回到 0 状态,而“会”字无法匹配任意热词(此处还可以做一些优化的工作,例如回到 0 状态后,“品”字继续匹配等等)。WeNet 不考虑 这种情况。
int ContextGraph::GetNextState(int cur_state, int word_id, float* score,
bool* is_start_boundary, bool* is_end_boundary) {
int next_state = 0;
// 遍历当前状态上所有的弧
for (fst::ArcIterator<fst::StdFst> aiter(*graph_, cur_state); !aiter.Done();
aiter.Next()) {
const fst::StdArc& arc = aiter.Value();
if (arc.ilabel == 0) {
// 记录回退弧的分数,如果匹配成功,该分数会被覆盖
*score = arc.weight.Value();
} else if (arc.ilabel == word_id) {
// 匹配成功,记录下一个状态和奖励的分数
next_state = arc.nextstate;
*score = arc.weight.Value();
// 判断是否为热词的开始或结束
if (cur_state == 0) {
*is_start_boundary = true;
}
if (graph_->Final(arc.nextstate) == fst::StdArc::Weight::One()) {
*is_end_boundary = true;
}
break;
}
}
return next_state;
}
代码解读
CTC Prefix Beam Search
在前缀束搜索的过程中,每个前缀需要记录热词匹配的信息。加上当前时刻的输出后,若前缀发生变化则直接调用上述 GetNextState 函数更新热词匹配的状态和分数。如果是热词的开始或者结束,还需要记录开始的位置和结束的位置,用于在识别结果中插入开始标签和结束标签,如:“前往
CTC WFST Beam Search
由于 WFST 束搜索采用的是 Kaldi 中的 Lattice Faster Online Decoder,所以为了支持热词增强,我们需要对 lattice-faster-decoder.cc 做一些修改。
WFST 束搜索根据 CTC 的输出在 TLG 上进行解码,我们一开始的想法是在 TLG 的输入标签 (ilabel) 上做热词增强,也就是经过语言模型之前做热词增强。这样的话 Context Graph 的输入标签和输出标签都是字级别,而且我们只需要修改 ProcessEmitting 函数。但是由于 TLG 的输入是 CTC 的输出,如果我们要维护热词的匹配状态,那么 Context Graph 还需要和 T (Token Graph) 进行 compose 操作。
我们最终决定在 TLG 的输出标签 (olabel) 上做热词增强,也就是在经过语言模型之后做热词增强。同时修改 ProcessEmitting 和 ProcessNonemitting 函数,加入相同的热词增强逻辑。Context Graph 构图时,只需要根据语言模型的字典对所有热词进行分词即可:

Elem *e_next =
FindOrAddToken(arc.nextstate, frame + 1, tot_cost, tok, NULL);
// NULL: no change indicator needed
// ========== Context code BEGIN ===========
bool is_start_boundary = false;
bool is_end_boundary = false;
float context_score = 0;
if (context_graph_) {
if (arc.olabel == 0) {
e_next->val->context_state = tok->context_state;
} else {
e_next->val->context_state = context_graph_->GetNextState(
tok->context_state, arc.olabel, &context_score,
&is_start_boundary, &is_end_boundary);
graph_cost -= context_score;
}
}
// ========== Context code END ==========
// Add ForwardLink from tok to next_tok (put on head of list
// tok->links)
tok->links = new ForwardLinkT(e_next->val, arc.ilabel, arc.olabel,
graph_cost, ac_cost, is_start_boundary,
is_end_boundary, tok->links);
tok->links->context_score = context_score;
代码解读
剪枝
Context Graph 的回退弧会一次性将前面累计的奖励分数回退到一条弧 (ForwardLink) 上,导致该弧的 cost 特别大而被剪枝,它不该一条弧承受这么多。因此在剪枝之前,我们需要移除回退弧产生的 cost,此处感谢爱奇艺@陈海涛 同学提出的解决方案。
void LatticeFasterDecoderTpl<FST, Token>::PruneForwardLinks(
int32 frame_plus_one, bool *extra_costs_changed, bool *links_pruned,
BaseFloat delta) {
...
BaseFloat link_extra_cost =
next_tok->extra_cost +
((tok->tot_cost + link->acoustic_cost + link->graph_cost) -
next_tok->tot_cost); // difference in brackets is >= 0
// ========== Context code BEGIN ===========
// graph_cost 中包含了热词的分数
// link->context_score < 0 表示该 link 上热词的分数来自于回退弧
// 剪枝的时候不应该考虑回退弧的分数,所以应该从 link_extra_cost 中移除
if (link->context_score < 0) {
link_extra_cost += link->context_score;
}
// ========== Context code END ==========
// link_exta_cost is the difference in score between the best paths
// through link source state and through link destination state
代码解读
实验结果
With-context (183 条):
拨打崔文平的办公电话
打给王思
打欧阳唯一159开头电话
...
Without-context (200 条): 从 aishell 测试集中随机抽 200 条,表示通用数据集
模型使用的是 aishell2 的 20210602_unified_transformer_server.zip。每一列对应不同的 context_score。由于 with-context 中包含较多的数字,而解码结果没有做 text-normalization,故 WER 较高。
| Prefix Beam Search | 0 | 1 | 3 | 5 | 10 |
|---|---|---|---|---|---|
| with-context | 27.71% | 27.65% | 25.64% | 22.26% | 19.05% |
| without-context | 1.59% | 1.59% | 1.66% | 1.86% | 2.62% |
| WFST Beam Search | 0 | 1 | 3 | 5 | 10 |
|---|---|---|---|---|---|
| with-context | 29.83% | 29.40% | 28.36% | 26.73% | 25.10% |
| without-context | 0.99% | 0.99% | 0.99% | 0.99% | 1.09% |
从实验结果可以看出,随着 context_score 的增加,with-context 数据集上的 WER 越低。当 context_score 超过 5 时,会导致通用数据集上的 WER 升高。WFST Beam Search 有了语言模型的加持, context_score 可以设置大一些。
限制
WeNet 提供了一种端到端热词定制化的方案,但是该方案在工程化的时候没有对下述问题做详细的探索。
context_score 设置为多少比较合适?对于不同模型和不同数据集,如何能快速确定该值?
某些情况下,热词奖励(但不是正确识别结果)得分过高,导致正确路径被裁剪掉,又该如何避免?
等等。对于上述限制,希望感兴趣的同学可以多多提供方案,多多到 github 上提交 pull request。
