[转]算术编码+统计模型=数据压缩 - 第二部分:统计模型

转自:http://deercrane.spaces.live.com/blog/cns!8BEF692B75EB8095!189.entry

算术编码 + 统计模型 = 数据压缩 - 第二部分:统计模型
(撼庭秋译自http://compression.graphicon.ru/download/articles/ppm/nelson/arithmetic2.htm

作者:Mark Nelson

  这两部分系列文章的第一篇以一些细节准确地解释了什么是算术编码。概括地讲,算术编码提供使用最佳位数目编码符号的方法。由每个符号所使用的位数不一定像霍夫曼编码(Huffman coding)的情况下一样是一个整数。为了使用算术编码压缩数据,需要给数据一个模型。这个模型需要能够完成两件事来有效地压缩数据:

·模型需要精确地预计输入数据流中符号的频率/概率。
·由模型生成的符号概率需要脱离均匀分布。

  本文将讨论有效达成这些目标,获取高性能压缩的一些方法。

建模

  需要精确地预计输入数据中符号的概率是算术编码中固有的本质。这类编码方法的原理是随着字符出现概率的增加而减少编码这个字符所需要的位数目。因此,如果字母“e”出现在输入数据的概率是25%,它将只使用2位来编码。如果字母“Z”在输入数据中出现的概率只有1%,它可能要用10位来编码。如果模型没有精确地生成概率,它可能用10位来表示“e”并且用2位来表示“Z”,原因是用数据展开代替了压缩。

  第二个情况是模型需要创建脱离均匀分布的预计。体现在创建脱离均匀的预计上越好的模型,将获得越好的压缩率。例如,模型可能是按给所有256个可能的字符指定均匀分布的概率1/256来创建的。这个模型将创建一个与输入文件大小严格一样的输出文件,因为每个符号将严格地用8位来编码。只有通过正确地找到脱离均匀分布的概率才能减少位数目,而获取压缩率。当然,如第一种情况所规定,增加的概率需要精确地反映事实。

  一个给定符号出现在数据流中的概率似乎是固定的,但是不完全是这样。取决于所使用的模型,字符的概率可以改变很多。例如,当压缩一个C程序时,换行符在文本中的概率可能是1/40。可以通过扫描整个文本并通过字符的总数除以这个字符出现的次数来决定这个概率。但是如果我们使用一个看前一个单个字符的建模技术,这个概率就变了。在这种情况下,如果前一个字符是“}”,换行符的概率增长为1/2。这改善了建模技术并获得了更好的压缩,尽管这两种模型都生成精确的概率。

有限上下文建模

  我将要在本文中介绍的建模类型称为“有限上下文”建模。这种建模类型基于一个非常简单的思想:每一个输入符号的概率都是基于符号出现的上下文中计算而来。在所有我将在此说明的例子中,上下文由除前面已经碰到的符号外,没有更多的东西所组成。模型的“序”称为建立起环境的前面字符的数目。

  最简单的有限上下文模型将会是一个0序模型。这意味着每一个符号的概率独立于任何前面的符号。为了实现这个模型,需要的所有事情是一个包含可以在输入流中碰到的每一个符号的频率计数。对于1序模型,你要明了256个不同的频率表,因为你需要为每一个可能的上下文保持独立的一组计数。同样,2序模型需要能够处理65,536个不同的上下文表。

自适应模型

  随着模型序的增长,压缩率也应该随之改善,这似乎是合乎正常的逻辑。例如,符号“u”在本文中的出现概率可能只有5%,但是如果前一个上下文字符是“q”,概率增长至95%。能够预测具有高概率的字符所需要的位数目较少,以及较大的上下文应该会让我创建更好的预测。

  不幸的是,随着模型序的线性增长,模型所消耗的内存呈指数级增长。使用0序模型,由统计消耗的空间可能跟256个字节一样小。一旦模型的序增长至2或者3,甚至是很聪明地设计的模型也要消耗数百K字节。

  压缩数据的一个便利的方法就是单程扫描一遍要压缩的符号为模型收集统计。然后进行第二遍扫描真正地编码数据。然后统计常常预先考虑压缩的数据,因此解码程序将有一个统计的副本与其一起工作。如果模型的统计比要压缩的数据消耗更多的内存,这个方法显然将会有严重的问题。

  这个问题的解决方法是执行“自适应”压缩。在自适应数据压缩中,压缩程序和解压缩程序都以相同的模型开始。压缩程序使用已的有模型编码符号,然后更新模型以说明新的符号。解压缩程序同样使用已有的模型解码符号,然后更新模型。对压缩程序和解压缩程序来说,只要更新模型的操作一致,那么处理可以完美地操作而无须从压缩程序传送统计表给解压缩程序。

  自适应数据压缩有一个稍微不利的一点就是它使用不如最佳的统计开始压缩。不过,通过减少随压缩数据传输的开销,自适应算法通常比固定统计模型执行得更好。

  自适应压缩真正要忍受的地方是在更新模型的开销中。当使用算术编码更新对某个特定符号的计数时,更新代码有为所有其它符号更新累积的计数的潜在开销,导致平均必须为每个单个符号的编码和解码执行128次算术操作。

  因为在内存和CPU时间两者中的高开销,较高序的自适应模型可能在以后10年内变得可以实用。有点讽刺意味的是,随磁盘空间和内存的降价,压缩存储在其中的数据的开销也在降低。随着这些开销持续下降,我们将能够实现比今天可以实践的程序更加有效的程序。

