看了《自然语言处理入门》那本书,里面大段的代码和公式把人吓到了,虽然都是码农,但现在隔领域如隔山,如果真是没有一点背景基础的人几乎看不懂到底要干什么,所以把先能理解的算法和规则用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")
}