【項目小結】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生快,託你的福,現在在杉數與比同道的人一起做自己很擅長,也很喜歡做的事情,總歸覺得自己還是有點價值的。

希望你也一樣。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章