Kaldi的I/O机制

翻译:izy

时间:2015年7月

本页提供了 Kaldi 输入输出机制的概述。

这部分文档主要面向I/O的代码层机制分析,从命令行角度分析的说明文档,参看 Kaldi I/O from a command-line perspective

The input/output style of Kaldi classes

Kaldi 定义的类有统一的I/O接口。标准接口如下所示:

class SomeKaldiClass {
 public:
   void Read(std::istream &is, bool binary);
   void Write(std::ostream &os, bool binary) const;
};

注意函数返回 void;而错误会通过异常处理来显示(见 Kaldi logging and error-reporting).布尔变量“binary”标识对象是以二进制数据还是文本数据被写(或读)。调用代码必须知道被读写的对象是二进制还是文本形式(关于在读的过程中它是如何知道,见 How Kaldi objects are stored in files).注意“binary”变量的值没有必要和文件打开的方式(在 Windows下),即二进制还是文本模式,保持一致。更多注释请参照 How the binary/text mode relates to the file open mode。

ReadWrite函数可以有额外的可选参数。一个常见的Read函数例子有如下形式:

class SomeKaldiClass {
 public:
  void Read(std::istream &is, bool binary, bool add = false);
};

如果add==true,Read函数会添加所有在磁盘上的内容(e.g.statistics)到当前类中,前提该类不为空。

Input/output mechanisms for fundamental types and STL types

参照 Low-level I/O functions 中相关的函数列表。我们提供这些函数,使读写基本类型变得更加容易;他们大多被Kaldi类中的ReadWrite函数调用。Kaldi 的类可以任意使用这些函数,只要它的Read函数可以正确地读取Write函数所写入的数据。

这一类中最重要的函数是ReadBasicType()WriteBasicType();这些是类模板,涵盖了bool, float, double和integer 类型。它们在ReadWrite函数中应用的例子如下:

// we suppose that class_member_ is of type int32.
void SomeKaldiClass::Read(std::istream &is, bool binary) {
  ReadBasicType(binary, &class_member_);
}
void SomeKaldiClass::Write(std::ostream &is, bool binary) const {
  WriteBasicType(binary, class_member_); 
}

我们假定class_member_是 int32类型,一种已知大小的类型。这些函数中用 int类型并不安全。在二进制模式中,函数写字符时会编码类型的大小和符号,如果不匹配就会读取失败。我们本可以尝试对它们进行自动转换,但并没有这么做;目前来说,在 I/O中你必须用已知大小的整型类型(一般建议采用 int32)。另一方面,浮点类型会被自动转换。这是为了调试方便,所以你可以以-DKALDI_DOUBLE_PRECISION方式编译,同时读取没有设定该选项而写成的二进制文件。I/O惯例中没有 byte swapping;如果这对你是个问题,请换用文本格式。

还有WriteIntegerVector()ReadIntegerVector()模板函数。它们和ReadBasicType(), WriteBasicType()是同样的代码风格,不过是针对std::vector<I>,其中 I是某种整型类型(当然,它的大小在编译时应该是已知的,e.g. int32)

其他一些重要的底层I/O函数有:

void ReadToken(std::istream &is, bool binary, std::string *token);
void WriteToken(std::ostream &os, bool binary, const std::string & token);

一个记号(token)必须是不带空格的非空字符串,实际中一般是类似于 XML形式的字符串,如<SomeKaldiClass>, <SomeClassMemberName>或者</SomeKaldiClass>。这些函数的功能正如它们被定义的那样。方便起见,我们提供ExpectToken(),类似ReadToken(),只是你要指定想要的字符串(如果没有指定,就会产生异常)。典型的调用代码如下:

// in writing code:
WriteToken(os, binary, "<MyClassName>");
// in reading code:
ExpectToken(is, binary, "<MyClassName>");
// or, if a class has multiple forms:
std::string token;
ReadToken(is, binary, &token);
if(token == "<OptionA>") { ... }
else if(token == "<OptionB>") { ... }
...

