高级用法

本节涉及如何通过实现您的模型和实验来扩展 TTS。还阐述了实施的指南。

对于一般的深度学习实验,有几个部分需要处理:

  1. 根据模型的需求对数据进行预处理,并按批次迭代数据集。

  2. 定义模型、优化器和其他组件。

  3. 写出训练过程(一般包括正向/反向计算、参数更新、日志记录、可视化、定期评估等)。

  4. 配置并运行实验。

PaddleSpeech TTS的模型组件

为了平衡模型的可重用性和功能,我们根据模型的特性将其划分为几种类型。

对于可以作为其他更大模型一部分的常用模块,我们尽量将它们实现得简单且通用,因为它们会被重用。具有可训练参数的模块通常实现为paddle.nn.Layer的子类。没有可训练参数的模块可以直接实现为一个函数,其输入和输出是paddle.Tensor

特定任务的模型作为 paddle.nn.Layer 的子类实现。模型可以很简单,比如一个单层 RNN。对于复杂的模型,建议将模型拆分为不同的组件。

对于一个seq-to-seq模型,将其分为编码器和解码器是很自然的。对于一个由多个相似层组成的模型,将子层提取为一个单独的层是很自然的。

定义由多个模块组成的模型有两种常见方法。

  1. 根据规格定义一个模块。这里是一个多层感知器的例子。

    class MLP(nn.Layer):
        def __init__(self, input_size, hidden_size, output_size):
            self.linear1 = nn.Linear(input_size, hidden_size)
            self.linear2 = nn.Linear(hidden_size, output_size)
    
        def forward(self, x):
            return self.linear2(paddle.tanh(self.linear1(x))
    
    module = MLP(16, 32, 4) # intialize a module
    

    当模块旨在成为一个通用且可重用的层,能够集成到更大的模型中时,我们更倾向于以这种方式定义它。

    为了可读性和可用性的考虑,我们强烈建议不要将规范打包成一个单一的对象。以下是一个示例。

    class MLP(nn.Layer):
        def __init__(self, hparams):
            self.linear1 = nn.Linear(hparams.input_size, hparams.hidden_size)
            self.linear2 = nn.Linear(hparams.hidden_size, hparams.output_size)
    
        def forward(self, x):
            return self.linear2(paddle.tanh(self.linear1(x))
    

    对于以这种方式定义的模块,用户初始化实例会更加困难。用户必须阅读代码以检查使用了哪些属性。

    此外,使用这种风格的代码往往会滥用,通过传递一个巨大的配置对象来初始化实验中使用的每个模块,尽管每个模块可能不需要整个配置。

    我们更喜欢明确。

  2. 将一个模块定义为其组件的组合。以下是一个序列到序列模型的示例。

    class Seq2Seq(nn.Layer):
        def __init__(self, encoder, decoder):
            self.encoder = encoder
            self.decoder = decoder
    
        def forward(self, x):
            encoder_output = self.encoder(x)
            output = self.decoder(encoder_output)
            return output
    
    encoder = Encoder(...)
    decoder = Decoder(...)
    # compose two components
    model = Seq2Seq(encoder, decoder)
    

    当一个模型复杂,并由几个组件组成,每个组件都有独立的功能,并且可以被其他具有相同功能的组件替代时,我们更喜欢以这种方式定义它。

在PaddleSpeech TTS的目录结构中,高重用性的模块放置在 paddlespeech.t2s.modules,而特定任务的模型则放置在 paddlespeech.t2s.models。在开发新模型时,开发者需要考虑拆分模块的可行性,以及模块的通用程度,并将它们放置在适当的目录中。

PaddleSpeech TTS的数据信息组件

深度学习项目的另一个关键组成部分是数据。 PaddleSpeech TTS 使用以下方法来训练数据:

  1. 对数据进行预处理。

  2. 加载预处理的数据用于训练。

之前,我们在 __getitem__ 的 Dataset 中编写了预处理,当访问某个批次样本时会进行处理,但遇到了一些问题:

  1. 效率问题。即使Paddle有异步加载数据的设计,当批量大小较大时,每个样本都需要预处理和设置批次,这需要花费大量时间,甚至可能严重减慢训练过程。

  2. 数据过滤问题。一些过滤条件依赖于所处理样本的特性。例如,根据文本长度过滤过短的样本。如果文本长度只能在__getitem__之后得知,那么每次过滤时,整个数据集都需要被加载一次!此外,如果不进行预过滤,__getitem__中的一个小异常(例如过短的文本)将导致整个数据流中的异常,这不可行,因为collate_fn假设每个样本的获取都是正常的。即使使用一些特殊标志,例如None,来标记数据获取失败,并跳过collate_fn,这也会改变batch_size。

因此,将预处理完全放在 __getitem__ 上是不现实的。我们使用上述提到的方法。 在预处理过程中,我们可以进行过滤,我们还可以保存更多的中间特征,例如文本长度、音频长度等,这些特征可以用于后续的过滤。由于 TTS 领域的习惯,数据存储在多个文件中,处理结果以 npy 格式保存。

使用类似列表的方式来存储元数据,并将文件路径存储在其中,这样您就不受文件具体存储位置的限制。除了文件路径,还可以存储其他元数据。例如,文本路径、音频路径、光谱路径、帧数、采样点数量等。

然后对于路径,有多种打开方法,例如 sf.readnp.load 等,因此最好使用一个可以输入的参数,我们甚至不想通过扩展名来确定读取方法,最好让用户输入,这样用户可以定义他们的方法来解析数据。

所以我们从 DataFrame 的设计中学到了,但我们的方法更简单,只需要一个 list of dicts,一个 dict 代表一条记录,方便与 jsonyaml 等格式进行交互。对于每个选择的字段,我们需要提供一个解析器(在接口中称为 converter),就这样。

然后我们需要选择一种格式来将元数据保存到硬盘上。在json中存储记录列表时有两个方括号,这对流的读取和写入不方便,因此我们使用jsonlines。我们不使用yaml,因为它在存储记录列表时占用了太多行。

同时,cache 在这里被添加,并且使用多进程管理器在多个进程之间共享内存。当使用 num_workers 时,可以保证每个子进程不会缓存一份拷贝。

paddlespeech/t2s/datasets/data_table.py 中可以找到 DataTable 的实现。

class DataTable(Dataset):
    """Dataset to load and convert data for general purpose.

    Parameters
    ----------
    data : List[Dict[str, Any]]
        Metadata, a list of meta datum, each of which is composed of
        several fields
    fields : List[str], optional
        Fields to use, if not specified, all the fields in the data are
        used, by default None
    converters : Dict[str, Callable], optional
        Converters used to process each field, by default None
    use_cache : bool, optional
        Whether to use a cache, by default False

    Raises
    ------
    ValueError
        If there is some field that does not exist in data.
    ValueError
        If there is some field in converters that does not exist in fields.
    """

    def __init__(self,
                 data: List[Dict[str, Any]],
                 fields: List[str]=None,
                 converters: Dict[str, Callable]=None,
                 use_cache: bool=False):

它的 __getitem__ 方法是用它们的解析器解析每个字段,然后组合一个字典返回。

def _convert(self, meta_datum: Dict[str, Any]) -> Dict[str, Any]:
    """Convert a meta datum to an example by applying the corresponding
    converters to each field requested.

    Parameters
    ----------
    meta_datum : Dict[str, Any]
        Meta datum

    Returns
    -------
    Dict[str, Any]
        Converted example
    """
    example = {}
    for field in self.fields:
        converter = self.converters.get(field, None)
        meta_datum_field = meta_datum[field]
        if converter is not None:
            converted_field = converter(meta_datum_field)
        else:
            converted_field = meta_datum_field
        example[field] = converted_field
    return example

PaddleSpeech TTS的训练组件

典型的训练过程包括以下几个步骤:

  1. 迭代数据集。

  2. 处理批量数据。

  3. 神经网络前向/后向计算。

  4. 参数更新。

  5. 在达到某些特殊条件时,对验证数据集进行模型评估。

  6. 编写日志,进行可视化,并在某些情况下保存必要的中间结果。

  7. 保存模型和优化器的状态。

在这里,我们主要介绍Pa中与训练相关的TTS组件,以及我们为什么这样设计它。

全球报告员

在训练和修改深度学习模型时,通常需要记录日志,这甚至已经成为模型调试和修改的关键。我们通常使用各种可视化工具,例如,在 paddle 中的 visualdl,在 tensorflow 中的 tensorboardvidsomwnb 等。此外, loggingprint 通常用于不同的目的。

在这些工具中, print 是最简单的,它没有 logging 中的 loggerhandler 概念,也没有 tensorboard 中的 summarywriterlogdir,打印时不需要 global_step,它足够轻便,可以出现在代码中的任何地方,并且打印到常见的标准输出。 当然,它的可定制性有限,例如,打印字典或更复杂对象时就不再直观。而且它是瞬时的,人们需要使用重定向来保存信息。

对于 TTS 模型的开发,我们希望有一个更通用的多媒体标准输出,这是一个类似于 tensorboard 的工具,允许多种多媒体形式,但在使用时需要一个 summary writer,并在写入信息时需要一个 step。如果数据是图像或声音,则需要一些格式控制参数。

这将一定程度上破坏模块化设计。例如,如果我的模型由多个子层组成,并且我想在某些子层的前向方法中记录一些重要信息。为此,我可能需要将summary writer传递给这些子层,但对于子层而言,其功能是计算,它不应该有额外的考虑,而且我们也很难容忍一个nn.Linear的初始化在方法中有一个可选的visualizer。而且,对于计算模块,如何能知道全局步骤?这些都与训练过程相关!

因此,一种更常见的方法是在层的定义中不放入 writing_log_code,而是返回它,然后在训练期间获取它们,并将其写入 summary writer。但是,返回值需要修改。 summary writer 是训练级别的广播器,然后每个模块通过修改返回值将信息传递给它。

我们认为这个方法有点丑陋。我们更倾向于仅返回必要的信息,而不是改变返回值以适应可视化和记录。当你需要报告一些信息时,你应该能够轻松地报告它。因此,我们模仿了 chainer 的设计,并使用 global repoter

它利用了Python模块级变量的全局性和上下文管理器的效果。

paddlespeech/t2s/training/reporter.py 中有一个模块级变量 OBSERVATIONS,它是一个 Dict 用于存储键值对。

# paddlespeech/t2s/training/reporter.py

@contextlib.contextmanager
def scope(observations):
    # make `observation` the target to report to.
    # it is basically a dictionary that stores temporary observations
    global OBSERVATIONS
    old = OBSERVATIONS
    OBSERVATIONS = observations

    try:
        yield
    finally:
        OBSERVATIONS = old

然后我们实现一个上下文管理器 scope,用于切换绑定在 OBSERVATIONS 名称上的变量。然后定义一个 getter 函数来获取绑定在 OBSERVATIONS 上的字典。

def get_observations():
    global OBSERVATIONS
    return OBSERVATIONS

然后我们定义一个函数来获取当前 OBSERVATIONS,并将键值对写入其中。

def report(name, value):
    # a simple function to report named value
    # you can use it everywhere, it will get the default target and writ to it
    # you can think of it as std.out
    observations = get_observations()
    if observations is None:
        return
    else:
        observations[name] = value

以下测试代码展示了使用方法。

  • 使用 first 作为当前 OBSERVATION,写 first_begin=1

  • 然后,打开第二个 OBSERVATION,写 second_begin=2

  • 然后,打开第三个 OBSERVATION,写入 third_begin=3

  • 退出第三个 OBSERVATION, 我们自动返回第二个 OBSERVATION

  • 在第二个 OBSERVATION 中写一些内容,然后退出它,我们会自动返回到第一个 OBSERVATION

def test_reporter_scope():
    first = {}
    second = {}
    third = {}

    with scope(first):
        report("first_begin", 1)
        with scope(second):
            report("second_begin", 2)
            with scope(third):
                report("third_begin", 3)
                report("third_end", 4)
            report("seconf_end", 5)
        report("first_end", 6)

    assert first == {'first_begin': 1, 'first_end': 6}
    assert second == {'second_begin': 2, 'seconf_end': 5}
    assert third == {'third_begin': 3, 'third_end': 4}

通过这种方式,当我们编写模块化组件时,可以直接调用 report。 调用者只要准备好 OBSERVATION,就会决定在哪里报告,然后打开一个 scope 并在这个 scope 内调用组件。

PaddleSpeech TTS中的Trainer以这种方式报告信息。

while True:
    self.observation = {}
    # set observation as the report target
    # you can use report freely in Updater.update()

    # updating parameters and state
    with scope(self.observation):
        update() # training for a step is defined here

更新者:模型训练过程

为了保持功能的纯粹性和代码的可重用性,我们将模型代码抽象到paddle.nn.Layer的一个子类中,并在其中编写核心计算函数。

我们倾向于在 forward() 中编写训练的前向过程,但只写入预测结果,而不写入损失。因此,这个模块可以被更大的模块调用。

然而,当我们设计一个实验时,我们需要添加一些其他内容,例如训练过程、评估过程、检查点保存、可视化等。在这个过程中,我们会遇到一些只存在于训练过程中的内容,例如 optimizerlearning rate schedulervisualizer 等。这些内容并不是模型的一部分,它们应该写在模型代码中。

我们为这些中间过程做了一个抽象,也就是 Updater,它将 modeloptimizerdata stream 作为输入,其功能是训练。由于不同模型的训练方法可能存在差异,我们倾向于为每个模型编写一个相应的 Updater。但这与最终的训练脚本不同,仍然有一定程度的封装,只是提取常规保存、可视化、评估等细节,保留最基本的功能,即训练模型。

可视化器

因为我们选择观察作为沟通方式,我们可以简单地将观察中的内容写入 visualizer

PaddleSpeech TTS的配置组件

深度学习实验通常有许多可配置的选项。这些配置大致可以分为几类。

  1. 数据源和数据处理模式配置。

  2. 保存实验结果的路径配置。

  3. 数据预处理模式配置。

  4. 模型结构和超参数配置。

  5. 训练过程配置。

更改运行配置以比较结果是很常见的。为了跟踪运行配置,我们使用 yaml 配置文件。

此外,我们希望与命令行选项进行交互。一些通常根据运行环境变化的选项由命令行参数提供。此外,我们希望在不编辑配置文件的情况下覆盖配置文件中的选项。

考虑到这些要求,我们使用 yacs 作为配置管理工具。其他工具如 omegaconf 也非常强大,并具有类似的功能。

在每个提供的示例中,有一个 config.py,默认配置定义在 conf/default.yaml 中。如果您想获取默认配置,请导入 config.py 并调用 get_cfg_defaults() 来获取它。然后,如果需要,可以使用 yaml 配置文件或命令行参数进行更新。

有关如何在实验中使用 yacs 的详细信息,请参见 yacs

以下是基本的 ArgumentParser

  1. --config 用于支持配置文件解析,配置文件本身处理每个实验的独特选项。

  2. --train-metadata 是训练数据的路径。

  3. --output-dir 是保存训练结果的目录。(如果在 --output-dircheckpoints/ 中有检查点,它默认会重新加载最新的检查点进行训练)

  4. --ngpu 确定操作模式,--ngpu 指的是训练进程的数量。如果 ngpu > 0,意味着使用GPU,否则使用CPU。

开发者可以参考examples中的示例来编写添加新实验时的默认配置文件。

PaddleSpeech TTS的实验模板

PaddleSpeech TTS中的实验代码通常按照以下方式组织:

.
├──  README.md               (help information)
├──  conf
│     └── default.yaml       (defalut config)
├──  local
│    ├──  preprocess.sh      (script to call data preprocessing.py)
│    ├──  synthesize.sh      (script to call synthesis.py)  
│    ├──  synthesize_e2e.sh  (script to call synthesis_e2e.py)
│    └──train.sh             (script to call train.py)
├── path.sh                  (script include paths to be sourced)
└── run.sh                   (script to call scripts in local)

由上述 *.sh 调用的 *.py 文件位于 ${BIN_DIR}/

我们添加了一个命名参数。 --output-dir 到每个训练脚本中,以指定输出目录。目录结构如下,开发者应遵循此规范:

exp/default/
├── checkpoints/
│   ├── records.jsonl        (record file)
│   └── snapshot_iter_*.pdz  (checkpoint files)
├── config.yaml              (config file of this experiment)
├── vdlrecords.*.log         (visualdl record file)
├── worker_*.log             (text logging, one file per process)
├── validation/              (output dir during training, information_iter_*/ is the output of each step, if necessary)
├── inference/               (output dir of exported static graph model, which is only used in the final stage of training, if implemented)
└── test/                    (output dir of synthesis results)

您可以在 examples 中查看我们提供的示例。这些实验作为可以直接运行的示例提供给用户。欢迎用户添加新的模型和实验,并为 PaddleSpeech 贡献代码。