《自然语言处理入门》读书笔记

第一章,新手上路

1.1自然语言与编程语言

  1. 词汇量
  2. 结构化:
  3. 歧义性:
  4. 容错性;
  5. 易变性
  6. 简略性

1.2自然语言处理的层次

  • 文本语音(语音识别),图像(光学符号识别),和文本。
  • 词法分析
    • 中文分词(将文本分割为有意义的词语,将字序列分割为次序列),
    • 词性标注(确定每个词语的类别和浅层的歧义消除),
    • 命名实体识别(较长的专有名词)
  • 信息抽取:根据单词和词性,抽取一部分有用的信息
  • 文本分类与文本聚类
    • 文本分类:一段话的语义,邮件是否是垃圾邮件,即把文档分类整理,即为文本分类,
    • 文本聚类:把相识的文档归档在一起,或者排除重复的文档,
  • 句法分析:根据句子主谓宾结构,分析描述句子意思,用于问答系统和搜索引擎。
  • 语义分析和篇章分析:相比较语法分析,语义分析侧重于语义而非语法,
    • 包括词义消歧(确定一个词在语境中的含义,不是简单词性),
    • 语义角色标注(标注句子中的谓语和其他成分的关系)
    • 语义依存分析(分析句子中词语之间的语义关系)
  • 其他任务
    • 自动问答
    • 自动摘要
    • 机器翻译
  • 信息检索(IR)是区别于自然语言处理的独立学科,IR的目标是查询信息,而NLP的目标是理解语言。

1.3 自然语言处理的流派

1.4 机器学习

机器学习不直接编程却能赋予计算机提高能力的方法,及计算机通过某项任务的经验数据提高在该项任务上的能力。让机器学会算法的算法,把机器学习算法称为元算法,把机器学到的算法成为模型。

模型:

模型是对现实问题的数学抽象,由一个假设函数和一系列参数构成,可以表示:

f(X) = W \cdot X + b

自变量X为一个特征向量。用来表示一个对象的特征。

特征:

指事物特点转化后的数值,把事物特征通过数值类型来提取,称为特征提取

名字的特征提取:

“沈雁冰”特征提取
特征序号 特征条件 特征值
1 是否含有“雁" 1
2 是否含有”冰“ 1

沈雁冰的可以表示为二维向量X = [1,1]

假设根据名字判断性别的模型为

f(X) = W \cdot X + b

f(X) = W\cdot X+ b = W+b 中,W = [w1,w2....]表示特征值,比如娜是女性特征,磊是男性特征。

不需要通过字来写特征,定义一套模板来提取特征,比如通过定义name变量,遍历多个不同的名字,获取属于名字的所有模板,W是通过名字生成,不是自己去分析统计得到,自动提取特征的模板称为特征模板。

设计特征模板称为特征工程。特征越多,参数就多,模型就越复杂,模型的复杂度与数据集匹配。

数据集:

供元算法学习的Demo称为样本(习题),多个样本称为数据集,在自然语言处理领域称为语料库,数据集的种类很多。

常用的数据集:

  1. MNIST (手写数字识别):
  2. ImageNet(图像识别):
  3. TREC(信息检索):
  4. SQuAD(自动问答):
  5. Europrl(机器翻译):

监督学习:

当习题有标准答案的时候,此时的学习算法称为监督学习,监督学习算法在机器做完题之后,会纠正模型错误,

对于性别识别来讲,数据集每个人名被表示为,(Xi,Yi), Yi \in (+1,-1) 

对于督导学习来讲,如果答案是男性,而函数输出女性。

在    F(X) = W1\cdot X1+W2\cdot X2 + ...... + Wn\cdot Xn+b  中,已知   Xi \in ( 0,1) ,要想使  f(x)  增大到非负值,需要将 Xi = 1 的Wi 值增大一些、当多次迭代修改之后,男性的常用字权重就会加大,女性的常用字频率会减小。即这种优先级就会自动求出来。在自然语言处理中,对应每个词的词频。

这种在有标签(答案)的数据集上迭代学习的过程称为训练,训练用的数据集称为训练集,训练的结果是一系列参数(特征权重)或者迭代好的模型。利用模型,我们可以为任意一个名字做性别预测

无监督学习:

即告诉题,不告诉答案。无监督学习一般用于聚类和降维,两者都不需要标注数据。

聚类:把相识的文档归档在一起,或者排除重复的文档,根据样本的相似性和簇的粒度决定。

降维:将样本点从高维空间变换到低维空间的过程,机器学习中的高维数据比比皆是,比如性别识别,以常用汉字为特征,就突破2000,如果有n个特征,则样本对应n + 1维空间的点,多出的给假设函数的因变量使用。如果想让样本点可视化,需要降到二维或者三维。

降维算法要求,降维后尽量不损失信息,或者讲,样本在低维空间里每个维度的方差尽量大。

其他的类型的机器学习算法:

半监督学习:从多个不同的模型中提取相同预测的一致结果作为训练样本,扩充训练集。

强化学习:一边预测,一边根据环境情况反馈规划下一次决策。

1.5语料库:

自然语言处理领域的数据集

中文分词语料库:

人工正确切分后的句子集合

词性标注语料库:

句子切分后为每个词语指定一个词性的语料。词性标注集

命名实体识别语料库

人工标注了文本内部制作者关心的实体名词以及实体类别。

句法分析语料库:

汉语中常用的句法分析语料库有CTB,即分词间语法的联系。

文本分类语料库:

人工标注了所所属分类的文章构成的语料库

语料库建设,即构建语料库的过程,规范制定 -- 人员培训--人工标注

1.6 开源工具:

  1. python接口:https://github.com/hankcs/pyhanlp
  2. java接口:https://github.com/hankcs/HanLP

第二章,词典分词

中文分词:即将一段文本拆分为一系列单词的过程。中文分词分为两类:

  1. 基于词典规则
  2. 基于机器学习

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)复杂度为  O(logn) ,n为词典的大小,如果用散列表的话(HashMap,dict),即空间还时间的方法。

字符串集合常用字典树(trie树,前缀树)存储

字典树即将词语视为root到某一节点的一条路径,并在词尾点上做标记。如果能走到特殊标记的节点,说明在该集合中。否则不存在。

不光是集合,字典树也可以实现映射,需要将相应的值悬挂在键的终点节点。当词典的大小为n时,最坏情况下的任然是O(logn)

实际速度比二分法要快,前缀匹配是递进的过程,算法不必比较支符串的前缀。

字典数的节点实现

我们约定用值为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个,远远小于2^{64},这就导致两个字符相邻,散列值差很多。Java的散列相对友好一些,Java使用UTF-16,每个字符可以映射为16位不重复的整数。所有散列函数输出区间为 [0,65535] 内的正整数,

具体做法是,创建一个65535的数组,将子节点按对应的哈希值索引存放到内存中,这样每次状态转移,都是直接寻址。

但是这种做法内存指数膨胀,当词典中词语长度为l时,则字段数

猜你喜欢

转载自blog.csdn.net/sanhewuyang/article/details/105205829