一个简单的例子

  0序压缩程序的一个例子在此说明在列表6-9中。(列表达式1-5在上个月的文章中。)COMP-1.C是压缩程序驱动,EXPAND-1.C是展开程序驱动。MODEL.H和MODEL-1.C组成用于实现0序统计模型的建模单元。

  当使用一个像这个例子程序一样的固定序的模型时,压缩程序自身相当简单。程序只需要在循环中不停地从纯文本文件中读取字符,将字符转换成算术编码,编码符号,然后更新模型。

  在MODEL-1.C中的建模代码是理解这段代码是如何工作的关键。在上个月的文章中,我们看到每个字符在从0.0至0.1的基础上放大的范围中是如何“拥有”概率范围的。为了用整数实现算术编码,这个所有权按照从0到最大计数重新声明为底计数和顶计数。统计有多精确留在MODEL.C中。

  使用MODEL-1的所有可能符号的计数存放在一个称为totals[]数组中。对于每一个c符号,底计数放在total[c]并且顶计数放在totals[c+1]。所有符号的总范围放在totals[256]。这三个计数就是需要传送给算术编码程序来编码一个给定符号的东西。

  这使查找符号的计数的操作非常直接。不过,在符号编码或者解码后更新totals[]数组是另一个麻烦事。为了更新符号c的累积计数,在totals[]中从c上至256之间的每一个计数都需要增加。这意味着对编码和解码每一个字符平均要执行128个增加操作。对于像使用MODEL-1.C的简单验证程序来说,这并不是一个主要的问题,但是对于产品程序应该修改得更有效率一些。

  减少增加操作数目的一个方法就是将最频繁访问符号的计数移到数组的顶部。这意味着模型必须明了每一个字符在total[]数组中的位置,但是使增加操作的数目有一个数量级的减少,会产生一个正面的负作用。这是对这个程序相对简单的增强。使用这个技术的一个非常好的程序示例是发表在1987年6月期的《Communication of the ACM》的论文《Arithmetic Coding for Data Compression》的一部分。这篇论文由 Ian H.Witten、Radford Neal和John Cleary合著的论文是关于算术编码的出色的信息资源,并且一些简单的C源程序来说明其内容。

性能

  COMP-1是一个只需要非常少内存的小程序。因为它只维护0序统计,当与那些通用的压缩程序相比,它压缩得并不是特别好。例如,当压缩C代码时,COMP-1通常会将文本压缩成每个字节大约5位。而这并不足以吸引人,但对于需要在内存宝贵的环境中运行的实现来说会很有用。如果只是有一点内存可用,维护排序的列表将增加压缩和展开的速度,而不影响压缩率。

改进

  COMP-1创建一个足够用的验证程序,但是它可能并不能让每个人都很兴奋。它压缩得比0序霍夫曼编码要好一点,但同时远不及像ARC或者PKZIP这样的程序。为了超过这些程序的压缩率,我们需要开始给模型代码加入一些增强功能。所有增强的顶点都在用于创建一个压缩程序和一个展开程序的COMP-2.C和EXPAND-2.C以及MODEL-2.C中找到。

