项目小结GEC模型中的难点:分词(Tokenizer)与回译(Backtranslation)

Posted 囚生CY

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了项目小结GEC模型中的难点:分词(Tokenizer)与回译(Backtranslation)相关的知识,希望对你有一定的参考价值。

前排提示本文涉及的数据集及外部文件在以下链接共享。包括 Lang-8 语料库,词形转换表(涉及79024组变换)与一些有用的pickle文件。

链接:https://pan.baidu.com/s/1fW266ZSLoZeEaRCl2yVQCg 
提取码:yfhm 


序言

GEC模型的概念及解决方案可以参考我之前写的一些论文提纲,但无论采用什么样的解决思路,都绕不开很多瓶颈性的问题。笔者根据自己近期基于 CONLL2014 任务尝试的经验,就训练数据短缺的解决方案给出两点参考及其代码实现:

  1. 寻找外部数据集(公开的GEC标注数据集如 Lang-8 ,可以从上面的链接中获取),一般不建议说使用其他付费的标注数据集,一些论文中都提到了自己使用了其他数据集,然后被其他作者在论文中谴责[汗]。但是公开的GEC标注数据集存在的问题是数据较脏(特殊符号,不规范缩写等),常用的分词器往往很难达到良好的分词效果(如NLTK语言工具包中封装了很多分词方法,但是不具有特定的针对性,GEC是一种对语料质量要求较高的NLP任务,毕竟是一个改错任务,如果给定的输出还是错的,就毫无意义),因此这里给出一种基于Lang-8数据集的分词类 Tokenizer ,主要就经验加入了一些正则替换。
  2. 回译 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-8NUCLE(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)的主要内容,如果未能解决你的问题,请参考以下文章

项目小结英语语法错误检测(GEC)开题论文阅读记录

使用 R 对标签进行分词

安装和使用nltk

如何用 Python 中的 NLTK 对中文进行分析和处理

中文分词中的技术难点

自然语言处理(NLP)的基础难点:分词算法