重写大学毕设中文分词器

重写大学毕设中文分词器

前言

大家好,香蕉君好久没有更新blog了。大家有没有好好学习T_T!最近我在搞elasticsearch做搜索,话说这个es真是一个不错的东西。搞的我脑壳都大咯。哈哈。虽然如此但是还是学到一些骚气的新姿势。就好像闭关修炼了一段时间一般,此时身上的内力无处发泄,怎么办?重写一个项目吧!于是我准备使用java重写一遍我大学的毕设。没有错!我大学毕设是实现一个通过“生语料库”学习进行分词的分词器。

生语料库是指收集之后未加工的语料库 相对而言,熟语料库就是经过加工的语料库。比如:我爱北京天安门。就是一段生的语料。而:我\爱\北京\天安门。就是熟的。

原来我是使用c/c++实现的。如果你感兴趣我可以给传送门。还有不要指望它可以跑起来,因为这个我大约二分之一时间时提交的代码,和我后来交作品的时候完全不一样。哈哈,之所以拿出来,只不过让大家看看我以前的黑历史,还有什么都敢往git上传的精神。好吧,久等了我们进入正文吧!HERE WE GO!

使用技术和特性

  • 使用java中泛型,还有多态的一些玩法。
  • 使用枚举实现的状态机
  • 使用buffer,主要是java内生实现的快速的io读写。

嗯就这么多了。感觉没有什么了不起对不对?当然如果你要百度可以找到很多有关的信息。可是在以上这些知识都是我新学的。可能你觉得夸张我来给你讲一下效果。我之前的C的代码写的和屎一样。但是我当年的我却以为自己算是我们年级编程能力比较强的那几个了。那时我读并且完成学习任务十万字的生语料库(我映像中10几个M吧),第一个版本花了我一天一宿结果还没有读完,我写的第二个版本30分钟多。于是演示的时候只能给老师们看学习后结果。而现在学习加分析只需10秒内。注意是10秒内。我的天啊,不好奇吗?来看看我用了什么黑魔法吧。

别急!看看我们要做什么!

说了这么多还是吹逼的话,你可能会说还是云里雾里的。这个分词器怎么实现呢?放心我再也不会给出什么高大上的数学公式了。也不涉及什么高深的算法。其实就两个工作。

  • 读文本记录下来不重复的出现了什么字。然后给每个字一个唯一的编号。
  • 看看字出现的顺序。然后填写一个记录字间信息的矩阵就好了。

这个矩阵怎么表示呢?其实也很简单。比如 我是黄油香蕉句这句话。的编号是1,的编号是11。那么第一行第十一列就在原来的数值上加个1。(初始的时候这个矩阵都是0)

获取你要问为什么要用这个矩阵呢?答案很简单。我们仅仅看一行的值。比如后面可能出现的子是什么呢?等。但是一些乱七八糟的字就很少出现。于是我们更加倾向于认为我们我是这样出现概率高的是一个词,而出现很少的就不认为是一个词了。

想想!可能遇到的困难?

  1. 读写速度
  2. 识别中文和标点和英文
  3. 表示我们刚才说的那个矩阵

来我们一个一个解决。(实际上我也是这么做的,先列出可能的困难在一个个攻破)

读写文件问题

Java是自己带着文件输入输出流的。但是用书上写的readLine慢的可以。我保证如果你用这种方法读写文件你可以瞬间恨上这种语言。

思考一下一般读写文件最快的方法是一次性的读到内存中再进行操作,所以说一般都有个什么临时缓存的东西。于是我们在百度中就找到了。ByteBuffer这个类。这个类可以将缓存到内存中进行快速的读写。(当然也是使用MappedByteBuffer 来实现内存映射)

