项目小结GEC模型中的难点:分词(Tokenizer)与回译(Backtranslation)
Posted 囚生CY
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了项目小结GEC模型中的难点:分词(Tokenizer)与回译(Backtranslation)相关的知识,希望对你有一定的参考价值。
前排提示本文涉及的数据集及外部文件在以下链接共享。包括 Lang-8 语料库,词形转换表(涉及79024组变换)与一些有用的pickle文件。
链接:https://pan.baidu.com/s/1fW266ZSLoZeEaRCl2yVQCg
提取码:yfhm
序言
GEC模型的概念及解决方案可以参考我之前写的一些论文提纲,但无论采用什么样的解决思路,都绕不开很多瓶颈性的问题。笔者根据自己近期基于 CONLL2014 任务尝试的经验,就训练数据短缺的解决方案给出两点参考及其代码实现:
- 寻找外部数据集(公开的GEC标注数据集如 Lang-8 ,可以从上面的链接中获取),一般不建议说使用其他付费的标注数据集,一些论文中都提到了自己使用了其他数据集,然后被其他作者在论文中谴责[汗]。但是公开的GEC标注数据集存在的问题是数据较脏(特殊符号,不规范缩写等),常用的分词器往往很难达到良好的分词效果(如NLTK语言工具包中封装了很多分词方法,但是不具有特定的针对性,GEC是一种对语料质量要求较高的NLP任务,毕竟是一个改错任务,如果给定的输出还是错的,就毫无意义),因此这里给出一种基于Lang-8数据集的分词类 Tokenizer ,主要就经验加入了一些正则替换。
- 回译 Backtranslation,将语法正确的语句转化为语法错误的语句,非常重要的伪造训练数据的方法,因为这种不需要已标注的数据集,只需要有正确的句子即可。所以可以使用一些规模较大的语料库(如Gigaword, Wiki),然后将其转为错误的语句作为训练数据使用。常用的回译方法有手动回译和模型回译。模型回译即利用已标注的GEC数据来训练一个模型,输入正确的语句,输出错误的语句,我做下来的经验认为这种方法吃力不讨好,不如用手动回译来的方便。所以这里给出一个基于统计数据的手动回译算法并给出代码实现。
1 分词 Tokenizer
# -*- coding: UTF-8 -*-
# Author: 囚生
# 分词器模块
"""
作者:囚生CY
平台:CSDN
时间:2020/03/19
转载请注明原作者
创作不易,仅供分享
"""
import re
import sys
class DummyTokenizer(object):
def tokenize(self,text): return text.split()
class PTBTokenizer(object):
def __init__(self,language="en"):
self.language = language
self.nonbreaking_prefixes =
self.nonbreaking_prefixes_numeric =
self.nonbreaking_prefixes["en"] = ''' A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
Adj Adm Adv Asst Bart Bldg Brig Bros Capt Cmdr Col Comdr Con Corp Cpl DR Dr Drs Ens
Gen Gov Hon Hr Hosp Insp Lt MM MR MRS MS Maj Messrs Mlle Mme Mr Mrs Ms Msgr Op Ord
Pfc Ph Prof Pvt Rep Reps Res Rev Rt Sen Sens Sfc Sgt Sr St Supt Surg
v vs i.e rev e.g Nos Nr'''.split()
self.nonbreaking_prefixes_numeric["en"] = '''No Art pp'''.split()
self.special_chars = re.compile(r"([^\\w\\s\\.\\'\\`\\,\\-\\"\\|\\/])",flags=re.UNICODE)
def tokenize(self,text,ptb=False):
text = text.strip()
text = " " + text + " "
# Separate all "other" punctuation
text = re.sub(self.special_chars,r' \\1 ',text)
text = re.sub(r";",r' ; ',text)
text = re.sub(r":",r' : ',text)
# replace the pipe character
text = re.sub(r"\\|",r' -PIPE- ',text)
# split internal slash,keep others
text = re.sub(r"(\\S)/(\\S)",r'\\1 / \\2',text)
# PTB tokenization
if ptb:
text = re.sub(r"\\(",r' -LRB- ',text)
text = re.sub(r"\\)",r' -RRB- ',text)
text = re.sub(r"\\[",r' -LSB- ',text)
text = re.sub(r"\\]",r' -RSB- ',text)
text = re.sub(r"\\",r' -LCB- ',text)
text = re.sub(r"\\",r' -RCB- ',text)
text = re.sub(r"\\"\\s*$",r" '' ",text)
text = re.sub(r"^\\s*\\"",r' `` ',text)
text = re.sub(r"(\\S)\\"\\s",r"\\1 '' ",text)
text = re.sub(r"\\s\\"(\\S)",r" `` \\1",text)
text = re.sub(r"(\\S)\\"",r"\\1 '' ",text)
text = re.sub(r"\\"(\\S)",r" `` \\1",text)
text = re.sub(r"'\\s*$",r" ' ",text)
text = re.sub(r"^\\s*'",r" ` ",text)
text = re.sub(r"(\\S)'\\s",r"\\1 ' ",text)
text = re.sub(r"\\s'(\\S)",r" ` \\1",text)
text = re.sub(r"'ll",r" -CONTRACT-ll",text)
text = re.sub(r"'re",r" -CONTRACT-re",text)
text = re.sub(r"'ve",r" -CONTRACT-ve",text)
text = re.sub(r"n't",r" n-CONTRACT-t",text)
text = re.sub(r"'LL",r" -CONTRACT-LL",text)
text = re.sub(r"'RE",r" -CONTRACT-RE",text)
text = re.sub(r"'VE",r" -CONTRACT-VE",text)
text = re.sub(r"N'T",r" N-CONTRACT-T",text)
text = re.sub(r"cannot",r"can not",text)
text = re.sub(r"Cannot",r"Can not",text)
# multidots stay together
text = re.sub(r"\\.([\\.]+)",r" DOTMULTI\\1",text)
while re.search("DOTMULTI\\.",text):
text = re.sub(r"DOTMULTI\\.([^\\.])",r"DOTDOTMULTI \\1",text)
text = re.sub(r"DOTMULTI\\.",r"DOTDOTMULTI",text)
# multidashes stay together
text = re.sub(r"\\-([\\-]+)",r" DASHMULTI\\1",text)
while re.search("DASHMULTI\\-",text):
text = re.sub(r"DASHMULTI\\-([^\\-])",r"DASHDASHMULTI \\1",text)
text = re.sub(r"DASHMULTI\\-",r"DASHDASHMULTI",text)
# Separate ',' except if within number.
text = re.sub(r"(\\D),(\\D)",r'\\1 ,\\2',text)
# Separate ',' pre and post number.
text = re.sub(r"(\\d),(\\D)",r'\\1 ,\\2',text)
text = re.sub(r"(\\D),(\\d)",r'\\1 ,\\2',text)
if self.language=="en":
text = re.sub(r"([^a-zA-Z])'([^a-zA-Z])",r"\\1 ' \\2",text)
text = re.sub(r"(\\W)'([a-zA-Z])",r"\\1 ' \\2",text)
text = re.sub(r"([a-zA-Z])'([^a-zA-Z])",r"\\1 ' \\2",text)
text = re.sub(r"([a-zA-Z])'([a-zA-Z])",r"\\1 '\\2",text)
text = re.sub(r"(\\d)'(s)",r"\\1 '\\2",text)
text = re.sub(r" '\\s+s ",r" 's ",text)
text = re.sub(r" '\\s+s ",r" 's ",text)
elif self.language=="fr":
text = re.sub(r"([^a-zA-Z])'([^a-zA-Z])",r"\\1 ' \\2",text)
text = re.sub(r"([^a-zA-Z])'([a-zA-Z])",r"\\1 ' \\2",text)
text = re.sub(r"([a-zA-Z])'([^a-zA-Z])",r"\\1 ' \\2",text)
text = re.sub(r"([a-zA-Z])'([a-zA-Z])",r"\\1' \\2",text)
else: text = re.sub(r"'",r" ' ")
# re-combine single quotes
text = re.sub(r"' '",r"''",text)
words = text.split()
text = ''
for i,word in enumerate(words):
m = re.match("^(\\S+)\\.$",word)
if m:
pre = m.group(1)
if ((re.search("\\.",pre) and re.search("[a-zA-Z]",pre)) or (pre in self.nonbreaking_prefixes[self.language]) or ((i<len(words)-1) and re.match("^\\d+",words[i+1]))): pass
elif ((pre in self.nonbreaking_prefixes_numeric[self.language] ) and (i<len(words)-1) and re.match("\\d+",words[i+1])): pass
else: word = pre + " ."
text += word + " "
text = re.sub(r"'\\s+'",r"''",text)
# restore multidots
while re.search("DOTDOTMULTI",text): text = re.sub(r"DOTDOTMULTI",r"DOTMULTI.",text)
text = re.sub(r"DOTMULTI",r".",text)
# restore multidashes
while re.search("DASHDASHMULTI",text): text = re.sub(r"DASHDASHMULTI",r"DASHMULTI-",text)
text = re.sub(r"DASHMULTI",r"-",text)
text = re.sub(r"-CONTRACT-",r"'",text)
return text.split()
def tokenize_all(self,sentences,ptb=False):
return [self.tokenize(t,ptb) for t in sentences]
if __name__ == "__main__":
tokenizer = PTBTokenizer()
for line in sys.stdin:
tokens = tokenizer.tokenize(line.strip())
out = " ".join(tokens)
print(out)
只依靠正则来分词的算法,比较冗长,主要是针对 Lang-8 数据集中很多脏数据的问题的处理,当然如果语料很干净,也是可行的。如下 Lang-8 截图片段,这种在语料中卖萌的做法是让人最抓狂的事情
2 回译 Backtranslation
在上面的链接中我给处理基于在 Lang-8 与 NUCLE(CONLL官方数据集) 中统计得到的一些改错过程中常见的插入、删除、替换及根据词形转换得到的一些常见的动词使用错误的数据,将它们保存为pickle文件分享在链接中。基于这些常见的改错方法,基于统计方法逆向将正确的句子改为错误的句子。应该还是比较浅显的逻辑,算法代码如下所示👇
# -*- coding: UTF-8 -*-
# Author: 囚生
# 伪造数据生成器
"""
作者:囚生CY
平台:CSDN
时间:2020/03/19
转载请注明原作者
创作不易,仅供分享
"""
import os
import time
import math
import pickle
import random
from numpy.random import choice
class Errorifier():
def __init__(self,
verbs_filename="verbs.p",
common_inserts_filename="common_inserts.p",
common_replaces_filename="common_replaces.p",
common_deletes_filename="common_deletes.p",
):
# 类初始化
Ipath = common_inserts_filename
Vpath = verbs_filename
Rpath = common_replaces_filename
Dpath = common_deletes_filename
self.common_insert_errors = pickle.load(open(Ipath,"rb")) # 常用插入错误
self.common_verb_errors = pickle.load(open(Vpath,"rb")) # 常用动词错误
self.common_replace_errors = pickle.load(open(Rpath,"rb")) # 常用替代错误
self.common_delete_errors = pickle.load(open(Dpath,"rb")) # 常用删除错误
def insert_error(self,tokens,
): # 删除一个常见的插入单词: 四种错误仅插入不考虑频率分布(合理)
if len(tokens)>1:
deletable = [i for i,token in enumerate(tokens) if token in self.common_insert_errors]
if not deletable: return tokens # 如果没有可以删除的单词就直接返回原分词列表
index = random.choice(deletable) # 随机挑选一个合适的可以删除的单词
del tokens[index] # 直接删掉它就完事了
return tokens # 返回分词列表
def verb_error(self,tokens,
redirectable=True, # 是否可以重定向为替代错误: 默认可以
): # 引入一个动词相关的错误: 如果没有找到合适的动词错误就转为替代错误处理
if len(tokens)>0:
verbs = [i for i,token in enumerate(tokens) if token in self.common_verb_errors]
if not verbs: # 如果没有发现动词: 可以重定向就重定向
if redirectable: return self.replace_error(tokens,redirectable=False)
return tokens # 不能就算了
index = random.choice(verbs) # 随机挑选一个可以改错的动词
verb = tokens[index] # 找到它
if not self.common_verb_errors[verb]: return tokens # 如果这个动词换不了(还有这种事?)
error_verb = random.choice(self.common_verb_errors[verb]) # 换得了就随机挑一个
tokens[index] = error_verb # 替换即可
return tokens
def replace_error(self,tokens,
redirectable=True, # 是否可以重定向为动词错误: 默认可以
): # 增加一个常见的替代错误
if len(tokens)>0:
replacable = [i for i,token in enumerate(tokens) if token in self.common_replace_errors]
if not replacable: # 如果没有发现可以替换的单词: 可以重定向就重定向
if redirectable: return self.verb_error(tokens,redirectable=False)
return tokens # 不能就算了
index = random.choice(replacable) # 随机挑选一个可以替换的单词
word = tokens[index] # 找到它
if not self.common_replace_errors[word]: return tokens # 如果这个单词换不了(还有这种事?)
frequency = list(self.common_replace_errors[word].values()) # 找到这个单词的替换频次分布情况
total = sum(frequency) # 统计总频率
p = [times/total for times in frequency] # 得到频率分布
error_word = choice(list(self.common_replace_errors[word].keys()),p=p)
tokens[index] = error_word # 替换即可
return tokens
def delete_error(self,tokens,
): # 增添一个常见的应删单词
if len(tokens)>0:
insertable = list(range(len(tokens))) # 默认每个位置都可以插入: 默认不可以插入在句末
index = random.choice(insertable) # 随机选择一个位置
frequency = list(self.common_delete_errors.values()) # 找到这个单词的替换频次分布情况
total = sum(frequency) # 统计总频率
p = [times/total for times in frequency] # 得到频率分布
insert_word = choice(list(self.common_delete_errors.keys()),p=p)
tokens.insert(index,insert_word) # 在index位置插入该单词
return tokens
def errorify(self,sentence,
count_probs=[.05,.07,.25,.35,.28], # 一句话中错误数量的概率
error_probs=[.30,.25,.25,.20], # 各种类型错误的概率: 依次为插入错误, 动词错误, 替代错误, 删除错误
): # 引入一个随机错误
tokens = sentence.split() # 先对句子分词
count = choice(list(range(len(count_probs))),p=count_probs) # 随机确定一句话中错误的数量: 默认是0,1,2,3,4个错误
for x in range(count): # 逐一生成错误
error_function = choice([self.insert_error,self.verb_error,self.replace_error,self.delete_error],p=error_probs)
tokens = error_function(tokens) # 随机确定一个错误的类型
return " ".join(tokens)
if __name__=="__main__":
sentence = "As is known to all, climbing is a dangerous sport around the world ."
e = Errorifier()
for i in range(100):
print(e.errorify(sentence))
后记
GEC总之也是很小众的方向,但是笔者认为很多思路在NLP中都是融会贯通的,分词与回译可能在其他场景下也有其使用价值。
分享学习,共同进步!
二月廿六,另祝sxy生快,托你的福,现在在杉数与比同道的人一起做自己很擅长,也很喜欢做的事情,总归觉得自己还是有点价值的。
希望你也一样。
以上是关于项目小结GEC模型中的难点:分词(Tokenizer)与回译(Backtranslation)的主要内容,如果未能解决你的问题,请参考以下文章