自然语言处理(NLP)

这是英语NLP特有的问题.
在需要将英文文本分割成句子的时候, 会发现只通过标点符号并不足以分割句子:
人名, 域名, 小数点, 省略号, 句号全都共用同一个符号 ..
指一个词所有形式的集合, 表示一种关联关系.
例如"go"的lexeme是集合 {"go", "goes", "going", "went", "gone"}.
指约定好的代表相关lexeme的一个词.
例如约定"go"作为代表, 表示"go"和"went"等词共有的lexeme.
词干提取器被称作Stemmer.
与词形还原相比, 更接近工程领域.
现实中的信息检索普遍使用词干提取而不是词形还原:
词形还原可能存在不准确的情况, 而词干提取不存在不准确的情况,
因为它会将具有相同词干的不同词都映射到同一个词元上, 这有效提高了召回率.
词干提取器往往比词性还原器更快, 更小, 更简单.
实现方式:
  • 基于规则, 例如将以"ing"结尾的单词根据规则变换.
  • 基于语料, 从一个单词映射到对应的lemma.
https://snowballstem.org/
一种可以编译为多种语言的字符串处理语言, 专用于创建词干提取算法.
为Porter Stemmer算法的后继者.
该算法是最常用的英语词干算法, 被广泛使用.
来自Hunspell拼写检查库的词典.
一个词典包括两个文件:
  • .aff 词缀(affix)
  • .dic 词典(dictionary)
# 根据语言导出带有词干的词典, 需要先安装对应的aspell词典
# http://aspell.net/man-html/Working-With-Affix-Info-in-Word-Lists.html
aspell --lang en dump master | aspell --lang en munch
https://github.com/michmech/lemmatization-lists
一个可以免费下载的大型英语词汇数据库, 提供同义词集.
官方的最新版本是2006年发布的, 由于年代久远, 被批评为缺乏维护, 内容过时, 导致世界各地出现分叉.
https://github.com/globalwordnet/english-wordnet
一个WorldNet的英语分叉, 每年发布一个新版本.
将一个词转为lemma的过程被称作词形还原(lemmatization, 动词为lemmatize).
词形还原需要理解词在上下文中的词性, 从而准确确定词的词元.
与词干提取相比, 更接近学术领域.
这种技术的缺陷在于, 它是对原文进行基于统计的抽样, 其结果只是对文本句子的提取.
这种技术的结果并不令人满意, 因为它是建立在删除内容之上的, 因此一定会有造成信息的丢失.
理想中的文本摘要技术是建立在理解内容的基础上的总结, 而不是提取.
这就是GPT发挥作用的地方, 从ChatGPT(GPT-3.5)开始, GPT已经表现出不俗的摘要能力.
字节对编码是一种数据压缩技术, 也用于深度学习的NLP模型.
已知通过排除现有词典里的词汇来分辨新词是一个不够准确的方法, 仍然需要大量人力来修正, 无法赶上新词出现的速度.
在这种前提下, 只有无监督词库生成方案是可行的, 而中文领域几乎所有的无监督词库生成研究都建立在matrix67的这篇博客文章的基础之上.
值得注意的是, 无监督词库生成普遍会生成不符合人类习惯的词汇.
例如算法可能更倾向于认为"的电影"比"电影院"更像一个词汇, 而这仅仅是因为"的电影"在统计意义上比"电影院"有优势.
直觉化的做法是人工修复无监督词库, 但很快就会意识到词汇太多根本无法修复的问题.
合理的做法是将"的电影"也视作正常的词汇, 因为统计意义上, 它已经是一个真正的词汇, 用于机器的词典本身并没有必要去迎合或匹配人类的习惯.
正向词库生成是从小词开始, 尝试组成大词的词库生成方式.
这类生成方式的主要问题是它需要存储大量的中间数据用于统计, 语料库越大, 存储压力就越大.
复杂度至少为 O(n^2).
在数据量庞大的情况下, 内存很容易不够用, 而且由于读写的碎片性很强, 会频繁发生内存交换, 导致效率极其低下.
朴素方案的方法很符合直觉:
  1. 1.
    将所有文本按N-gram进行切分, 对于中文一般切分到四元就够了.
  2. 2.
    统计每个词汇的词频.
  3. 3.
    设置一个阈值, 将出现频率在该值以上的词视作词汇.
    例如, 可以通过80/20法则将前20%的词视作词汇.