如果你有兴趣可看看下面这测试代码。速度快到你不敢相信。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package com.zzh.com.test;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
/**
* Created by zhouzihao on 2018/6/11.
*/
public class TestBuffer {
//下面放上你的测试文件的地址
private static final String file = "/Users/zhouzihao/mytmp/statetest/testfile.txt";
public static void readByChannel(int allocate) throws IOException {
long start = System.currentTimeMillis();
RandomAccessFile fis = new RandomAccessFile(new File(file), "rw");
FileChannel channel = fis.getChannel();
long size = channel.size();
// 构建一个只读的MappedByteBuffer
MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, size);
// 如果文件不大,可以选择一次性读取到数组
// byte[] all = new byte[(int)size];
// mappedByteBuffer.get(all, 0, (int)size);
// 打印文件内容
// System.out.println(new String(all));
// 如果文件内容很大,可以循环读取,计算应该读取多少次
byte[] bytes = new byte[allocate];
long cycle = size / allocate;
int mode = (int)(size % allocate);
//byte[] eachBytes = new byte[allocate];
for (int i = 0; i < cycle; i++) {
// 每次读取allocate个字节
mappedByteBuffer.get(bytes);
// 打印文件内容,关闭打印速度会很快
// System.out.print(new String(eachBytes));
System.out.print(new String(bytes));
}
if(mode > 0) {
bytes = new byte[mode];
mappedByteBuffer.get(bytes);
System.out.print(new String(bytes));
// 打印文件内容,关闭打印速度会很快
// System.out.print(new String(eachBytes));
}
// 关闭通道和文件流
channel.close();
fis.close();
long end = System.currentTimeMillis();
System.out.println(String.format("\n===>文件大小:%s 字节", size));
System.out.println(String.format("===>读取并打印文件耗时:%s毫秒", end - start));
}
}

识别中文和标点和英文

首先我们不能假设文件中没有英文和标点符号,于是乎我们要有个方法来很好的区分中英文。其中有一种方法是吧中文英文转成ASCII码之后在用数值进行判断。我记得我之前的C语言版本就是如此但是并不好。在java中有许多的已设置的常量,如果可以用起来也是很优雅的。于是我们有了下面的代码。(我放在了Until.class中做静态方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.zzh.com.test;
/**
* Created by zhouzihao on 2018/6/11.
*/
public class Until {
public static boolean isChinese(char c) {
Character.UnicodeBlock ub = Character.UnicodeBlock.of(c);
if (ub == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|| ub == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS
|| ub == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
|| ub == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B
|| ub == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION
|| ub == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS
|| ub == Character.UnicodeBlock.GENERAL_PUNCTUATION) {
return true;
}
return false;
}
}

好的,我们知道了怎么判断中英文,但是还是不够的。我想写个什么for或者while循环然后在里面写上一堆令人头疼的if-else了。因为那种代码的可读性和性能都是一个噩梦。但是不写这些要怎么做呢?我们回顾一下程序操作的流程。读然后判断如果中文就进行中文的处理,如果标点就进行标点的处理,如果是英文就进行英文的处理。而且还要记录出上一个处理的状态。怎么样?想到了什么?使用状态机不可以吗?

Java正好可以用枚举类型实现一个状态机。读者不妨先看看传送门。怎么样是不是很神奇,更神奇的是使用这种方法的速度和效率也不低。于是我们要定义一个给状态机状态枚举使用的接口。然后还有我们要保证的重要状态的上下文。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.zzh.com.test;
import java.nio.ByteBuffer;
import java.util.List;
import java.util.Map;
/**
* 上下文
* Created by zhouzihao on 2018/6/8.
*/
public interface Context {
/**
* get the buffer of file
* @return
*/
ByteBuffer buffer();
/**
* get current State
* @return
*/
State state();
/**
* set current State
* @return
*/
void state(State state);
/**
* get the Index of Character
* @return
*/
Map<Character,Long> index();
State preState();
void preState(State state);
List<Character> indexList();
void addIndex(Character character);
Matrix matrix();
}

其中Matrix 先定义在这里我们一会再说。

1
2
3
4
5
6
7
8
9
package com.zzh.com.test;
/**
* Created by zhouzihao on 2018/6/8.
*/
public interface State {
boolean process(Context context);
}

关于这个上下问的实现。正好用到了我们刚才说的ByteBuffer。可以先看一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
package com.zzh.com.test;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
* Created by zhouzihao on 2018/6/8.
*/
public class ContextImpl implements Context {
private State state;
private State preState;
private ByteBuffer buffer;
private Map<Character,Long> indexMap = new ConcurrentHashMap<>();
private List<Character> list = new ArrayList<>();
private Matrix matrix = new ElasticMatrix();
public ContextImpl(List<Character> list, Matrix matrix) {
if (Objects.nonNull(list))
this.list = list;
if (!Objects.nonNull(matrix))
this.matrix = matrix;
}
@Override
public Map<Character, Long> index() {
return this.indexMap;
}
public void buffer(String file) throws IOException{
RandomAccessFile fis = new RandomAccessFile(new File(file), "rw");
FileChannel channel = fis.getChannel();
long size = channel.size();
buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, size);
}
@Override
public ByteBuffer buffer() {
return buffer;
}
@Override
public State state() {
return state;
}
@Override
public void state(State state) {
this.state = state;
}
@Override
public State preState() {
return preState;
}
@Override
public void preState(State state) {
preState = state;
}
@Override
public List<Character> indexList() {
return list;
}
@Override
public void addIndex(Character character) {
list.add(character);
}
@Override
public Matrix matrix() {
return matrix;
}
}