还有WritePretty()ExpectPretty()函数,一般较少使用。除了是以文本方式读写,它们的作用和相应的 Token函数相似,而且可以接受任意字符串(i.e.允许空格).ReadPretty函数可以接受与期望输入仅仅有空格差异的输入。Kaldi 类中的Read函数不会检查文件是否结束,而默认读到Write函数写完的地方(在文本模式下,有几个空格没被读取也没有关系)。这是多个 Kaldi对象可以放入同一文件, 也是允许 archive概念(见 The Kaldi archive format)的原因。

How Kaldi objects are stored in files

如上所示,Kaldi 代码读取数据时需要知道读模式是文本还是二进制,但是我们不希望由用户来跟踪一个文件是文本还是二进制。所以,包含 Kaldi对象的文件需要声明它们存储的是二进制还是文本数据。二进制文件以字符串“\0B”开始,同时由于文本文件不能包含“\0”,也就不需要头。如果你用标准的 C++机制来打开文件(一般不会这么做,见 How to open files in Kaldi),你需要在采取其他操作前先处理这个文件头。你可以用InitKaldiOutputStream()(它同时设定了数据流的精度)和InitKaldiInputStream()

How to open files in Kaldi

假定需要从/向磁盘载入/储存一个Kaldi对象,同时对象是类似声学模型那样的(而不像声学特征那样有很多,针对这个请参考 The Table concept);你一般会用到InputOutput类。一个例子:

