构建Unicode归一化数据

什么是Unicode归一化数据呢?顾名思义,就是把Unicode进行归一化,以方便统一地进行处理。我之所以着手做这项工作,一个重要原因是我看到了前人在这方面做过的努力,我希望做一份实用性更强,并且兼顾可读性的归一化数据。

在这份数据中,我采用“字形”作为依据来进行归一化,这一点恰好契合了一篇Unicode技术标准的要求,也就是《tr39:Unicode安全机制》一文。该文提到了一种“骨架算法”(Skeleton algorithm),用于比较两个Unicode字符是否为形近字。在该算法的解释中提到两次运用NFD来拆解Unicode。NFD是Normalization Form Canonical Decomposition的缩写,是一个Unicode归一化的标准,用来将一个字拆解为组成这个字的各个基础部分1 在骨架算法中还用到了Unicode形近字(confusables)库,这个我后面也会用到。但我在第一步没有了解到这一点。

那么构建这样一份数据的意义何在呢?我根据我个人的想法总结以下几点供大家参考:

  1. 破开伪装(antispoof)。在网站用户注册方面,破坏者可能会注册与正常用户形近的用户。维基百科的用户注册就加了这方面限制,与其他用户名的Unicode形近的用户名,直接禁止注册。
  2. 内容审查。在开放编辑的百科(如维基百科),以及论坛、聊天群组中,常常需要对特定的词汇进行控制。然而就会有一些用户使用Unicode意义上的形近字避开审查。当然如果想要更强,可以结合IDS数据,或是Unihan数据的读音,来把更广泛的相似的字也囊括在内。
  3. 简化代码。在字符串匹配中,如果我们想同时匹配简繁体,或者形近字也匹配上,往往会在正则表达式里面用class语法,比如[电電]影。但在使用归一化数据处理一遍文字之后,可以避免这样的语法。

下面就让我来说一下我具体是怎样构建数据的。虽然构建完数据,其实就是一个简单的对应表,但想要基于其他数据集,构建一个符合我们预期的归一化数据,还是需要一套合适的流程的。那么我就介绍一下。

初版:ccnorm

有了想法之后,我就在网上查找相关的数据。维基百科的防滥用过滤器(Abuse Filter)中的ccnorm函数是我首先想到的。因为我们维基百科的开放性,破坏者层出不穷。全自动的过滤器的存在大大减轻了我们人工巡查的工作,在用户提交编辑的时候,先进过滤器,如果过滤器通不过,就根本不会提交到正式的条目历史中去。

ccnorm正是过滤器语法提供给我们使用的一个函数,效果就像下面这样

阅读代码,我发现这个函数引用的是另一个库的数据,叫做Equivset。Equivset从字面就能看出来,equivalent set嘛。也是WMF官方的一个库。采用PHP将原始数据处理成更方便使用的格式。基金会似乎真的是偏好PHP。一个完全没有必要用到PHP的地方都要秀一下PHP。

这个库正是我想要的,它会把拉丁文的A和希腊文的A画等号。还会把美元符号$和拉丁文S等价。但我没想到它不止于此。打开它的原始数据,我发现这样的描述:

我们试图包括以下类型的对等:

本列表是基于尼尔·哈里斯制作的一个列表,那个列表是通过未知的方法得出的。该列表还包含音译对,我们认为这些对过多,并试图删除。例如,拉丁字母E和H被认为是等价的,因为西里尔字母“Н”(看起来像拉丁语H)的拉丁音译是“E”。

起初我认为它的中文字符只是简繁体对应,但我发现并非如此,它会还会把形近的字归在一起,正如我们前面提到的Unicode confusable干的事那样。但正如这个描述,这是Neil Harris通过不知什么办法搞出来的,但经过我认真的检查,发现确实准确性较高。连一些古汉语通假字都认为是等价的,这让我感觉很不可思议。经过调查,这个Neil Harris是一个活跃在Unicode邮件列表的人,我觉得比较可信,所以我会把这份数据作为基础,来构建我自己的ccnorm数据。

事实上我之所以不直接用这份现成的ccnorm,而再次处理的原因之一是,这份列表关于中文简繁体的部分有时会归到繁体,甚至异体字上面。虽然Unicode tr39提到,skeleton算法关注的是把形近字归一化,并不会考虑归一化后的可读性。但我希望,我构建的数据集是关注这一点的,而且是以简体中文为中心的。所以我的第一想法就是把我之前用过的简繁转换对应表找过来。大概就是这样:

这套转换是网上广为流传的。也不知道是谁整理的,很早以前就有。有了这个对应表之后,对于不符合这个规则的,调一下顺序就好。但是这个简繁转换的库实在是不全。而且如果同一组里找错了转换的对象,会对后面可读性造成影响。不过出来的结果还算满意,只不过总是要因为有些字符没有找到合适的转换,而无法进行正确的交换。为此,我增加了更强大的OpenCC单字转换库。OpenCC是最流行的开源简繁转换库,它的数据也确实很好用,有了它的数据,我几乎不需要手工再去补充简繁数据了。但异体字依然有一定数量。

我终于意识到或许有更简便的算法。我直接拿国标的字去每一组候选字里找不就完了吗,费劲找繁简对应干什么。那么国标的汉字列表去哪里找呢?我先搜索了GB2312,然后从一个维基百科的链接里找到了通用规范汉字表这个名词,于是在维基文库的原始文档里找到了整个列表。与其自己去wikitext里面取出来处理,不如找GitHub上别人处理好的。一开始我找到了chinese_character这个项目,然后成功的写出了改版后的ccnorm。效果好多了,好到我可以去QQ群通知大家帮我测试了。