最高序的建模

  对于用于此处的模型的每一个增强是增加模型的序。0序模型在计算文本文件中当前符号的概率时并不重视前面任何的任何符号。通过考虑文本文件中前面的符号,或者“上下文”,我们可以更精确地预测输入符号。

  当我们提升到2序或者3序模型时,一个问题立刻变得很明显。验证前面上下文使用的一个好例子就是在文字处理文本文件中尝试预测“REQ”后面会出现哪个字符。下一个字符是“U”的概率至少应该是90%,我们编码这个符号少于1位。但是我们使用自适应模型的时间可能是在我们建立起足够大的统计体来开始用高概率预测“U”之前的某个时间。

  在固定序模型中的问题是,每一个字符必须有一个有限非零概率,因此如果并且当它出现时就能被编码。因为我们并没有任何关于我们的输入文本会有什么类型的统计方面的预备知识,我们通常需要从给每一个字符指定一个相等的概率开始。这意味着如果我们包括了EOF符号,从-1至255之间的每一个字节都有1/257出现在“REQ”之后的机会。现在,尽管在这个特别的上下文之后字母“U”在一行中出现了10次,它的概率将只是增加至11/267。当剩余的符号十有八九将不再可见时,它们占用概率表中的有值空间。

  解决这个问题的方案是在给定的上下文中将所有符号的初始概率设为0,并且在前面未曾见过的符号出现时有方法回退到一个不同的上下文中。通过发出一个称为转义码(Escape code)的特殊编码来做到这一点。对于前面的“REQ”的上下文,我们可以将转义码的计数置为1,并且所有其它符号的计数置为0。在“REQ”后面字符“U”第一次出现时,我们将必须在一个不同的上下文中“U”的编码之后发出转义码。在随后立即更新模型期间,我们把在“REQ”上下文中的“U”的计数置为 1,因此它现在的概率为 1/2。根据随着字符概率的递增它的每一次出现所需要的位数目是递减的原则,下一次它出现时,将只需用1位来编码它。

  然后明显的问题是:在发出转义码之后,我们使用称为我们的“回退”上下文做什么呢?在MODEL-2中,如果3序上下文生成一个转义码,下一个要尝试的上下文是2序上下文。这意味着上下文“REQ”第一次被使用,并且“U”需要被编码,然后生成一个转义码。随后,MODEL-2程序回退到2序模型,并且使用上下文“EQ”尝试编码字符“U”。这会继续向下进行直到0序上下文。如果在0序仍然生成转义码,我们回退到一个特殊的序(-1)上下文。这个-1上下文在初始化时建立,每一个可能符号的计数为1,并且从不会更新。因此它保证能够编码每一个符号。

  使用这个转义码技术意味着对驱动程序只做少许的修改。COMP-2.C程序现在处在一个循环中尝试编码它的字符:

do {
  escaped = convert_int_to_symbol(c, &s);
  encode_symbol(compressed_file, &s);
} while (escaped);

  建模代码负责记住当前的序是多少,并且只要转义码发出时就递减它。甚至更复杂的是明了需要哪一个上下文表用于当前序的建模模块的工作。

更新模型

  最高序建模算法的使用要求我们需要记录从每个序至最高序的全部上下文表,而不是只记录用于最高序的一组上下文表。这意味着如果我们正在进行2序建模,就有一个0序表、256个1序表以及65,535个2序表。当新来字符已经编码过或者解码过时,建模编码需要更新每一个序的相关表。在前一个例子中,“U”在“REQ”之后,建模代码将更新3序“REQ”表中“U”的计数,2序“EQ”表中的“U”的计数,1序“Q”表中的“U”的计数,以及0序表中“U”的计数。

  更新所有这些表的代码看起来如下:

for (order = 0; order <= max_order; order++)
  update_model(order, symbol);

  对这个算法进行少许修改可以产生既可以更快地更新又可以获得更好的压缩。我们可以只更新这些实际参与编码字符的模型,而不是为当前上下文更新所有不同序的模型。例如,如果“U”在“REQ”之后作为转义码(ESCAPE)编码,我们只递增在“REQ”和“EQ”模型中“U”的计数,因为只是在“EQ”表中找到“U”的计数。“R”和“”两个表都不会受影响。

  这个对全部更新方法的修改称为“排除更新”(update exclusion),因为它排除了不用更新的较低序模型。这在压缩率方面通常有一个较小但是值得注意的改时。排除更新的工作基于一个事实,即如果符号在较高序的模型中频繁出现,它们常常不会出现在较低序的模型中,这意味着我们不必在较低序的模型中增加它们的计数。使用了排除更新方法的更新代码看起来如下:

for(order = encoding_order; order <= max_order; order++)
  update_model(order, symbol);