{ // input.
  bool binary_in;
  Input ki(some_rxfilename, &binary_in);
  my_object.Read(ki.Stream(), binary_in);
  // you can have more than one object in a file:
  my_other_object.Read(ki.Stream(), binary_in);
}
// output.  note, "binary" is probably a command-line option.
{
  Output ko(some_wxfilename, binary);
  my_object.Write(ko.Stream(), binary);

花括号的作用是使InputOutput的对象在结束时就离开作用域,因此文件会被立刻关闭。这也许没什么用处(为什么不用标准的C++流呢?)。但是这样我们可以支持各种扩展的文件名,同时处理错误也更容易些(InputOutput类在遇到错误时会打印提示性信息,并抛出异常)。注意文件名包括“rxfilename”和“wxfilename”。我们经常使用这种类型的文件名,它们意在提示编程人员它们是扩展的文件名,这会在下一部分进行描述。

InputOutput类的接口比上面例子中的要稍微丰富一些。你可以调用Open()来打开,调用Close()来关闭,而不仅仅是让他们离开作用域。这些函数会返回布尔类型的状态值,而不像构造函数和析构函数在遇到错误时抛出异常。Open()函数(包括构造函数)也可以在被调用时不处理 Kaldi的二进制文件头,如果你是想读写非Kaldi对象。估计你不会用到这一额外的功能。

参考 Classes for opening streams 中与InputOutput相关的类和函数,和rxfilenames, wxfilenames (下一部分)

Extended filenames:rxfilename and wxfilename

“rxfilename”和“wxfilename”不是类,他们是对变量名的描述符,他们表示:

  • rxfilename 是一个可以被 Input类当做扩展的文件名来读取的字符串
  • wxfilename 是一个可以被 Output类当做扩展的文件名来写入的字符串

rxfilename的类型如下:

  • “-”或“” 表示标准输入
  • “some command |” 表示一个输入管道命令,i.e.我们去掉管道符“|”,把剩下的字符串通过popen()传入shell
  • “/some/filename:12345” 表示文件的偏置,i.e.我们打开文件并定位至12345
  • “/some/filename”... 与以上不匹配的模式都会被当做普通的文件名(当然,一些明显的错误会被检测出来,在它们被打开之前)

你可以用ClassifyRxfilename()来获得 rxfilename的类型,不过这一般没有必要。

wxfilename的类型如下:

  • “-”或“” 表示标准输入
  • “| some command” 表示一个输出管道命令,i.e.我们去掉管道符“|”,把剩下的字符串通过popen()传入shell
  • “/some/filename”... 与以上不匹配的模式都会被当做普通的文件名(当然,会检测并过滤掉明显的错误)

同样地,ClassifyWxfilename()可以告诉你一个文件名的类型。

The Table concept

表(Table)是一个概念而不是一个实际的C++类。它是一个已知类型的对象的集合,并且用字符串(strings)索引。字符串必须是 tokens(不包含空格的非空字符串)。表的典型实例包括:

  • 特征文件(表示为Matrix<float>)的集合,由语句id (utterance id)索引
  • 转码文本(表示为std::vector<int32>)的集合,由语句id索引
  • 约束的MLLR变换(表示为Matrix<float>)的集合,由说话人id索引

在 Types of data that we write as tables, 我们对这些类型的表有更详细的处理;在这里我们只是解释一般原则和内部机理。表可以存在磁盘(或实际上,在管道里)以两种可能的格式:脚本文件(script file)或存档文件(archive)(见下面的 The Kaldi script-file format 和 The Kaldi archive format)。表相关的类和类型,见 Table types and related functions。

Table可以通过三种方式访问:TableWriter, SequentialTableReaderRandomAccessTableReader(还有RandomAccessTableReaderMapped是一个特例,后面会讲到)。这些都是类模板;它们不是基于 table对象的模板,而是基于 Holder类型(见下面, Holders as helpers to Table classes),可以告诉代码如何读写该类型的对象。为了打开 Table类型,你必须提供一个 wspecifier或 rspecifier的字符串(见下面,Specifying Table formats:wspecifiers and rspecifiers)来告诉代码表在磁盘上是如何存储的,同时提供各种其他指令。我们用一个示例代码来解释,这个代码读取特征,对其进行线性变换然后再写出:

std::string feature_rspecifier = "scp:/tmp/my_orig_features.scp",
   transform_rspecifier = "ark:/tmp/transforms.ark",
   feature_wspecifier = "ark,t:/tmp/new_features.ark";
// there are actually more convenient typedefs for the types below,
// e.g. BaseFloatMatrixWriter, SequentialBaseFloatMatrixReader, etc.
TableWriter<BaseFloatMatrixHolder> feature_writer(feature_wspecifier);
SequentialTableReader<BaseFloatMatrixHolder> feature_reader(feature_rspecifier);
RandomAccessTableReader<BaseFloatMatrixHolder> transform_reader(transform_rspecifier);
for(; !feature_reader.Done(); feature_reader.Next()) {
   std::string utt = feature_reader.Key();
   if(transform_reader.HasKey(utt)) {
      Matrix<BaseFloat> new_feats(feature_reader.Value());
      ApplyFmllrTransform(new_feats, transform_reader.Value(utt));
      feature_writer.Write(utt, new_feats);
   }
}

这个设定的优点是访问表的代码可以把表中数据看做 generic maps或 lists。数据类型和读过程中的参数(e.g.容错率)可以通过 rspecifiers和 wspecifiers中的选项进行控制,而不必由调用代码进行处理;上面的例子中,“,t”选项表示以文本形式写数据。

一个理想的表可能是一个字符串到对象的映射。然而,只要我们不在表上做随机访问,即使同一字符串有多个入口(i.e.在写或顺序访问时,它更像是a list of pairs),代码也不会出现问题。

关于 Table类型读写特定类型的类型定义,参考 Specific Table types。

The Kaldi script-file format

脚本文件(script file)(名字可能不太合适)是一个文本文件,每一行一般包括:

some_string_identifier /some/filename

另外一种有效的行可能是这样:

utt_id_01002 gunzip -c /usr/data/file_010001.wav.gz |

当读取 script file的一行时,Kaldi 会去除开头和结尾的空格,再以空格为分隔符进行拆分。 第一部分成为表的 key(例如发声id,在上面例子里是“utt_id_01001”),而第二部分成为 xfilename(即 wxfilename或 rxfilename,在上面例子里是“gunzip -c /usr/data/file_010001.wav.gz |”)。 空行或空 xfilename是不允许的。 script file用于读或写或同时读写都是可以的,这取决于 xfilenames是否是有效的 rxfilenames,或 wxfilenames,或两者兼而有之。

假设一个 script file是读取有效的,包含一些 Kaldi类中的对象。通常可以读出其中的一行, 用Input对象打开(见 How to open files in Kaldi)。如果是二进制的,文件流会包括二进制文件头“\0B”(即使是在文件的中间部分,如 archive)

The Kaldi archive format

Kaldi的存档文件(archive)格式很简单。再次明确 token定义为不含空格的非空字符串。archive格式描述如下:

token1 [something]token2 [something]token3 [something] ....

我们可以把这看成零个或多个重复(token;空格;调用Holder的Write函数的结果)。回想 Holder是讲述代码如何读写数据的一个对象。

当写 Kaldi对象时,Holder写的[something]会包括二进制模式的文件头(如果是二进制模式),然后是调用该对象Write函数返回的结果。当写的非Kaldi对象是简单的(像int32, float或vector),Holder类一般会确保文本模式下[something]是以换行符结尾的字符串。这样一来,archive是每行一个条目,很像 script file,比如:

    utt_id_1 5
    utt_id_2 7
    ...

是我们用于存储整数的文本存档格式。

archive 可以合并起来,仍是有效的(假定它们包含同样类型的对象)。这种格式被设计为管道友好的,i.e.你可以把 archive放入管道而读它的程序不用等到管道末尾就可以处理这些数据。为了有效的访问 archive,可以同时写 archive和 script file,script file存放的是地址的偏移。为此,请参阅下一节。

Specifying Table formats: wspecifiers and rspecifiers

Table类需要传递字符窜给构造函数或Open函数。这个字符串叫做 wspecifier如果它是传递给TableWriter类,或 rspecifier如果它是传递给RandomAccessTableReaderSequentialTableReader类。有效的 rspecifiers和 wspecifiers的例子包括:

std::string rspecifier1 = "scp:data/train.scp"; // script file.
std::string rspecifier2 = "ark:-"; // archive read from stdin.
// write to a gzipped text archive.
std::string wspecifier1 = "ark,t:| gzip -c > /some/dir/foo.ark.gz";
std::string wspecifier2 = "ark,scp:data/my.ark,data/my.ark";

通常,rspecifier或 wspecifier包括由逗号分隔的,无序的一个或两个字母的定义的选项和“ark”“scp”标识。后面跟着冒号和一个 rxfilename或 wxfilename。冒号前面选项的顺序并不重要。

Writing an archive and a script file simutaneously

wspecifiers的一个特例是:冒号前是“ark,scp”;冒号后是一个用于写 archive的 wxfilename,一个逗号,然后是一个 wxfilename(用于script file)。例如:

"ark,scp:/some/dir/foo.ark,/some/dir/foo.scp"

这会以像"utt_id /somedir/foo.ark:1234"这样的方式来写入archive和script file,指定了 archive的偏置,来达到更有效率的随机访问。接着你就可以随意处理script file,比如分解成多个片段,它仍像其他 script file一样正常工作。注意,虽然冒号前面的选项顺序一般不重要,这个例子中“ark”必须放在“scp”前面;这是为了避免冒号后面的两个 wxfilename产生混淆(archive始终在第一个)。指定 archive的 wxfilename必须是一个正常的文件名,否则写入的 script file不能被 Kaldi直接读取,但是代码中并没有限定这一点。

Valid options for wspecifiers

允许的wspecifier选项有:

  • “b”(binary) 以二进制方式写(目前这是不必要,因为它是默认方式)
  • “t”(text) 以文本方式写
  • “f”(flush) 每次写操作后都刷新数据流
  • “nf”(non-flush) 每次写操作后不刷新数据流(目前这是无意义的,但是调用代码可以更改这个默认值)
  • “p” 指许可模式,影响“scp:”wspecifiers在scp文件丢失一部分条目时,“p”选项会导致它不写这些内容,同时不会报告错误。

用了很多选项的 wspecifiers的例子是:

       "ark,t,f:data/my.ark"
       "ark,scp,t,f:data/my.ark,|gzip -c > data/my.scp.gz"

Valid options for rspecifiers

读下面的选项时,请记住读 archives的代码不能在 archive中进行搜索,因为 archive可能是一个管道(经常是这样)。如果RandomAccessTableReader在读 archive,读代码可能要在内存中存储很多对象,以防后面再次被请求到,或代码需要搜寻到 archive的末尾,当它想找到一个 key而 archive中实际并不存在这样的 key时。下面的一些选项就是用来避免这种情况。

重要的rspecifier选项是:

  • “o”(once) 是用户声明给RandomAccessTableReader每个 key只被请求一次。这样就不必把已经读过的对象储存在内存中以防万一它们会再被用到。
  • “p”(pemissive) 指示代码忽略掉错误,只提供有效数据;无效数据会被视为不存在。在 scp文件中,一个查询Haskey()强制加载相应的文件,而如果该文件已损坏,代码知道返回错误。在 archive中,这个选项避免了由 archive损坏或截断所引起的异常(它只是不会读那样的点)
  • “s”(sorted) 指示代码 archive中的 key是按字符顺序排序的。对RandomAccessTableReader来说,这意味着当Haskey()查询某个不存在 archive中的 key时,它在遇到一个“更高”的 key时就会立刻返回错误;而不必读到文件末尾
  • “cs”(called-sorted) 指示代码调用Haskey()Value()时是按照字符顺序的。因此,如果其中一个函数被调用于某个字符串时,读代码就会忽视字符串序低的那些对象。这节省了内存。实际上,“cs”代表用户声明了程序可能在遍历的其他 archive是排好序的。

如果用户错误地提供了上面的选项,e.g.对实际上没有排序的 archive给定“s”选项,RandomAccessTableReader代码会尽可能地检测出此错误并停止工作。

为了对称和方便,也提供下面的一些选项,但是目前并不起作用。

  • “no”(not-once) 是“o”的对立选项(当前代码中,它没有任何作用)
  • “np”(not-permissive) 是“p”的对立选项(当前代码中,它没有任何作用)
  • “ns”(not-sorted) 是“s”的对立选项(当前代码中,它没有任何作用)
  • “ncs”(not-called-sorted) 是“cs”的对立选项(当前代码中,它没有任何作用)
  • “b”(binary) 什么都不做,仅为了写脚本方便
  • “t”(text) 什么都不做,仅为了写脚本方便

使用了很多选项的rspecifiers的典型例子是:

     "ark:o,s,cs:-"
     "scp,p:data/my.scp"

Holders as helpers to Table classes

如之前所提到的,Table类i.e.TableWriter, RandomAccessTableReaderSequentialTableReader,是基于 Holder类的模板。Holder不是类或基类,而是一系列的类,它们的命名以 Holder结尾,e.g.TokenHolderKaldiObjectHolder。(KaldiObjectHolder是一个通用的 Holder,可以作为任意符合如 The input/output style of Kaldi classes 中 Kaldi I/O风格的类的模板)。我们写好了模板类GenericHolder,不为实际使用,只为说明 Holder类必须满足的特性。

Holder类所包含的类是 typedef Holder::T(此处 Holder是实际 Holder类的类名)。可用的 Holder类型列表见 Holder types。

How the binary/text mode relates to the file open mode

本节仅与 Windows平台相关。通用的规则是,当写入时,文件模式要与Write函数中的“binary”变量值一致;当读二进制数据时,文件模式永远是二进制,但是读文本数据时,文件模式可以使二进制或文本(因此文本模式的读函数必须接受 Windows插入的额外“\r”字符)。这是因为直到打开一个文件,我们永远也不知道它的内容是二进制还是文本,所以当不确定的时候,我们以二进制方式打开。

Avoiding memory bloat when reading

当 Table代码随机访问大的 archive时,可能发生内存膨胀(memory bloat)。这可能发生在RandomAccessTableReader<SomeHolder>的一个对象读取 archive时。Table代码在编写时被优先保证正确性,所以当以随机访问方式读 archive时,除非你给定了额外的信息(也是下面要讨论的),它不会丢掉它已经读过的对象,以防后面还会被再次访问。一个明显的问题是:为什么 Table代码不直接跟踪对象在文件中的起点,然后用 fseek()定位到那个位置呢?我们还没实现这一点,原因如下:你能用 fseek()的前提是被读的 archive是一个实际的文件(i.e.不是一个管道命令或标准输入)。如果 archive是一个磁盘上的实际文件,你在写它时很可能已经附含了 scp文件来标识偏置信息(用“ark,scp”前缀,见 Writing an archive and a script file simutaneously),并提供了这个 scp文件给程序来帮助读 archive。这几乎和直接读 archive一样高效,因为代码读入 scp文件后可以避免重复打开不需要的文件或不必要地调用 fseek()。所以把 archive文件看做一个特列并在文件中附加偏置信息并不会解决这一问题。

当以随机访问模式读 archive时会产生两个独立的问题,如果你用“ark:”前缀并不提供额外选项,它们可能同时产生。

  • 如果你请求一个不存在 archive中的 key时,读代码会被强制读到文档结束来确保它真的不存在。
  • 每次代码读一个对象时,它都被强制保留在内存中以防后面还会被请求到。

关于第一个问题(要一直读到文件末尾),避免它的方式是确保 archive按照 key排序(按照通用的“C”字符串顺序排序,即“sort”代码所用的那样,如果指定“export LC_ALL=C”)。你可以在读 archives时用“s”选项来实现:例如,rspecifier“ark,s:-”指示代码读标准输入时将其看做 archive并期望它是有序的。Table代码会检测你声明的是否是真,如果不是将会停止运行。当然,你应该设置你的脚本,保证 archives实际上是按 key排序好的(通常这在特征提取阶段就会被完成)。

关于第二个问题(强制在内存中保留已经读过的内容),有两个解决方案。

  • 第一个解决方案,是一个不太稳定的方案,即提供“once”选项;例如,rspecifier“ark,o:-”从标准输入中读数据并认为每个对象你只会请求一次。为了确保这一点你必须知道在处理问题的程序是如何工作的,而且你应该知道提供给程序的其他 Table不包含重复的 keys(是的,Tables可以包含重复的 keys只要它是被顺序访问的)

    如果你提供“o”选项,Table可以再访问对象后释放它们。然而,这只在你的 archives完全同步对齐,且不存在空隙(gaps)或缺少元素时才能有效。例如,假设你执行以下命令:

    some-program ark:somedir/some.ark "ark,o:some command|"
    

    程序“some-program”首先会顺序遍历 archive“somedir/some.ark”,然后对于它遇到的每一个 key,随机访问第二个 archive。注意命令行中变量的顺序不是任意的:我们采用了这样的惯例,被顺序访问的 rspecifiers出现在被随机访问的 rspecifiers之前。

    假设两个 archives大部分都对齐了,但是有很多空隙(i.e.丢失的 keys,e.g.由于特征提取、数据对齐等产生的错误)每当第一个 archive中有空隙时,程序就需要缓存第二个 archive中的相关对象,因为它不知道它们在后面会不会被访问(它只能丢弃已经读过了的对象)。第二个 archive中有空隙时,问题会更严重,因为哪怕只有一个元素有空隙,当程序请求这 个key时,它就要一直读到第二个 archive的末尾来寻找,并且缓存这一过程中遇到的所有对象。

  • 第二个解决方案比较鲁棒,即用“called-sorted”(cs)选项。这确保对象会被顺序地请求,同样地,这也需要知道程序是如何工作的,而且所有被顺序访问的 archives都是排好序的。“cs”选项一般和“s”选项一起用时效用最高。假设我们执行以下命令:

    some-program ark:somedir/some.ark "ark,s,cs:some command|"
    

    我们假设两个 archives都是排好序的,程序会顺序访问第一个 archive,随机访问第二个 archive。这样就对空隙鲁棒了。想象第一个 archive中存在一个空隙(e.g.它的 keys是001, 002, 003, 081, 082, ...)。当在第二个 archive,搜索完 key 003后搜索 key 081时,代码会遇到 keys 004, 005, ...,但是它能忽视这些对象,因为它知道081之前的 key都不会再被请求(感谢“cs”选项)。如果第二个 archive中存在空隙,因为是排好序的,它能避免要搜索到文件末尾(这是“s”选项的作用)

io_sec_mapped

为了压缩很多程序中反复出现的特定代码模式,我们已经介绍了模板类RandomAccessTableReaderMapped。不像RandomAccessTableReader,这需要两个初始化参数,比如:

std::string rspecifier, utt2spk_map_rspecifier; // get these from somewhere.
   RandomAccessTableReaderMapped<BaseFloatMatrixHolder> transform_reader(rspecifier,
                                                                         utt2spk_map_rspecifier);

如果utt2spk_map_rspecifier是空字符串,它和普通的RandomAccessTableReader表现得一样。如果不是空,e.g.ark:/data/train/utt2spk,它会读一个 utterance-to-speaker 的映射,并在任何查询到特定字符串 e.g.utt1的地方,将这个 utterance-id 映射到一个 speaker-id (e.g.spk1)然后用这个作为key来查询从 rspecifier读到的 table。这个 utterance-to-speaker映射也是一个 archive,因为这碰巧是 Table代码最容易读的方式。