本节书摘来异步社区《机器学习项目开发实战》一书中的第2章,第2.6节,作者:【美】Mathias Brandewinder(马蒂亚斯·布兰德温德尔),更多章节内容可以访问云栖社区“异步社区”公众号查看
2.6 改进分类器
现在我们已经有了基准—— 低于84.4%是糟糕的结果,至少要超过87.7%。是时候观察使用可以*支配的两大手段(标记化程序和分类器选用的标记)能达到什么效果了。
2.6.1 使用每个单词
利用一个单词,我们就能将分类器的正确率从84.8%提升到87.7%。如果使用每个可用的标记代替单个词汇,预测正确率当然应该大幅提高。我们来尝试一下这个思路,首先,必须从训练集中提取每个标记。我们编写一个vocabulary函数,对每个文档应用标记化程序,将标记合并为单一集合,实现上述功能。然后,从训练集中读取标记——用snd取出每个文档元组中的第二个元素,通过管道进入vocabulary函数:
let vocabulary (tokenizer:Tokenizer) (corpus:string seq) =
corpus
|> Seq.map tokenizer
|> Set.unionMany
let allTokens =
training
|> Seq.map snd
|> vocabulary wordTokenizer```
干脆利索。现在我们可以使用大的标记列表训练新的分类器,评估其性能:
let fullClassifier = train training wordTokenizer allTokens
validation
|> Seq.averageBy (fun (docType,sms) ->
if docType = fullClassifier sms then 1.0 else 0.0)
|> printfn "Based on all tokens, correctly classified: %.3f"`
结果相当令人失望,没有出现我们希望的重大改善:“根据所有标记,正确分类的比例为0.864”。我们必须面对这一失败,得到的模型仅仅胜过最简单的预测程序,比依赖单一词汇“txt”决策的前一个模型更差。
在此得到的教训是,盲目地向一个标准算法中投入许多数据,只会离目标越来越远。在我们的例子中,精心选择了一个特征,形成的模型更简单、更快、更擅长于预测。选择合适的特征是构建好的预测程序的关键部分之一,也是我们将要尝试推进的。在本例中,可以在两方面改变特征的定义,而无须更改算法本身:可以使用不同的标记化程序,以不同方式利用消息,也可以选择(或者忽略)不同的标记集合。
忙于这些工作时,我们将会多次重复某些代码,可以将整个“训练——评估”过程打包为一个函数,该函数以一个标记化器和一组标记为参数,运行训练过程,打印输出评估结果:
程序清单2-8 通用模型评估函数
let evaluate (tokenizer:Tokenizer) (tokens:Token Set) =
let classifier = train training tokenizer tokens
validation
|> Seq.averageBy (fun (docType,sms) ->
if docType = classifier sms then 1.0 else 0.0)
|> printfn "Correctly classified: %.3f"```
评估特定模型变成一行代码:
evaluate wordTokenizer allTokens;;
Correctly classified: 0.864`
这为我们提供了相对简单的推进过程:选择一个标记化器和一组标记,调用evaluate函数观察特定组合是否好于之前尝试的组合。
2.6.2 大小写是否重要?
我们从标记化程序开始,回忆之前的讨论,目前使用的wordTokenizer函数忽略大小写,从它的角度,“txt”和“TXT”之间没有差别——两者被视为同一个标记。
这并不是不合理的做法。例如,考虑消息“Txt me now”和“txt me now”(给我发文本消息),忽略大小写,我们实质上将这两条消息视为意义上没有任何差别的消息。大小写没有带来任何相关的信息,可以当成噪声。
相反,考虑这两条消息:“are you free now?”(你现在有空吗?)和“FREE STUFF TXT NOW”(这是有关免费物品的短信)——这时,忽略大小写就可能丢失信息。
那么,哪一种方法才对呢?我们来尝试一下,看看不将所有字符串转换为小写的不同标记化程序是否比wordTokenizer更好:
程序清单2-9 使用正则表达式标记化一行文本
let casedTokenizer (text:string) =
text
|> matchWords.Matches
|> Seq.cast<Match>
|> Seq.map (fun m -> m.Value)
|> Set.ofSeq
let casedTokens =
training
|> Seq.map snd
|> vocabulary casedTokenizer
evaluate casedTokenizer casedTokens```
在FSI中运行达到87.5%的正确率。一方面,我们的结果仍然低于依赖“txt”的简单分类器;另一方面,两者已经相当接近(87.5%对 87.7%),这明显好于使用所有单一标记的wordTokenizer(86.4%)。
####2.6.3 简单就是美
我们得到了表面上更好的标记化程序,但是,添加大量特征所得到的结果仍然是效果更差、更缓慢的分类器。这明显不理想,也是反直觉的:增加更多信息怎么可能使模型更差?
让我们从另一方面去看待问题,思考之前的单标记模型为什么工作得更好。我们选择“txt”作为标记的原因是它在垃圾短信中经常发现,而在非垃圾短信中很少出现。换言之,它很好地区分两组,相当特定于垃圾短信。
我们现在做的是使用每个单一标记,不管它们的信息量如何。结果是,我们在模型中可能引入了相当多的噪声。更有选择性的方法——可能只选择每个文档组中最常见的标记,会得到什么样的结果呢?
让我们从一个简单的函数开始,给定一组原始文档(简单字符串)和一个标记化程序,将返回最常用的标记:
let top n (tokenizer:Tokenizer) (docs:string []) =
let tokenized = docs |> Array.map tokenizer
let tokens = tokenized |> Set.unionMany
tokens
|> Seq.sortBy (fun t -> - countIn tokenized t)
|> Seq.take n
|> Set.ofSeq```
现在,我们可以将训练样本分拆为非垃圾短信(Ham)和垃圾短信(Spam),在此过程中去掉标签:
let ham,spam =
let rawHam,rawSpam =
training
|> Array.partition (fun (lbl,_) -> lbl=Ham)
rawHam |> Array.map snd,
rawSpam |> Array.map snd```
我们提取和计数每个组的标签,提取前10%,用Set.union将其合并为一个标记集合:
let hamCount = ham |> vocabulary casedTokenizer |> Set.count
let spamCount = spam |> vocabulary casedTokenizer |> Set.count
let topHam = ham |> top (hamCount / 10) casedTokenizer
let topSpam = spam |> top (spamCount / 10) casedTokenizer
let topTokens = Set.union topHam topSpam`
现在,可以将这些标记整合为一个新的模型,评估其表现:
> evaluate casedTokenizer topTokens;;
Correctly classified: 0.952```
正确率从87.5%一跃达到95.2%!这是可观的改善,实现这一飞跃不是通过增加更多的数据,而是通过删除特征。
####2.6.4 仔细选择单词
我们还能做得更好吗?试试看!通过选择标记的一个子集,只保留携带有意义信息的标记,我们得到了很明显的改善。按照这一思路,可以开始检查非垃圾短信和垃圾短信中最常用的标记,这相当容易实现:
ham |> top 20 casedTokenizer |> Seq.iter (printfn "%s")
spam |> top 20 casedTokenizer |> Seq.iter (printfn "%s")`
运行上述语句产生表2-1中的结果:
我们立刻注意到两件事。首先,两个列表都很相似,在20个标记中,有8个是共同的(a、and、for、is、on、the、to、you)。而且,这些单词都非常通用,不管什么主题,任何语句都必然包含几个冠词和代词。这一列表并不重要,表中的标记可以描述为“填充材料”,它们没有告诉我们很多关于消息是非垃圾短信还是垃圾短信的情况,甚至在我们的分析中引入了一些噪声。从性能的角度看,这意味着我们花费了许多CPU周期分析传达有限甚至毫不相关信息的标记。因此,从分析中去掉这些标记可能是有益的。
在此之前,我想到了列表中出现的另一个有趣特征。和非垃圾短信不同,垃圾短信的列表中包含几个相当特殊的单词——如“call”“free”“mobile”和“now”,不包含“I”或者“me”。如果你考虑到这一点,那是很有意义的:常规的文本消息有许多种目的,而垃圾短信通常试图“钓鱼”,诱惑你做某些事情。在这样的背景下,看到“now”(现在)和“free”(免费)这些带着广告腔调的单词也就不足为奇了,因为通过奉承的方法达到目的通常没有错,垃圾短信很少和“I”(我)相关而更多地与“You”(你)相关也是正常的。
■ 注意:
什么是“钓鱼”?钓鱼是使用电子邮件、SMS或者电话从某人那里窃取财物的行为。钓鱼通常采用一定的社会工程(伪装成受尊重的机构、威胁或者引诱)使目标采取骗子无法独自完成的措施,比如单击一个在机器上安装恶意软件的链接。
我们将很快回到上述要点。与此同时,观察一下,能否删除“填充材料”。常见的方法是依赖这些单词的预定义列表,这通常称作“停用词”。在http://norm.al/2009/04/14/list-of- english-stop-words/上可以找到一个此类列表。遗憾的是,没有通用的正确方法,显然,这取决于编写文档所用的语言,甚至语境也很重要。例如,在我们的例子中,文本消息中常用的“you”缩写词“u”是一个很合适的“停用词”。但是,大部分标准列表不会包含它,因为这种用法与SMS的关联度极大,不太可能在其他文档类型中找到。
我们不依赖于停用词列表,而是采用更简单的方法。我们的topHam和topSpam集合中包含非垃圾短信和垃圾短信中最常用的标记。如果某个标记在两个列表中都出现,它很有可能就是在英语文本消息中常见的单词,不特定于非垃圾短信和垃圾短信。我们找出所有共有的标记(这对应于两个列表的交集),将它们从标记选择中删除,再次运行分析:
let commonTokens = Set.intersect topHam topSpam
let specificTokens = Set.difference topTokens commonTokens
evaluate casedTokenizer specificTokens```
利用这一简单的更改,分类SMS消息的正确率从95.2%上升到了97.9%。这可以认为是相当好的结果了,离100%越近,改善越难实现。
####2.6.5 创建新特征
在尝试提出新想法时,我常常觉得有用的方法之一是颠覆问题,以便从不同角度观察。举个例子,当我编写代码时,通常从想象“快乐路径”——程序完成某项功能所需的最少步骤——开始,然后实现它们。在测试代码之前,这是很棒的方法,你的心思已经完全集中在成功路径上,而难以考虑失败的情况。所以,我喜欢在测试时将问题颠倒并提问:“使这个程序崩溃的最快方法是什么?”我发现这一招非常有效,而且使测试变得很有趣!
让我们来试试用这个方法改进分类器。对每个类别中最常见单词进行观察最终可以得到很明显的改善,因为贝叶斯分类器依赖于分类中的常见词以识别类别。观察一下每个文档类别中最少见的词如何?稍微修改一下前面编写的代码,就可以提取分组中最少使用的标记:
程序清单2-10 提取最少使用的标记
let rareTokens n (tokenizer:Tokenizer) (docs:string []) =
let tokenized = docs |> Array.map tokenizer
let tokens = tokenized |> Set.unionMany
tokens
|> Seq.sortBy (fun t -> countIn tokenized t)
|> Seq.take n
|> Set.ofSeq
let rareHam = ham |> rareTokens 50 casedTokenizer |> Seq.iter (printfn "%s")
let rareSpam = spam |> rareTokens 50 casedTokenizer |> Seq.iter (printfn "%s")`
你是否注意到某种模式?垃圾短信列表中充满了电话号码或者文本编码。同样,这也很有意义:如果我成为“钓鱼”的目标,有人希望我做某件事情——在电话上,这意味着拨打某个号码或者用短信发送一个数字。
这也强调了一个问题:作为人类,我们立刻发现这个列表是“许多电话号码”,但是每个号码在模型中都被视为单独的标记,这些标记出现的很少。然而,SMS消息中存在电话号码似乎是垃圾短信的可能标记。我们可以创建一个新特征,捕捉消息是否包含电话号码,而不管这些号码是什么,解决这个问题。这将使我们能够计算电话号码在垃圾短信中出现的频率,并(潜在地)在我们的简单贝叶斯分类器中将其作为一个标记使用。
如果仔细研究少见标记列表,就有可能发现更特殊的模式。首先,列出的电话号码都有类似的结构:它们以07、08或者09开头,然后是9个其他号码。其次,列表中有其他一些数字,主要是5位数字,这很可能是短信编码。
我们为每种情况创建一个特征。每当看到07之后的9个数字,就将其转换为标记__PHONE__,每当遇到5位数字,就将其转换为__TXT__:
程序清单2-11 识别电话号码
let phoneWords = Regex(@"0[7-9]\d{9}")
let phone (text:string) =
match (phoneWords.IsMatch text) with
| true -> "__PHONE__"
| false -> text
let txtCode = Regex(@"\b\d{5}\b")
let txt (text:string) =
match (txtCode.IsMatch text) with
| true -> "__TXT__"
| false -> text
let smartTokenizer = casedTokenizer >> Set.map phone >> Set.map txt```
smartTokenizer简单地将3个函数链接在一起。casedTokenizer函数取得一个字符串并返回一个字符串集合,包含单独识别的标记。因为它返回的是字符串集合,可以应用Set.map,对每个标记运行phone函数,将看起来像电话号码的标记转换为“__PHONE__”,然后同样执行txt函数。
在F# Interactive中运行如下代码,确认上述函数正常工作:
smartTokenizer "hello World, call 08123456789 or txt 12345";;
val it : Set =
set ["World"; "__PHONE__"; "__TXT__"; "call"; "hello"; "or"; "txt"]`
结果正如我们预期的那样——一切正常。在列表中人工添加刚刚创建的两个标记,尝试调整过后的标记化程序(如果不这么做,标记列表中仍然包含单独的数字,没有与“智能标记化程序”产生的__PHONE__标记匹配):
let smartTokens =
specificTokens
|> Set.add "__TXT__"
|> Set.add "__PHONE__"
evaluate smartTokenizer smartTokens```
请击鼓喝彩……准确率又一次跃升,从96.7%上升到98.3%!考虑到目前的性能水平,这是非常了不起的提升,值得花一些时间讨论。我们转换了数据集,通过聚合现有特征创建了一个新特征。这一过程是开发好的机器学习模型的关键部分。从原始数据集(可能包含低质量数据)开始,随着对领域的理解的进一步深入,找出变量之间的关系,就可以不断将数据重塑为新的表现形式,更好地适应手上的问题。这种试验、将原始数据精炼为好的特征的循环是机器学习的核心。
####2.6.6 处理数字值
让我们以一个简短的讨论结束关于特征提取的部分。在本章中,我们只考虑离散特征,也就是说,只取一组离散值的特征。对于连续特征(或者数值特征)该怎么办?例如,想象一下,如果你的直觉是消息的长度很重要,能否使用到目前为止已经有的想法?
可能性之一是将问题简化为已知的问题,将消息长度(0~140个字符)变成一个二分特征,将其分为两类——短消息和长消息。这样,问题就变成了“区分长消息和短消息的合适长度值是多少?”
我们可以直接用贝叶斯定理解决这个问题:对于指定的阈值,我们可以计算长于该阈值的消息是垃圾短信的概率。然后,识别“好”的阈值就只是测试不同数值,选择信息量最大的一个。下面的代码完成上述功能,应该不太难以理解:
程序清单2-12 根据短信长度计算是垃圾短信的概率
let lengthAnalysis len =
let long (msg:string) = msg.Length > len
let ham,spam =
dataset
|> Array.partition (fun (docType,_) -> docType = Ham)
let spamAndLongCount =
spam
|> Array.filter (fun (_,sms) -> long sms)
|> Array.length
let longCount =
dataset
|> Array.filter (fun (_,sms) -> long sms)
|> Array.length
let pSpam = (float spam.Length) / (float dataset.Length)
let pLongIfSpam =
float spamAndLongCount / float spam.Length
let pLong =
float longCount /
float (dataset.Length)
let pSpamIfLong = pLongIfSpam * pSpam / pLong
pSpamIfLong
for l in 10 .. 10 .. 130 do
printfn "P(Spam if Length > %i) = %.4f" l (lengthAnalysis l)```
运行上述代码,将会看到一条短信是垃圾短信的概率随着长度的增长而显著增大,这并不奇怪。垃圾短信制造者发送许多消息,希望得到最合理的收益,因此会在单条SMS中加入尽可能多的内容。现在,我将暂时搁置这一话题,在第6章中从稍有不同的角度讨论,但是我希望说明的是,贝叶斯定理在许多不同的情况下都很方便,不需要花费太多的精力。