本节书摘来自华章计算机《大数据架构和算法实现之路:电商系统的技术实战》一书中的第1章,第1.6节,作者 黄 申,更多章节内容可以访问云栖社区“华章计算机”公众号查看。
1.6 案例实践
1.6.1 实验环境设置
帮助读者熟悉理论知识并不是本书的最终目的。为了展示分类任务的常规实现,我们会实践一个假想的案例,让机器对18类共28 000多件商品进行自动分类。下面是商品数据的片段:
ID Title CategoryID CategoryName
1 雀巢 脆脆鲨 威化巧克力(巧克力味夹心)20g*24/盒 1 饼干
2 奥利奥 原味夹心饼干 390g/袋 1 饼干
3 嘉顿 香葱薄饼 225g/盒 1 饼干
4 Aji 苏打饼干 酵母减盐味 472.5g/袋 1 饼干
5 趣多多 曲奇饼干 经典巧克力原味 285g/袋 1 饼干
6 趣多多 曲奇饼干 经典巧克力原味 285g/袋 X 2 1 饼干
7 Aji 尼西亚惊奇脆片饼干 起士味 200g/袋 1 饼干
8 格力高 百醇 抹茶慕斯+提拉米苏+芝士蛋糕味48g*3盒 1 饼干
9 奥利奥 巧克力味夹心饼干 390g/袋 1 饼干
10 趣多多 巧克力味曲奇饼干 香脆米粒味 85g/袋 1 饼干
11 趣多多 巧克力味曲奇饼干 香脆米粒味 85g/袋 X 5 1 饼干
12 …
可以看到,每条记录有4个字段,包括商品的ID(ID)、商品的标题(Title)、分类的ID(CategoryID)和分类的名称(CategoryName)等。完整的数据集合位于:
本书所有案例中的测试数据,包括以上的商品数据都是虚构的,仅供教学和实验使用。请不要将其内容或产生的结论用于任何生产环境。
针对这些数据,我们将分别使用R包和Mahout对其进行分类处理。此外,由于测试数据包含中文标题,因此还需要中文分词软件对其进行处理。相关的编码将采用Java语言(JDK 1.8),以及Eclipse的IDE环境(Neon.1a Release (4.6.1))来实现。
目前运行R、Mahout、中文分词及相关代码的硬件是一台2015款的iMac一体机,在后文中我们将为它冠以iMac2015的代号,其CPU为Intel Core i7 4.0GHz,内存为16GB,其具体配置如图1-5所示。
下面将展示并分析每个关键的步骤,直至机器可以对商品合理分类。
1.6.2 中文分词
在对文本进行分类测试之前,首先要将文本转换成机器能够理解的数据来表示。对于这个步骤,一种常见的方法是词包(Bag of Word),即将文本按照单词进行划分,并建立字典。每个唯一的单词则是组成字典的词条,同时也成为特征向量中的一维。最终文本就被转换成为拥有多个维度的向量。对于英文等拉丁语,单词的划分是非常直观的,空格和标点符号就可以满足大多数的需求。然而,对于中文而言却要困难得多。中文只有字、句和段能够通过明显的分界符来简单划界,词与词之间没有一个形式上的分界符。为此,中文分词的研究应运而生,其目的就是将一个汉字序列切分成一个个单独的词。目前有不少开源的中文分词软件可供使用,这里使用知名的IKAnalyzer,你可以通过如下链接下载其源码和相关的配置文件:
http://www.oschina.net/p/ikanalyzer/
Eclipse Neon.1a Release (4.6.1)的版本在默认的情况下,自带了Maven的插件,我们可以建立一个Maven项目并导入IKAnalyzer的源码。图1-6展示了Maven项目的建立。
项目中的pom.xml内容配置如下:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/
2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/
xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>ChineseSegmentation</groupId>
<artifactId>IKAnalyzer</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>IKAnalyzer</name>
<url>http://maven.apache.org</url>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- https://mvnrepository.com/artifact/org.apache.lucene/
lucene-analyzers-common -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers-common</artifactId>
<version>4.0.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.lucene/
lucene-queryparser -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queryparser</artifactId>
<version>4.0.0</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>3.8.1</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
其中加入了lucene-analyzers-common和lucene-queryparser的依赖。这样IKAnalyzer的源码就可以编译成功。你可以从下面的链接访问已建成的IKAnalyzer Maven项目:
在org.wltea.analyzer.sample.IKAnalzyerDemo的基础上,我们编写了org.wltea.analyzer.sample.IKAnalzyerForListing,其主要的函数如下:
public static void processListing(String inputFileName, String outputFileName) {
try {
br = new BufferedReader(new FileReader(inputFileName));
pw = new PrintWriter(new FileWriter(outputFileName));
String strLine = br.readLine(); // 跳过header这行
pw.println(strLine);
while ((strLine = br.readLine()) != null) {
// 获取每个字段
String[] tokens = strLine.split("\t");
String id = tokens[0];
String title = tokens[1];
String cateId = tokens[2];
String cateName = tokens[3];
// 对原有商品标题进行中文分词
ts = analyzer.tokenStream("myfield", new StringReader(title));
// 获取词元位置属性
OffsetAttribute offset = ts.addAttribute(OffsetAttribute.class);
// 获取词元文本属性
CharTermAttribute term = ts.addAttribute(CharTermAttribute.class);
// 获取词元文本属性
TypeAttribute type = ts.addAttribute(TypeAttribute.class);
// 重置TokenStream(重置StringReader)
ts.reset();
// 迭代获取分词结果
StringBuffer sbSegmentedTitle = new StringBuffer();
while (ts.incrementToken()) {
sbSegmentedTitle.append(term.toString()).append(" ");
}
// 重新写入分词后的商品标题
pw.println(String.format("%s\t%s\t%s\t%s", id, sbSegmentedTitle.
toString().trim(), cateId, cateName));
// 关闭TokenStream(关闭StringReader)
ts.end(); // Perform end-of-stream operations, e.g. set the final offset.
}
br.close();
br = null;
pw.close();
pw = null;
} catch (Exception e) {
e.printStackTrace();
} finally {
cleanup();
}
}
其功能在于打开原始的数据文件,读取每一行,取出商品的标题,采用IKAnalyzer对其标题进行分词,然后生成一个使用分词后标题的新数据文件。新数据文件的片段如下:
ID title CategoryID CategoryName
1 雀巢 脆 脆 鲨 威 化 巧克力 巧克力 味 夹心 20g 24 盒 ??????1 ??????饼干
2 奥 利 奥 原 味 夹心饼干 390g 袋 1 饼干
3 嘉 顿 香 葱 薄饼 225g 盒 1 饼干
4 aji 苏打饼干 酵母 减 盐味 472.5g 袋 1 饼干
5 趣 多多 曲奇 饼干 经典 巧克力 原 味 285g 袋 1 ??????饼干
6 趣 多多 曲奇 饼干 经典 巧克力 原 味 285g 袋 x 2 1 ??????饼干
7 aji 尼 西亚 惊奇 脆 片 饼干 起 士 味 200g 袋 1 ??????饼干
8 格力 高 百 醇 抹 茶 慕 斯 提 拉 米 苏 芝 士 蛋糕 味 48g 3盒 1 ??????饼干
9 奥 利 奥 巧克力 味 夹心饼干 390g 袋 1 饼干
10 趣 多多 巧克力 味 曲奇 饼干 香脆 米粒 味 85g 袋 1 ??????饼干
11 趣 多多 巧克力 味 曲奇 饼干 香脆 米粒 味 85g 袋 x 5 ??????1 ??????饼干
12 ...
可以看出每个标题都被进行了切分。当然,我们也发现中文分词软件也不一定100%准确。在上述的例子中,对于某些品牌的切分出现了错误。好在IKAnalyzer是支持自定义字典的,我们可以编辑class运行目录中的ext.dic,加入必要的品牌词,如图1-7所示。
再次运行分词,可以看到品牌被正确地切分出来了:
ID title CategoryID CategoryName
1 雀巢 脆 脆 鲨 威 化 巧克力 巧克力 味 夹心 20g 24 盒 ??????1 ??????饼干
2 奥利奥 原 味 夹心饼干 390g 袋 1 饼干
3 嘉顿 香 葱 薄饼 225g 盒 1 饼干
4 aji 苏打饼干 酵母 减 盐味 472.5g 袋 1 饼干
5 趣多多 曲奇 饼干 经典 巧克力 原 味 285g 袋 1 ??????饼干
6 趣多多 曲奇 饼干 经典 巧克力 原 味 285g 袋 x 2 1 ??????饼干
7 aji 尼 西亚 惊奇 脆 片 饼干 起 士 味 200g 袋 1 ??????饼干
8 格力高 百 醇 抹 茶 慕 斯 提 拉 米 苏 芝 士 蛋糕 味 48g 3盒 1 ??????饼干
9 奥利奥 巧克力 味 夹心饼干 390g 袋 1 饼干
10 趣多多 巧克力 味 曲奇 饼干 香脆 米粒 味 85g 袋 1 ??????饼干
11 趣多多 巧克力 味 曲奇 饼干 香脆 米粒 味 85g 袋 x 5 ??????1 ??????饼干
12 ...
完整的分词后的数据集合位于:
当然,中文分词是一个很有挑战性的课题,特别是针对存在歧义的情况,分词算法通常无法保证切分完全准确。由于这不是本章讨论的重点,因此这里暂时忽略可能存在的错误。接下来,就是使用R中的机器学习包,对分词后的标题进行分类。
1.6.3 使用R进行朴素贝叶斯分类
1.R的基础
目前为止,R的最新版本是3.3.2,可以从如下的链接选择合适的平台下载并安装:
https://cran.r-project.org/mirrors.html
安装后再运行,你将看到如图1-8所示的界面,这实际上就是一个输入命令的终端。你可以在提示符“>”后面输入并执行一条命令,或者通过编写脚本一次性执行多个命令。R支持很多数据类型,例如向量、矩阵、列表等。
下面让我们看几条基本的命令,包括最简单的函数c(),它可以让你输入一个向量,例如下面的两条命令:
> apple.a <- c(1,1,1,2,1,1)
> apple.a
[1] 1 1 1 2 1 1
灵感依旧来自前述的水果案例,第一条命“> apple.a <- c (1, 1, 1, 2, 1, 1)”是将苹果a虚构的特征值以向量的形式赋予对象apple.a,其中“<-”表示赋值。第二条命令是显示apple.a,是不是很简单呢?依此类推,可以手动建立多个水果对象,展示如下:
> apple.b <- c(1,1,1,1,1,1)
> apple.c <- c(2,3,1,1,2,1)
> orange.a <- c(2,2,1,1,2,2)
> orange.b <- c(2,2,1,2,2,2)
> orange.c <- c(1,2,1,2,1,1)
> watermelon.a <- c(3,3,2,3,1,2)
> watermelon.b <- c(3,3,2,3,1,1)
> watermelon.c <- c(3,3,2,3,1,2)
> watermelon.d <- c(1,3,2,3,2,2)
> ls()
[1] "apple.a" "apple.b" "apple.c" "applea" "orange.a"
[6] "orange.b" "orange.c" "watermelon.a" "watermelon.b" "watermelon.c"
[11] "watermelon.d"
其中ls()是列出当前定义的所有对象。除了允许用户在终端手工输入信息之外,R还支持从文本文件、数据库系统,甚至是其他统计软件上导入数据,对于数据源的整合很有益处。有了这些数据,要进行基础的处理就变得非常快捷。下面的命令分别列出了西瓜a作为数组处理时,其最大值、最小值、均值、中位数、方差和标准差的数值。
> max(watermelon.a)
[1] 3
> min(watermelon.a)
[1] 1
> mean(watermelon.a)
[1] 2.333333
> median(watermelon.a)
[1] 2.5
> var(watermelon.a)
[1] 0.6666667
> sd(watermelon.a)
[1] 0.8164966
至此,你已经开始了R工作的第一步,那如何保存这些成果呢?别急,R还提供了工作间(Workspace)的概念,即指当前的工作环境。通过保存工作间的镜像,你可以存储用户定义的数据对象和一些设置, R在下次启动时会自动加载所有这些内容。在这些基础之上,让我们看看如何利用现有的扩展包,快捷地构建基于朴素贝叶斯的分类器。
2.文本数据预处理
在使用R的扩展包对商品标题进行分类之前,除了中文分词以外,还有一系列其他的预处理工作,具体如下。
1)打散样本。
2)将样本加载到R的变量中。
3)将样本集合变量转换为文档集和文档-单词矩阵。
4)切分训练和测试数据集。
(1)打散样本
由于要使用同一个样本集合生成训练数据和测试数据,所以需要保证不同分类的样本出现的顺序足够随机,否则切分的时候容易导致某些分类在训练数据中出现的次数过少甚至不出现的情况。这种情形最终会导致拟合出的模型会有偏差,分类预测效果不理想,无法反映理论模型的真实性能等。如果你的样本按照分类来看其出现的顺序已经足够随机,那么可以跳过这一步。从listing-segmented.txt中可以看出,同一分类的数据都是紧密相邻的,一个分类结束之后才会出现下一个分类,因此不满足随机性的条件,我们需要某种随机的方式,将样本出现的顺序打散。
通常,打散可以分为两种方式,一种是预先将样本文件的顺序打乱,另一种是在使用R切分训练和测试数据时进行打散。这里采用第一种方法,目的是便于用户重现此处的实验,并在后面不同的算法或系统实践时重用相同的数据。具体的实现请参考org.wltea.analyzer.sample.IKAnalzyerForListing中的另一个函数processListingWithShuff?le:
public static void processListingWithShuffle(String inputFileName, String output
FileName) {
try {
br = new BufferedReader(new FileReader(inputFileName));
pw = new PrintWriter(new FileWriter(outputFileName));
ArrayList<String> samples = new ArrayList<String>();
String strLine = br.readLine(); // 跳过header这一行
pw.println(strLine);
while ((strLine = br.readLine()) != null) {
// 获取每个字段
String[] tokens = strLine.split("\t");
String id = tokens[0];
String title = tokens[1];
String cateId = tokens[2];
String cateName = tokens[3];
// 对原有的商品标题进行中文分词
ts = analyzer.tokenStream("myfield", new StringReader(title));
// 获取词元位置属性
OffsetAttribute offset = ts.addAttribute(OffsetAttribute.class);
// 获取词元文本属性
CharTermAttribute term = ts.addAttribute(CharTermAttribute.class);
// 获取词元文本属性
TypeAttribute type = ts.addAttribute(TypeAttribute.class);
// 重置TokenStream(重置StringReader)
ts.reset();
// 迭代获取分词结果
StringBuffer sbSegmentedTitle = new StringBuffer();
while (ts.incrementToken()) {
sbSegmentedTitle.append(term.toString()).append(" ");
}
samples.add(String.format("%s\t%s\t%s\t%s", id, sbSegmentedTitle.
toString().trim(), cateId, cateName));
// 关闭TokenStream(关闭StringReader)
ts.end(); // Perform end-of-stream operations, e.g. set the final offset.
}
br.close();
br = null;
Random rand = new Random(System.currentTimeMillis());
while (samples.size() > 0) {
int index = rand.nextInt(samples.size());
pw.println(samples.get(index));
samples.remove(index);
}
pw.close();
pw = null;
} catch (Exception e) {
e.printStackTrace();
} finally {
cleanup();
}
}
其增加的主要部分是利用Random随机抽函数,每次随机抽取出一个样本生成新的序列。打散后的全部数据可参见:
下面的文本片段是该文件的开头部分:
ID Title CategoryID CategoryName
22785 samsung 三星 galaxy tab3 t211 1g 8g wifi+3g 可 通话 平板 电脑 gps 300万像素 白色 15 电脑
19436 samsung 三星 galaxy fame s6818 智能手机 td-scdma gsm 蓝色 移动 定制 机 14 手机
3590 金本位 美味 章 鱼丸 250g 3 海鲜水产
3787 莲花 居 预售 阳澄湖 大闸蟹 实物 558 型 公 3.3-3.6 两 母 2.3-2.6 两 5对 装 3 海鲜水产
11671 rongs 融 氏 纯 玉米 胚芽油 5l 绿色食品 非 转基因 送 300ml 小 油 1瓶 9 食用油
23188 kerastase 卡 诗 男士 系列 去 头屑 洗发水 250ml 去 屑 止痒 男士 专用 进口 专业 洗 护发 16 美发护发
25150 dove 多 芬 丰盈 宠 肤 沐浴 系列 乳 木 果 和 香草 沐浴乳 400ml 5瓶 17 沐浴露
14707 魏 小 宏 weixiaohong 长寿 枣 400克 袋装 美容 养颜 安徽 宣城 水 东 特产 10 枣类
28657 80 茶客 特级 平阴 玫瑰花 玫瑰 茶 花草 茶 花茶 女人 茶 冲 饮 50克 袋 18 茶叶
6275 德芙 兄弟 品牌 脆 香米 脆 米 心 牛奶 巧克力 500g 散装 6 巧克力
18663 十月 稻田 五常 稻 花香 大米 5kg 袋 x 2 12 大米
15229 …
(2)加载变量
接下来使用read.csv命令,将本地文件系统中的listing-segmented-shuff?led.txt 导入为R的变量listing:
> listing <- read.csv("/Users/huangsean/Coding/data/BigDataArchitectureAndAlgori
thm/listing-segmented-shuffled.txt", stringsAsFactors = FALSE, sep='\t')
然后查看listing的基本情况:
> str(listing)
'data.frame': 28706 obs. of 4 variables:
$ ID : int 22785 19436 3590 3787 11671 23188 25150 14707 28657 6275 ...
$ Title : chr "samsung 三星 galaxy tab3 t211 1g 8g wifi+3g 可 通话 平板 电脑 gps 300万
像素 白色" "samsung 三星 galaxy fame s6818 智能手机 td-scdma gsm 蓝色 移动 定制 机" "金本位
美味 章 鱼丸 250g" "莲花 居 预售 阳澄湖 大闸蟹 实物 558 型 公 3.3-3.6 两 母 2.3-2.6 两 5对 装" ...
$ CategoryID : int 15 14 3 3 9 16 17 10 18 6 ...
$ CategoryName: chr "电脑" "手机" "海鲜水产" "海鲜水产" ...
像CategoryID、CategoryName这样的字段,我们希望它们可以按照唯一性进行分组,因此将其转换为R中的因子(factor)类型。首先将factor(listing$CategoryID)赋予listing$CategoryID,并再次查看listing的基本情况:
> listing$CategoryID <- factor(listing$CategoryID)
> str(listing)
'data.frame': 28706 obs. of 4 variables:
$ ID : int 22785 19436 3590 3787 11671 23188 25150 14707 28657 6275 ...
$ Title : chr "samsung 三星 galaxy tab3 t211 1g 8g wifi+3g 可 通话 平板 电脑 gps 300万
像素 白色" "samsung 三星 galaxy fame s6818 智能手机 td-scdma gsm 蓝色 移动 定制 机" "金本位
美味 章 鱼丸 250g" "莲花 居 预售 阳澄湖 大闸蟹 实物 558 型 公 3.3-3.6 两 母 2.3-2.6 两 5对 装" ...
$ CategoryID : Factor w/ 18 levels "1","2","3","4",..: 15 14 3 3 9 16 17 10 18 6 ...
$ CategoryName: chr "电脑" "手机" "海鲜水产" "海鲜水产" ...
你会发现CategoryID的描述发生了变化。此外,还可以使用table命令查看每个分类ID的数量(也就是每个分类的样本数量):
> table(listing$CategoryID)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
1874 818 1815 665 334 1837 2331 1896 1573 1691 1804 2400 258 1800 1800 1844 1953 2013
对于CategoryName,可以进行同样的操作:
> listing$CategoryName <- factor(listing$CategoryName)
> table(listing$CategoryName)
坚果 大米 巧克力 手机 新鲜水果 方便面 枣类 沐浴露 海鲜水产 电脑 纯牛奶 美发护发 茶叶
进口牛奶 面粉 食用油 饮料饮品 饼干
1896 2400 1837 1800 1804 818 1691 1953 1815 1800 334 1844 2013
665 258 1573 2331 1874
(3)生成文档-单词矩阵
根据之前所述,文本分类常常采用词包(Bag of Word)的数据表示方法,所以我们需要将listing变量转变为文档对单词的二维矩阵。首先,我们要使用install.packages()函数安装R的文本挖掘包tm:
> install.packages("tm")
第一次运行扩展包的安装时,可能需要选择最佳的镜像站点,如图1-9所示。
运行后R会自动进行安装:
> install.packages("tm")
--- Please select a CRAN mirror for use in this session ---
also installing the dependencies ‘NLP’, ‘slam’
trying URL 'https://cran.cnr.berkeley.edu/bin/macosx/mavericks/contrib/3.3/NLP_0.1-9.tgz'
Content type 'application/x-gzip' length 278807 bytes (272 KB)
==================================================
downloaded 272 KB
trying URL 'https://cran.cnr.berkeley.edu/bin/macosx/mavericks/contrib/3.3/slam_
0.1-40.tgz'
Content type 'application/x-gzip' length 106561 bytes (104 KB)
==================================================
downloaded 104 KB
trying URL 'https://cran.cnr.berkeley.edu/bin/macosx/mavericks/contrib/3.3/tm_0.6-2.tgz'
Content type 'application/x-gzip' length 665347 bytes (649 KB)
==================================================
downloaded 649 KB
The downloaded binary packages are in
/var/folders/fr/gb14wrwn5296_7rmyrhx1wsw0000gn/T//RtmprXQjRG/downloaded_packages
然后使用library()函数加载tm:
> library(tm)
Loading required package: NLP
加载完成后,就可以使用VCorpus (VectorSource (listing$Title))命令,将listing的Title字段取出并转变为文档集合listing_corpus。命令inspect (listing_corpus[1:3])可以帮助你检视前3个标题记录的基本情况:
> listing_corpus <- VCorpus(VectorSource(listing$Title))
> print(listing_corpus)
<<VCorpus>>
Metadata: corpus specific: 0, document level (indexed): 0
Content: documents: 28706
> inspect(listing_corpus[1:3])
<<VCorpus>>
Metadata: corpus specific: 0, document level (indexed): 0
Content: documents: 3
[[1]]
<<PlainTextDocument>>
Metadata: 7
Content: chars: 66
[[2]]
<<PlainTextDocument>>
Metadata: 7
Content: chars: 57
[[3]]
<<PlainTextDocument>>
Metadata: 7
Content: chars: 16
下一步是使用DocumentTermMatrix()函数从listing_corpus获取文档-单词矩阵listing_dtm:
> listing_dtm <- DocumentTermMatrix(listing_corpus, control=list(wordLengths=c(0,Inf)))
> listing_dtm
<<DocumentTermMatrix (documents: 28706, terms: 16458)>>
Non-/sparse entries: 359791/472083557
Sparsity : 100%
Maximal term length: 25
Weighting : term frequency (tf)
其中,需要注意的是,DocumentTermMatrix函数原本是针对英文单词进行编码的。由于只包含1个或2个字母的英文单词基本上都没有意义,所以这个函数默认会去除字符长度小于3的单词。但是,这里处理的是中文,而且很多重要的中文词都是少于3个字符的,例如本案例中的“牛奶” “茶叶” “手机” “水” “酒”等。这些词都是分类的重要线索,不能丢弃,所以我们要加上参数control = list (wordLengths = c (0, Inf)),保留全部的中文词,单词总数量是16458,文档总数量是28706。下一步是使用convert函数将矩阵中的词频tf转变为在R中朴素贝叶斯分类所需的“Yes”和“No”值:
> convert <- function(x) { x <- ifelse(x > 0, "Yes", "No") }
> listing_all <- apply(listing_dtm, MARGIN = 2, convert)
(4)切分训练和测试集
在正式上线之前,监督式学习算法很重要的一步就是进行离线的测试。针对标注的数据我们可以切分出训练和测试集合,来实现这个目标。由于之前已经打散了样本数据,所以可以直接将前90%的数据作为训练样本,后10%的作为测试样本:
> listing_train <- listing_all[1:25835, ]
> listing_test <- listing_all[25836:28706, ]
除了样本内容的切分,分类标签也需要切分,我们可以使用CategoryID或CategoryName来实现:
> listing_train_labels <- listing[1:25835, ]$CategoryID
> listing_test_labels <- listing[25836:28706, ]$CategoryID
前面也提到过,如果你的样本数据尚未提前打散,那么也可以在R中进行此步骤:
> split.data = function(data, p = 0.9, s = 888){
+ set.seed(s)
+ index = sample(1:dim(data)[1])
+ train = data[index[1:floor(dim(data)[1] * p)], ]
+ test = data[index[((floor(dim(data)[1] * p)) +
1):dim(data)[1]], ]
+ return(list(train = train, test = test))
+}
> twosets = split.data(listing_all, p = 0.9)
> listing_train = twosets$train
> listing_train = twosets$test
后面使用convert函数进行转变的步骤与之前的相似。
3.训练、预测和评估
实现朴素贝叶斯的R扩展包是e1071,安装该包的命令如下:
> install.packages("e1071")
trying URL 'https://cran.cnr.berkeley.edu/bin/macosx/mavericks/contrib/3.3/e10
71_1.6-7.tgz'
Content type 'application/x-gzip' length 752286 bytes (734 KB)
==================================================
downloaded 734 KB
The downloaded binary packages are in
/var/folders/fr/gb14wrwn5296_7rmyrhx1wsw0000gn/T//RtmprXQjRG/downloaded_packages
> library(e1071)
然后就可以使用listing_train进入训练阶段,实现模型的拟合了:
> listing_classifier <- naiveBayes(listing_train, listing_train_labels)
模型存放于listing_classif?ier,它包括每个分类的出现概率:
> listing_classifier
Naive Bayes Classifier for Discrete Predictors
Call:
naiveBayes.default(x = listing_train, y = listing_train_labels)
A-priori probabilities:
listing_train_labels
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18
0.065453842 0.028682021 0.063324947 0.023185601 0.011302497 0.063712019 0.080665763
0.066034449 0.053802980 0.059686472 0.062279853 0.084730017 0.008747823 0.063208825
0.062395974 0.064370041 0.068124637 0.070292239
Conditional probabilities:
...
在Conditional probabilities部分包含了每个词在不同分类中出现的概率,可以通过listing_classif?ier$tables快速查看某个特定的词,例如:
> listing_classifier$tables[['小米']]
小米
listing_train_labels No Yes
1 1.0000000000 0.0000000000
2 0.9973009447 0.0026990553
3 1.0000000000 0.0000000000
4 1.0000000000 0.0000000000
5 1.0000000000 0.0000000000
6 1.0000000000 0.0000000000
7 1.0000000000 0.0000000000
8 1.0000000000 0.0000000000
9 0.9992805755 0.0007194245
10 1.0000000000 0.0000000000
11 0.9993784960 0.0006215040
12 1.0000000000 0.0000000000
13 0.9955752212 0.0044247788
14 0.9546846295 0.0453153705
15 1.0000000000 0.0000000000
16 1.0000000000 0.0000000000
17 1.0000000000 0.0000000000
18 1.0000000000 0.0000000000
> listing_classifier$tables[['牛奶']]
牛奶
listing_train_labels No Yes
1 0.9379065642 0.0620934358
2 1.0000000000 0.0000000000
3 1.0000000000 0.0000000000
4 0.3238731219 0.6761268781
5 0.4657534247 0.5342465753
6 0.7982989064 0.2017010936
7 0.9846449136 0.0153550864
8 0.9994138335 0.0005861665
9 1.0000000000 0.0000000000
10 0.9948119326 0.0051880674
11 0.9975139838 0.0024860162
12 1.0000000000 0.0000000000
13 1.0000000000 0.0000000000
14 1.0000000000 0.0000000000
15 1.0000000000 0.0000000000
16 1.0000000000 0.0000000000
17 0.9818181818 0.0181818182
18 1.0000000000 0.0000000000
> listing_classifier$tables[['手机']]
手机
listing_train_labels No Yes
1 1.0000000000 0.0000000000
2 1.0000000000 0.0000000000
3 1.0000000000 0.0000000000
4 1.0000000000 0.0000000000
5 1.0000000000 0.0000000000
6 1.0000000000 0.0000000000
7 0.9990403071 0.0009596929
8 0.9994138335 0.0005861665
9 1.0000000000 0.0000000000
10 1.0000000000 0.0000000000
11 1.0000000000 0.0000000000
12 1.0000000000 0.0000000000
13 1.0000000000 0.0000000000
14 0.3949785671 0.6050214329
15 0.9950372208 0.0049627792
16 1.0000000000 0.0000000000
17 1.0000000000 0.0000000000
18 0.9994493392 0.0005506608
从上述三个关键词的例子可以看出,“小米”这个词在分类14(手机)中有一定的出现概率(注意是概率而不是绝对次数),在分类13(面粉)中也有一点出现概率;“牛奶”一词在分类4(进口牛奶)和分类5(纯牛奶)中出现的概率很高;“手机”一词在分类14(手机)中出现的概率很高。有了这些先验概率,就可以根据贝叶斯理论预估后验概率。使用该模型对测试集合listing_test进行预测的命令如下:
> listing_test_pred <- predict(listing_classifier, listing_test)
不过在使用本案例的数据集合时,你很可能会发现在运行预测函数predict之后,系统抛出了异常“Error in '[.default'(object$tables[[v]], , nd) : subscript out of bounds”,如图1-10所示。
经过仔细排查,我们发现出现异常的根本原因是某些词在训练样本中没有出现过,但是在测试样本中却出现了。例如如下这个被测试的样本:
1277 古 陵 山 大 薯 核桃 曲奇 112g 3 每 一口 都能 吃到 核桃仁 山西 晋城 休闲 办公室 零食
1 饼干
其中包含了“都能”这个词,但是在训练样本中没有出现过“都能”。这一点可以使用listing_classif?ier$tables来验证,你会发现这个词在所有类的训练样本中都没有出现过,因此只有“No”这一个列,如下所示:
> listing_classifier$tables[['都能']]
都能
listing_train_labels No
1 1
2 1
3 1
4 1
5 1
6 1
7 1
8 1
9 1
10 1
11 1
12 1
13 1
14 1
15 1
16 1
17 1
18 1
而查看e1071中朴素贝叶斯的实现源码,发现它并没有考虑这种极端情况:
predict.naiveBayes <- function(object,
newdata,
type = c("class", "raw"),
threshold = 0.001,
...) {
type <- match.arg(type)
newdata <- as.data.frame(newdata)
attribs <- match(names(object$tables), names(newdata))
isnumeric <- sapply(newdata, is.numeric)
newdata <- data.matrix(newdata)
L <- sapply(1:nrow(newdata), function(i) {
ndata <- newdata[i, ]
L <- log(object$apriori) + apply(log(sapply(seq_along(attribs),
function(v) {
nd <- ndata[attribs[v]]
if (is.na(nd)) rep(1, length(object$apriori)) else {
prob <- if (isnumeric[attribs[v]]) {
msd <- object$tables[[v]]
msd[, 2][msd[, 2] == 0] <- threshold
dnorm(nd, msd[, 1], msd[, 2])
} else object$tables[[v]][, nd]
prob[prob == 0] <- threshold
prob
}
})), 1, sum)
if (type == "class")
L
else {
## Numerically unstable:
## L <- exp(L)
## L / sum(L)
## instead, we use:
sapply(L, function(lp) {
1/sum(exp(L - lp))
})
}
})
if (type == "class")
factor(object$levels[apply(L, 2, which.max)], levels = object$levels)
else t(L)
}
object$tables[[v]][, nd]并未考虑第2列不存在的情况,因此导致分类器抛出下标越界的异常。为此我们在相应的部分加入判定,并针对训练样本中未出现的新词赋予最小的threshold值:
if (dim(object$tables[[v]])[2] < 2) {
prob<-vector(mode="numeric",length=0)
for(i in 1:dim(object$tables[[v]])[1])
{
prob[i] <- 0
}
} else {
prob <- if (isnumeric[attribs[v]]) {
msd <- object$tables[[v]]
msd[, 2][msd[, 2] == 0] <- threshold
dnorm(nd, msd[, 1], msd[, 2])
} else object$tables[[v]][, nd]
}
prob[prob == 0] <- threshold
prob
}
完整的修正代码位于:
再次运行预测函数就不会产生越界的错误了,预测的结果会保存于listing_test_pred中。最后可通过gmodels包中的函数进行评估,首先安装相应的扩展包:
> install.packages("gmodels")
also installing the dependencies ‘gtools’, ‘gdata’
trying URL 'https://cran.cnr.berkeley.edu/bin/macosx/mavericks/contrib/3.3/gtools_
3.5.0.tgz'
Content type 'application/x-gzip' length 134356 bytes (131 KB)
==================================================
downloaded 131 KB
trying URL 'https://cran.cnr.berkeley.edu/bin/macosx/mavericks/contrib/3.3/gdata_
2.17.0.tgz'
Content type 'application/x-gzip' length 1136842 bytes (1.1 MB)
==================================================
downloaded 1.1 MB
trying URL 'https://cran.cnr.berkeley.edu/bin/macosx/mavericks/contrib/3.3/gmodels_
2.16.2.tgz'
Content type 'application/x-gzip' length 72626 bytes (70 KB)
==================================================
downloaded 70 KB
The downloaded binary packages are in
/var/folders/fr/gb14wrwn5296_7rmyrhx1wsw0000gn/T//RtmprXQjRG/downloaded_packages
> library(gmodels)
然后使用CrossTable()函数计算混淆矩阵:
> CrossTable(listing_test_pred, listing_test_labels, prop.chisq = FALSE, prop.t = FALSE, dnn = c('预测值', '实际值'))
图1-11展示了混淆矩阵的局部内容,通过这个局部内容的左上角可以看出,分类1*有177个测试样例被正确地预测为分类1,该类的精度为92.7%,召回率为96.7%,而前6个分类中分类5(纯牛奶)的预测性能最差,召回率只有69%,精度只有66%,从图1-12可以看出,主要是纯牛奶(非进口)和进口牛奶两个分类容易混淆,从字面上来看两个分类过于接近。混淆矩阵全部内容可以参见:
总体来说,18个分类中有16个分类的召回率和精度都在90%以上,全局的准确率在96%以上,分类效果较好。当然,我们可以使用10-folder的交叉验证,轮流测试10%的数据并获取每个类的平均召回率和精度,以及全局的平均准确率。
给定机器学习的模型,我们可以改变训练样本的数量,或者是用于分类的特征,来测试该模型的效果。这里将训练集合放大到99%的标注数据,而测试样本为1%的标注数据:
> listing_train <- listing_all[1:28419, ]
> listing_test <- listing_all[28420:28706, ]
> listing_train_labels <- listing[1:28419, ]$CategoryID
> listing_test_labels <- listing[28420:28706, ]$CategoryID
> listing_classifier <- naiveBayes(listing_train, listing_train_labels)
> listing_test_pred <- predict(listing_classifier, listing_test)
> CrossTable(listing_test_pred, listing_test_labels, prop.chisq = FALSE, prop.t = FALSE, dnn = c('预测值', '实际值'))
图1-13展示了混淆矩阵的局部,而完整的混淆矩阵位于:
从图1-13中可以看出,某些类别的召回率和精度有所提升,而某些却下降了,可能是由于模型过拟合所导致的。整体的准确率大约为96.5%。
当然,你也可以查看贝叶斯分类器对每个被测样例的预测值。可以通过修改预测函数predicate()的参数type为“raw”来实现,代码如下:
> listing_test_pred <- predict(listing_classifier, listing_test, type = "raw")
> listing_test_pred[1:3,]
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18
[1,] 1.163562e-02 8.686446e-12 2.103992e-12 2.508276e-14 6.986207e-14 9.883640e-01 1.419304e-12
4.030010e-07 1.039946e-13 6.245893e-11 4.309790e-14 3.826502e-16 1.896761e-09 1.470796e-20
1.129724e-17 5.292009e-17 8.534417e-19 2.267523e-15
[2,] 7.581626e-01 1.257191e-08 1.997998e-14 1.978235e-16 6.065871e-17 7.761154e-11 2.107551e-13
2.418373e-01 4.168406e-14 1.328692e-07 3.947417e-16 3.505745e-17 1.252182e-15 5.676353e-16
8.126072e-19 4.764065e-17 1.697768e-16 1.249436e-12
[3,] 1.548534e-09 2.067730e-10 3.250368e-11 4.691759e-08 2.715113e-07 2.418692e-08 1.056756e-07
2.312425e-10 9.023589e-10 4.037764e-11 3.655755e-09 1.269035e-08 3.647616e-11 3.340507e-11
7.912837e-11 9.999994e-01 1.206560e-07 1.152458e-10
此刻,查看listing_test_pred的值就会发现,对于每个被测试的样例,分类器都给出了它属于某个分类的概率。
1.6.4 使用R进行K最近邻分类
当然,对于同样的任务可以尝试不同的分类模型。让我们再次尝试一下R扩展包class中的KNN分类。数据的预处理过程是类似的,我们将直接从之前获取的listing_dtm开始:
> convert_2 <- function(x) { x <- x }
> listing_all_knn <- as.data.frame(apply(listing_dtm, MARGIN=2, convert_2))
> listing_train_knn <- listing_all_knn[1:28419, ]
> listing_test_knn <- listing_all_knn[28420:28706, ]
> listing_train_labels <- listing[1:28419, ]$CategoryID
> listing_test_labels <- listing[28420:28706, ]$CategoryID
这里保留了listing_dtm中的词频tf数值,用于KNN计算样例之间的距离,此处和针对朴素贝叶斯分类器的处理有所不同。另外,考虑到KNN在预测阶段的时间复杂度太高,此次测试的样本也控制在全体数据的1%,尽管如此,在单台iMac上运行如下预测仍然可能需要数十分钟:
> listing_test_pred_knn <- knn(train = listing_train_knn, test = listing_test_knn, cl = listing_train_labels, k = 3)
> CrossTable(listing_test_pred_knn, listing_test_labels, prop.chisq = FALSE, prop.t = FALSE, dnn = c('预测值', '实际值'))
图1-14展示了KNN预测结果和标注相比,混淆矩阵的局部内容。完整的混淆矩阵位于:
在以上的测试样本上使用KNN分类算法,最终获得的整体准确率大约在91.3%,略逊于朴素贝叶斯。可以看出,相比KNN,朴素贝叶斯分类模型虽然需要学习的过程,而且也更难理解,但是其具有良好的分类效果,以及实时预测的性能。因此,在现实生产环境中,我们可以优先考虑朴素贝叶斯。
尽管我们可以便捷地在R语言中测试不同的分类算法,但是它也有一定的局限性,主要体现在如下几个方面。
- 性能:R属于解释性语言,其性能比不上C++、Java这样的编程语言,因此不适合应用于大量的在线服务。
- 并行性:R最常见的应用还是侧重于单机环境。其并行处理方案是存在的,例如和Hadoop结合的RHadoop,但是不如Mahout和Hadoop结合得那么紧密。
- 集成复杂度:R和其他主流的编程语言,例如Java,也可以集成,但是比较复杂,开发成本较高。
鉴于此,下面来介绍一下 Apache Mahout中的分类实现,它不仅可以利用Hadoop来开展并行的训练,而且可以让你打造实时的在线预测系统。
1.6.5 单机环境使用Mahout运行朴素贝叶斯分类
1.实验准备
为了达到更好的效果,我们将由浅入深地进行学习,首先来学习在单机上如何运行Mahout的机器学习算法——朴素贝叶斯。硬件仍然使用2015款iMac一体机1台,软件环境除了之前采用的Java语言(JDK 1.8)和Eclipse IDE环境(Neon.1a Release (4.6.1))之外,当然还需要安装Mahout。这里部署的是版本号为0.9的Mahout,你可以在这里下载并解压:
http://mahout.apache.org/general/downloads.html
然后根据解压的目录,相应地设置环境变量如下:
export MAHOUT_HOME=/Users/huangsean/Coding/mahout-distribution-0.9
export PATH=$PATH:$MAHOUT_HOME/bin
export MAHOUT_LOCAL=1
注意,这里也设置了MAHOUT_LOCAL变量,目的是为了确保当前的Mahout是在单台机器上运行的。
2.通过命令行进行训练和测试
在编写实时性预测的代码之前,你可以先通过Mahout的命令行模式来了解其分类算法的工作流程。为了这项任务,首先准备原始的实验数据。数据的内容依然是R实验中的 listing-segmented-shuff?led.txt。不过出于Mahout的需求,我们为每件商品单独生成一个商品文件,内容是商品的标题,文件名称是商品的ID,并将同一个分类的商品文件存放在同一个子目录中,子目录的名称是分类的ID,目录和文件的组织如图1-15所示,其中标题为“雀巢 脆 脆 鲨 威 化 巧克力 巧克力 味 夹心 20g 24 盒”的商品,形成了1.txt(商品ID为1)的文件,并置于名为1(分类ID为1)的目录中。
上述完整的数据文件位于:
有了这些数据,Mahout进行朴素贝叶斯分类的主要步骤具体如下。
1)将原始数据文件转换成Hadoop的序列文件(SequenceFile)。序列文件是Hadoop所使用的文件格式之一,尽管目前使用的是单机模式,但Mahout还是需要读取这种格式。
2)将序列文件中的数据转换为向量。向量是使用词包(Bag of Word)来表示文本的基本方式,这步操作和R语言中DocumentTermMatrix的功能相类似。
3)切分训练样本和待测样本集合。
4)使用朴素贝叶斯算法训练模型。
5)使用朴素贝叶斯算法测试待测的样本。
首先使用mahout命令的seqdirectory选项,将原始数据转换为序列文件:
[huangsean@iMac2015:/Users/huangsean/Coding]mahout seqdirectory -i /Users/huangsean
/Coding/data/BigDataArchitectureAndAlgorithm/listing-segmented-shuffled-mahout/ -o /
Users/huangsean/Coding/data/BigDataArchitectureAndAlgorithm/listing-segmented-shuff
led-seq -ow
MAHOUT_LOCAL is set, so we don't add HADOOP_CONF_DIR to classpath.
MAHOUT_LOCAL is set, running locally
...
17/01/17 21:27:04 INFO driver.MahoutDriver: Program took 6513 ms (Minutes: 0.10855)
其中-i用于指定输入的原始数据文件用于,而-o用于指定输出的序列文件,-ow表示覆盖已有的结果。生成的序列文件如图1-16所示。
再使用seq2sparse选项,将该part-m-00000文件作为输入,获取稀疏向量:
[huangsean@iMac2015:/Users/huangsean/Coding]mahout seq2sparse -i /Users/huangsean
/Coding/data/BigDataArchitectureAndAlgorithm/listing-segmented-shuffled-seq/part-
m-00000 -o /Users/huangsean/Coding/data/BigDataArchitectureAndAlgorithm/listing-segm
ented-shuffled-vec -lnorm -nv -wt tf -a org.apache.lucene.analysis.core.Whitespace
Analyzer
MAHOUT_LOCAL is set, so we don't add HADOOP_CONF_DIR to classpath.
MAHOUT_LOCAL is set, running locally
...
17/01/17 21:30:19 INFO driver.MahoutDriver: Program took 7904 ms (Minutes: 0.131
73333333333334)
其中,-lnorm表示使用了归一化。-wt tf表示权重值使用了词频。这里采用词频是为了和之前R的实验保持一致,便于比较分类的效果。当然,还可以通过-wt tf?idf,使用tf-idf的机制定义每个单词维度的取值,该机制的具体含义将在第4章有关搜索引擎的部分中进行介绍。另外,一定要通过-a org.apache.lucene.analysis.core.WhitespaceAnalyzer来指定分析器(analyzer),如果不指定,那么Mahout将默认按照英文的处理方式,将中文单词都切分为单个汉字,这可能会对最终的分类结果产生负面影响。由于之前已经使用IKAnalyzer将商品的标题进行了中文分词,所以这里指定以空格为分隔符的WhitespaceAnalyzer。此命令执行完毕后,我们将获得如图1-17所示的向量文件:
下一步就是将该向量文件切分为训练数据集和待测的数据集:
[huangsean@iMac2015:/Users/huangsean/Coding]mahout split -i /Users/huangsean/
Coding/data/BigDataArchitectureAndAlgorithm/listing-segmented-shuffled-vec/tf-vectors --
trainingOutput /Users/huangsean/Coding/data/BigDataArchitectureAndAlgorithm/listing-
segmented-shuffled-train --testOutput /Users/huangsean/Coding/data/BigDataArchitecture
AndAlgorithm/listing-segmented-shuffled-test --randomSelectionPct 10 --overwrite --
sequenceFiles -xm sequential
MAHOUT_LOCAL is set, so we don't add HADOOP_CONF_DIR to classpath.
MAHOUT_LOCAL is set, running locally
...
17/01/17 21:31:09 INFO driver.MahoutDriver: Program took 881 ms (Minutes: 0.0146
83333333333333)
其中--randomSelectionPct 10表示待测样本的占比为10%,也就是90%的数据用于训练。而-xm sequential表示在单机上执行,而不进行MapReduce操作。下一步就是执行训练过程,同时利用-li参数来生成评测所用的类标索引(labelindex)文件:
[huangsean@iMac2015:/Users/huangsean/Coding]mahout trainnb -i /Users/huangsean/
Coding/data/BigDataArchitectureAndAlgorithm/listing-segmented-shuffled-train -el -o /
Users/huangsean/Coding/data/BigDataArchitectureAndAlgorithm/listing-segmented-shuffled
-model -li /Users/huangsean/Coding/data/BigDataArchitectureAndAlgorithm/listing-
segmented-shuffled-mahout-labelindex -ow
MAHOUT_LOCAL is set, so we don't add HADOOP_CONF_DIR to classpath.
MAHOUT_LOCAL is set, running locally
...
17/01/17 21:32:14 INFO driver.MahoutDriver: Program took 3037 ms (Minutes: 0.050
616666666666664)
生成的模型model目录和类标索引labelindex文件如图1-18所示。其中类标索引相当于考试答案,可供稍后的离线测试使用。
最后,通过训练的模型目录和类标索引,对待测样本进行测试和评估:
[huangsean@iMac2015:/Users/huangsean/Coding]mahout testnb -i /Users/huangsean/
Coding/data/BigDataArchitectureAndAlgorithm/listing-segmented-shuffled-test -m /Users/
huangsean/Coding/data/BigDataArchitectureAndAlgorithm/listing-segmented-shuffled-
model -l /Users/huangsean/Coding/data/BigDataArchitectureAndAlgorithm/listing-segmented-
shuffled-mahout-labelindex -ow -o /Users/huangsean/Coding/data/BigDataArchitecture
AndAlgorithm/listing-segmented-shuffled-mahout-results
MAHOUT_LOCAL is set, so we don't add HADOOP_CONF_DIR to classpath.
MAHOUT_LOCAL is set, running locally
...
=======================================================
Statistics
-------------------------------------------------------
Kappa 0.9613
Accuracy 97.8484%
Reliability 90.129%
Reliability (standard deviation) 0.2563
17/01/17 21:34:21 INFO driver.MahoutDriver: Program took 1460 ms (Minutes: 0.024
333333333333332)
执行完毕后Mahout直接输出了评估结果。你将看到类似图1-11和图1-13的混淆矩阵,如图1-19所示。如果需要查阅完整的矩阵内容,可以访问:
此外你还可以看到准确率(Accuracy)在97.8%,和R的朴素贝叶斯分类实践中的数据96%非常接近。两者的相差可能是由于训练和测试数据集的切分不一致,或者是细微的分类器实现差异所导致的。从分类的结果来看,整体效果非常理想。
3.打造实时预测
当然,如果需要将Mahout运用到线上服务,那么上述离线式的处理和测试还远远不够。我们可以利用训练阶段所生成的模型文件,创建一个实时性的分类预测模块。这个过程按顺序可以分为以下几个主要步骤。
1)预加载必要的数据,包括Mahout训练命令所产生的朴素贝叶斯模型、类标索引文件和分类ID/名称间的映射。
2)对实时输入的文本进行中文分词。
3)将分词结果转变为单词向量。
4)根据训练的模型和单词向量,给出分类的预测。
下面是一段用于演示核心流程的示例代码:
public static void main(String[] args) throws Exception {
// 指定Mahout朴素贝叶斯分类模型的目录、类标文件和字典文件
String modelPath = "/Users/huangsean/Coding/data/BigDataArchitectureAnd
Algorithm/listing-segmented-shuffled-model/";
String labelIndexPath = "/Users/huangsean/Coding/data/BigDataArchitectureAnd
Algorithm/listing-segmented-shuffled-mahout-labelindex";
String dictionaryPath = "/Users/huangsean/Coding/data/BigDataArchitectureAnd
Algorithm/listing-segmented-shuffled-vec/dictionary.file-0";
Configuration configuration = new Configuration();
// 加载Mahout朴素贝叶斯分类模型,以及相关的类标、字典和分类名称映射
NaiveBayesModel model = NaiveBayesModel.materialize(new Path(modelPath),
configuration);
StandardNaiveBayesClassifier classifier = new StandardNaiveBayesClassifier
(model);
Map<Integer, String> labels = BayesUtils.readLabelIndex(configuration, new Path
(labelIndexPath));
Map<String, Integer> dictionary = readDictionnary(configuration, new Path
(dictionaryPath));
Map<String, String> categoryMapping = loadCategoryMapping();
// 使用IKAnalyzer进行中文分词
Analyzer ikanalyzer = new IKAnalyzer();
TokenStream ts = null;
while (true) {
BufferedReader strin=new BufferedReader(new InputStreamReader(System.in));
System.out.print("请输入待测的文本:");
String content = strin.readLine();
if ("exit".equalsIgnoreCase(content)) break;
// 进行中文分词,同时构造单词列表
Map<String, Integer> terms = new Hashtable<String, Integer>();
ts = ikanalyzer.tokenStream("myfield", content);
CharTermAttribute term = ts.addAttribute(CharTermAttribute.class);
ts.reset();
while (ts.incrementToken()) {
if (term.length() > 0) {
String strTerm = term.toString();
Integer termId = dictionary.get(strTerm);
if (termId != null) {
if (!terms.containsKey(strTerm)) {
terms.put(strTerm, 0);
}
terms.put(strTerm, terms.get(strTerm) + 1);
termsCnt ++;
}
}
}
ts.end();
ts.close();
// 使用词频tf(term frequency)构造向量
RandomAccessSparseVector rasvector = new RandomAccessSparseVector
(100000);
for (Map.Entry<String, Integer> entry : terms.entrySet()) {
String strTerm = entry.getKey();
int tf = entry.getValue();
Integer termId = dictionary.get(strTerm);
rasvector.setQuick(termId, tf);
}
// 根据构造好的向量和之前训练的模型,进行分类
org.apache.mahout.math.Vector predictionVector = classifier.classifyFull
(rasvector);
double bestScore = -Double.MAX_VALUE;
int bestCategoryId = -1;
for(Element element : predictionVector.all()) {
int categoryId = element.index();
double score = element.get();
if (score > bestScore) {
bestScore = score;
bestCategoryId = categoryId;
}
}
System.out.println();
String category = categoryMapping.get(labels.get(bestCategoryId));
if (category == null) category = "未知";
System.out.println(String.format("预测的分类为:%s", category));
System.out.println();
}
ikanalyzer.close();
}
代码的最后一步,Mahout将计算输入文本属于不同分类的概率。其中需要注意的是,Mahout对概率值进行了一定的数值转换,也就是将它们转变为了一个负数,数值越大,表示概率越高。所以,这段代码找出了拥有最大值的分类。如果是多分类问题,可以取最大的n个数值及其对应的分类。
其他辅助的数据预加载函数如下:
public static Map<String, Integer> readDictionnary(Configuration conf, Path dictionnaryPath) {
Map<String, Integer> dictionnary = new HashMap<String, Integer>();
for (Pair<Text, IntWritable> pair : new SequenceFileIterable<Text,
IntWritable>(dictionnaryPath, true, conf)) {
dictionnary.put(pair.getFirst().toString(), pair.getSecond().get());
}
return dictionnary;
}
public static Map<Integer, Long> readDocumentFrequency(Configuration conf, Path
documentFrequencyPath) {
Map<Integer, Long> documentFrequency = new HashMap<Integer, Long>();
for (Pair<IntWritable, LongWritable> pair : new SequenceFileIterable<Int
Writable, LongWritable>(documentFrequencyPath, true, conf)) {
documentFrequency.put(pair.getFirst().get(), pair.getSecond().get());
}
return documentFrequency;
}
public static Map<String, String> loadCategoryMapping() {
Map<String, String> categoryMapping = new HashMap<String, String>();
categoryMapping.put("1", "饼干");
categoryMapping.put("2", "方便面");
categoryMapping.put("3", "海鲜水产");
categoryMapping.put("4", "进口牛奶");
categoryMapping.put("5", "纯牛奶");
categoryMapping.put("6", "巧克力");
categoryMapping.put("7", "饮料饮品");
categoryMapping.put("8", "坚果");
categoryMapping.put("9", "食用油");
categoryMapping.put("10", "枣类");
categoryMapping.put("11", "新鲜水果");
categoryMapping.put("12", "大米");
categoryMapping.put("13", "面粉");
categoryMapping.put("14", "手机");
categoryMapping.put("15", "电脑");
categoryMapping.put("16", "美发护发");
categoryMapping.put("17", "沐浴露");
categoryMapping.put("18", "茶叶");
return categoryMapping;
}
完整的代码和Maven项目文件,可以访问:
编译时,需要在pom.xml中加入Mahout和IKAnalyzer的依赖包:
<!-- https://mvnrepository.com/artifact/org.apache.mahout/mahout-core -->
<dependency>
<groupId>org.apache.mahout</groupId>
<artifactId>mahout-core</artifactId>
<version>0.9</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.janeluo/ikanalyzer -->
<dependency>
<groupId>com.janeluo</groupId>
<artifactId>ikanalyzer</artifactId>
<version>2012_u6</version>
</dependency>
编译成功并运行main函数,你就可以在Console窗口中输入一段文本,程序将实时给出分类的预测,如图1-20所示。最后输入“exit”并退出。
有了这些代码的基础,就可以为线上应用(例如,根据商家的输入为其实时推荐商品分类)构建预测模块、RESTFUL风格的API等。
1.6.6 多机环境使用Mahout运行朴素贝叶斯分类
Mahout最早是基于Hadoop开发的,当然也支持多机并行处理。需要注意的是,Hadoop的MapReduce计算模式只适用于批量的训练和评测,并不适用于实时的预测。关于离线批量处理和实时处理的更多探讨,可参见《大数据架构商业之路》一书的第4章。
1.Hadoop集群的安装和设置
在Mahout使用Hadoop之前,需要一步步地搭建Hadoop集群。用于本案例的硬件环境为三台苹果个人电脑,除了之前的iMac2015,还有两台MacBook Pro,代号分别为Mac-BookPro2012和MacBookPro2013,其大体配置分别如图1-21和图1-22所示。局域网也是需要的,三台机器分配的IP分别如下。
iMac2015 192.168.1.48
MacBookPro2013 192.168.1.28
MacBookPro2012 192.168.1.78
至于软件,由于所有的操作系统都是Mac OS,因此下面示例中的命令和路径都以Mac OS为准,请根据自己的需要进行适当调整。
首先在三台机器之间构建SSH的互信连接,在iMac2015上生成本台机器的公钥:
[huangsean@iMac2015:/Users/huangsean/Coding]ssh-keygen -t rsa
Generating public/private rsa key pair.
Enter fi le in which to save the key (/Users/huangsean/.ssh/id_rsa):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /Users/huangsean/.ssh/id_rsa.
Your public key has been saved in /Users/huangsean/.ssh/id_rsa.pub.
[huangsean@iMac2015:/Users/huangsean/Coding]more /Users/huangsean/.ssh/id_rsa.pub
...
然后将公钥发布到另外两台机器MacBookPro2012和MacBookPro2013上,此时还需要手动登录:
[huangsean@iMac2015:/Users/huangsean/Coding]scp ~/.ssh/id_rsa.pub huangsean@MacBookPro2012:~/master_key
[huangsean@iMac2015:/Users/huangsean/Coding]scp ~/.ssh/id_rsa.pub huangsean@MacBookPro2013:~/master_key
如果MacBookPro2012和MacBookPro2013上还没有~/.ssh目录,那么先创建该目录并设置合适的权限。而后,将iMac2015的公钥移动过去并命名为“authorized_keys”,下面以MacBookPro2012为例:
[huangsean@MacBookPro2012:/Users/huangsean/Coding]mkdir ~/.ssh
[huangsean@ MacBookPro2012:/Users/huangsean/Coding]chmod 700 ~/.ssh
[huangsean@ MacBookPro2012:/Users/huangsean/Coding]mv ~/master_key ~/.ssh/authorized_keys
[huangsean@ MacBookPro2012:/Users/huangsean/Coding]chmod 600 ~/.ssh/authorized_keys
如果MacBookPro2012和MacBookPro2013已有~/.ssh目录,那么将iMac2015的公钥移动过去并命名为“authorized_keys”。如果之前“authorized_keys”文件已经存在,那么将iMac2015的公钥移动附加在其后面。同样以MacBookPro2012为例:
[huangsean@ MacBookPro2012:/Users/huangsean/Coding]cat ~/master_key >> ~/.ssh/authorized_keys
这样,iMac2015就可以免密码SSH登录MacBookPro2012和MacBookPro2013了。然后如法炮制,让三台机器可以相互免密码登录。
下面,让我们进入Hadoop分布式环境搭建的正题。通过如下链接下载并解压Hadoop发行版,本文使用的版本是2.7.3:
http://hadoop.apache.org/releases.html
解压后,部署分布式Hadoop 2.x版的主要步骤具体如下。
1)为Hadoop设置正确的环境变量。
2)编辑一些重要的配置文件包括core-site.xml、hdfs-site.xml等。
3)一个容易被遗忘但是很关键的步骤:格式化名称节点。
4)运行start-dfs.sh来启动HDFS。
5)运行start-yarn.sh来启动MapReduce的作业调度。
下面分别来看看这些步骤。
首先,设置环境变量:
export HADOOP_HOME=/Users/huangsean/Coding/hadoop-2.7.3
export PATH=$PATH:$HADOOP_HOME/bin
export PATH=$PATH:$HADOOP_HOME/sbin
export HADOOP_MAPRED_HOME=$HADOOP_HOME
export HADOOP_COMMON_HOME=$HADOOP_HOME
export HADOOP_HDFS_HOME=$HADOOP_HOME
export HADOOP_YARN_HOME=$HADOOP_HOME
export YARN_HOME=$HADOOP_HOME
export HADOOP_COMMON_LIB_NATIVE_DIR=$HADOOP_HOME/lib/native
export HADOOP_OPTS="-Djava.library.path=$HADOOP_HOME/lib"
进入Hadoop主目录中的/etc/hadoop/,分别修改core-site.xml、hdfs-site.xml、mapred-site.xml、yarn-site.xml、slaves和hadoop-env.sh。
配置文件core-site.xml的示例如下:
<configuration>
<property>
<name>hadoop.tmp.dir</name>
<value>file:/Users/huangsean/Coding/hadoop-2.7.3/tmp</value>
<description>Abase for other temporary directories.</description>
</property>
<property>
<name>fs.defaultFS</name>
<value>hdfs://iMac2015:9000</value>
</property>
</configuration>
其中,hadoop.tmp.dir指定了HDFS数据存放的目录。而fs.defaultFS指定了命名节点(Name Node)的IP或名称(iMac2015),以及端口(9000),这点是非常重要的,稍后依赖HDFS的其他系统也都需要使用这个设置。
配置文件hdfs-site.xml的示例如下:
<configuration>
<property>
<name>dfs.replication</name>
<value>2</value>
</property>
<property>
<name>dfs.namenode.name.dir</name>
<value>file:/Users/huangsean/Coding/hadoop-2.7.3/tmp/dfs/name</value>
</property>
<property>
<name>dfs.datanode.data.dir</name>
<value>file:/Users/huangsean/Coding/hadoop-2.7.3/tmp/dfs/data</value>
</property>
<property>
<name>dfs.blocksize</name>
<value>124800000</value>
</property>
</configuration>
这里将副本数量replication设置为2,并设置命名节点(NameNode)和数据节点(DataNode)的目录来保存文件。另一个关键参数是块大小(blocksize)。由于HDFS擅长批处理,所以通常它需要较大的文件块。太多的小文件将影响Hadoop的性能。
配置文件mapred-site.xml的示例如下:
<configuration>
<property>
<name>mapreduce.framework.name</name>
<value>yarn</value>
</property>
<property>
<name>mapreduce.jobhistory.address</name>
<value>iMac2015:10020</value>
</property>
<property>
<name>mapreduce.jobhistory.webapp.address</name>
<value>iMac2015:19888</value>
</property>
</configuration>
从中你可以看到有关任务的设置,主要是用于跟踪MapReduce的计算任务。
配置文件yarn-site.xml的示例如下:
<configuration>
<!-- Site specific YARN configuration properties -->
<property>
<name>mapreduce.framework.name</name>
<value>yarn</value>
</property>
<property>
<name>yarn.resourcemanager.scheduler.address</name>
<value>iMac2015:8030</value>
</property>
<property>
<name>yarn.resourcemanager.resource-tracker.address</name>
<value>iMac2015:8031</value>
</property>
<property>
<name>yarn.resourcemanager.address</name>
<value>iMac2015:8032</value>
</property>
<property>
<name>yarn.resourcemanager.admin.address</name>
<value>iMac2015:8033</value>
</property>
<property>
<name>yarn.resourcemanager.webapp.address</name>
<value>iMac2015:8088</value>
</property>
<property>
<name>yarn.nodemanager.aux-services</name>
<value>mapreduce_shuffle</value>
</property>
<property>
<name>yarn.nodemanager.aux-service.mapreduce.shuffle.class</name>
<value>org.apache.hadoop.mapred.ShuffleHandler</value>
</property>
</configuration>
其中端口的默认配置通常都是可以正常工作的,请注意将主机IP地址或名称进行合理的替换。
配置文件slaves的示例如下:
iMac2015
MacBookPro2013
MacBookPro2012
由于硬件资源非常有限,因此这里使用了全部的三台机器作为slave。最后在hadoop-env.sh文件中,记得设置Java JDK的路径:
export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk1.8.0_112.jdk/Contents/Home
对于上述所有的配置文件,你可以通过下面的链接获取更多的参考:
在三台机器上完成上述配置后,在主节点iMac2015上通过如下命令格式化HDFS:
[huangsean@iMac2015:/Users/huangsean/Coding]hdfs namenode -format
然后在主节点上通过Hadoop sbin目录中的如下命令,分别启动Hadoop集群的HDFS和YARN管理:
[huangsean@iMac2015:/Users/huangsean/Coding]start-dfs.sh
[huangsean@iMac2015:/Users/huangsean/Coding]start-yarn.sh
如果要关闭集群,那就使用Hadoop sbin目录中的如下命令:
[huangsean@iMac2015:/Users/huangsean/Coding]stop-all.sh
集群成功启动之后,我们可以通过如下的链接来查看HDFS的整体状况:
http://imac2015:50070/dfshealth.html
图1-23展示了HDFS的概括,单击页面上的“Live Nodes”链接,可以进一步看到类似于图1-24的数据节点信息。
在图1-23的界面上点击“Utilities”选项卡的“Browse the f?ile system”选项,你还可以看到目前刚刚启动的HDFS中尚无数据,如图1-25所示。
而通过如下链接,你可以查看MapReduce的任务执行情况:
图1-26展示了目前集群中任务分配和执行的情况。
2.Hadoop所支持的训练过程
Hadoop集群部署完毕之后,我们就可以将Mahout所要使用的数据导入HDFS了。首先是创建相应的目录:
[huangsean@iMac2015:/Users/huangsean/Coding]hadoop fs -mkdir -p /data/BigData
ArchitectureAndAlgorithm
如图1-27所示,新目录在HDFS中创建成功。
然后将实验数据复制到HDFS的新目录中:
[huangsean@iMac2015:/Users/huangsean/Coding]hadoop fs -put -p /Users/huangsean/Coding/data/BigDataArchitectureAndAlgorithm/listing-segmented-shuffled-mahout /data/BigDataArchitectureAndAlgorithm/
其过程较慢,主要原因是一共有超过28?000个商品文件,而且每个文件的内容都只是商品的标题。这可能证明了HDFS不太善于处理大量小文件。最终的结果类似于图1-28的截屏。
Mahout的部署和设置也要做稍许修改。由于Hadoop是2.7.3的版本,所以需要将Mahout的版本切换到和该Hadoop兼容的0.12.2,并设置相应的环境变量。需要注意的是,为了确保Mahout是在Hadoop上进行并行处理的,此处不能再设置MAHOUT_LOCAL的变量:
export MAHOUT_HOME=/Users/huangsean/Coding/apache-mahout-distribution-0.12.2
export PATH=$PATH:$MAHOUT_HOME/bin
之后的步骤和单机版的相似,不过要使用HDFS中的路径。首先是获取序列文件:
[huangsean@iMac2015:/Users/huangsean/Coding]mahout seqdirectory -i /data/BigData
ArchitectureAndAlgorithm/listing-segmented-shuffled-mahout/ -o /data/BigDataArchitecture
AndAlgorithm/listing-segmented-shuffled-seq -ow
Running on hadoop, using /Users/huangsean/Coding/hadoop-2.7.3/bin/hadoop and HAD
OOP_CONF_DIR=
MAHOUT-JOB: /Users/huangsean/Coding/apache-mahout-distribution-0.12.2/mahout-
examples-0.12.2-job.jar
...
17/01/18 23:11:24 INFO MahoutDriver: Program took 177910 ms (Minutes: 2.965
1666666666667)
如图1-29所示,序列文件的结果被存储于HDFS之中。而从图1-30中以看出,Mahout刚刚在Hadoop上启动了MapReduce的任务。
将序列文件转为向量文件:
[huangsean@iMac2015:/Users/huangsean/Coding]mahout seq2sparse -i /data/BigData
ArchitectureAndAlgorithm/listing-segmented-shuffled-seq/part-m-00000 -o /data/Big
DataArchitectureAndAlgorithm/listing-segmented-shuffled-vec -lnorm -nv -wt tf -a
org.apache.lucene.analysis.core.WhitespaceAnalyzer
Running on hadoop, using /Users/huangsean/Coding/hadoop-2.7.3/bin/hadoop and
HADOOP_CONF_DIR=
MAHOUT-JOB: /Users/huangsean/Coding/apache-mahout-distribution-0.12.2/mahout-
examples-0.12.2-job.jar
...
17/01/18 23:38:19 INFO MahoutDriver: Program took 1181880 ms (Minutes: 19.698)
切分训练样本和测试样本集合,训练集仍占全体数据的90%,剩下的10%作为待测集:
[huangsean@iMac2015:/Users/huangsean/Coding]mahout split -i /data/BigDataArchitecture
AndAlgorithm/listing-segmented-shuffled-vec/tf-vectors --trainingOutput /data/Big
DataArchitectureAndAlgorithm/listing-segmented-shuffled-train --testOutput /data/Big
DataArchitectureAndAlgorithm/listing-segmented-shuffled-test --randomSelectionPct 10 --overwrite --sequenceFiles -xm sequential
Running on hadoop, using /Users/huangsean/Coding/hadoop-2.7.3/bin/hadoop and
HADOOP_CONF_DIR=
MAHOUT-JOB: /Users/huangsean/Coding/apache-mahout-distribution-0.12.2/mahout-
examples-0.12.2-job.jar
...
17/01/18 23:43:19 INFO MahoutDriver: Program took 1259 ms (Minutes: 0.0209833333
33333333)
并行地训练朴素贝叶斯模型:
[huangsean@iMac2015:/Users/huangsean/Coding]mahout trainnb -i /data/BigData
ArchitectureAndAlgorithm/listing-segmented-shuffled-train -o /data/BigDataArchitecture
AndAlgorithm/listing-segmented-shuffled-model -li /data/BigDataArchitectureAnd
Algorithm/listing-segmented-shuffled-mahout-labelindex -ow
Running on hadoop, using /Users/huangsean/Coding/hadoop-2.7.3/bin/hadoop and
HADOOP_CONF_DIR=
MAHOUT-JOB: /Users/huangsean/Coding/apache-mahout-distribution-0.12.2/mahout-
examples-0.12.2-job.jar
...
17/01/18 23:55:31 INFO MahoutDriver: Program took 371944 ms (Minutes: 6.19906666
6666667)
测试训练后的贝叶斯模型:
[huangsean@iMac2015:/Users/huangsean/Coding]mahout testnb -i /data/BigData
ArchitectureAndAlgorithm/listing-segmented-shuffled-test -m /data/BigDataArchitecture
AndAlgorithm/listing-segmented-shuffled-model -l /data/BigDataArchitectureAndAlgorithm/
listing-segmented-shuffled-mahout-labelindex -ow -o /data/BigDataArchitecture
AndAlgorithm/listing-segmented-shuffled-mahout-results
Running on hadoop, using /Users/huangsean/Coding/hadoop-2.7.3/bin/hadoop and
HADOOP_CONF_DIR=
MAHOUT-JOB: /Users/huangsean/Coding/apache-mahout-distribution-0.12.2/mahout-
examples-0.12.2-job.jar
...
=======================================================
Statistics
-------------------------------------------------------
Kappa 0.9581
Accuracy 97.6308%
Reliability 89.9985%
Reliability (standard deviation) 0.2536
Weighted precision 0.9779
Weighted recall 0.9763
Weighted F1 score 0.9757
17/01/18 23:59:14 INFO MahoutDriver: Program took 153815 ms (Minutes: 2.56358333
33333333)
如图1-31所示,我们可以在HDFS上找到整个过程所产生的各种数据。不过,你可能发现并非分布式计算就一定好。相对于之前的单机实验,多机的耗时明显更长了。其原因可能包括如下两点。
第一,测试数据规模太小,远远没有到达单机性能的瓶颈,分布式的协同和网络通信反而占用了更多的开销。
第二,小文件过多,这并非MapReduce计算模式的长处。
所以,在实际生产中,我们需要结合实际情况,进行具体分析,再定下最合适的技术方案。