如何对文本后处理之:大小写转换

最近工作中文本处理任务特别多,今天特地看一下大小写转换。大小写转换对于文本的后处理很重要,如果做不好,句子看起来很ugly。一开始想通过端到端的方法做,后来想一想感觉不需要上神经网络模型。大小写本身就是跟“是否句子开头”、“是否命名实体”、“是否缩略词”等有关系。因此认为大小写转换过程应该走一个pipeline的流程,看了一些资料发现确实如此。

参考:
https://www.cs.cmu.edu/~llita/papers/lita.truecasing-acl2003.pdf
https://towardsdatascience.com/truecasing-in-natural-language-processing-12c4df086c21

论文tRuEcasIng

大小写对于NER、ASR后处理等任务很重要,比如“the president”和“the President”表达的是不同的含义。大小写跟文本的来源、作者的写作风格都有关系,为了对不同语料库中的文本进行统一归一化,可以使用Truecasing将大小写变为统一规范形式。

在以往相关工作中,关注到的是句首、引号后面和命名实体的大写。论文将词的大小写分为4类:全部小写(LC)、首字母大写(UC)、全部字母大写(CA)、混合大小写(MC)。论文中使用的方法可以关注到word的上下文信息。因此即便遇到UNK(如lenon),也会因为跟lenon有相同上下文的词常采用UC,而把lenon也记为UC类别。

如果我们使用Unigram语言模型的话,可能发现这样的问题:只有12%的词是有多种大小写表示的,那对于new这个词,new后面跟着的词中只有少部分(York、Zealand)是需要大写的,那么new这个词就很容易改写为小写的,使得后面是York时出错了(可以使用贪心法解码Unigram Model)。

为了避免Unigram模型的缺点,需要不仅仅考虑local context,还要考虑整个句子的语义。因此论文使用了HMM对整个句子进行建模和推理:

图片

其中每个节点包含了如下信息:词的可能的truecase、词的语法信息、词在句子中位置信息、前面两个词的语法信息、前面两个词的truecase信息。也就是说,HMM的隐藏状态$\left(q_{1}, q_{2}, \cdots, q_{n}\right)$是词的大小写和上下文信息组合,观察状态$O_{1} O_{2} \cdots O_{t}$是词的lexical item,使用维特比进行求解:$q_{\tau}^{*}=\operatorname{argmax}_{q_{i 1} q_{i 2} \cdots q_{i t}} P\left(q_{i 1} q_{i 2} \cdots q_{i t} \mid O_{1} O_{2} \cdots O_{t}, \lambda\right)$转移概率lambda是语言模型特征的打分:

$\begin{aligned}
P_{\text {model}}\left(w_{3} \mid w_{2}, w_{1}\right) &=\lambda_{\text {trigram}} P\left(w_{3} \mid w_{2}, w_{1}\right) \\
&+\lambda_{\text {bigram}} P\left(w_{3} \mid w_{2}\right) \\
&+\lambda_{\text {unigram}} P\left(w_{3}\right) \\
&+\lambda_{\text {uniform}} P_{0}
\end{aligned}$

对于UNK单词,比如‘mispeling’,在训练时被替换为UNKNOWN LC,‘Lenon’被替换为UNKNOWN_UC,这样做的目的是当一个unknown word出现同样上下文的context时,就知道怎样大小写了。

这篇论文有一个简单版的实现:https://github.com/daltonfury42/truecase,我们也阅读一下这个代码。下面是它的训练过程的主函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def train(self, corpus):
for sentence in corpus:
# 首先检查句子合法性,去掉全部是大写的句子
if not self.check_sentence_sanity(sentence):
continue
for word_idx, word in enumerate(sentence):
self.uni_dist[word] += 1
word_lower = word.lower()
# 把单词的所有大小写可能放到word_casing_lookup中
if word_lower not in self.word_casing_lookup:
self.word_casing_lookup[word_lower] = set()
self.word_casing_lookup[word_lower].add(word)
# 将word的上下文加入bi-gram语言模型统计中
self.__function_one(sentence, word, word_idx, word_lower)
# 将word的上下文加入tri-gram语言模型统计中
self.__function_two(sentence, word, word_idx)

训练好的模型用下面代码存放到pickle中:

1
2
3
4
5
6
7
8
9
10
11
def save_to_file(self, file_path):
pickle_dict = {
"uni_dist": self.uni_dist,
"backward_bi_dist": self.backward_bi_dist,
"forward_bi_dist": self.forward_bi_dist,
"trigram_dist": self.trigram_dist,
"word_casing_lookup": self.word_casing_lookup,
}
with open(file_path, "wb") as fp:
pickle.dump(pickle_dict, fp)
print("Model saved to " + file_path)

模型推理的主函数:

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
def get_true_case(self, sentence, out_of_vocabulary_token_option="title"):
# outOfVocabulariyTokenOption=title将OOV词以大写格式输出
# outOfVocabulariyTokenOption=lower将OOV词以小写格式输出
# outOfVocabulariyTokenOption=as-is将OOV词以原先格式输出

tokens = self.tknzr.tokenize(sentence)
tokens_true_case = []
for token_idx, token in enumerate(tokens):
# 标点和数字原样输出
if token in string.punctuation or token.isdigit():
tokens_true_case.append(token)
else:
token = token.lower()
# 在词表中
if token in self.word_casing_lookup:
# 只有一种形式的直接返回
if len(self.word_casing_lookup[token]) == 1:
tokens_true_case.append(
list(self.word_casing_lookup[token])[0])
else:
prev_token = (tokens_true_case[token_idx - 1]
if token_idx > 0 else None)
next_token = (tokens[token_idx + 1]
if token_idx < len(tokens) - 1 else None)
best_token = None
highest_score = float("-inf")
# 找到语言模型得分最高的得分组合
for possible_token in self.word_casing_lookup[token]:
score = self.get_score(prev_token, possible_token, next_token)
if score > highest_score:
best_token = possible_token
highest_score = score
tokens_true_case.append(best_token)
if token_idx == 0:
tokens_true_case[0] = tokens_true_case[0].title()
else: # OOV
if out_of_vocabulary_token_option == "title":
tokens_true_case.append(token.title())
elif out_of_vocabulary_token_option == "lower":
tokens_true_case.append(token.lower())
else:
tokens_true_case.append(token)
return "".join([
" " +
i if not i.startswith("'") and i not in string.punctuation else i
for i in tokens_true_case

一个pipeline的设计

我们可以设计这样一个pipeline以满足大部分大小写转换的需求:分句 + 命名实体大写 + n-gram语言模型处理其他。分句使用spacy,它利用dependency parse tree和一些规则判断句子的边界。在处理时有一些情况要注意:

  • 注意句子开始的标志,一些不以字母为开始的句子,它的第一个字母应该大写:”non-digital items(t-shirts, printed posters, mugs, and cd-roms) require physical shipping.”
  • 注意句子切分,如省略号、双引号后面是否是独立的一句话
  • 注意缩略词的分词