ZMonster's Blog 巧者劳而智者忧,无能者无所求,饱食而遨游,泛若不系之舟

NLP哪里跑: Unicode相关的一些小知识和工具

本文是《NLP 哪里跑》系列的第三篇文章,系列文章如下:

  1. NLP哪里跑: 开篇及一些碎碎念 · ZMonster's Blog
  2. NLP哪里跑: 什么是自然语言处理 · ZMonster's Blog
  3. NLP哪里跑: Unicode相关的一些小知识和工具 · ZMonster's Blog
  4. NLP哪里跑: 文本分类工具一览 · ZMonster's Blog

一、Unicode 简介

我们都知道,所有的数据,在计算机上都是以数字(严格来说是二进制)的形式存在的,文字也是如此,只不过咱们的编辑器、浏览器对这些数字做了特殊处理,将其对应的形状展示出来了而已。在这个基础上,不同的操作系统、平台、应用为了能够正常地交流,就必须约定一个统一的「计算机中的数字」到「实际文字」的对应关系(即编码标准),比方说数字 97 对应小写英文字母「a」、33528 对应「言」字之类的 —— 没错,所谓的编码标准,就相当于一个大的索引表,每个文字在这个索引表里都有一个对应的索引号(也就是我们刚才说到的数字)。

在计算机系统发展早期,其实是并没有这样一个统一的编码系统的,美国一开始就用了 0-127 的值来编码,包括了大小写字母、数字、标点符号以及一些特殊符号,这就是“美国信息交换标准代码(American Standard Code for Information Interchange, ASCII)”。显然 ASCII 是不适用于中文的,所以后来我国推出过 GB2312 标准,收录了 6763 个汉字,并在之后经过扩展有了 GBK 和 GB18030 多个编码标准;另外一方面,港澳台地区又独立发展出了繁体的 BIG5 编码……这些编码都是互相不兼容的,这就会导致使用编码 A 的网站,被使用编码 B 的计算机访问后显示为乱码的状况,而这里只提到了中英文的编码体系,实际上很多国家都有过自己的标准,而且很多是还在使用的。

基于这种状况,后来计算机领域产生了一个叫做 Unicode 的统一编码,又称「万国码」,收录了世界上各个国家大部分的文字,并且仍然在不断增修,今年六月份发布了第十一个正式版本。目前使用最广泛的是 Unicode 实现是 UTF-8 编码。

本文无意就编码标准和编码实现的关系、不同编码之间的同异这类问题做太多展开,只是说一下在做自然语言处理的工作时会涉及到的一些小知识以及相关的工具。

二、Unicode 在 NLP 中的应用

也许读者会疑惑,编码标准不就是字符和索引值的对应嘛,和 NLP 有什么关系呢?

是这样的,Unicode 这个标准,并不是单纯做好所有文字的索引,它还对文字分门别类做了很多的整理,比如说同一个语系的文字会放在索引表的邻近区域,而一个文字是否是数字或标点、数学符号这些信息也都在 Unicode 标准中有记录,并且所有这些数据都是公开的。如果能善加利用这些信息的话,能帮助到咱们在 NLP 工作中对文字进行处理的部分。

这里不准备对 Unicode 数据做系统、全面的说明,如有兴趣,可以前往 http://unicode.org/charts/ 查看完整的 Unicode 数据,这里就以几个例子来讲讲我个人的一些认识和经验吧。

根据 Unihan 数据来从文本中筛选中文字符

Unicode 中中文数据的部分被称为「Unihan 数据库」,在这个页面可以看到 Unihan 中数据的范围。根据 Unihan 数据,我们可以得知在 Unicode 编码里,中文的索引值的范围包括以下几部分:

  • U+3400 - U+4DB5: 「U+」表示这是 Unicode 编码,3400 是十六进制表示,换算成十进制是 13312,下同
  • U+4E00 - U+9FCC
  • U+F900 - U+FAD9
  • U+20000 - U+2A6D6
  • U+2A700 - U+2B734
  • U+2B740 - U+2B81D
  • U+2B820 - U+2CEA1
  • U+2F800 - U+2FA1D

