从零开始实现中文分词器(2)

先回顾一下上一篇文章的内容:我们简单介绍了中文分词的原理,并且实现了一个前缀树,以及实现了加载词典的方法,还实现了给定一个句子输出里面收录于词典中的词语。

我们最终目标是实现一个分词器(并且最好能够实现歧义消除),现在距离我们的目标已经很近了。这篇文章会继续完善我们的分词器,真正实现基于词典的分词。

接下来会实现的功能:

  1. 将输入的待分词文本构建成一个DAG图。
  2. 使用动态规划的思想,基于DAG图计算出文本的最佳分词方式(上一篇文章说到过,最优分词方案就是使得句子出现频率最高)

在构建DAG图之前,需要这里需要新引入一个元素:

  1. 为了能够对不同分词情况进行对比,需要给每个词语增加一个权重属性 frequency (这样不同的句子就可以用所有词语权重之和来衡量句子的权重了,权重最高的句子也就出现概率最大)

前缀树加载词典的方法需要改成:

    /**
     * 加载字符
     */
    public void load(Queue wordQueue, int frequency) {
        if (wordQueue.isEmpty())
            return;
        // 弹出队列中第一个字符
        char c = wordQueue.poll();
        if (childrenMap == null)
            childrenMap = new HashMap<>();
        TrieNode node = childrenMap.computeIfAbsent(c, s -> new TrieNode(this, c));
        // 如果队列非空,继续递归加载剩余字符
        if (!wordQueue.isEmpty())
            node.load(wordQueue, frequency);
        else {
            // 队列为空了,说明当前节点是最后一个字符,刚好成一个词
            node.isWord = true;
            node.frequency = frequency;
        }
    }

对输入文本构建DAG图

