在NLP中,分词(也称切词)是一种特殊的文档切分过程。而文档切分能够将文本切分成更小的文本块或片段,其中含有更集中的信息内容。文档切分可以是将文本分成段落,将段落分成句子,将句子分成短语,或将短语分成词条(通常是词)和标点符号。将文本分割成词条的过程称为分词。
用于编译计算机语言的分词器通常称为扫描器 或者语法分词器 。某种计算机语言的词汇表(所有有效的记号合)构成所谓的词库 。如果分词器合并到计算机语言编译器的分析器中,则该分析器常常称为无扫描器分析器 。而记号 (token)则是用于分析计算机语言的上下文无关语法 (CFG)的最终输出结果,由于它们终结了CFG中从根结点到叶子结点的一条路径,因此它们也称为终结符。
对于NLP的基础构建模块,计算机语言编辑器中存在一些与它们等同的模块:
1、分词器:扫描器,或者称为语法分析器;
2、词汇表:词库;
3、分析器:编译器;
4、词条 、词项 、词 或n-gram:标识符或终结符。
分词是NLP流水线的第一步,因此它对流水线的后续处理过程具有重要的影响。分词器将自然语言文本这种非结构化数据切分成多个信息块,每个块都可看成可计数的离散元素。这些元素在文档中的出现频率可以直接用于该文档的向量表示。上述过程立即将非结构化字符串(文本文档)转换成适合机器学习的数值型数据结构。元素的出现频率可以直接被计算机用于触发有用的行动或回复。或者,它们也可以以特征方式用于某个机器学习流水线来触发更复杂的决策或行为。通过这种方式构建的词袋向量最常应用于文档检索或搜索任务重。
对句子切分的最简单方法就是利用字符串汇总的空白符来作为词的"边界"。在Python中,可以通过标准库方法split来实现这种操作,split在所有的str对象实例中都可调用,也可以在str内嵌类本身进行调用,示例如下:
python
sentence="""
Thomas Jefferson Began buliding Monticelli as the age of 26.
"""
print(sentence.split())
print(str.split(sentence))
如上图所示,Python的内置方法已经对这个简单的句子进行了相当不错的分词处理,仅仅出现了"26."这样带标点符号的问题。通常来说,我们会希望句中有意义的词条和标点符号等分开。"26."、"26!"、"26,"等意义都不同。一个优秀的分词器应该将额外的字符去掉得到"26"。此外,一个更为精准的分词器应该也将句尾的标点符号作为词条输出,这样句子切分工具或者边界检测工具才能确定句子的结束位置。
现在,我们先利用当前这个并不完美的分词器来进行分词处理,后面再处理标点符号和其他有挑战性的问题。再利用一点Python技术,我们就能构建每个词的数值向量。这些向量称为独热向量。这些独热向量构成的序列能够一向量序列(数字构成的表格)的方式完美捕捉原始文本。上述处理过程解决了NLP的第一个问题,即将词转化成数字:
python
import numpy as np
token_sequence=str.split(sentence)
#词汇表中列举了所有想要记录的独立词条
vocab=sorted(set(token_sequence))
#词条按照词库顺序进行排序,数字排在字母前面,大写字母排在小写字母的前面
print(','.join(vocab))
num_tokens=len(token_sequence)
vocab_size=len(vocab)
#这张空表的宽度是词汇表中独立词项昂的个数,长度是文档的长度,这里就是10行10列的表
onehot_vectors=np.zeros((num_tokens,vocab_size),int)
#对于集中的每个词,将词汇表中与该词对应的列标记为1
for i,word in enumerate(token_sequence):
onehot_vectors[i,vocab.index(word)]=1
print(' '.join(vocab))
print(onehot_vectors)
对于上面的数据,如果用Pandas DataFrame可以使其看起来更容易一些,信息量也更多一些。Pandas会利用Series对象中的辅助功能对一维数组打包。此外,Pandas对于表示数值型表格特别方便,如列表组成的列表(list)、二维numpy数组,二维numpy矩阵、数组组成的数组以及字典组成的字典。
DataFrame为每一列记录了其对应的标签,这样就可以将每一列对应的词条或词标在上面。为了加快查找过程,DataFrame也可以利用DataFrame.index为每一行记录其对应的标签。当然,对于大部分应用来说,行的标签只是连续的整数。在刚刚这里例子上,我们目前暂时只使用默认的行标签整数:
python
import pandas as pd
ddd=pd.DataFrame(onehot_vectors,columns=vocab)
print(ddd)
独热向量看起来十分稀疏,每个行向量中只有一个非零值。因此,我们可以把所有的0替换成空,这样可以使独热行向量表格看上去更美观一些。但是,不要在机器学习流水线中的DataFrame上进行这样的操作,因为这样做的话会在numpy数组中构建大量非数值型对象,从而导致数学计算上的混乱。
当然,如果只是为了显示美观,可以做类似下面的操作:
python
df=pd.DataFrame(onehot_vectors,columns=vocab)
df[df == 0]=''
print(df)
在上述这个单句子文档的表示中,每行的向量都对应一个单独的词。该句子包含10个相互不同的词,没有任何重复。于是,上述表格包含10列10行。每列中的数字"1"表示词汇表中的词出现在当前文档的当前位置。因此,如果想知道文档中的第3个词是什么,就可以定位表格的第3行(标号为2),从这行中找到数字"1"对应的列,就可以知道是哪个单词。
上述表格的每一行都是一个二值的行向量,这就是该向量成为独热向量的原因:这一行的元素除1个位置之外都是0或空白,而只有该位置上是"热"的(为1)。"1"意味着"打开"或者"热"。而0意味着"关闭"或者"缺失"。
上面的词向量表示及文档的表格化表示有一个优点,就是任何信息都没有丢失。只要记录了哪一列代表哪个词,就可以基于整张表格中的独热向量重构出原始文档。即使分词器在生成我们认为有用的词条时只有90%的精确率,上述重构过程的精确率也是100%。因此,和上面一样的独热向量尝尝用于神经网络、序列到序列语言模型及生成式语言模型中。对任何需要保留原始文本所有含义的模型或NLP流水线来说,独热向量模式提供了一个好的选择。
上述独热向量表格就像是对原始文本进行了完全录制。表格上面的词汇表告诉机器的是,每个行序列到底对应哪个词,就像是钢琴棋谱中应该演奏哪个音符。
我们已经将一个自然语言的句子转换成了数值序列,即向量。现在我们可以利用计算机读入这些向量并进行一系列数字运算,就像对其他向量或者数值列表进行的运算一样。这样就可以将向量输入任何需要这类向量的自然语言处理流水线中。
如果想要为聊天机器人生成文本,可以基于独热编码向量反向还原出文本内容。现在所有需要做的事情就是,规划如何构建一个能够以新方式理解并组合这些词向量的演奏钢琴。最后,我们期望聊天机器人或者NLP流水线能够演奏或者说出某些以前我们没听说过的东西。
上述基于独热向量的句子表示方法保留了原始句子的所有细节,包括语法和语序。至此,我们已经成功的将词转换为计算机能够"理解"的数值,并且这些数值还是计算机非常喜欢的一类数值:二值数字0或1。但是,相对于上述的短句子而言的整个表格却很大。如果考虑到这一点,我们可能已经对文件的大小进行了扩充以便能够存储上述表格。但是,对长文档来说,这种做法不太现实,此时文档的大小会急剧增加。英语中包含至少20000个常用词,如果考虑人名和其他专用词的话,数量可能达到数百万。对于要处理的每篇文档,其独热表示方法都需要一个新的表格(矩阵)。这基本是相当于得到了文档的原始映像。
下面简单地用数学演算一下,以便大概了解了表格会有多大。大部分情况下,NLP流水线中使用的词汇表中的词条数将远远超过10000或20000,有时可能会达到数十甚至上百万。假设我们的NLP流水线的词汇表包含100万个词条,并且我们拥有3000本很薄的书,每本书有3500个句子,每个句子平均15个词,那么,整个表格(矩阵)的大小:
python
#表格行数:
num_rows=3000*3500*15
print(num_rows)
#如果表格中每个元素只用一个字节表示的话,那么总字节数:
num_bytes=num_rows*1000000
print(num_bytes)
#表格的大小:
num_bytes=num_bytes/1e12
print(num_bytes)
即使将表格中的每个元素用单个位来表示,也需要接近20TB的空间来存储这些书籍。幸运的是,我们从来都不需要用上面的数据结构来存储文档。只有在一个词一个词处理文档时,才会临时在内存中使用上述数据结构。
因此,存储所有0并试图记住所有文档中的词序没有太大意义,也不太现实。我们真正想要做的实际是将文档的语义压缩为其本质内容。我们想将文档压缩成单个向量而不是一张大表。而且我们将放弃完美的"召回"过程,我们想做的是提取文档中的大部分而非全部含义(信息)。
假设我们可以忽略词的顺序和语法,并将它们混合在一个"袋子"中,每个句子或每篇短文对应一个"袋子",这个假设是合理的。即使对于长达几页的文档,词袋向量也可以用来概括文档的本质内容。对于前面那句关于Jefferson的句子,即使把所有的词都按词库序重新排列,人们也可以猜出那句话的大致意义。机器也可以实现这一点。我们可以使用这种新的词袋向量方法,将每篇文档的信息内容压缩到更易处理的数据结构中。
如果把所有这些独热向量加在一起,而不是一次一个地"回放"它们,我们会得到一个词袋向量。这个向量也被成为词频向量 ,因为它只计算了词的频率,而不是词的顺序。这个具备合理长度的单一向量可以用来表示整篇文档或整个句子,其长度只相当于词汇表的大小。
另一种做法是,如果正在进行基本的关键词搜索,可以对这些独热词向量进行OR处理,从而得到一个二值的词袋向量。在搜索中可以忽略很多次,这些词并不适合作为搜索词或关键词。这对搜索引擎索引或信息检索系统的第一个过滤器来说都很不错。搜索引擎只需要知道每篇文档中每个词的存在与否,以帮助我们后续找到这些文档。
如果将词条限制在10000个最重要的词以内,就可以将刚才虚构的包含3500个句子的书的数值表示压缩到10kb,也就是说上述虚构的3000本书构成的语料库大约会压缩到30MB左右,独热向量构成的序列仍然需要占用数百GB的空间。
幸运的是,对于任何给定的文本,词汇表中的词只有很少一部分会出现在这个文本中,而对大多数词袋应用来说,往往会保持文档的简洁性,有时候一个句子就够了。即使同一个语句中有很多通常不会一起使用的词而产生不和谐,甚至这种不和谐也包含很多与语句相关的有用信息,机器学习流水线也可以利用这些信息。
这就是如何将词条放入一个二值向量的过程,这个向量可以表示某个具体词在某个特定句子中是否存在。一系列句子的上述向量表示可以"索引"起来,从而记录哪个词出现在哪篇文档中。这个索引除了不记录词出现在哪个页面,这里可以保存句子(或相关向量)的出现位置。
下面就是一篇单文本文档,文档中只有一个关于Thomas Jefferdon的句子,看上去像一个二值的词袋向量:
python
sentence_bow={}
for token in sentence.split():
sentence_bow[token]=1
print(sorted(sentence_bow.items()))
可以看到,sorted()将十进制数放在字符之前,同时将大写的词放在小写的词之前。这是ASCII和Unicode字符集中的字符顺序。在ASCII表中,大写字母在小写字母之前。其实,词汇表的顺序并不重要,只要所有需要分词的文档都采用相同的方式,机器学习流水线就可以很好地处理任何词汇表顺序。
还有,使用dict存储二值向量不会浪费太多空间。使用字典来表示向量可以确保只需要存储为数不多的1,因为字典中的数千甚至数百万个词中只要极小一部分会出现在具体某篇文档中,我们可以看到,上述表示会比将一袋子词表示为连续的0和1 的列表要高效的多,后者用一个"密集"向量为词汇表中的每个词都指定了一个位置。即使对于上面这个有关"Thomas Jefferdon"的短句子,采用"密集"的二值向量也需要100kb的存储空间。因为字典会"忽略"不存在的词,所以用字典表示时也只需要对10个词的句子中的每个词用几字节来表示,而如果把每个词都表示乘指向词库内该词所在位置的整数指针,那么这个字典的效率可能会更高。
接下来,我们使用一种更有效的字典形式,即Pandas中的Series,可以把它封装在Pandas的DataFrame中,这样就可以向关于"Thomas Jefferdon"的二值向量文本"语料库"中添加更多的句子。当在DataFrame中添加更多的句子和其对应的词袋向量时,所有这些向量之间以及稀疏与密集词袋之间的差距就会变得清晰起来:
python
df=pd.DataFrame(pd.Series(dict([(token,1) for token in sentence.split()])),columns=['sent']).T
print(df)
下面向语料库中添加一些文本,观察DataFrame是如何堆叠起来的:
python
sentence=sentence+"""\n Construction was done mostly by local masons and carpenters.\n"""
sentence=sentence+"""He moved into the South Pavilion in 1770.\n"""
sentence=sentence+"""Turning Monticello into a neoclassical masterpiece was Jefferson's obsession."""
corpus={}
#一般来说,只需要使用.splitlines()即可,但是这里显式地在每个行尾增加了 \n 字符,因此这里要显式地对此字符串进行分割
for i,sent in enumerate(sentence.split('\n')):
corpus['sent{}'.format(i)]=dict((tok,1) for tok in sent.split())
df=pd.DataFrame.from_records(corpus).fillna(0).astype(int).T
print(df[df.columns[:10]])