如何构建一个字典是基本所有自然语言任务都需要考虑的,当然也是根据具体任务因地制宜的。
- 基于单个字符的字典应用于一些中文任务中,如NER,BERT,但是在英文任务中可能使学习任务难度剧增。
- 基于单词的字典可能会使字典维度过大,单个单词的出现次数过少,得不到充分的训练。
Neural Machine Translation of Rare Words with Subword Units中提出了Subword的概念,并提供了一个从训练数据中构建字典的算法(BPE)。当然这里不会炒冷饭,接下来介绍一种tensor2tensor框架中构建Subword字典的方法。大致的项目法
构建
tensor2tensor中text_encoder.SubwordTextEncoder
类提供了构建字典和根据已知字典编码、解码功能,其中构建字典的方法使这里重点关注的。类方法build_from_generator
从一个文本生成器构建指定字典大小的subword字典,大致流程如下:
- 对所有的token计数,token可以是英文单词或者中文分词。
-
build_to_target_size
方法利用二分查找法在初设的min_val
和max_val
中找到合适的min_token_count使得构建的字典维度近似目标target_size
,其中build_from_token
方法负责核心的构建字典操作。
build_from_token
方法的主要想法是从所有character ngram寻找字符长度长、出现频率高的subtoken集合。
-
使用全部单个字符和预留token初始化字典,其中预留token一般包括填充符<pad>、句子结束符<EOS>和_ESCAPE_CHARS=set(u"\_u;0123456789")
alphabet_tokens = chain(six.iterkeys(token_counts), [native_to_unicode(t) for t in reserved_tokens]) self._init_alphabet_from_tokens(alphabet_tokens) # Bootstrap the initial list of subtokens with the characters from the # alphabet plus the escaping characters. self._init_subtokens_from_list(list(self._alphabet), reserved_tokens=reserved_tokens)
-
For each iteration:
a. 首先利用现有的subtoken字典对所有原始token编码,并计数。其中
_escape_token
在token末尾添加标志符号'_',同时使用Unicode值替换字符表alphabet
不存在的字符(OOV)。subtoken_counts = collections.defaultdict(int) for token, count in six.iteritems(token_counts): iter_start_time = time.time() escaped_token = _escape_token(token, self._alphabet) subtokens = self._escaped_token_to_subtoken_strings(escaped_token) start = 0 for subtoken in subtokens: last_position = len(escaped_token) + 1 if max_subtoken_length is not None: last_position = min(last_position, start + max_subtoken_length) for end in range(start + 1, last_position): new_subtoken = escaped_token[start:end] subtoken_counts[new_subtoken] += count start += len(subtoken)
其中针对subtokens的for循环可能比较晦涩,举个例子
# Example 1 subtokens=['l', 'o', 'w', 'e', 'r', '_'] new_subtokens=['l', 'lo', 'low', 'lowe', 'lower', 'lower_', 'o', 'ow', 'owe', 'ower', 'ower_', 'w', 'we', 'wer', 'wer_', 'e', 'er', 'er_', 'r', 'r_', '_'] # Example 2 subtokens=['low', 'er_'] new_subtokens=['l', 'lo', 'low', 'e', 'er', 'er_']
b. 将subtoken_counts按照subtoken的长度聚类
len_to_subtoken_strings = [] for subtoken_string, count in six.iteritems(subtoken_counts): lsub = len(subtoken_string) if count >= min_count: while len(len_to_subtoken_strings) <= lsub: len_to_subtoken_strings.append(set()) len_to_subtoken_strings[lsub].add(subtoken_string)
c. 按照subtoken长度由大到小遍历所有subtokens,保留频数大于等于预设min_count的subtoken,同时减去当前subtoken子串的相应频数。
new_subtoken_strings = [] for lsub in range(len(len_to_subtoken_strings) - 1, 0, -1): subtoken_strings = len_to_subtoken_strings[lsub] for subtoken_string in subtoken_strings: count = subtoken_counts[subtoken_string] if count >= min_count: # Exclude alphabet tokens here, as they must be included later, # explicitly, regardless of count. if subtoken_string not in self._alphabet: new_subtoken_strings.append((count, subtoken_string)) for l in range(1, lsub): subtoken_counts[subtoken_string[:l]] -= count
d. 利用new_subtoken_strings和全部单字符(alphabet)的并集更新字典
编码
给定一个SubwordTextEncoder
和一个token字符串,首先通过_escape_token
规范原始token,再利用_escaped_token_to_subtoken_strings
把token分解为subtoken_strings,最后根据字典编码映射strings到ids。其中_escaped_token_to_subtoken_strings
是利用前向最大匹配算法,由于subtoken字典中包含了所有单个字符,所以必然存在一个分解。
def _escaped_token_to_subtoken_strings(self, escaped_token):
"""Converts an escaped token string to a list of subtoken strings.
Args:
escaped_token: An escaped token as a unicode string.
Returns:
A list of subtokens as unicode strings.
"""
# NOTE: This algorithm is greedy; it won't necessarily produce the "best"
# list of subtokens.
ret = []
start = 0
token_len = len(escaped_token)
while start < token_len:
for end in range(
min(token_len, start + self._max_subtoken_len), start, -1):
subtoken = escaped_token[start:end]
if subtoken in self._subtoken_string_to_id:
ret.append(subtoken)
start = end
break
else: # Did not break
# If there is no possible encoding of the escaped token then one of the
# characters in the token is not in the alphabet. This should be
# impossible and would be indicative of a bug.
assert False, "Token substring not found in subtoken vocabulary."
return ret
解码
相对于编码,解码是比较简单的。由于每个token的结尾都会被标记'',解码过程就是拼接所有输出subtokens,再按照''分割,就可以得到tokens。在编码过程中,_escape_token
针对OOV做的特殊处理需要通过_unescape_token
逆向转化回来。
def _subtoken_ids_to_tokens(self, subtokens):
"""Converts a list of subtoken ids to a list of tokens.
Args:
subtokens: a list of integers in the range [0, vocab_size)
Returns:
a list of strings.
"""
concatenated = "".join(
[self._subtoken_id_to_subtoken_string(s) for s in subtokens])
split = concatenated.split("_")
ret = []
for t in split:
if t:
unescaped = _unescape_token(t + "_")
if unescaped:
ret.append(unescaped)
return ret