首先是实现将输入文本转化成DAG图

  • 了解过jieba分词的实现的同学都知道,jieba实现的动态规划是从右往左开始迭代求解的。这是因为生成的DAG图是由词的首个字符指向最后一个字符。比如输入"抗日战争",刚好"抗日战争"是一个词,(不考虑其他词)生成的邻接矩阵就是: {0:[3], 1:[], 2:[], 3:[]}。这个DAG图正向迭代(从左到右)是无法求出最优解的,因为如果从左开始遍历,决定经过某个字符的最优路径不是看这个字符是哪些词的前缀,而是看他是哪些词的后缀。反向迭代则反过来,看到是组成前缀的情况。(可能描述的还不是很清楚, 最好自己实现一下)
  • 为了容易理解,下面实现反向DAG,然后正向迭代来进行求解
    /**
     * 这里需要构建反向的DAG,
     * 假设三个点的图1,2,3构建成DAG之后是:1 -> 2 -> 3
     * 原邻接矩阵应该是
     * 1 -> 2 -> null
     * 2 -> 3 -> null
     * 3 -> null
     * 

* 但此处要构建成 * 1 -> null * 2 -> 1 -> null * 3 -> 2 -> null *

* 这样做是为了后面寻找最近路径的时候能够根据词的最后一个字符迅速定位其对应的首个字符 */ public static List> buildDAG(TrieNode head, String str) { List> dag = new ArrayList<>(str.length()); for (int i = 0; i < str.length(); i++) { dag.add(i, new HashMap<>()); } // 词典为空直接返回空邻接矩阵 if (head == null || head.childrenMap == null) return dag; // 前缀遍历字符串 for (int i = 0; i < str.length() - 1; i++) { char c = str.charAt(i); TrieNode node = head.childrenMap.get(c); if (node == null) continue; TrieNode n = node; int offset = i; while (n != null) { if (n.isWord) { dag.get(offset).put(i, n.frequency); } if (n.childrenMap == null || offset == str.length() - 1) break; n = n.childrenMap.get(str.charAt(++offset)); } } return dag; }

基于DAG图求最优分词方案

构建出DAG图,之后就是使用DAG图来寻找最优路径了,可以通过动态规划法来求解。

先来阐述一下动态规划的思路:假设输入文本为“天下第一”,现在词典中有三个关联的词语“天下”,“第一”,“天下第一”,其对应词频分别是 1,1,3

首先生成反向DAG图:

// 位置0 对应天,3对应上
0: null 
1: 0 -> null
2: null
3: 0 -> 2 -> null

从最左开始遍历:(设w(i)是第i个位置上的最优路径权重,f(s)是词s的权重)

  1. ->"天":因为"天"是第一个字符,且词典中不存在"天"这个词,因此位置0的权重是0,即w(0)=0

  2. ->"下":(根据邻接矩阵)此时有两条路径可选:

    1. "天下":"与"天"组成词"天下",此时路径权重=w(0-1)+f("天下")=f("天下")=1 (这里的w(0-1)的0表示"天下"中首个字符"天"的位置,w(0-1)表示"天"前一个字符的路径权重,由于"天"是第一个字符,所以这里直接去掉了)
    2. "天/下":不与"天"组成词,此时路径权重=w(1-1)=w(0)=0

明显第一条路径权重更高,即w(1)=max(w(0), w(0-1)+f("天下")) = 1

  1. ->"第":此时因为与前面的字符不能组成词语,所以只能与前面字符分开一条路径,此时权重为:w(2)=w(2-1)=1

  2. ->"一":(根据邻接矩阵)此时有三条路径可选:

    1. "天下第一":w = w(0-1)+f("天下第一")=3
    2. "天下/第一":w = w(2-1)+f("第一")=1+1=2
    3. "天下/第/一":w = w(3-1)=w(2)=1

因此 w(3) = 3,

按照这种方法进行迭代求解,最终最后一个字符的最优权重路径其实就是整个输入文本的最优分词方案。

即整词"天下第一"就是输入文本"天下第一"的最优分词方案。

下面来实现一下相关求解代码

    /**
     * 使用动态规划求解
     * 状态转移方程: w(x) = max(w(x-1), w(k1-1) + f(k1), ..., w(kn-1) + f(kn))
     * x是字符位置
     * w(x)表示位置x上的最优路径权重
     * k1~kn是以位置x上字符结尾的不同词
     */
    public static void findOptimalPath(String str, List> list) {
        int[] indexArr = new int[str.length()];
        int[] weightArr = new int[str.length()];

        indexArr[0] = 0;
        weightArr[0] = 0;
        for (int i = 1; i < str.length(); i++) {
            int index = i;
            int weight = weightArr[index - 1];
            Map m = list.get(i);
            for (Integer inx : m.keySet()) {
                int w = m.get(inx);
                if (inx != 0)
                    w += weightArr[inx - 1];
                if (w > weight) {
                    weight = w;
                    index = inx;
                }
            }
            indexArr[i] = index;
            weightArr[i] = weight;
        }
        // 到这一步就已经求出结果了,indexArr[str.length()-1]就是最终结果
        // 剩下的就是往回推导出整个分词路径
        
        // 往回推导并输出分词结果
        LinkedList l = new LinkedList<>();
        int offset = str.length() - 1;
        while (offset >= 0) {
            int start = indexArr[offset];
            l.addFirst(str.substring(start, offset + 1));
            offset = start - 1;
        }
        // 以/的形式表示分词
        for (String s : l) {
            System.out.print(s+"/");
        }
        System.out.println();
    }
  • findOptimalPath 方法追加到 buildDAG 方法末尾就可以构建完DAG图之后直接计算分词结果了。

回到我们的main方法:

TrieNode node = new TrieNode(null, ' ');
node.load(TrieNode.string2Queue("中华"), 10);
node.load(TrieNode.string2Queue("华人"), 8);
node.load(TrieNode.string2Queue("人民"), 15);
node.load(TrieNode.string2Queue("共和国"), 6);
node.load(TrieNode.string2Queue("中华人民"), 24);
node.load(TrieNode.string2Queue("中华人民共和国"), 30);
node.load(TrieNode.string2Queue("国歌"), 8);
node.load(TrieNode.string2Queue("共和"), 5);

TrieNode.buildDAG(node, "中华人民共和国万岁");

>>分词结果:
中华/人民/共和国/万/岁/
    
// 如果将"中华人民共和国"权重调整到50,分词结果将发生变化:
>>分词结果:
中华人民共和国/万/岁/

至此,我们已经实现了一个分词器粗糙的模型了:

  1. 加载词典树
  2. 输出文本中所有词语
  3. 对输入文本进行分词,且进行歧义消除(寻找最优分词路径)

本文仅作学习用途,如有错误,欢迎指出

参考

结巴分词

IK分词

中文分词原理理解+jieba分词详解(二)

<<数学之美>>

完整代码

import java.util.*;

/**
 * @Description
 * @auther edqi
 * @create 2020-05-21 23:33
 */

public class TrieNode {

    char value;
    Map childrenMap;
    TrieNode parent;
    int deep;
    boolean isWord = false;
    int frequency = 0;
    String word;


    public TrieNode(TrieNode parent, char value) {
        this.parent = parent;
        this.value = value;
        // 假定根节点不存储有意义的值,深度为0
        if (parent == null)
            deep = 0;
        else
            deep = parent.deep + 1;
    }

    @Override
    public String toString() {
        return "TrieNode{" + nodePath() + "}";
    }

    String nodePath() {
        if (word == null) {
            char[] w = new char[deep];
            TrieNode n = this;
            while (n != null && n.deep != 0) {
                w[n.deep - 1] = n.value;
                n = n.parent;
            }
            word = String.valueOf(w);
        }
        return word;
    }

    /**
     * 将字符串转化成字符队列的静态方法
     */
    public static Queue string2Queue(String str) {
        Queue queue = new LinkedList<>();
        for (char c : str.toCharArray()) {
            queue.add(c);
        }
        return queue;
    }

    /**
     * 加载字符
     */
    public void load(Queue wordQueue, int frequency) {
        if (wordQueue.isEmpty())
            return;
        // 弹出队列中第一个字符
        char c = wordQueue.poll();
        if (childrenMap == null)
            childrenMap = new HashMap<>();
        TrieNode node = childrenMap.computeIfAbsent(c, s -> new TrieNode(this, c));
        // 如果队列非空,继续递归加载剩余字符
        if (!wordQueue.isEmpty())
            node.load(wordQueue, frequency);
        else {
            // 队列为空了,说明当前节点是最后一个字符,刚好成一个词
            node.isWord = true;
            node.frequency = frequency;
        }
    }


    public static void match(TrieNode node, String word) {
        if (word == null || word.length() == 0)
            return;
        System.out.println(String.format("开始对\"%s\"进行匹配:", word));
        // 对输入字符串的所有子串均进行前缀匹配
        for (int i = 0; i < word.length(); i++)
            match(node, word, i);
    }

    private static void match(TrieNode node, String word, int index) {
        // 要考虑边界情况
        if (index >= word.length() || node.childrenMap == null)
            return;
        // 取出当前位置的字符进行匹配
        char c = word.charAt(index);
        TrieNode child = node.childrenMap.get(c);
        // 子节点存在对应字符才能往下遍历/判断
        if (child != null) {
            if (child.isWord) {
                char[] w = new char[child.deep];
                TrieNode n = child;
                while (n != null && n.deep != 0) {
                    w[n.deep - 1] = n.value;
                    n = n.parent;
                }
                // 当找到一个匹配的词语时直接打印
                System.out.println(String.valueOf(w));
            }
            match(child, word, index + 1);
        }
    }


    /**
     * 这里需要构建DAG的反向引用,
     * 1 -> 2 -> 3
     * 原邻接矩阵应该是
     * 1 -> 2 -> null
     * 2 -> 3 -> null
     * 3 -> null
     * 

* 但此处要构建成 * 1 -> null * 2 -> 1 -> null * 3 -> 2 -> null *

* 这是为了后面寻找最近路径的时候能够根据词的最后一个字符迅速定位其对应的首个字符 */ public static List> buildDAG(TrieNode head, String str) { List> dag = new ArrayList<>(str.length()); for (int i = 0; i < str.length(); i++) { dag.add(i, new HashMap<>()); } // 词典为空直接返回空邻接矩阵 if (head == null || head.childrenMap == null) return dag; // 前缀遍历字符串 for (int i = 0; i < str.length() - 1; i++) { char c = str.charAt(i); TrieNode node = head.childrenMap.get(c); if (node == null) continue; TrieNode n = node; int offset = i; while (n != null) { if (n.isWord) { dag.get(offset).put(i, n.frequency); } if (n.childrenMap == null || offset == str.length() - 1) break; n = n.childrenMap.get(str.charAt(++offset)); } } findOptimalPath(str, dag); return dag; } /** * 使用动态规划求解 * 状态转移方程: w(x) = max(w(x-1), w(k1-1) + f(k1), ..., w(kn-1) + f(kn)) * x是字符位置 * w(x)表示位置x上的最优路径权重 * k1~kn是以位置x上字符结尾的不同词 */ public static void findOptimalPath(String str, List> list) { int[] indexArr = new int[str.length()]; int[] weightArr = new int[str.length()]; indexArr[0] = 0; weightArr[0] = 0; for (int i = 1; i < str.length(); i++) { int index = i; int weight = weightArr[index - 1]; Map m = list.get(i); for (Integer inx : m.keySet()) { int w = m.get(inx); if (inx != 0) w += weightArr[inx - 1]; if (w > weight) { weight = w; index = inx; } } indexArr[i] = index; weightArr[i] = weight; } // 到这一步就已经求出结果了,indexArr[str.length()-1]就是最终结果 // 剩下的就是往回推导出整个分词路径 // 往回推导并输出分词结果 LinkedList l = new LinkedList<>(); int offset = str.length() - 1; while (offset >= 0) { int start = indexArr[offset]; l.addFirst(str.substring(start, offset + 1)); offset = start - 1; } // 以/的形式表示分词 for (String s : l) { System.out.print(s+"/"); } System.out.println(); } }

测试用例

public class Main {
    public static void main(String[] args) {
        // 初始化树根节点,置parent=null, value=' '
        TrieNode node = new TrieNode(null, ' ');
        node.load(TrieNode.string2Queue("中华"), 10);
        node.load(TrieNode.string2Queue("华人"), 8);
        node.load(TrieNode.string2Queue("人民"), 15);
        node.load(TrieNode.string2Queue("共和国"), 6);
        node.load(TrieNode.string2Queue("中华人民"), 24);
        node.load(TrieNode.string2Queue("中华人民共和国"), 50);
        node.load(TrieNode.string2Queue("国歌"), 8);
        node.load(TrieNode.string2Queue("共和"), 5);

        TrieNode.buildDAG(node, "中华人民共和国万岁");
    }
}

你可能感兴趣的:(从零开始实现中文分词器(2))