TTS 前端中 NLP 知识

2022/03/13 语音合成 共 3377 字,约 10 分钟 Read:

最近抽时间回去看了一下 TTS 中的前端内容,查缺补漏,温故知新。特此笔记如下。

一个基本的 tts 结构,一般包括了前端和后端。tts 前端是原始文本到文本特征的模块。 和后端模型相比,前端模型可能更像一个纯 NLP 问题,大多集中在文本本身的处理上。 例如文本正则/发音预测/韵律分析等。

虽然目前神经网络方案大行其道,但受限于训练数据和推理性能,传统的 nlp 方法并未完全代替。 轻量级处理问题的方法,依然必不可少。

分词

中文这种天然没有词边界信息的语言,分词几乎是所有 NLP 问题的基础。 对于分词这一基础问题,大家研究了许多年,具体的分类和一些基础知识简介,可以参考 https://lujiaying.github.io/posts/2018/01/Chinese-word-segmentation/

这里介绍实际工程中一种常用的方案: 基于 Uni-gram 和 Pos-Gram 的词图搜索方法

该方案的输入,包括如下数据

  • Word Uni-Gram 词典,包括词性和count
word1, count1, pos1
word2, count2, pos2
...
  • Pos-3Gram 词典(arpa format),可选
pos1, pos2, pos3, count1
pos1, pos3, pos4, count2
...

构建词图

对每个输入对句子,根据词典构建对应词图(lattice)。

每个字为一个顶点(node),每个句子增加一个额外的开始和结束顶点(S and E),使用边(arc)连接相邻两个词对应结尾字的顶点。一个样例如下所示:

每个从 S 到 E 的路径,代表了一种可能的分词。例如,红色 arc 代表了一种分词:

今/天天/气/很好

词图结构建成后,我们给每条路径加上对应的权重。例如,根据每个词出现的频率高低,赋予权重。 (实际中常用该词出现的次数,除以所有词出现总次数再取对数作为权重值)

这样权重越大的路径,出现几率则越高。例如:

今天/天气/很好 weight = -(0.1 + 0.1 + 0.1) = -0.3
今/天天/气很好 weight = -(0.2 + 0.1 +0.2 + 0.1) = -0.3

这表明第一一个选择可能是一个更优的结果。

可以看出,词图其实是一个 DAG (有向无环图)。

搜索词图

这样,我们的任务变成了从所有S到E中,搜索一条权重最大的边。 怎样来高效的搜索呢?

对于DAG,一个显而易见的性质是,对于一条权重最优路径,其每个子路径都是权重最优。 因此我们可以使用贪心算法进行求解,即 Viterbi 算法。简单的介绍一下搜索的过程:

想象每个结点上有一个令牌(token),该 token 上记录了以下信息: 当前最大权重(weight),当前最大权重对应路径的上一个token所在id(pre_node_id)。 从起始点开始,依次更新token信息。一个大概的更新算法如下:

token_id = 0
while (token_id != end_id):
  max_weight = 0
  for arc in all_arc:  // 遍历所有指向该 node 的 arc
    if weight(arc) > max_weight:
      max_weight = weight(arc)
      pre_node_id = get_start_node(arc)
  token(token_id) = {max_weight, pre_node_id}
  token_id ++ 

当我们搜索到最后的时候,可以通过每个 token 上的 pre_node_id 反向回溯,从而得到最优路径,以及其权值。

对于更精细的使用场景,可以考虑使用 ngram3 概率来计算每条边对应的 weight,算法如下:

token_id = 0
while (token_id != end_id):
  node = node(token_id)
  max_weight = 0
  for arc in all_arc(node):  // 遍历所有指向该 node 的 arc
    pre_node = get_start_node(arc)
    all_pre_node_arc = all_arc(pre_node)
    for pre_arc in all_pre_node_arc:
      weight = compute_bigram(arc, pre_arc)
      ...
        
  token(token_id) = {max_weight, pre_node}
  token_id ++ 

当然,实际工程中,由于大规模语料对应的3gram模型对内存占用较大。使用一种更为折衷的方,采用 pos-3gram,由于不同词性的数目一般只有十几到几十个,因此,pos-3gram 会比 word-3gram小很多。 同时,分词时加入词性信息也会一定程度上提高准确率。因此,采用了如下的权重方案:

weight =  weigth(uni-gram) + weigth(pos-3gram) * alpha

韵律预测

一般 tts 方案中,我们常使用四级韵律预测。例如

今天天气很好。-> 今天#2天气#1很好。#4

其中,数字越大表示停顿时间越长。有一些情况下,韵律标签比较容易预测。例如每句话的结尾,一定是#4。 对于有标点的情况,句号/叹号/问号后面一定是#4, 逗号后面是#3等等。

这里介绍一个工程里常用的方案: CART, 在 HTS-engine 和 Festival 中,也称其为 Wagon Tree

est-wagon 介绍文档 https://github.com/festvox/speech_tools/blob/master/doc/estwagon.md

注: Protea nitida (Wagon Tree) 南非的一种树,以生长缓慢而出名

相对来说,CART 比较适合于输入特征维度不高,且输入特征和输出有比较线性相关性的数据。

WagonTree 本质是一颗二叉树,一般的设计结构包括 node/leave,其中 每个 node 上有一个 question, 根据问题的答案流转到对应的子结点(相当于每个结点上有一个 if/else 语句),直到叶子结点,每个叶子结点对应一个分类标签。

整个 wagonTree 的流程分成构建和推理两部分。

WagonTree 的构建

首先针对每条文本构建特征向量,可以使用 当前词/前一词/后一词的词性/词长度/是否为标点等特征。 通过对训练数据进行分析,构建CART树模型。可以通过Gini指数或Entropy等准则来构建一颗树。该树最终可以写成类似如下的形式:

Question-Set:
node_id fea_id value node_left node_rigth
... 

leave_id value
...
 

对于每个结点,根据 fea_id 和 value 决定跳转流向。

if feature[fea_id] == value:
  go to left node
else:
  go to right node 

使用类似的数据结构存储数据,可以按行依次将问题集存储到树当中。

node{
int fea_num;
int vale;
int cur_node;
int left_node;
int right_node;
int is_leave; 
}

WagonTree 的推理

整个推理过程如下

node_id = 0 
while(true)
  if node(node_id).is_leaf:
    return node(node_id).label
  if node(node_id)==label:
    node_id = left_node
  else:
    node_id = right_node    

根据此过程可以看出,推理速度和树深度正相关。 因此,在性能相同的情况下,应该尽可能使得整个树深度最小。

word To phone

phone 模块主要来处理文本转拼音,虽然现在也有少数方法支持直接输入汉字, 不过这种做法目前还不是主流,比较中文汉字的数量比拼音多很多。

多音字 (poly phone)

对于非多音字/多字节多音字,可以通过发音词典来解决。

多音字问题的难点,在于预测单音节多音字,例如 为:wei2/wei4 之类的case。

可以使用类似韵律预测的 WagonTree 方法。 使用时,除了上下文信息外,将分词信息和韵律信息加入特征中来,可以使得整体效果进一步提升。

语流变调 (sandhi)

这部分内容,可以参考之前的一个总结。

tts中文前端语流变调小结


总结,大体上前端包括四部分,除了本文没有涉及的正则以外,其他三部分都大概看了一下具体实现。

感觉 TTS-前端核心还是要轻量级快速的处理,以及足够好的可解释性(实际中发现badcase可以迅速修正和生效)。

Search

    Table of Contents

    本站总访问量