第一章,新手上路
1.1自然语言与编程语言
- 词汇量
- 结构化:
- 歧义性:
- 容错性;
- 易变性
- 简略性
1.2自然语言处理的层次
- 文本:语音(语音识别),图像(光学符号识别),和文本。
- 词法分析:
- 中文分词(将文本分割为有意义的词语,将字序列分割为次序列),
- 词性标注(确定每个词语的类别和浅层的歧义消除),
- 命名实体识别(较长的专有名词)
- 信息抽取:根据单词和词性,抽取一部分有用的信息
- 文本分类与文本聚类:
- 文本分类:一段话的语义,邮件是否是垃圾邮件,即把文档分类整理,即为文本分类,
- 文本聚类:把相识的文档归档在一起,或者排除重复的文档,
- 句法分析:根据句子主谓宾结构,分析描述句子意思,用于问答系统和搜索引擎。
- 语义分析和篇章分析:相比较语法分析,语义分析侧重于语义而非语法,
- 包括词义消歧(确定一个词在语境中的含义,不是简单词性),
- 语义角色标注(标注句子中的谓语和其他成分的关系)
- 语义依存分析(分析句子中词语之间的语义关系)
- 其他任务:
- 自动问答
- 自动摘要
- 机器翻译
- 信息检索(IR)是区别于自然语言处理的独立学科,IR的目标是查询信息,而NLP的目标是理解语言。
1.3 自然语言处理的流派
1.4 机器学习
机器学习:不直接编程却能赋予计算机提高能力的方法,及计算机通过某项任务的经验数据提高在该项任务上的能力。让机器学会算法的算法,把机器学习算法称为元算法,把机器学到的算法成为模型。
模型:
模型是对现实问题的数学抽象,由一个假设函数和一系列参数构成,可以表示:
自变量X为一个特征向量。用来表示一个对象的特征。
特征:
指事物特点转化后的数值,把事物特征通过数值类型来提取,称为特征提取,
名字的特征提取:
“沈雁冰”特征提取
特征序号 | 特征条件 | 特征值 |
1 | 是否含有“雁" | 1 |
2 | 是否含有”冰“ | 1 |
沈雁冰的可以表示为二维向量
,
假设根据名字判断性别的模型为:
。
中,表示特征值,比如娜是女性特征,磊是男性特征。
不需要通过字来写特征,定义一套模板来提取特征,比如通过定义变量,遍历多个不同的名字,获取属于名字的所有模板,是通过名字生成,不是自己去分析统计得到,自动提取特征的模板称为特征模板。
设计特征模板称为特征工程。特征越多,参数就多,模型就越复杂,模型的复杂度与数据集匹配。
数据集:
供元算法学习的Demo称为样本(习题),多个样本称为数据集,在自然语言处理领域称为语料库,数据集的种类很多。
常用的数据集:
- MNIST (手写数字识别):
- ImageNet(图像识别):
- TREC(信息检索):
- SQuAD(自动问答):
- Europrl(机器翻译):
监督学习:
当习题有标准答案的时候,此时的学习算法称为监督学习,监督学习算法在机器做完题之后,会纠正模型错误,
对于性别识别来讲,数据集每个人名被表示为,
对于督导学习来讲,如果答案是男性,而函数输出女性。
在 中,已知 ,要想使 增大到非负值,需要将 的 值增大一些、当多次迭代修改之后,男性的常用字权重就会加大,女性的常用字频率会减小。即这种优先级就会自动求出来。在自然语言处理中,对应每个词的词频。
这种在有标签(答案)的数据集上迭代学习的过程称为训练,训练用的数据集称为训练集,训练的结果是一系列参数(特征权重)或者迭代好的模型。利用模型,我们可以为任意一个名字做性别预测,
无监督学习:
即告诉题,不告诉答案。无监督学习一般用于聚类和降维,两者都不需要标注数据。
聚类:把相识的文档归档在一起,或者排除重复的文档,根据样本的相似性和簇的粒度决定。
降维:将样本点从高维空间变换到低维空间的过程,机器学习中的高维数据比比皆是,比如性别识别,以常用汉字为特征,就突破2000,如果有个特征,则样本对应维空间的点,多出的给假设函数的因变量使用。如果想让样本点可视化,需要降到二维或者三维。
降维算法要求,降维后尽量不损失信息,或者讲,样本在低维空间里每个维度的方差尽量大。
其他的类型的机器学习算法:
半监督学习:从多个不同的模型中提取相同预测的一致结果作为训练样本,扩充训练集。
强化学习:一边预测,一边根据环境情况反馈规划下一次决策。
1.5语料库:
自然语言处理领域的数据集
中文分词语料库:
人工正确切分后的句子集合
词性标注语料库:
句子切分后为每个词语指定一个词性的语料。词性标注集。
命名实体识别语料库
人工标注了文本内部制作者关心的实体名词以及实体类别。
句法分析语料库:
汉语中常用的句法分析语料库有CTB,即分词间语法的联系。
文本分类语料库:
人工标注了所所属分类的文章构成的语料库
语料库建设,即构建语料库的过程,规范制定 -- 人员培训--人工标注
1.6 开源工具:
- python接口:https://github.com/hankcs/pyhanlp
- java接口:https://github.com/hankcs/HanLP
第二章,词典分词
中文分词:即将一段文本拆分为一系列单词的过程。中文分词分为两类:
- 基于词典规则
- 基于机器学习
2.1 词典分词:
词的定义
语言学上定义具备独立意义的最小的单位。
词的性质:
齐夫定律:一个单词的词频于它的词频排名成反比,中文如此。
前30个常用词统计
2.2 词典
Hanlp词典:
词典的加载:
python:
from pyhanlp import *
def load_dictionary():
"""
加载HanLP中的mini词库
:return: 一个set形式的词库
"""
IOUtil = JClass('com.hankcs.hanlp.corpus.io.IOUtil')
path = HanLP.Config.CoreDictionaryPath.replace('.txt', '.mini.txt')
dic = IOUtil.loadDictionary([path])
return set(dic.keySet())
if __name__ == '__main__':
dic = load_dictionary()
print(len(dic))
print(list(dic)[0])
85584
倒贴
切分算法:
对于不同的词语选择规则来进行词典查询,常用的规则有正向最长匹配,逆向最长匹配。和双向最长匹配。它都是基于完全切分过程。
完全切分:
找出一段文本中的所有单词。朴素的完全切分算法比较简单,只需要遍历文本中的连续序列,查询改序列是否在文本上即可,
def fully_segment(text, dic):
word_list = []
for i in range(len(text)): # i 从 0 到text的最后一个字的下标遍历
for j in range(i + 1, len(text) + 1): # j 遍历[i + 1, len(text)]区间
word = text[i:j] # 取出连续区间[i, j]对应的字符串
if word in dic: # 如果在词典中,则认为是一个词
word_list.append(word)
return word_list
if __name__ == '__main__':
dic = load_dictionary()
print(fully_segment('商品和服务', dic))
正向最长匹配:
对完全切分进行优化,我们需要有意义的词语序列,不是字典中单词构成的列表。考虑单词越长表达的意义越多。定义单词越长,优先级越高,即优先输出字典中最长的词,扫描顺序从前往后,即正向最长匹配。
from tests.book.ch02.utility import load_dictionary
def forward_segment(text, dic):
word_list = []
i = 0
while i < len(text):
longest_word = text[i] # 当前扫描位置的单字
for j in range(i + 1, len(text) + 1): # 所有可能的结尾
word = text[i:j] # 从当前位置到结尾的连续字符串
if word in dic: # 在词典中
if len(word) > len(longest_word): # 并且更长
longest_word = word # 则更优先输出
word_list.append(longest_word) # 输出最长词
i += len(longest_word) # 正向扫描
return word_list
if __name__ == '__main__':
dic = load_dictionary()
print(forward_segment('就读北京大学', dic))
print(forward_segment('研究生命起源', dic))
['就读', '北京大学']
['研究生', '命', '起源']
误差的原因及研究生的优先级大于研究
逆向最长匹配:
from tests.book.ch02.utility import load_dictionary
def backward_segment(text, dic):
word_list = []
i = len(text) - 1
while i >= 0: # 扫描位置作为终点
longest_word = text[i] # 扫描位置的单字
for j in range(0, i): # 遍历[0, i]区间作为待查询词语的起点
word = text[j: i + 1] # 取出[j, i]区间作为待查询单词
if word in dic:
if len(word) > len(longest_word): # 越长优先级越高
longest_word = word
break
word_list.insert(0, longest_word) # 逆向扫描,所以越先查出的单词在位置上越靠后
i -= len(longest_word)
return word_list
if __name__ == '__main__':
dic = load_dictionary()
print(backward_segment('项目的研究', dic))
print(backward_segment('研究生命起源', dic))
['项', '目的', '研究']
['研究', '生命', '起源']
双向最长匹配,
即同时执行正向逆向最长匹配,若词数不同,则返回词数更少的那个,否则返回两者中单字更少的那个,当单字也相同的时候,优先返回逆向最长匹配的结果。来源于语言学汉字的特性,启发式算法(单字词的算法要小于非单字词的算法),
from tests.book.ch02.backward_segment import backward_segment
from tests.book.ch02.forward_segment import forward_segment
from tests.book.ch02.utility import load_dictionary
def count_single_char(word_list: list): # 统计单字成词的个数
return sum(1 for word in word_list if len(word) == 1)
def bidirectional_segment(text, dic):
f = forward_segment(text, dic)
b = backward_segment(text, dic)
if len(f) < len(b): # 词数更少优先级更高
return f
elif len(f) > len(b):
return b
else:
if count_single_char(f) < count_single_char(b): # 单字更少优先级更高
return f
else:
return b # 都相等时逆向匹配优先级更高
if __name__ == '__main__':
dic = load_dictionary()
print(bidirectional_segment('研究生命起源', dic))
效果还没有逆向分词好。
速度测评:
词典分词的核心在于速度而不在于精度。
python;
java:
java代码:
package com.hankcs.book.ch02;
import com.hankcs.hanlp.corpus.io.IOUtil;
import com.hankcs.hanlp.dictionary.CoreDictionary;
import java.io.IOException;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* 《自然语言处理入门》2.3 切分算法
*/
public class NaiveDictionaryBasedSegmentation
{
public static void main(String[] args) throws IOException
{
// 加载词典
TreeMap<String, CoreDictionary.Attribute> dictionary =
IOUtil.loadDictionary("data/dictionary/CoreNatureDictionary.mini.txt");
System.out.printf("词典大小:%d个词条\n", dictionary.size());
System.out.println(dictionary.keySet().iterator().next());
// 完全切分
System.out.println(segmentFully("就读北京大学", dictionary));
// 正向最长匹配
System.out.println(segmentForwardLongest("就读北京大学", dictionary));
System.out.println(segmentForwardLongest("研究生命起源", dictionary));
System.out.println(segmentForwardLongest("项目的研究", dictionary));
// 逆向最长匹配
System.out.println(segmentBackwardLongest("研究生命起源", dictionary));
System.out.println(segmentBackwardLongest("项目的研究", dictionary));
// 双向最长匹配
String[] text = new String[]{
"项目的研究",
"商品和服务",
"研究生命起源",
"当下雨天地面积水",
"结婚的和尚未结婚的",
"欢迎新老师生前来就餐",
};
for (int i = 0; i < text.length; i++)
{
System.out.printf("| %d | %s | %s | %s | %s |\n", i + 1, text[i],
segmentForwardLongest(text[i], dictionary),
segmentBackwardLongest(text[i], dictionary),
segmentBidirectional(text[i], dictionary)
);
}
evaluateSpeed(dictionary);
}
/**
* 评测速度
*
* @param dictionary 词典
*/
public static void evaluateSpeed(Map<String, CoreDictionary.Attribute> dictionary)
{
String text = "江西鄱阳湖干枯,中国最大淡水湖变成大草原";
long start;
double costTime;
final int pressure = 10000;
System.out.println("正向最长");
start = System.currentTimeMillis();
for (int i = 0; i < pressure; ++i)
{
segmentForwardLongest(text, dictionary);
}
costTime = (System.currentTimeMillis() - start) / (double) 1000;
System.out.printf("%.2f万字/秒\n", text.length() * pressure / 10000 / costTime);
System.out.println("逆向最长");
start = System.currentTimeMillis();
for (int i = 0; i < pressure; ++i)
{
segmentBackwardLongest(text, dictionary);
}
costTime = (System.currentTimeMillis() - start) / (double) 1000;
System.out.printf("%.2f万字/秒\n", text.length() * pressure / 10000 / costTime);
System.out.println("双向最长");
start = System.currentTimeMillis();
for (int i = 0; i < pressure; ++i)
{
segmentBidirectional(text, dictionary);
}
costTime = (System.currentTimeMillis() - start) / (double) 1000;
System.out.printf("%.2f万字/秒\n", text.length() * pressure / 10000 / costTime);
}
/**
* 完全切分式的中文分词算法
*
* @param text 待分词的文本
* @param dictionary 词典
* @return 单词列表
*/
public static List<String> segmentFully(String text, Map<String, CoreDictionary.Attribute> dictionary)
{
List<String> wordList = new LinkedList<String>();
for (int i = 0; i < text.length(); ++i)
{
for (int j = i + 1; j <= text.length(); ++j)
{
String word = text.substring(i, j);
if (dictionary.containsKey(word))
{
wordList.add(word);
}
}
}
return wordList;
}
/**
* 正向最长匹配的中文分词算法
*
* @param text 待分词的文本
* @param dictionary 词典
* @return 单词列表
*/
public static List<String> segmentForwardLongest(String text, Map<String, CoreDictionary.Attribute> dictionary)
{
List<String> wordList = new LinkedList<String>();
for (int i = 0; i < text.length(); )
{
String longestWord = text.substring(i, i + 1);
for (int j = i + 1; j <= text.length(); ++j)
{
String word = text.substring(i, j);
if (dictionary.containsKey(word))
{
if (word.length() > longestWord.length())
{
longestWord = word;
}
}
}
wordList.add(longestWord);
i += longestWord.length();
}
return wordList;
}
/**
* 逆向最长匹配的中文分词算法
*
* @param text 待分词的文本
* @param dictionary 词典
* @return 单词列表
*/
public static List<String> segmentBackwardLongest(String text, Map<String, CoreDictionary.Attribute> dictionary)
{
List<String> wordList = new LinkedList<String>();
for (int i = text.length() - 1; i >= 0; )
{
String longestWord = text.substring(i, i + 1);
for (int j = 0; j <= i; ++j)
{
String word = text.substring(j, i + 1);
if (dictionary.containsKey(word))
{
if (word.length() > longestWord.length())
{
longestWord = word;
break;
}
}
}
wordList.add(0, longestWord);
i -= longestWord.length();
}
return wordList;
}
/**
* 统计分词结果中的单字数量
*
* @param wordList 分词结果
* @return 单字数量
*/
public static int countSingleChar(List<String> wordList)
{
int size = 0;
for (String word : wordList)
{
if (word.length() == 1)
++size;
}
return size;
}
/**
* 双向最长匹配的中文分词算法
*
* @param text 待分词的文本
* @param dictionary 词典
* @return 单词列表
*/
public static List<String> segmentBidirectional(String text, Map<String, CoreDictionary.Attribute> dictionary)
{
List<String> forwardLongest = segmentForwardLongest(text, dictionary);
List<String> backwardLongest = segmentBackwardLongest(text, dictionary);
if (forwardLongest.size() < backwardLongest.size())
return forwardLongest;
else if (forwardLongest.size() > backwardLongest.size())
return backwardLongest;
else
{
if (countSingleChar(forwardLongest) < countSingleChar(backwardLongest))
return forwardLongest;
else
return backwardLongest;
}
}
}
字典树
匹配算法的瓶颈之一在与如何判断集合是否含有字符串,如果用有序集合的话(TreeMap)复杂度为
,
为词典的大小,如果用散列表的话(HashMap,dict),即空间还时间的方法。
字符串集合常用字典树(trie树,前缀树)存储。
字典树即将词语视为root到某一节点的一条路径,并在词尾点上做标记。如果能走到特殊标记的节点,说明在该集合中。否则不存在。
不光是集合,字典树也可以实现映射,需要将相应的值悬挂在键的终点节点。当词典的大小为
时,最坏情况下的任然是
实际速度比二分法要快,前缀匹配是递进的过程,算法不必比较支符串的前缀。
字典数的节点实现
我们约定用值为None表示节点不对应词语,这样就不能插入值为None 的键了,节点的实现代码:
class Node(object):
# 数据结构,构造方法。
# 指向下一个节点的字典
# 当前节点的值
def __init__(self, value) -> None:
self._children = {}
self._value = value
# 添加子节点
def _add_child(self, char, value, overwrite=False):
child = self._children.get(char)
if child is None:
child = Node(value)
self._children[char] = child
elif overwrite:
child._value = value
return child
在添加节点中,会先检查已经存在的字符char对应的child,如果不存在就直接插入子节点,如果存在,根据标识确定是否要覆盖。
字典树的增删改查的实现
class Trie(Node):
def __init__(self) -> None:
super().__init__(None)
def __contains__(self, key):
return self[key] is not None
def __getitem__(self, key):
state = self
for char in key:
state = state._children.get(char)
if state is None:
return None
return state._value
def __setitem__(self, key, value):
state = self
for i, char in enumerate(key):
if i < len(key) - 1:
state = state._add_child(char, None, False)
else:
state = state._add_child(char, value, True)
删改查其实是一回事,都是查询,删除操作无非是将终点设置成None,修改即设置成其他的值。
从确定有限状态机自动机(DFA)来讲,每个节点都是一个状态,状态表示当前,已经查询的的前缀
从父节点到子节点的移动过程可以看做是一次状态的转移,当某个字符进行状态转移的时候,会询问该字符父节点与子节点的映射关系,如果父节点有满足条件的边,则进行状态转移,否则失败,当完成了全部的状态转移,我们询问最后一个状态是否为终点状态。
Java代码实现
首子散列其余二分的字典树
散列函数,用于将对象转换为整数,散列函数满足,对象相同,则散列值相同,如果做到对象不同,散列值不同则为完美散列。
对于Python内置的dict来讲,实际用的是字符串的散列函数,在64位系统上,返回64位整数,但unicode字符总共才136690个,远远小于
,这就导致两个字符相邻,散列值差很多。Java的散列相对友好一些,Java使用UTF-16,每个字符可以映射为16位不重复的整数。所有散列函数输出区间为
内的正整数,
具体做法是,创建一个65535的数组,将子节点按对应的哈希值索引存放到内存中,这样每次状态转移,都是直接寻址。
但是这种做法内存指数膨胀,当词典中词语长度为
时,则字段数