增强版:eccnorm

也许读到上面你会以为我已经完成了,其实我当时也确实觉得差不多了。可是我发现我忽略了什么。没错,有些汉字即便是在规范汉字表中,也是出现多个。这让我想起了我看到通用汉字表的时候,好像分了一级、二级、三级的。于是我再回GitHub,找到common-standard-chinese-characters-table这个项目,里面有分好级的数据。于是代码重新写过,成为了下面这样子:

你或许看出了我的意图。经过精细处理的归一化库甚至可以作为简繁转换库来使用。虽然有合并到其他汉字的可能,但一定是简体字,而且是比这个字更常用的字。所以一般不会出错。因为常用的字永远是优先的。而我们日常接触到的文本中也大部分是常用字。

这就是ccnorm的最后一版。也是eccnorm的基础。要知道,群友永远有出不完的花样。果然没经过多久测试,就暴露出ccnorm的数据不足的问题了,这主要是因为我们一开始作为基础的Equivset在收集数据上是不足的缘故。据我猜测,Equivset的数据很多都是基于非官方人工收集,虽然已经足够好了,但是不够全。尤其表现在连NFD的映射表都不能囊括,还有就是Unicode官方提供的Confusables没有融入进来。

举个例子,Unicode有一个区域叫做康熙部首的,英文叫Kangxi radicals,存着一些和正常汉字长得一模一样的字,但是作为偏旁部首存在,所以独立占一个位置。比如这个字“⼼”,看着像“心”,但是你用程序去比,会发现不相等。下图是康熙字典里面汉字的部首分布,蛮有意思,所以给大家展示在这里。

Kangxi Radicals

通过Confusables就可以了把这部分补上,而且Confusables里面还有大量其他语种的homoglyph的例子,如果融合了这部分,那我的数据集将会上一个新台阶。不过由于我认为会带来比较大的影响,所以重新起名eccnorm,意为extended ccnorm。

那么话不多说我们来看看实现细节。首先我们NFD数据用的是WMF的另一个库Scribunto的ustring里面的normalization-data.lua。这个数据直接就是Lua的,实在没有再好用啦。另外我们还会用到之前生成ccnorm没有提到的normset,也就是一个从归一化后的文字逆向对应的表,形如下面这样

处理Confusables的代码大致如下,homo_pairs即ccnorm:

从上面的代码中可以看出,我是更加信任Unicode Confusables的,给了它更高的优先级。但是它有个问题就是英文字母归一化没有Equivset更加全面,所以如果是英文字母的话我们进行了特殊操作。此外我还利用Confusables的数据把原来ccnorm的数字纠正了,一旦有Confusable的指向存在,直接忽略原来的ccnorm。

接下来就是NFD的事情了。从下面的代码可以看出,基本上就是最Unicode的范围做了一些排除,尤其是长得奇奇怪怪的那种。因为我要拿出它NFD后的第一个字符作为归一化的结果,如果不排除的话,很多附加符的混合也被归一化成单个附加符了,没有必要。所以直接抛弃他们比较方便。与Confusables对比,可以看出NFD的结果我给它的优先级比ccnorm低,一旦有ccnorm,那我就会避免采用NFD的结果。

这其中有一个工具函数是用的我自己的实现,就是utf8char,把Unicode转成UTF-8编码。应该是在全网都没有相同的实现。因为常见的实现都会限制在4个字节内(即最高编码到U+1FFFFF),我这个实现是自己瞎搞的,所以不必在意4个字节的限制,超出4个字节就会继续,可以编到U+7FFFFFFF都没有问题。虽然RFC 3629限制了UTF-8只能编码到U+10FFFF,但不要让RFC限制我们的想象嘛,哈哈。

在这些数据的加持下,我的eccnorm就终于完成了,代码也上传到了https://github.com/AlexanderMisel/ccnorm

实际运用时的小改进

在实际使用中,遇到群友使用附加符号(diacritics)捣乱的情形。的确,对于标准的Unicode归一化我们游刃有余了,但是加上了diacritics的文字又变得难以匹配了。但还好diacritics在Unicode的范围非常集中,所以我就稍稍排除了一下,大家可以参考这段代码

这样c̳̻͚̻̩̻͉̯̄̏͑̋͆̎͐ͬ͑͌́͢h̵͔͈͍͇̪̯͇̞͖͇̜͉̪̪̤̙ͧͣ̓̐̓ͤ͋͒ͥ͑̆͒̓͋̑́͞ǎ̡̮̤̤̬͚̝͙̞͎̇ͧ͆͊ͅo̴̲̺͓̖͖͉̜̟̗̮̳͉̻͉̫̯̫̍̋̿̒͌̃̂͊̏̈̏̿ͧ́ͬ̌ͥ̇̓̀͢͜s̵̵̘̹̜̝̘̺̙̻̠̱͚̤͓͚̠͙̝͕͆̿̽ͥ̃͠͡也可以就可以了直接转换为CHAOS了,一步到位。这就是有关构建Unicode归一化数据的内容。

 


1 这是我自己的描述。其实准确定义NFD是不好定义的。这里“基础部分”与汉字拆解意义不同。它强调的是把附加符拆掉(比如把ü拆成u¨),以及把合字拆开(比如把德语字母ß拆成ss)。而拆解汉字实际类似于转换成IDS(表意描述序列)。所以二者是不同的。