利用Mallet进行文本挖掘—— 主题模型与垃圾邮件检测
目录
创建工程
文本挖掘简介
主题模型
文本分类
使用文本数据
导入数据
从目录导入
从文件导入数据
对文本数据做预处理
为 BBC 新闻做主题模型
BBC 数据
建模
评估模型
重用模型
保存模型
恢复模型
垃圾邮件检测
垃圾邮件数据集
特征生成
训练与测试模型
模型性能
完整代码
TxtFilter
TopicModeling
SpamDetector
本章先讨论文本挖掘的定义以及可以进行的分析,以及为何将其应用于应用程序。再讨论如 何使用Mallet——一个处理自然语言的Java库,包括数据导入与文本预处理。然后了解文本挖掘 的两个具体应用:主题模型与垃圾邮件检测,在主题模型中,我们将讨论如何使用文本挖掘技术 从不曾阅读过的文本文档中识别主题;在垃圾邮件检测中,我们将讨论如何自动为文本文档进行 分类。
本章内容涵盖如下主题:
文本挖掘简介
安装与使用Mallet
主题模型
垃圾邮件检测
创建工程
接着前面的项目,导入pom依赖和数据:
<dependency>
<groupId>mallet</groupId>
<artifactId>mallet</artifactId>
<version>1.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/lib/mallet.jar</systemPath>
</dependency>
<dependency>
<groupId>mallet</groupId>
<artifactId>mallet-deps</artifactId>
<version>1.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/lib/mallet-deps.jar</systemPath>
</dependency>
文本挖掘简介
文本挖掘也叫文本分析,指的是从文本文档自动提取高质量信息的过程。这些文本文档大部 分是使用自然语言写成的,高质量信息是那些紧密相关、新颖又有趣的信息。
一个典型的文本分析应用是扫描一组文档产生搜索索引,文本挖掘可以应用到其他许多领 域,包括文本分类(分类到特定域)、文本聚类(自动组织一组文档)、情绪分析(识别与提取文 档中的主观信息)、概念/实体提取(可以从文档中识别人、地点、组织、其他实体)、文档摘要 (自动给出源文档中最重要的观点)、学习命名实体间的关系。
基于统计模式挖掘的过程通常包含如下步骤:
(1) 信息检索与提取;
(2) 将无结构文本数据转换为有结构数据,比如分析、移除噪声单词、词汇分析、计算词频、撷取语言特征等;
(3) 从结构化数据与标注/注释发现模式;
(4) 评价与解释结果。
本章后半部分将学习文本挖掘的两个应用:主题模型与文本分类。接下来,先看看它们能做什么。
主题模型
主题模型是一种无监督技术,如果你需要分析一个包含大量文本文档的档案,希望了解该档案 包含的内容,但又不想亲自阅读每个文档,这时主题模型就很有用。文本文档可以是博客文章、电 子邮件、推文、文件、图书章节、日记等。主题模型在文本语料库中寻找模式,更准确地说,它采 用一种有统计意义的方式识别主题,并组成单词表。最有名的算法是隐含狄利克雷分布(Latent Dirichlet Allocation,Blei等,2003)它假设作者从可能的单词篮中选择单词并组成一段文字,每个篮子对应一个主题。借助这个假设,这个算法可以从数学上把文本拆解到相应篮子(baskets), 这些篮子是拆解后的单词最有可能的来源。然后算法不断迭代这个过程,直到它将所有词分配到最 有可能的篮子,我们把这些篮子叫作“主题”
比如,如果针对一系列新闻文章使用主题模型,算法将会返回一系列主题以及最有可能组成 这些主题的关键字。
借用新闻文章的例子,列表看起来可能像下面这样:
Winner(获胜者)、goal(射门)、football(足球)、score(得分)、first place(第一名)
Company(公司)、stocks(股票)、bank(银行)、credit(信用)、business(生意)
Election(选举)、opponent(对手)、president(总统)、debate(辩论)upcoming(即将 来临)
通过浏览关键字,我们能够知道这些新闻文章与体育、商业、即将到来的选举有关。本章后 半部分将通过这个新闻文章的例子学习实现主题模型的方法。
文本分类
文本分类的目标是根据文本文档的内容,将一个文本文档划入一个或多个分类。这些分类往 往是一个更普通的主题,比如车辆、宠物。这些普通的类别就是主题,分类任务也叫文本分类、 主题分类、主题发现。虽然我们可以根据文档类型、作者、印刷年份等属性对文档进行分类,但 本章学习的重点是只根据文档内容进行分类。文本分类的例子如下。
对电子邮件、用户评论、网页等垃圾内容的检测
色情内容检测
情绪检测:自动将用户对一个产品或服务的评论划分为正面评论与负面评论
根据电子邮件内容对电子邮件进行分类
专题搜索:搜索引擎将搜索限制到某个特定主题或类型以提供更准确的结果
这些例子表明文本分类在信息检索系统中非常重要,因此大部分现代信息检索系统都在使用 某种类型的文本分类器。本书所用的分类任务的例子是通过文本分类检测垃圾邮件。
本章还会介绍Mallet,它是一个Java包,用于执行自然语言统计处理、文档分类、聚类、主 题模型、信息提取,以及其他针对文本的机器学习应用。然后学习文本分析(文本分类)的两个 应用:主题模型与垃圾邮件检测。
使用文本数据
文本挖掘的主要挑战之一是,将没有结构的自然语言转换为结构化的基于属性的实例。这个过程包含许多步骤,如图
首先,从网络、现有文档或数据库提取一些文本。在第一步的最后,文本仍然是XML格式 或其他某些专用格式。因此,接下来的一步是提取实际文本,并将其划分到文档的各个部分,比 如题目、大标题、摘要、正文等。第三步是规范文本编码,保证字符用相同方式表示,比如将 ASCII、ISO 8859-1、Windows-1250编码格式的文档转换为Unicode编码。接着,通过断词将文档 分割为特定词,然后移除那些具有较低预测能力的常用词,比如the、a、I、We等。
词性(POS)标注与词形还原通过移除词尾和修饰符,将每个标记(即单词)转换为其基本 形式,即词根,比如将running变化为run、将better变为good等。一个简化的方法是词干提取,它 针对的是单个词。关于如何使用这个特定的词,没有任何上下文,因此它不能区分带有不同含义 的词,而主要依赖于词性,比如axes既是axe的复数,也是axis的复数。
最后一步是将标记转换为特征空间。通常,特征空间是一个词袋(BoW)表示。这个表示中, 出现在数据集中的所有单词组都会被创建,即词袋。每个文档表示为一个向量,记录某个特定单 词在文档中出现的次数。
请看下面两个句子:
Jacob likes table tennis. Emma likes table tennis too.(雅各布喜欢乒乓球。艾玛也喜欢乒乓球)
Jacob also likes basketball.(雅各布也喜欢篮球。)
这个例子中,词袋为 { Jacob, likes, table, tennis, Emma, too, also, basketball },包含8个不同单词。
现在,可以使用列表索引将这两个句子表示为向量,表示 文档中的一个单词在特定索引位置出现的次数,如下:
[1, 2, 2, 2, 1, 0, 0, 0]
[1, 1, 0, 0, 0, 0, 1, 1]
最后,将这样的向量变成实例,以做进一步学习。
导入数据
本章不会学习如何从网站抓取一组文档或者从数据库提取它们,此处假设已经收集好了这 组文档,并将其保存在.txt文件中。接下来,了解一下加载它们时的两种情况:第一种情况是 每个文档存储在各自的.txt文件中;另一种情况是所有文档都存在一个文件中,每行就是一个 文档。
从目录导入
Mallet提供了cc.mallet.pipe.iterator. FileIterator类,支持从路径读取文件。文 件迭代器带有如下3个参数:
包含文本文件的File[]目录列表
文件过滤器,用于指定选择目录中的哪些文件
要应用到文件名的模式,用于产生一个类标签
图 1显示了文件夹中的数据文件,这些文档按文件夹组织成 5 个主题( tech 、 entertainment、politics、sport、business)。
每个文件夹包含与特定主题相关的文档。
此情形下,初始化iterator
// 创建一个FileIterator,用于遍历指定文件夹中的文本文件
FileIterator folderIterator = new FileIterator(new File[] {new File(dataFolderPath)}, new TxtFilter(), FileIterator.LAST_DIRECTORY);
第一个参数指定根文件夹的路径,第二个参数将迭代器限制在.txt文件上,最后一个参数 让方法将路径中的最后目录名用作类标签。
从文件导入数据
加载文档的另外一种方法是使用cc.mallet.pipe.iterator.CsvIterator.CsvIterator (Reader, Pattern, int, int, int),它假定所有文档位于一个文件,每行返回一个实例, 通过一个正则表达式进行提取。初始化这个类时,需要提供如下参数。
Reader:这个对象指定如何从文件读取数据。
Pattern:这是一个正则表达式,提取3个组:数据、目标标签、文档名。
int, int, int:这些是数据、目标、名称组的索引,它们出现在上面的正则表达式中。
假设有一个文本文档,它有指定的文档名、分类、内容,格式如下
AP881218 local-news A 16-year-old student at a private Baptist...
AP880224 business The Bechtel Group Inc. offered in 1985 to...
AP881017 local-news A gunman took a 74-year-old woman hostage...
AP900117 entertainment Cupid has a new message for lovers this...
AP880405 politics The Reagan administration is weighing w...
使用如下正则表达式,将文档的每一行解析成3个组:
^(\\S*)[\\s,]*(\\S*)[\\s,]*(.*)$
有3个组出现在圆括号()中,其中第三组包含数据,第二组包含目标类别,第一组包含文档 ID。
迭代器初始化如下
CsvIterator iterator = new CsvIterator ( fileReader, Pattern.compile("^(\\S*)[\\s,]*(\\S*)[\\s,]*(.*)$"), 3, 2, 1));
上面代码中,正则表达式用于提取3个组,采用空格分隔,它们的顺序为3、2、1。
接下来进入数据预处理流程。
对文本数据做预处理
对遍历数据的迭代器做好初始化后,需要对数据做一系列变换,这在开头部分已经提到过。 对此,Mallet提供了相应的处理流程,其中包含各种步骤,在cc.mallet.pipe包中可以找到它 们。下面给出了一些例子。
Input2CharSequence :从各种文本源( URI 、 File 、 Reader )读取数据,并转换为 CharSequence。
CharSequenceRemoveHTML:从CharSequence移走HTML。
MakeAmpersandXMLFriendly:将符号序列的标记中的&转换为&。
TokenSequenceLowercase:将数据域符号序列中每个标记的文本转换成小写。
TokenSequence2FeatureSequence:将每个实例数据域中的符号序列转换为特征序列。
TokenSequenceNGrams:将数据域中的符号序列转换为带标记的ngrams序列,即两个或 更多个单词组合。
接下来,创建用于导入数据的类。
首先创建一个管道(pipeline),每个处理步骤对应于Mallet中的一个管道。可以用串行方式 把管道连接起来,形成ArrayList<Pipe>对象列表。
先从一个文件对象读取数据,将所有字符转换为小写
接下来,使用正则表达式对原始字符串进行标记化。下面模式包括Unicode字母、数字以及 下划线字符
使用标准的英文停止词表,移走停止词,即那些不具预测能力的高频词。其他两个参数分别 指定移走停止词时是否区分大小写,以及删除单词后是否标记。将这两个参数全部设置为false
我们不会保存实际单词,而将它们变成整数,表示单词在词袋中的索引。
对于类标签做同样处理,即不用标签字符串而使用一个整数,指示标签在词袋中的位置。
可以通过调用PrintInputAndTarget打印特征与标签。
最后,将管道列表存储到SerialPipes类,这个类通过一系列管道转换实例。
// 创建一个管道列表,用于定义数据处理步骤
List<Pipe> pipeList = new ArrayList<Pipe>();
// 将输入转换为CharSequence,使用UTF-8编码
pipeList.add(new Input2CharSequence(CharsetUtil.UTF_8));
// 定义分词模式,匹配字母、数字和下划线
Pattern tokenPattern = Pattern.compile("[\\p{L}\\p{N}_]+");
// 将CharSequence转换为TokenSequence,使用定义的分词模式
pipeList.add(new CharSequence2TokenSequence(tokenPattern));
// 将TokenSequence中的词语转换为小写
pipeList.add(new TokenSequenceLowercase());
// 去除停用词,使用指定的停用词文件
pipeList.add(new TokenSequenceRemoveStopwords(new File(stopListFilePath), CharsetUtil.UTF_8, false, false, false));
// 将TokenSequence转换为FeatureSequence
pipeList.add(new TokenSequence2FeatureSequence());
// 将目标标签转换为Label
pipeList.add(new Target2Label());
// 创建一个SerialPipes对象,将管道列表串联起来
SerialPipes pipeline = new SerialPipes(pipeList);
接下来,看看如何将其应用到文本挖掘应用。
为 BBC 新闻做主题模型
如前所述,主题模型的目标是识别文本语料库(对应于文档主题)中的模式。这个例子中, 我们使用的数据集来自BBC新闻。这个数据集是机器学习研究中常用的基准测试数据集之一,仅 用于非商业与研究目的。
我们的目标是创建一个分类器,用它为未分类的文档指派一个标题。
BBC 数据
为了研究基于支持向量机的文档聚类问题,Greene与Cunningham(2006)采集了BBC新闻数据 并制成BBC数据集。这个数据集包含2225个文档,它们全部来自BBC新闻网站,时间跨度为2004~2005 年,可划分为5个主题:商业、环境、政治、体育、技术。
建模
首先,导入数据集,并对文本做处理
然后,创建一个默认管道(如前所述)
接着,初始化folderIterator
新建实例列表,将我们想用于处理文本的管道传递给它
最后,处理迭代器给出的每个实例:
// 获取数据文件夹的路径
String dataFolderPath = ClassUtils.getDefaultClassLoader().getResource("data/test09/bbc").getPath();
// 获取停用词文件的路径
String stopListFilePath = ClassUtils.getDefaultClassLoader().getResource("data/test09/stoplists/en.txt").getPath();
// 创建一个管道列表,用于定义数据处理步骤
List<Pipe> pipeList = new ArrayList<Pipe>();
// 将输入转换为CharSequence,使用UTF-8编码
pipeList.add(new Input2CharSequence(CharsetUtil.UTF_8));
// 定义分词模式,匹配字母、数字和下划线
Pattern tokenPattern = Pattern.compile("[\\p{L}\\p{N}_]+");
// 将CharSequence转换为TokenSequence,使用定义的分词模式
pipeList.add(new CharSequence2TokenSequence(tokenPattern));
// 将TokenSequence中的词语转换为小写
pipeList.add(new TokenSequenceLowercase());
// 去除停用词,使用指定的停用词文件
pipeList.add(new TokenSequenceRemoveStopwords(new File(stopListFilePath), CharsetUtil.UTF_8, false, false, false));
// 将TokenSequence转换为FeatureSequence
pipeList.add(new TokenSequence2FeatureSequence());
// 将目标标签转换为Label
pipeList.add(new Target2Label());
// 创建一个SerialPipes对象,将管道列表串联起来
SerialPipes pipeline = new SerialPipes(pipeList);
// 创建一个FileIterator,用于遍历指定文件夹中的文本文件
FileIterator folderIterator = new FileIterator(new File[] {new File(dataFolderPath)}, new TxtFilter(), FileIterator.LAST_DIRECTORY);
// 创建一个InstanceList对象,用于存储处理后的实例,并指定使用的管道
InstanceList instances = new InstanceList(pipeline);
// 通过管道处理每个实例,并添加到InstanceList中
instances.addThruPipe(folderIterator);
下面使用cc.mallet.topics.ParallelTopicModel.ParallelTopicModel类(实现了一个简单的Latent Dirichlet Allocation模型)创建带有5个主题的模型。LDA是主题模型的通用方 法,它使用狄利克雷分布评估选定主题产生特定文档的可能性。本章不会涉及相关细节,感兴趣 的读者请参考D. Blei等人发表的原论文(2003)。请注意,LDA这个缩写还有可能指机器学习中 另外一种分类算法——线性判别分析(Linear Discriminant Analysis),但它们只是缩写一样,此 外再无共同之处。
对ParallelTopicModel类进行实例化时,有alpha与beta参数,基本含义如下。
高alpha值表示每个文档可能混合多个主题,不特指某一个主题;低alpha值使文档较少受 这些条件的约束,这意味着文档可能混合几个主题,也可能只有一个主题。
高beta值表示每个主题可能混合很多单词,不是特定某个单词;低beta值表示主题只混合 几个单词。 例子中,我们将这两个参数设置得小一些(alpha_t = 0.01,beta_w = 0.01)。
因为我们假设数 据集中的主题混合得不多,并且每个主题有很多单词
// 定义主题模型参数
int numTopics = 5;
// 创建一个ParallelTopicModel对象,使用指定的主题数量、alpha和beta参数
ParallelTopicModel model = new ParallelTopicModel(numTopics, 0.01, 0.01);
接下来,将实例添加到模型。由于我们使用的是并行实现,所以还要指定并行执行的线程数, 代码如下
// 添加实例到模型中
model.addInstances(instances);
// 设置并行采样器的数量
model.setNumThreads(4);
按照选定的迭代次数运行模型。每次迭代都是为了更好地评估内部LDA参数。测试时,我们 可以指定较少的迭代次数,比如50次;而实际应用中,通常将迭代测试设置为1000或2000次。 最后,调用void estimate()方法,实际创建一个LDA模型
// 设置迭代次数
model.setNumIterations(50);
// 运行模型估计
model.estimate();
模型输出结果如下
Mallet LDA: 5 topics, 3 topic bits, 111 topic mask
max tokens: 1823
total tokens: 430606
<10> LL/token: -9.60964
<20> LL/token: -9.21832
<30> LL/token: -9.09329
<40> LL/token: -9.02635
0 0.002 mr year labour election government 1 blair
1 0.002 mr people government told law public bbc
2 0.002 people mobile technology music users digital games
3 0.002 film year number show won music wales
4 0.002 game time year world back win 6
<50> LL/token: -8.97752
Total time: 3 seconds
LL/token指模型的对数相似度(log-likelihood)除以标记总数,表示数据与给定模型的相似程度,该值越大,表示模型品质越高。
输出也显示了描述每个主题的热门词汇,这些词汇与初始主题有很好的对应。
Topic 0: game, England, year, time, win, world, 6 → sport
Topic 1: year, 1, company, market, growth, economy, firm → finance
Topic 2: people, technology, mobile, mr, games, users, music → tech
Topic 3: film, year, music, show, awards, award, won → entertainment
Topic 4: mr, government, people, labor, election, party, blair → politics
其中,有些词汇意义不大,比如mr、1、6,我们可以将其放入停止词列表。此外,有些词出 现了两次,比如award与awards。之所以会出现这种情况是因为,我们没有使用任何词干分析器 与词形还原管道。
接下来,对模型的性能进行评估。
评估模型
统计上的主题模型拥有非监督特征,这使选择模型变得困难。对于某些应用,可能有一些非 本质的任务要处理,比如信息检索或文档分类,我们可以为其评估性能。但我们通常希望评估的 是模型概括主题的能力,而不是这些任务。
Wallach等人(2009)提出了一种衡量模型质量的方法,这种方法计算的是模型下抽取文档 的对数概率,将未见过的文档的可能性用来比较模型——可能性越高,表示模型越好。
首先,将文档分成训练集与测试集(即留存文档) ,把90%的文档用来训练, 10%的文档用来
测试。
接着,使用90%的文档重建模型
然后,初始化评价器MarginalProbEstimator,它实现了留存文档的Wallach对数概率
这个类实现了许多评价器,需要非常深厚的理论知识才能理解LDA方法的工作原理。我们从 中选择从左到右(left-to-right)的评价器,它的适用范围很广,比如文本挖掘、语音识别等。从 左到右的评价器实现为double evaluateLeftToRight方法,接收如下4个参数。
Instances heldOutDocuments:测试实例。
int numParticles:这个算法参数指从左到右的标记数量,默认值是10。
boolean useResampling:表示在从左到右的评价中是否对主题重采样。重采样会提 高准确度,但会导致文档长度呈指数增长。
PrintStream docProbabilityStream:这是个文件或stdout,我们将为每个文档推 算的对数概率写入其中。
运行评价器
// 将数据集分为训练集和测试集,90%用于训练,10%用于测试
InstanceList[] instanceSplit = instances.split(new Randoms(), new double[] {0.9, 0.1, 0.0});
// 使用训练集重新添加实例到模型
model.addInstances(instanceSplit[0]);
model.setNumThreads(4);
model.setNumIterations(50);
model.estimate();
// 获取概率估计器
MarginalProbEstimator estimator = model.getProbEstimator();
// 计算测试集的对数似然度
double loglike = estimator.evaluateLeftToRight(instanceSplit[1], 10, false, null);
System.out.println("Total log likelihood: " + loglike);
我们的例子中,评价器输出如下对数似然值。与使用其他参数、流水线或数据创建的模型进 行比较时,这个值才有意义。对数似然值越高,模型越好
Evaluation
max tokens: 1823
total tokens: 815785
<10> LL/token: -8.69718
<20> LL/token: -8.62865
<30> LL/token: -8.6013
<40> LL/token: -8.58294
0 0.002 year 1 market economy company mr growth
1 0.002 mr government people labour election party blair
2 0.002 people mobile technology music games users mr
3 0.002 film year music awards show award won
4 0.002 game england year time win world back
<50> LL/token: -8.56848
Total time: 2 seconds
Topic Evaluator: 5 topics, 3 topic bits, 111 topic mask
Total log likelihood: -377363.43031867297
Disconnected from the target VM, address: '127.0.0.1:53363', transport: 'socket'
Process finished with exit code 0
接下来,看看如何使用这个模型。
重用模型
我们通常不会在运行中创建模型,更常见的做法是,训练一次模型,然后重复用它对新数据 做分类。
请注意,如果你打算对新文档做分类,它们也需要像其他文档一样通过一样的流水线——对 于训练与分类,管道需要是一样的。训练期间,管道数据字母表随每个训练实例更新。如果你使用相同步骤创建了一个新管道,那么不会得到相同的流水线,因为它的数据字母表是空的。因此, 为了将模型应用到新数据,要随模型一起保存/加载管道,并且使用这个管道添加新实例。
保存模型
Mallet提供了一个标准方法,它基于序列化保存与恢复对象。新建一个ObjectOutputStream 类的实例,然后将实例对象写入文件
// 保存模型和管道到文件
String modelPath = "myTopicModel";
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(new File(modelPath + ".model")));
oos.writeObject(model);
oos.close();
oos = new ObjectOutputStream(new FileOutputStream(new File(modelPath + ".pipeline")));
oos.writeObject(pipeline);
oos.close();
恢复模型
相对于通过序列化保存模型,使用ObjectInputStream类恢复模型是一个相反操作
// 显示第一个实例的词语和主题分配
Alphabet dataAlphabet = instances.getDataAlphabet();
FeatureSequence tokens = (FeatureSequence) model.getData().get(0).instance.getData();
LabelSequence topics = model.getData().get(0).topicSequence;
Formatter out = new Formatter(new StringBuilder(), Locale.US);
for (int position = 0; position < tokens.getLength(); position++) {
out.format("%s-%d ", dataAlphabet.lookupObject(tokens.getIndexAtPosition(position)), topics.getIndexAtPosition(position));
}
System.out.println(out);
// 获取第一个文档的主题分布
double[] topicDistribution = model.getTopicProbabilities(0);
// 获取每个主题中词语的排序列表
ArrayList<TreeSet<IDSorter>> topicSortedWords = model.getSortedWords();
// 显示每个主题的前5个高频词语及其在第一个文档中的主题分布
for (int topic = 0; topic < numTopics; topic++) {
Iterator<IDSorter> iterator = topicSortedWords.get(topic).iterator();
out = new Formatter(new StringBuilder(), Locale.US);
out.format("%d\t%.3f\t", topic, topicDistribution[topic]);
int rank = 0;
while (iterator.hasNext() && rank < 5) {
IDSorter idCountPair = iterator.next();
out.format("%s (%.0f) ", dataAlphabet.lookupObject(idCountPair.getID()), idCountPair.getWeight());
rank++;
}
System.out.println(out);
}
上面讨论了如何创建一个LDA模型,并根据各主题自动对文档进行分类。接下来的例子中, 我们将学习另外一个文本挖掘问题——文本分类。
垃圾邮件检测
垃圾信息主要指那些未经用户同意发送来的信息,这些信息包含广告内容、受病毒感染的附 件以及跳转到钓鱼网站或恶意网站的链接等。最常见的垃圾信息是垃圾邮件,除此之外,垃圾信 息还出现在其他各种地方,比如网站评论区、即时信息、网络论坛、博客、在线广告等。
接下来,我们将讨论如何创建朴素贝叶斯垃圾邮件过滤器,使用词袋描述识别垃圾邮件。朴 素贝叶斯垃圾邮件过滤器是基本技术之一,它在第一批商业垃圾邮件过滤器中被实现,比如 Mozilla Thunderbird邮件客户端就使用了这样的过滤器实现。虽然我们列举的是过滤垃圾邮件的 例子,但这些方法也可以用于过滤其他类型的文本垃圾。
垃圾邮件数据集
Androutsopoulos等人(2000)收集并制作了第一批垃圾邮件数据集之一,他们使用它测试垃 圾邮件过滤算法。这些人研究如何使用朴素贝叶斯分类器检测垃圾邮件,并且研究使用其他管道 (比如停用词表、词干分析器、词形还原)是否有利于提高过滤器的性能。 Andrew Ng 在 OpenClassroom 的 机器学习课堂上对数据集进行重新组织
特征生成
根据前面的讲解,创建一个默认流水线(pipeline)。
请注意,我们额外添加了一个FeatureSequence2FeatureVector管道,将特征序列转换 为特征向量。特征向量中有数据时,可以使用前面学习的任何一种分类算法。接下来继续我们的 例子,演示如何在Mallet中创建一个分类模型。
接着,初始化一个文件夹迭代器,加载train文件夹中的样例。train文件夹包含spam与 nonspam两个子文件夹,里面包含电子邮件样例,文件夹用作样例标签
使用流水线新建实例列表,用于处理文本
最后,处理迭代器提供的每一个实例
// 创建一个管道列表,用于处理文本数据
List<Pipe> pipeList = new ArrayList<Pipe>();
// 将输入转换为字符序列,并指定字符集为 UTF-8
pipeList.add(new Input2CharSequence(CharsetUtil.UTF_8));
// 使用正则表达式将字符序列分割为单词序列
Pattern tokenPattern = Pattern.compile("[\\p{L}\\p{N}_]+");
pipeList.add(new CharSequence2TokenSequence(tokenPattern));
// 将单词序列转换为小写
pipeList.add(new TokenSequenceLowercase());
// 移除停用词,使用指定的停用词列表文件
pipeList.add(new TokenSequenceRemoveStopwords(new File(stopListFilePath), CharsetUtil.UTF_8, false, false, false));
// 将单词序列转换为特征序列
pipeList.add(new TokenSequence2FeatureSequence());
// 将特征序列转换为特征向量
pipeList.add(new FeatureSequence2FeatureVector());
// 将标签转换为分类标签
pipeList.add(new Target2Label());
// 创建一个串行管道,包含上述所有处理步骤
SerialPipes pipeline = new SerialPipes(pipeList);
// 创建文件迭代器,用于遍历训练数据文件夹中的文件
FileIterator folderIterator = new FileIterator(new File[]{new File(dataFolderPath)}, new TxtFilter(), FileIterator.LAST_DIRECTORY);
// 创建实例列表,用于存储处理后的训练数据
InstanceList instances = new InstanceList(pipeline);
// 通过管道处理训练数据并添加到实例列表中
instances.addThruPipe(folderIterator);
现在已经加载好数据,并且转换为特征向量。接下来,使用训练集训练模型,并在测试集test 上做预测分类——spam/nonspam。
训练与测试模型
Mallet实现了一组分类器,它们位于cc.mallet.classify包,包括决策树、朴素贝叶斯、 AdaBoost、bagging、boosting等。这里只讲一个基本的分类器——朴素贝叶斯分类器。先通过 ClassifierTrainer类初始化分类器,再调用它的train(Instances)方法,即会返回分类器。
// 创建分类器训练器,使用朴素贝叶斯算法
ClassifierTrainer classifierTrainer = new NaiveBayesTrainer();
// 训练分类器
Classifier classifier = classifierTrainer.train(instances);
接下来,了解一下这个分类器是如何工作的,以及如何在单独的数据集上评价其性能。
模型性能
在单独的数据集上评价分类器之前,先导入test文件夹中的电子邮件
通过训练期间初始化的流水线传递数据
为了评价分类器性能,我们将用到cc.mallet.classify.Trial类,并且使用分类器与一 系列测试样例对其进行初始化
初始化后立即执行评估。最后,可以只打印自己关心的指标。我们的例子中,我们想了解垃 圾邮件信息分类的准确率与召回率,或者F值——返回两个值的调和平均数
// 创建实例列表,用于存储处理后的测试数据
InstanceList testInstances = new InstanceList(classifier.getInstancePipe());
// 创建文件迭代器,用于遍历测试数据文件夹中的文件
folderIterator = new FileIterator(new File[]{new File(testFolderPath)}, new TxtFilter(), FileIterator.LAST_DIRECTORY);
// 通过管道处理测试数据并添加到实例列表中
testInstances.addThruPipe(folderIterator);
// 创建试验对象,用于评估分类器在测试数据上的性能
Trial trial = new Trial(classifier, testInstances);
// 输出分类器的准确率
System.out.println("Accuracy: " + trial.getAccuracy());
// 输出类别 "spam" 的 F1 值
System.out.println("F1 for class 'spam': " + trial.getF1("spam"));
// 输出类别 1 的精确度
System.out.println("Precision for class '" + classifier.getLabelAlphabet().lookupLabel(1) + "': " + trial.getPrecision(1));
// 输出类别 1 的召回率
System.out.println("Recall for class '" + classifier.getLabelAlphabet().lookupLabel(1) + "': " + trial.getRecall(1));
评估结果输出如下
Accuracy: 0.9730769230769231
F1 for class 'spam': 0.9731800766283524
Precision for class 'spam': 0.9694656488549618
Recall for class 'spam': 0.9769230769230769
从上述结果可以看到,模型发现垃圾信息的准确率是97.69%(recall);标记为垃圾邮件的电 子邮件中,准确率是96.94%。换言之,每100封垃圾邮件中,大约错判2个;每100封合法邮件中, 有3封被错判为垃圾邮件。虽然结果并不十分完美,但却是一个很好的开始!
完整代码
TxtFilter
/**
* This class illustrates how to build a simple file filter
*/
public class TxtFilter implements FileFilter {
/**
* Test whether the string representation of the file
* ends with the correct extension. Note that {@ref FileIterator}
* will only call this filter if the file is not a directory,
* so we do not need to test that it is a file.
*/
public boolean accept(File file) {
return file.toString().endsWith(".txt");
}
}
TopicModeling
public class TopicModeling {
public static void main(String[] args) throws Exception {
// 获取数据文件夹的路径
String dataFolderPath = ClassUtils.getDefaultClassLoader().getResource("data/test09/bbc").getPath();
// 获取停用词文件的路径
String stopListFilePath = ClassUtils.getDefaultClassLoader().getResource("data/test09/stoplists/en.txt").getPath();
// 创建一个管道列表,用于定义数据处理步骤
List<Pipe> pipeList = new ArrayList<Pipe>();
// 将输入转换为CharSequence,使用UTF-8编码
pipeList.add(new Input2CharSequence(CharsetUtil.UTF_8));
// 定义分词模式,匹配字母、数字和下划线
Pattern tokenPattern = Pattern.compile("[\\p{L}\\p{N}_]+");
// 将CharSequence转换为TokenSequence,使用定义的分词模式
pipeList.add(new CharSequence2TokenSequence(tokenPattern));
// 将TokenSequence中的词语转换为小写
pipeList.add(new TokenSequenceLowercase());
// 去除停用词,使用指定的停用词文件
pipeList.add(new TokenSequenceRemoveStopwords(new File(stopListFilePath), CharsetUtil.UTF_8, false, false, false));
// 将TokenSequence转换为FeatureSequence
pipeList.add(new TokenSequence2FeatureSequence());
// 将目标标签转换为Label
pipeList.add(new Target2Label());
// 创建一个SerialPipes对象,将管道列表串联起来
SerialPipes pipeline = new SerialPipes(pipeList);
// 创建一个FileIterator,用于遍历指定文件夹中的文本文件
FileIterator folderIterator = new FileIterator(new File[] {new File(dataFolderPath)}, new TxtFilter(), FileIterator.LAST_DIRECTORY);
// 创建一个InstanceList对象,用于存储处理后的实例,并指定使用的管道
InstanceList instances = new InstanceList(pipeline);
// 通过管道处理每个实例,并添加到InstanceList中
instances.addThruPipe(folderIterator);
// 定义主题模型参数
int numTopics = 5;
// 创建一个ParallelTopicModel对象,使用指定的主题数量、alpha和beta参数
ParallelTopicModel model = new ParallelTopicModel(numTopics, 0.01, 0.01);
// 添加实例到模型中
model.addInstances(instances);
// 设置并行采样器的数量
model.setNumThreads(4);
// 设置迭代次数
model.setNumIterations(50);
// 运行模型估计
model.estimate();
// 保存模型和管道到文件
String modelPath = "myTopicModel";
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(new File(modelPath + ".model")));
oos.writeObject(model);
oos.close();
oos = new ObjectOutputStream(new FileOutputStream(new File(modelPath + ".pipeline")));
oos.writeObject(pipeline);
oos.close();
System.out.println("Model saved.");
// 显示第一个实例的词语和主题分配
Alphabet dataAlphabet = instances.getDataAlphabet();
FeatureSequence tokens = (FeatureSequence) model.getData().get(0).instance.getData();
LabelSequence topics = model.getData().get(0).topicSequence;
Formatter out = new Formatter(new StringBuilder(), Locale.US);
for (int position = 0; position < tokens.getLength(); position++) {
out.format("%s-%d ", dataAlphabet.lookupObject(tokens.getIndexAtPosition(position)), topics.getIndexAtPosition(position));
}
System.out.println(out);
// 获取第一个文档的主题分布
double[] topicDistribution = model.getTopicProbabilities(0);
// 获取每个主题中词语的排序列表
ArrayList<TreeSet<IDSorter>> topicSortedWords = model.getSortedWords();
// 显示每个主题的前5个高频词语及其在第一个文档中的主题分布
for (int topic = 0; topic < numTopics; topic++) {
Iterator<IDSorter> iterator = topicSortedWords.get(topic).iterator();
out = new Formatter(new StringBuilder(), Locale.US);
out.format("%d\t%.3f\t", topic, topicDistribution[topic]);
int rank = 0;
while (iterator.hasNext() && rank < 5) {
IDSorter idCountPair = iterator.next();
out.format("%s (%.0f) ", dataAlphabet.lookupObject(idCountPair.getID()), idCountPair.getWeight());
rank++;
}
System.out.println(out);
}
// 模型评估
System.out.println("Evaluation");
// 将数据集分为训练集和测试集,90%用于训练,10%用于测试
InstanceList[] instanceSplit = instances.split(new Randoms(), new double[] {0.9, 0.1, 0.0});
// 使用训练集重新添加实例到模型
model.addInstances(instanceSplit[0]);
model.setNumThreads(4);
model.setNumIterations(50);
model.estimate();
// 获取概率估计器
MarginalProbEstimator estimator = model.getProbEstimator();
// 计算测试集的对数似然度
double loglike = estimator.evaluateLeftToRight(instanceSplit[1], 10, false, null);
System.out.println("Total log likelihood: " + loglike);
}
}
SpamDetector
public class SpamDetector {
public static void main(String[] args) {
// 获取停用词列表文件的路径
String stopListFilePath = ClassUtils.getDefaultClassLoader().getResource("data/test09/stoplists/en.txt").getPath();
// 获取训练数据文件夹的路径
String dataFolderPath = ClassUtils.getDefaultClassLoader().getResource("data/test09/ex6DataEmails/train").getPath();
// 获取测试数据文件夹的路径
String testFolderPath = ClassUtils.getDefaultClassLoader().getResource("data/test09/ex6DataEmails/test").getPath();
// 创建一个管道列表,用于处理文本数据
List<Pipe> pipeList = new ArrayList<Pipe>();
// 将输入转换为字符序列,并指定字符集为 UTF-8
pipeList.add(new Input2CharSequence(CharsetUtil.UTF_8));
// 使用正则表达式将字符序列分割为单词序列
Pattern tokenPattern = Pattern.compile("[\\p{L}\\p{N}_]+");
pipeList.add(new CharSequence2TokenSequence(tokenPattern));
// 将单词序列转换为小写
pipeList.add(new TokenSequenceLowercase());
// 移除停用词,使用指定的停用词列表文件
pipeList.add(new TokenSequenceRemoveStopwords(new File(stopListFilePath), CharsetUtil.UTF_8, false, false, false));
// 将单词序列转换为特征序列
pipeList.add(new TokenSequence2FeatureSequence());
// 将特征序列转换为特征向量
pipeList.add(new FeatureSequence2FeatureVector());
// 将标签转换为分类标签
pipeList.add(new Target2Label());
// 创建一个串行管道,包含上述所有处理步骤
SerialPipes pipeline = new SerialPipes(pipeList);
// 创建文件迭代器,用于遍历训练数据文件夹中的文件
FileIterator folderIterator = new FileIterator(new File[]{new File(dataFolderPath)}, new TxtFilter(), FileIterator.LAST_DIRECTORY);
// 创建实例列表,用于存储处理后的训练数据
InstanceList instances = new InstanceList(pipeline);
// 通过管道处理训练数据并添加到实例列表中
instances.addThruPipe(folderIterator);
// 创建分类器训练器,使用朴素贝叶斯算法
ClassifierTrainer classifierTrainer = new NaiveBayesTrainer();
// 训练分类器
Classifier classifier = classifierTrainer.train(instances);
// 创建实例列表,用于存储处理后的测试数据
InstanceList testInstances = new InstanceList(classifier.getInstancePipe());
// 创建文件迭代器,用于遍历测试数据文件夹中的文件
folderIterator = new FileIterator(new File[]{new File(testFolderPath)}, new TxtFilter(), FileIterator.LAST_DIRECTORY);
// 通过管道处理测试数据并添加到实例列表中
testInstances.addThruPipe(folderIterator);
// 创建试验对象,用于评估分类器在测试数据上的性能
Trial trial = new Trial(classifier, testInstances);
// 输出分类器的准确率
System.out.println("Accuracy: " + trial.getAccuracy());
// 输出类别 "spam" 的 F1 值
System.out.println("F1 for class 'spam': " + trial.getF1("spam"));
// 输出类别 1 的精确度
System.out.println("Precision for class '" + classifier.getLabelAlphabet().lookupLabel(1) + "': " + trial.getPrecision(1));
// 输出类别 1 的召回率
System.out.println("Recall for class '" + classifier.getLabelAlphabet().lookupLabel(1) + "': " + trial.getRecall(1));
}
}