根据这个我们能很容易地写出一个检查某个字符是不是中文字符的方法来,如下

def is_chinese_char(char):
    char_idx = ord(char)
    if 0x3400 <= char_idx <= 0x4DB5 or \
       0xF900 <= char_idx <= 0xFAD9 or \
       0x4E00 <= char_idx <= 0x9FCC or \
       0x20000 <= char_idx <= 0x2A6D6 or \
       0x2A700 <= char_idx <= 0x2B734 or \
       0x2B740 <= char_idx <= 0x2B81D or \
       0x2B820 <= char_idx <= 0x2CEA1 or \
       0x2F800 <= char_idx <= 0x2FA1D:
        return True

    return False

或者写个正则

import re

CHINESE_CHAR_PAT = re.compile(
    r'[\u3400-\u4DB5\u4E00-\u9FCC\uF900-\uFAD9'
    r'\u20000-\u2A6D6\u2A700-\u2B734\u2B740-\u2B81D'
    r'\u2B820-\u2CEA1\u2F800-\u2FA1D]'
)

def is_chinese_char(char):
    return bool(CHINESE_CHAR_PAT.match(char))

以上都是笨办法,因为实际上我们并不需要去记中文的编码范围,而且由于 Unicode 是在扩展的,如果将来扩充了,那么扩充进来的新的字可能就没有办法用上面的方法检查了。前面提到,Unicode 标准做了多方面的整理,而这些整理结果都作为属性附加到每个 Unicode 字符上了。

首先,每个 Unicode 字符都会被赋予一个名字,下面是一部分对照表

Unicode Name
a LATIN SMALL LETTER A
9 DIGIT NINE
CJK UNIFIED IDEOGRAPH-6211
α GREEK SMALL LETTER ALPHA

对于中文,我们只要取其 name,然后判断是否包含 CJK 这个关键词就行了。要怎么获取 Unicode 字符的 name 呢?用 Python 标准库里的 unicodedata 模块即可

import unicodedata

def is_chinese(char):
    return unicodedata.name(char).startswith('CJK')

而除了 name,Unicode 字符还有 block 和 script 两个属性:block 其实就是我们前面提到的连续编码区域,不过会有一个名字;script 是指每个文字的书写体系,可能会包含多个 block,详情见 文档

前面提到的汉字的编码区域和 block 名称的对应关系如下表所示

Block Name Block Range
CJK Unified Ideographs Extension A U+3400 - U+4DB5
CJK Unified Ideographs U+4E00 - U+9FCC
CJK Compatibility Ideographs U+F900 - U+FAD9
CJK Unified Ideographs Extension B U+20000 - U+2A6D6
CJK Unified Ideographs Extension C U+2A700 - U+2B734
CJK Unified Ideographs Extension D U+2B740 - U+2B81D
CJK Unified Ideographs Extension E U+2B820 - U+2CEA1
CJK Compatibility Ideographs Supplement U+2F800 - U+2FA1D

汉字对应的 script 名字是 Han,直接包括了上述所有 block。使用 Python 的 regex 这个工具,可以直接在正则表达式中使用 block 和 script。仍以汉字判断为例,可以这么写

import regex

def is_chinese_char(char):
    return bool(regex.match(r'\p{script=han}', char))

可惜在标准库 unicodedata 中并没有访问 Unicode 字符的 block、script 等属性的方法。

对于其他语言的文字,将上述方法中的参数(编码区域、script 等)稍作修改也是可行的,不再赘述。

用 category 属性判断标点、数字、货币单位等

