词典分词的初级算法

193 阅读4分钟

看了《自然语言处理入门》那本书,里面大段的代码和公式把人吓到了,虽然都是码农,但现在隔领域如隔山,如果真是没有一点背景基础的人几乎看不懂到底要干什么,所以把先能理解的算法和规则用Kotlin实现一遍,慢慢加深理解,这应该是循序渐进的方法。

在讲词典分词的时候,实际上书里没有给出一个清晰的前提背景,那就是用一个有限的微型词典配合不同的算法去验证分词的效果。结果书里上来就直接说那个“词在词典里有/没有”,我们怎么知道这个词典应当包含怎样的内容,它是怎么加载数据的,应该有还是没有呀?而看书的上下文,这个词典其实就是一个没有任何格式信息的短语列表,它的作用仅仅是为了表示已经预先划分好的汉字组合。而用计算机的数据结构表示许许多多的短语列表,当然可以用List来表示,如果需要快速查找,那就用Set。书中把现成代码直接展示,实际是用了一个后续优化了的自定义的类表示词典,实际上不符合人们从简单到复杂的认知规律。

所以,需要先写一个用于测试的mock数据。算法代码写在app/src/main/kotlin/demo/App.kt,测试用例写在app/src/test/kotlin/demo/AppTest.kt

正向最长匹配

在书中给出的算法中,还判断了一下已有最长单词与当前单词的长度,这一步其实是多余的,因为当前单词一直是顺序增长的,一旦判断当前单词在词典中,一定是比已有单词longest还长的。

fun forwardLongest(text: String, dict: Iterable<String>): List<String> {
    var i = 0
    val len = text.length
    val result = mutableListOf<String>()
    while (i < len) {
        var longest = text.substring(i, i + 1)
        var j = i + 2
        while (j <= len) {
            val word = text.substring(i, j)
            if (dict.contains(word)) {
                longest = word
            }
            j++
        }
        result.add(longest)
        i += longest.length
    }
    return result
}

逆向最长匹配

逆向几乎没有什么难度,只是需要注意一下边界条件,最外层的循环下标不需要到0

fun backwardLongest(text: String, dict: Iterable<String>): List<String> {
    val len = text.length
    var i = len
    val result = mutableListOf<String>()
    while (i > 0) {
        var longest = text.substring(i - 1, i)
        var j = i - 2
        while (j >= 0) {
            val word = text.substring(j, i)
            if (dict.contains(word)) {
                longest = word
            }
            j--
        }
        result.add(longest)
        i -= longest.length
    }
    return result.apply {
        reverse()
    }
}

双向最长匹配

因为需要统计结果中单字个数,所以写一个私有的扩展方法singleCount达到目的,kotlin的代码和python几乎一样简洁:

private fun Iterable<String>.singleCount(): Int {
    var sum = 0
    for (e in this) {
        if (e.length == 1) {
            sum++
        }
    }
    return sum

}

fun biDirection(text: String, dict: Iterable<String>): List<String> {
    val forward = forwardLongest(text, dict)
    val backward = backwardLongest(text, dict)
    return when {
        forward.size < backward.size -> forward
        forward.size > backward.size -> backward
        forward.singleCount() < backward.singleCount() -> forward
        else -> backward
    }
}

验证结果

写测试用例。不知道为什么,kotlin的test框架里assertEquals带的message参数不好使,实际不会输出错误信息,只好自己写个testExpect

接着,最重要的其实是这个微型词典,包含可能的短语列表,否则切分的方式就是不知所云的。在AppTest.kt中加入:

    companion object {
        private fun <T> testExpect(e: T, a: T) {
//            assertEquals(e, a, "Error: expect $e, but actually is $a")
            if (e != a) {
                println("Error: expect <$e>, but actually is <$a>")
                assert(false)
            }
        }

        private val dictionary = setOf(
            "就读",
            "北京",
            "大学",
            "北京大学",

            "研究",
            "研究生",
            "生命",
            "起源",
            "项目",
            "目的",

            "下雨",
            "天地",
            "面积",
            "当下",
            "雨天",
            "地面",
            "积水",
            "下雨天",

            "结婚",
            "和尚",
            "尚未",

            "欢迎",
            "迎新",
            "老师",
            "师生",
            "前来",
            "就餐",
        )
    }

    @Test
    fun testForwardLongest() {
        testExpect(listOf("就读", "北京大学"),
            forwardLongest("就读北京大学", dictionary))
        testExpect(listOf("研究生", "命", "起源"),
            forwardLongest("研究生命起源", dictionary))
    }

    @Test
    fun testBackwardLongest() {
        testExpect(listOf("就读", "北京大学"),
            backwardLongest("就读北京大学", dictionary))
        testExpect(listOf("研究", "生命", "起源"),
            backwardLongest("研究生命起源", dictionary))
        testExpect(listOf("项", "目的", "研究"),
            backwardLongest("项目的研究", dictionary))
        testExpect(listOf("当", "下雨天", "地面", "积水"),
            backwardLongest("当下雨天地面积水", dictionary))
        testExpect(listOf("结婚", "的", "和", "尚未", "结婚", "的"),
            backwardLongest("结婚的和尚未结婚的", dictionary))
        testExpect(listOf("欢", "迎新", "老", "师生", "前来", "就餐"),
            backwardLongest("欢迎新老师生前来就餐", dictionary))
    }
    
    @Test
    fun testBiDirection() {
        testExpect(listOf("欢", "迎新", "老", "师生", "前来", "就餐"),
            biDirection("欢迎新老师生前来就餐", dictionary))
    }

字典树

参照书的代码实现一个树节点很简单,树的节点因为已经有了add方法,成员children应当设置为私有,不应当被外部修改,同时因为需要节点通过单个字返回子节点,增加一个get方法,operator关键字可以重载[]操作符。

书中的树结构是直接继承了树节点,这种把树认为是一种节点的想法容易导致混乱,不是一种好的设计方法。应当更多的使用组合,因此树结构直接持有一个根节点。

class TrieNode(val value: String?, val edge: Char = Char.MIN_VALUE) {
    private val children = mutableMapOf<Char, TrieNode>()

    fun add(char: Char, v: String?, overwrite: Boolean = false): TrieNode {
        return children[char]?.let {
            if (overwrite) {
                children[char] = TrieNode(v, char)
            }
            it
        } ?: TrieNode(v, char).also {
            children[char] = it
        }
    }

    operator fun get(c: Char): TrieNode? = children[c]
}

class TrieTree {
    private val root = TrieNode(null)

    operator fun contains(key: String): Boolean = get(key) != null

    operator fun get(key: String): String? {
        var node = root
        for (c in key) {
            node = node[c] ?: return null
        }
        return node.value
    }

    operator fun set(key: String, value: String?) {
        var node = root
        key.forEachIndexed { i, c ->
            val end = i == key.length - 1
            node = if (end) {
                node.add(c, value, true)
            } else {
                node.add(c, null, false)
            }
        }
    }
}

用例直接用书上的即可验证:

@Test
fun testTrieTree() {
    val trie = TrieTree()
    trie["自然"] = "nature"
    trie["自然人"] = "human"
    trie["自然语言"] = "language"
    trie["自语"] = "talk to oneself"
    trie["入门"] = "introduction"

    testExpect(true, "自然" in trie)
    trie["自然"] = null
    testExpect(false, "自然" in trie)

    trie["自然语言"] = "human language"
    testExpect("human language", trie["自然语言"])

    testExpect(true, trie["入门"] == "introduction")
}