JavaScript 机器学习实用指南(三)
原文:
annas-archive.org/md5/86fc6595b85c1a353b88aee9d304e735译者:飞龙
第十章:自然语言处理实践
自然语言处理是解析、分析和重建自然语言(如书面或口语英语、法语或德语)的科学(和艺术)。这不是一项容易的任务;自然语言处理(NLP)是一个完整的研究领域,拥有充满活力的学术研究社区和来自主要科技公司的重大资金支持。每当谷歌、苹果、亚马逊和微软投资其谷歌助手、Siri、Alexa 和 Cortana 产品时,NLP 领域就会获得更多资金。简而言之,NLP 是您能够与手机交谈,手机也能对您说话的原因。
Siri 不仅仅是 NLP。作为消费者,我们喜欢批评我们的人工智能(AI)助手当它们犯下可笑的错误。但它们确实是工程奇迹,它们能够做到任何正确的事情都是一个奇迹!
如果我看向我的手机并说,“Ok Google,给我去 7-Eleven 的路线”,我的手机将自动唤醒并对我回应,“好的,去 Main Ave 的 7-Eleven,下一个右转”。让我们思考一下要完成这个任务需要什么:
-
我的睡眠中的手机正在监控我预先训练的“OK Google”短语。
-
音频缓冲区在训练的 OK Google 声音波上得到音频哈希匹配,并唤醒手机。
-
手机开始捕捉音频,这只是一个表示声音波强度的数字时间序列向量。
-
语音音频被解码为音素,或语音声音的文本表示。为每个话语生成几个候选者。
-
将候选音素组合在一起,试图形成单词。算法使用最大似然或其他估计器来确定哪种组合最有可能是在当前上下文中实际使用的句子。
-
结果句子必须解析其意义,因此执行了许多类型的预处理,并且每个单词都被标记为其可能的词性(POS)。
-
一个学习系统(通常是人工神经网络)将尝试根据短语的主题、宾语和动词确定意图。
-
实际意图必须由子例程执行。
-
必须制定对用户的响应。在响应无法脚本化的情况下,它必须通过算法生成。
-
文本到语音算法将响应解码为音素,然后必须合成听起来自然的语音,该语音随后通过手机的扬声器播放。
恭喜你,你正在走向获得你的 Slurpee!您的体验由多个人工神经网络、各种 NLP 工具的多种用途、庞大的数据集以及数百万工程师小时的努力来构建和维护。这种体验还解释了 NLP 和 ML 之间的密切关系——它们不是同一件事,但它们在技术前沿并肩作战。
显然,NLP 的内容远不止 25 页所能涵盖的主题。本章的目标不是全面介绍;它的目标是使你熟悉在解决涉及自然语言的 ML 问题时最常用的策略。我们将快速浏览七个与 NLP 相关的概念:
-
测量字符串距离
-
TF-IDF 度量
-
文本分词
-
词干提取
-
语音学
-
词性标注
-
使用 Word2vec 进行词嵌入
如果这些主题看起来令人畏惧,请不要担心。我们将逐一介绍每个主题,并展示许多示例。在 NLP 中涉及许多术语,以及许多边缘情况,所以乍一看这个主题似乎难以接近。但毕竟,这个主题是自然语言:我们每天都在说它!一旦我们学会了术语,这个主题就变得相当直观,因为我们大家对语言都有非常强烈的直观理解。
我们将从一个简单的问题开始我们的讨论:你如何测量quit和quote之间的距离?我们已经知道我们可以测量空间中两点之间的距离,那么现在让我们来看看如何测量两个单词之间的距离。
字符串距离
总是能够测量两点之间某种形式的距离是非常方便的。在之前的章节中,我们使用了点之间的距离来辅助聚类和分类。我们也可以在 NLP 中对单词和段落做同样的事情。当然,问题是单词由字母组成,而距离由数字组成——那么我们如何从两个单词中得出一个数字呢?
输入 Levenshtein 距离*—*这是一个简单的度量,它衡量将一个字符串转换为另一个字符串所需的单字符编辑次数。Levenshtein 距离允许插入、删除和替换。Levenshtein 距离的一种修改版本,称为Damerau-Levenshtein 距离,也允许交换两个相邻字母。
为了用示例说明这个概念,让我们尝试将单词crate转换为单词plate:
-
将r替换为l以得到clate
-
将c替换为p以得到plate
crate 和 plate 之间的 Levenshtein 距离因此是 2。
板和激光器之间的距离是 3:
-
删除p以得到late
-
插入一个r以得到later
-
将t替换为s以得到laser
让我们在代码中确认这些示例。创建一个名为Ch10-NLP的新目录,并添加以下package.json文件:
{
"name": "Ch10-NLP",
"version": "1.0.0",
"description": "ML in JS Example for Chapter 10 - NLP",
"main": "src/index.js",
"author": "Burak Kanber",
"license": "MIT",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"compromise": "¹¹.7.0",
"natural": "⁰.5.6",
"wordnet-db": "³.1.6"
}
}
然后从命令行运行yarn install来安装依赖项。这个package.json文件与之前章节中的文件略有不同,因为wordnet-db依赖项与 Browserify 打包器不兼容。因此,我们将不得不在本章中省略一些高级 JavaScript 功能。
创建一个名为src的目录,并向其中添加一个index.js文件,你将在其中添加以下内容:
const compromise = require('compromise');
const natural = require('natural');
你将在本章的其余部分使用这些导入,所以请将它们保存在index.js文件中。然而,本章中我们使用的其余代码将是可互换的;如果你愿意,在处理本章中的示例时可以删除旧的不相关代码。
让我们使用natural.js库来看看 Levenshtein 距离:
[
['plate', 'laser'],
['parachute', 'parasail'],
['parachute', 'panoply']
]
.forEach(function(pair) {
console.log("Levenshtein distance between '"+pair[0]+"' and '"+pair[1]+"': "
+ natural.LevenshteinDistance.apply(null, pair)
);
});
在命令行中运行yarn start,你会看到以下输出:
Levenshtein distance between 'plate' and 'laser': 3
Levenshtein distance between 'parachute' and 'parasail': 5
Levenshtein distance between 'parachute' and 'panoply': 7
尝试对几对单词进行实验,看看你是否能在大脑中计算出距离,以获得对它的直观感受。
Levenshtein 距离有许多用途,因为它是一个度量标准,而不是任何特定的工具。其他系统,如拼写检查器、建议器和模糊匹配器,在自己的算法中使用 Levenshtein 或编辑距离度量。
让我们看看一个更高级的度量标准:TF-IDF 分数,它表示一个特定单词在文档集中有多有趣或重要。
词频-逆文档频率
在搜索相关性、文本挖掘和信息检索中最受欢迎的度量标准之一是词频-逆文档频率(TF-IDF)分数。本质上,TF-IDF 衡量一个词对特定文档的重要性。因此,TF-IDF 度量标准因此只在单词属于更大文档集的文档的上下文中才有意义。
想象一下,你有一批文档,比如不同主题的博客文章,你希望使其可搜索。你的应用程序的最终用户运行了一个搜索查询,搜索的是fashion style。那么,你如何找到匹配的文档并根据相关性对它们进行排序?
TF-IDF 分数由两个单独但相关的组成部分组成。第一个是词频,即在给定文档中一个特定词的相对频率。如果一个 100 字的博客文章中包含单词fashion四次,那么该文档中单词fashion的词频是 4%。
注意,词频只需要一个词和一个文档作为参数;TF-IDF 的词频组件不需要整个文档集。
单独的词频不足以确定相关性。像this和the这样的词在大多数文本中都非常常见,并且会有很高的词频,但这些词通常与任何搜索都不相关。
因此,我们在计算中引入了第二个度量标准:逆文档频率。这个度量标准本质上是一个给定单词出现在文档中的百分比的倒数。如果你有 1,000 篇博客文章,而单词fashion出现在其中的 50 篇,那么该单词的非逆文档频率是 5%。逆文档频率是这个概念的扩展,通过取逆文档频率的对数给出。
如果 n[fashion]是包含单词fashion的文档数量,而N是文档总数,那么逆文档频率由log(N / n[fashion])给出。在我们的例子中,单词fashion的逆文档频率大约是 1.3。
如果我们现在考虑单词the,它可能出现在 90%的文档中,我们发现the的逆文档频率是 0.0451,远小于我们为fashion得到的 1.3。因此,逆文档频率衡量的是给定单词在文档集中的稀有程度或独特性;值越高,意味着单词越稀有。计算逆文档频率所需的参数是术语本身和文档语料库(与仅需要一个文档的词频不同)。
TF-IDF 分数是通过将词频和逆文档频率相乘来计算的。结果是单个指标,它封装了单个术语对特定文档的重要性或兴趣,考虑了您所看到的所有文档。像the和that这样的词可能在任何单个文档中具有高词频,但由于它们在所有文档中都普遍存在,它们的总体 TF-IDF 分数将非常低。像fashion这样的词,只存在于文档的子集中,将具有更高的 TF-IDF 分数。当比较两个都包含单词fashion的单独文档时,使用它更频繁的文档将具有更高的 TF-IDF 分数,因为两个文档的逆文档频率部分将是相同的。
在对搜索结果进行相关性评分时,最常见的方法是计算搜索查询中每个术语以及语料库中每个文档的 TF-IDF 分数。每个查询术语的个别 TF-IDF 分数可以相加,得到的总和可以称为该特定文档的相关性分数。一旦所有匹配的文档都以这种方式评分,就可以按相关性排序并按此顺序显示它们。大多数全文搜索引擎,如 Lucene 和 Elasticsearch,都使用这种相关性评分方法。
让我们通过使用natural.js TF-IDF 工具来实际看看。将以下内容添加到index.js中:
const fulltextSearch = (query, documents) => {
const db = new natural.TfIdf();
documents.forEach(document => db.addDocument(document));
db.tfidfs(query, (docId, score) => {
console.log("DocID " + docId + " has score: " + score);
});
};
fulltextSearch("fashion style", [
"i love cooking, it really relaxes me and makes me feel at home",
"food and restaurants are basically my favorite things",
"i'm not really a fashionable person",
"that new fashion blogger has a really great style",
"i don't love the cinematic style of that movie"
]);
此代码定义了一个fulltextSearch函数,该函数接受一个搜索查询和要搜索的文档数组。每个文档都添加到 TF-IDF 数据库对象中,其中它被natural.js自动分词。使用yarn start运行程序,您将看到以下输出:
DocID 0 has score: 0
DocID 1 has score: 0
DocID 2 has score: 0
DocID 3 has score: 3.4271163556401456
DocID 4 has score: 1.5108256237659907
前两个文档与时尚或风格无关,返回的分数为零。这些文档中时尚和风格的词频组件为零,因此整体分数变为零。第三个文档的分数也是零。然而,该文档确实提到了时尚,但是分词器无法将单词时尚的与时尚相匹配,因为没有进行词干提取。我们将在本章后面的部分深入讨论分词和词干提取,但就目前而言,了解词干提取是一种将单词还原为其词根形式的操作就足够了。
第三个和第四个文档的分数不为零。第三个文档的分数更高,因为它包含了时尚和风格这两个词,而第四个文档只包含了风格这个词。这个简单的指标在捕捉相关性方面做得出奇的好,这也是为什么它被广泛使用的原因。
让我们更新我们的代码以添加一个词干提取操作。在应用词干提取到文本之后,我们预计第二个文档也将有一个非零的相关性分数,因为时尚的应该被词干提取器转换为时尚。将以下代码添加到index.js中:
const stemmedFulltextSearch = (query, documents) => {
const db = new natural.TfIdf();
const tokenizer = new natural.WordTokenizer();
const stemmer = natural.PorterStemmer.stem;
const stemAndTokenize = text => tokenizer.tokenize(text).map(token => stemmer(token));
documents.forEach(document => db.addDocument(stemAndTokenize(document)));
db.tfidfs(stemAndTokenize(query), (docId, score) => {
console.log("DocID " + docId + " has score: " + score);
});
};
stemmedFulltextSearch("fashion style", [
"i love cooking, it really relaxes me and makes me feel at home",
"food and restaurants are basically my favorite things",
"i'm not really a fashionable person",
"that new fashion blogger has a really great style",
"i don't love the cinematic style of that movie"
]);
我们已经添加了一个stemAndTokenize辅助方法,并将其应用于添加到数据库中的文档以及搜索查询。使用yarn start运行代码,你会看到更新的输出:
DocID 0 has score: 0
DocID 1 has score: 0
DocID 2 has score: 1.5108256237659907
DocID 3 has score: 3.0216512475319814
DocID 4 has score: 1.5108256237659907
如预期的那样,第二个文档现在有一个非零分数,因为词干提取器能够将单词时尚的转换为时尚。第二个和第四个文档的分数相同,但这仅仅是因为这是一个非常简单的例子;在一个更大的语料库中,我们不会期望时尚和风格这两个词的逆文档频率是相等的。
TF-IDF 不仅用于搜索相关性和排名。这个指标在许多用例和问题领域中得到了广泛的应用。TF-IDF 的一个有趣用途是文章摘要。在文章摘要中,目标是减少一段文字,只保留几个能够有效总结该段落的句子。
解决文章摘要问题的方法之一是将文章中的每个句子或段落视为一个单独的文档。在为 TF-IDF 索引每个句子之后,然后评估每个单词的 TF-IDF 分数,并使用这些分数对整个句子进行评分。选择前三或五个句子,并按原始顺序显示它们,你将得到一个不错的摘要。
让我们看看这个实际应用,使用natural.js和compromise.js。将以下代码添加到index.js中:
const summarize = (article, maxSentences = 3) => {
const sentences = compromise(article).sentences().out('array');
const db = new natural.TfIdf();
const tokenizer = new natural.WordTokenizer();
const stemmer = natural.PorterStemmer.stem;
const stemAndTokenize = text => tokenizer.tokenize(text).map(token => stemmer(token));
const scoresMap = {};
// Add each sentence to the document
sentences.forEach(sentence => db.addDocument(stemAndTokenize(sentence)));
// Loop over all words in the document and add that word's score to an overall score for each sentence
stemAndTokenize(article).forEach(token => {
db.tfidfs(token, (sentenceId, score) => {
if (!scoresMap[sentenceId]) scoresMap[sentenceId] = 0;
scoresMap[sentenceId] += score;
});
});
// Convert our scoresMap into an array so that we can easily sort it
let scoresArray = Object.entries(scoresMap).map(item => ({score: item[1], sentenceId: item[0]}));
// Sort the array by descending score
scoresArray.sort((a, b) => a.score < b.score ? 1 : -1);
// Pick the top maxSentences sentences
scoresArray = scoresArray.slice(0, maxSentences);
// Re-sort by ascending sentenceId
scoresArray.sort((a, b) => parseInt(a.sentenceId) < parseInt(b.sentenceId) ? -1 : 1);
// Return sentences
return scoresArray
.map(item => sentences[item.sentenceId])
.join('. ');
};
之前的summarize方法实现了以下步骤:
-
使用
compromise.js从文章中提取句子 -
将每个单独的句子添加到 TF-IDF 数据库中
-
对于文章中的每个单词,计算其在每个句子中的 TF-IDF 分数
-
将每个单词的 TF-IDF 分数添加到每个句子的总分数列表(
scoresMap对象)中 -
将
scoresMap转换为数组,以便排序更简单 -
按降序相关性分数对
scoresArray进行排序 -
删除除了得分最高的句子之外的所有句子
-
按句子的时间顺序重新排序
scoresArray -
通过连接得分最高的句子来构建摘要
让我们在代码中添加一个简单的文章,并尝试使用三句和五句的摘要。在这个例子中,我会使用本节的前几段,但你可以用任何你喜欢的内容替换文本。将以下内容添加到index.js中:
const summarizableArticle = "One of the most popular metrics used in search relevance, text mining, and information retrieval is the term frequency - inverse document frequency score, or tf-idf for short. In essence, tf-idf measures how significant a word is to a particular document. The tf-idf metric therefore only makes sense in the context of a word in a document that's part of a larger corpus of documents. Imagine you have a corpus of documents, like blog posts on varying topics, that you want to make searchable. The end user of your application runs a search query for fashion style. How do you then find matching documents and rank them by relevance? The tf-idf score is made of two separate but related components. The first is term frequency, or the relative frequency of a specific term in a given document. If a 100-word blog post contains the word fashion four times, then the term frequency of the word fashion is 4% for that one document. Note that term frequency only requires a single term and a single document as parameters; the full corpus of documents is not required for the term frequency component of tf-idf. Term frequency by itself is not sufficient to determine relevance, however. Words like this and the appear very frequently in most text and will have high term frequencies, but those words are not typically relevant to any search.";
console.log("3-sentence summary:");
console.log(summarize(summarizableArticle, 3));
console.log("5-sentence summary:");
console.log(summarize(summarizableArticle, 5));
当你使用yarn start运行代码时,你会看到以下输出:
3-sentence summary:
the tf idf metric therefore only makes sense in the context of a word in a document that's part of a larger corpus of documents. if a 100-word blog post contains the word fashion four times then the term frequency of the word fashion is 4% for that one document. note that term frequency only requires a single term and a single document as parameters the full corpus of documents is not required for the term frequency component of tf idf
5-sentence summary:
one of the most popular metrics used in search relevance text mining and information retrieval is the term frequency inverse document frequency score or tf idf for short. the tf idf metric therefore only makes sense in the context of a word in a document that's part of a larger corpus of documents. the first is term frequency or the relative frequency of a specific term in a given document. if a 100-word blog post contains the word fashion four times then the term frequency of the word fashion is 4% for that one document. note that term frequency only requires a single term and a single document as parameters the full corpus of documents is not required for the term frequency component of tf idf
这些摘要的质量展示了tf-idf 度量的强大功能和灵活性,同时也突出了这样一个事实:你并不总是需要高级的 ML 或 AI 算法来完成有趣的任务。TF-IDF 有许多其他用途,所以你应该考虑在需要将单词或术语与语料库中的文档的相关性相关联时使用此度量。
在本节中,我们使用了分词器和词干提取器,但没有正式介绍它们。这些是 NLP 中的核心概念,所以现在让我们正式介绍它们。
分词
分词是将输入字符串(如句子、段落,甚至是一个对象,如电子邮件)转换为单个tokens的行为。一个非常简单的分词器可能会将句子或段落按空格分割,从而生成单个单词的 tokens。然而,tokens 不一定是单词,输入字符串中的每个单词也不一定需要被分词器返回,分词器生成的每个 tokens 也不一定需要在原始文本中存在,而且一个 tokens 也不一定只代表一个单词。因此,我们使用token这个词而不是word来描述分词器的输出,因为 tokens 并不总是单词。
在使用机器学习算法处理文本之前进行分词的方式对算法的性能有重大影响。许多 NLP 和 ML 应用使用词袋模型方法,其中只关注单词或 tokens,而不关注它们的顺序,就像我们在第五章中探讨的朴素贝叶斯分类器一样,分类算法。然而,生成二元组(即相邻单词的成对)的分词器实际上在用于词袋模型算法时,会保留原始文本的一些位置和语义意义。
文本标记化有许多方法。如前所述,最简单的方法是将句子通过空格拆分以生成一个标记流,其中包含单个单词。然而,简单方法存在许多问题。首先,算法将大写单词视为与其小写版本不同;Buffalo 和 buffalo 被视为两个不同的单词或标记。有时这是可取的,有时则不然。过于简化的标记化还将像won't这样的缩写视为独立且与单词will not不同,后者将被拆分为两个单独的标记,will和not。
在大多数情况下,即在 80%的应用中,一个人应该考虑的最简单的标记化是,将所有文本转换为小写,删除标点符号和新行,删除格式化和标记,如 HTML,甚至删除停用词或常见单词,如this或the。在其他情况下,需要更高级的标记化,在某些情况下,需要更简单的标记化。
在本节中,我一直在描述标记化行为作为一个复合过程,包括大小写转换、删除非字母数字字符和停用词过滤。然而,标记化库将各自有自己的观点,关于标记化器的角色和责任。您可能需要将库的标记化工具与其他工具结合使用,以实现所需的效果。
首先,让我们构建自己的简单标记化器。这个标记化器将字符串转换为小写,删除非字母数字字符,并删除长度少于三个字符的单词。将以下内容添加到您的index.js文件中,要么替换 Levenshtein 距离代码,要么添加到其下方:
const tokenizablePhrase = "I've not yet seen 'THOR: RAGNAROK'; I've heard it's a great movie though. What'd you think of it?";
const simpleTokenizer = (text) =>
text.toLowerCase()
.replace(/(\w)'(\w)/g, '$1$2')
.replace(/\W/g, ' ')
.split(' ')
.filter(token => token.length > 2);
console.log(simpleTokenizer(tokenizablePhrase));
这个simpleTokenizer会将字符串转换为小写,删除单词中间的撇号(因此won't变为wont),并通过将所有其他非单词字符替换为空格来过滤掉所有其他非单词字符。然后,它通过空格字符拆分字符串,返回一个数组,并最终删除任何少于三个字符的项目。
运行yarn start,您将看到以下内容:
[ 'ive', 'not', 'yet', 'seen', 'thor',
'ragnarok', 'ive', 'heard', 'its',
'great', 'movie', 'though',
'whatd', 'you', 'think' ]
这个标记流可以被提供给一个算法,无论是按顺序还是无序。例如,朴素贝叶斯分类器将忽略顺序,并将每个单词视为独立进行分析。
让我们比较我们的简单标记化器与natural.js和compromise.js提供的两个标记化器。将以下内容添加到您的index.js文件中:
console.log("Natural.js Word Tokenizer:");
console.log((new natural.WordTokenizer()).tokenize(tokenizablePhrase));
使用yarn start运行代码将产生以下输出:
Natural.js Word Tokenizer:
[ 'I', 've', 'not', 'yet', 'seen',
'THOR', 'RAGNAROK', 'I', 've',
'heard', 'it', 's', 'a', 'great', 'movie',
'though', 'What', 'd', 'you', 'think',
'of', 'it' ]
如您所见,短单词已被保留,并且像I've这样的缩写已被拆分为单独的标记。此外,大小写也被保留。
让我们尝试另一个natural.js标记化器:
console.log("Natural.js WordPunct Tokenizer:");
console.log((new natural.WordPunctTokenizer()).tokenize(tokenizablePhrase));
这将产生:
Natural.js WordPunct Tokenizer:
[ 'I', '\'', 've', 'not', 'yet', 'seen',
'\'', 'THOR', ': ', 'RAGNAROK', '\'', '; ',
'I', '\'', 've', 'heard', 'it', '\'', 's',
'a', 'great', 'movie', 'though', '.', 'What',
'\'', 'd', 'you', 'think', 'of',
'it', '?' ]
然而,这个标记化器继续在标点符号上拆分,但标点符号本身被保留。在标点符号重要的应用中,这可能是有需求的。
其他分词库,例如compromise.js中的分词库,采取了一种更智能的方法,甚至在分词的同时进行词性标注,以便在分词过程中解析和理解句子。让我们尝试几种compromise.js的分词技术:
console.log("Compromise.js Words:");
console.log(compromise(tokenizablePhrase).words().out('array'));
console.log("Compromise.js Adjectives:");
console.log(compromise(tokenizablePhrase).adjectives().out('array'));
console.log("Compromise.js Nouns:");
console.log(compromise(tokenizablePhrase).nouns().out('array'));
console.log("Compromise.js Questions:");
console.log(compromise(tokenizablePhrase).questions().out('array'));
console.log("Compromise.js Contractions:");
console.log(compromise(tokenizablePhrase).contractions().out('array'));
console.log("Compromise.js Contractions, Expanded:");
console.log(compromise(tokenizablePhrase).contractions().expand().out('array'));
使用yarn start运行新代码,您将看到以下内容:
Compromise.js Words:
[ 'i\'ve', '', 'not', 'yet', 'seen',
'thor', 'ragnarok', 'i\'ve', '', 'heard',
'it\'s', '', 'a', 'great', 'movie', 'though',
'what\'d', '', 'you', 'think', 'of', 'it' ]
Compromise.js Adjectives:
[ 'great' ]
Compromise.js Nouns:
[ 'thor', 'ragnarok', 'movie' ]
Compromise.js Questions:
[ 'what\'d you think of it' ]
Compromise.js Contractions:
[ 'i\'ve', 'i\'ve', 'it\'s', 'what\'d' ]
Compromise.js Contractions, Expanded:
[ 'i have', 'i have', 'it is', 'what did' ]
words()分词器不会像natural.js分词器那样将缩写词分开。此外,compromise.js还为您提供从文本中提取特定实体类型的能力。我们可以分别提取形容词、名词、动词、疑问词、缩写词(甚至具有扩展缩写词的能力);我们还可以使用compromise.js提取日期、标签、列表、从句和数值。
您的标记不必直接映射到输入文本中的单词和短语。例如,当为电子邮件系统开发垃圾邮件过滤器时,您可能会发现将一些来自电子邮件头部的数据包含在标记流中可以大幅提高准确性。电子邮件是否通过 SPF 和 DKIM 检查可能对您的垃圾邮件过滤器来说是一个非常强烈的信号。您还可能发现区分正文文本和主题行也是有益的;可能的情况是,作为超链接出现的单词比纯文本中的单词是更强的信号。
通常,对这种半结构化数据进行分词的最简单方法是在标记前加上一个或一组通常不允许分词器使用的字符。例如,电子邮件主题行中的标记可能以_SUBJ:为前缀,而出现在超链接中的标记可能以_LINK:为前缀。为了说明这一点,这里是一个电子邮件标记流的示例:
['_SPF:PASS',
'_DKIM:FAIL',
'_SUBJ:buy',
'_SUBJ:pharmaceuticals',
'_SUBJ:online',
'_LINK:pay',
'_LINK:bitcoin',
'are',
'you',
'interested',
'buying',
'medicine',
'online']
即使朴素贝叶斯分类器以前从未见过关于药品的引用,它也可能发现大多数垃圾邮件邮件都未能通过 DKIM 检查,但仍将此消息标记为垃圾邮件。或者,也许您与会计部门紧密合作,他们经常收到有关付款的电子邮件,但几乎从未收到包含指向外部网站的超链接中的单词pay的合法电子邮件;在纯文本中出现的*pay*标记与在超链接中出现的_LINK:pay标记之间的区分可能对电子邮件是否被分类为垃圾邮件有决定性的影响。
实际上,最早期的垃圾邮件过滤突破之一,由 Y Combinator 的保罗·格雷厄姆开发,就是使用这种带有注释的电子邮件标记的方法,显著提高了早期垃圾邮件过滤器的准确性。
另一种分词方法是n-gram分词,它将输入字符串分割成 N 个相邻标记的 N 大小组。实际上,所有分词都是 n-gram 分词,然而,在前面的例子中,N 被设置为 1。更典型的是,n-gram 分词通常指的是 N > 1 的方案。最常见的是二元组和三元组分词。
二元和三元标记化的目的是保留围绕单个单词的一些上下文。与情感分析相关的一个例子是易于可视化。短语I did not love the movie将被标记化(使用单语标记化器,或 n-gram 标记化器,其中 N = 1)为I,did,not,love,the,movie。当使用如朴素贝叶斯这样的词袋算法时,算法将看到单词love并猜测句子具有积极情感,因为词袋算法不考虑单词之间的关系。
另一方面,二元标记化器可以欺骗一个简单的算法去考虑单词之间的关系,因为每一对单词都变成了一个标记。使用二元标记化器处理的前一个短语将变成I did,did not,not love,love the,the movie。尽管每个标记由两个单独的单词组成,但算法是在标记上操作的,因此会将not love与I love区别对待。因此,情感分析器将围绕每个单词有更多的上下文,并能区分否定(not love)和积极短语。
让我们在先前的示例句子上尝试natural.js二元标记化器。将以下代码添加到index.js中:
console.log("Natural.js bigrams:");
console.log(natural.NGrams.bigrams(tokenizablePhrase));
使用yarn start运行代码将产生:
Natural.js bigrams:
[ [ 'I', 've' ],
[ 've', 'not' ],
[ 'not', 'yet' ],
[ 'yet', 'seen' ],
[ 'seen', 'THOR' ],
[ 'THOR', 'RAGNAROK' ],
[ 'RAGNAROK', 'I' ],
[ 'I', 've' ],
[ 've', 'heard' ],
[ 'heard', 'it' ],
[ 'it', 's' ],
[ 's', 'a' ],
[ 'a', 'great' ],
[ 'great', 'movie' ],
[ 'movie', 'though' ],
[ 'though', 'What' ],
[ 'What', 'd' ],
[ 'd', 'you' ],
[ 'you', 'think' ],
[ 'think', 'of' ],
[ 'of', 'it' ] ]
n-gram 标记化最大的问题是它会显著增加数据域的熵。当在 n-gram 上训练算法时,你不仅要确保算法学习到所有重要的单词,还要学习到所有重要的单词对。单词对的数量比唯一的单词数量要多得多,因此 n-gram 标记化只有在你有一个非常庞大且全面的训练集时才能工作。
一种巧妙地绕过 n-gram 熵问题的方法,尤其是在处理情感分析中的否定时,是将否定词后面的标记以与处理电子邮件标题和主题行相同的方式进行转换。例如,短语not love可以被标记为not, _NOT:love,或者not, !love,甚至只是*!love*(将not作为一个单独的标记丢弃)。
在这个方案下,短语I did not love the movie将被标记化为I,did,not,_NOT:love,the,movie。这种方法的优势在于上下文否定仍然得到了保留,但总的来说,我们仍然使用低熵的单语标记,这些标记可以用较小的数据集进行训练。
标记文本有许多方法,每种方法都有其优缺点。正如往常一样,你选择的方法将取决于手头的任务、可用的训练数据以及问题域本身。
在接下来的几节中,请始终牢记分词的主题,因为这些主题也可以应用于分词过程。例如,您可以在分词后对单词进行词干提取以进一步减少熵,或者您可以根据它们的 TF-IDF 分数过滤您的标记,因此只使用文档中最有趣的单词。
为了继续我们关于熵的讨论,让我们花一点时间来讨论词干提取。
词干提取
词干提取是一种可以应用于单个单词的转换类型,尽管通常词干操作发生在分词之后。在分词后进行词干提取非常常见,以至于natural.js提供了一个tokenizeAndStem便利方法,可以附加到String类原型上。
具体来说,词干提取将单词还原为其词根形式,例如将running转换为run。在分词后对文本进行词干提取可以显著减少数据集的熵,因为它本质上去除了具有相似意义但时态或词形不同的单词。您的算法不需要分别学习单词run、runs、running和runnings,因为它们都将被转换为run。
最受欢迎的词干提取算法,即Porter词干提取器,是一种定义了多个阶段规则的启发式算法。但本质上,它归结为从单词末尾切掉标准的动词和名词词形变化,并处理出现的特定边缘情况和常见不规则形式。
从某种意义上说,词干提取是一种压缩算法,它丢弃了关于词形变化和特定单词形式的信息,但保留了由词根留下的概念信息。因此,在词形变化或语言形式本身很重要的场合不应使用词干提取。
由于同样的原因,词干提取在概念信息比形式更重要的情况下表现优异。主题提取就是一个很好的例子:无论是某人写关于自己作为跑者的经历还是观看田径比赛的经历,他们都是在写关于跑步。
由于词干提取减少了数据熵,因此在数据集较小或适度大小时非常有效地使用。然而,词干提取不能随意使用。如果在不必要的情况下使用词干提取,非常大的数据集可能会因准确性降低而受到惩罚。您在提取文本时会破坏信息,具有非常大的训练集的模型可能已经能够使用这些额外信息来生成更好的预测。
在实践中,您永远不需要猜测您的模型是否在带词干或不带词干的情况下表现更好:您应该尝试两种方法,看看哪种表现更好。我无法告诉您何时使用词干提取,我只能告诉您为什么它有效,以及为什么有时它不起作用。
让我们尝试一下natural.js的 Porter 词干提取器,并将其与之前的分词结合起来。将以下内容添加到index.js中:
console.log("Tokenized and stemmed:");
console.log(
(new natural.WordTokenizer())
.tokenize(
"Writing and write, lucky and luckies, part parts and parted"
)
.map(natural.PorterStemmer.stem)
使用yarn start运行代码,你会看到以下内容:
Tokenized and stemmed:
[ 'write', 'and', 'write',
'lucki', 'and', 'lucki',
'part', 'part', 'and', 'part' ]
这个简单的例子说明了不同形式的单词是如何被简化为其概念意义的。它还说明了,并不能保证词干提取器会创建出真实的单词(你不会在词典中找到lucki),而只是它会为一系列结构相似的单词减少熵。
有其他词干提取算法试图从更语言学角度来解决这个问题。这种类型的词干提取被称为词元化,而词元的对应物称为词元,或单词的词典形式。本质上,词元化器是一个词干提取器,它首先确定单词的词性(通常需要一个词典,如WordNet),然后应用针对该特定词性的深入规则,可能涉及更多的查找表。例如,单词better在词干提取中保持不变,但通过词元化它被转换成单词good。在大多数日常任务中,词元化并不是必要的,但在你的问题需要更精确的语言学规则或显著减少熵时可能是有用的。
我们在讨论自然语言处理或语言学时,不能不讨论最常见的交流方式:语音。语音转文字或文字转语音系统实际上是如何知道如何说出英语中定义的数十万个单词,以及任意数量的名字的呢?答案是声音学。
声音学
语音检测,如语音转文字系统中使用的,是一个出人意料困难的问题。说话的风格、发音、方言和口音,以及节奏、音调、速度和发音的变化如此之多,再加上音频是一个简单的一维时间域信号的事实,因此,即使是当今最先进的智能手机技术也只是良好,而非卓越。
虽然现代语音转文字技术比我要展示的深入得多,但我希望向你展示声音学算法的概念。这些算法将一个单词转换成类似声音散列的东西,使得识别听起来相似的字词变得容易。
元音算法就是这样一种声音学算法。它的目的是将一个单词简化为一个简化的声音形式,最终目标是能够索引相似的发音。元音算法使用 16 个字符的字母表:0BFHJKLMNPRSTWXY。0 字符代表th音,X代表sh或ch音,其他字母按常规发音。几乎所有的元音信息都在转换中丢失,尽管如果它们是一个单词的第一个声音,一些元音会被保留。
一个简单的例子说明了音位算法可能在哪里有用。想象一下,你负责一个搜索引擎,人们不断搜索“知识就是力量,法国是培根”。你熟悉艺术史,会明白实际上是弗朗西斯·培根说过“知识就是力量”,而你的用户只是听错了引言。你希望在你的搜索结果中添加一个“你是指:弗朗西斯·培根”的链接,但你不知道如何解决这个问题。
让我们看看 Metaphone 算法如何将France is Bacon和Francis Bacon这两个术语音位化。在index.js中添加以下内容:
console.log(
(new natural.WordTokenizer())
.tokenize("Francis Bacon and France is Bacon")
.map(t => natural.Metaphone.process(t))
);
当你使用yarn start运行代码时,你会看到以下内容:
[ 'FRNSS', 'BKN', 'ANT', 'FRNS', 'IS', 'BKN' ]
弗朗西斯已经变成了FRNSS,法国变成了FRNS,而培根变成了BKN。直观上,这些字符串代表了用来发音单词的最易区分的音素。
在音位化之后,我们可以使用 Levenshtein 距离来衡量两个单词之间的相似度。如果你忽略空格,FRNSS BKN和FRNS IS BKN之间只有一个 Levenshtein 距离(添加了I);因此这两个短语听起来非常相似。你可以使用这些信息,结合搜索词的其余部分和反向查找,来确定France is Bacon是Francis Bacon可能的误读,并且Francis Bacon实际上是你在搜索结果中应该展示的正确主题。像France is Bacon这样的音位拼写错误和误解非常普遍,以至于我们在一些拼写检查工具中也使用它们。
在语音到文本系统中,使用了一种类似的方法。录音系统尽力捕捉你发出的特定元音和辅音音素,并使用音位索引(音位映射到各种词典单词的反向查找)来提出一组候选单词。通常,一个神经网络将确定哪种单词组合最有可能,考虑到音位形式的置信度和结果语句的语义意义或无意义。最有意义的单词集就是展示给你的。
natural.js库还提供了一个方便的方法来比较两个单词,如果它们听起来相似则返回true。尝试以下代码:
console.log(natural.Metaphone.compare("praise", "preys"));
console.log(natural.Metaphone.compare("praise", "frays"));
运行时,这将返回true然后false。
当你的问题涉及发音或处理类似发音的单词和短语时,你应该考虑使用音位算法。这通常限于更专业的领域,但语音到文本和文本到语音系统变得越来越受欢迎,你可能会发现自己需要更新你的搜索算法以适应语音相似音素,如果用户未来通过语音与你服务互动的话。
说到语音系统,现在让我们看看 POS 标注以及它是如何用于从短语中提取语义信息的——例如,您可能对智能手机助手下达的命令。
词性标注
词性(POS)标注器分析一段文本,如一个句子,并确定句子中每个单词的词性。唯一实现这一点的方法是字典查找,因此它不是一个仅从第一原理开发的算法。
POS 标注的一个很好的用例是从命令中提取意图。例如,当你对 Siri 说“请从约翰的比萨店为我订一份披萨”时,人工智能系统将使用词性对命令进行标注,以便从命令中提取主语、谓语、宾语以及任何其他相关细节。
此外,POS 标注通常用作其他 NLP 操作的辅助工具。例如,主题提取就大量使用了 POS 标注,以便将人、地点和主题从动词和形容词中分离出来。
请记住,由于英语语言的歧义性,POS 标注永远不会完美。许多词既可以作名词也可以作动词,因此许多 POS 标注器将为给定单词返回一系列候选词性。执行 POS 标注的库具有广泛的复杂性,从简单的启发式方法到字典查找,再到基于上下文尝试确定词性的高级模型。
compromise.js 库具有灵活的 POS 标注器和匹配/提取系统。compromise.js 库的独特之处在于它旨在“足够好”但不是全面的;它仅训练了英语中最常见的单词,这对于大多数情况来说足够提供 80-90% 的准确性,同时仍然是一个快速且小巧的库。
让我们看看 compromise.js 的 POS 标注和匹配的实际效果。将以下代码添加到 index.js 中:
const siriCommand = "Hey Siri, order me a pizza from John's pizzeria";
const siriCommandObject = compromise(siriCommand);
console.log(siriCommandObject.verbs().out('array'));
console.log(siriCommandObject.nouns().out('array'));
使用 compromise.js 允许我们从命令中提取仅动词,或仅名词(以及其他词性)。使用 yarn start 运行代码将产生:
[ 'order' ]
[ 'siri', 'pizza', 'john\'s pizzeria' ]
POS 标记器已将 order 识别为句子中的唯一动词;然后可以使用此信息来加载 Siri 人工智能系统中内置的用于下订单的正确子程序。然后可以将提取出的名词发送到子程序,以确定要下何种类型的订单以及从哪里下。
令人印象深刻的是,POS 标注器还将 John's pizzeria 识别为一个单独的名词,而不是将 John's 和 pizzeria 视为单独的名词。标注器已经理解 John's 是一个所有格,因此适用于其后的单词。
我们还可以使用 compromise.js 编写用于常见命令的解析和提取规则。让我们试一个例子:
console.log(
compromise("Hey Siri, order me a pizza from John's pizzeria")
.match("#Noun [#Verb me a #Noun+ *+ #Noun+]").out('text')
);
console.log(
compromise("OK Google, write me a letter to the congressman")
.match("#Noun [#Verb me a #Noun+ *+ #Noun+]").out('text')
);
使用 yarn start 运行代码将产生:
order me a pizza from John's
write me a letter to the congressman
相同的匹配选择器能够捕捉这两个命令,通过匹配组(用[]表示)忽略命令的接收者(Siri 或 Google)。因为这两个命令都遵循动词-名词-名词的模式,所以两者都会匹配选择器。
当然,仅凭这个选择器本身是不够构建一个完整的 AI 系统,如 Siri 或 Google Assistant 的。这个工具将在 AI 系统过程的早期使用,以便根据预定义但灵活的命令格式确定用户的整体意图。你可以编程一个系统来响应诸如“打开我的#名词”这样的短语,其中名词可以是“日历”、“电子邮件”或Spotify,或者“给#名词写一封电子邮件”,等等。这个工具可以用作构建自己的语音或自然语言命令系统的第一步,以及用于各种主题提取应用。
在本章中,我们讨论了 NLP 中使用的基石工具。许多高级 NLP 任务将 ANN 作为学习过程的一部分,但对于许多新手实践者来说,如何将单词和自然语言发送到 ANN 的输入层并不明确。在下一节中,我们将讨论“词嵌入”,特别是 Word2vec 算法,它可以用来将单词输入到 ANN 和其他系统中。
词嵌入和神经网络
在本章中,我们讨论了各种 NLP 技术,特别是关于文本预处理。在许多用例中,我们需要与 ANN 交互以执行最终分析。分析的类型与这一节无关,但想象你正在开发一个情感分析 ANN。你适当地标记和词干化你的训练文本,然后,当你尝试在预处理后的文本上训练你的 ANN 时,你意识到你不知道如何将单词输入到神经网络中。
最简单的方法是将网络中的每个输入神经元映射到一个独特的单词。在处理文档时,你可以将输入神经元的值设置为该单词在文档中的词频(或绝对计数)。你将拥有一个网络,其中一个输入神经元对单词“时尚”做出反应,另一个神经元对“技术”做出反应,另一个神经元对“食物”做出反应,等等。
这种方法可以工作,但它有几个缺点。ANN 的拓扑结构必须预先定义,因此在开始训练网络之前,你必须知道你的训练集中有多少独特的单词;这将成为输入层的大小。这也意味着一旦网络被训练,它就无法学习新单词。要向网络添加新单词,你实际上必须从头开始构建和训练一个新的网络。
此外,在整个文档语料库中,你可能会遇到成千上万的独特单词。这会对 ANN 的效率产生巨大的负面影响,因为你将需要一个有 10,000 个神经元的输入层。这将大大增加网络所需的训练时间,以及系统的内存和处理需求。
每个神经元对应一个单词的方法在直观上感觉效率不高。虽然你的语料库包含 10,000 个独特的单词,但其中大多数将是罕见的,并且只出现在少数文档中。对于大多数文档,只有几百个输入神经元会被激活,其他则设置为零。这相当于所谓的稀疏矩阵或稀疏向量,或者是一个大部分值都是零的向量。
因此,当自然语言与人工神经网络(ANNs)交互时,需要一种更高级的方法。一种被称为词嵌入的技术族可以分析文本语料库,并将每个单词转换为一个固定长度的数值向量。这个向量与哈希(如 md5 或 sha1)作为任意数据的固定长度表示方式类似,也是单词的固定长度表示。
词嵌入提供了几个优势,尤其是在与人工神经网络结合使用时。由于单词向量长度固定,网络的拓扑结构可以在事先决定,并且也可以处理初始训练后新词的出现。
单词向量也是密集向量,这意味着你不需要在你的网络中有 10,000 个输入神经元。单词向量(以及输入层)的大小一个好的值是在 100-300 项之间。这个因素本身就可以显著降低你的 ANN 的维度,并允许模型训练和收敛更快。
有许多词嵌入算法可供选择,但当前最先进的选项是谷歌开发的 Word2vec 算法。这个特定的算法还有一个令人向往的特性:在向量表示方面,相似的单词会聚集在一起。
在本章的早期,我们看到了我们可以使用字符串距离来衡量两个单词之间的印刷距离。我们还可以使用两个单词的音位表示之间的字符串距离来衡量它们听起来有多相似。当使用 Word2vec 时,你可以测量两个单词向量之间的距离,以获取两个单词之间的概念距离。
Word2vec 算法本身是一个浅层神经网络,它在你文本语料库上自我训练。该算法使用 n-gram 来发展单词之间的上下文感觉。如果你的语料库中“时尚”和“博主”经常一起出现,Word2vec 将为这些单词分配相似的向量。如果“时尚”和“数学”很少一起出现,它们的结果向量将被一定距离分开。因此,两个词向量之间的距离代表了它们的概念和上下文距离,或者两个单词在语义内容和上下文方面有多相似。
Word2vec 算法的这一特性也赋予了最终处理数据的 ANN 自己的效率和准确性优势,因为词向量将为相似单词激活相似的输入神经元。Word2vec 算法不仅降低了问题的维度,还为词嵌入添加了上下文信息。这种额外的上下文信息正是 ANN 非常擅长捕捉的信号类型。
以下是一个涉及自然语言和人工神经网络的常见工作流程示例:
-
对所有文本进行分词和词干提取
-
从文本中移除停用词
-
确定适当的 ANN 输入层大小;使用此值既用于输入层也用于 Word2vec 的维度
-
使用 Word2vec 为你的文本生成词嵌入
-
使用词嵌入来训练 ANN 以完成你的任务
-
在评估新文档时,在将其传递给 ANN 之前对文档进行分词、词干提取和向量化
使用 Word2vec 等词嵌入算法不仅可以提高你模型的速度和内存性能,而且由于 Word2vec 算法保留的上下文信息,它可能还会提高你模型的准确性。还应注意的是,Word2vec 就像 n-gram 分词一样,是欺骗朴素词袋算法考虑词上下文的一种可能方式,因为 Word2vec 算法本身使用 n-gram 来开发嵌入。
虽然词嵌入主要在自然语言处理中使用,但同样的方法也可以用于其他领域,例如遗传学和生物化学。在这些领域中,有时能够将蛋白质或氨基酸序列向量化是有利的,这样相似的结构的向量嵌入也将相似。
摘要
自然语言处理是一个研究领域,拥有许多高级技术,并在机器学习、计算语言学和人工智能中有广泛的应用。然而,在本章中,我们专注于在日常工作任务中最普遍使用的特定工具和策略。
本章中介绍的技术是构建模块,可以混合搭配以实现许多不同的结果。仅使用本章中的信息,你可以构建一个简单的全文搜索引擎,一个用于语音或书面命令的意图提取器,一个文章摘要器,以及许多其他令人印象深刻的工具。然而,当这些技术与高级学习模型(如 ANNs 和 RNNs)结合时,NLP 的最令人印象深刻的应用才真正出现。
尤其是您学习了关于单词度量,如字符串距离和 TF-IDF 相关性评分;预处理和降维技术,如分词和词干提取;语音算法,如 Metaphone 算法;词性提取和短语解析;以及使用词嵌入算法将单词转换为向量。
您还通过众多示例介绍了两个优秀的 JavaScript 库,natural.js和compromise.js,这些库可以轻松完成与机器学习相关的多数 NLP 任务。您甚至能用 20 行代码编写一个文章摘要器!
在下一章中,我们将讨论如何将您迄今为止所学的一切整合到一个实时、面向用户的 JavaScript 应用程序中。
第十一章:在实时应用中使用机器学习
在本书中,你已经学习了许多机器学习算法和技术。然而,剩下的工作是将这些算法部署到现实世界的应用中。本章专门讨论与在现实世界、实际应用和生产环境中使用机器学习相关的建议。
理想化的机器学习算法使用与实际使用之间存在许多差异。在我们的示例中,我们一步训练和执行模型,响应一个命令。我们假设模型不需要以任何方式序列化、保存或重新加载。我们没有考虑用户界面的响应性、在移动设备上执行或构建客户端和服务器之间的 API 接口。
真实应用的范围可能比我们讨论的例子大几个数量级。你如何在一个包含数十亿数据点的数据集中训练一个人工神经网络(ANN)?你如何收集、存储和处理这么多的信息?
在本章中,我们将讨论以下主题:
-
前端架构
-
后端架构
-
数据管道
-
可以用来构建生产级机器学习系统的工具和服务
序列化模型
本书中的示例仅构建、训练和测试模型,然后在毫秒后将其销毁。我们之所以能够这样做,是因为我们的示例使用的是有限的训练数据,最坏的情况也只需要几分钟就能完成训练。在实际应用中,通常会使用更多的数据,并且需要更多的时间来训练。在生产应用中,训练好的模型本身是一项宝贵的资产,应该根据需要存储、保存和加载。换句话说,我们的模型必须是可序列化的。
序列化本身通常不是一个难题。模型本质上是对训练数据的压缩版本。一些模型确实可能非常大,但它们仍然只是训练它们的数据大小的一小部分。使序列化问题变得具有挑战性的是,它引发了许多其他架构问题,你必须考虑的第一个问题就是模型存储的位置和方式。
令人失望的是,没有正确答案。模型可以根据其大小、复杂性、使用频率、可用技术等因素存储在几乎任何地方。朴素贝叶斯分类器只需要存储标记和文档计数,并且仅使用键/值查找,没有高级查询,因此单个 Redis 服务器可以托管一个在数十亿文档上训练的巨大分类器。非常大的模型可以序列化到一个专用数据库中,甚至可能是一个专用的图数据库集群。中等大小的模型可以序列化为 JSON 或二进制格式,并存储在数据库的 BLOB 字段中,托管在文件服务器或 API(如 Amazon S3)上,或者如果足够小,可以存储在浏览器本地存储中。
大多数机器学习库都内置了序列化和反序列化功能,因为最终这种功能依赖于库的实现细节。大多数库包括save()和load()等方法,但你仍需参考你所使用的特定库的文档。
确保在编写自己的库时包含序列化功能。如果你想支持多个存储后端,最好将序列化功能与核心逻辑解耦,并实现一个驱动程序和接口架构。
这只是我们现在需要回答的第一个问题,因为我们已经有一个可序列化的模型。可序列化模型也是可移植的,这意味着它们可以从一台机器移动到另一台机器。例如,你可以将预训练模型下载到智能手机上进行离线使用。你的 JavaScript 应用程序可以使用 Web Worker 下载并维护一个用于语音检测的现成模型,请求麦克风权限,并通过 Chrome 扩展仅通过语音命令使网站可导航。
在本节中,我们将讨论一旦模型可序列化和可移植后出现的各种架构考虑因素。
在服务器上训练模型
由于训练复杂模型涉及的时间、数据、处理能力和内存需求,通常在服务器上而不是在客户端训练模型是可取的。根据用例,模型的评估也可能需要在服务器上完成。
在考虑模型训练和评估的位置方面,有几个范例需要考虑。一般来说,你的选择将是完全在服务器上训练和评估,完全在客户端训练和评估,或者是在服务器上训练但在客户端评估。让我们探讨每个范例的一些示例。
最简单的实现方式是在服务器上同时训练和评估模型。这种方法的优点在于你可以决定并控制模型的整个执行环境。你可以轻松分析训练和执行模型所需的服务器负载,并根据需要调整服务器规模。由于数据很可能存储在你也控制的数据库中,因此完全控制的服务器更容易访问大量训练数据。你不必担心客户端运行的是哪种版本的 JavaScript,或者你是否能够访问客户端的 GPU 进行训练。在服务器上训练和执行模型还意味着由于模型的存在,客户端机器不会增加额外的负载。
完全服务器端方法的缺点主要是需要设计良好的、健壮的 API。如果你有一个需要快速响应时间的模型评估的应用,你需要确保你的 API 能够快速且可靠地提供服务。这种方法还意味着无法进行离线模型评估;客户端需要连接到你的服务器才能使任何操作生效。大多数被称为软件即服务(SaaS)的应用或产品将使用服务器端模型,如果你在向客户提供付费服务,这种方法应该是你首先考虑的。
相反,模型也可以在客户端完全进行训练和评估。在这种情况下,客户端本身需要访问训练数据,并且需要足够的处理能力来训练模型。这种方法通常不适用于需要大量训练集或长时间训练时间的模型,因为没有办法确保客户端的设备能够处理数据。你还得应对那些可能没有 GPU 或处理能力训练甚至简单模型的旧设备。
然而,对于训练数据来自设备本身且需要高度数据隐私或数据所有权的应用来说,客户端训练和评估是一个很好的方法。将处理限制在客户端设备上可以确保用户数据不会被传输到任何第三方服务器,并且可以直接由用户删除。指纹扫描、生物识别分析、位置数据分析、电话分析等应用是采用完全客户端方法的良好候选者。这种方法还确保了模型可以在离线状态下进行训练和评估,无需互联网连接。
在某些情况下,混合方法可以将两者的优点结合起来。需要大量训练数据的高级模型可以在服务器上训练并序列化。客户端在首次连接到你的应用时,可以下载并存储训练好的模型以供离线使用。客户端本身负责评估模型,但在此情况下不需要训练模型。
混合方法允许你在服务器上训练和定期更新复杂模型。序列化模型比原始训练数据小得多,因此可以发送到客户端进行离线评估。只要客户端和服务器使用兼容的库或算法(即,两边都使用TensorFlow.js),客户端就可以利用服务器的处理能力进行训练,但在对评估步骤要求较低的情况下,使用自己的离线处理能力。
混合模型的示例用例包括语音或图像识别,可能是用于人工智能助手或增强现实(AR)应用程序。在 AR 应用程序的情况下,服务器负责维护数百万个训练图像并训练(例如)一个 RNN 来分类物体。一旦训练完成,这个模型就可以被序列化、存储并由客户端下载。
让我们想象一个增强现实(AR)应用程序,该程序连接到设备的摄像头并显示一个标注的视频流,用于识别物体。当应用程序首次启动时,客户端会下载 AR RNN 模型并将其存储在设备的本地存储中,同时存储版本信息。当视频流首次启动时,应用程序从存储中检索模型并将其反序列化到客户端自己的 RNN 实现中。理想情况下,客户端的 RNN 实现将使用与服务器上相同的库和版本。
为了对视频的每一帧进行分类和标注,客户端需要在仅仅 16 毫秒内(对于 60 FPS 的视频)完成所有必要的工作。这是可行的,但在实践中并非每一帧都用于分类;每 3 帧中就有 1 帧(相隔 50 毫秒)就足够了。混合方法在这里表现出色;如果视频的每一帧都需要上传到服务器、评估然后返回,应用程序将遭受严重的性能损失。即使模型性能非常出色——例如,模型评估需要 5 毫秒——你也可能因为 HTTP 请求所需的往返时间而额外体验 100 毫秒的延迟。
在混合方法下,客户端不需要将图像发送到服务器进行评估,而是可以直接根据现在加载到内存中的先前训练模型立即评估图像。一个设计良好的客户端会定期检查服务器以获取模型更新,并在必要时更新它,但仍然允许过时的模型离线运行。当应用程序“正常工作”时,用户最满意,混合模型为你提供了性能和弹性。服务器仅用于可以异步进行的任务,例如下载更新模型或将信息发送回服务器。
因此,混合方法最适合需要大型、复杂模型但模型评估需要非常快速或离线进行的用例。当然,这不是一个绝对规则。还有许多其他情况下,混合方法最为合理;如果你有多个客户端且无法承担服务器资源来处理所有他们的评估,你可能使用混合方法来卸载你的处理责任。
在设计执行模型训练或评估的客户端应用程序时,必须格外小心。虽然评估比训练快得多,但如果实现不当,它仍然是非平凡的,可能会在客户端引起 UI 性能问题。在下一节中,我们将探讨一个现代网络浏览器功能,称为 web workers,它可以用于在独立线程中执行处理,保持你的 UI 响应。
Web workers
如果你正在为网络浏览器应用程序开发,你当然会想使用 web worker 在后台管理模型。Web workers 是一个浏览器特定功能,旨在允许后台处理,这正是我们在处理大型模型时想要的。
Web workers 可以与 XMLHttpRequest、IndexedDB 和 postMessage 交互。Web worker 可以使用 XMLHttpRequest 从服务器下载模型,使用 IndexedDB 本地存储它,并使用 postMessage 与 UI 线程通信。这三个工具结合使用,为响应式、高性能以及可能离线体验提供了完整的基础。其他 JavaScript 平台,如 React Native,也具有类似的 HTTP 请求、数据存储和进程间通信功能。
Web workers 可以与其他浏览器特定功能(如 service workers 和设备 API)结合使用,以提供完整的离线体验。Service workers 可以缓存特定资产以供离线使用,或智能地在在线和离线评估之间切换。浏览器扩展平台以及如 React Native 这样的移动平台也提供了一系列机制来支持缓存数据、后台线程和离线使用。
不论是哪个平台,概念都是相同的:当有互联网连接时,应用程序应该异步下载和上传数据;应用程序应该缓存(并版本控制)它需要运行的任何内容,如预训练模型;并且应用程序应该独立于 UI 评估模型。
容易错误地假设模型足够小且运行速度快,可以与 UI 在同一线程中运行。如果你的平均评估时间仅为 5 毫秒,并且你每 50 毫秒只需要进行一次评估,那么可能会变得自满,并跳过在单独线程中评估模型的额外细节。然而,市场上各种设备的范围使得你甚至不能假设性能上有数量级的相似性。例如,如果你在一个带有 GPU 的现代手机上测试了你的应用程序,你可能无法准确评估它在旧手机 CPU 上的性能。评估时间可能会从 5 毫秒跳到 100 毫秒。在设计不良的应用程序中,这会导致 UI 延迟或冻结,但在设计良好的应用程序中,UI 将保持响应,但更新频率较低。
幸运的是,Web Worker 和postMessage API 使用简单。IndexedDB API 是一个低级 API,最初可能难以使用,但有许多用户友好的库可以抽象出细节。你下载和存储预训练模型的具体方式完全取决于你应用程序的实现细节和所选的具体机器学习算法。较小的模型可以序列化为 JSON 并存储在IndexedDB中;更高级的模型可以直接集成到IndexedDB中。确保在你的服务器端 API 中包含一个比较版本信息的机制;你应该有一种方法可以询问服务器当前模型的版本,并将其与自己的副本进行比较,以便可以使其无效并更新模型。
在设计你的 Web Worker 的消息传递 API 时也要多加思考。你将使用postMessageAPI(在所有主流浏览器中都可用)来在 UI 线程和后台线程之间进行通信。这种通信至少应该包括检查模型状态的方法以及向模型发送数据点以供评估的方法。但你也会希望展望未来的功能,并使你的 API 灵活且具有前瞻性。
你可能需要计划的功能示例包括持续改进的模型,这些模型根据用户反馈重新训练,以及针对每个用户的模型,这些模型学习单个用户的行为或偏好。
持续改进和针对每个用户的模型
在你应用程序的生命周期中,最终用户很可能会以某种方式与你的模型进行交互。通常,这种交互可以用作进一步训练模型的反馈。这种交互还可以用来根据用户的需求定制模型,以适应他们的兴趣和行为。
两个概念的良例是垃圾邮件过滤器。垃圾邮件过滤器应该随着用户将消息标记为垃圾邮件而不断改进。当垃圾邮件过滤器拥有大量数据点用于训练时,它们最为强大,而这些数据可以来自应用程序的其他用户。每当用户将一条消息标记为垃圾邮件时,这种知识应该应用于模型,并且其他用户也应该能够享受到他们自己垃圾邮件过滤器的自动改进。
垃圾邮件过滤器也是应该针对每个用户定制的模型的良例。我认为是垃圾邮件的东西可能和你认为的不同。我积极地标记那些我没有注册的营销邮件和新闻通讯为垃圾邮件,但其他用户可能希望在自己的收件箱中看到这些类型的消息。同时,有些消息是每个人都同意是垃圾邮件的,因此设计我们的应用程序以使用一个中央、持续更新的模型会很好,这个模型可以本地优化以更好地适应特定用户的行为。
贝叶斯分类器非常适合这种描述,因为贝叶斯定理是为了通过新信息进行更新而设计的。在第五章,“分类算法”中,我们讨论了 Naive Bayes 分类器的实现,该实现能够优雅地处理稀有词汇。在该方案中,一个权重因子将词汇概率偏向中性,这样稀有词汇就不会对模型产生过强的干扰。一个针对用户的垃圾邮件过滤器可以使用同样的技术,但不是将词汇偏向中性,而是偏向中心模型的概率。
在这种用法中,稀有词汇的权重因子变成了一个平衡中心模型和本地模型的权重因子。你使权重因子越大,中心模型就越重要,用户影响本地模型所需的时间就越长。较小的权重因子将更敏感于用户反馈,但也可能导致性能的不规律。在典型的稀有词汇实现中,权重因子在 3 到 10 的范围内。然而,在针对用户的模型中,权重因子应该更大——可能是 50 到 1,000,考虑到中心模型是由数百万个示例训练的,不应该轻易被少量本地示例所覆盖。
在将数据发送回服务器以进行持续模型改进时,必须小心谨慎。你不应该将电子邮件消息发送回服务器,因为这会创建一个不必要的安全风险——尤其是如果你的产品不是一个电子邮件托管服务提供商,而只是一个电子邮件客户端。如果你也是电子邮件托管服务提供商,那么你可以简单地发送电子邮件 ID 回服务器,将其标记为垃圾邮件并供模型训练;客户端和服务器将分别维护自己的模型。如果你不是电子邮件托管服务提供商,那么你应该格外小心,确保用户数据的安全。如果你必须将令牌流发送回服务器,那么你应该在传输过程中对其进行加密,并对其进行匿名化。你也可以考虑使用一个在分词和词干提取后对令牌进行盐化和散列的标记器(例如,使用 sha1 或 hmac)。分类器在处理散列数据时与处理可读数据一样有效,但会添加一个额外的混淆层。最后,确保不要记录 HTTP 请求和原始令牌数据。一旦数据以令牌计数的形式进入模型,它就足够匿名化了,但请确保间谍无法将特定的令牌流与特定的用户联系起来。
当然,朴素贝叶斯分类器并不是唯一可以持续更新或根据用户定制的模型。大多数机器学习算法都支持模型的持续更新。如果一个用户指出一个循环神经网络(RNN)在图像分类上犯了错误,那么这个用户的数据点可以被添加到模型的训练集中,模型可以定期完全重新训练,或者可以与新训练示例一起批量更新。
一些算法支持真正实时的模型更新。朴素贝叶斯分类器只需要更新标记和文档计数,这些甚至可能存储在内存中。knn 和 k-means 算法类似地允许在任何时候将数据点添加到模型中。一些用于强化学习的 ANN(人工神经网络)也依赖于实时反馈。
其他算法更适合定期批量更新。这些算法通常依赖于梯度下降或随机方法,并在训练期间需要许多示例的反馈循环;例如,ANN 和随机森林。确实可以使用单个数据点重新训练 ANN 模型,但批量训练更有效。在更新模型时,请注意不要过拟合模型;过多的训练并不总是好事。
在某些情况下,最好基于更新的训练集完全重新训练模型。这样做的一个原因是为了避免训练数据中的短期趋势过拟合。通过完全重新训练模型,你可以确保最近的训练示例与旧的训练示例具有相同的权重;这可能是或可能不是所希望的。如果模型定期自动重新训练,请确保训练算法正在查看正确的信号。它应该能够平衡准确性、损失和方差,以开发可靠的模型。由于机器学习训练在很大程度上是随机的,因此不能保证两次训练运行将以相同的质量或相似的时间完成。你的训练算法应该控制这些因素,并在必要时能够丢弃不良模型,例如,如果在最大训练轮数限制内没有达到目标准确性或损失。
在这一点上,一个新的问题出现了:你如何收集、存储和处理数 GB 或 TB 的训练数据?你如何以及在哪里存储和分发序列化模型给客户?你如何从数百万用户那里收集新的训练示例?这个话题被称为数据管道,我们将在下一节讨论。
数据管道
在开发生产级 ML 系统时,你不太可能得到以可处理格式提供的训练数据。生产级 ML 系统通常是更大应用程序系统的一部分,你使用的数据可能来自多个不同的来源。ML 算法的训练集可能是你更大数据库的一个子集,结合存储在内容分发网络(CDN)上的图像和来自 Elasticsearch 服务器的的事件数据。在我们的示例中,我们得到了一个隔离的训练集,但在现实世界中,我们需要以自动化和可重复的方式生成训练集。
将数据引导通过生命周期各个阶段的过程被称为数据管道。数据管道可能包括运行 SQL 或 Elasticsearch 查询的对象选择器,允许基于事件或日志的数据流入的事件订阅,聚合,连接,将数据与第三方 API 的数据结合,净化,标准化和存储。
在理想的实现中,数据管道充当了更大应用程序环境和 ML 过程之间的抽象层。ML 算法应该能够读取数据管道的输出,而不需要了解数据的原始来源,类似于我们的示例。在这种方法下,ML 算法不需要了解应用程序的实现细节;管道本身负责知道应用程序是如何构建的。
由于可能存在许多可能的数据源和无限多的应用程序架构方式,没有一种数据管道可以适用于所有情况。然而,大多数数据管道将包含以下组件,我们将在接下来的章节中讨论:
-
数据查询和事件订阅
-
数据连接或聚合
-
转换和标准化
-
存储和交付
让我们来看看这些概念,并介绍一些可以实现它们的工具和技术。
数据查询
想象一下像 Disqus 这样的应用程序,它是一个可嵌入的评论表单,网站所有者可以使用它来为博客文章或其他页面添加评论功能。Disqus 的主要功能是允许用户对帖子进行点赞或留言,然而,作为一个额外的功能和收入来源,Disqus 可以提供内容推荐并在赞助内容旁边展示它们。内容推荐系统是一个 ML 系统的例子,它是更大应用程序的一个功能。
在 Disqus 这样的应用中的内容推荐系统并不一定需要与评论数据交互,但可能会使用用户的喜欢历史来生成与当前页面类似的推荐。这样的系统还需要分析喜欢页面的文本内容,并将其与网络中所有页面的文本内容进行比较,以便做出推荐。Disqus 不需要帖子的内容来提供评论功能,但需要在数据库中存储关于页面的元数据(如 URL 和标题)。因此,帖子内容可能不会存储在应用程序的主数据库中,尽管喜欢和页面元数据可能会存储在那里。
建立在 Disqus 推荐系统周围的数据管道首先需要查询主数据库以获取用户喜欢的页面——或者喜欢当前页面的用户所喜欢的页面——并返回它们的元数据。然而,为了找到类似的内容,系统将需要使用每个喜欢帖子的文本内容。这些数据可能存储在单独的系统,比如 MongoDB 或 Elasticsearch 这样的二级数据库,或者 Amazon S3 或其他数据仓库中。该管道需要根据主数据库返回的元数据检索文本内容,并将内容与元数据关联起来。
这是在数据管道早期阶段的一个多数据选择器或数据源的例子。一个数据源是主要应用程序数据,它存储帖子和喜欢元数据。另一个数据源是二级服务器,它存储帖子的文本内容。
该管道的下一步可能涉及找到与用户喜欢的帖子相似的一批候选帖子,这可能通过请求 Elasticsearch 或其他能够找到相似内容的服务来实现。然而,相似的内容并不一定是正确的内容来提供,因此这些候选文章最终将由一个(假设的)人工神经网络(ANN)进行排名,以确定要显示的最佳内容。在这个例子中,数据管道的输入是当前页面,输出是数据管道的一个列表,例如 200 个相似的页面,然后 ANN 将对这些页面进行排名。
如果所有必要的数据都驻留在主数据库中,整个管道可以通过一个 SQL 语句和一些 JOIN 操作来实现。即使在这种情况下,也应该在机器学习算法和数据管道之间开发一定程度的抽象,因为您可能决定在未来更新应用程序的架构。然而,在其他情况下,数据将驻留在不同的位置,因此需要开发一个更周全的管道。
构建这个数据管道有许多方法。你可以开发一个执行所有管道任务的 JavaScript 模块,在某些情况下,你甚至可以使用标准的 Unix 工具编写 bash 脚本来完成任务。在复杂性的另一端,有专门用于数据管道的工具,如 Apache Kafka 和 AWS Pipeline。这些系统设计为模块化,允许你定义特定的数据源、查询、转换和聚合模块,以及连接它们的流程。例如,在 AWS Pipeline 中,你定义 数据节点,这些节点了解如何与你的应用程序中的各种数据源进行交互。
管道的最早阶段通常是某种数据查询操作。必须从更大的数据库中提取训练示例,同时考虑到数据库中的每条记录并不一定是训练示例。例如,在垃圾邮件过滤器的情况下,你应该只选择被用户标记为垃圾邮件或非垃圾邮件的消息。那些被垃圾邮件过滤器自动标记为垃圾邮件的消息可能不应该用于训练,因为这可能会引起正反馈循环,最终导致不可接受的误报率。
类似地,你可能想阻止被你的系统阻止或禁止的用户影响你的模型训练。一个恶意行为者可能会通过对自己数据进行不适当的行为来故意误导机器学习模型,因此你应该将这些数据点作为训练示例排除。
或者,如果你的应用程序要求最近的数据点应该比旧的数据点优先考虑,你的数据查询操作可能需要对用于训练的数据设置基于时间限制,或者选择一个按时间顺序逆序排列的固定限制。无论情况如何,确保你仔细考虑你的数据查询,因为它们是你数据管道中的基本第一步。
然而,并非所有数据都需要来自数据库查询。许多应用程序使用 pub/sub 或事件订阅架构来捕获流数据。这些数据可能是来自多个服务器的活动日志聚合,或者来自多个来源的实时交易数据。在这些情况下,事件订阅者将是你的数据管道的早期部分。请注意,事件订阅和数据查询不是互斥的操作。通过 pub/sub 系统传入的事件仍然可以根据各种标准进行过滤;这仍然是一种数据查询的形式。
当事件订阅模型与批量训练方案结合时,可能会出现一个潜在问题。如果你需要 5,000 个数据点,但每秒只收到 100 个,你的管道需要维护一个数据点的缓冲区,直到达到目标大小。有各种消息队列系统可以协助完成这项工作,例如 RabbitMQ 或 Redis。需要这种功能的管道可能会在队列中保留消息,直到达到 5,000 条消息的目标,然后才将消息释放到管道的其余部分进行批量处理。
如果数据是从多个来源收集的,它很可能需要以某种方式连接或聚合。现在让我们看看需要将数据与外部 API 数据连接的情况。
数据连接和聚合
让我们回到我们的 Disqus 内容推荐系统示例。想象一下,数据管道能够直接从主数据库查询点赞和帖子元数据,但没有系统在应用程序中存储帖子的文本内容。相反,开发了一个以 API 形式存在的微服务,该 API 接受帖子 ID 或 URL,并返回页面的净化文本内容。
在这种情况下,数据管道需要与微服务 API 交互,以获取每个帖子的文本内容。这种方法是完全有效的,尽管如果帖子内容请求的频率很高,可能需要实施一些缓存或存储。
数据管道需要采用与事件订阅模型中消息缓冲类似的方法。管道可以使用消息队列来排队仍需要内容的帖子,并对队列中的每个帖子向内容微服务发出请求,直到队列耗尽。随着每个帖子内容的检索,它被添加到帖子元数据中,并存储在单独的队列中,用于完成请求。只有当源队列耗尽且目标队列满时,管道才应继续下一步。
数据连接不一定需要涉及微服务 API。如果管道从两个需要合并的独立来源收集数据,可以采用类似的方法。管道是唯一需要理解两个数据源和格式之间关系的组件,让数据源和机器学习算法独立于这些细节进行操作。
当需要数据聚合时,队列方法也工作得很好。这种情况的一个例子是,输入是流式输入数据,输出是标记计数或值聚合的管道。在这些情况下,使用消息队列是可取的,因为大多数消息队列确保消息只能被消费一次,从而防止聚合器产生任何重复。当事件流非常高频时,这一点尤其有价值,因为将每个事件作为它到来时进行标记可能会导致备份或服务器过载。
由于消息队列确保每条消息只被消费一次,高频事件数据可以直接流入一个队列,其中消息由多个并行工作的进程消费。每个工作进程可能负责对事件数据进行分词,然后将分词流推送到不同的消息队列。消息队列软件确保没有两个工作进程处理相同的消息,每个工作进程可以作为独立单元运行,只关注分词。
当分词器将结果推送到新的消息队列时,另一个工作进程可以消费这些消息并汇总分词计数,每秒、每分钟或每 1,000 个事件(以适用于应用程序的方式)将自身的结果传递到管道的下一步。这种风格管道的输出可能被输入到一个持续更新的贝叶斯模型中,例如。
以这种方式设计的数据管道的一个好处是性能。如果您试图订阅高频事件数据,对每条消息进行分词,汇总分词计数,并更新模型,您可能被迫使用一个非常强大(且昂贵)的单个服务器。服务器同时需要高性能 CPU、大量 RAM 和高吞吐量网络连接。
然而,通过将管道分解为阶段,您可以针对每个阶段的特定任务和负载条件进行优化。接收源事件流的消息队列只需要接收事件流,但不需要处理它。分词工作进程不一定是高性能服务器,因为它们可以并行运行。汇总队列和工作进程将处理大量数据,但不需要保留数据超过几秒钟,因此可能不需要太多 RAM。最终的模型,即源数据的压缩版本,可以存储在更普通的机器上。由于数据管道鼓励模块化设计,因此数据管道的许多组件可以用通用硬件构建。
在许多情况下,您需要在管道中从一种格式转换数据到另一种格式。这可能意味着将原生数据结构转换为 JSON,转置或插值值,或对值进行哈希处理。现在让我们讨论在数据管道中可能发生的几种数据转换类型。
转换和归一化
当您的数据通过管道传输时,可能需要将其转换为与算法输入层兼容的结构。在管道中的数据可以进行许多可能的转换。例如,为了在数据到达基于分词的分类器之前保护敏感用户数据,您可能需要对分词应用加密哈希函数,这样它们就不再是人类可读的。
更典型的情况是,转换类型将与清理、归一化或转置相关。清理操作可能涉及删除不必要的空白或 HTML 标签,从标记流中删除电子邮件地址,以及从数据结构中删除不必要的字段。如果你的管道已订阅事件流作为数据源,并且事件流将源服务器 IP 地址附加到事件数据中,那么从数据结构中移除这些值是一个好主意,这样既可以节省空间,也可以最大限度地减少潜在数据泄露的表面积。
类似地,如果你的分类算法不需要电子邮件地址,那么管道应该移除这些数据,以便它与尽可能少的服务器和系统交互。如果你设计了一个垃圾邮件过滤器,你可能想考虑只使用电子邮件地址的域名部分而不是完全合格的地址。或者,电子邮件地址或域名可以通过管道进行哈希处理,这样分类器仍然可以识别它们,但人类却不能。
确保审查数据中的其他潜在安全和隐私问题。如果你的应用程序在事件流中收集最终用户的 IP 地址,但分类器不需要这些数据,那么应尽早将其从管道中移除。随着新欧洲隐私法律的实施,这些考虑因素变得越来越重要,每个开发者都应该意识到隐私和合规问题。
数据转换的常见类别之一是归一化。当处理给定字段或特征的数值范围时,通常希望将范围归一化,使其具有已知的最小和最大边界。一种方法是将同一字段的全部值归一化到[0,1]的范围内,使用遇到的最高值作为除数(例如,序列1, 2, 4可以归一化为0.25, 0.5, 1)。数据是否需要以这种方式归一化完全取决于消耗数据的算法。
另一种归一化的方法是转换值成为百分位数。在这个方案中,非常大的异常值不会使算法产生太大的偏差。如果大多数值位于 0 到 100 之间,但少数点包括像 50,000 这样的值,算法可能会给予大值过大的优先级。然而,如果数据以百分位数归一化,那么你保证不会有任何超过 100 的值,异常值也会被纳入与数据其他部分相同的范围。这好不好取决于算法。
数据管道也是计算派生或二阶特征的好地方。想象一个随机森林分类器,它使用 Instagram 个人资料数据来确定个人资料属于人类还是机器人。Instagram 个人资料数据将包括用户的关注者数量、朋友数量、帖子数量、网站、简介和用户名。然而,随机森林分类器在使用这些字段的原有表示时可能会遇到困难,但是通过应用一些简单的数据转换,你可以达到 90%的准确率。
在 Instagram 的情况下,一种有用的数据转换是计算比率。关注者数量和粉丝数量作为单独的特征或信号,可能对分类器没有太大帮助,因为它们被处理得相对独立。但是,朋友与关注者的比率可能成为一个非常强烈的信号,可能会暴露出机器人用户。一个有 1,000 个朋友的 Instagram 用户不会引起任何警报,同样,一个有 50 个粉丝的 Instagram 用户也不会;独立处理,这些特征不是强烈的信号。然而,一个朋友与关注者比率为 20(或 1,000/50)的 Instagram 用户几乎肯定是一个设计来关注其他用户的机器人。同样,像帖子与关注者比或帖子与朋友比这样的比率可能最终比任何单独的特征都强。
文本内容,如 Instagram 用户的个人资料简介、网站或用户名,通过从它们中提取二阶特征也能变得有用。分类器可能无法对网站的 URL 做任何事情,但也许可以用一个布尔值特征has_profile_website作为信号。如果在你的研究中,你注意到机器人的用户名中往往有很多数字,你可以从用户名本身提取特征。一个特征可以计算用户名中字母与数字的比例,另一个布尔值特征可以表示用户名是否以数字开头或结尾,一个更高级的特征可以确定用户名中是否使用了字典中的单词(因此区分@themachinelearningwriter和像@panatoe234这样的乱码)。
提取的特征可以是任何复杂度或简单度。另一个简单的特征可能是 Instagram 个人资料是否在个人资料简介字段中包含 URL(与专门的网站字段相对);这可以通过正则表达式检测,布尔值用作特征。一个更高级的特征可以自动检测用户内容中使用的语言是否与用户指定的地区设置相同。如果用户声称他们在法国,但总是用俄语写标题,这确实可能是一个住在法国的俄罗斯人,但结合其他信号,如关注者与粉丝的比例远非 1,这些信息可能表明是一个机器人用户。
还有一些低级转换可能需要应用于管道中的数据。如果源数据是 XML 格式,但分类器需要 JSON 格式,则管道应负责解析和格式转换。
还可以应用其他数学转换。如果数据的原生格式是面向行的,但分类器需要面向列的数据,则管道可以在处理过程中执行向量转置操作。
同样,管道可以使用数学插值来填充缺失值。如果你的管道订阅了实验室环境中一套传感器发出的事件,并且单个传感器在几次测量中离线,那么在两个已知值之间进行插值以填充缺失数据可能是合理的。在其他情况下,缺失值可以用总体均值或中位数来替换。用均值或中位数替换缺失值通常会导致分类器优先考虑该数据点的特征,而不是通过提供一个空值来破坏分类器。
通常,在数据管道中的转换和归一化方面,有两个方面需要考虑。第一个是源数据和目标格式的机械细节:XML 数据必须转换为 JSON,行必须转换为列,图像必须从 JPEG 格式转换为 BMP 格式,等等。这些机械细节并不太复杂,因为你已经知道系统所需的源和目标格式。
另一个考虑因素是您数据的语义或数学转换。这是一个特征选择和特征工程练习,并不像机械转换那样直接。确定要推导出哪些二阶特征既是一门艺术也是一门科学。艺术在于提出新的衍生特征想法,而科学在于严格测试和实验你的工作。以我在 Instagram 机器人检测方面的经验为例,我发现 Instagram 用户名中的字母与数字比例是一个非常微弱的信号。经过一些实验后,我放弃了这个想法,以避免给问题添加不必要的维度。
到目前为止,我们有一个假设的数据管道,它收集数据,将其连接和聚合,处理它,并将其归一化。我们几乎完成了,但数据仍需要交付给算法本身。一旦算法被训练,我们可能还希望序列化模型并存储它以供以后使用。在下一节中,我们将讨论在传输和存储训练数据或序列化模型时需要考虑的一些因素。
存储和交付数据
一旦你的数据处理管道完成了所有必要的处理和转换,它剩下的任务就是将数据传递给你的算法。理想情况下,算法不需要了解数据管道的实现细节。算法应该有一个单一的位置可以与之交互,以获取完全处理过的数据。这个位置可能是一个磁盘上的文件,一个消息队列,一个如 Amazon S3 这样的服务,一个数据库,或者一个 API 端点。你选择的方法将取决于你可用的资源,你的服务器系统的拓扑或架构,以及数据的格式和大小。
只定期训练的模型通常是处理起来最简单的情况。如果你正在开发一个图像识别 RNN,它学习大量图像的标签,并且只需要每几个月重新训练一次,一个很好的方法是将所有图像以及一个清单文件(将图像名称与标签相关联)存储在 Amazon S3 或磁盘上的专用路径上。算法首先加载并解析清单文件,然后根据需要从存储服务加载图像。
类似地,一个 Instagram 机器人检测算法可能只需要每周或每月重新训练一次。算法可以直接从数据库表、存储在 S3 或本地磁盘上的 JSON 或 CSV 文件中读取训练数据。
这种情况很少发生,但在一些特殊的数据管道实现中,你也可以为算法提供一个作为微服务构建的专用 API 端点;算法会首先查询 API 端点以获取训练点引用的列表,然后依次从 API 请求每个引用。
需要在线更新或近似实时更新的模型,另一方面,最好通过消息队列来提供服务。如果一个贝叶斯分类器需要实时更新,算法可以订阅消息队列,并在更新到来时应用它们。即使使用复杂的分阶段管道,如果你设计好了所有组件,处理新数据和更新模型也可能在几秒钟内完成。
回到垃圾邮件过滤器示例,我们可以设计一个高性能的数据管道,如下所示:首先,一个 API 端点接收用户的反馈。为了保持用户界面的响应性,这个 API 端点只负责将用户的反馈放入消息队列,并且可以在不到一毫秒内完成其任务。然后,数据处理管道订阅消息队列,在另几个毫秒内就会知道有新消息。管道随后对消息应用一些简单的转换,如分词、词干提取,甚至可能对标记进行散列。
管道下一步将把标记流转换成标记及其计数的哈希表(例如,从 hey hey there 转换为 {hey: 2, there: 1});这样可以避免分类器需要多次更新同一个标记的计数。这一处理阶段在最坏的情况下也只需额外几毫秒。最后,完全处理后的数据被放置在一个单独的消息队列中,分类器会订阅这个队列。一旦分类器意识到数据,它就可以立即将更新应用到模型上。如果分类器由 Redis 支持,例如,这一最终阶段也只需几毫秒。
我们所描述的整个过程,从用户反馈到达 API 服务器到模型更新的时间,可能只需要 20 毫秒。考虑到互联网(或任何其他方式)的通信速度受光速限制,纽约和旧金山之间 TCP 数据包往返的最佳情况场景是 40 毫秒;在实际操作中,良好互联网连接的平均跨国家延迟约为 80 毫秒。因此,我们的数据管道和模型能够在用户甚至收到他们的 HTTP 响应之前 20 毫秒就根据用户反馈进行自我更新。
并非每个应用程序都需要实时处理。为 API、数据管道、消息队列、Redis 存储和分类器托管分别管理服务器,在努力和预算方面可能都是过度的。您需要确定最适合您用例的方案。
最后要考虑的不是数据管道相关的问题,而是模型本身的存储和交付,特别是在混合方法中,模型在服务器上训练但在客户端评估的情况下。首先要问自己的问题是模型是否被认为是公共的还是私有的。例如,私有模型不应存储在公共的 Amazon S3 存储桶中;相反,S3 存储桶应设置访问控制规则,并且您的应用程序需要获取一个带有过期时间的签名下载链接(S3 API 可以帮助完成这项工作)。
下一个考虑因素是模型的大小以及客户端下载模型的频率。如果公共模型经常被下载但更新不频繁,使用 CDN 以利用边缘缓存可能是最好的选择。例如,如果您的模型存储在 Amazon S3 上,那么 Amazon CloudFront CDN 将是一个不错的选择。
当然,你总是可以构建自己的存储和交付解决方案。在本章中,我假设了一个云架构,然而如果你只有一个专用的或共址服务器,你可能只想将序列化的模型存储在磁盘上,并通过你的网络服务器软件或应用程序的 API 提供服务。在处理大型模型时,确保考虑如果许多用户同时尝试下载模型会发生什么。如果太多人同时请求文件,你可能会无意中饱和服务器的网络连接,你可能会超出服务器 ISP 设置的任何带宽限制,或者你可能会发现服务器的 CPU 在移动数据时陷入 I/O 等待状态。
如前所述,没有一种适合所有情况的数据管道解决方案。如果你是一个为了乐趣或仅仅为几个用户开发应用程序的爱好者,你有很多数据存储和交付的选择。然而,如果你在一个大型企业项目中以专业身份工作,你将不得不考虑数据管道的所有方面以及它们如何影响应用程序的性能。
我将给阅读这一部分的爱好者提供一条最后的建议。虽然对于爱好项目来说,你确实不需要一个复杂的、实时的数据处理管道,但你仍然应该构建一个。能够设计和构建实时的数据处理管道是一项非常具有市场价值和稀缺的技能,而且很多人都不具备这项技能。如果你愿意投入实践去学习机器学习算法,那么你也应该练习构建性能良好的数据处理管道。我并不是说你应该为每一个爱好项目都构建一个庞大而复杂的数据处理管道——只是说你应该尝试几次,使用几种不同的方法,直到你不仅对概念感到舒适,也对实现感到舒适。熟能生巧,而实践意味着亲自动手。
摘要
在本章中,我们讨论了与生产中机器学习应用相关的许多实际问题。学习机器学习算法当然是构建机器学习应用的核心,但构建应用远不止简单地实现算法。应用最终需要与各种设备上的用户进行交互,因此,仅仅考虑你的应用能做什么是不够的——你还必须计划它将如何以及在哪里被使用。
我们本章开始时讨论了可序列化和可移植的模型,并学习了模型训练和评估的不同架构方法。我们讨论了完全服务器端的方法(常见于 SaaS 产品),完全客户端的方法(对于敏感数据很有用),以及一种混合方法,即模型在服务器上训练但在客户端评估。你还学习了关于 Web Workers 的内容,这是一个有用的浏览器特定功能,你可以使用它来确保在客户端评估模型时有一个性能良好且响应迅速的用户界面。
我们还讨论了持续更新或定期重新训练的模型,以及客户端和服务器之间传递反馈的各种方法。你还学习了关于按用户模型,或者可以由一个中心真实来源训练但可以通过个别用户的特定行为进行优化的算法。
最后,你学习了关于数据管道以及各种管理数据从一系统到下一系统收集、组合、转换和交付的机制。在我们对数据管道的讨论中,一个中心主题是使用数据管道作为机器学习算法和其余生产系统之间的一层抽象。
我想要讨论的最后一个话题是许多机器学习学生都好奇的:你究竟是如何为特定问题选择正确的机器学习算法的?机器学习专家通常发展出一种指导他们决策的直觉,但这种直觉可能需要数年才能形成。在下一章中,我们将讨论你可以使用的实用技术,以缩小针对任何给定问题的适当机器学习算法的选择范围。
第十二章:为您的应用程序选择最佳算法
软件工程过程有三个不同的阶段:构思、实施和部署。本书主要关注过程的实施阶段,这是软件工程师开发项目核心功能(即机器学习算法)和特性的阶段。在最后一章,我们讨论了与部署阶段相关的问题。我们的学习几乎已经完成。
在这一章的最后,我们将转向构思阶段,以完善我们对整个机器学习开发过程的理解。具体来说,我们将讨论如何为给定问题选择最佳算法。机器学习生态系统正在演变,令人畏惧,充满了连经验丰富的软件开发者都感到陌生的术语。我经常看到机器学习的初学者在过程的开始阶段陷入困境,不知道在广阔而陌生的领域中从何入手。他们还没有意识到,一旦你克服了选择算法和解读术语的初步障碍,剩下的旅程就会容易得多。
本章的目标是提供一个指南针,一个简单的指南,你可以用它来在领域中找到自己的道路。选择正确的算法并不总是容易,但有时也是可能的。本章的前几节将教你四个简单的决策点——本质上就是四个多项选择题——你可以使用这些决策点来专注于最适合你项目的算法。在经过这个过程后,大多数情况下,你将只剩下一个或两个算法可供选择。
然后,我们将继续通过讨论与规划机器学习系统相关的其他主题来继续我们的教育。我们将讨论你选择了错误算法的明显迹象,这样你就可以尽早识别错误。你还将学习如何区分使用错误的算法与一个糟糕的实现之间的区别。
我还会给你展示如何结合两种不同的机器学习模型(ML models)的例子,这样你就可以从适合各自任务的独立模型中组合出更大的系统。如果设计得当,这种方法可以产生非常好的结果。
我之所以称这一章为指南针而不是地图,是有原因的。它不是一本涵盖计算机科学家所知的所有机器学习算法的全面指南。就像指南针一样,你也必须运用你的智慧和技能来找到自己的道路。使用本章来找到你自己的项目的起点,然后继续进行你自己的研究。虽然本书中讨论的 20 多种算法和技术为你提供了对整个领域的广泛视角,但它们只是生态系统的一小部分。
当我们来到这本书的结尾时,我想给你一条最后的建议。要成为某个领域的专家,需要持续致力于练习和玩耍。如果你想成为一名世界级的钢琴家,你必须花无数个小时用节拍器进行细致的死记硬背练习,练习指法练习,学习有挑战性的练习曲。
但你还得去“玩”,这是探索发生和创造力发展的地方。经过三十分钟的练习后,钢琴家可能会花三十分钟即兴创作爵士乐,尝试旋律和对位,学习音乐的“je ne sais quoi”或音阶和模式中的情感本质。这种玩耍的探索,创造力中的实验,以死记硬背所不具备的方式发展了音乐的直觉感。死记硬背——细致的工作和学习——反过来又发展了机械感和技能,这是玩耍所不能做到的。练习和玩耍在良性循环中相互提升。有技能的人能够比没有技能的人探索得更远、更深入,而更深层次的东西所带来的兴奋感正是推动技能发展的练习的动力。时间和耐心、练习和玩耍、动机和纪律是你从新手到专家所需要的一切。
机器学习与爵士钢琴相反,但成为专家的道路是相同的。机器学习的死记硬背——相当于练习音阶——是构建和实现算法。我特别推荐从头开始编写算法作为练习;这是真正理解内部发生情况的唯一方法。不要只写一次算法来证明自己可以做到。要在不同的环境中、不同的编程语言、不同的架构、不同的数据集上多次编写算法,并且一直这样做,直到你能够几乎从头到尾地写出整个算法。我相当确信我可以在任何三种编程语言中闭着眼睛编写一个朴素贝叶斯分类器,就像你在编写了数十个之后也能做到的那样。
机器学习的乐趣在于实验。本章是关于为你的应用程序选择最佳算法,但这并不是法律规则。如果你从不实验,你就永远不会对算法或数据发展出丰富的直觉。尝试其他方法、参数或算法的变体,并从实验中学习。你会惊讶于实验成功有多频繁,但更重要的是,实验应该成为你的练习和教育的一部分。
让我们从讨论四个主要决策点开始,这些决策点可以帮助你在算法上磨练技能:
-
学习方式
-
当前任务
-
数据的格式或形式
-
可用资源
我们还将讨论当一切出错时应该做什么,最后我们将讨论将多个模型组合在一起。
学习方式
选择机器学习算法时,首先要考虑学习过程的模式:监督学习、无监督学习或强化学习。这些模式之间几乎没有重叠;一般来说,一个算法要么是监督学习,要么是无监督学习,但不会两者兼具。这大约将你的选择缩小了一半,幸运的是,判断哪种学习模式适用于你的问题非常容易。
监督学习和无监督学习之间的区别在于你是否需要标记的训练示例来教授算法。如果你只有数据点,而没有与之关联的标签或类别,那么你只能执行无监督学习。因此,你必须选择一个无监督学习算法,例如 k-means 聚类、回归、主成分分析(PCA)或奇异值分解。
监督学习和无监督学习之间的另一个明显区别是是否存在判断语义准确性的方法。如果你的应用中判断准确性的概念没有意义(因为你没有标记的训练数据或参考数据),那么你面临的是一个无监督学习问题。
然而,在某些情况下,你可能没有训练数据,但问题最好通过监督学习来解决。认识到拥有训练数据和需要训练数据之间的区别很重要。当你需要训练数据时,你很可能面对的是一个监督学习问题。如果你需要训练数据但没有,你必须想出某种方法来获取训练数据。
为问题生成训练数据的最简单方法是自行生成。在图像分类任务中,你可以手动标记几百张图片来生成你的训练数据。这很耗时,但对于小规模训练集来说可行。
一种更可扩展的方法是使用像 Amazon Mechanical Turk 这样的服务,通过这个服务,你可以支付给人工工作者每张图片 0.05-0.10 美元的标签费用。Mechanical Turk 方法在机器学习研究人员和数据科学家中变得非常流行,因为它是一种快速且可扩展的方式,以合理的成本生成大量训练数据。在 Mechanical Turk 上为 5,000 张图片生成标签可能需要 250 美元,并且可能需要一两天的时间。如果你认为 250 美元很贵,考虑一下如果你亲自标记这 5,000 张图片需要花费多少时间。
有更多巧妙的方法来生成训练数据,例如将责任转移到你的应用程序的用户身上。多年前,当 Facebook 首次引入在照片中标记人的功能时,他们要求照片的上传者围绕每个主题的脸部画一个框并标记他们。经过数十亿张照片的上传,Facebook 拥有一个巨大的训练集,不仅能够识别面部形状,还能识别照片中的特定人物。如今,在标记照片时不再需要围绕人们的脸部画框,通常 Facebook 能够自动识别照片中的每个主题。我们,作为用户,为他们提供了这个庞大的训练集。
监督学习通过在预标注数据上训练算法来显现,其目标是算法从标签中学习,并且能够将这种知识扩展并应用于新的、未见过的例子。如果你发现自己处于一个有很多数据点,但其中只有一些,而不是全部都有正确答案或标签的情况,你可能需要一个监督学习算法。如果你有百万封电子邮件,其中 5,000 封被手动过滤为垃圾邮件或非垃圾邮件,并且如果目标是扩展这种知识到其他 995,000 条消息,那么你正在寻找一个监督学习算法。
如果你需要判断算法的语义准确性,你也必须使用监督学习算法。无监督算法没有真相来源;这些算法将聚类、平滑或外推数据,但没有权威的参考,无法判断算法的准确性。另一方面,监督学习算法可以通过预标注的训练数据作为真相来源来判断其语义准确性。
虽然我们在这本书中没有涵盖强化学习算法,但它们的特点是算法必须通过影响其环境来尝试优化行为。算法的输出与行动结果之间存在一定的距离,因为环境本身就是一个因素。强化学习的一个例子是教 AI 通过扫描屏幕并使用鼠标和键盘控制来玩电子游戏。玩《超级马里奥兄弟》的 AI 只能通过在控制板上按上、下、左、右、A 和 B 的组合来与环境互动。算法的输出在环境中采取行动,环境将奖励或惩罚这些行动。因此,算法试图最大化其奖励,例如通过收集金币、通过关卡、击败敌人以及不落入无底洞。
强化学习算法是应用机器人学、控制系统设计、基于模拟的优化、硬件在环模拟以及许多其他领域的重大主题,这些领域将物理世界与算法世界相结合。当您研究的系统——环境——是一个复杂的黑盒,无法直接建模时,强化学习最为有效。例如,强化学习被用于优化将在任意环境中使用的系统的控制策略,例如必须自主导航未知地形的机器人。
最后,有一些任务只需要优化一个模型已知或可以直接观察的系统。这些只是与机器学习问题有间接关系,更合适地称为优化问题。如果您必须根据当前交通状况从 A 点选择最佳驾驶路线到 B 点,例如,您可以使用遗传算法。如果您必须优化配置不同模型的一小部分参数,您可能尝试网格搜索。如果您必须确定复杂系统的边界条件,蒙特卡洛方法可能有所帮助。如果您必须找到连续系统的全局最优解,那么随机梯度下降可能适合您的目的。
优化算法通常用于解决机器学习算法的问题。确实,用于训练人工神经网络的反向传播算法使用梯度下降作为其优化器。k-means 或其他无监督算法的参数可以通过网格搜索自动调整,以最小化方差。我们在这本书中没有深入讨论优化算法,但您应该了解它们及其用例。
当为您的应用程序选择算法时,从最简单的问题开始:我需要监督学习还是无监督学习?确定您是否拥有或可以生成训练数据;如果您不能,您被迫使用无监督算法。问问自己是否需要判断算法输出的准确性(监督学习),或者您是否只是在探索数据(无监督)。
在您确定了学习模式,这将大致将您的选择减半之后,您可以通过考虑具体任务或研究目标来进一步专注于您需要的算法。
当前任务
最有效地划分机器学习算法的世界的方法是考虑当前任务,或者算法的预期结果和目的。如果您能确定您问题的目标——也就是说,您是否需要根据输入预测连续值,对数据进行分类,对文本进行分类,降低维度等——您就能将您的选择减少到只有几个算法。
例如,在你需要预测一个连续输出值的情况下——例如预测未来某天的服务器负载——你可能会需要一个回归算法。可供选择的回归算法只有少数几个,本指南中的其他决策点将有助于进一步减少这些选项。
在你需要检查数据并识别彼此相似的数据点的情况下,聚类算法将是最合适的。你选择的特定聚类算法将取决于其他决策点,例如数据的格式或形式、关系的线性或非线性,以及你拥有的资源(时间、处理能力、内存等)。
如果你的算法目的是将数据点分类为一打可能的标签之一,你必须从几个分类算法中选择一个。再次强调,从你可用的分类算法家族中选择正确的算法将取决于数据的形式、你对准确度的要求以及施加在你身上的任何资源限制。
初学者在这个阶段面临的一个常见问题是项目实际目标与单个算法能力之间的不明确性。有时,问题的业务目标是抽象的,只部分定义。在这些情况下,业务目标通常只能通过使用几个单独的算法来实现。机器学习的学生可能难以确定为了实现目标必须组合的具体技术步骤。
一个说明性的例子是编写一个分析图像并返回图像内容自然语言描述的应用程序的业务目标。例如,当上传一张公园小径的照片时,目标可能是返回文本“一条小径上有一个公园长椅和垃圾桶,背景有树木”。人们很容易专注于项目的单一业务目标,并假设一个业务目标对应一个算法。
然而,这个例子至少需要两个或三个机器学习算法。首先,一个卷积神经网络(CNN)必须能够识别图像中的对象。然后,另一个算法必须能够确定对象之间的空间关系。最后,一个自然语言处理或机器学习算法必须能够从前两个算法的输出中提取信息,并从该信息中构建自然语言表示。
能够理解业务目标并将其转化为具体的技术步骤需要时间和经验来培养。你必须能够解析业务目标,并反向工作以将其分解为单个子任务。一旦确定了子任务,确定哪个算法最适合每个子任务就成为了这个决策过程中的一个简单练习。我们很快就会讨论算法组合的话题,但到目前为止,重要的启示是,某些业务目标可能需要多个机器学习算法。
在某些情况下,手头的任务将你的选择缩小到只有一个算法。例如,图像中的目标检测最好通过卷积神经网络(CNN)来实现。当然,有许多不同的专门子类型的 CNN 可以执行目标检测(如 RCNN、Fast RCNN、Mask RCNN 等),但在这个案例中,我们能够将竞争范围缩小到仅 CNN。
在其他情况下,手头的任务可以通过几个或许多算法来完成,在这种情况下,你必须使用额外的决策点来选择最适合你应用的最佳算法。例如,情感分析可以通过许多算法来实现。朴素贝叶斯分类器、最大熵模型、随机森林和人工神经网络(尤其是循环神经网络)都可以解决情感分析问题。
你可能还需要组合多个算法以实现情感分析器最佳精度。因此,使用哪种方法的决策不仅取决于手头的任务,还取决于你组合中使用的其他算法的数据的形式和格式。并非每个算法都适用于与其他所有算法组合使用,因此形式和格式决策点实际上是递归的,你需要将其应用于你为追求业务目标而确定的每个子任务。
格式、形式、输入和输出
我所描述的数据的格式和形式包含几个概念。最表面地,数据的格式与输入和输出的具体数据类型(例如,整数、连续数字/浮点数、文本和离散类别)有关。数据的形式封装了数据结构之间的关系以及问题或解决方案空间的整体形状。这些因素可以帮助你在手头的任务提供了多个算法选择的情况下选择合适的算法。
当处理文本(格式)时,例如,你必须考虑文本在问题空间和当前任务中的处理方式;我称之为数据的形式。在过滤垃圾邮件时,通常没有必要映射单个单词之间的关系。在分析文本以进行情感分析时,确实可能需要映射一些单词之间的关系(例如,处理否定或其他语言修饰语),这将需要额外的维度。在解析文本以构建知识图谱时,你需要细致地映射不仅单个单词之间的关系,还有它们的概念意义之间的关系,这需要更高的维度。
在这些例子中,数据格式是相同的——所有三个例子都处理文本,但形式不同。问题空间的结构不同。垃圾邮件过滤器有一个更简单的问题空间,具有较低维度和线性可分的关系;每个单词都是独立处理的。情感分析器具有不同的形式,需要问题空间中的一些额外维度来编码某些单词之间的关系,但并不一定需要所有单词之间所有关系。知识图谱问题需要一个高度维度的空间,将单词之间复杂的语义和空间关系映射进去。
在这些文本分析案例中,你常常可以问自己一系列简单的问题,这些问题有助于你缩小到正确的算法:每个单词是否可以独立处理?我们需要考虑单词是否被其他单词修改(例如不像与不像,或好与非常好)吗?我们需要维护相隔很远的单词之间的关系(例如,一篇在第一段介绍主题但在许多段落之后继续提及该主题的维基百科文章)?
你跟踪的每一项关系都会给问题空间增加一个维度,因此你必须选择一个能够在问题空间维度上有效工作的算法。在处理像知识图谱问题这样的高维问题时使用低维度的算法,例如朴素贝叶斯,是不会得到好结果的;这就像一个人一生都在三维空间中生活,却试图可视化物理中的十维超弦理论空间。
相反,一个高度维度的算法,如长短期记忆(LSTM)RNN,可以解决低维问题,如垃圾邮件过滤,但它有一个代价:训练高度维度算法所需的时间和资源。它们与问题的难度不相称。贝叶斯分类器可以在几十秒内训练数百万份文档,但 LSTM RNN 可能需要数小时来训练同样的任务,并且评估数据点的速度慢一个数量级。即使如此,也不能保证 LSTM RNN 在准确性方面优于贝叶斯分类器。
你还必须考虑形式和维度性,当处理数值数据时。与统计分析相比,时间序列分析需要额外的维度来捕捉数据的顺序性,除了数据的值。这类似于词袋模型文本算法(如朴素贝叶斯)与保留文本顺序的算法(如 LSTM RNN)之间的区别。
最后,数据的结构格式可能也是一个需要考虑的因素,尽管格式通常可以成功转换为更易于处理的格式。在第十章,《实践中的自然语言处理》中,我们讨论了 Word2vec 词嵌入算法,该算法将文本转换为数值向量,以便它们可以作为神经网络(需要数值输入)的输入。格式转换实际上使我们的决策过程更加困难,因为它允许我们从更广泛的算法中选择。使用 Word2vec 意味着我们可以将文本作为输入提供给通常不接受文本的算法,因此给我们提供了更多的选择。
另一种常见的格式转换是对连续数值的量化或分桶。例如,一个从 0 到 10 的连续数值可以量化为三个桶:小、中、大。这种转换允许我们在只处理离散值或类别的算法中使用我们的连续值数据。
你还应该考虑从算法中获取的输出形式和格式。在分类任务中,分类器的名义输出将是一个离散标签,用于描述输入。但并非所有分类器都是同等创建的。决策树分类器将输出一个标签作为其输出,而贝叶斯分类器将输出所有可能标签的概率作为其输出。在这两种情况下,名义输出是一个可能的标签,但贝叶斯分类器还会返回其猜测的置信度以及所有可能猜测的概率分布。在某些任务中,你从算法中需要的可能只是一个单一标签;在其他情况下,概率分布可能是有用的,甚至可能是必需的。
算法输出的形式与模型的数学机制密切相关。这意味着即使没有对算法本身的深入了解,你也可以通过观察其输出的形式来评估模型的数学属性。如果一个分类器将其输出作为概率的一部分,那么它很可能是一个概率分类器,可以用于你怀疑非确定性方法比确定性方法更有效的任务。
同样,如果一个算法返回语义排序的输出(与无序输出相对),那么这表明该算法本身模型化和记住了一些形式的排序。即使你的应用程序不需要直接排序的输出,你也可能选择这个算法,因为你认识到你的数据形式包含了嵌入在数据序数中的信息。另一方面,如果你知道你的数据序数中不包含任何相关信息,那么返回排序输出(因此将序数作为维度进行建模)的算法可能就是过度设计。
如果此时你还有几个算法可供选择,那么最后一步确定最佳算法将是要考虑你所能利用的资源,并将它们与你对准确性和速度的要求进行权衡。
可用资源
通常情况下,从一系列算法选项中很难找出一个明显的胜者。例如,在情感分析问题中,有几种可能的方法,而且通常并不清楚应该选择哪一种。你可以选择带有嵌入否定词的朴素贝叶斯分类器、使用二元组的朴素贝叶斯分类器、LSTM RNN、最大熵模型以及几种其他技术。
如果格式和形式决策点在这里没有帮助你——例如,如果你没有对概率分类器的需求——你可以根据你可用的资源和性能目标来做出决定。贝叶斯分类器轻量级,训练时间快,评估时间非常快,内存占用小,存储和 CPU 需求相对较小。
另一方面,LSTM RNN 是一个复杂的模型,训练时间较长,评估时间适中,对 CPU/GPU 的要求显著,尤其是在训练期间,并且比贝叶斯分类器有更高的内存和存储需求。
如果没有其他因素为你提供如何选择算法的明确指导,你可以根据你或你的应用程序用户可用的资源(CPU、GPU、内存、存储或时间)来做出决定。在这种情况下,几乎总是存在权衡;更复杂的模型通常比简单的模型更准确。当然,这并不总是正确的,因为朴素贝叶斯分类器在垃圾邮件过滤方面始终优于其他方法;但这是因为垃圾邮件检测问题空间的形态和维度。
在某些情况下,你的应用的限制因素将是训练或评估时间。如果一个应用需要 1 毫秒或更少的评估,可能不适合使用人工神经网络(ANN),而一个可以容忍 50 毫秒评估的应用则更加灵活。
在其他情况下,限制因素或可用的资源是算法所需的精度。如果你认为错误的预测是一种资源消耗,你可能能够确定算法所需精度的下限。机器学习的世界并不太不同于高性能体育的世界,因为在金牌和铜牌之间的差距可能只是几秒钟的时间。
这同样适用于机器学习算法:对于特定问题,最先进的算法可能具有 94%的准确率,而更常见的算法可能只有 90%的准确率。如果错误预测的成本足够高,那么这四个百分点的差异可能就是你在选择算法以及投入多少时间和精力解决问题时的决定性因素。另一方面,如果错误预测的成本很低,那么更常见的算法可能基于资源、时间和努力来实现,可能是最佳选择。
如果你仔细考虑这四个决策点——学习模式、当前任务、数据的格式和形式,以及你拥有的资源——你通常会发现最佳算法的选择非常明显。这并不总是如此;有时你将不得不基于你最熟悉的算法和流程做出判断。
有时候,在选择了一个算法之后,你会发现你的结果无法接受。立即放弃你的算法并选择一个新的算法可能会很有诱惑力,但在这里要小心,因为通常很难区分算法选择不当和算法配置不当。因此,你必须准备好在一切出错时调试你的系统。
当出错时
在机器学习中,可能的不理想结果范围很广。这些可能包括模型根本不起作用,或者模型虽然有效但在这个过程中使用了不必要的资源。负面结果可能由许多因素引起,例如选择不适当的算法、特征工程不良、不当的训练技术、预处理不足或结果解释错误。
在最佳情况下——即负面结果的最好情况——问题将在你实施早期阶段自行显现。你可能会在训练和验证阶段发现你的 ANN 从未达到超过 50%的准确率。在某些情况下,ANN 在经过几个训练周期后可能会迅速稳定在 25%的准确率,并且不再提高。
在这种方式下训练过程中显现出来的问题是最容易调试的。一般来说,这些都是你选择了错误算法的迹象。在第五章《分类算法》中,我向你介绍了随机森林分类器,我们发现它在我们示例问题中的准确率低得无法接受。使用随机森林的决定仅仅是错误的决策吗?或者这是一个合理的决策,但由于参数选择不当而受阻?在这种情况下,答案是都不是。我对使用随机森林以及参数选择都很有信心,所以我用不同的编程语言将相同的数据和参数通过一个随机森林库运行,得到了更符合我预期的结果。这指向了第三个可能的原因:我选择的随机森林算法库的具体实现可能存在问题。
有时候问题更难调试,尤其是当你没有从第一原理编写算法时。在第五章《分类算法》中,我向你介绍了随机森林分类器,我们发现它在我们示例问题中的准确率低得无法接受。使用随机森林的决定仅仅是错误的决策吗?或者这是一个合理的决策,但由于参数选择不当而受阻?在这种情况下,答案是都不是。我对使用随机森林以及参数选择都很有信心,所以我用不同的编程语言将相同的数据和参数通过一个随机森林库运行,得到了更符合我预期的结果。这指向了第三个可能的原因:我选择的随机森林算法库的具体实现可能存在问题。
不幸的是,如果没有经验带来的信心,很容易假设随机森林只是那个问题的算法选择不当。这就是为什么我鼓励实践和玩耍,理论和实验。如果没有对随机森林背后概念的彻底理解,我可能被误导,认为算法本身是问题所在,而且如果没有实验的倾向,我可能永远无法确认算法和参数确实合适。
当事情出错时,我的建议是回归到第一原理。回到你的工程设计过程的最初阶段,依次考虑每一步。算法的选择是否合适?数据的形式和格式是否合适?算法能否足够地解决问题空间的维度?我是否适当地训练了算法?问自己这些问题,直到你确定你最没有信心的一项,然后开始探索和实验。
从我的角度来看,机器学习的最坏情况是一个“无声失败”的算法。这些情况是指一个算法在训练和验证中成功,被部署到实际应用中,但使用现实世界数据产生了较差的结果。该算法未能推广其知识,只是足够好地记住了训练数据以通过验证。这种无声的失败发生是因为算法在验证期间显示出良好的准确性,让你产生了虚假的安全感。然后你将算法部署到生产环境中,信任其结果,但几个月或一年后发现算法表现极差,做出了影响真实人或过程的错误决策,现在需要纠正。
因此,你必须始终监控你的算法在实际工作负载下的性能。你应该定期抽查算法的工作,以确保其在现实世界中的准确性与你训练期间观察到的相当。如果一个算法在训练期间达到 85%的准确性,而在对 20 个数据点的生产抽查中产生了 15 个正确答案(75%),那么这个算法可能正在按预期工作。然而,如果你发现只有 50%的现实世界评估是正确的,你应该扩大对算法的审计,并可能基于从更真实的数据点中抽取的更新后的训练集重新训练它。
这些无声的失败通常是由过度训练或训练不当引起的。即使你遵循了训练的最佳实践,并将预标记的数据集分成单独的训练和验证集,仍然可能过度训练和泛化不足。在某些情况下,源数据本身可能存在问题。例如,如果你的整个训练和验证集都是根据大学生的调查结果生成的,那么模型可能无法准确评估老年人的调查结果。在这种情况下,即使你在独立数据集上验证了你的模型,训练和验证数据本身也不是现实世界条件的适当随机抽样。在实际使用中,你将看到比验证结果更低的准确性,因为用于训练模型的数据源已被选择偏差所损害。
类似的情况也可能发生在其他类型的分类任务中。一个在电影评论上训练的情感分析算法可能无法推广到餐厅评论;这两个数据源之间的术语和语气——数据的形式——可能不同。
如果你的模型表现不佳,你真正不知道下一步该做什么,转向实验和探索。使用相同的训练集测试不同的算法并比较结果。尝试生成一个新的训练集,要么更广泛,要么更窄。尝试不同的标记化技术,不同的预处理技术,甚至可能尝试同一算法的不同实现。在网上搜索其他研究人员如何处理类似问题,最重要的是,永远不要放弃。挫折是学习过程的一部分。
模型组合
有时,为了实现单一的商业目标,你需要结合多个算法和模型,并将它们协同使用来解决单个问题。实现这一目标有两种主要方法:串联组合模型和并行组合模型。
在模型串联组合中,第一个模型的输出成为第二个模型的输入。一个简单的例子是在分类器 ANN 之前使用的 Word2vec 词嵌入算法。Word2vec 算法本身就是一个 ANN,其输出被用作另一个 ANN 的输入。在这种情况下,Word2vec 和分类器是分别训练但一起评估的,串联进行。
你也可以将 CNN 视为模型的串联组合;每一层(卷积、最大池化和全连接)的操作都有不同的目的,本质上是一个独立的模型,其输出为下一层提供输入。然而,在这种情况下,整个网络既被评估也被作为一个单一单元进行训练。
并行运行的模型通常被称为集成,随机森林是一个简单的例子。在随机森林中,许多单独的决策树并行运行,并将它们的输出组合。更普遍地说,并行运行的模型不需要是相同类型的算法。例如,在分析情感时,你可以并行运行一个二元朴素贝叶斯分类器和一个 LSTM RNN,并使用它们输出的加权平均来产生比单独运行更准确的结果。
在某些情况下,你可以组合模型以更好地处理异构数据。让我们想象一个商业目标,即根据用户的书面内容和从其个人资料中衍生出的许多其他特征,将用户分类到十个心理测量类别之一。也许目标是分析 Twitter 上的用户,以确定将他们放置在哪个营销垂直领域:时尚达人,周末战士,运动狂热者等等。你可以使用的数据包括他们推文历史的文本内容,他们的朋友列表和内容互动,以及一些衍生指标,如平均发帖频率,平均 Flesch-Kincaid 阅读难度,关注者与朋友的比例等等。
这个问题是一个分类任务,但它是一个需要通过多个技术步骤才能实现的企业目标。因为输入数据不均匀,我们必须将问题分解成几个部分,并分别解决。然后我们将这些部分组合起来,以形成一个准确且高效的机器学习系统。
首先,我们可以获取用户的推文文本内容,并通过朴素贝叶斯分类器来决定内容最适合的 10 个类别之一。该分类器将返回一个概率分布,例如 5% fashionista,60% sports junkie,和 25% weekend warrior。这个分类器单独可能不足以解决问题;周末战士和运动狂热者倾向于写类似的话题,贝叶斯分类器无法区分两者,因为它们有很多重叠。
幸运的是,我们可以将文本分类与其他信号结合起来,例如用户在推特上发布图片的频率,他们在周末和周中发推文的频率等等。像随机森林这样的算法,可以处理异构输入数据,在这里会很有用。
我们可以采取的方法是使用贝叶斯分类器生成的 10 个概率,将它们与从用户个人资料数据直接导出的另外 10 个特征结合起来,然后将这 20 个特征的组合列表输入到随机森林分类器中。随机森林将学会何时信任贝叶斯分类器的输出,何时更依赖其他信号。例如,当贝叶斯分类器难以区分运动狂热者和周末战士时,随机森林可能会根据额外的上下文在这两者之间做出区分。
此外,随机森林将能够学习何时信任贝叶斯概率,何时不信任。随机森林可能会学会,当贝叶斯分类器以 90% 的概率判断 fashionista 时,它通常是正确的。它可能会以类似的方式学会,当贝叶斯分类器在高概率下判断 weekend warrior 时是不可靠的,并且对于相当一部分时间,周末战士可能会被误认为是运动狂热者。
从直观的角度来看,随机森林是适用于此用例的好算法。因为它基于决策树,能够根据特定属性的值创建决策点。随机森林可能会生成如下逻辑结构:
-
如果 bayes_fashionista_probability 大于 85%,则返回 fashionista
-
如果 bayes_weekend_warrior_probability 大于 99%,则返回 weekend warrior
-
如果 bayes_weekend_warrior_probability 小于 99%,则继续:
-
如果 twitter_weekend_post_frequency 大于 70%,则返回 weekend warrior
-
否则,如果 bayes_sports_junkie_probability 大于 60%,则返回 sports junkie
-
在这个简化的例子中,随机森林已经学会了信任贝叶斯分类器对时尚达人类别的判断。然而,只有当概率非常高时,森林才会信任贝叶斯分类器对周末战士的判断。如果贝叶斯分类器对周末战士的分类不太确定,那么随机森林可以转向用户周末发推文的频率作为一个单独的信号,用于区分周末战士和运动狂热者。
当精心设计时,像这样的组合模型可以是非常强大的工具,能够处理许多情况。这项技术允许你将业务目标分解成多个技术目标,为每种类型的数据或分类选择最佳算法,并将结果组合成一个连贯且自信的响应。
摘要
本书的大部分内容都集中在实现用于解决特定问题的机器学习算法上。然而,算法的实现只是软件开发设计过程的一部分。工程师还必须擅长选择适合她问题的正确算法或系统,并且能够处理出现的问题。
在本章中,你学习了一个简单的四点决策过程,可以帮助你为特定用例选择最佳算法或算法。通过排除法,你可以通过根据每个决策点排除算法来逐步减少你的选择。最明显的是,当你面对监督学习问题时,你不应该使用无监督算法。你可以通过考虑手头的具体任务或业务目标,考虑输入和输出数据的格式和形式或问题空间,以及对你可用的资源进行成本效益分析来进一步排除选项。
我们还讨论了在现实世界中使用机器学习模型时可能出现的某些问题,例如由于训练实践不当而导致的隐蔽的静默故障问题,或者由于算法选择不当或网络拓扑不合适而导致的更明显的故障。
最后,我们讨论了将模型以串联或并行方式组合的想法,以便利用算法的特定优势,尤其是在面对异构数据时。我展示了一个随机森林分类器的例子,它使用直接信号和另一个贝叶斯分类器的输出作为其输入;这种方法有助于消除贝叶斯分类器产生的混淆信号,因为贝叶斯分类器本身可能无法准确解决重叠类别。
我还有很多东西想教给你。这本书仅仅是一个概述,是对机器学习核心概念和算法的快速介绍。我所展示的每一个算法都是一个研究领域,其深度远远超过 10 或 20 页所能教授的内容。
我不期望这本书能解决你所有的机器学习问题,你也不应该这样期望。然而,我希望这本书为你提供了一个坚实的理解基础,你可以在此基础上构建未来的教育。在像机器学习这样的领域,既神秘又充满术语,最大的挑战往往是知道从哪里开始。希望这些页面中的信息已经给你足够的理解和清晰度,能够独自导航更广泛的机器学习世界。
当你阅读完这些最后的页面时,我不期望你在这个机器学习语言的熟练度上已经达到流利,但希望你现在至少能够进行对话。虽然你可能还不能独自设计复杂的 ANN 拓扑结构,但你至少应该对核心概念感到舒适,能够与其他研究人员交流,并找到自己继续深入学习资源的途径。你也可以解决许多以前可能无法解决的问题,如果那样的话,我就达到了我的目标。
我有一个最后的请求:如果你继续你的机器学习教育,尤其是在 JavaScript 生态系统内,请回馈社区。正如你所看到的,今天有很多高质量的 JavaScript 机器学习库和工具,但生态系统中也存在很大的空白。一些算法和技术在 JavaScript 世界中尚不存在,我鼓励你寻找机会,尽可能填补这些空白,无论是通过为开源软件做出贡献,还是为他人编写教育材料。
感谢你抽出时间阅读这本关于 JavaScript 机器学习的谦逊导论——我希望它对你有所帮助。