转义概率

  当我们第一次开始编码文本流时,我们会顺理成章地发出相当多的转义码。从这一点看,用于编码转义码的位数目可能会对达到的压缩有较大的影响,特别是对小文件来说。在MODEL-2A.C中,我们将转义码的计数置为1并将其保持为1,而不管剩余上下文表的状态。这与Bell、Cleary和Witten称为“A 方法”(Method A)相当。B方法(Method B)将转义字符的计数置为目前定义在上下文表中符号的数目。因此,如果目前为止已经见过十一个不同的字符, 那么转义符号的计数不管是多少将置为11。

  A方法和B方法两种方法似乎都工作得相当好。在MODEL-2.C中的代码可以很容易地进行修改以支持这两种方法之一。对于A方法和B方法最好的事情可能是它们都不需要密集的计算。当使用A方法时,可以将转义符号加入表中,更新有这个符号的表并不会比更新没有这个符号的表耗费更多的工作。

  在MODEL-2.C中,我已经实现了一个些微复杂点的转义符计数的计算算法。这个算法在计算转义符概率时尝试考虑三个不同的因素。首先,随着定义在上下文表中的符号数目的增加,转义符概率将会减少,这似乎是有意义的。当表中的256个符号全部定义后,它达到它的最大值,使转义符的概率成为0。

  其次,这个算法尝试考虑表中对随机性的估量。通过将可以在表中找到的最大计数除以平均计数可以计算出这个随机量。这个比率越高,表的随机性就越低。在“REQ”表中有一个好例子。它可能只定义了三个符号:有50个计数的“U”、10个计数的“U”和3个计数的“.”。“U”的计数50与平均计数21的比率相当高。这意味着字符“U”可以用相当高的精确度来预测,并且转义符的概率应该更低。在一个高计数是10,并且平均计数是8的表中,似乎有更多一点随机性,并且转义符的概率可能更高。

  最后一个因素在通过简单地计算对于这个特定的表来说已经见过多少个符号来判断转义符概率的时候考虑。随着符号数目的增长,表的可预测性上升,使转义符的概率下降。

  我用于计算转义符号的计数数目的公式在下面说明:

count = (256 - number of symbols seen) * number of symbols seen
count = count / (256 * the highest symbol count)
if count is less than 1
  count is 1

  在这个等式中丢失的变量是未出现符号的计数。这是隐含在计算中,因为转义符概率是转义符计数除以未出现符号计数。 未出现符号计数将自动地将转义符的计数缩小成概率。???

记分板

  当使用最高序建模技术时,一些额外的增强可以用来改善压缩效率。当我们首先尝试使用最高序上下文压缩符号时,我们可以生成符号的编码,或者生成转义码。如果我们生成转义码,它意味着符号以前在这个上下文中没有出现过,因此我们可以将其计数为0。但是我们通过生成转义码获得了一些信息。我们可以考虑转义符的上下文并且生成与要被编码的符号不匹配的符号列表。然后在我们在较低序模型进行计算时,可以将这些符号临时计数为0。在这个特定的字符的编码工作完成后,再将这些符号的计数恢复成它们的永久值。

  下面说明一个例子。如果现在的上下文是“HAC”,并且下一个符号是“K”,我们将使用下面的表来编码K。不用记分板,“HAC”上下文生成一个具有1/6概率的转义符。“AC”上下文生成具有1/7概率的转义符。“C”上下文生成具有1/40概率的转义符,以及“”上下文最后生成具有1/73概率的“K”。

   ""       "C"       "AC"      "HAC"
-------------------------------------------------------------------------
 ESC   1   ESC   1   ESC   1   ESC   1
 'K'   1   'H'   20   'C'   5   'E'   3
 'E'   40   'T'   11   'H'   2   'L'   1
 'I'   22   'L'   5           'C'   1
 'A'   9   'A'   3

  如果我们使用记分板来排除前面见过的字符的计数,我们可以在这些概率中做出重大的改善。第一个“HAC”的转义符的编码不受影响,因为之前并没有看到字符。不过,“AC”的转义码从它的计算中消除“C”符号,结果得到的概率是1/3。“C”转义码从它的概率中排除“H”和“A”的计数,概率从1/40上长到1/17。并且最终,“”上下文通过排除“E”和“A”的计数,将概率从1/73上升到1/24。这使编码这个字符所要求的位数目从14.9降为12.9,可观的节约。

  保持符号记分板在压缩中大多数情况下都会得到一些改进,并且它从不会将事情弄糟。使用记分板的主要问题是所有较低序上下文的概率表在每次表被访问时都必须重新计算。这导致编码文本所要求的CPU时间大大增加。记分板留在MODEL-2.C中,以便在使用它压缩文本时验证可能获得的结果。

