高级用法
本节涉及如何通过实现您的模型和实验来扩展 TTS。还阐述了实施的指南。
对于一般的深度学习实验,有几个部分需要处理:
根据模型的需求对数据进行预处理,并按批次迭代数据集。
定义模型、优化器和其他组件。
写出训练过程(一般包括正向/反向计算、参数更新、日志记录、可视化、定期评估等)。
配置并运行实验。
PaddleSpeech TTS的模型组件
为了平衡模型的可重用性和功能,我们根据模型的特性将其划分为几种类型。
对于可以作为其他更大模型一部分的常用模块,我们尽量将它们实现得简单且通用,因为它们会被重用。具有可训练参数的模块通常实现为paddle.nn.Layer的子类。没有可训练参数的模块可以直接实现为一个函数,其输入和输出是paddle.Tensor。
特定任务的模型作为 paddle.nn.Layer 的子类实现。模型可以很简单,比如一个单层 RNN。对于复杂的模型,建议将模型拆分为不同的组件。
对于一个seq-to-seq模型,将其分为编码器和解码器是很自然的。对于一个由多个相似层组成的模型,将子层提取为一个单独的层是很自然的。
定义由多个模块组成的模型有两种常见方法。
根据规格定义一个模块。这里是一个多层感知器的例子。
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))
对于以这种方式定义的模块,用户初始化实例会更加困难。用户必须阅读代码以检查使用了哪些属性。
此外,使用这种风格的代码往往会滥用,通过传递一个巨大的配置对象来初始化实验中使用的每个模块,尽管每个模块可能不需要整个配置。
我们更喜欢明确。
将一个模块定义为其组件的组合。以下是一个序列到序列模型的示例。
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 使用以下方法来训练数据:
对数据进行预处理。
加载预处理的数据用于训练。
之前,我们在 __getitem__ 的 Dataset 中编写了预处理,当访问某个批次样本时会进行处理,但遇到了一些问题:
效率问题。即使Paddle有异步加载数据的设计,当批量大小较大时,每个样本都需要预处理和设置批次,这需要花费大量时间,甚至可能严重减慢训练过程。
数据过滤问题。一些过滤条件依赖于所处理样本的特性。例如,根据文本长度过滤过短的样本。如果文本长度只能在
__getitem__之后得知,那么每次过滤时,整个数据集都需要被加载一次!此外,如果不进行预过滤,__getitem__中的一个小异常(例如过短的文本)将导致整个数据流中的异常,这不可行,因为collate_fn假设每个样本的获取都是正常的。即使使用一些特殊标志,例如None,来标记数据获取失败,并跳过collate_fn,这也会改变batch_size。
因此,将预处理完全放在 __getitem__ 上是不现实的。我们使用上述提到的方法。 在预处理过程中,我们可以进行过滤,我们还可以保存更多的中间特征,例如文本长度、音频长度等,这些特征可以用于后续的过滤。由于 TTS 领域的习惯,数据存储在多个文件中,处理结果以 npy 格式保存。
使用类似列表的方式来存储元数据,并将文件路径存储在其中,这样您就不受文件具体存储位置的限制。除了文件路径,还可以存储其他元数据。例如,文本路径、音频路径、光谱路径、帧数、采样点数量等。
然后对于路径,有多种打开方法,例如 sf.read、 np.load 等,因此最好使用一个可以输入的参数,我们甚至不想通过扩展名来确定读取方法,最好让用户输入,这样用户可以定义他们的方法来解析数据。
所以我们从 DataFrame 的设计中学到了,但我们的方法更简单,只需要一个 list of dicts,一个 dict 代表一条记录,方便与 json、yaml 等格式进行交互。对于每个选择的字段,我们需要提供一个解析器(在接口中称为 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的训练组件
典型的训练过程包括以下几个步骤:
迭代数据集。
处理批量数据。
神经网络前向/后向计算。
参数更新。
在达到某些特殊条件时,对验证数据集进行模型评估。
编写日志,进行可视化,并在某些情况下保存必要的中间结果。
保存模型和优化器的状态。
在这里,我们主要介绍Pa中与训练相关的TTS组件,以及我们为什么这样设计它。
全球报告员
在训练和修改深度学习模型时,通常需要记录日志,这甚至已经成为模型调试和修改的关键。我们通常使用各种可视化工具,例如,在 paddle 中的 visualdl,在 tensorflow 中的 tensorboard 和 vidsom,wnb 等。此外, logging 和 print 通常用于不同的目的。
在这些工具中, print 是最简单的,它没有 logging 中的 logger 和 handler 概念,也没有 tensorboard 中的 summarywriter 和 logdir,打印时不需要 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() 中编写训练的前向过程,但只写入预测结果,而不写入损失。因此,这个模块可以被更大的模块调用。
然而,当我们设计一个实验时,我们需要添加一些其他内容,例如训练过程、评估过程、检查点保存、可视化等。在这个过程中,我们会遇到一些只存在于训练过程中的内容,例如 optimizer、 learning rate scheduler、 visualizer 等。这些内容并不是模型的一部分,它们不应该写在模型代码中。
我们为这些中间过程做了一个抽象,也就是 Updater,它将 model、optimizer 和 data stream 作为输入,其功能是训练。由于不同模型的训练方法可能存在差异,我们倾向于为每个模型编写一个相应的 Updater。但这与最终的训练脚本不同,仍然有一定程度的封装,只是提取常规保存、可视化、评估等细节,保留最基本的功能,即训练模型。
可视化器
因为我们选择观察作为沟通方式,我们可以简单地将观察中的内容写入 visualizer。
PaddleSpeech TTS的配置组件
深度学习实验通常有许多可配置的选项。这些配置大致可以分为几类。
数据源和数据处理模式配置。
保存实验结果的路径配置。
数据预处理模式配置。
模型结构和超参数配置。
训练过程配置。
更改运行配置以比较结果是很常见的。为了跟踪运行配置,我们使用 yaml 配置文件。
此外,我们希望与命令行选项进行交互。一些通常根据运行环境变化的选项由命令行参数提供。此外,我们希望在不编辑配置文件的情况下覆盖配置文件中的选项。
考虑到这些要求,我们使用 yacs 作为配置管理工具。其他工具如 omegaconf 也非常强大,并具有类似的功能。
在每个提供的示例中,有一个 config.py,默认配置定义在 conf/default.yaml 中。如果您想获取默认配置,请导入 config.py 并调用 get_cfg_defaults() 来获取它。然后,如果需要,可以使用 yaml 配置文件或命令行参数进行更新。
有关如何在实验中使用 yacs 的详细信息,请参见 yacs。
以下是基本的 ArgumentParser:
--config用于支持配置文件解析,配置文件本身处理每个实验的独特选项。--train-metadata是训练数据的路径。--output-dir是保存训练结果的目录。(如果在--output-dir的checkpoints/中有检查点,它默认会重新加载最新的检查点进行训练)--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 贡献代码。