Unicode 数据中,每个 Unicode 字符还有一个叫做 category 的属性,这个属性和字从属的语言无关。category 一共有 Letter、Mark、Number、Punctuation、Symbol、Seperator、Other 七大类,然后每个大类下还有一些小类,总体上是一个二级分类结构。因此在 Unicode 中有两个字母来组合表示一个 Unicode 字符的类型信息,我们可以用 unicodedata.category 来得到这个信息

import unicodedata

for char in '1天。':
    print(char, unicodedata.category(char))

结果为

1 Nd
天 Lo
。 Po

类型码和分类信息的对照表如下

类型码 类型信息
Lu Letter, uppercase
Ll Letter, lowercase
Lt Letter, titlecase
Lm Letter, modifier
Lo Letter, other
Mn Mark, nonspacing
Mc Mark, spacing combining
Me Mark, enclosing
Nd Number, decimal digit
Nl Number, letter
No Number, other
Pc Punctuation, connector
Pd Punctuation, dash
Ps Punctuation, open
Pe Punctuation, close
Pi Punctuation, initial quote (may behave like Ps or Pe depending on usage)
Pf Punctuation, final quote (may behave like Ps or Pe depending on usage)
Po Punctuation, other
Sm Symbol, math
Sc Symbol, currency
Sk Symbol, modifier
So Symbol, other
Zs Separator, space
Zl Separator, line
Zp Separator, paragraph
Cc Other, control
Cf Other, format
Cs Other, surrogate
Co Other, private use
Cn Other, not assigned (including noncharacters)

如上,标点符号的类型码都是 P 开头的,根据这个就能把标点筛出来了

import unicodedata

def is_punctuation_char(char):
    return unicodedata.category(char).startswith('P')

类似的,货币单位符号的类型码为 Sc,可以直接判断

import unicodedata

def is_currency_char(char):
    return unicodedata.category(char) == 'Sc'

类型码 N 开头的是数字字符,除了我们常见的十个阿拉伯数字外,像罗马数字、带圆圈的数字序号等都被涵盖在内。

此外类型信息也可以在 regex 这个工具里使用,例如

  • 找到文本中所有数字

    regex.findall(r'\p{N}', '第⑩项')
    

    结果

    ['⑩']
    
    
  • 找到各种括号表示开始的那一个

    regex.findall(r'\p{Ps}', '「Unicode」(又名万国码)见《标准》')
    

    结果

    ['「', '(', '《']
    
    

    或找到表示结束那一个

    regex.findall(r'\p{Pe}', '「Unicode」(又名万国码)见《标准》')
    

    结果

    ['」', ')', '》']
    
    

根据这张类型表,我们也可以写出一个用于文本预处理的简单清洗函数来,用来把一些奇奇怪怪的字符都从文本里去掉

import regex

def clean_text(text):
    # 去除明确无意义的字符
    # 1. Zl: Separator, line
    # 2. Zp: Separator, paragraph
    # 3. Cc, Cf, Cs, Co, Cn
    text = regex.sub(r'[\p{Zl}\p{Zp}\p{C}]', '', text)

    # 将空白符归一化
    text = regex.sub(r'\p{Zs}', ' ', text)
    return text

我们还可以根据数字类型 Unicode 字符的 name 来将其归一化到阿拉伯数字上,先看看数字类型的 Unicode 字符的 name 吧

import unicodedata

chars = ['⑩', '1', 'Ⅲ', '〹', '⒗']
for char in chars:
    print('{}: {}'.format(char, unicodedata.name(char)))

结果

⑩: CIRCLED NUMBER TEN
1: DIGIT ONE
Ⅲ: ROMAN NUMERAL THREE
〹: HANGZHOU NUMERAL TWENTY
⒗: NUMBER SIXTEEN FULL STOP

可以看到,在 NUMBER/DIGIT/NUMERAL 后面的那个单词,就是对应数值的英文单词,只要把这个英文单词提取出来就得到了一个统一的表示,然后再将其转换成阿拉伯数字即可。