目录

  1. 一切都乱了
  2. 怎么回事
  3. Unicode 规范化
  4. 韩语怎么特殊了
  5. References
  6. END

NLP 中,经常要对字符串进行预处理,再送给模型。常在河边走,哪能不湿鞋。这不前段时间就因为这个预处理,出了大问题。

一切都乱了

我们做的是新闻处理,会涉及新闻分类、去重等操作。国庆假期回来,同事反馈,韩语的内容分类,一下全都不对了!全都被分到了某几个分类中。

怎么回事

我们有个 embedding 服务,负责给输入的文本生成向量,以备后面给其他服务使用,比如向量搜索。这个服务是支持多语言的。有天我在休假中,领导发来消息说发现同一文本的全角和半角形式,相似度比较低。我上线看了下,确实是,当时想这也太傻了。不过解决不复杂,直接统一转成半角形式就行了。

搜了下,网上大部分是硬编码的形式转的,固定范围的 unicode 码位减去一个常量就得到了相应的半角形式。我觉得不太优雅,想到了 unicodedata 这个库,当时 bert tokenizer 还用这个库来规范化文本,用的是 NFD,我所用的模型又是 bert 系的,所以我就直接用了 NFKD。由于是在日文上发现的,所以我也测试日语上的效果,以及中文、阿拉伯文等。没什么问题,就更新了。

结果后来问题就出来了。NFD 在韩语上有大问题,韩语向量一下就全不对了。

Unicode 规范化

为了能够比较任意两个字符串是否等价,具体来说就是二进制是否相等,有了规范化形式(Normalization Form)这个概念。有很多字符看起来的样子和它实际的样子是不一样的,比如带有重音字符的字母,看起来是一个字符,但实际上是由重音字符和相应字母组成的。

Unicode 文本有 4 种规范化方法,这 4 种方法区别在于使用哪种分解方法(canonical decomposition 和 compatibility decomposition)以及是否进行组合:

compatibility 这个单词中明明没有 K,那为什么是 KD 和 KC 呢?这是为了避免 compatibility 的 C 与 canonical 和 composition 的 C 混淆,所以用了 K 来指代 compatibility。

分解方式有两种:canonical decomposition 和 compatibility decomposition,也就是有无 K 的区别。两种分解方式都是将字符串分解为他们的等价形式,前者的分解结果,在外观上尽量与原始字符串一致(这里的一致指的是在字符串被正确渲染时,看起来是一样的),语义上也是一致的。而后者则是忽略外观,只找到语义上等价的字符串。典型的兼容分解有:

  • 全角转半角。
  • 上下标转成普通数字字母,比如上标 $^2$ 转成普通的 2。

简单来说,带 C 的都多一个组合的步骤,带 D 的都只有分解步骤,带 K 的都会进行兼容性处理(比如全角转半角、上下标转为对应的字母数字)。

以下是几个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
In [24]: def normalize(text: str):
...: res = unicodedata.normalize('NFC', text)
...: print(f"NFC: {res!r}, len={len(res)}")
...: res = unicodedata.normalize('NFD', text)
...: print(f"NFD: {res!r}, len={len(res)}")
...: res = unicodedata.normalize('NFKC', text)
...: print(f"NFKC: {res!r}, len={len(res)}")
...: res = unicodedata.normalize('NFKC', text)
...: print(f"NFKD: {res!r}, len={len(res)}")

# 全角 1
In [25]: normalize('1')
NFC: '1', len=1
NFD: '1', len=1
NFKC: '1', len=1
NFKD: '1', len=1

# 下标 m
In [26]: normalize('ₘ')
NFC: 'ₘ', len=1
NFD: 'ₘ', len=1
NFKC: 'm', len=1
NFKD: 'm', len=1

# 圆圈 1
In [27]: normalize('①')
NFC: '①', len=1
NFD: '①', len=1
NFKC: '1', len=1
NFKD: '1', len=1

韩语怎么特殊了

我们再来看两个例子:

前面不是说了尽量保持外观一致吗?怎么韩文这里看起来完全变了呢?下面的 e 就没问题(虽然实际上也是被分解成了两个字符)。那是因为:

There are also special rules to fully decompose Hangul syllables.

韩文就是这么特殊,韩文是由音节组成的,没错,就是类似我们的拼音,所以他们是直接用拼音写字的。具体来说, 是一个预组合的韩文字符,由以下部分组成:

  • 初声:ᄀ (U+1100, HANGUL CHOSEONG KIYEOK)
  • 中声:ᅡ (U+1161, HANGUL JUNGSEONG A)

所以会看到被分开了。但是如果你把输出复制到网页上,也就是 '가' ,你就又发现,看起来好像又一致了,这就是上面说的渲染的问题。

而韩文的 NFKD 和 NFD 的结果是一样的,所以就导致所有韩文都被分解成他们的音节了,导致 embedding 模型出错,几乎全都判成相似了。比如下面的两句话:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sents = [
'오늘 날씨가 좋다', # 今天天气很好
'나는 영화를 보러 가고 싶다' # 我想去看电影
]
print(f"lens: {list(map(len, sents))}")
embeddings = model.encode(sents)
print(cos_sim(embeddings, embeddings))

# lens: [9, 15]
# tensor([[1.0000, 0.1595],
# [0.1595, 1.0000]])

normalized_sents = [unicodedata.normalize('NFD', sent) for sent in sents]
print(f"lens: {list(map(len, normalized_sents))}")
embeddings = model.encode(normalized_sents)
print(cos_sim(embeddings, embeddings))

# lens: [19, 30]
# tensor([[1.0000, 0.9953],
# [0.9953, 1.0000]])

可以看到原始句子的相似度很低,这是正常的,然而 NFD 规范化后,相似度都快到 1 了

References

END