数据结构

  对基本建模的所有改善假设较高序建模实际上可以在目标机器上完成。随着序的增加内存是问题。对于 MODEL-1中的0序模型,累积的总表占用516字节的内存空间。如果我们对1序使用相同的数据结构,内存的使用将暴涨到133K字节,这仍然是一个可以接受的数目。但是增到达2序时统计单元的内存需求将增至34M字节!因为我们喜欢能够试验甚至比2序更高的序,我们需要重新设计数据结构来容纳计数。

  为了节省内存空间,我们必须重新设计上下文统计表。在MODEL-1中的表通过将每个符号被用作直接索引到一对计数,来使其自身尽可能地简单。在1序模型中,通过一次在上下表的数组中的索引来找到合适的上下文表,然后再次索引正在被讨论的符号,代码看起来如下:

low_count = totals[last_char][current_char];
high_count = totals[last_char][current_char+1];
range = totals[last_char][256];

  这很方便,但是有巨大的浪费。首先,全部的上下文空间甚至没有使用的表都被分配了。其次,在这些表中,为所有的符号都分配了空间,而不管这些符号有没有见过。这两个因素导致在较高序模型中有巨大数量的内存被浪费。

  第一问题,即为没有使用的上下文预留了空间的解决方法是将上下文表组织成一个树。我们可以从将0序上下文表放在一个已知位置开始,然后用它来包含指向1序上下文表的指针。1序上下文表将包含它自身的统计,以及指向2序上下文表的指针。这样继续下去,直到上下文树的叶子包含了n序表,而没有指向更高序的指针为止。

  通过使用树结构,我们可以将没有使用的指针节点置为空指针,直到上下文出现。一旦上下文出现,创建一个表并将其加入到树结构的父节点上。

  第二个问题是每次新表创建的时候也创建了表中的256个计数。事实是,最高序的上下文经常只有少数几个符号,因此我们可以通过只为那些在某个上下文中可见的符号分配内存来节省许多空间。

  在实现了这些改变后,我得到了一组如下的数据结构:

typedef struct {
    unsigned char symbol;
    unsigned char counts;
} STATS;

typedef struct {
    struct context *next;
} LINKS;

typedef struct context {
    int max_index;
    STATS *stats;
  LINKS *links;
    struct context *lesser_context;
} CONTEXT;

  新的CONTEXT结构有四个主要元素。第一个是计数器max_index。max_index告诉当前在某个特定上下文表中定义了多少个符号。当表第一次创建时,它并没有定义符号,并且max_index是-1。完全定义的表的max_index是255。Max_index告诉为*stats和*links所指向的数组分配了多少个元素,每一个元素包含一个符号和对这个符号的计数。如果CONTEXT表并不是最高序的表之一,它也将有一个links数组。对于定义在stats数组中的符号来说,在links表中将会有一个指向下一个更高序CONTEXT表的指针。

  CONTEXT表的树的例子放在在图1中。放在这里的表将在保持最大3序统计的时候,输入文“ABCABDABE”后创建。9个输入符号已经生成了一个相当复杂的数据结构,但是它在数量级上比一个数组的数组成的结构要小。

   Order 0       Order 1       Order 2      Order 3