依旧是看不懂的部分可以先忽略。而关于状态机真正的实现最后在讲。现在我们知道了。我用要做的是搭好了使用状态机转换的架子。之后的状态包括流转规则一切都由我们自己说的算。但是关于这个枚举的实现我将最后给出。下面先看一个简单的。

二维的稀松矩阵的表示

我们先要搞清楚我们的矩阵要什么方法。当然一个任何一个位置的值可以取到,可以修改,为了方便可以加一。于是乎接口就定义好了。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.zzh.com.test;
import java.io.Serializable;
/**
* 管理矩阵的类 2元组
* Created by zhouzihao on 2018/6/12.
*/
public interface Matrix extends Serializable{
/**
* 给固定位置添加一
* @param x
* @param y
*/
void inc(Integer x,Integer y);
/**
* 获取固定位置的值
* @param x
* @param y
* @return
*/
Long getValue(Integer x,Integer y);
/**
* 设置固定位置的值
* @param x
* @param y
*/
void setValue(Integer x,Integer y,Long value);
}

你会说这太简单了什么也没有实现。但是我相信你使用的接口虽然不能真正的运行但是可以假装已经实现先写代码了。这就是java使用接口去定义好处。这种语言自发性的让你去拆解了任务。现在你只要全身心的考虑怎么实现这样一个矩阵就可以了。用什么呢?作者愚钝,使用两层map吧。下面是代码真的简单不解释了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package com.zzh.com.test;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* 可伸缩二维 的 稀松矩阵
* Created by zhouzihao on 2018/6/13.
*/
public class ElasticMatrix implements Matrix{
private Map<Integer,Map<Integer,Long>> data = new HashMap<>();
@Override
public void inc(Integer x, Integer y) {
if(Objects.isNull(getValue(x,y))){
setValue(x,y,1L);
return;
}else {
setValue(x,y,getValue(x,y) + 1L);
}
}
@Override
public Long getValue(Integer x, Integer y) {
Map<Integer,Long> yy = data.get(x);
if (Objects.isNull(yy)){
return null;
}
if (Objects.isNull(yy.get(y))){
return null;
}
return yy.get(y);
}
@Override
public void setValue(Integer x, Integer y, Long value) {
Map<Integer,Long> yy = data.get(x);
if (Objects.isNull(yy)){
yy = new HashMap<Integer,Long>();
yy.put(y,value);
data.put(x,yy);
return;
}
data.get(x).put(y,value);
}
}

