相对于英文聚类,Mahout进行中文聚类主要注意的就是数据的编码方式和分词器的选择问题。
一、数据准备
这里使用复旦大学中文语料(http://www.nlp.org.cn/docs/doclist.php?cat_id=16&type=15)(PS:这个文本集好像下不到了,推荐另一个语料http://ishare.iask.sina.com.cn/f/22774613.html,2805篇中文文本)我下载的是文本分类语料库(训练),里面一共包含9804篇文档。刚开始在Linux环境下折腾半天,结果总是乱码,结果发现是编码方式的问题。可以使用iconv命令来将GB2312的文件的编码方式转换为UTF-8,脚本如下:
for dir in `ls train`; do for file in `ls train/$dir`; do iconv -f GB2312 -t UTF8 train/$dir/$file > train/$dir/utf8-$file rm train/$dir/$file done done
把脚本放在train目录的同级目录,运行即可。
二、分词器修改
命令行可以使用mahout seq2sparse命令从文本生成向量信息,或者直接在源码中调用SparseVectorsFromSequenceFiles.main(String[] args)方法,传入相应的参数即可。可以通过-a选项指定使用的分词器。源码(0.5)中加载分词器的代码如下:
Class<? extends Analyzer> analyzerClass = DefaultAnalyzer.class; if (cmdLine.hasOption(analyzerNameOpt)) { String className = cmdLine.getValue(analyzerNameOpt).toString(); analyzerClass = Class.forName(className).asSubclass(Analyzer.class); // try instantiating it, b/c there isn't any point in setting it if // you can't instantiate it analyzerClass.newInstance(); }
代码里我们能够得到两个信息,一个就是要求使用的分词器是要继承自Lucene的Analyzer类的;还有调用newInstance()会调用指定分词器的缺省构造函数,有时你指定的分词器没有缺省的构造函数,就会报错。
这里我想使用Lucene的SmartChineseAnalyzer中文分词器,它是继承Analyzer的,但没有缺省的构造函数,若我们直接用-a指定该分词器,则会报Exception in thread "main" java.lang.InstantiationException: org.apache.lucene.analysis.standard.StandardAnalyzer的错误。
一种解决办法就是修改SmartChineseAnalyzer的源代码,增加空的构造函数,重新编译jar包。这里我想使用类似上面DefaultAnalyzer的办法。看一下它的源码
public final class DefaultAnalyzer extends Analyzer { private StandardAnalyzer stdAnalyzer = new StandardAnalyzer(Version.LUCENE_31); public DefaultAnalyzer() { super(); } @Override public TokenStream tokenStream(String fieldName, Reader reader) { return stdAnalyzer.tokenStream(fieldName, reader); } }
只有仿照这个DefaultAnalyzer写一个ChineseAnalyzer,可以根据我们指定的-a参数来选择不同的分词器。
三、SmartChineseAnalyzer停词文件的加载
有很多没用实质的有效信息的词,比如“的”、“你”、“他们”之类,称之为停用词,一般可以建一个停用词表,在生成向量的过程中,会把这些词剔除。
如果使用的是SmartChineseAnalyzer(Version matchVersion)这个构造函数的话,它是会加载一个默认的停词文件的,通过看它的源码,停词文件应当命名为stopwords.txt,然后在在工程bin目录下键一个org/apache/lucene/analysis/cn/smart/目录,把停词文件放进去即可(后来发现使用eclipse重新编译后会清空原来的bin目录)。
当然也可以使用SmartChineseAnalyzer(Version matchVersion, Set stopWords)构造函数,把停用词集合直接传进去。
四、使用KMeans聚类
好,下面就可以进行聚类了,这里先说一下生成向量时minSupport参数设置的是12,maxDFPercent参数设置为50,即在50%的文档中都出现的词将被剔除。
KMeans的运行参数为:聚类数目,20;最大迭代次数,10;聚类度量方式,CosineDistanceMeasure;收敛阈值,0.05。OK了。
下面是聚出来的比较好的几个类之一,可以看出其中主要讲的是经济相关的内容,类中共有924篇文档,从下面的构成统计中可以看出,原来C34-Economy中的843篇文档被聚到这个类中,说明聚类结果还是相当好的。
topTrems: [经济, 企业, 市场, 投资, 增长, 资本, 产业, 金融, 政府, 政策, 消费, 社会, 产品, 生产, 需求, 银行, 货币, 国际, 我国, 竞争] num of docs: 924 /C38-Politics 7 /C19-Computer 16 /C16-Electronics 4 /C39-Sports 14 /C34-Economy 843 /C3-Art 1 /C29-Transport 1 /C7-History 3 /C32-Agriculture 15 /C31-Enviornment 5 /C17-Communication 1 /C11-Space 14
当然也有聚类效果不好的类,下面就是一个。可以看到里面包含的文章大多为政治(94篇)、艺术(63篇)和经济(44篇)类的,这几类话题本质上就有些接近,所以被聚到一起也属于正常。
聚类结果统计和原始语料的类别统计如下:
从上表中可以看出最后聚类的结果显示那些包含文档数量小的类基本识别不出来,文档较多(航空、计算机、环境、农业、经济、政治、运动、艺术、历史)的则被聚到好几个类中。
对另一个语料2805篇文本的聚类统计结果如下:
topTrems: [日月, 站, 系统, 使用, 软件, cn, 阅读, e, 文章, fudan, 来源, bb, 返回, 程序, 页, 用户, s, 标题, 种, 讨论]
num of docs: 472
/computer200 182 38.559322033898304%
/economic325 8
/military249 67
/medicine204 128 27.11864406779661%
/traffic214 1
/sports450 2
/environment200 84
topTrems: [教育, 学校, 学生, 教师, 教学, 培养, 素质, 办学, 学习, 社会, 校, 中学, 工作, 小学, 思想, 提高, 改革, 人才, 课程, 大学]
num of docs: 202
/art248 1
/economic325 1
/military249 3
/medicine204 1
/traffic214 1
/sports450 1
/education220 194 96.03960396039604%
topTrems: [队, 比赛, 选手, 冠军, 名, 赛, 中国队, 女子, 金牌, 分, 亚运会, 男子, 参加, 成绩, 锦标赛, 胜, 队员, 决赛, 运动员, 米]
num of docs: 402
/art248 1
/sports450 400 99.50248756218906%
/education220 1
topTrems: [国, 访问, 主席, 尔, 总统, 关系, 友好, 德, 会见, 巴, 合作, 会谈, 问题, 拉, 政府, 举行, 表示, 外长, 总理, 副]
num of docs: 469
/computer200 1
/art248 8
/economic325 4
/military249 38
/medicine204 6
/traffic214 1
/sports450 9
/political505 399 85.07462686567165%
/environment200 2
/education220 1
topTrems: [美国, 以色列, 军事, 美军, 巴勒斯坦, 伊拉克, 阿拉伯, 会议, 战争, 政府, 报道, 部队, 举行, 名, 武装, 军队, 地区, 军, 武器, 问题]
num of docs: 248
/computer200 1
/economic325 14
/military249 123 49.596774193548384%
/medicine204 6
/political505 94 37.903225806451616%
/environment200 9
/education220 1
topTrems: [投资, 台湾, 两岸, 大陆, 企业, 航运, 港, 万, 外商, 海峡, 亿, 香港, 贸易, 批准, 考察团, 经贸, 厦门, 出口, 公司, 经济]
num of docs: 76
/computer200 1
/art248 1
/economic325 41 53.94736842105263%
/medicine204 5
/traffic214 16 21.05263157894737%
/sports450 2
/political505 5
/environment200 4
/education220 1
topTrems: [环境, 污染, 保护, 工作, 环保, 我国, 生态, 建设, 管理, 经济, 问题, 治理, 部门, 全国, 万, 防治, 研究, 城市, 社会, 提高]
num of docs: 201
/computer200 2
/art248 2
/economic325 14
/military249 14
/medicine204 45 22.388059701492537%
/traffic214 5
/sports450 8
/political505 4
/environment200 96 47.76119402985075%
/education220 11
topTrems: [艺术, 文化, 文艺, 创作, 演出, 作品, 群众, 活动, 举办, 观众, 音乐, 民族, 优秀, 节目, 奖, 届, 万, 表演, 生活, 舞蹈]
num of docs: 283
/art248 235 83.03886925795052%
/economic325 3
/military249 3
/medicine204 5
/sports450 25
/political505 1
/environment200 1
/education220 10
topTrems: [交通, 铁路, 公路, 运输, 车, 条, 车辆, 列车, 线, 公里, 旅客, 号, 管理, 客运, 部门, 汽车, 机动车, traffic, 安全, info]
num of docs: 190
/economic325 1
/traffic214 189 99.47368421052632%
topTrems: [经济, 增长, 企业, 市场, 亿, 产品, 百分之, 出口, 美元, 投资, 银行, 生产, 下降, econom, 美国, 工业, 金融, 政策, 十, 政府]
num of docs: 272
/computer200 13
/economic325 239 87.86764705882354%
/military249 1
/medicine204 8
/traffic214 1
/sports450 3
/political505 2
/environment200 5
F-measure is 0.7541411956990633
直观的看,聚类效果还是不错的,F-measure值也达到了0.75
五、我恨bug
最后汇总聚类结果时,发现只剩6800多篇文章了,尼玛老子给你9800篇啊,老子的结果统计白写了啊。于是我带着一腔愤怒Debug啊Debug,终于发现你了
public void write(String key, String value) throws IOException { if (currentChunkSize > maxChunkSizeInBytes) { writer.close(); writer = new SequenceFile.Writer(fs, conf, getPath(currentChunkID++), Text.class, Text.class); currentChunkSize = 0; } Text keyT = new Text(key); Text valueT = new Text(value); currentChunkSize += keyT.getBytes().length + valueT.getBytes().length; // Overhead writer.append(keyT, valueT); }
这是就是元凶,ChunkedWriter类中的一段代码,负责把文本以sequenceFile格式写回文件系统。问题就出在那个currentChunkID++上,它的初值是0有木有,你第一次写满一块放到chunk-0里,第二次getPath(currentChunkID++)取回来还是0好不好,第一次写的那块直接被覆盖了有木有。国外的程序员是不是不考i++与++i的区别……