┌─────────┬─────────┬─────────┬─────────┐
│Context: ""    │Context: "A"   │Context: "AB"   │Context: "ABC"  │
│Lesser: NULL   │Lesser: ""    │Lesser: "B"    │Lesser: "BC"   │
│Symbol Count Link │Symbol Count Link │Symbol Count Link │Symbol Count Link │
│ A   3  "A" │ B   3  "AB" │ C   1  "ABC"│ A   1  NULL │
│ B   3  "B" ├─────────┤ D   1  "ABD"├─────────┤
│ C   1  "C" │Context: "B"   │ E   1  "ABE"│Context: "BCA"  │
│ D   1  "D" │Lesser: ""    ├─────────┤Lesser: "CA"   │
│ E   1  "E" │Symbol Count Link │Context: "BC"   │Symbol Count Link │
└─────────┤ C   1  "BC" │Lesser: "C"    │ B   1  NULL │
          │ D   1  "BD" │Symbol Count Link ├─────────┤
          │ E   1  "BE" │ A   1  "BCA"│Context: "CAB"  │
          ├─────────┼─────────┤Lesser: "AB"   │
          │Context: "C"   │Context: "CA"   │Symbol Count Link │
          │Lesser: ""    │Lesser: "A"    │ D   1  NULL │
          │Symbol Count Link │Symbol Count Link ├─────────┤
          │ A   1  "CA" │ B   1  "CAB"│Context: "ABD"  │
          ├─────────┼─────────┤Lesser: "BD"   │
          │Context: "D"   │Context:"BD"   │Symbol Count Link │
          │Lesser: ""    │Lesser: "D"    │ A   1  NULL │
          │Symbol Count Link │Symbol Count Link ├─────────┤
          │ A   1  "DA" │ A   1  "BDA"│Context: "BDA"  │
          ├─────────┼─────────┤Lesser: "DA"   │
          │Context: "E"   │Context: "BE"   │Symbol Count Link │
          │Lesser: ""    │Lesser: "E"    │ B   1  NULL │
          │Symbol Count Link │Symbol Count Link ├─────────┤
          └─────────┴─────────┤Context: "DAB"  │
                              │Lesser: "AB"   │
                              │Symbol Count Link │
                              │ E   1  NULL │
                              ├─────────┤
                              │Context: "ABE"  │
                              │Lesser: "BE"   │
                              │Symbol Count Link │
                              └─────────┘

                  "ABCABDABE"

                    图1

  在这个结构中我们没有解释的一个元素是lesser_context指针。当使用更高序模型时,对这个指针的需要变得很明显。如果我的建模代码正在尝试定位3序上下文表,它首先扫描一遍0序符号列表以查找第一个符号,匹配,然后扫描1序符号列表,等等。如果在较低序模型中的符号列表相对较满,这个扫描可能会是较长的过程。更糟的情况是,每次生成转义码后,当查找较低序上下文时,这个过程必须重复。这些搜索会消耗过度量的CPU时间。

  这个问题的解决方法是为每个表维护一个指针,这个指针指向比其低一级序的上下文的表。例如,上下文表“ABC”将有它的指向“BC”的向后指针,“BC”有一指向“C”的向后指针,“C”也将有一个指向“”即空表的指针。然后,建模代码需要总是要有一个指向当前最高序上下文的指针。通过这一点,查找order-i上下文表简单地说实质是进行指针操作。

  例如,在图1中说明的表中,假设下一个输入文本是“X”,并且当前的上下文是“ABE”。不需要利用lesser上下文指针的好处,我们不得不为符号“X”检查3、2、1和0序表。这会进行总共15次符号比较,以及3次表查找。使用反向指针消除了所有的符号比较,并且只让我进行3次表查找。

  在维护后向指针中的工作发生在更新模型期间。当更新图1上下文树,以使其在“ABE”之后包含“X”,建模代码必须为每一个序/上下文执行一组单向查找。这个代码放在MODEL-2.C中的add_character_to_model()程序中。每次创建新表时,需要同时创建后向指针,在设计更新程序时要注意这一情况。

最后一笔:表1和2

  对MODEL-2.C中的上下文树的最后一笔是加上两个特殊表。1序表前面已经讨论过。这是一个每个符号具有固定概率的表。如果在任何较高序模型中都符号不能找到符号,我们保证它一定会出现在1序模型中。这个表是最后的手段。因为它必须保证它总是能够为每一个符号提供一个编码,我们不用更新这个表,这意味着它为每一个符号使用一个固定的概率。

  另外,我增加了一个用于将控制信息从编码程序传送到解码程序的特殊2序表。例如,编码程序可以传送一个-1给解码程序来表示一个EOF情况。因为正常的符号总是作为范围从0到255的无符号值来定义的,建模代码将一个负数认作一个生成转回2序表的所有路径的转义码的特殊符号。建模代码也可以检测到因为它是一个负数,当调用update_model()代码时,符号将被忽略的情况。

模型刷新(Model Flushing)

  创建2序模型让我将一个第二控制码从编码程序传送到展开程序。这就是告诉解码程序将统计刷出模型的刷新码(Flush code)。我在压缩程序的性能开始减退时执行这个操作。这个下降比率是可调的,但是我曾经使用过我可以容忍的相当于最坏压缩率90%的统计。当超过这个比率时,我通过将所有的计数除以2来刷新模型。这会给较新的统计更多的权重,这样应该对改善模型的性能有所帮助。

  实际上模型只要在输入符号流中的字符剧烈变化时就可能会刷新。例如,如果程序正在压缩可执行文件,可执行代码压缩期间,累积的统计可能在压缩程序的数据时没有值。不幸的是,定义检测输入的“自然改变”的算法并不容易。