逆向词库生成法只需要分别执行一次1-gram和2-gram的统计, 然后根据统计结果将句子拆分成更小的片段, 将最后无法拆分的片段视作词汇.
这种方法无论是性能还是内存需求都要比正向词库生成好得多, 复杂度可以看作为 O(n).
采用这种方法时, 由于无法事先算好高于2-gram的信息熵, 以及考虑到存储成本, 不会计算信息熵.
参考: https://blog.csdn.net/gzdmcaoyc/article/details/50001801
(actual_term_frequency / irrelevant_term_frequency) <= alpha

actual_term_frequency <= irrelevant_term_frequency * alpha
alpha的取值范围为大于等于1的整数, alpha越高词汇拆分得越深, 通常也意味着词库体积的缩小.
alpha的取值完全依赖于经验, 取1是完全可以的, 但由于没有计算信息熵, 拆分结果会出现一些名词与副词的组合, 需要进行过滤.
参考: https://spaces.ac.cn/archives/3913
通过观察生成的词库, 不难假设一个"完美"的词库能够符合幂律分布:
词频最高的词汇与词频一般的词汇有着巨大的差距, 位于长尾部分的低词频词汇的数量也理所当然的多.
通过将词频去重后排序, 像找中位数那样寻找一个可以用于分隔基准值(例如百分比1%, 4%, 10%, 20%, 50%, 80%),
然后删掉词频小于此基准值的结果, 就可以生成一个有可用性的词库.
词频是该词在整个语料库里出现的次数, 由 该词语的出现次数/语料库里所有词语的出现次数 得出.
在新词发现时, 用来衡量片段内部搭配的固定程度.
朴素无监督词库生成没有考虑到将词汇拼接在一起的情况.
显然如果一些词总是一前一后出现, 意味着这些词很可能本身就是一个词的不同部分.
例如, "电影"是一个词, "电影"又普遍与"院"一前一后出现, 那么有理由将"电影院"作为一个单独的词汇输出.
如果两个一前一后出现的词汇本身毫无关系, 它们恰好以这种情况出现的概率应该是 P(先出现的词) * P(后出现的词).
该概率应该等同于用这两个词拼接出来的词的词频, 可得出 P(合并后得到的长词) = P(先出现的词) * P(后出现的词).
如果实际上长词的词频高于 P(先出现的词) * P(后出现的词), 则说明这个长词可能真实存在.
通过为 长词词频 / P(先出现的词) * P(后出现的词) 设置一个阈值, 可以将这些长词过滤出来.
用于新词发现时, 用来衡量片段外部左右搭配的丰富程度, 搭配越多, 信息熵越大.
可以通过设置一个阈值, 将此阈值之上的词汇视作单独词汇, 将此阈值之下的词汇视作其他词汇的一部分.
在用于新词发现时, 由于词语左右两边都可能发生组合, 因此存在"左信息熵"和"右信息熵".
当需要判断一个词的信息熵时, 一般取较小的值作为信息熵.
https://en.wikipedia.org/wiki/Entropy_(information_theory)
信息熵描述"听说新消息后, 关于相关事件的不确定性的减少量".
信息熵可以有效过滤掉重复/空洞的内容.
由于信息熵的计算不涉及语义分析, 不能用来判断内容的品质.
function getEntropy(text: string): number {
const words = tokenize(text)
const totalWords = words.length
const wordToCount = new Map<string, number>()
for (const word of words) {
wordToCount.set(word, (wordToCount.get(word) ?? 0) + 1)
}
let entropy = 0
for (const count of wordToCount.values()) {
const wordFrequency = count / totalWords
entropy += -wordFrequency * Math.log2(wordFrequency)
}
return entropy
}
根据情况, 可以像拉丁字母那样直接使用逐汉字, 连续二汉字, 连续三汉字进行索引.
与专门的中文分词方法相比, term数量太大, 查询效率较低.
配合基于统计的方法, 则可以借由N-gram来生成词典, 从而减少term数量.
通过查找词典里已有的词进行切分.
词语越长, 匹配的优先级就越高.
与其他方法相比, 在速度上很有优势.
  • 基于词典规则的系统无法正确处理有二义性的短语.
  • 无法处理未记载于词典里的新词.
    实际整理词典时会发现, 中文环境中的新词几乎是源源不断地被创造出来, 想要通过人工完善词典是不可能的.
    估计中文的词汇量超过40万个, 这对于查询来说也很困难, 为此必须使用专门的数据结构.