当然如果你愿意的话可以将上面的改成使用泛型。不过我这是一个特定问题,还没有抽象到这样一个层次的必要。

实现状态机

那么怎么实现这个状态机核心部分呢?

这里不愿意讲的太细。因为真的简单只有4个状态开始,中文字符,英文包括英文标点,结束而已。有了上面的准备工作我们现在仅仅要在一个状态中判断字符形态在移到下一个状态即可了。少废话看代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
package com.zzh.com.test;
import static java.lang.Character.UnicodeBlock.*;
/**
* Created by zhouzihao on 2018/6/8.
*/
public enum States implements State {
START{
@Override
public boolean process(Context context) {
/**
* 判断中文英文还是结束
*/
if (context.buffer().position() == context.buffer().capacity()){
context.state(END);
return true;
}
context.buffer().mark();//标记
if(context.buffer().capacity()-context.buffer().position()>=3){
byte[] test = new byte[3];
context.buffer().get(test);
char[] list = new String(test).toCharArray();
if(Until.isChinese(list[0])) {
context.state(CN);
}else {
context.state(EN);
}
}else {
context.state(EN);
}
return true;
}
},
CN{
@Override
public boolean process(Context context) {
context.buffer().reset();//恢复
byte[] test = new byte[3];//中文要读3个字符
context.buffer().get(test);
char[] list = new String(test).toCharArray();
indexOf(context,list[0]);
// 计算字间互信息矩阵
if (context.preState() == CN){
matrix(context,preChar,list[0]);
}
context.state(START);
context.preState(CN);
preChar = list[0];
return true;
}
},
EN{
@Override
public boolean process(Context context) {
context.buffer().reset();//恢复
//处理英文
byte[] test = new byte[1];//英文只要读1个字符
context.buffer().get(test);
char[] list = new String(test).toCharArray();
//FIXME 这里不处理英文单词
context.state(START);
context.preState(EN);
return true;
}
},
END{
@Override
public boolean process(Context context) {
System.out.println("end");
return false;
}
};
public void indexOf(Context context,char i){
//这里不能存中文的标点符号
Character.UnicodeBlock ub = Character.UnicodeBlock.of(i);
if (ub == CJK_SYMBOLS_AND_PUNCTUATION
|| ub == CJK_COMPATIBILITY_IDEOGRAPHS
|| ub == CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
|| ub == HALFWIDTH_AND_FULLWIDTH_FORMS
|| ub == CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B){
return;
}
Long hit = context.index().get(i);
if (hit ==null){
context.index().put(i,0L);
if (context.indexList().indexOf(i)<0)
context.addIndex(i);
}else {
context.index().put(i,hit+1);
}
}
public static char preChar;
//todo 记录一个词后出现另外一个词
// 这里使用的汉字个数是 3500到4000那么使用
public void matrix(Context context,char pre,char next){
//index list
Integer x = context.indexList().indexOf(pre);
Integer y = context.indexList().indexOf(next);
//这里跳过没有收录的字段
if(x<0 || y<0){
return;
}
//决定了使用链表吧!反正是稀松矩阵嘞
context.matrix().inc(x,y);
}
}

怎么样有点懵?关于全部的代码可以看这里传送门.

后记

现在只实现了学习到文件的过程还是没有实现分析的过程。为什么不写了呢?因为再写也没有比我以前更优秀的内容了。就是没有写的必要了。这两年来,在语言的使用上有了进步会使用写一些骚操作,可以更快更加高效的完成代码。可是对于分词的方法上的认知上没有一点点的进步!在写完这篇文章之时,笔者感到了深深的焦虑。

我将一直迷惑和无知,我是黄油香蕉君,再见。

给作者买杯咖啡吧。喵~