实现

  即使在这里使用稍微复杂些的数据结构,围绕MODEL-2.C的压缩程序和展开程序的创建有巨大的内存要求。当运行在内存限制为640K的DOS机器上时,这些程序也必须限制为1序,或者对于有较高冗余率的文本限制为2序。

  为了在二进制文件上测试较高序的压缩率,对于这些程序来说有三个选择。首先,它们可以使用Zortech C并用EMS_handle指针来编译。其次,它们可以使用DOS Extender,如Rational Systems 16/M来创建。第三,它们可以在支持虚拟内存的机器上,如VMS创建。在这里发布的代码是以尝试在所有这三种选择中可移植的方式编写。

  我发现用额外1M字节的EMS,我实际上在我的PC上使用3序压缩模型来压缩任何ASCII文件。一些二进制文件要求更多的内存。我的Xenix系统使用3序压缩模型没有问题,并且按速度来说全面取得最佳性能。我混合使用多个DOS Extender。出于未知的原因,我使用Lattice C 286和Microsoft C + DOS 16/M的测试比Zortech的 EMS code慢很多,而逻辑将说明与此相反的事实。它也并不是优化的问题,因为当在640K之内时,Lattice和Microsoft实现运行得比Zortech快。用Lattice C/286和Zortech 2.06 code创建的可执行代码可以在DDJ的清单服务上获得。Rational Systems 16/M授权协议要求特许使用金,因此代码不能发布。

测试和比较:CHURN

  为了测试压缩程序,我创建了一个称为CHURN的通用测试程序。CHURN通过遍历一个目录及其子目录来简单地搅拌,压缩,然后展开它在那里找到的所有文件。在每一次压缩/展开之后,比较最初的输入和最终的输出来确保它们是一致的。对时间和空间的压缩统计也加入到累积总数中,并且程序继续。在程序完成之后,它打印出运行完后的累积统计,以便可以检测它们。(CHURN和一个它的Unix变体--CHURNX,出于空间的考虑,在此并不列出。这两个程序都可以从DDJ清单服务上获得。)

  用一个包含要测试数据的目录的名称作为参数调用CHURN。我通常将CHURN的输出重定向到一个日志文件中,以备以后分析,调用看起来如下:

churn c:\textdata > textdata.log

  压缩程序将一些信息写到stderr,因此这此信息在压缩运行的时候可以显示在屏幕上。注意随着测试不同的程序,CHURN.C按照它调用的是spawn()还是system()必须作一些修改。

  在下面表1中说明的是当根据两个不同的输入体测试不同的压缩程序时返回的结果。第一个例子是TEXTDATA,有大约1M字节的一组文本文件,由源代码、字处理文件和文档组成。第二个例子,BINDATA,是大约1M字节的一组随机选择的文件,包括可执行文件,数据库文件以及二进制图像等等。

  我在这两个数据上用七种不同的压缩程序完成了测试。MODEL-1是在本文开头讨论的0序示例模型。MODEL-1A实现1序模型而不需要转义码。MODEL-2A实现最高上下文的1序算法,它将为前面未出现过的符号生成转义码。最后,MODEL-2是一个实现了所有在本文中所讨论的全部改善的最高上下文3序模型。

  就比较而言,三个基于字典的编码方案也可以运行在数据集中。COMPRESS是在Unix系统上广泛使用的16位 LZW实现。使用16位的PC实现占用大约500K的内存。LZHUF是一个由Haruyasu Yoshikazi编写的具有自适用霍夫曼编码阶段的LZSS程序,后来由Paul Edwards修改并投稿给Fidonet。这与用在LHARC程序中的压缩本质上是相同的。最后,由PKWare推出的商业产品PKZIP 1.10,(Glendale Wisconsin),我们也在数据集上进行了测试。PKZIP使用一个在程序的文档中讨论的私有基于字典的方案。

  结果说明了完全优化的MODEL-2算法在此测试的数据集上提供了超强压缩性能,并且在基于文本的数据上执行得极好。二进制数据似乎并不适合统计分析,基于字典的方案在性能上取得与MODEL-2接近一致的结果。

 TEXTDATA - Total input bytes: 987070 Text data