以下算法的性能瓶颈在于"判断词典是否有这个词", 因此有多种实现方式:
  • Set: 牺牲时间换空间
  • Map: 牺牲空间换时间
  • 字典树(Trie): 仅在静态语言里可用(内存结构有优势)的推荐做法, 需要提前编译
  • 前缀树(Prefix Trie): 在字典树的基础上上进一步加快速度, 编译时间更长.
  • AC自动机(Aho-Corasick): 优于前缀树, 内部含有后缀树.
  • BinTrie: 首字散列其余二分的字典树, 出自HanLP.
  • 双数组字典树(DAT, Double Array Trie): 速度最快的结构, 实现难度大, 编译难度大.
    DAT的经典实现是Darts(Double-ARray Trie System)
该实现通常会暴露一个类似commonPrefixSearch的方法, 用以进行前缀匹配,
用于切分时, 只需要创建一层循环, 将子字符串投入匹配:
for i in 0..text.len() {
for (id, length) in dat.common_prefix_search(&text[i..]) {
// ...
}
}
朴素完全切分是最简单的完全切分(找出文本中的所有单词)算法.
其具体做法是创建两层嵌套循环, 第一层作为起点索引, 第二层作为终点索引,
遍历被查找字符串中的所有子文本, 输出词典中的所有匹配项.
由于复杂度是 O(n^2), 性能最差, 稍微长一点的文本就会造成性能问题.
完全切分方式之一, 通过AC自动机找到所有匹配项.
完全切分方式之一, 通过ACDAT找到所有匹配项.
性能最高的完全切分方式, 通过DAT找到所有匹配项.
在分词时正向遍历匹配最长的词, 每当成功匹配到一个最长词后, 从这个词之后的字符继续匹配, 直到结束.
正向最长匹配的逆向版本, 在分词时逆向遍历匹配最长的词.
在实践中效果比正向最长匹配好很多, 但仍然不完美.
为解决正向, 逆向最长匹配的问题而出现的一种融合了正向逆向两种方法的匹配规则:
同时进行正向和逆向匹配.
如果结果的词数量不同, 则取数量更少的那个作为结果.
如果结果的词数量相同, 则取字符更少的作为结果.
如果字符数量也相同, 返回逆向最长匹配的结果.
在实践中效果和逆向最长匹配差不多, 有时甚至会比逆向最长匹配差.
https://github.com/NLP-LOVE/Introduction-NLP
https://github.com/NLP-LOVE/ML-NLP
在深度学习的情况下, 训练模型不需要预先分词,
不分词(逐字符)的训练结果比分词的要好.
由Google于2019年发布的NLP模型.
它直接对中文文本内容进行基于字符的tokenize.
句子中出现了不存在于词典中的词.
能够从文本里发现词与词的关系, 从而发现新词的模型.
最流行的方案是将单字分为Begin(B), Middle(M), End(E), Single(S)四种类型.
根据词性将词分类.
  • 汉语中一个单词具有多个词性是很常见的
  • 依赖人力进行标注, 可用的语料库很少
  • 学界对于标注规范存在分歧