----------------------------------------------------------------------------------
 Model-1  Fixed context order-0.      548218 bytes 4.44 bits/byte
 Model-1A Fixed context order-1.      437277 bytes 3.54 bits/byte
 Model-2A Highest context, max order-1.  381703 bytes 3.09 bits/byte
 COMPRESS Unix 16 bit LZW implementation. 351446 bytes 2.85 bits/byte
 LZHUF   Sliding window dictionary    313541 bytes 2.54 bits/byte
 PKZIP   Proprietary dictionary based.  292232 bytes 2.37 bits/byte
 Model-2  Highest context, max order-3.  299327 bytes 1.94 bits/byte

 BINDATA - Total input bytes: 989917 Binary data
----------------------------------------------------------------------------------
 Model-1  Fixed context order-0.      736785 bytes 5.95 bits/byte
 COMPRESS Unix 16 bit LZW implementation. 662692 bytes 5.35 bits/byte
 Model-1A Fixed context order-1.      660260 bytes 5.34 bits/byte
 Model-2A Highest context, max order-1.  641161 bytes 5.18 bits/byte
 PKZIP   Proprietary dictionary based.  503827 bytes 4.06 bits/byte
 LZHUF   Sliding window dictionary    503224 bytes 4.06 bits/byte
 Model-2  Highest context, max order-3.  500055 bytes 4.03 bits/byte

总结

  根据压缩率,这些测试说明统计建模可以至少与基于字典的方法执行得一样好。不过,由于这些程序有较高的资源需要,它们目前稍微有点不实际。MODEL-2相当慢,以范围在每秒1K之内的速度压缩数据,并且需要大量的内存用于较高序建模。不过,随着内存正在变得更便宜并且处理器变得更强大,在此说明的这类方案可能变得实用。它们在今天可以用于存储或者传输开销极高的情形。

  使用算术编码的0序自适应建模在今天可以有效地应用于要求极低内存消耗的情况。压缩率可能不如更有老练的模型,但是内存的消耗是最小的。

增强

  超过在这里讨论的实现,这些算法的性能可以得到重大的改进。第一个改进的区域将是内存管理。当程序运行时内存溢出,它们立刻终止。更明智的方法将是开始时为统计提供固定数量的有效内存。当统计填满空间,然后程序将停止更新表,并且只使用它有的东西。这将意味着实现内部内存管理程序而不是使用C运行时库例程。

  另一个潜在的改时将在用于上下文表的树数据结构中。通过使用哈希来定位表会相当快,并且可以要求较少的内存。上下文表自身也可以得到改善。当一个表到达超过为其定义的潜在符号的50%的点时,可以使用另一个将计数存储在线性数组中的数据结构。这会允许更快的索引,并且降低了内存需求。

  最后,尝试自适应地修改所使用的模型的序的方法可能是有价值的。当使用3序统计压缩时,输入文本的前面部分在统计表填满时生成许多转义码。开始使用0序统计编码而保持3序统计应该是可能的。随着表被填满,用于编码的序可能会增长直到达到最大值。

列表 6 comp-1.c 0序固定上下文压缩程序的驱动程序。通过从文件中读取符号,将它们转换成顶、底、范围集,然后对它们进行编码,它遵守在BILL.C中为压缩程序说明的模型。 列表 7 expand-1.c 使用0序固定上下文模型的解压缩程序的驱动程序。 列表 8 model.h 需要与在model-1.c或model-2.c中发现的建模代码接口的函数原型和外部变量声明。 列表 9 model-1.h 用于0序固定上下文数据压缩程序的建模模块。 列表 10 comp-2.c 用于可变序的有限上下文压缩程序的驱动程序模块。最大的序由命令行选项确定。这个特别的版本也监控压缩率,并且只要本地(256符号)压缩率达到或者高于90%时,刷新模型。 列表 11 expand-2.c 这个模块是可变有限上下文展开程序的驱动程序。最大的序由命令行选项决定。这个特别版本可以响应由压缩程序插入在位流中的刷新码(FLUSH code)。 列表 12 model-2.c 这个模块包含所有与comp-2.c和expand-2.c一起使用的建模函数。这个建模单元明了从0至max_order所有上下文,缺省是3。 列表 13 churn.c 这是用于测试压缩/解压缩程序精确度、速度和压缩率的工具程序。用一个单个参数调用CHURN.EXE将引起CHURN压缩然后解压缩在指定目录下的每一个文件,检查压缩率和操作的精确度。这是在你认为你的新算法工作正确时可以整天运行的一个好程序。 列表 14 churnx.c

churn程序工作在Unix系统上的版本。

posted @ 2009-11-08 23:54  莫忆往西  阅读(418)  评论(0编辑  收藏  举报