提取文本中高频出现的词汇作为关键词(需要过滤掉停用词).
高频词不等于关键词, 因此通常会使用TF-IDF.
TF-IDF是词频和逆向文档频率的乘积, 旨在定义文档中词语(或短语)的重要性.
TF-IDF的值与词语在文档中出现的次数成正比, 与在整个语料库里出现的次数成反比.
在整个语料库里常见的词语(主要是日常用语)会被降权, 在少数文档中被反复提及的词语(例如专用术语)会被提权.
TF-IDF = 词频 * 逆向文档频率
对于不同的文档, TF的值会改变.
简单版本的算法: 词频 = 该文档中词语出现的次数/该文档的总词数
在实际使用中, 很可能会想要把词频向下缩放, 以避免因为它的值太大而对TF-IDF产生影响:
词频 = log10(1 + (该文档中词语出现的次数/该文档的总词数))
注: 加上1是为了防止 log10(0) 错误.
参考: https://jmotif.github.io/sax-vsm_site/morea/algorithm/TFIDF.html
一个词汇的IDF值越高, 越说明这个词很少被使用.
对于相同的语料库, IDF的值是不变的.
简单版本的算法: 逆向文档频率 = 文档总数/出现该词语的文档数量
在实际使用中, 很可能会想要把逆向文档频率向下缩放, 以避免因为它的值太大而对TF-IDF产生影响.
常见的缩放方法是使用对数函数:
逆向文档频率 = log10(文档总数/(出现该词语的文档数量 + 1))
注: 分母加上1是为了防止除零错误.
  • 需要大型的语料库来得出IDF值.
  • 用于提取关键词时, 罕见词语是重要词语的假设可能是错误的.
  • 忽略了语序的意义.
TF-IDF的一种改进变种, 不仅能衡量一个词的重要性, 还能衡量多个词的重要性.
语料库是结构化的大量文本资料, 用于NLP研究.
维基百科上搜集的语料库列表:
https://zh.m.wikipedia.org/zh-hans/%E8%AF%AD%E6%96%99%E5%BA%93
https://en.wikipedia.org/wiki/List_of_text_corpora
维基百科提供各语言的百科数据库下载, 数据量大(仅中文就有超过400万个页面), 内容重复性低, 很容易相信它是一个很好的语料来源.
维基百科的内容使用了Wikitext标记语言, 存在大量自定义的模板, 在提取文本时需要过滤掉它们.
中文: https://dumps.wikimedia.org/zhwiki/
英文: https://dumps.wikimedia.org/enwiki/
维基百科的数据量相当大, 因此转储使用bz2算法压缩.
bz2压缩能够将XML文件压缩至1/5, 但由于压缩的原因, 必须解压整个bz2文件才能读取信息.
multistream bz2解决了必须整个解压bz2文件才能读取信息的问题, 通过将未压缩的数据拆分为块的方式实现分块压缩, 最后得到一个分块压缩的bz2文件.
通过只解压相应的块, 就可以在不解压整个bz2文件的情况下获取数据.
为了索引页面, 维基百科的转储提供一个用 : 作为分隔符的index文件存储内容的偏移值以起到索引的效果.
索引文件的格式为: 块的起始字节:文件的ID:文章标题, 由于分块压缩, 会有多个文章使用相同的"块的起始字节".
参考: https://alchemy.pub/wikipedia
https://github.com/attardi/wikiextractor
用于从维基转储里提取文本内容的CLI程序.
https://github.com/attardi/wikiextractor/issues/287
https://conferences.unite.un.org/UNCorpus/Home/Index/zh
多语言对齐语料库, 适用于机器翻译.
https://files.pushshift.io/reddit/submissions/
词典的最佳来源是权威词典, 例如各种人工编撰出版的词典;
其次是学术结果, 例如知识图谱;
然后才是维基百科之类的众包内容源, 以及输入法词库这样的经过实战的内容源;
而互联网上由一般用户分发的词典基本上是不可用的垃圾, 绝对不应使用.
Unix系统的 /usr/share/dict 目录包含系统上安装的词典, 此处的词典会被一些需要词典的程序使用.
Debian系发行版通过wordlist包提供词典.
https://freedict.org/
内容距离上次更新久远.
提供的语言到语言到词典, 数据需要从TEI格式里手动分离.
http://wordlist.aspell.net/
可供Hunspell和Aspell使用的词典.
没有提供中文词典.
北京大学语言计算与机器学习研究组推出的中文分词工具包.
准确率高于jieba和THULAC.
性能比jieba差, 内存占用量也高于jieba.
清华大学自然语言处理与社会人文计算实验室推出的中文分词工具包.
准确率高于jieba.
据称jieba的词性标注参照了 ICTCLAS 汉语词性标注集.
jieba项目已知存在诸多问题:
  • 技术陈旧.
  • 算法存在缺陷.
  • 自带词典的词性标注错误百出.
  • 词典收录了一些莫明其妙的词汇.
  • 词典大小不匹配: idf词典的内容少于主词典.
  • 用户词典优先级太高, 会覆盖掉默认词典里相同的词, 这导致一些原本有词性的词丢失词性标注.
  • 分词功能不支持自定义停用词.
  • API缺乏实用性:
    • 无法在不分词的前提下查询词典里的项目, 例如查找单个词汇的td-idf值.
    • cutForSearch(在不使用HMM的情况下)和cutAll会把单词切分为字母, 简直无法理解.
    • cut系列的API太高级, 以至于无法生成适合连续短语搜索的分词结果.
  • 尽管jieba的各种实现速度都比较快, 但准确性往往大幅弱于其他解决方案.
  • cppjieba的实现没有足够的内存优化, 载入字典后会占用大量内存.
jieba自带的词典长期没有更新, 最旧的词典已经8年没有改动了.
dict: 主词典, 带权重和词性标签.
hmmDict: 隐式马尔科夫模型, 建议使用默认词典.
userDict: 用户词典, 建议自己根据需要定制.
idfDict: 关键词抽取所需的idf信息.
stopWordDict: 关键词抽取所需的停用词列表.
词典需是UTF-8编码的文件, 格式如下:
{词语} {词频(可省略)} {词性(可省略}}
示例:
创新办 3 i
云计算 5
凱特琳 nz
停用词词典只适用于TFIDF和TextRank, 不可用于分词.
停用词词典没有外部接口, 需要修改源代码才能实现.
专门开发了更有效率的DAT实现cedarwood以提升性能,
但其性能(v0.4.5)已经远慢于其他实现:
  • yada(v0.5.0, 性能为cedarwood的2~3倍)
  • daachorse(v0.4.3, 性能为cedarwood的4~5倍)
该实现的分词存在一些问题, 无法分词 test-1 这样的文本:
https://github.com/messense/jieba-rs/issues/83
停用词词典只适用于KeyExtractor和TextRankExtractor, 不可用于分词.
使用最新NLP技术的NLP项目.
由Java编写, 使用经典NLP技术实现.
由Python编写, 使用深度学习技术实现(即使用Python的原因).
载入模型的速度相当慢, 只支持按句子分词.
对于开放和封闭两个模型来说, 一条简单的句子在CPU下需要几十毫秒乃至数百毫秒才能完成分词,
实在难以作为一个分词组件使用.
Python主要的NLP工具库之一, 包含大量可用的功能.
被认为适合研究人员.
官方提供多种语料库的下载.
Python主要的NLP工具库之一, 只提供最好的方法.
自称为工业级, 一开始就以投入生产为目的开发, 并且有较好的准确度.
在官方的速度比较中, 性能远优于Stanza, Flair, UDPipe.
中文分词时底层使用逐字符分割, jieba, pkuseg等.
官方提供多种语言的模型的下载.
Java主要的NLP工具库.
深度学习NLP工具库.
Hugging Face旗下的Rust分词库, 基于NLP模型.
https://www.hankcs.com/nlp/corpus/several-revenue-segmentation-system-used-set-of-source-tagging.html