JavaScript-机器学习实用指南-二-

76 阅读1小时+

JavaScript 机器学习实用指南(二)

原文:annas-archive.org/md5/86fc6595b85c1a353b88aee9d304e735

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:分类算法

分类问题涉及在数据中检测模式,并使用这些模式将数据点分配到一组相似的数据点中。如果这还不够具体,这里有一些分类问题的例子:分析一封电子邮件以确定它是否为垃圾邮件;检测一段文本的语言;阅读一篇文章并将其分类为财经、体育、政治、观点文章或犯罪;以及确定你在 Twitter 上发布的关于产品的评论是正面还是负面(这个最后的例子通常被称为情感分析)。

分类算法是解决分类问题的工具。根据定义,它们是监督学习算法,因为它们始终需要一个标记的训练集来构建模型。有许多分类算法,每个都是基于特定的原则设计的,或者针对特定类型的输入数据。

在本章中,我们将讨论四种分类器:k-最近邻KNN)、朴素贝叶斯、支持向量机SVMs)和随机森林。以下是每个算法的简要介绍:

  • KNN 算法是最简单的分类器之一,当你的数据集具有数值特征和聚类模式时,它表现得很好。在本质上,它与 k-means 聚类算法相似,因为它依赖于绘制数据点和测量点与点之间的距离。

  • 朴素贝叶斯分类器是基于贝叶斯概率的有效且通用的分类器。虽然它可以用于数值数据,但它最常用于文本分类问题,如垃圾邮件检测和情感分析。当正确实现时,朴素贝叶斯分类器对于狭窄领域既快又高度准确。朴素贝叶斯分类器是我首选的分类算法之一。

  • SVMs 在精神上是非常先进的 KNN 算法形式。SVM 绘制你的数据并试图找到你已标记的类别之间的分隔线。通过一些非平凡的数学方法,SVM 可以将非线性模式线性化,因此这个工具对于线性和非线性数据都有效。

  • 随机森林是分类算法中相对较新的发展,但它们既有效又灵活,因此许多研究人员(包括我自己)的首选分类器。随机森林构建了一个决策树集合(我们稍后将要讨论的另一种分类器),每个决策树都包含数据特征的一个随机子集。决策树可以处理数值和分类数据,它们可以执行回归和分类任务,并且还帮助进行特征选择,因此它们正成为许多研究人员面对新问题时首先抓取的工具。

k-最近邻

KNN 是一个简单、快速且直接的分类算法。它对于自然聚类的分类数值数据集非常有用。在某些方面,它将类似于 k-means 聚类算法,但主要区别在于 k-means 是一个无监督算法,而 KNN 是一个监督学习算法。

如果你手动执行 KNN 分析,过程如下:首先,将所有训练数据点绘制在图上,并给每个点标注其类别或标签。当你想要对一个新的、未知的数据点进行分类时,将其放在图上,并找到距离它最近的 k 个点(即 最近邻)。k 应该是一个奇数,以避免平局;3 是一个不错的起点,但某些应用可能需要更多,而某些应用则可以用 1 来完成。报告大多数 k 个最近邻被分类为何种类别,这将作为算法的结果。

找到测试点的 k 个最近邻是直接的,但如果你的训练数据非常大,则可以使用一些优化。通常,在评估一个新点时,你会计算它与每个其他训练点之间的欧几里得距离(我们在第四章[84fd2c4d-41b4-46c4-82e5-4d8e55bb0066.xhtml],使用聚类算法进行分组中介绍的高中几何距离度量),并按距离排序。这个算法相当快,因为训练数据通常不超过 10,000 个点。

如果你有很多训练示例(以百万计)或者你真的需要算法非常快,你可以进行两种优化。第一种是跳过距离度量中的平方根运算,而使用平方距离。虽然现代 CPU 非常快,但平方根运算仍然比乘法和加法慢得多,所以你可以通过避免平方根来节省几毫秒。第二种优化是只考虑距离测试点某个边界矩形内的点;例如,只考虑每个维度上距离测试点位置 +/- 5 个单位的点。如果你的训练数据密集,这种优化不会影响结果,但会加快算法速度,因为它将避免计算许多点的距离。

以下是对 KNN 算法的高级描述:

  1. 记录所有训练数据和它们的标签

  2. 给定一个要评估的新点,生成它到所有训练点的距离列表

  3. 按照从近到远的顺序对距离列表进行排序

  4. 丢弃除了 k 个最近距离之外的所有距离

  5. 确定哪个标签代表了你的 k 个最近邻中的大多数;这是算法的结果

一个更高效的版本通过限制距离列表只包含 k 项来避免维护一个需要排序的大距离列表。现在让我们编写我们自己的 KNN 算法实现。

构建 KNN 算法

由于 KNN 算法相当简单,我们将构建自己的实现:

  1. 创建一个新的文件夹,并将其命名为Ch5-knn

  2. 到该文件夹中添加以下package.json文件。请注意,这个文件与之前的示例略有不同,因为我们为jimp库添加了一个依赖项,这是一个我们将用于第二个示例的图像处理库:

{
  "name": "Ch5-knn",
  "version": "1.0.0",
  "description": "ML in JS Example for Chapter 5 - k-nearest-neighbor",
  "main": "src/index.js",
  "author": "Burak Kanber",
  "license": "MIT",
  "scripts": {
    "build-web": "browserify src/index.js -o dist/index.js -t [ babelify --presets [ env ] ]",
    "build-cli": "browserify src/index.js --node -o dist/index.js -t [ babelify --presets [ env ] ]",
    "start": "yarn build-cli && node dist/index.js"
  },
  "dependencies": {
    "babel-core": "⁶.26.0",
    "babel-plugin-transform-object-rest-spread": "⁶.26.0",
    "babel-preset-env": "¹.6.1",
    "babelify": "⁸.0.0",
    "browserify": "¹⁵.1.0",
    "jimp": "⁰.2.28"
  }
}
  1. 运行yarn install命令以下载和安装所有依赖项,然后创建名为srcdistfiles的子文件夹。

  2. src文件夹内,创建一个index.js文件和一个knn.js文件。

你还需要一个data.js文件。对于这些示例,我使用了一个比这本书能打印的更大的数据集,所以你应该花一分钟时间从这个书的 GitHub 账户下载Ch5-knn/src/data.js文件。

让我们从knn.js文件开始。就像前一章中的 k-means 示例一样,我们需要一个距离测量函数。让我们使用来自第四章,使用聚类算法进行分组的函数;将以下内容添加到knn.js的开头:

/**
 * Calculate the distance between two points.
 * Points must be given as arrays or objects with equivalent keys.
 * @param {Array.<number>} a
 * @param {Array.<number>} b
 * @return {number}
 */
const distance = (a, b) => Math.sqrt(
    a.map((aPoint, i) => b[i] - aPoint)
        .reduce((sumOfSquares, diff) => sumOfSquares + (diff*diff), 0)
);

如果你真的需要对你的 KNN 实现进行性能优化,你可能在这里省略Math.sqrt操作,只返回平方距离。然而,我再次强调,由于这个算法本质上非常快,你应该只有在处理极端问题、大量数据或非常严格的速度要求时才需要这样做。

接下来,让我们添加我们的 KNN 类的骨架。将以下内容添加到knn.js中,在距离函数下方:

class KNN {

    constructor(k = 1, data, labels) {
        this.k = k;
        this.data = data;
        this.labels = labels;
    }

}

export default KNN;

构造函数接受三个参数:k或分类新点时考虑的邻居数量;将训练数据拆分为单独的数据点;以及它们对应标签的数组。

接下来,我们需要添加一个内部方法,该方法考虑一个测试点并计算从测试点到训练点的距离的排序列表。我们将称之为距离图。将以下内容添加到 KNN 类的主体中:

generateDistanceMap(point) {

    const map = [];
    let maxDistanceInMap;

    for (let index = 0, len = this.data.length; index < len; index++) {

        const otherPoint = this.data[index];
        const otherPointLabel = this.labels[index];
        const thisDistance = distance(point, otherPoint);

        /**
         * Keep at most k items in the map. 
         * Much more efficient for large sets, because this 
         * avoids storing and then sorting a million-item map.
         * This adds many more sort operations, but hopefully k is small.
         */
        if (!maxDistanceInMap || thisDistance < maxDistanceInMap) {

            // Only add an item if it's closer than the farthest of the candidates
            map.push({
                index,
                distance: thisDistance,
                label: otherPointLabel
            });

            // Sort the map so the closest is first
            map.sort((a, b) => a.distance < b.distance ? -1 : 1);

            // If the map became too long, drop the farthest item
            if (map.length > this.k) {
                map.pop();
            }

            // Update this value for the next comparison
            maxDistanceInMap = map[map.length - 1].distance;

        }
    }

    return map;
}

这个方法可能更容易阅读,但简单的版本对于非常大的训练集来说并不高效。我们在这里做的是维护一个可能包含 KNN 的点列表,并将它们存储在map中。通过维护一个名为maxDistanceInMap的变量,我们可以遍历每个训练点,进行简单的比较以确定该点是否应该添加到我们的候选列表中。如果我们正在迭代的点比我们的候选点中最远的点更近,我们可以将该点添加到列表中,重新排序列表,移除最远的点以保持列表较小,然后更新mapDistanceInMap

如果这听起来像是一项繁重的工作,一个更简单的版本可能会遍历所有点,将每个点及其距离测量值添加到映射中,对映射进行排序,然后返回前k个条目。这种实现的缺点是,对于一个包含一百万个点的数据集,你需要构建一个包含一百万个点的距离映射,然后在内存中对这个巨大的列表进行排序。在我们的版本中,你只需要始终保留k个候选项,因此你永远不需要存储一个单独的一百万点映射。我们的版本确实需要在将项目添加到映射时调用Array.sort。这种方式本身就不太高效,因为每次添加到映射时都会调用排序函数。幸运的是,排序操作仅针对k个项,其中k可能类似于 3 或 5。排序算法的计算复杂度很可能是O(n log n)(对于快速排序或归并排序实现),因此当k=3时,更复杂的版本在约 30 个数据点时比简单版本更高效,当k=5时,这种情况发生在大约 3,000 个数据点时。然而,两种版本都非常快,对于小于 3,000 个点的数据集,你不会注意到任何区别。

最后,我们将算法与一个predict方法结合起来。predict方法必须接受一个测试点,并且至少返回该点的确定标签。我们还将向该方法添加一些额外的输出,并报告k个最近邻的标签以及每个标签所贡献的投票数。

将以下内容添加到 KNN 类的主体中:

predict(point) {

    const map = this.generateDistanceMap(point);
    const votes = map.slice(0, this.k);
    const voteCounts = votes
        // Reduces into an object like {label: voteCount}
        .reduce((obj, vote) => Object.assign({}, obj, {[vote.label]: (obj[vote.label] || 0) + 1}), {})
    ;
    const sortedVotes = Object.keys(voteCounts)
        .map(label => ({label, count: voteCounts[label]}))
        .sort((a, b) => a.count > b.count ? -1 : 1)
    ;

    return {
        label: sortedVotes[0].label,
        voteCounts,
        votes
    };

}

这个方法在 JavaScript 中需要进行一些数据类型转换,但在概念上很简单。首先,我们使用我们刚刚实现的方法生成我们的距离映射。然后,我们移除所有数据,只保留k个最近点,并将这些数据存储在votes变量中。如果你使用k=3,那么votes将是一个长度为三的数组。

现在我们有了k个最近邻,我们需要确定哪个标签代表了大多数邻居。我们将通过将投票数组缩减成一个名为voteCounts的对象来完成这项工作。为了了解我们希望voteCounts看起来像什么,想象我们在寻找三个最近邻,可能的类别是MaleFemalevoteCounts变量可能看起来像这样:{"Female": 2, "Male": 1}

然而,我们的工作还没有完成——在将我们的投票汇总成一个投票计数对象之后,我们仍然需要对其进行排序并确定多数标签。我们通过将投票计数对象映射回一个数组,然后根据投票数对数组进行排序来完成这项工作。

有其他方法可以处理这个投票计数问题;任何你能想到的方法都可以工作,只要你在最后能够返回多数投票。我喜欢从结构和从一种结构转换到另一种结构所需的转换来思考数据,但只要你能报告最高票,算法就会工作。

knn.js文件中,我们只需要做这些。算法已经完成,只需要少于 70 行代码。

让我们设置index.js文件并准备运行一些示例。记住,你首先需要下载data.js文件——请参阅 Packt 的 GitHub 账户或我的个人 GitHub 账户github.com/bkanber/MLinJSBook

将以下内容添加到index.js的顶部:

import KNN from './knn.js';
import {weight_height} from './data.js';

让我们在几个简单的例子上尝试我们的算法。

示例 1 – 身高、体重和性别

KNN,就像 k-means 一样,可以处理高维数据——但是,就像 k-means 一样,我们只能在二维平面上绘制示例数据,所以我们将保持示例简单。我们将要解决的第一个问题是:我们能否仅根据一个人的身高和体重预测其生物性别?

我从这个例子中下载了一些数据,数据来自一项关于人们对自身体重感知的全国性纵向调查。数据中包括受访者的身高、体重和性别。以下是数据在图表中的样子:

图片

只需看一下前面的图表数据,你就可以感受到 KNN 在评估聚类数据时为什么如此有效。确实,男性和女性之间没有清晰的边界,但如果你要评估一个体重 200 磅、身高 72 英寸的新数据点,很明显,围绕该点的所有训练数据都是男性,你的新点也很可能是男性。相反,一个体重 125 磅、身高 62 英寸的新受访者已经进入了图表中的女性区域,尽管也有几个男性具有这些特征。图表的中间,大约在 145 磅和 65 英寸高,是最模糊的,男性和女性的训练点分布均匀。我预计算法在这个区域的新点将不确定。因为在这个数据集中没有清晰的分割线,我们需要更多的特征或更多的维度来获得更好的边界分辨率。

在任何情况下,让我们尝试几个例子。我们将选择五个我们预期肯定是男性、肯定是女性、可能是男性、可能是女性和无法确定的点。将以下代码添加到index.js文件中,在两个导入行下面:

console.log("Testing height and weight with k=5");
console.log("==========================");

 const solver1 = new KNN(5, weight_height.data, weight_height.labels);

 console.log("Testing a 'definitely male' point:");
 console.log(solver1.predict([200, 75]));
 console.log("\nTesting a 'probably male' point:");
 console.log(solver1.predict([170, 70]));
 console.log("\nTesting a 'totally uncertain' point:");
 console.log(solver1.predict([140, 64]));
 console.log("\nTesting a 'probably female' point:");
 console.log(solver1.predict([130, 63]));
 console.log("\nTesting a 'definitely female' point:");
 console.log(solver1.predict([120, 60]));

在命令行中运行yarn start,你应该看到以下输出。由于 KNN 不是随机的,这意味着它在评估时没有使用任何随机条件,你应该看到与我完全相同的输出——除非两个投票有相同的距离,否则投票顺序和它们的索引可能会有所不同。

如果你运行yarn start时出现错误,请确保你的data.js文件已经正确下载并安装。

下面是前面代码的输出:

Testing height and weight with k=5
======================================================================

 Testing a 'definitely male' point:
 { label: 'Male',
 voteCounts: { Male: 5 },
 votes:
 [ { index: 372, distance: 0, label: 'Male' },
 { index: 256, distance: 1, label: 'Male' },
 { index: 291, distance: 1, label: 'Male' },
 { index: 236, distance: 2.8284271247461903, label: 'Male' },
 { index: 310, distance: 3, label: 'Male' } ] }

 Testing a 'probably male' point:
 { label: 'Male',
 voteCounts: { Male: 5 },
 votes:
 [ { index: 463, distance: 0, label: 'Male' },
 { index: 311, distance: 0, label: 'Male' },
 { index: 247, distance: 1, label: 'Male' },
 { index: 437, distance: 1, label: 'Male' },
 { index: 435, distance: 1, label: 'Male' } ] }

 Testing a 'totally uncertain' point:
 { label: 'Male',
 voteCounts: { Male: 3, Female: 2 },
 votes:
 [ { index: 329, distance: 0, label: 'Male' },
 { index: 465, distance: 0, label: 'Male' },
 { index: 386, distance: 0, label: 'Male' },
 { index: 126, distance: 0, label: 'Female' },
 { index: 174, distance: 1, label: 'Female' } ] }

 Testing a 'probably female' point:
 { label: 'Female',
 voteCounts: { Female: 4, Male: 1 },
 votes:
 [ { index: 186, distance: 0, label: 'Female' },
 { index: 90, distance: 0, label: 'Female' },
 { index: 330, distance: 0, label: 'Male' },
 { index: 51, distance: 1, label: 'Female' },
 { index: 96, distance: 1, label: 'Female' } ] }

 Testing a 'definitely female' point:
 { label: 'Female',
 voteCounts: { Female: 5 },
 votes:
 [ { index: 200, distance: 0, label: 'Female' },
 { index: 150, distance: 0, label: 'Female' },
 { index: 198, distance: 1, label: 'Female' },
 { index: 147, distance: 1, label: 'Female' },
 { index: 157, distance: 1, label: 'Female' } ] }

该算法已经确定了性别,就像我们通过查看图表进行视觉判断一样。您可以随意玩转这个例子,并尝试不同的 k 值,看看结果可能会有何不同。

现在我们来看一个 KNN 实际应用的第二个例子。这次,我们将选择一个 k = 1 真正发光的问题。

示例 2 – 去色照片

KNN 算法非常容易受到局部噪声的影响,当预期类别之间存在大量重叠时,它并不很有用。它通常对更高级的任务,如心理分析、人口统计或行为分析,并不很有用。但它是您工具箱中一个非常有用的工具,因为它可以很容易地帮助完成底层任务。

在这个例子中,我们将使用我们的 KNN 类去色照片。具体来说,我们将从彩色输入照片中提取,并限制它们只使用 16 种颜色方案。我们将使用 KNN 来选择给定像素的适当替代颜色。

我们的工作流程将如下所示:

  1. 使用 jimp 库读取输入图像

  2. 遍历图像中的每个像素:

    1. 在我们的 16 种颜色方案中找到最相似的颜色

    2. 用新颜色替换那个像素

  3. 基于十六色方案写入一个新输出文件

在我们开始之前,验证 以下内容是否存在于您的 data.js 文件中。如果您从 GitHub 下载了这本书的 data.js 文件,那么它应该已经在那里了。然而,如果您从其他地方获取了性别调查数据,您需要在 data.js 文件中包含以下内容:

export const colors_16 = {
 data: [
 [0, 0, 0], // black
 [128, 128, 128], // gray
 [128, 0, 0], //maroon
 [255, 0, 0], // red
 [0, 128, 0], // green
 [0, 255, 0], // lime
 [128, 128, 0], // olive
 [255, 255, 0], // yellow
 [0, 0, 128], // navy
 [0, 0, 255], // blue
 [128, 0, 128], // purple
 [255, 0, 255], // fuchsia
 [0, 128, 128], // teal
 [0, 255, 255], // aqua
 [192, 192, 192], // silver
 [255, 255, 255], // white
 ],

 labels: [
 'Black',
 'Gray',
 'Maroon',
 'Red',
 'Green',
 'Lime',
 'Olive',
 'Yellow',
 'Navy',
 'Blue',
 'Purple',
 'Fuchsia',
 'Teal',
 'Aqua',
 'Silver',
 'White',
 ]
 };

上述颜色定义代表一个常见的 16 种颜色方案。您也可以自己尝试不同的颜色方案;您可以使用这种方法将图像着色为蓝色调、暖色调或棕褐色调等。您还可以通过增加训练数据的大小来允许远超过 16 种颜色。

让我们先编写几个辅助函数。在 src 文件夹中创建一个名为 decolorize.js 的新文件。确保您已将 jimp 添加到您的 package.json 中——如果您不确定,请在命令行中运行 yarn add jimp。将以下导入添加到文件顶部:

import KNN from './knn.js';
import {colors_16} from './data.js';
import jimp from 'jimp'

然后,创建并导出一个函数,该函数接受一个图像文件名并写入一个去色图像的新文件。我在代码片段中留下了一些温和的注释,描述了工作流程;大部分代码只是处理数据格式。一般来说,我们的方法是打开并读取输入文件,遍历所有像素,使用 KNN 为该像素找到一个替代颜色,将新颜色写入像素,然后最终使用修改后的颜色写入一个新输出文件:

const decolorize = filename => {

  return jimp.read(filename)
    .then(image => {

      // Create a KNN instance with our color scheme as training data
      // We use k=1 to find the single closest color
      // k > 1 wouldn't work, because we only have 1 label per training point
      const mapper = new KNN(1, colors_16.data, colors_16.labels);
      const {width, height} = image.bitmap;

      // For every pixel in the image...
      for (let x = 0; x < width; x++) {
      for (let y = 0; y < height; y++) {

      // Do some work to get the RGB value as an array: [R,G,B]
      const originalColorHex = image.getPixelColor(x, y);
      const originalColorRgb = jimp.intToRGBA(originalColorHex);
      const pixelPoint = [originalColorRgb.r, originalColorRgb.g, originalColorRgb.b];

      // Ask the KNN instance what the closest color from the scheme is
      const closestColor = mapper.predict(pixelPoint);

      // Then get that color in hex format, and set the pixel to the new color
      const newColor = colors_16.data[colors_16.labels.indexOf(closestColor.label)];
      const newColorHex = jimp.rgbaToInt(newColor[0], newColor[1], newColor[2], 255);
      image.setPixelColor(newColorHex, x, y);

    }
  }

  const ext = image.getExtension();
  image.write(filename.replace('.'+ext, '') + '_16.' + ext);

  })
  .catch(err => {
    console.log("Error reading image:");
    console.log(err);
  })
};

export default decolorize

我们现在有一个函数,它将接受一个文件名并创建一个新的去色照片。如果你还没有创建,请在Ch5-knn目录下创建一个名为files的文件夹。找到一些你喜欢的图片并将它们添加到files文件夹中。或者,你可以使用书中 GitHub 上的图像示例,它们是landscape.jpeglily.jpegwaterlilies.jpeg

最后,打开index.js文件,并将以下内容添加到文件底部:

['landscape.jpeg', 'lily.jpeg', 'waterlilies.jpeg'].forEach(filename => {
  console.log("Decolorizing " + filename + '...');
  decolorize('./files/' + filename)
    .then(() => console.log(filename + " decolorized"));
});

如果你正在使用自己的示例文件,请确保更新前面代码中显示的粗体文件名。

使用yarn start运行代码,你应该会看到以下输出(你的输出中可能还有其他 KNN 实验的结果):

 Decolorizing images
 =======================================================
 Decolorizing landscape.jpeg...
 Decolorizing lily.jpeg...
 Decolorizing waterlilies.jpeg...
 lily.jpeg decolorized
 waterlilies.jpeg decolorized
 landscape.jpeg decolorized

如果有任何关于文件名或权限的错误,请解决它们。在files文件夹中查找你的新照片。我不知道你用哪种格式阅读这本书,以及这些图片将如何显示给你,但以下是我的landscape.jpeg文件,原始和处理的。

原始:

去色版本:

我认为它在前景和风景方面做得非常好,然而,有限的调色板无疑影响了背景中的天空、水和山脉。尝试向训练数据中添加 8 或 16 种颜色,看看会发生什么。

我喜欢这个项目作为 KNN 示例,因为它表明机器学习ML)算法并不总是用于复杂分析。其中许多可以作为你日常工具箱的一部分使用,用较小的模型训练,帮助你处理更简单的数据处理任务。

我还应该在这里记一笔关于测量颜色之间距离的事情。我们采用的方法,即使用欧几里得距离公式来测量 RGB 值之间的距离,在感知上并不准确。当涉及到人类视觉感知时,RGB 空间略有扭曲,因此我们的欧几里得距离测量并不完全准确。就我们的目的而言,它们已经足够接近了,因为我们正在降低到非常低的分辨率。如果你需要进行感知上准确的照片处理,你可能需要将所有 RGB 值转换到一个更准确的颜色空间,例如Lab,或者更新你的距离函数来测量感知距离,而不仅仅是点之间的几何距离。

让我们从 KNN 转向一个更复杂的方法来分类对象,基于几个世纪前的概率理论,至今仍然强大:贝叶斯分类。

天真贝叶斯分类器

天真贝叶斯分类器是一种概率分类器,或者是一种将概率分布分配给潜在结果的算法。与MaleFemale这样的二进制分类不同,概率分类器会告诉你这个数据点有 87%的概率是Male,有 13%的概率是Female

并非所有概率分类器都是贝叶斯分类器,它们也不一定都是朴素分类器。在这个上下文中,“朴素”一词并不是对分类器的隐晦侮辱——它是一个在概率论中有特定意义的数学术语,我们稍后会进一步讨论。术语“贝叶斯”或“贝叶斯派”意味着分类器中使用的原理最初是由 18 世纪的数学家托马斯·贝叶斯牧师发表的,他因其在概率论中的贝叶斯定理而闻名。

让我们先来复习一下概率。首先,你应该知道概率可以与连续分布离散分布一起工作。连续分布是变量是一个数字,可以具有任何值的分布。离散分布只有有限数量的可能状态,即使这个数量很大。连续值是像每周 54.21 分钟的活动;每股 23.34 美元;总共 18 次登录这样的东西。离散值是真/假好莱坞八卦政治体育本地事件,或者世界新闻,甚至是文章中单个单词的频率。概率论中的大多数定理都可以用于连续和离散分布,尽管两者之间的实现细节会有所不同。

在我们例子中使用的离散概率中,你处理的是各种事件发生的概率。事件是从实验中可能得到的结果的集合。这个经典的说明性例子涉及一副扑克牌;想象你从洗好的牌堆中随机抽取一张牌。你抽到的牌是红心的概率是多少?当我们提出这个问题时,我们是在询问某个事件的概率,具体来说是这张牌是红心的概率。我们可以给我们的事件一个标签,比如用H表示红心,然后我们可以将短语“这张牌是红心的概率”简短地表示为P(H)。答案是 13/52,或者 1/4,或者 0.25,所以你也可以说P(H) = 0.25。在我们的场景中还有许多其他可能的事件。抽到方块 5 的概率是多少?抽到黑桃的概率是多少?抽到人头牌的概率是多少?数值小于 5 的概率是多少?所有这些都是事件类型,每个事件都有其自己的概率。

并非所有事件都是独立的。例如,假设实验是“你昨天喝了汽水吗?”,并且我们在调查美国人。我们可以定义事件S为“昨天喝了汽水”。通过调查美国所有人(或者至少是一个代表性样本),我们发现近 50%的受访者表示是的!(根据耶鲁大学的数据,实际上是 48%)所以我们可以这样说,事件S的概率是 50%,或者P(S) = 0.5。我们还可以定义一个事件为S',这是事件“昨天没有喝汽水”的概率,或者其逆事件。

我们希望更深入地了解公民的饮食习惯,所以我们向调查中添加了另一个问题:你昨天在快餐店吃饭了吗?我们将这个事件命名为M,代表麦当劳,我们发现P(M) = 0.25,即全国的四分之一。

我们现在可以问更复杂的问题,例如:吃快餐是否会影响人们喝汽水?我们可以询问在昨天吃了快餐的情况下,某人喝了汽水的概率。这被称为S事件在M条件下的条件概率,即P(S|M)

如果我们在同一份调查中询问关于喝汽水和吃快餐的问题,那么我们可以通过找到同时进行这两个事件的受访者的概率(这写成P(S ∩ M),发音为概率 S 交集 M),然后除以P(M)来计算P(S|M)。完整的公式是P(S|M) = P(S ∩ M) / P(M)

假设有 20%的受访者既喝了汽水又吃了快餐。我们现在可以计算出P(S|M) = 0.2 / 0.25 = 0.8。如果你昨天吃了快餐,那么昨天喝了汽水的概率是 80%。

注意,这不是你在吃快餐时喝了汽水的概率。要回答这个问题,你必须去快餐店并对那里的人进行调查。我们的版本在因果关系方面承诺较少。

现在你想问相反的问题:如果某人昨天喝了汽水,那么他们昨天吃快餐的概率是多少?这是在询问P(M|S)。我们本可以直接反转前面的公式,但假设我们失去了原始的调查数据,无法再确定P(S ∩ M)

我们可以使用贝叶斯定理来正确地反转我们的概率:

P(M|S) = P(S|M) * P(M) / P(S)

幸运的是,我们记得这三个值,并发现:

P(M|S) = 0.8 * 0.25 / 0.5 = 0.4

知道某人昨天喝了汽水,那么他们昨天吃快餐的概率是 40%。这比任何吃快餐的人的基准概率 25%要高。

这如何应用于朴素贝叶斯分类器?我们使用前面的条件概率定理将特征与其相应的类别联系起来。在垃圾邮件过滤器中,我们提出这样的问题:这个文档包含单词credit时,它是垃圾邮件的概率是多少?以及这个文档包含单词transfer时,它是垃圾邮件的概率是多少?我们对文档中的每个单词都提出这样的问题,然后我们将这些概率结合起来,得到文档是垃圾邮件的整体概率。朴素贝叶斯分类器之所以被称为朴素,是因为它假设所有事件都是独立的。实际上,这是一个错误的假设。包含单词credit的电子邮件更有可能也包含单词transfer,但在实践中,这些分类器仍然非常准确,尽管存在错误的假设。

分词

我们还必须简要讨论一下分词的概念。我们将在第十章“实践中的自然语言处理”中深入讨论分词,当我们讨论自然语言编程时,但我们现在确实需要对其有一个简短的介绍。分词是将文档分解成单个分词的行为。你可以把分词想象成单词,但并非所有单词都是分词,也不是所有分词都是单词。

最简单的分词器可能是通过空格分割文档。结果将是一个包含单词、它们的字母大小写和标点符号的数组。一个稍微高级一点的分词器可能会将所有内容转换为小写并删除任何非字母数字字符。现在,所有分词都是小写单词、数字以及包含数字的单词。你的分词器可以删除常见单词,如“和”和“的”——这被称为停用词过滤。你还可以在分词器中实现词干提取,即从单词中删除不必要的结尾。例如,“parties”、“partied”和“party”都可能变成“parti”。这是一个很好的降维技术,有助于你的分类器关注单词的意义而不是特定的时态或用法。你可以通过词形还原更进一步,这与词干提取类似,但实际上是将单词语法上转换为它们的词根形式,因此“running”、“runs”和“ran”都会变成“run”。

分词可以采取更高级的形式。一个分词不一定是单个单词;它可以是单词的对或三联组。这些分别被称为二元组三元组。分词也可以从元数据生成。特别是电子邮件垃圾邮件过滤器,当将消息头中的某些信息作为分词包含在内时,表现非常好:电子邮件是否通过了 SPF 检查,是否有有效的 DKIM 密钥,发送者的域名等等。分词器还可以修改某些字段中的分词;例如,发现给电子邮件主题行中的分词加上前缀(与正文内容相反)可以提高垃圾邮件过滤性能。与其将“现在购买药品”分词为“购买”、“药品”、“现在”,不如将其分词为“SUBJ_buy”、“SUBJ_pharmaceuticals”、“SUBJ_now”。这种前缀的效果是允许分类器分别考虑主题和正文中的单词,这可能会提高性能。

不要低估分词器的重要性。通常,通过深思熟虑地选择分词器算法,你可以获得显著的准确率提升。在这个例子中,我们将使用一个简单、直观但仍相当有效的分词器。

构建算法

现在让我们构建朴素贝叶斯分类器。以下是构建算法需要遵循的步骤:

  1. 为项目创建一个名为Ch5-Bayes的新文件夹。像往常一样,创建srcdatadist文件夹,并添加以下package.json文件:
{
 "name": "Ch5-Bayes",
 "version": "1.0.0",
 "description": "ML in JS Example for Chapter 5 - Bayes",
 "main": "src/index.js",
 "author": "Burak Kanber",
 "license": "MIT",
 "scripts": {
 "build-web": "browserify src/index.js -o dist/index.js -t [ babelify --presets [ env ] ]",
 "build-cli": "browserify src/index.js --node -o dist/index.js -t [ babelify --presets [ env ] ]",
 "start": "yarn build-cli && node dist/index.js"
 },
 "dependencies": {
 "babel-core": "⁶.26.0",
 "babel-plugin-transform-object-rest-spread": "⁶.26.0",
 "babel-preset-env": "¹.6.1",
 "babelify": "⁸.0.0",
 "browserify": "¹⁵.1.0"
 }
 }
  1. 一旦添加了package.json文件,请在命令行中运行yarn install来安装所有项目依赖项。

  2. 导航到书籍的 GitHub 账户,并在data文件夹中下载四个文件。这些文件应命名为train_negative.txttrain_positive.txttest_negative.txttest_positive.txt。这些文件包含来自www.imdb.com/的电影评论,并使用 IMDB 的星级评分系统预先分类为正面评论和负面评论。我们将使用这些数据来训练并验证一个检测电影评论情感的算法。

  3. src文件夹中创建一个bayes.js文件。将以下分词器函数添加到文件顶部:

export const simpleTokenizer = string => string
 .toLowerCase()
 .replace(/[^\w\d]/g, ' ')
 .split(' ')
 .filter(word => word.length > 3)
 .filter((word, index, arr) => arr.indexOf(word, index+1) === -1);

此函数接受一个字符串作为输入,并返回一个标记数组作为输出。首先将字符串转换为小写,因为我们的分析是区分大小写的。然后删除任何非单词或数字字符,并用空格替换。通过空格分割字符串以获取标记数组。接下来,过滤掉任何长度为三个字符或更短的标记(因此会移除像thewas这样的单词,而像thisthat这样的单词会被保留)。分词器的最后一行过滤掉非唯一标记;我们只考虑文档中单词的存在,而不是这些单词的使用次数。

注意,分词器中的filter函数不会保留单词顺序。为了保留单词顺序,你需要在最后的过滤行前后添加.reverse()。然而,我们的算法不考虑单词顺序,因此保留它是不必要的。

  1. 创建BayesClassifier类并将其从bayes.js文件中导出。将以下内容添加到文件中:
class BayesClassifier {

 constructor(tokenizer = null) {
 this.database = {
 labels: {},
 tokens: {}
 };

 this.tokenizer = (tokenizer !== null) ? tokenizer : simpleTokenizer;
 }
 }

 export default BayesClassifier;

分类器的构造函数只接受一个tokenizer函数,但是默认为我们创建的简单分词器。将分词器配置为可配置的,这样你就可以尝试适合你特定数据集的更好的分词器。

训练朴素贝叶斯分类器是一个简单的过程。首先,简单地计算你看到的每个类别的文档数量。如果你的训练集包含 600 条正面电影评论和 400 条负面电影评论,那么你应该分别有 600 和 400 作为你的文档计数。接下来,对要训练的文档进行分词。你必须始终确保在训练期间使用与评估期间相同的分词器。对于训练文档中的每个标记,记录你在该类别中所有文档中看到该标记的次数。例如,如果你的训练数据有 600 条正面电影评论,而单词beautiful出现在其中的 100 条,那么你需要在positive类别中为标记beautiful维护一个计数为 100。如果标记beautiful在你的负面评论训练数据中只出现了三次,那么你必须单独维护这个计数。

让我们将这个翻译成代码。这是一个非常简单的操作,但我们也在将工作分配给许多小的计数和递增函数;我们将在评估阶段也使用这些计数函数:

/**
 * Trains a given document for a label.
 * @param label
 * @param text
 */
train(label, text) {
  this.incrementLabelDocumentCount(label);
  this.tokenizer(text).forEach(token => this.incrementTokenCount(token, label));
}

 /**
 * Increments the count of documents in a given category/label
 * @param label
 */
incrementLabelDocumentCount(label) {
  this.database.labels[label] = this.getLabelDocumentCount(label) + 1;
}

 /**
 * Returns the number of documents seen for a given category/label.
 * If null is passed as the label, return the total number of training documents seen.
 * @param label
 */
getLabelDocumentCount(label = null) {
  if (label) {
    return this.database.labels[label] || 0;
  } else {
    return Object.values(this.database.labels)
      .reduce((sum, count) => sum + count, 0);
  }
}

 /**
 * Increment the count of a token observed with a given label.
 * @param token
 * @param label
 */
incrementTokenCount(token, label) {
  if (typeof this.database.tokens[token] === 'undefined') {
    this.database.tokens[token] = {};
  }

  this.database.tokens[token][label] = this.getTokenCount(token, label) + 1;
}

 /**
 * Get the number of times a token was seen with a given category/label.
 * If no label is given, returns the total number of times the token was seen
 * across all training examples.
 * @param token
 * @param label
 * @returns {*}
 */
getTokenCount(token, label = null) {
  if (label) {
    return (this.database.tokens[token] || {})[label] || 0;
  } else {
    return Object.values(this.database.tokens[token] || {})
      .reduce((sum, count) => sum + count, 0);
  }
}

如您所见,train()方法相当简单:增加给定标签的文档计数(例如,垃圾邮件非垃圾邮件正面情感负面情感);然后,对于文档中的每个标记,增加给定标签的标记计数(例如,beautiful在正面情感文档中出现了 100 次,在负面情感文档中出现了 3 次)。这些计数保存在BayesClassifier类的一个实例变量this.database中。

为了对新文档进行预测,我们需要单独考虑我们在训练过程中遇到的每个标签,计算该标签的概率,并返回最可能的标签。让我们从实现预测的反向工作开始;我们首先添加predict方法,然后反向工作,填充我们需要的所有其他方法。

首先,将以下predict方法添加到BayesClassifier类中:

/**
 * Given a document, predict its category or label.
 * @param text
 * @returns {{label: string, probability: number, probabilities: array}}
 */
predict(text) {
  const probabilities = this.calculateAllLabelProbabilities(text);
  const best = probabilities[0];

  return {
    label: best.label,
    probability: best.probability,
    probabilities
  };

}

此方法接受一个输入字符串或文档,并返回一个result对象,其中包含最可能的标签或类别,该标签或类别的概率,以及训练过程中遇到的所有标签的概率数组。

接下来,添加predict方法所依赖的方法,用于计算输入文档中每个标签的概率:

/**
 * Given a document, determine its probability for all labels/categories encountered in the training set.
 * The first element in the return array (element 0) is the label/category with the best match.
 * @param text
 * @returns {Array.<Object>}
 */
calculateAllLabelProbabilities(text) {
  const tokens = this.tokenizer(text);
  return this.getAllLabels()
    .map(label => ({
      label,
      probability: this.calculateLabelProbability(label, tokens)
    }))
    .sort((a, b) => a.probability > b.probability ? -1 : 1);
}

此方法将输入文本进行分词,然后生成一个包含所有标签及其概率的数组,按最可能到最不可能的顺序排序。现在您需要将这两个方法添加到类中——首先,是简单的getAllLabels()方法:

/**
 * Get all labels encountered during training.
 * @returns {Array}
 */
getAllLabels() {
  return Object.keys(this.database.labels);
}

然后添加更复杂的calculateLabelProbability函数,该函数负责计算单个标签适合文档的概率:

/**
 * Given a token stream (ie a tokenized document), calculate the probability that
 * this document has a given label.
 * @param label
 * @param tokens
 * @returns {number}
 */
calculateLabelProbability(label, tokens) {

  // We assume that the a-priori probability of all labels are equal.
  // You could alternatively calculate the probability based on label frequencies.
  const probLabel = 1 / this.getAllLabels().length;

  // How significant each token must be in order to be considered;
  // Their score must be greater than epsilon from the default token score
  // This basically filters out uninteresting tokens from consideration.
  // Responsible for 78% => 87.8% accuracy bump (e=.17) overall.
  const epsilon = 0.15;

  // For each token, we have to calculate a "token score", which is the probability of this document
  // belonging to a category given the token appears in it.
  const tokenScores = tokens
    .map(token => this.calculateTokenScore(token, label))
    .filter(score => Math.abs(probLabel - score) > epsilon);

 // To avoid floating point underflow when working with really small numbers,
 // we add combine the token probabilities in log space instead.
 // This is only used because of floating point math and should not affect the algorithm overall.
  const logSum = tokenScores.reduce((sum, score) => sum + (Math.log(1-score) - Math.log(score)), 0);
  const probability = 1 / (1 + Math.exp(logSum));

  return probability;
}

calculateLabelProbability方法中的内联注释说明了该方法的具体工作方式,但这一步的基本目标是计算文档中每个标记的概率,然后将单个标记概率组合成一个标签的整体概率。

例如,如果一部电影评论说beautiful [but] awful garbage,这个方法负责查看所有标记(but被分词器省略)并确定它们与给定标签(例如,正面负面)的匹配程度。

让我们假设我们正在为 正面 类别标签运行这个方法。单词 beautiful 会得到一个强烈的分数,可能是 90%,但标记 awfulgarbage 都会得到弱的分数,例如 5%。这种方法会报告说,对于这个文档,正面 标签的概率很低。另一方面,当这个方法为 负面 类别标签运行时,beautiful 标记得到一个低的分数,但 awfulgarbage 都得到高的分数,所以该方法会返回文档为负面的高概率。

这种方法涉及一些技巧。第一个技巧是准确性增强。如果一个标记是模糊的(例如像 thatmovie 这样的词,它适用于所有类别),它就会被从考虑中移除。我们通过过滤掉接近 50% 的标记分数来实现这一点;具体来说,我们忽略所有分数在 35-65% 之间的标记。这是一个非常有效的技术,可以提高大约 10% 的准确性。它之所以工作得很好,是因为它过滤掉了那些边缘标记中的噪声。如果单词 movie 有一个正面的分数 55%,但它通常出现在正面和负面的文档中,它会使所有文档都偏向正面类别。我们的方法是不考虑那些最具影响力的标记。

第二个技巧是我们的对数和技巧。通常,将单个单词或标记概率组合成整体概率的方法如下——假设你已经有一个名为 tokenScores 的数组变量:

const multiplyArray = arr => arr.reduce((product, current) => current * product, 1);
const tokenScores = []; // array of scores, defined elsewhere
const inverseTokenScores = tokenScores.map(score => 1 - score);
const combinedProbability = multiplyArray(tokenScores) / (multiplyArray(tokenScores) + multiplyArray(inverseTokenScores));

换句话说,假设你有一些称为 p1p2p3、... pN 的单个标记的概率;获取所有这些标记的联合概率的方法是:

p = (p1 * p2 * p3 * ... pN) / ( (p1 * p2 * p3 * ... pN) + (1-p1 * 1-p2 * 1-p3 * ... 1-pN) )

这种方法在处理小的浮点数时有一些问题。如果你开始将小的浮点数相互相乘,你可能会得到非常小的数,以至于浮点数学无法处理它,这会导致 浮点下溢,或者在 JavaScript 中是 NaN。解决方案是将这个计算转换为对数空间,并通过添加每个概率的自然对数值来管理整个计算,并在最后移除对数。

拼图的最后一块是生成给定标签的每个单个标记的概率。这正是贝叶斯定理真正发挥作用的地方。我们寻找的是类似于 P(L|W) 的概率,或者说是给定一个 单词标签 的概率。我们需要为文档中的每个标记以及我们考虑的每个标签计算这个概率。然而,我们手头没有 P(L|W) 的值,所以我们可以使用贝叶斯定理来得到一个等价的表达式:

P(L|W) = P(W|L)P(L) / P(W|L)P(L) + P(W|L')P(L')

这可能看起来很复杂,但实际上并不糟糕。我们正在将P(L|W)的目标转化为更容易计算的概率,例如P(W|L)(给定标签时单词出现的概率,或在该标签中的频率)和P(L)(任何给定标签的概率)。分母也使用了逆概率,P(W|L')(单词出现在任何其他标签中的概率)和P(L')(任何其他标签的概率)。

我们进行这种转换是因为我们可以在训练过程中通过计数 token 和标签来获得单词频率;我们不需要记录哪些 token 出现在哪些文档中,我们可以保持我们的数据库简单且快速。

之前提到的表达式就是我们所说的token score,或者说是给定文档中包含一个单词时,文档具有某个标签的概率。为了使问题更加具体,我们可以提出这样的问题:P("positive review" | "beautiful"),或者说是给定单词 beautiful 时,文档是正面电影评论的概率。

如果评论是正面或负面的概率各占 50%,并且我们在 10%的正面评论中看到了单词beautiful,而在 1%的负面评论中只看到了它,那么我们的P(L|W)概率大约是 91%。(这个计算的公式是(0.1 * 0.5) / ( (0.1 * 0.5) + (0.01 * 0.5) ),使用前面的公式。)你可以将这个 91%的数字理解为单词beautiful积极性。通过以这种方式分析文档中的所有单词,我们可以将它们的积极性分数结合起来,得到一个文档是正面的整体概率。这同样适用于任何类型的分类,无论是正面/负面电影评论,垃圾邮件/非垃圾邮件,还是英语/法语/西班牙语语言检测。

在计算 token 分数时,我们还需要考虑另一件事。如果我们以前从未见过一个 token,或者我们只见过它一两次,我们应该怎么办?对我们来说,最好的方法是调整我们计算出的 token 分数的加权平均值;我们希望加权平均,使得罕见单词的分数接近 50/50。

让我们实现前面提到的所有逻辑。这个方法可能比较长,但正如你所见,大部分工作只是简单地获取我们需要计算的各种变量的正确计数。我们还定义了一个强度用于罕见单词加权;我们将强度定义为三,这意味着我们必须看到这个 token 三次,它才能具有与默认的 50/50 加权等效的权重:

 /**
 * Given a token and a label, calculate the probability that
 * the document has the label given that the token is in the document.
 * We do this by calculating the much easier to find Bayesian equivalent:
 * the probability that the token appears, given the label (the word frequency in that category).
 * This method also adjusts for rare tokens.
 * @param token
 * @param label
 * @returns {number}
 */
calculateTokenScore(token, label) {
  const rareTokenWeight = 3;

  const totalDocumentCount = this.getLabelDocumentCount();
  const labelDocumentCount = this.getLabelDocumentCount(label);
  const notLabelDocumentCount = totalDocumentCount - labelDocumentCount;

  // Assuming equal probabilities gave us 1% accuracy bump over using the frequencies of each label
  const probLabel = 1 / this.getAllLabels().length;
  const probNotLabel = 1 - probLabel;

  const tokenLabelCount = this.getTokenCount(token, label);
  const tokenTotalCount = this.getTokenCount(token);
  const tokenNotLabelCount = tokenTotalCount - tokenLabelCount;

  const probTokenGivenLabel = tokenLabelCount / labelDocumentCount;
  const probTokenGivenNotLabel = tokenNotLabelCount / notLabelDocumentCount;
  const probTokenLabelSupport = probTokenGivenLabel * probLabel;
  const probTokenNotLabelSupport = probTokenGivenNotLabel * probNotLabel;

  const rawWordScore =
    (probTokenLabelSupport)
    /
    (probTokenLabelSupport + probTokenNotLabelSupport);

  // Adjust for rare tokens -- essentially weighted average
  // We're going to shorthand some variables to make reading easier.
  // s is the "strength" or the "weight"
  // n is the number of times we've seen the token total
  const s = rareTokenWeight;
  const n = tokenTotalCount;
  const adjustedTokenScore =
    ( (s * probLabel) + (n * (rawWordScore || probLabel)) )
    /
    ( s + n );

  return adjustedTokenScore;
}

为了回顾这个算法的工作方式,这里有一个简要的总结:

训练:

  1. 接受一个输入文档和已知的标签或类别

  2. 将输入文档分词成一个 token 数组

  3. 记录你看到这个特定标签的文档总数

  4. 对于每个 token,记录你看到这个 token 与这个特定标签一起出现的次数

预测:

  1. 接受一个输入文档并将其分词

  2. 对于每个可能的标签(你在训练过程中遇到的全部标签),以及文档中的每个标记,计算该标记的标记分数(从数学上讲,给定特定标记的文档具有该标签的概率)

  3. 你可能需要过滤标记分数的重要性

  4. 你可能需要调整罕见词的标记分数

  5. 对于每个可能的标签,将标记分数组合成一个单一的、整体的标签概率(例如,文档属于这个类别或标签的概率)

  6. 报告具有最高整体概率的标签

在添加了所有代码后,我们准备训练和测试我们的朴素贝叶斯分类器。我们将使用 IMDB 电影评论来训练它,并尝试猜测从未见过的评论的情感。

示例 3 – 电影评论情感

我们将使用我们的朴素贝叶斯分类器来解决情感分析问题,或者检查一段文本并确定它是否具有整体正面或负面情感的问题。这在广告、营销和公共关系中是一个常见的分析;大多数品牌经理想知道推特上的人对他们的品牌或产品是好评还是差评。

本例的训练数据将来自www.imdb.com/。我们将使用正面和负面的电影评论来训练我们的分类器,然后使用我们的分类器来检查未经训练(但已预先标记)的评论,看看它能正确识别多少。

如果你还没有这样做,请从本项目的 GitHub 页面上的data目录下载数据文件。你需要所有四个文本文件:train_positive.txttrain_negative.txttest_positive.txttest_negative.txt。我们将使用两个训练文件进行训练,两个测试文件进行验证。

接下来,在src文件夹中创建一个index.js文件。将以下代码添加到文件顶部:

import readline from 'readline';
import fs from 'fs';
import BayesClassifier, {simpleTokenizer} from "./bayes";

const classifier = new BayesClassifier(simpleTokenizer);

我们导入readlinefs库来帮助我们处理训练文件。接下来,创建一个utility函数来帮助我们训练分类器:

const trainer = (filename, label, classifier) => {

  return new Promise((resolve) => {
    console.log("Training " + label + " examples...");
    readline.createInterface({
      input: fs.createReadStream(filename)
    })
      .on('line', line => classifier.train(label, line))
      .on('close', () => {
        console.log("Finished training " + label + " examples.");
        resolve();
      });
  });
}

这个helper函数接受一个文件名、一个标签和一个BayesClassifier类的实例。它逐行读取输入文件,并针对给定的标签在每个标签上训练分类器。所有逻辑都被封装在一个承诺中,这样我们就可以在外部检测到训练器何时完成。

接下来,添加一个辅助实用工具来测试分类器。为了测试分类器,它必须首先被训练。测试函数将打开一个已知标签的测试文件,并使用分类器的predict方法测试文件中的每一行。实用工具将计算分类器正确和错误识别的例子数量,并报告:

const tester = (filename, label, classifier) => {

  return new Promise((resolve) => {
    let total = 0;
    let correct = 0;
    console.log("Testing " + label + " examples...");
    readline.createInterface({ input: fs.createReadStream(filename) })
      .on('line', line => {
        const prediction = classifier.predict(line);
        total++;
        if (prediction.label === label) {
          correct++;
        }
      })
      .on('close', () => {
        console.log("Finished testing " + label + " examples.");
        const results = {total, correct};
        console.log(results);
        resolve(results);
      });
  }); 
}

我们也将这个操作封装在一个承诺中,并确保将结果作为承诺解决的一部分提供,这样我们就可以从外部检查结果。

最后,添加一些引导代码。这段代码将在两个训练文件上训练分类器,等待训练完成,然后在对两个测试文件进行测试后报告整体结果:

Promise.all([
  trainer('./data/train_positive.txt', 'positive', classifier),
  trainer('./data/train_negative.txt', 'negative', classifier)
])
  .then(() => {
    console.log("Finished training. Now testing.");

    Promise.all([
      tester('./data/test_negative.txt', 'negative', classifier),
      tester('./data/test_positive.txt', 'positive', classifier)
    ])
      .then(results => results.reduce(
        (obj, item) => ({total: obj.total + item.total, correct: obj.correct + item.correct}), {total: 0, correct: 0}
      ))
      .then(results => {
        const pct = (100 * results.correct / results.total).toFixed(2) + '%';
        console.log(results);
        console.log("Test results: " + pct);
      });
 })

一旦添加了这段代码,你就可以通过在命令行中输入yarn start来运行程序。你应该会看到以下类似的输出:

Training positive examples...
Training negative examples...
Finished training positive examples.
Finished training negative examples.
Finished training. Now testing.
Testing negative examples...
Testing positive examples...
Finished testing positive examples.
{ total: 4999, correct: 4402 }
Finished testing negative examples.
{ total: 5022, correct: 4738 }
{ total: 10021, correct: 9140 }
Test results: 91.21%

这个简单、概率性的分类器准确率超过 91%!9%的错误率可能看起来并不令人印象深刻,但在机器学习世界中,这实际上是一个非常良好的结果,特别是考虑到分类器的实现简便性和操作速度。正是这些结果使得朴素贝叶斯分类器在文本分类中非常受欢迎。通过更细致的标记化,尤其是在狭窄领域,如垃圾邮件检测,你可以将朴素贝叶斯分类器的准确率提高到 95%以上。

让我们看看一个单独的例子是什么样的。如果你想在自己的文档上测试一些文档,可以将以下代码添加到index.js文件中:

Promise.all([
  trainer('./data/train_positive.txt', 'positive', classifier),
  trainer('./data/train_negative.txt', 'negative', classifier)
])
  .then(() => {

    const tests = [
      "i really hated this awful movie, it was so bad I didn't even know what to do with myself",
      "this was the best movie i've ever seen. it was so exciting, i was on the edge of my seat every minute",
      "i am indifferent about this"
    ];

    tests.forEach(test => {
      console.log("Testing: " + test);
      const result = classifier.predict(test);
      console.log(result);
    });
  });

运行前面的代码会产生以下代码:

Training positive examples...
Training negative examples...
Finished training positive examples.
Finished training negative examples.

Testing: i really hated this awful movie, it was so bad I didn't even know what to do with myself
{ label: 'negative',
 probability: 0.9727173302897202,
 probabilities:
 [ { label: 'negative', probability: 0.9727173302897202 },
 { label: 'positive', probability: 0.027282669710279664 } ] }

Testing: this was the best movie i've ever seen. it was so exciting, i was on the edge of my seat every minute
{ label: 'positive',
 probability: 0.8636681390743286,
 probabilities:
 [ { label: 'positive', probability: 0.8636681390743286 },
 { label: 'negative', probability: 0.13633186092567148 } ] }

Testing: i am indifferent about this
{ label: 'negative',
 probability: 0.5,
 probabilities:
 [ { label: 'negative', probability: 0.5 },
 { label: 'positive', probability: 0.5 } ] }

分类器按预期工作。我们强烈的负面陈述有 97%的概率是负面的。我们的正面陈述有 86%的概率是正面的。即使我们的中立陈述返回了负面标签,也报告了正面和负面情绪的 50/50 概率分割。

我们通过简单地计算我们在文档中看到单词的次数,并使用几个世纪的概率理论来解释数据,就完成了所有这些工作,并取得了很高的准确率。我们不需要神经网络、高级框架或深度自然语言编程知识来获得这些结果;因此,朴素贝叶斯分类器应该是你在研究机器学习时应该关注的核心算法之一。

在接下来的章节中,我们将探讨两个不应被忽视的分类算法:SVM 和随机森林。

支持向量机

支持向量机(SVM)是一种数值分类器,在某些方面与 KNN 算法相似,尽管 SVM 在数学上更为先进。SVM 不是将测试点与其最近的点进行比较,而是试图在数据点的类别之间绘制边界线,创建一个区域,其中该区域内的所有点都将被视为该类别的成员。

考虑这张图片(来自维基百科关于 SVM 的文章)。数据点的两个类别由一条直线分开。分隔类别的线被选为最大间隔线,这意味着这条分割线在其两侧都有最多的空间,与你可以绘制的任何其他分割线相比:

图片

正如这里实现的那样,SVM 在某些有限情况下是有用的,但它不是一个强大的工具,因为它要求类别必须是线性可分的;也就是说,它要求你可以在两个类别之间画一条直线。这个 SVM 也是一个二元分类器,意味着它只处理两个类别或类别。

考虑以下数据(此图像及之后的图像均由 Shiyu Ji 提供,并授权使用 Creative Commons CC BY-SA 4.0 许可)。尽管只有两个类别,但它们并不是线性可分的;只有圆形或椭圆形才能将这两个类别分开:

虽然 SVM 自 20 世纪 60 年代以来就存在,但直到 1992 年研究人员才找到了解决这个问题的方法。通过使用一种称为核技巧的技术,可以将非线性可分的数据转换成更高维度的线性可分数据。在这种情况下,通过核转换数据将增加一个第三维度,而正是这个新的第三维度变得线性可分:

应用核技巧后,数据已经被映射到三维空间。红色数据点在第三维度上被向下拉,而紫色点则被向上拉。现在可以绘制一个平面(在二维空间中直线的三维等价物),以分离这两个类别。

通过适当选择核和参数,支持向量机可以处理各种形状的数据。虽然支持向量机总是会在数据上绘制一条线、一个平面或超平面(平面的更高维版本)——这些总是直线——但算法首先将数据转换成可以用直线分离的形式。

可以与 SVM 一起使用的核类型有很多。每种核以不同的方式转换数据,而适当的核选择将取决于你的数据形状。在我们的案例中,我们将使用径向基函数核,这是一种适用于聚类数据的良好通用核。SVM 本身有设置和参数需要调整,例如错误成本参数,但请记住,你选择的核也可能有自己的可配置参数。例如,径向基函数使用一个称为gamma的参数,它控制核的曲率。

由于 SVM 需要大量的数学知识,我们不会尝试自己构建。相反,我们将使用一个现成的库和一个流行的经典数据集。我们将使用的数据集被称为iris flower数据集。这个特定的数据集是在 1936 年由 Edgar Anderson(一位植物学家)和 Ronald Fisher(一位统计学家和生物学家)创建的。Anderson 选择了三种鸢尾花物种,具体是* Iris setosa*、* Iris versicolor* 和 * Iris virginica*。对于每种物种,Anderson 选择了 50 个样本,并测量了花瓣长度、花瓣宽度、萼片长度和萼片宽度,并记录了测量值以及物种名称(萼片是保护花蕾在开花前生长的绿色叶子)。

Iris数据集是许多机器学习算法的常见玩具或测试数据集,原因有几个。这是一个小数据集:只有 150 个样本,四个维度或特征,以及三个类别。数据是多维的,但只有四个特征,仍然容易可视化和直观理解。数据中的模式也很有趣,对分类器提出了非平凡的挑战:一种物种(* Iris setosa*)与其他两种物种明显分离,但 * Iris versicolor* 和 * Iris virginica* 则更为交织。

因为数据是四维的,不能直接可视化,但我们可以将两个特征的所有组合分别绘制到网格中。此图像由维基百科用户 Nicoguaro 提供,并授权为 CC BY 4.0:

图片

你可以理解为什么这个数据集对研究人员来说很有趣。在几个维度上,例如花瓣长度与花瓣宽度的比较,* Iris versicolor* 和 * Iris virginica* 有很大的重叠;在其他维度上,它们看起来几乎是线性可分的,例如在花瓣长度与花瓣宽度图上。

最后,让我们实现一个支持向量机(SVM)来帮我们解决这个问题。

创建一个名为Ch5-SVM的新文件夹,并添加以下package.json文件:

{
 "name": "Ch5-SVM",
 "version": "1.0.0",
 "description": "ML in JS Example for Chapter 5 - Support Vector Machine",
 "main": "src/index.js",
 "author": "Burak Kanber",
 "license": "MIT",
 "scripts": {
 "build-web": "browserify src/index.js -o dist/index.js -t [ babelify --presets [ env ] ]",
 "build-cli": "browserify src/index.js --node -o dist/index.js -t [ babelify --presets [ env ] ]",
 "start": "yarn build-cli && node dist/index.js"
 },
 "dependencies": {
 "babel-core": "⁶.26.0",
 "babel-plugin-transform-object-rest-spread": "⁶.26.0",
 "babel-preset-env": "¹.6.1",
 "babelify": "⁸.0.0",
 "browserify": "¹⁵.1.0",
 "libsvm-js": "⁰.1.3",
 "ml-cross-validation": "¹.2.0",
 "ml-dataset-iris": "¹.0.0",
 "ml-random-forest": "¹.0.2"
 }
 }

一旦文件就绪,运行yarn install来安装所有依赖项。我们不会使用data.js文件,而是将使用MLJS库附带的Iris数据集。

接下来,创建一个src文件夹和一个index.js文件。在index.js的顶部,导入以下内容:

import SVM from 'libsvm-js/asm';
import IrisDataset from 'ml-dataset-iris';

接下来,我们需要从IrisDataset库中提取数据。这个 SVM 算法的实现要求我们的标签必须是整数(它不支持将字符串作为标签),因此我们必须将数据集中的物种名称映射到整数:

const data = IrisDataset.getNumbers();
const labels = IrisDataset.getClasses().map(
  (elem) => IrisDataset.getDistinctClasses().indexOf(elem)
);

让我们也编写一个简单的函数来衡量准确度,或者更具体地说,损失(或误差)。这个函数必须接受一个预期值的数组以及一个实际值的数组,并返回错误猜测的比例:

const loss = (expected, actual) => {
  let incorrect = 0,
  len = expected.length;
  for (let i in expected) {
    if (expected[i] !== actual[i]) {
      incorrect++;
    }
  }
  return incorrect / len;
};

我们现在准备实现 SVM 类。我们将以两种方式测试我们的分类器:首先,我们将在整个数据集上训练分类器,然后在整个数据集上测试它;这将测试算法拟合数据的能力。然后我们将使用交叉验证方法,只对数据的子集进行训练,并在未见过的数据上进行测试;这将测试算法泛化其学习的能力。

将以下代码添加到 index.js

console.log("Support Vector Machine");
console.log("======================");

const svm = new SVM({
  kernel: SVM.KERNEL_TYPES.RBF,
  type: SVM.SVM_TYPES.C_SVC,
  gamma: 0.25,
  cost: 1,
  quiet: true
});

svm.train(data, labels);

const svmPredictions = svm.predict(data);
const svmCvPredictions = svm.crossValidation(data, labels, 5);

console.log("Loss for predictions: " + Math.round(loss(labels, svmPredictions) * 100) + "%");
console.log("Loss for crossvalidated predictions: " + Math.round(loss(labels, svmCvPredictions) * 100) + "%");

我们使用一些合理的参数初始化 SVM。我们选择径向基函数作为我们的核函数,我们选择一个称为 CSVC 的特定算法作为我们的 SVM(这是最常见的 SVM 算法),我们选择成本为 1,gamma 为 0.25。成本和 gamma 都将对分类器围绕类别绘制边界的方式产生类似的影响:值越大,围绕聚类的曲线和边界就越紧密。

svm.crossValidation 方法接受三个参数:数据、标签以及将数据分割成多少个段,每个遍历保留一个段用于验证。

从命令行运行 yarn start,你应该会看到以下内容:

 Support Vector Machine
 =============================================
 Loss for predictions: 1%
 Loss for crossvalidated predictions: 3%

这是一个非常强的结果。SVM 能够正确回忆起 99% 的训练示例,这意味着在完全训练后,只有几个数据点被错误地猜测。在交叉验证时,我们只看到 3% 的损失;只有可能五例中的 150 个例子被错误地猜测。交叉验证步骤很重要,因为它更准确地代表了现实世界的性能;你应该调整算法的参数,以便交叉验证的准确率最大化。

对于完全训练的算法,获得 100% 的准确率很容易:我们可以简单地过度拟合数据并记住每个数据点的类别。将 gamma 和 cost 的值都改为 50 并重新运行算法。你应该会看到类似以下内容:

 Support Vector Machine
 =============================================
 Loss for predictions: 0%
 Loss for crossvalidated predictions: 25%

通过提高成本和 gamma 的值,我们正在围绕现有数据点绘制非常紧密的边界。当成本和 gamma 的值足够高时,我们甚至可能为每个数据点绘制单独的圆圈!当测试完全训练的分类器时(例如,每个训练点都已记住),结果是完美的分数,但在交叉验证数据集时,分数会很糟糕。我们的交叉验证使用 80% 的数据用于训练,并保留 20% 用于验证;在这种情况下,我们过度拟合了训练数据,以至于分类器根本无法对未见过的数据点进行分类。分类器记住了数据,但没有从中学习。

作为经验法则,成本值的良好起点大约是 1。较高的成本会更严厉地惩罚训练错误,这意味着你的分类边界将试图更紧密地包裹训练数据。成本参数试图在边界的简单性和训练数据的召回率之间取得平衡:较低的成本将倾向于更简单、更平滑的边界,而较高的成本将倾向于更高的训练准确率,即使这意味着绘制更复杂的边界。这可能会导致样本空间的大部分区域在现实世界数据中被错误分类,特别是如果你的数据集高度分散。较高的成本值对于非常紧密聚集和清晰分离的数据效果更好;你越信任你的数据,你可以将成本设置得越高。成本参数最常见的是介于 0.01 和 100 之间,尽管当然也有可能需要更大或更小的成本值。

同样,gamma 值也控制 SVM 边界的形状和曲率,然而,这个值在应用核技巧转换数据时的数据预处理中产生影响。结果是与成本参数相似,但源于完全不同的机制。gamma 参数本质上控制单个训练样本的影响。gamma 值较低将导致训练点周围的边界更平滑、更宽,而较高的值将导致边界更紧密。gamma 的一个常见经验法则是将其设置为大约 1/M,其中 M 是数据中的特征数量。在我们的例子中,我们的数据有四个特征或维度,因此我们将 gamma 设置为 1/4 或 0.25。

当第一次训练支持向量机(SVM)时,你应该始终使用交叉验证来调整你的参数。与任何机器学习(ML)算法一样,你必须调整参数以适应你的数据集,并确保你对问题进行了充分的泛化,而不是过度拟合你的数据。有系统地调整和测试参数:例如,选择五个可能的成本值和五个可能的 gamma 值,使用交叉验证测试所有 25 种组合,并选择具有最高准确率的参数。

接下来,我们将探讨机器学习的一个现代工作马:随机森林。

随机森林

随机森林算法是现代的、多才多艺的、健壮的、准确的,并且对于你可能会遇到的几乎所有新的分类任务都值得考虑。它不总是给定问题域的最佳算法,并且在高维和非常大的数据集上存在问题。如果你有超过 20-30 个特征或超过,比如说,10 万个训练点,它肯定会在资源和训练时间上遇到困难。

然而,随机森林在许多方面都是优秀的。它可以轻松处理不同类型的特征,这意味着一些特征可以是数值型的,而其他特征可以是分类型的;你可以将如number_of_logins: 24这样的特征与如account_type: guest这样的特征混合。随机森林对噪声非常鲁棒,因此在实际数据上表现良好。随机森林旨在避免过拟合,因此训练和实现起来非常简单,需要的调整和微调比其他算法少。随机森林还会自动评估你数据中每个特征的重要性,因此可以免费帮助你降低维度或选择更好的特征。尽管随机森林在高维数据上可能成本较高,但根据我的经验,大多数现实世界的机器学习问题只涉及大约十几个特征和几千个训练点,而随机森林可以处理这些。这些优点使随机森林成为通用分类任务的优秀算法选择。

因此,我非常难过地报告说,在写作的时候,我在 JavaScript 生态系统中没有找到高质量的随机森林分类器。无论如何,我将继续撰写这一部分——甚至向你展示一个我认为可能存在一些错误或问题的现有库——希望在你阅读这段文字的时候,一切都已经修复,高质量的随机森林将在 JavaScript 中轻松可用。

随机森林是一种基于决策树的集成分类器。集成分类器由多个或许多个单独的分类器组成,它们都对预测进行投票。在第二章“数据探索”中,我们多次运行了 k-means 算法,并使用不同的随机初始条件,以避免陷入局部最优;这是一个基本的集成分类示例。

随机森林是一组决策树。你可能已经熟悉决策树了:在日常生活中,决策树更常被称为流程图。在机器学习(ML)的背景下,决策树是由算法自动训练和构建的,而不是手工绘制。

首先,让我们讨论单个决策树。决策树在随机森林之前就已经存在,但历史上对机器学习的贡献仅限于中等水平。决策树背后的概念与手绘流程图相同。当决策树评估一个数据点时,它会依次检查每个特征:*花瓣长度是否小于 1.5 厘米?如果是,检查萼片长度;如果不是,检查花瓣宽度。*最终,决策树会到达一个最终的叶子或节点,在那里不再可能做出决策,然后树会预测数据点的类别。

决策树通过使用信息理论中的几个概念自动训练,例如信息增益、熵以及一个称为基尼不纯度的度量。本质上,这些技术用于确定最重要的分支决策是什么。决策树希望尽可能小和简单,因此这些技术用于确定如何最好地在决策之间分割数据集。树的第一个分支应该检查花瓣宽度还是萼片长度?如果它检查萼片长度,应该是在 2.0 厘米还是 1.5 厘米处分割?哪些比较将导致整个数据集的最佳分割?这种训练是递归进行的,每个特征和每个训练点都会被评估以确定其对整体的影响。

结果是一个既快速又易于理解和调试的分类器。与神经网络不同,其中每个神经元的影響非常抽象,也与贝叶斯分类器不同,后者需要概率方面的技能才能理解,决策树可以被表示为流程图,并由研究人员直接解释。

很不幸,决策树本身并不非常准确,它们对训练数据或噪声的变化不稳健,可能会陷入局部最优,而且有一些问题类别决策树处理得并不好(比如经典的 XOR 问题,会导致树变得非常复杂)。

在 20 世纪 90 年代中期,研究人员找到了两种新的决策树集成方法。首先,开发了样本袋装法(或自助聚集)技术。在这种方法中,你创建多个决策树,每个树基于训练数据的完全随机子集(有放回),并在做出预测时使用所有树的多数投票。袋装法之所以有效,是因为单个树的噪声方差很高,但对于许多不相关的树,噪声往往会相互抵消。想象一下在体育馆里,观众们跟着他们最喜欢的乐队一起唱歌——人群总是听起来很和谐,因为唱得尖锐的人会被唱得平的人所抵消。

随机森林建立在 bagging(袋装法)的基础上,不仅随机化每个树接收到的样本,还随机化每个树接收到的特征。与样本袋装法相反,你可以称之为特征袋装法。如果你为我们的Iris数据集(该数据集有四个特征和 150 个数据点)构建一个包含 50 棵树的随机森林,你可能期望每棵树只有 100 个独特的数据点,并且只有四个特征中的两个。像样本袋装法一样,特征袋装法旨在解耦每个决策树,并减少集成整体的方差。特征袋装法还旨在识别最重要的特征,如果你需要节省资源,你总是可以从数据集中移除最不重要的特征。当你尝试预测一个数据点时,每棵树都会提交它的投票;有些树可能会非常错误,但整个集成将做出一个非常好的预测,对噪声具有鲁棒性。

让我们构建一个随机森林,并用我们的Iris数据对其进行测试。你应该已经在package.json文件中安装了随机森林和交叉验证库,从 SVM 部分开始;如果没有,你应该使用yarn add安装ml-cross-validationml-random-forest

Ch5-SVM示例的现有index.js文件顶部,导入适当的类:

import {RandomForestClassifier} from 'ml-random-forest';
import crossValidation from 'ml-cross-validation';

你应该已经从 SVM 部分设置了labelsdata。现在,将以下内容添加到文件底部,在 SVM 示例下方:

console.log("======================");
console.log("Random Forest");
console.log("======================");

const rfOptions = {
  maxFeatures: 3,
  replacement: true,
  nEstimators: 100,
  useSampleBagging: true
};

const rf = new RandomForestClassifier(rfOptions);
rf.train(data, labels);
const rfPredictions = rf.predict(data);

const confusionMatrix = crossValidation.kFold(RandomForestClassifier, data, labels, rfOptions, 10);
const accuracy = confusionMatrix.getAccuracy();

console.log("Predictions:");
console.log(rfPredictions.join(","));
console.log("\nLoss for predictions: " + Math.round(loss(labels, rfPredictions) * 100) + "%");
console.log("Loss for crossvalidated predictions: " + Math.round( (1 - accuracy) * 100) + "%\n");
console.log(confusionMatrix);

与 SVM 示例类似,我们以两种方式评估随机森林。我们首先在全部训练数据上训练森林,并评估其召回率,然后我们使用交叉验证来了解其实际性能。在这个例子中,我们使用 MLJS 的交叉验证和混淆矩阵工具来评估分类器的性能。

使用yarn start运行代码,你应该会看到以下类似的内容:

Random Forest
======================================================================
Predictions:
0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0, 0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,1,2,0,2,0,2,0,0,2,2,2,1,2,1,2,2,1, 2,2,2,2,2,2,2,2,2,0,1,1,2,2,0,2,2,2,1,1,1,2,2,0,1,0,0,2,0,0,2,2,2,2,2,
2,0,2,2,2,2,2,2,0,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
2,2,2,2,2,2,2,2,2,2

Loss for predictions: 31%
Loss for crossvalidated predictions: 33%

ConfusionMatrix {
 labels: [ 0, 1, 2 ],
 matrix: [ [ 43, 6, 1 ], [ 8, 11, 31 ], [ 1, 2, 47 ] ] }

很不幸,这个算法的准确性非常差。实际上,这种表现并不典型,尤其是对于Iris数据集,这个数据集对于算法来说应该非常容易解释。

我想要确定这些糟糕的结果是由于实现问题而不是概念问题,所以我用相同的 Iris 数据集通过我日常使用的熟悉的随机森林库运行,使用相同的选项和参数,但得到了非常不同的结果:我的随机森林的交叉验证损失仅为 2%。不幸的是,我必须将这个糟糕的准确率归咎于算法的具体实现,而不是随机森林本身。虽然我花了一些时间调查这个问题,但我并没有能够快速地识别出这个实现的问题。有可能我误用了这个工具,然而,更有可能的是,在库的某个地方有一个负号应该是一个正号(或者类似愚蠢且灾难性的错误)。我对于随机森林在Iris数据集上的性能的个人预测是大约 95%的准确率,我熟悉的随机森林库得到了 98%的准确率,但这个库只得到了 70%的准确率。

更糟糕的是,我无法在 JavaScript 中找到一个适用于Iris数据集的随机森林库。虽然有几个随机森林库,但没有一个是现代的、维护良好的和正确的。Andrej Karpathy 有一个废弃的随机森林库似乎可以工作,但它只能处理二分类(只有 1 和-1 作为标签),还有几个其他随机森林库在类似的方式下有限制。《MLJS》随机森林库是我们之前使用的最接近一个工作、维护良好的库,所以我希望无论问题是什么,它都会在你阅读这篇文章的时候被发现并解决。

我不希望你们因为使用随机森林而气馁。如果你在除了 JavaScript 以外的语言中工作,有许多随机森林库可供选择。你应该熟悉它们,因为它们很快就会成为你大多数分类问题的首选。至于 JavaScript,虽然从零开始构建随机森林比构建贝叶斯分类器更难,但它们仍然是可以实现的。如果你能够正确实现决策树,或者从不同的语言中移植一个,构建随机森林就会变得非常简单——森林中的树做了大部分工作。

虽然 JavaScript 的机器学习工具集一直在进步,但这个随机森林的例子完美地突出了还有很多工作要做。你必须谨慎行事。我开始写这个例子时,预期至少有 95%的准确率,基于我对随机森林的先前经验。但如果没有期望或经验呢?我会接受这个工具的 70%准确率吗?我会说服自己随机森林不适合这项工作吗?这会让我在将来不愿意使用随机森林吗?也许吧!JavaScript 生态系统中的机器学习会有更多这样的地雷;小心它们。

在我们结束这一章之前,我想回顾一下我们刚才看到的混淆矩阵,因为这可能对你来说是一个新概念。我们在前面的章节中讨论了精确度、召回率和准确率。混淆矩阵是这些值可以从任何分类中得出的原始数据。这是随机森林的混淆矩阵,再次呈现:

ConfusionMatrix {
 labels: [ 0, 1, 2 ],
 matrix: [ [ 43, 6, 1 ], [ 8, 11, 31 ], [ 1, 2, 47 ] ] }

如果我们将这些组织成表格,可能看起来是这样的:

Guessed I. setosaGuessed I. versicolorGuessed I. virginica
Actual I. setosa4361
实际 I. versicolor81131
实际 I. virginica1247

混淆矩阵是猜测与实际类别之间的矩阵(或表格)。在理想的世界里,你希望混淆矩阵除了对角线外都是零。混淆矩阵告诉我们随机森林在猜测Iris setosaIris virginica方面做得相当不错,但它错误地将大多数Iris versicolor标记为Iris virginica。考虑到数据的形状,这并不太令人惊讶;回想一下,后两种物种重叠相当多(然而,随机森林仍然应该能够解决这个问题)。

我们为随机森林编写的代码还打印出了每个数据点的个别预测,看起来是这样的:

0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,1,2,0,2,0,2,0,0,2,2,2,1,2,1,2,2,1,2,2,2,2,2,2,2,2,2,0,1,1,2,2,0,2,2,2,1,1,1,2,2,0,1,0,0,2,0,0,2,2,2,2,2,2,0,2,2,2,2,2,2,0,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2

这些数字并不完全与混淆矩阵中的数字相同,因为这些预测来自完全训练的树,而混淆矩阵来自交叉验证过程;但你可以看到它们仍然很相似。前 50 个预测应该都是 0(对于Iris setosa),而且大多数确实是。接下来的 50 个预测应该是全部 1,但主要是 2;混淆矩阵告诉我们同样的事情(即大多数I. versicolor被错误地标记为I. virginica)。最后 50 个预测应该是全部 2,大部分是正确的。混淆矩阵是查看预期猜测与实际猜测之间差异的一种更紧凑和直观的方式,这正是你在微调算法时需要的信息。

简而言之,随机森林是一种优秀的分类算法,但目前还没有令人信服的 JavaScript 实现。我鼓励您成为 JavaScript 进化的参与者,构建自己的随机森林,或者至少将这个算法记在心里以备将来使用。

摘要

分类算法是一种监督学习算法,其目的是分析数据并将未见过的数据点分配到预存在的类别、标签或分类中。分类算法是机器学习(ML)中一个非常受欢迎的子集,有众多分类算法可供选择。

具体来说,我们讨论了简单直观的 k 近邻算法,该算法在图上比较数据点与其邻居。我们还讨论了优秀且非常受欢迎的朴素贝叶斯分类器,它是一种经典的基于概率的分类器,在文本分类和情感分析问题空间中占据主导地位(尽管它也可以用于许多其他类型的问题)。我们还讨论了支持向量机,这是一种适用于非线性可分数据的先进几何分类器。最后,我们讨论了随机森林分类器,这是一种强大且稳健的集成技术,依赖于决策树,但不幸的是,在 JavaScript 中只有一种有疑问的实现。

我们还讨论了交叉验证和混淆矩阵,这两种强大的技术可以用来评估你模型的准确性。

在下一章中,我们将探讨关联规则,这些规则为我们提供了更多的预测能力。如果有人在商店购买了面包和黄油,他们更有可能还会购买牛奶,还是购买熟食肉类?关联规则可以帮助我们建模和解释这些关系。

第六章:关联规则算法

关联规则学习,或称关联规则挖掘,是一种相对较新的无监督学习技术,最初用于在杂货店发现购买商品之间的关联。关联规则挖掘的目标是发现商品集合之间的有趣关系,例如,发现为应对飓风做准备的人通常会购买 Pop-Tarts、瓶装水、电池和手电筒。

在第五章,“分类算法”中,我们介绍了条件概率的概念。在本章中,我们将把这一概念进一步拓展,并将其应用于关联规则学习。回想一下,条件概率询问(并回答)的问题是:如果我们知道某事,另一件事发生的概率是多少?或者,如果某人买了瓶装水和电池,他们购买 Pop-Tarts 的概率是多少?这个概率很高,正如我们很快就会看到的。

在关联规则学习中,我们的目标是查看交易或事件数据库,并通过概率将最常见的子集相互关联。这可以通过一个例子更容易理解。想象你经营一家电子商务商店,你的任务是创建一个个性化的主页小部件,向购物者推荐产品。你可以使用他们完整的订单历史数据库,你必须使用购物者的浏览历史来推荐他们很可能购买的商品。

自然地,解决这个问题有几种方法。没有理由你不能在商店整个订单历史上训练一个神经网络来推荐新产品——除了时间和复杂性。在数百万笔交易上训练神经网络既耗时又非常难以直观地检查和理解。另一方面,关联规则学习为我们提供了一个简单快捷的工具,这个工具基于基本的概率概念。

假设你的电子商务商店是一家销售精品、精选家居装饰和家具的直邮业务。你的目标是确定最常一起购买的商品组合,例如:90%购买躺椅和茶几的人也购买了脚凳,80%购买巨型挂钟的人也购买了干墙安装锚固件套装。

如果你有一种快速有效的方法来搜索数百万笔以前的订单以找到这些关系,你可以将当前购物者的浏览历史与其他购物者的购买历史进行比较,并显示购物者最有可能购买的商品。

关联规则学习不仅限于电子商务。另一个明显的应用是实体店,比如你当地的超市。如果 90%购买牛奶和鸡蛋的购物者也会购买面包,那么把面包放在附近可能会让购物者更容易找到它。或者,你可能想把面包放在商店的对面,因为你知道购物者将不得不走过很多通道,并且可能在这个过程中购买更多商品。如何使用这些数据取决于你,这取决于你想要优化什么:购物者的便利性还是整个购物篮的价值。

初看起来,这似乎是一个容易编写的算法——毕竟我们只是在计算概率。然而,在大型数据库和大量可能的商品选择中,检查每个商品组合的频率会变得非常耗时,因此我们需要比暴力穷举搜索方法更复杂一些的方法。

在本章中,我们将讨论:

  • 从数学角度的关联规则学习

  • Apriori 算法的描述

  • 关联规则学习的各种应用

  • 各种关联规则算法的工作示例

让我们从数学的角度来探讨关联规则学习。

数学角度的描述

关联规则学习假设你有一个事务数据库来学习。这并不指代任何特定的技术,而是指存储事务的数据库概念——数据库可以是内存中的数组、Excel 文件,或者你生产环境中的 MySQL 或 PostgreSQL 实例中的表。由于关联规则学习最初是为超市中的产品开发的,原始的事务数据库是每个购物者在一次购物过程中购买的商品列表——本质上是一个收银通道的收据档案。然而,事务数据库可以是任何单次会话中发生的商品或事件的列表,无论这个会话是购物之旅、网站访问还是去看医生。目前,我们将考虑超市的例子。我们将在后面的章节中讨论关联规则的其他用途。

事务数据库是一个行代表会话、列代表商品的数据库。考虑以下:

收据鸡蛋牛奶面包奶酪洗发水
1
2
3
4
5

这样的表格可以被视为一个事务数据库。请注意,我们并没有记录每个商品购买的数量,只是记录商品是否被购买。在大多数关联规则学习中,通常忽略商品的数量和顺序。

根据表中的信息,我们可以组合出各种事件发生的概率。例如,购物者购买洗发水的概率,或P(E[Shampoo]),是 20%。购物者同时购买奶酪和面包的概率是 40%,因为有两位购物者同时购买了奶酪和面包。

从数学上讲,牛奶面包被称为项集,通常写作{milk, bread}。项集类似于我们在第五章“分类算法”中引入的概率事件的概念,但项集专门用于这种情况,而事件是概率中更一般的概念。

在关联规则学习中,一个项集作为交易的一部分出现的概率被称为该项集的支持度。刚才我们提到,某人购买牛奶和面包的概率是 40%;这是另一种说法,即{milk, bread}项集的支持度为 40%。用数学表示,我们可以写成supp({milk, bread}) = 40%

然而,计算项集的支持度并不能让我们完全达到关联规则学习。我们首先需要定义什么是关联规则。关联规则的形式是 X -> Y,其中XY都是项集。完整写出来,一个示例关联规则可以是{eggs, milk} -> {cheese},这关联了购买鸡蛋和牛奶与购买奶酪。尽管左侧可以有任意数量的项目,但关联规则几乎总是只有右侧有一个项目。关联规则本身并不能告诉我们关于关联的信息;我们还需要查看各种指标,如关联的置信度提升度,以了解关联有多强。

对于关联规则来说,最重要的指标是其置信度,这本质上是指规则被发现为真的频率。置信度也恰好是条件概率P(E[Y]|E[X]),或给定某人购买了X中的项目,他们购买Y中项目的概率。

使用我们在第五章“分类算法”中关于条件概率的知识,以及关联规则学习中的新概念“支持”和“置信度”,让我们写出一些等价式,这将帮助我们巩固这些数学概念。

首先,让我们假设项集X是鸡蛋和牛奶,或X = {eggs, milk},而Y = {cheese}

X的支持,或supp(X),等同于在交易中找到X中项目的概率,或P(E[X])。在这种情况下,鸡蛋和牛奶出现在五笔交易中的三笔,因此其支持度为 60%。同样,Y(仅奶酪)的支持度为 80%。

关联规则 X -> Y 的置信度定义为 conf(X -> Y) = supp(X ∪ Y) / supp(X)。另一种说法是,规则的置信度是规则中所有项的支持度除以左侧的支持度。在概率论中,∪ 符号表示 并集——基本上是一个布尔 OR 操作。因此,XY 项集的 并集 是出现在 X 或 Y 中的任何项。在我们的例子中,并集是鸡蛋、牛奶和奶酪。

如果 supp(X) = P(EX),那么 supp(X ∪ Y) = P(EX ∩ XY)。回想一下,∩ 是 交集 的符号,或者说本质上是一个布尔 AND 操作。这是项集语义与概率事件语义不同的一种情况——两个项集的 并集 与包含这些项集的两个事件的 交集 有关。尽管符号有点令人困惑,但我们想要表达的是:当我们开始将关联规则符号翻译成标准的概率符号时,这个 置信度 公式开始看起来非常像条件概率的公式。

由于在条件概率中,P(E[Y] | E[X]) = P(E[X] ∩ E[Y]) / P(E[X]) 这个关系定义了条件概率,并且我们知道 supp(X ∪ Y) = P(E[X] ∩ E[Y]),我们还知道 P(E[X]) = supp(X),我们发现关联规则的置信度就是它的条件概率。

回到我们的示例规则 {eggs, milk} ⇒ {cheese},我们发现这个规则的置信度为 1.0。XY(或 {eggs, milk, cheese})的并集在五笔交易中出现了三次,其支持度为 0.6。我们将这个支持度除以左侧的支持度,即 supp ({eggs, milk}),我们也在五笔交易中找到了它。将 0.6 除以 0.6 得到 1.0,这是可能的最大置信值。每次购物者购买鸡蛋和牛奶时,他们也会购买奶酪。或者,用条件概率的说法,给定他们购买了鸡蛋和牛奶,购买奶酪的概率是 100%。与购买奶酪的概率只有 80% 相比,我们明显看到鸡蛋、牛奶和奶酪之间存在正相关关系。

这种偶然关系可以通过一个称为提升度的概念进一步探索。提升度定义为组合项的支持度除以左侧和右侧各自的支持度(即,假设它们是独立的)。公式是 提升度(X -> Y) = supp(X ∪ Y) / ( supp(X) * supp(Y) )。这个公式本质上衡量了XY相互之间是依赖还是独立。如果XY一起的支持度与XY分别的支持度相同,那么规则的提升度将是 1,XY可以被认为是完全相互独立的。随着两个项集的相互依赖性增加,提升度的值也会增加。在我们的例子中,{鸡蛋,牛奶,奶酪}的支持度再次是 0.6,{鸡蛋,牛奶}的支持度是 0.6,而{奶酪}的支持度是 0.8。将这些值与提升度公式结合起来,我们得到 提升度(X -> Y) = 0.6 / (0.6 * 0.8) = 1.25。这个规则据说有 25%的提升度,这表明{鸡蛋,牛奶}{奶酪}之间存在某种依赖关系。

在开发关联规则时,研究人员可以使用几种其他指标,尽管在我们的示例中我们不会遇到这些指标。例如有信念度杠杆作用集体力量等指标,但大部分情况下,熟悉的支持度、置信度和提升度概念就足够了。

如果你从这个部分学到了什么,让它成为这一点:许多计算机科学和机器学习中的现代问题都可以用几个世纪的概率理论来解决。关联规则学习是在 20 世纪 90 年代开发的,但其核心概念可以追溯到数百年前。正如我们在第五章中看到的,分类算法,我们可以使用概率理论来开发强大的机器学习ML)算法,关联规则学习也是提高你对概率理论知识的另一个论据。

现在我们来探讨分析事务型数据库的挑战,以及关联规则算法可能的工作方式。

算法视角

我们现在面临的是一个更加困难的任务,即在数据库中识别频繁项集。一旦我们知道我们想要为哪些项集和关联生成规则,计算规则的支持度和置信度就相当容易了。然而,困难在于自动发现数百万笔交易中数以千计的可能项的频繁且有趣的项集。

假设你的电子商务商店只有 100 种独特的商品。显然,你的客户在会话期间可以购买任意数量的商品。让我们说一个购物者只买了两种商品——从你的目录中考虑两种商品的不同组合有 4,950 种。但你还需要考虑购买三种商品的购物者,这其中有 161,700 种组合需要搜索。如果你的产品目录包含 1,000 种商品,在搜索频繁项集时,你需要考虑的三个商品组合有 1,660 万种。

显然,需要一个更高级的算法来搜索事务数据库中的频繁项集。请注意,频繁项集搜索只是解决方案的一半;一旦找到频繁项集,你仍然必须从它们中生成关联规则。然而,由于频繁项集搜索比生成关联规则要困难得多,因此项集搜索成为大多数算法的关键焦点。

在本节中,我们将描述一种原始的频繁项集搜索算法:Apriori 算法。我们这样做只是为了教育目的;你不太可能需要实现自己的 Apriori 算法版本,因为现在有更新、更快的频繁项集搜索算法可用。然而,我认为研究并理解这些经典算法很重要,特别是那些解决非常广大搜索空间的算法。大多数搜索非常广大空间的算法都使用某种公理化或启发式证明的技巧来极大地减少搜索空间,Apriori 也不例外。

Apriori 算法首先扫描事务数据库,并记录每个单独物品的支持度(或频率)。结果是物品列表或哈希表,例如鸡蛋 = 0.6,牛奶 = 0.6,洗发水 = 0.2。

下一步是找到两个物品的组合并确定它们在数据库中的支持度(或频率)。这一步骤的结果可能类似于 {鸡蛋, 牛奶} = 0.6{鸡蛋, 面包} = 0.2{鸡蛋, 奶酪} = 0.6{鸡蛋, 洗发水} = 0.0,等等。暴力搜索、穷举搜索方法的问题从这一步开始。如果你目录中有 100 个物品,你需要计算 4,950 对的支持度。如果你目录中有 1,000 个物品,你必须计算近 500,000 对的支持度。我不知道亚马逊([www.amazon.com/](www.amazon.com/))卖了多少产品(20… 年 1 月的最新报告称有 3.68 亿),但假设他们现在有 4 亿个产品,有 8 x 10¹⁶对物品需要考虑(那是八十万亿对物品)。而且这只是物品的。我们还需要查看每个物品的三元组、四元组,等等。

Apriori 用来减少搜索空间的巧妙技巧是通过最小支持度或最小感兴趣频率来过滤唯一产品列表。例如,如果我们设定最小支持度为 0.25,我们会发现{洗发水}不符合条件,因此洗发水永远不会成为我们的频繁项集分析的一部分,因为它简单地没有频繁购买。

如果洗发水本身购买频率不够高,不足以被认为是频繁的,那么任何包含洗发水的项目对也将同样不足以被考虑。如果洗发水出现在 20%的购买中,那么{鸡蛋, 洗发水}这对必须出现在(或等于)20%的购买中更少(或等于)的频率。我们不仅可以从搜索中排除洗发水,还可以从考虑中排除任何包含洗发水的集合。如果洗发水本身购买频率足够低以至于我们可以忽略它,那么{鸡蛋, 洗发水}{面包, 洗发水}{鸡蛋, 面包, 洗发水}也将同样足够低以至于我们可以忽略它们。这大大减少了我们的搜索空间。

我们可以在检查更大组合的项目时将这种方法进一步深化。在我们的例子中,{鸡蛋}的支持度为 60%,而{面包}的支持度为 40%。如果我们设定的最小支持度为 25%,这两个项目单独都符合条件,应该在我们的频繁数据集分析中考虑。然而,{鸡蛋, 面包}的组合支持度仅为 20%,可以被舍弃。同样地,我们能够从二级搜索中消除任何包含{洗发水}的组合,现在我们也可以从三级搜索中消除任何包含{鸡蛋, 面包}的组合。因为鸡蛋和面包一起出现的频率很低,所以任何包含鸡蛋和面包的三个或更多项目的组合也必须很少见。因此,我们可以从考虑中排除像{鸡蛋, 面包, 奶酪}{鸡蛋, 面包, 牛奶}{鸡蛋, 面包, 洗发水}这样的组合,因为它们都包含了罕见的鸡蛋面包组合。

虽然这种方法大大减少了寻找频繁项集所需的时间,但你应该谨慎使用这种方法,因为可能会意外地跳过一些有趣但相对罕见的组合。大多数 Apriori 实现都将允许你为生成的关联规则设置最小支持和最小置信度。如果你将最小支持度设定为高值,你的搜索将会更快,但你可能会得到更明显或不太有趣的结果;如果你将支持度设定得较低,你可能会在等待搜索完成上花费很长时间。通常,关联规则是在找到频繁项集之后生成的,所以你设定的任何最小置信度水平都不会影响搜索时间——只有最小支持度变量会对搜索时间产生重大影响。

还应注意的是,对于频繁项集搜索,存在更多高级且更快的算法。特别是,我们将在本章后面实验 FP-Growth 算法。然而,Apriori 算法是理解实际中频繁项集搜索如何工作的绝佳起点。

在我们实现库之前,让我们看看一些可能有助于关联规则的场景。

关联规则应用

关联规则算法的原始用途是市场篮子分析,例如我们在本章中一直使用的杂货店示例。这是关联规则挖掘的一个明确应用。市场篮子分析可以用于实体店和电子商务店,并且可以根据不同的星期日、季节或甚至特定罕见事件(如即将到来的音乐会或飓风)维护不同的模型。

事实上,在 2004 年,《纽约时报》(以及其他媒体)报道说,沃尔玛使用关联规则挖掘来提前了解如何为飓风储备商店。沃尔玛发现,在飓风来临前的最高提升关联并不是瓶装水或手电筒,而是草莓 Pop-Tarts。另一个具有高置信度的关联是啤酒。我对啤酒并不感到太惊讶,但草莓 Pop-Tarts 这种洞察力只能从机器学习中真正获得!

想象一下,如果你在 2004 年的沃尔玛担任数据科学家。查看不同时间段各种产品的单个销售量很容易。可能草莓 Pop-Tarts 作为一种小额商品,在飓风期间相对销售量的百分比变化非常小。这就是你可能自然忽略的、看似不重要的数据点。Pop-Tarts 销量略有上升,那又如何?但如果你挖掘频繁项集和关联规则的数据,你可能会发现{瓶装水,电池} -> {草莓 Pop-Tarts}规则在飓风来临前的几天出现了异常强的置信度和大约 8.0 的提升(提升值非常高)。在飓风季节之外,这种关联可能不存在或太弱而无法被选中。但当飓风即将来临,草莓 Pop-Tarts 成为必需的飓风补给品,几乎肯定是因为它们的长期保质期以及它们能让孩子们和成年人快乐的特性。看到这个关联,你会告诉商店增加草莓 Pop-Tarts 的库存,并将它们放在商店最前面——紧挨着瓶装水和电池——从而在 Pop-Tarts 销售上大赚一笔。

虽然这种类型的场景是关联规则设计的目的,但你可以将频繁项集挖掘和关联规则应用于任何事务数据库。如果你将网站会话视为一个事务,并且如果你可以捕获采取的行动(例如 登录加入愿望清单的商品下载案例研究)作为你的项目,你就可以将相同的算法和关联规则挖掘应用于网站访客行为。你可以开发关联规则,例如 {下载案例研究, 查看定价页面} -> {输入信用卡},来模拟访客行为并优化你网站的布局和功能,以鼓励你希望的行为。

请记住,关联规则不仅在它们为正时才有价值。当它们为负时,同样有价值。很多时候,你需要冷酷、硬性的事实来改变你对之前顽固信念的看法。在对数据集进行关联规则挖掘时,如果没有看到你预期看到的关联,这可以与发现意外的关联一样强大。看到你直觉上认为的强关联的置信度实际上非常低,或者低于你的阈值,这可以帮助你放弃可能阻碍你或你的产品的过时思维。

有许多关于关联规则挖掘在许多和不同领域被使用的例子。是的,关联规则可以用来在飓风来临之前最大化 Pop-Tarts 的利润,但关联规则也可以用来根据其特征和功率输出来描述飓风本身。尽管关联规则学习是为篮子分析开发的,但其基于条件概率的基础使其适用于几乎任何可以用项目和事务表示的统计系统。

以医疗诊断为例。如果每位医生的诊断被视为一个事务,每种医疗状况或环境因素被视为一个项目,我们可以应用关联规则挖掘来发现现有条件、环境因素和新诊断之间的惊人关联。你可能会发现 {空气质量差,饮食差} -> {哮喘} 规则具有高置信度或提升,这可以告知研究人员和医生如何治疗哮喘,也许可以通过更仔细地关注饮食来实现。

关联规则可以应用于许多其他领域,如遗传学、生物信息学和 IT 安全。由于这些方法可以如此广泛地使用,因此很难认识到何时应该应用关联规则。一个很好的经验法则是:如果你的数据集包含事务,或者如果你可以看到自己计算许多事件组合的条件概率,你可能需要考虑关联规则挖掘。

让我们来看看几个用于关联规则挖掘的 JavaScript 库。

示例 – 零售数据

在这个例子中,我们将使用 Apriori 算法来分析一个零售数据集。首先,为这个项目创建一个名为Ch6-Apriori的新文件夹,并添加以下package.json文件:

{
  "name": "Ch6-Apriori",
  "version": "1.0.0",
  "description": "ML in JS Example for Chapter 6 - Association Rules",
  "main": "src/index.js",
  "author": "Burak Kanber",
  "license": "MIT",
  "scripts": {
    "build-web": "browserify src/index.js -o dist/index.js -t [ babelify --presets [ env ] ]",
    "build-cli": "browserify src/index.js --node -o dist/index.js -t [ babelify --presets [ env ] ]",
    "start": "yarn build-cli && node dist/index.js"
  },
  "dependencies": {
    "apriori": "¹.0.7",
    "babel-core": "⁶.26.0",
    "babel-plugin-transform-object-rest-spread": "⁶.26.0",
    "babel-preset-env": "¹.6.1",
    "babelify": "⁸.0.0",
    "browserify": "¹⁵.1.0",
    "node-fpgrowth": "¹.0.0"
  }
}

在添加package.json文件后,从命令行运行yarn install以安装依赖项。

接下来,创建一个src目录,并从本书的 GitHub 仓库下载所需的数据文件retail-data.json到文件夹中。

现在将index.js文件添加到src文件夹中,并添加以下代码:

import receipts from './retail-data.json';
import Apriori  from 'apriori';
import {FPGrowth} from 'node-fpgrowth';

const results = (new Apriori.Algorithm(0.02, 0.9, false))
    .analyze(receipts.slice(0, 1000));

console.log(results.associationRules
    .sort((a, b) => a.confidence > b.confidence ? -1 : 1));

上述代码导入数据集和 Apriori 库。然后,使用最小支持度为0.02(2%)和最小规则置信度为 90%初始化一个新的 Apriori 求解器。我们还在数据集中仅分析前 1000 张收据;由于 Apriori 算法本质上比较慢,所以在最初实验时你可能想要限制数据集的大小。

使用yarn start运行程序,你应该会看到类似以下输出的结果。输出将比这里显示的更长;花点时间探索你自己的控制台输出:

[ a {
 lhs:
 [ 'KNITTED UNION FLAG HOT WATER BOTTLE',
 'RED WOOLLY HOTTIE WHITE HEART.',
 'SET 7 BABUSHKA NESTING BOXES' ],
 rhs: [ 'WHITE HANGING HEART T-LIGHT HOLDER' ],
 confidence: 1 },
 a {
 lhs:
 [ 'RETRO COFFEE MUGS ASSORTED',
 'SAVE THE PLANET MUG',
 'VINTAGE BILLBOARD DRINK ME MUG',
 'WHITE HANGING HEART T-LIGHT HOLDER' ],
 rhs: [ 'KNITTED UNION FLAG HOT WATER BOTTLE' ],
 confidence: 1 },
 a {
 lhs:
 [ 'RETRO COFFEE MUGS ASSORTED',
 'SAVE THE PLANET MUG',
 'VINTAGE BILLBOARD DRINK ME MUG' ],
 rhs: [ 'WHITE HANGING HEART T-LIGHT HOLDER' ],
 confidence: 1 },

这些关联规则都具有 1.0 的置信度,这意味着右侧(标记为rhs)在左侧出现时 100%的情况下都会出现。

在结果中向下滚动一点,你可能会找到以下规则:

 a {
 lhs: [ 'HAND WARMER BABUSHKA DESIGN', 'HAND WARMER RED RETROSPOT' ],
 rhs: [ 'HAND WARMER BIRD DESIGN' ],
 confidence: 0.9130434782608696 },

这个规则实际上告诉我们,当购物者购买 babushka 和红色复古设计的手暖器时,他们有 91%的可能性也会购买鸟形设计的手暖器。你有没有想过,当你在亚马逊购物时,为什么经常看到类似你刚刚购买或添加到购物车中的商品的建议?这就是原因——显然,购物者经常购买足够多的相似商品,以至于关联规则通过了它需要通过的各种阈值,尽管平均购物者可能不需要三个不同设计的手暖器。但迎合平均购物者并不总是目标;你想要迎合那些会花更多钱的购物者,而你可以通过统计数据找到这样的购物者。

尝试调整 Apriori 设置。如果你降低最小置信度会发生什么?如果你增加最小支持度会发生什么?

在保持最小支持度不变的同时降低最小置信度应该会给你更多的关联规则结果,而不会对执行时间产生实际影响。大部分执行时间都花在发现频繁项集上,此时置信度尚未是一个定义好的参数;置信度只在组合规则时发挥作用,不会影响单个项集。

提高最小支持度将加快算法的速度,然而,你会发现得到的结果不那么有趣。随着你提高最小支持度,你会发现规则的左侧变得更加简单。你以前会看到左侧有三个或四个项的规则,现在你将开始看到只有一项或可能两项的更简单的左侧项集。包含多个项的项集自然倾向于具有较低的支持值,所以随着你提高最小支持度,你最终会得到更简单的关联。

另一方面,降低最小支持度将大大增加执行时间,但也会产生更有趣的结果。请注意,可能存在支持度一般但置信度非常高的规则;这些规则通常成立,但发生频率较低。随着你降低最小支持度,你会发现新出现的规则在置信度值范围内均匀分布。

还可以尝试增加receipts.slice所给的限制。如果你保持最小支持度参数不变,不仅程序会变慢,而且输出中的规则也会更少。原因在于支持值取决于数据集的大小。一个在 1,000 笔交易中出现在 2%的项集可能只在 2,000 笔交易中的 1%出现,这取决于项的分布。如果你有非常多的项选择,或者如果你的项分布是指数衰减的(即,长尾分布),你会发现你需要随着考虑的项的数量成比例地调整最小支持度值。

为了演示这一点,我从一个最小支持度为 0.02、最小置信度为 0.9 以及从收据变量中选取 1,000 项的限制开始。在这些参数下,Apriori 算法找到了 67 条关联规则。当我将限制从 1,000 增加到 2,000 时,算法没有找到任何规则。在前 1,000 笔交易中的频繁项集与后 1,000 笔交易中的项集差异足够大,以至于当我增加限制时,大多数项集的支持值都降低了。

为了找到更多结果,我必须降低最小支持度。我首先尝试将最小支持度设置为 0.01,然而,在等待程序完成两个小时后,我不得不取消那次尝试。我再次尝试设置为 0.015。这次,程序在 70 秒内完成,并给了我 12 个结果。在 0.010 和 0.015 之间,必须存在某个点,使得项集的数量会急剧增加——确实,程序在最小支持度为 0.0125 时找到了 584 条规则。

项集的支持简单是其所有交易中的频率。我们可以用频率来重新表述与支持相关的一切。如果我们考虑 2,000 笔交易,支持值为 0.0125 对应于 25 次出现。换句话说,我刚刚生成的 584 条规则列表只包括在我的 2,000 笔交易数据集中至少被购买 25 次的商品。为了生成只购买过,比如说 5 次或更多次的产品规则,我需要设置最小支持值为 0.0025——一个我相当确信会烧毁我的笔记本电脑的值。

在这里,需要比 Apriori 更精细的算法变得明显。不幸的是,JavaScript 生态系统在这方面仍然缺乏。另一个流行的频繁项集挖掘算法 ECLAT 似乎没有任何 JavaScript 实现。

我们还有另一个可用的频繁项集挖掘算法:FP-Growth 算法。这个算法应该能够轻松地处理我们的任务,然而,我们可用的库只执行频繁项集搜索,并不生成关联规则。一旦发现了频繁项集,生成关联规则就变得容易多了,但我将这个练习留给读者。现在,让我们看看 FP-Growth 库。

index.js文件中,你可以取消与 Apriori 求解器相关的现有行的注释,并添加以下代码:

const fpgrowth = new FPGrowth(0.01);
fpgrowth.exec(receipts)
    .then(result => {
        console.log(result.itemsets);
        console.log("Completed in " + result.executionTime + "ms.");
    });

FP-Growth 实现不生成关联规则,因此它只接受最小支持值作为参数。在这个例子中,我们没有截断receipts交易数据库,因为算法应该能够处理更大的数据集。完整的交易数据库大约有 26,000 条记录,所以最小支持值为0.01对应于被购买至少260次的产品。

从命令行运行yarn start,你应该看到类似以下输出的内容:

[ { items: [ 'DECORATIVE WICKER HEART LARGE' ], support: 260 },
 { items: [ 'MINIATURE ANTIQUE ROSE HOOK IVORY' ], support: 260 },
 { items: [ 'PINK HEART SHAPE EGG FRYING PAN' ], support: 260 },
 ... 965 more items ]
 Completed in 14659ms.

注意,支持值是以绝对值给出的,即项目在数据库中出现的次数。虽然这些只是频繁项集而不是关联规则,但它们仍然很有用。如果你看到以下类似的频繁项集,你可能想在用户浏览糖碗页面时展示玫瑰茶壶:

{ items: [ 'REGENCY SUGAR BOWL GREEN', 'REGENCY TEAPOT ROSES ' ],
 support: 247 }

虽然我认为在 JavaScript 生态系统中,关联规则学习方面还有一些工作要做,但 Apriori 和 FP-Growth 算法都是可用且有用的。特别是 Apriori 的实现,在大多数现实世界的用例中应该很有用,这些用例通常包含较少的交易和较小的商品目录。虽然 FP-Growth 的实现不生成关联规则,但通过找到频繁出现的商品集合,你仍然可以做很多事情。

摘要

在本章中,我们讨论了关联规则学习,或是在事务数据库中寻找频繁项集的方法,并通过概率将它们相互关联。我们了解到,关联规则学习最初是为了市场篮子分析而发明的,但由于其背后的概率理论和事务数据库的概念都具有广泛的应用性,因此它在许多领域都有应用。

接着,我们深入探讨了关联规则学习的数学原理,并研究了频繁项集挖掘的典型算法方法:Apriori 算法。在尝试我们自己零售数据集上的示例之前,我们探讨了关联规则学习的其他可能应用。

第七章:使用回归算法进行预测

在本章中,我们将简要介绍使用回归算法进行预测。我们还将讨论时间序列分析以及我们如何使用数字信号处理技术来辅助我们的分析。到本章结束时,你将看到时间序列和连续值数据中常见的许多模式,并了解哪些类型的回归适合哪些类型的数据。此外,你还将学习一些数字信号处理技术,例如滤波、季节性分析和傅里叶变换。

预测是一个非常广泛的概念,涵盖了多种类型的任务。本章将为你提供一套适用于时间序列数据的初始概念和算法工具箱。我们将关注基础,并讨论以下主题:

  • 回归与分类

  • 回归基础

  • 线性、指数和多项式回归

  • 时间序列分析基础

  • 低通和高通滤波

  • 季节性和减法分析

  • 傅里叶分析

这些概念构建了一个基本的工具箱,你可以在处理现实世界的预测和分析问题时使用。还有许多其他适用于特定情况的工具,但我认为这些主题是绝对的基础。

让我们从比较和对比机器学习(ML)中回归和分类的相似之处和不同之处开始。

回归与分类

本书的大部分内容都与分类任务有关,分析的目标是将数据点拟合到预定义的多个类别或标签之一。在分类数据时,你可以通过将预测值与真实值进行比较来判断你的算法的准确性;一个猜测的标签要么是正确的,要么是错误的。在分类任务中,你通常可以确定一个猜测的标签与数据的匹配可能性或概率,并且你通常选择具有最大可能性的标签。

让我们比较和对比分类任务与回归任务。两者在最终目标上相似,即根据先前的知识或数据进行预测。两者在目标上相似,即我们希望创建某种函数或逻辑,将输入值映射到输出值,并使映射函数尽可能准确和通用。然而,回归和分类之间的主要区别在于,在回归中,你的目标是确定值的数量而不是其标签。

假设你有一份关于你管理的服务器随时间处理负载的历史数据。这些数据是时间序列的,因为数据随时间演变。数据也是连续的(与离散的相对),因为输出值可以是任何实数:1,或 2.3,或 2.34353,等等。在时间序列分析或回归分析中的目标不是标记数据,而是预测例如下周四晚上 20:15 的服务器负载将会是多少。为了实现这个目标,你必须分析时间序列数据,并尝试从中提取模式,然后使用这些模式进行未来预测。你的预测也将是一个真实且连续的数字,例如我预测下周四晚上服务器负载将是 2.75

在分类任务中,你可以通过将预测与真实值进行比较,并计算预测正确或错误的数量来判断算法的准确性。由于回归任务涉及连续值,不能简单地确定预测是否正确。如果你预测服务器负载将是 2.75,结果实际上是 2.65,你能说预测是正确的吗?或者错误的?如果结果是 2.74 呢?当分类垃圾邮件非垃圾邮件时,要么预测正确,要么预测错误。然而,当你比较连续值时,你只能确定预测有多接近,因此必须使用其他指标来定义算法的准确性。

通常,你将使用不同的算法集来分析连续或时间序列数据,而不是用于分类任务。然而,有一些机器学习算法可以通过轻微的修改来处理回归和分类任务。最值得注意的是,决策树、随机森林和神经网络都可以用于分类和回归任务。

在本章中,我们将探讨以下概念:

  • 最小二乘回归技术,如线性回归、多项式回归、幂律回归等

  • 趋势分析或平滑

  • 季节性分析或模式减法

回归基础

在进行回归分析时,有两个主要和整体的目标。首先,我们希望确定和识别数据中任何潜在的、系统的模式。如果我们能识别出系统的模式,我们可能能够识别出导致这些模式的现象,并更深入地理解整个系统。如果你通过分析发现有一个每 16 小时重复一次的模式,你将处于一个更好的位置来弄清楚是什么现象导致了这个模式,并采取行动。与所有机器学习任务一样,这个 16 小时的模式可能深深隐藏在数据中,并且可能一眼看不出来。

第二个主要目标是利用对基本模式的知识来做出未来的预测。你所做的预测将仅与驱动预测的分析一样好。如果你的数据中有四个不同的系统性模式,而你只识别并建模了其中的三个,那么你的预测可能不准确,因为你没有完全建模涉及的现实世界现象。

实现这两个目标依赖于你识别和正式(即数学上)描述模式和现象的能力。在某些情况下,你可能无法完全识别模式的根本原因;即使如此,如果模式是可靠的,你的分析是好的,即使你不完全理解原因,你仍然能够预测系统的未来行为。这是所有机器学习问题的情况;机器学习最终分析的是行为和结果——我们可以衡量的东西,但深入了解原因只能有所帮助。

在所有机器学习问题中,我们还得应对噪声。在分类问题中,噪声可以有多种形式,例如缺失或错误的数据值,或者不可定义的人类行为。在回归问题中,噪声也可以有多种形式:传感器可能容易受到环境噪声的影响,基本过程可能存在随机波动,或者噪声可能由许多难以预测的小型系统性因素引起。

无论是在进行回归分析还是分类分析,噪声总是使模式更难以识别。在回归分析中,你的目标是能够将数据中的系统性行为(实际模式)与随机噪声源分开。在某些情况下,也很重要将噪声建模为一种行为,因为噪声本身可能对你的预测产生重大影响;在其他情况下,噪声可以被忽略。

为了说明系统性模式和噪声之间的区别,考虑以下数据集。图中没有单位,因为这只是一个关于某些依赖参数Y随某些独立参数X变化的抽象示例:

图片

在这个例子中,我们可以清楚地看到系统性模式和噪声之间的区别。系统性模式是稳定的线性增长——Y 值通常随着 X 值的增加而增加,尽管由于噪声导致的点与点之间的波动,Y 值有所波动。通过在这个数据中建模系统性模式,我们就能对当 X 值为 75、100 或-20 时 Y 值将是什么做出合理的预测。噪声是否重要将取决于具体的应用;你可以忽略噪声,或者你可以对其进行建模并将其包含在分析中。

在第一章,“探索 JavaScript 的潜力”中,我们了解了一种处理噪声的技术:使用移动平均进行平滑。我们不是绘制单个点,而是可以一起取三个点的组合并绘制它们的平均值。如果噪声确实是真正的随机且均匀分布的(也就是说,所有噪声效应的平均值接近于零),则移动平均将倾向于消除一些噪声。如果你平均三个点,并且每个点由于噪声产生的影响分别增加+1、-2 和+1.2,那么移动平均将减少噪声的总影响至+0.2。当我们绘制移动平均时,我们通常会找到一个更平滑的模式:

图片

移动平均减少了噪声的影响,并帮助我们更多地关注系统模式——但我们并没有更接近于能够预测未来的值,例如当X为 75 时。移动平均仅帮助我们减少数据集中数据点的噪声影响。例如,当你查看X = 4时的 Y 值时,测量的值大约是 21,而平滑后的值是 28。在这种情况下,28 的平滑值更好地代表了X = 4处的系统模式,尽管在此点的实际测量值是 21。很可能是由于在这次测量时存在一个显著的随机噪声源,导致测量值与系统模式之间存在很大的差异。

在处理噪声时请谨慎。重要的是要认识到,在前面的例子中,实际测量的 Y 值在X = 4时确实是 21。平滑的移动平均是一种理想化。这是我们试图穿过噪声以看到信号的努力,但我们不能忘记实际测量受到了噪声的显著影响。这个事实是否对你的分析重要,很大程度上取决于你试图解决的问题。

那么,我们如何处理预测这些数据未来值的难题呢?移动平均在插值数据时可能对我们有所帮助,但在外推到未来的 X 值时则不然。当然,你可以猜测当X = 75时该值将会是多少,因为此例简单且易于可视化。然而,由于这是一本关于机器学习的书,我们可以假设现实世界的问题不会如此容易通过肉眼分析,我们需要引入新的工具。

这个问题的解决方案是回归。与所有预测性机器学习问题一样,我们希望创建某种抽象函数,可以将输入值映射到输出值,并使用该函数进行预测。在分类任务中,该映射函数可能是一个贝叶斯预测器或基于随机森林的启发式方法。在回归任务中,映射函数通常是一个描述直线、多项式或其他适合数据的形状的数学函数。

如果你曾经在 Excel 或 Google Sheets 中绘制过数据,那么你很可能已经使用了线性回归。这些程序的趋势线功能执行线性回归,以确定最佳拟合数据的映射函数。以下图表是由线性回归确定的趋势线,线性回归是一种用于找到最佳拟合数据的数学线的算法:

此外,Excel 还给我们提供了另一条信息,称为R²值,它是趋势线如何适合数据的表示。R²值接近 1.0 表示趋势线解释了点之间的大部分方差;R²值低表示模型没有解释方差。

我们之前看到的趋势线和移动平均的主要区别在于趋势线是一个实际的数学模型。当你找到一个线性回归的趋势线时,你将得到一个描述整条线的数学公式。移动平均只存在于数据点存在的地方;我们只能在 X = 0 和 X = 50 之间有一个移动平均。另一方面,趋势线由直线的数学公式描述,并且向左和向右无限延伸。如果你知道趋势线的公式,你可以将任何 X 的值代入该公式,并得到 Y 值的预测。例如,如果你发现一条线的公式是 Y = 2.5 x X + 22,你可以将 X = 75 代入,你将得到预测的 Y = 2.5 x 75 + 22,或者 Y = 209.5。从移动平均中无法得到这样的预测。

线性回归只是回归算法的一种类型,专门用于找到适合数据的直线。在本章中,我们将探讨几种其他类型的回归算法,每种算法都有不同的形状。在所有情况下,你可以使用一个描述回归如何适合数据的度量。通常,这个度量将是均方根误差RMSE),它是每个点与趋势线比较的平方误差平均值的平方根。大多数回归算法都是最小二乘法回归,旨在找到最小化 RMSE 的趋势线。

让我们看看几个回归形状的例子以及如何在 JavaScript 中将它们拟合到数据中。

示例 1 – 线性回归

在我们深入第一个例子之前,让我们花一分钟时间设置我们的项目文件夹和依赖项。创建一个名为 Ch7-Regression 的新文件夹,并在该文件夹内添加以下 package.json 文件:

{
  "name": "Ch7-Regression",
  "version": "1.0.0",
  "description": "ML in JS Example for Chapter 7 - Regression",
  "main": "src/index.js",
  "author": "Burak Kanber",
  "license": "MIT",
  "scripts": {
    "build-web": "browserify src/index.js -o dist/index.js -t [ babelify --presets [ env ] ]",
    "build-cli": "browserify src/index.js --node -o dist/index.js -t [ babelify --presets [ env ] ]",
    "start": "yarn build-cli && node dist/index.js"
  },
  "dependencies": {
    "babel-core": "⁶.26.0",
    "babel-plugin-transform-object-rest-spread": "⁶.26.0",
    "babel-preset-env": "¹.6.1",
    "babelify": "⁸.0.0",
    "browserify": "¹⁵.1.0",
    "dspjs": "¹.0.0",
    "regression": "².0.1"
  }
}

然后,从命令行运行 yarn install 命令来安装所有依赖项。接下来,创建一个名为 src 的文件夹,并添加一个名为 index.js 的空文件。最后,从书籍的 GitHub 仓库下载 data.js 文件到 src 文件夹。

在这个例子中,我们将处理上一节中的噪声线性数据。作为提醒,数据本身看起来是这样的:

我们的目标是找到一个适合数据的直线公式,并在 X = 75 时对未来值进行预测。我们将使用汤姆·亚历山大的 regression 库,它可以执行多种类型的回归,并提供基于结果回归进行预测的能力。

index.js 文件中,将以下导入语句添加到文件顶部:

import * as data from './data';
import regression from 'regression';

与所有机器学习问题一样,你应该首先可视化你的数据,并在选择算法之前尝试理解数据的整体形状。在这种情况下,我们可以看到数据遵循线性趋势,因此我们将选择线性回归算法。

在线性回归中,目标是确定最佳拟合数据的直线公式的参数。直线的公式具有以下形式:y = mx + b,有时也写作 y = ax + b,其中 x 是输入变量或自变量,y 是目标或因变量,m(或 a)是直线的斜率梯度,而 b 是直线的截距(当 X = 0 时的 Y 值)。因此,线性回归输出的最小要求是 ab 的值,这两个参数是决定直线形状的唯一两个参数。

将以下导入行添加到 index.js

console.log("Performing linear regression:");
console.log("=============================");
const linearModel = regression.linear(data.linear);
console.log("Slope and intercept:");
console.log(linearModel.equation);
console.log("Line formula:");
console.log(linearModel.string);
console.log("R² fitness: " + linearModel.r2);
console.log("Predict X = 75: " + linearModel.predict(75)[1]);

对数据进行线性回归将返回一个模型;该模型本质上封装了 ab 的值,即直线的斜率和截距。这个特定的库不仅返回 linearModel.equation 属性中的直线参数,还提供了直线公式的字符串表示,计算回归的 R² 拟合度,并给我们一个名为 predict 的方法,我们可以用它将新的 X 值插入到模型中。

通过在命令行中发出 yarn start 命令来运行代码。你应该会看到以下输出:

 Performing linear regression:
 =============================
 Slope and intercept:
 [ 2.47, 22.6 ]
 Line formula:
 y = 2.47x + 22.6
 R² fitness: 0.96
 Predict X = 75: 207.85

回归确定,最适合我们数据的直线公式是 y = 2.47x + 22.6。我用来创建测试数据的原始公式是 y = 2.5x + 22。确定方程与实际方程之间的小差异是由于我添加到数据集中的随机噪声的影响。正如你所见,线性回归很好地超越了噪声,发现了潜在的规律。如果我们绘制这些结果,我们将看到以下情况:

图片

如前图所示,回归的结果与 Excel 或 Google Sheets 的趋势线功能给出的结果完全相同,区别在于我们是在 JavaScript 中生成的趋势线。

当被要求预测 X = 75 的未来值时,回归返回 Y = 207.85。使用我的原始公式,真实值应该是 209.5。我添加到数据中的噪声量相当于任何给定点的随机和均匀噪声水平 +/- 12.5,因此当考虑到噪声引起的不确定性时,预测值非常接近实际值。

然而,需要注意的是,随着你预测越来越远离原始数据域,回归误差会累积。当预测 X = 75 时,预测值与实际值之间的误差仅为 1.65。另一方面,如果我们预测 X = 1000,则真正的公式会返回 2,522,但回归会预测 2,492.6。在 X = 1000 时,实际值与预测值之间的误差现在为 29.4,接近 30,远超过由于噪声引起的不确定性。回归是非常有用的预测工具,但你必须始终记住,这些误差会累积,因此随着你远离数据集的域,预测将变得不那么准确。

这种预测误差的原因在于方程斜率的回归。原始方程中线的斜率是 2.5。这意味着对于 X 值的每单位变化,我们应该期望 Y 值变化 2.5 个单位。另一方面,回归确定斜率为 2.47。因此,对于 X 值的每单位变化,回归会继承一个微小的误差 -0.03。预测值将比实际值略低,这个量乘以你的预测的 X 距离。对于每 10 个单位的 X,回归会继承总共 -0.3 的误差。对于每 100 个单位的 X,回归会继承 -3.0 的误差,依此类推。当我们外推到 X=1000 时,我们继承了 -30 的误差,因为那个每单位微小的误差 -0.03 乘以我们在 x 轴上移动的距离。

当我们查看数据域内的值——X = 0 和 X = 50 之间的值——由于斜率略有差异,我们只会得到非常小的预测误差。在我们的数据域内,回归通过略微增加 y 截距值(原始值为 +22,回归返回 +22.6)来纠正斜率误差。由于我们数据中的噪声,回归公式 y = 2.47x + 22.6 比实际公式 y = 2.5x + 22 更好地拟合。回归找到一个略微平缓的斜率,并通过将整个线提高 0.6 个单位(y 截距的差异)来弥补这一点,因为这样更适合数据和噪声。这个模型在 X = 0 和 X = 50 之间拟合得非常好,但当我们尝试预测 X = 1000 时的值时,y 截距中轻微的 +0.6 修改已不足以弥补如此巨大距离上的斜率下降。

像这个例子中找到的线性趋势非常常见。有许多类型的数据表现出线性关系,只要你不尝试过度外推数据,就可以简单而准确地建模。在下一个例子中,我们将查看指数回归。

示例 2 – 指数回归

连续数据模式中的另一个常见趋势是 指数增长*,它也通常被视为 指数衰减。 在指数增长中,未来的值与当前值成比例。这种类型增长的一般公式可以写成:

y = y[0] (1 + r) x

y[0] 表示数量的初始值(当 x = 0 时),而 r 是该数量的增长率。

例如,如果你在股市投资并期望每年有 5%的回报率(r = 0.05),初始投资为 10,000 美元,五年后你可以期望有 12,763 美元。指数增长公式适用于这里,因为明年你拥有的金额与今年你拥有的金额成比例,两年后你拥有的金额与明年你拥有的金额成比例,依此类推。这仅适用于你重新投资回报,导致你积极投资的金额每年增加。

指数增长方程的另一种形式如下:

y = ae^(bx)

其中 b = ln(1 + r)a 是初始值 y[0],而 e 是约等于 2.718 的欧拉常数。这种形式上的轻微变换在数学上更容易操作,并且通常是数学家用于分析的首选形式。在我们的股市投资例子中,我们可以将五年增长公式重写为 y = 10000e^(ln(1.05)5),我们将会得到同样的结果 12,763 美元。

指数增长有时被称为 曲棍球棒增长,因为曲线的形状类似于曲棍球的轮廓:

指数增长的例子包括:

  • 人口增长;即世界人口或细菌培养生长

  • 病毒式增长,例如疾病感染的分析或 YouTube 视频的病毒式传播

  • 机械或信号处理中的正反馈回路

  • 经济增长,包括复利

  • 摩尔定律下计算机的处理能力

重要的是要注意,在几乎所有情况下,指数增长都是不可持续的。例如,如果你在预测培养皿中细菌菌落的增长,你可能会观察到一段时间的指数增长,然而一旦培养皿中的食物和空间耗尽,其他因素将占主导地位,增长将不再呈指数形式。同样,如果你的网站通过激励新用户邀请朋友来增加会员,你可能会看到一段时间内会员数的指数增长,但最终市场将饱和,增长会放缓。因此,在分析指数增长模型时必须谨慎,并理解推动指数增长的条件最终可能会改变。与线性回归类似,指数回归只适用于数据的适度外推。你的网站会员可能一年内会呈指数增长,但不可能持续十年;地球上只有 70 亿人口,你不能有 200 亿会员。

如果增长率r或参数k(称为增长常数)为负,那么你将得到指数衰减而不是指数增长。尽管在指数衰减中,未来的值与当前值成比例,但未来的值在比例上比当前值

指数衰减的一个实际应用是在碳-14 年代测定中。因为放射性碳-14 同位素以 5730 年的半衰期衰变为非放射性碳-12——这意味着在总体上,每 5730 年有一半的碳-14 衰变为碳-12——科学家可以使用指数衰减公式来计算出物体必须有多久才能达到碳-14 与碳-12 的适当比例。

指数衰减在物理学和力学中也有所体现,尤其是在弹簧-质量-阻尼问题中。验尸官和法医也可以利用这一原理,根据尸体温度以指数衰减的方式逐渐接近室温来确定死亡时间。

在指数回归中,我们的目标是确定参数ab的值——初始值和增长常数。让我们用 JavaScript 来尝试一下。我们希望分析的数据是指数衰减的,并添加了随机传感器噪声:

图片

前面的图表显示了一些量,它从接近 100 开始衰减到大约 0。例如,这可以代表在 Facebook 上分享的帖子随时间变化的访问者数量。

尝试使用 Excel 或 Google Sheets 来拟合趋势线在这种情况下并不能帮助我们。线性趋势线并不适合指数曲线,不合适的拟合可以通过较差的 R²值来表示:

图片

现在我们使用 JavaScript 来找到这个数据的回归,并预测数据集开始前一秒的值。将以下代码添加到index.js中;这是线性回归代码:

console.log("Performing exponential regression:");
console.log("=============================");
const expModel = regression.exponential(data.exponential);
console.log("Initial value and rate:");
console.log(expModel.equation);
console.log("Exponential formula:");
console.log(expModel.string);
console.log("R² fitness: " + expModel.r2);
console.log("Predict X = -1: " + expModel.predict(-1)[1]);

使用yarn start运行程序,你应该看到以下输出,紧随线性回归示例的输出之后:

 Performing exponential regression:
 =============================
 Initial value and rate:
 [ 94.45, -0.09 ]
 Exponential formula:
 y = 94.45e^(-0.09x)
 R² fitness: 0.99
 Predict X = -1: 103.34

我们可以立即看到高 R²值 0.99,这表明回归已经很好地拟合了数据。如果我们将这个回归与原始数据一起绘制,我们会看到以下结果:

图片

此外,我们还得到了 X = -1 时的预测值为 103,这与我们的数据拟合得很好。我用来生成测试数据的方程的原始参数是a = 100b = -0.1,而预测的参数是a = 94.5b = -0.09。噪声的存在对起始值产生了重大影响,如果没有噪声,起始值应该是 100,但实际上测量值为 96。当比较回归值a与实际值a时,你还必须考虑回归值a接近测量值(即使它离系统值相当远)这一事实。

在下一节中,我们将探讨多项式回归。

示例 3 – 多项式回归

多项式回归可以被认为是线性回归的更一般形式。多项式关系的形式为:

y = a[0] + a[1]x¹ + a[2]x² + a[3]x³ + ... + a[n]x^n

多项式可以有任意多个项,这被称为多项式的次数。对于多项式的每个次数,自变量x乘以某个参数a[n],X 值被提升到n次幂。一条直线被认为是一次多项式;如果你更新前面的多项式公式以删除高于一次的所有次数,你将剩下:

y = a[0] + a[1]x

其中**a[0]**是 y 轴截距,a[1]是线的斜率。尽管符号略有不同,但这与y = mx + b是等价的。

二次方程,你可能还记得从高中数学中学过的,它仅仅是二次的多项式,或者y = a[0] + a[1]x + a[2]x²。三次方程是三次的多项式,四次方程是四次的多项式,以此类推。

多项式和多项式回归的属性使它们如此强大,是因为在有限的值范围内,几乎任何形状都可以用足够次数的多项式来描述。只要你不尝试过度外推,多项式回归甚至可以拟合正弦形状。多项式回归在某种程度上表现出与其他机器学习算法相似的性质,即如果你尝试过度外推,它们可能会过拟合并对于新的数据点变得非常不准确。

因为多项式可以是任何次数,所以你也必须配置回归的附加参数;这个参数可以猜测,或者你可以寻找最大化 R²拟合度的次数。这种方法与我们用于 k-means(当你事先不知道聚类数量时)的方法类似。

我们希望拟合的数据,当绘制时,看起来像这样:

这个小数据窗口看起来是正弦形的,但实际上是多项式的;记住,多项式方程可以复制许多类型的形状。

将以下代码添加到index.js的底部:

console.log("Performing polynomial regression:");
console.log("=============================");
const polyModel = regression.polynomial(data.polynomial, {order: 2});
console.log("Polynomial parameters");
console.log(polyModel.equation);
console.log("Polynomial formula:");
console.log(polyModel.string);
console.log("R² fitness: " + polyModel.r2);
console.log("Predict X = 6: " + polyModel.predict(6)[1]);

注意,我们已经将回归配置为{order: 2},这意味着我们正在尝试用二次公式拟合数据。使用yarn start运行程序,可以看到以下输出:

 Performing polynomial regression:
 =============================
 Polynomial parameters
 [ 0.28, -17.83, -6.6 ]
 Polynomial formula:
 y = 0.28x² + -17.83x + -6.6
 R² fitness: 0.75
 Predict X = 6: -103.5

这组数据的 R²拟合度相当低,为0.75,这表明我们可能使用了错误的order参数值。尝试将顺序增加到{order: 4}并重新运行程序,可以得到以下结果:

 Performing polynomial regression:
 =============================
 Polynomial parameters
 [ 0.13, 1.45, -2.59, -40.45, 0.86 ]
 Polynomial formula:
 y = 0.13x⁴ + 1.45x³ + -2.59x² + -40.45x + 0.86
 R² fitness: 0.99
 Predict X = 6: 146.6

现在回归拟合得更好了,但代价是方程中添加了额外的多项式项。如果我们用原始数据来绘制这个回归,我们会看到以下输出,这确实很好地拟合了数据:

在下一节中,我们将探讨可以在时间序列数据上执行的其他类型分析,包括低通滤波器、高通滤波器和季节性分析。

其他时间序列分析技术

回归分析是分析连续数据的良好起点,然而,在分析特定的时间序列数据时,还有许多其他技术可以采用。虽然回归可以用于任何连续数据的映射,但时间序列分析专门针对随时间演变的连续数据。

时间序列数据有很多例子,例如:

  • 服务器负载随时间变化

  • 股票价格随时间变化

  • 用户活动随时间变化

  • 气候模式随时间变化

分析时间序列数据时的目标与使用回归分析连续数据时的目标相似。我们希望识别和描述影响随时间变化的值的各种因素。本节将描述一些超越回归的技术,您可以使用这些技术来分析时间序列数据。

在本节中,我们将探讨来自数字信号处理领域的技巧,该领域在电子学、传感器分析和音频信号处理中都有应用。虽然你的具体时间序列问题可能与这些领域无关,但数字信号处理应用中使用的工具可以应用于任何处理数字信号的领域。其中最显著的工具和技术包括滤波、季节性检测和频谱分析。我们将讨论这些技术,但我将把实现自己的示例和实验留给你。

滤波

在数字信号处理的背景下,滤波是一种用于过滤掉信号的高频或低频成分的技术。这些分别被称为低通滤波器高通滤波器;低通滤波器允许低频信号通过,同时从信号中移除高频成分。还有带通滤波器陷波滤波器,它们允许一定范围内的频率通过或从信号中截断一定范围的频率。

在电子学中,通过使用电容器、电阻和其他简单的电子元件来设计滤波器,以便只允许高于或低于一个截止频率的频率通过电路。在数字信号处理中,可以通过一个无限脉冲响应滤波器实现相同的效果,这是一种可以再现电子电路对时间序列数据影响的算法。

为了说明这一点,考虑以下数据:

图片

这组数据是通过组合两个正弦信号生成的,一个是低频信号,另一个是高频信号。如果我们单独绘制这两个信号,我们可以看到它们是如何组合成整体信号的:

图片

在过滤整体信号时,目标是提取信号的低频或高频成分,同时过滤掉另一个成分。这被称为减法处理,因为我们是从信号中移除(过滤)一个成分。

通常情况下,你应该使用低通滤波来隔离时间序列数据中的大范围、一般性的周期性趋势,同时忽略较快的周期性趋势。另一方面,当你希望探索短期周期性趋势而忽略长期趋势时,应该使用高通滤波。这种方法的一个例子是在分析访客流量时;你可以使用高通和低通滤波来选择性地忽略月度趋势与日度趋势。

季节性分析

在上一节的基础上,我们还可以使用数字信号处理来分析季节性趋势。季节性趋势是长期周期性(即低频)趋势,你希望从整体数据中减去,以便分析数据中的其他可能非周期性趋势。考虑以下图表:

图片

这份数据显示了活动周期性波动之上的线性增长组合。具体来说,这个数据趋势有两个周期性成分(一个低频和一个高频)和一个线性成分。

为了分析这些数据,首先的方法是识别线性趋势,无论是通过一个大的移动平均窗口还是通过线性回归。一旦确定了线性趋势,就可以从数据中减去它,以仅隔离周期性部分。以下是如何展示这个方法的:

图片

因为信号是可加的,所以你可以从原始数据中减去线性趋势,以隔离信号的非线性成分。如果你已经通过回归或其他方式识别了多个趋势,你可以继续从原始信号中减去你已识别的趋势,最终只剩下未识别的信号成分。一旦你识别并减去了所有的系统模式,你将只剩下传感器噪声。

在这种情况下,一旦你从数据中识别并减去线性趋势,你可以对结果信号进行滤波,以隔离低频和高频成分,或者可以对剩余信号进行傅里叶分析,以识别剩余成分的具体频率和幅度。

傅里叶分析

傅里叶分析是一种数学技术,用于将时间序列信号分解为其各自的频率分量。回想一下,任意阶数的多项式回归可以复制几乎任何信号形状。以类似的方式,多个正弦振荡器的总和可以复制几乎任何周期性信号。如果你曾经看到过示波器频谱分析仪在工作,你就看到了傅里叶变换应用于信号的实时结果。简而言之,傅里叶变换将周期性信号,如我们在上一节中看到的,转换成类似以下的公式:

a[1]sin(f[1]+φ[1]) + a[2]sin(f[2]+φ[2]) + a[3]sin(f[3]+φ[3]) + ... + a[n]sin(f[n]+φ[n])

其中 f[n] 代表频率,a[n] 代表其幅度,φ[n] 代表相位偏移。通过组合任意数量的这些正弦信号,可以复制几乎任何周期性信号。

进行傅里叶分析有许多原因。最直观的例子与音频和声音处理相关。如果你取一个一秒钟长的音频样本,记录钢琴上演奏的 A4 音符,并对它进行傅里叶变换,你会看到 440 Hz 的频率具有最大的振幅。你还会看到 440 Hz 的谐波,如 880 Hz 和 1,320 Hz,也具有一定的能量。你可以使用这些数据来辅助音频指纹识别、自动调音、可视化以及许多其他应用。傅里叶变换是一种采样算法,因此它容易受到混叠和其他采样误差的影响。傅里叶变换可以用来部分重建原始信号,但在转换过程中会丢失很多细节。这个过程与对图像进行下采样然后再尝试上采样是相似的。

在几乎每个领域,傅里叶变换都有许多其他应用。傅里叶变换之所以受欢迎,是因为在数学上,许多类型的操作在频域中比在时域中更容易执行。在数学、物理和工程中,有许多问题在时域中非常难以解决,但在频域中却容易解决。

傅里叶变换是一种由特定算法执行的计算过程。最流行的傅里叶变换算法称为快速傅里叶变换FFT),之所以命名为 FFT,是因为它比其前身离散傅里叶变换快得多。FFT 有一个显著的限制,即要分析样本的数量必须是 2 的幂,也就是说,它必须是 128、256、512、1,024、2,048 等等样本长。如果你有 1,400 个样本要分析,你必须将其截断到 1,024 个样本或填充到 2,048 个样本。通常,你会对较大的样本进行加窗;在钢琴音符录音的例子中,我们从实时或录制的信号中提取了一秒钟的样本。如果音频采样率为 44,100 Hz,那么我们就有了 44,100 个样本(一秒钟的样本)要提供给傅里叶变换。

当对来自较大信号的样本进行填充、截断或加窗时,你应该使用一个窗函数,这是一个在两端逐渐减小信号的函数,以便它不会被你的窗口锐利地截断。有许多类型的窗函数,每种都有其自己的数学特性和对信号处理的独特影响。一些流行的窗函数包括矩形窗和三角形窗,以及高斯窗、兰佐斯窗、汉宁窗、汉明窗和布莱克曼窗,它们在不同的分析类型中都具有可取的特性。

类似于 FFT 算法的傅里叶变换算法的输出是一个频域频谱。更具体地说,FFT 算法的输出将是一个数组或哈希表,其中键是频率桶(例如 0-10 Hz,10-20 Hz 等),值是幅度和相位。这些可能表示为复数、多维数组或算法实现中特定的其他结构。

所有采样算法都存在一些限制;这些限制是信号处理本身的限制。例如,如果您的信号包含高于奈奎斯特频率的成分,或者采样率的一半,就会发生混叠。在音频中,常见的采样率为 44,100 Hz,任何高于 22,050 Hz 的频率都会发生混叠,或者被错误地表示为低频信号。因此,使用低通滤波器预处理信号是一种常见的技术。同样,FFT 算法只能解析到奈奎斯特频率。FFT 算法将只返回与样本缓冲区大小一样多的频率桶,所以如果您提供 1,024 个样本,您将只得到 1,024 个频率桶。在音频中,这意味着每个频率桶的带宽为 44,100 Hz / 1,024 = 43 Hz。这意味着您可能无法区分 50 Hz 和 55 Hz,但您很容易就能区分 50 Hz 和 500 Hz。为了获得更高的分辨率,您需要提供更多的样本,然而,这反过来又会降低您窗口的时间分辨率。

您可以使用 FFT 来分析我们在上一节中看到的时间序列数据的周期部分。最好在从信号中减去线性趋势后执行 FFT。然而,如果您有足够高的频率分辨率,线性趋势可能只被解释为傅里叶变换的低频成分,因此是否需要减去线性趋势将取决于您的具体应用。

通过将 FFT 添加到本章中您所学的其他工具,您已经准备好应对大多数现实世界的回归或时间序列分析任务。每个问题都是独特的,您将必须仔细考虑您任务中需要哪些特定的工具。

摘要

在本章中,您学习了在预测、信号处理、回归和时间序列数据分析中使用的许多技术。由于预测和时间序列分析是一个广泛的类别,没有单一的算法可以涵盖所有情况。相反,本章为您提供了一个初始的工具箱,其中包含了一些重要的概念和算法,您可以从这些算法开始应用到您的预测和回归任务中。

具体来说,你学习了回归和分类之间的区别。分类将标签分配给数据点,而回归则试图预测数据点的数值。并非所有的回归都是必要的预测,但回归是预测中使用的最显著的单一技术。

在学习回归的基本知识之后,我们探索了几种特定的回归类型。具体来说,我们讨论了线性、多项式和对数回归。我们看到了回归如何处理噪声,以及我们如何利用它来预测未来的值。

然后,我们转向更广泛的时间序列分析概念,并讨论了核心概念,例如从信号中提取趋势。我们讨论了在数字信号处理中适用的工具,这些工具适用于时间序列分析,例如低通和高通滤波器、季节性分析和傅里叶变换。

在下一章中,我们将探讨更高级的机器学习模型。具体来说,我们将学习神经网络——顺便提一下,神经网络也可以执行回归。

第八章:人工神经网络算法

人工神经网络(ANNs)或简称 NNs,可能是今天最流行的机器学习(ML)工具,如果不是最广泛使用的。当时的科技媒体和评论喜欢关注神经网络,许多人认为它们是神奇的算法。人们相信神经网络将为通用人工智能(AGI)铺平道路——但技术现实却大不相同。

虽然神经网络功能强大,但它们是高度专业化的机器学习模型,专注于解决单个任务或问题——它们不是可以现成解决问题的神奇大脑。一个表现出 90%准确率的模型通常被认为是好的。神经网络训练缓慢,需要精心设计和实现。尽管如此,它们确实是高度熟练的问题解决者,可以解开甚至非常困难的问题,例如图像中的物体识别。

神经网络可能在实现通用人工智能(AGI)中扮演重要角色。然而,许多其他机器学习(ML)和自然语言处理(NLP)领域也将需要参与其中。因为人工神经网络(ANNs)仅仅是专门的问题解决者,普遍认为通往 AGI 的道路是通过成千上万的人工神经网络的大集合,每个网络针对一个特定的任务进行优化。我个人认为,我们可能会很快看到类似 AGI 的东西。然而,AGI 最初只能通过巨大的资源来实现——不是指计算能力,而是指训练数据。

在本章中,你将学习神经网络的基础知识。神经网络有许多使用方式,以及许多可能的拓扑结构——我们将在本章和第九章深度神经网络中讨论其中的一些。每种神经网络拓扑都有其自身的目的、优势和劣势。

首先,我们将从概念上讨论神经网络。我们将检查它们的组件和构建,并探讨它们的应用和优势。我们将讨论反向传播算法以及如何训练人工神经网络。然后,我们将简要地看一下人工神经网络的数学,接着深入探讨野外神经网络的实用建议。最后,我们将使用TensorFlow.js库演示一个简单神经网络的例子。

本章我们将涵盖以下主题:

  • 神经网络的概念概述

  • 反向传播训练

  • 示例——TensorFlow.js中的 XOR

神经网络的概念概述

人工神经网络(ANNs)几乎与计算机一样历史悠久,最初确实是由电子硬件构建的。第一个 ANN 是在 20 世纪 70 年代开发的,用于自适应地过滤电话线路传输中的回声。尽管它们最初取得了早期成功,但 ANNs 在 1985 年中期之前一直不太受欢迎,那时反向传播训练算法被普及。

ANNs 是基于我们对生物大脑的理解构建的。ANN 包含许多相互连接的神经元。这些神经元连接的方式、结构和组织被称为网络的拓扑结构(或形状)。每个单独的神经元是一个简单的结构:它接受几个数值输入值,并输出一个数值,这个数值可能随后被传输到几个其他神经元。以下是一个简单的、概念性的神经元示例:

神经网络图

神经元通常但并非总是排列成层。神经元的具体排列和连接由网络的拓扑结构定义。然而,大多数人工神经网络(ANNs)将具有三到四个全连接层,或者每一层的每个神经元都连接到下一层的每个神经元的层。在这些常见的拓扑结构中,第一层是输入层,最后一层是输出层。输入数据直接馈送到输入神经元,算法的结果从输出神经元读取。在输入层和输出层之间,通常有一到两个用户或程序员不直接交互的隐藏层。以下图显示了具有三个层的神经网络:

神经网络图

输入层有四个神经元,单个隐藏层有六个神经元,输出层有两个神经元。描述这种网络的简写方法是列出每层的神经元数量,因此可以简称为4-6-2 网络。这样的网络能够接受四个不同的特征,并输出两份数据,例如 X/Y 坐标,两个属性的布尔值,或者如果输出被视为二进制位,甚至可以是 0-3 的数字。

当使用 ANN 进行预测时,你实际上是在使用前馈模式下的网络,这实际上相当简单。我们将深入讨论神经元的机制,但就目前而言,你需要知道的是,一个神经元接受多个输入,并根据简单的加权总和和光滑函数(称为激活函数)生成单个输出。

为了做出预测,你直接将输入数据加载到输入神经元中。如果你的问题是图像识别问题,那么每个输入神经元可能被提供单个像素的灰度强度(处理一个 50 x 50 像素的灰度图像可能需要 2,500 个输入神经元)。输入神经元被激活,意味着它们的输入被求和、加权、加偏差,然后将结果输入到激活函数中,该函数将返回一个数值(通常在-1 和+1 之间,或 0 和+1 之间)。输入神经元随后将它们的激活输出发送到隐藏层的神经元,这些神经元经历相同的过程,并将结果发送到输出层,输出层再次被激活。算法的结果是输出层激活函数的值。如果你的图像识别问题是具有 15 个可能类别的分类问题,那么输出层将有 15 个神经元,每个神经元代表一个类别标签。输出神经元将返回 1 或 0 的值(或介于两者之间的分数),具有最高值的输出神经元是图像最可能代表的类别。

为了理解像这样的网络实际上是如何产生结果的,我们需要更仔细地研究神经元。在人工神经网络中,神经元有几个不同的特性。首先,一个神经元保持一组(一个向量)的权重。每个输入到神经元的值都乘以其相应的权重。如果你看前面图像中隐藏层最顶部的神经元,你可以看到它从输入层的神经元接收四个输入。因此,隐藏层中的每个神经元必须有一个包含四个权重的向量,每个权重对应于前一层发送信号的神经元。权重基本上决定了特定输入信号对相关神经元的重要性。例如,最顶部的隐藏层神经元可能对最底部的输入神经元有一个权重为 0;在这种情况下,两个神经元基本上是未连接的。另一方面,下一个隐藏神经元可能对最底部的输入神经元有一个非常高的权重,这意味着它非常重视其输入。

每个神经元还有一个偏差。偏差不适用于任何一个单独的输入,而是在激活函数被调用之前添加到加权输入的总和中。偏差可以看作是神经元激活阈值的修饰器。我们很快就会讨论激活函数,但让我们先看看神经元的更新图示:

图片

描述神经元的数学形式大致如下,其中粗体数字 wx 代表输入和权重的向量(即[x[1], x[2], x[3]]),非粗体 by 分别代表神经元的偏差和输出,而 fn(...) 代表激活函数。下面是具体内容:

y = fn( w·x + b)

wx 之间的点表示两个向量的向量点积。另一种写w·x的方式是 *w[1]*x[1] + w[2]*x[2] + w[3]x[3] + … + w[n]x[n],或者简单地表示为 Σ[j] w[j]x[j]

总的来说,网络中神经元的权重和偏置实际上负责学习和计算。当你训练一个神经网络时,你是在逐渐更新权重和偏置,目的是将它们配置来解决你的问题。具有相同拓扑结构(例如,两个全连接的 10-15-5 网络)但不同权重和偏置的两个神经网络是不同的网络,将解决不同的问题。

激活函数在这个过程中的作用是什么?人工神经元的原始模型被称为感知器,其激活函数是阶跃函数。基本上,如果一个神经元的w·x + b大于零,那么神经元将输出 1。另一方面,如果w·x + b小于零,那么神经元将输出零。

这个早期的感知器模型之所以强大,是因为可以用人工神经元来表示逻辑门。如果你曾经上过布尔逻辑或电路的课程,你就会知道你可以使用 NAND 门来构建任何其他类型的逻辑门,并且用感知器构建 NAND 门是极其容易的。

想象一个接受两个输入的感知器,每个输入的权重为-2。感知器的偏置为+3。如果两个输入都是 0,那么w·x + b = +3(仅仅是权重,因为所有输入都是零)。由于感知器的激活函数是阶跃函数,在这种情况下神经元的输出将是 1(+3 大于零,因此阶跃函数返回+1)。

如果输入是 1 和 0,无论顺序如何,那么w·x + b = +1,因此感知器的输出也将是 1。然而,如果两个输入都是 1,那么w·x + b = -1。两个输入,权重都是-2,将克服神经元的偏置+3,激活函数(返回 1 或 0)将返回 0。这就是 NAND 门的逻辑:如果两个输入都是 1,感知器将返回 0,对于任何其他输入组合,它将返回 1。

这些早期结果在 20 世纪 70 年代激发了计算机科学和电子学界的兴趣,人工神经网络(ANNs)受到了大量的炒作。然而,我们很难自动训练神经网络。感知器可以通过手工制作来表示逻辑门,并且对神经网络进行一定程度的自动训练是可能的,但对于大规模问题仍然难以接近。

问题在于用作感知器激活函数的阶跃函数。在训练人工神经网络时,你希望网络权重或偏置的微小变化只会导致网络输出的微小变化。但阶跃函数阻碍了这个过程;权重的一个微小变化可能不会改变输出,但下一个微小变化可能导致输出发生巨大变化!这是因为阶跃函数不是一个平滑函数——一旦越过阈值,它就会从 0 突然跳到 1,而在所有其他点上它恰好是 0 或恰好是 1。这种感知器的限制,以及因此人工神经网络的重大限制,导致了十多年的研究停滞。

最终,1986 年,研究人员重新发现了几年前就已经发现的一种训练技术。他们发现,这种称为反向传播的技术使训练变得更快、更可靠。因此,人工神经网络经历了第二次发展。

反向传播训练

有一个关键的洞察力将神经网络研究从停滞中带入了现代时代:为神经元选择更好的激活函数。阶跃函数导致网络自动训练出现问题,因为网络参数(权重和偏置)的微小变化可能会交替产生没有效果或突然的重大效果。显然,这不是一个可训练系统的期望属性。

自动训练人工神经网络的一般方法是从输出层开始,逆向工作。对于训练集中每个示例,你以前馈模式(即预测模式)运行网络,并将实际输出与期望输出进行比较。用于比较期望结果与实际结果的好指标是均方误差MSE);测试所有训练示例,并对每个示例计算输出与期望值之间的差异并平方。将所有平方误差相加,并除以训练示例的数量,你就得到了一个成本函数或损失函数。成本函数是给定网络拓扑的权重和偏置的函数。训练人工神经网络的目标是将成本函数降低到——理想情况下——零。你可以使用人工神经网络在所有训练示例上的准确率作为成本函数,但均方误差在训练中具有更好的数学特性。

反向传播算法的关键在于以下洞察:如果你知道所有神经元的权重和偏置,如果你知道输入和期望的输出,以及如果你知道神经元使用的激活函数,你可以从输出神经元开始反向工作,以发现哪些权重或偏置对大的误差有贡献。也就是说,如果神经元 Z 有来自神经元 A、B 和 C 的输入,它们的权重分别为 100、10 和 0,你就会知道神经元 C 对神经元 Z 没有影响,因此神经元 C 没有对神经元 Z 的误差做出贡献。另一方面,神经元 A 对神经元 Z 有巨大的影响,所以如果神经元 Z 有一个大的误差,那么很可能是神经元 A 的责任。反向传播算法之所以得名,是因为它通过网络将输出神经元的误差反向传播。

将这个概念进一步扩展,如果你还知道激活函数及其与权重、偏置和误差之间的关系,你可以确定权重需要改变多少才能使神经元的输出产生相应的变化。当然,在人工神经网络中有很多权重,它是一个高度复杂的系统,所以我们使用的方法是对权重进行微小的调整——我们只能使用对权重微小变化的简化近似来预测网络输出的变化。这个方法的一部分被称为梯度下降,之所以得名,是因为我们试图通过调整权重和偏置来降低成本函数的梯度(斜率)。

为了形象地理解这一点,想象一个挂在两棵树之间的尼龙吊床。吊床代表成本函数,而xy轴(从天空看)抽象地代表网络的偏置和权重(实际上,这是一个多千维度的图像)。存在一些权重和偏置的组合,使得吊床挂得最低:那个点就是我们的目标。我们是一只坐在吊床表面某处的微小蚂蚁。我们不知道吊床最低点在哪里,而且我们太小了,即使布料上的褶皱或折痕也能让我们偏离方向。但我们知道吊床是光滑且连续的,我们可以在我们周围摸索。只要我们在每一步都朝下山方向前进,我们最终会在吊床中找到最低点——或者至少,一个接近我们起始点(局部最小值)的低点,这取决于吊床形状的复杂程度。

这种梯度下降的方法要求我们数学上理解和能够描述成本函数的梯度,这意味着我们也必须理解激活函数的梯度。函数的梯度本质上是其斜率或导数。我们不能使用感知器原始的步函数作为激活函数的原因是步函数在所有点上都是不可微分的;步函数在 0 和 1 之间巨大的、瞬间的跳跃是一个不可微分的间断。

一旦我们弄清楚我们应该使用梯度下降和反向传播来训练我们的神经网络,其他事情就变得容易了。我们不再使用步函数作为神经元激活函数,而是开始使用 Sigmoid 函数。Sigmoid 函数通常呈阶梯函数形状,但它们被平滑了,是连续的,并且在所有点上都是可微分的。以下是一个 Sigmoid 函数与步函数的例子:

图片

有许多类型的 Sigmoid 函数;前一个函数由方程 y = 1 / (1+e^(-x)) 描述,被称为逻辑函数逻辑曲线。其他流行的 Sigmoid 函数是双曲正切(即 tanh),其范围从 -1 到 +1,与逻辑函数的范围 0 到 +1 相比。另一个流行的激活函数是修正线性单元ReLU),它常用于图像处理和输出层。还有 softplus 函数,其导数实际上是逻辑函数本身。你选择的激活函数将取决于你想要的特定数学属性。在不同的网络层中使用不同的激活函数也很常见;隐藏层通常会使用逻辑或 tanh 激活函数,而输出层可能会使用 softmax,输入层可能会使用 ReLU。然而,你可以为你的神经元发明自己的激活函数,但你必须能够微分该函数并确定其梯度,以便将其与反向传播算法集成。

对神经元激活函数的这种微小改变对我们训练人工神经网络产生了巨大的影响。一旦我们开始使用可微分的激活函数,我们就能计算成本和激活函数的梯度,并利用这些信息来确定在反向传播算法中如何精确地更新权重。神经网络训练变得更快、更强大,神经网络被推进到现代时代,尽管它们仍然需要等待硬件和软件库的跟进。更重要的是,神经网络训练成为了一项数学研究——尤其是矢量微积分——而不是仅限于计算机科学家的研究。

示例 - TensorFlow.js 中的 XOR 操作

在这个例子中,我们将使用TensorFlow.js前馈神经网络来解决 XOR 问题。首先,让我们探索 XOR 问题,以及为什么它对我们来说是一个好的起点。

XOR,或称为排他或操作,是一个布尔运算符,当且仅当其输入中只有一个为真时返回真。与您更熟悉的常规布尔 OR 运算符相比,后者在两个输入都为真时返回真——XOR 在两个输入都为真时返回假。以下是一个比较 XOR 和 OR 的表格;我已经突出显示了 OR 和 XOR 不同的情况:

输入 1输入 2ORXOR
FalseFalseFalseFalse
FalseTrueTrueTrue
TrueFalseTrueTrue
TrueTrueTrueFalse

为什么 XOR 问题对我们来说是一个好的测试?让我们在图上绘制 XOR 操作:

观察前面的图表,我们可以看到在 XOR 操作中涉及的两个类别在图上不是线性可分的。换句话说,不可能画出一条直线来将前面的图中的圆圈与 X 分开。

XOR 操作非常简单,但类别不是线性可分的事实,使得 XOR 操作在测试新的分类算法时是一个极好的起点。您不需要一个复杂的数据集来测试新的库或算法是否适合您。

在跳入 TensorFlow 示例之前,让我们首先讨论我们如何手动构建一个解决 XOR 的神经网络。我们将设计自己的权重和偏差,看看我们是否能开发出一个手动神经网络来解决 XOR。

首先,我们知道网络需要两个输入和一个输出。我们知道输入和输出是二进制的,因此我们必须选择范围在[0, 1]的激活函数;ReLU 或 sigmoid 是合适的,而 tanh,其范围是[-1, 1],则不太合适。

最后,我们知道 XOR 不是线性可分的,因此不能轻易解决;我们需要在网络上添加一个隐藏层。因此,让我们尝试构建一个 2-2-1 神经网络:

接下来,我们需要考虑网络中神经元的权重和偏差。我们知道网络需要设计成对两个输入都为真时有一个惩罚。因此,一个隐藏层神经元应该表示一个弱正信号(即,当输入被激活时它会被激活),而另一个隐藏层神经元应该表示一个强负信号(即,如果两个输入都为真,这个神经元应该压倒弱正神经元)。

这是一个可以用来实现 XOR 的一组权重的示例:

让我们进行几个示例计算。我将从两个输入都为真的不同情况开始。隐藏的 h1 神经元将有一个总加权输入为 4,因为每个输入的权重是 2,且两个输入都为真。h1 神经元还有一个 -1 的偏差,然而,这个偏差不足以使神经元失活。因此,h1 神经元的带偏差输入总和是 3;由于我们还没有决定一个特定的激活函数,我们不会尝试猜测实际的激活会变成什么——只需说一个 +3 的输入足以激活神经元。

我们现在将注意力转向隐藏的 h2 神经元。它也接收来自两个输入神经元的输入,然而,这些权重是负的,因此它接收到的无偏差输入总和是 -4。h2 的偏差是 +3,所以 h2 的总带偏差输入是 -1。如果我们选择 ReLU 激活函数,神经元的输出将是零。无论如何,h2 没有被激活。

最后,我们来看看输出节点。它从 h1 接收一个 +2 的加权输入,但从 h2 接收不到输入。由于输出节点的偏差是 -3(本质上要求 h1 和 h2 都被激活),输出节点将返回 0 或假。这是当两个输入都设置为真或 1 时 XOR 的预期结果。

让我们类似地列出其他 XOR 情况的结果。h1h2Out 列代表神经元在应用激活函数之前的加权带偏差输入(因为我们还没有选择一个)。只需记住,每个神经元将向下一个神经元传输 [0, 1] 范围内的值;激活函数应用后,它不会发送像 -1 或 3 这样的值:

In 1In 2h1h2Out
00-13-1
01111
10111
113-1-1

前面的表格证明了手工制作的 ANN 对所有 XOR 测试案例都有效。它也让我们对网络的内部工作原理有了一点了解。隐藏的 h1 和 h2 神经元有特定的作用。h1 神经元默认是关闭的,但很容易满足,如果任何输入是活跃的,它就会变得活跃;h1 实质上是一个典型的 OR 操作。另一方面,h2 默认是开启的,只有当两个输入都开启时才能被关闭;h2 实质上是一个 NAND 操作。输出神经元需要 h1 和 h2 都活跃,因此输出神经元是一个 AND 操作。

让我们现在使用 TensorFlow.js 库来看看我们是否能取得同样的成功。在你的电脑上,创建一个名为 Ch8-ANN 的新文件夹。添加以下 package.json 文件,然后执行 yarn install

{
  "name": "Ch8-ANN",
  "version": "1.0.0",
  "description": "ML in JS Example for Chapter 8 - ANN",
  "main": "src/index.js",
  "author": "Burak Kanber",
  "license": "MIT",
  "scripts": {
    "build-web": "browserify src/index.js -o dist/index.js -t [ babelify --presets [ env ] ]",
    "build-cli": "browserify src/index.js --node -o dist/index.js -t [ babelify --presets [ env ] ]",
    "start": "yarn build-cli && node dist/index.js"
  },
  "dependencies": {
    "@tensorflow/tfjs": "⁰.9.1",
    "babel-core": "⁶.26.0",
    "babel-plugin-transform-object-rest-spread": "⁶.26.0",
    "babel-preset-env": "¹.6.1",
    "babelify": "⁸.0.0",
    "browserify": "¹⁵.1.0"
  }
}

现在添加 src/index.js 文件并导入 TensorFlow:

import * as tf from '@tensorflow/tfjs';

TensorFlow 不仅仅是一个 ANN 库。TensorFlow 库提供了一系列在 ANN 和通用 ML(机器学习)以及线性代数(即向量/矩阵数学)问题中都很有用的构建块。因为 TensorFlow 更像是一个工具箱而不是一个单一的工具,所以解决任何给定问题的方式总是多种多样的。

让我们从创建一个顺序模型开始:

const model = tf.sequential();

TensorFlow 的模型是高级容器,本质上运行函数;它们是从输入到输出的映射。你可以使用 TensorFlow 的低级算子(库中附带线性代数工具)来构建你的模型,或者你可以使用一个高级模型类。在这种情况下,我们正在构建一个顺序模型,它是 TensorFlow 通用模型的一个特例。你可以将顺序模型视为一个仅向前传播,不涉及任何内部递归或反馈循环的神经网络。顺序模型本质上是一个传统的神经网络。

接下来,让我们向模型中添加层:

model.add(tf.layers.dense({units: 4, activation: 'relu', inputDim: 2}));
model.add(tf.layers.dense({units: 4, activation: 'relu'}));
model.add(tf.layers.dense({units: 1, activation: 'sigmoid'}));

我们正在将三层添加到我们的模型中。所有层都是密集层,这意味着它们与下一层完全连接。这正是从传统的神经网络中期望得到的结果。我们为每一层指定了单元——单元是 TensorFlow 对神经元的称呼,因为 TensorFlow 可以在 ANN(人工神经网络)以外的环境中使用。我选择在每个层中使用四个神经元而不是两个,因为我发现额外的神经元大大提高了训练过程的速度和鲁棒性。我们在第一层中指定了inputDim,告诉该层它应该期望每个数据点有两个输入。第一和第二层使用 ReLU 激活函数。第三层,也就是输出层,只有一个单元/神经元,并使用熟悉的 sigmoid 激活函数,因为我希望结果能够更快地趋近于 0 或 1。

现在我们必须编译模型,然后才能使用它。我们将指定一个损失函数,这可以是库中预构建的损失函数,也可以是我们提供的自定义损失函数。我们还将指定我们的优化器;我们在本章前面讨论了梯度下降,但还有许多其他优化器可供选择,例如 Adam、Adagrad 和 Adadelta。在这种情况下,我们将使用随机梯度下降优化器(对于传统的神经网络来说是典型的),然而,我们将选择binaryCrossentropy损失函数,这对于我们的二分类任务来说比均方误差更合适:

const learningRate = 1;
const optimizer = tf.train.sgd(learningRate);
model.compile({loss: 'binaryCrossentropy', optimizer, metrics: ['accuracy']});

我们还设置了梯度下降优化器的学习率;学习率决定了反向传播训练算法在每次训练生成或 epoch 中修改权重和偏置的程度。较低的学习率会导致网络训练时间更长,但会更稳定。较高的学习率会使网络训练得更快,但可靠性较低;如果学习率过高,你的网络可能根本无法收敛。

最后,我们在编译步骤中添加了metrics: ['accuracy']。这允许我们在最终调用model.evaluate时获取关于网络准确性的报告。

接下来,我们将设置我们的训练数据,这仅仅是四个数据点。TensorFlow 在张量上操作,张量本质上是一种数学矩阵。TensorFlow 的张量是不可变的,对张量执行的所有操作都会返回新的张量,而不是修改现有的张量。如果您需要就地修改张量,您必须使用 TensorFlow 的变量,这些变量是围绕张量的可变包装器。TensorFlow 要求所有数学运算都通过张量进行,以便库可以优化 GPU 处理:

// XOR data x values.
const xs = tf.tensor([
    [0, 0],
    [0, 1],
    [1, 0],
    [1, 1]
],
// Shape of the tensor is 4 rows x 2 cols
[4, 2]);

// XOR data y values.
const ys = tf.tensor([ 0, 1, 1, 0 ], [4, 1]);

因为张量是矩阵,所以每个张量都有一个形状。对于二维张量,形状定义为*[行,列]。对于三维张量,形状是[行,列,深度]*;图像处理通常使用三维张量,其中行和列代表像素的 Y 和 X 坐标,深度代表该像素的颜色通道(例如,RGBA)。由于我们有四个训练示例,并且每个训练示例需要两个输入字段,因此我们的输入张量具有四行两列的形状。同样,我们的目标值张量具有四行一列的形状。如果您尝试使用错误的输入和输出形状进行计算或训练模型,TensorFlow 将抛出错误。

我们的最后一步是用数据训练模型,然后评估模型。TensorFlow 的model.fit方法是训练模型的方法,一旦训练完成,我们可以使用model.evaluate来获取统计数据,例如准确性和损失,我们还可以使用model.predict在前馈或预测模式下运行模型:

model.fit(xs, ys, {epochs: 1000}).then(() => {
    console.log("Done training. Evaluating model...");
    const r = model.evaluate(xs, ys);

    console.log("Loss:");
    r[0].print();
    console.log("Accuracy:");
    r[1].print();

    console.log("Testing 0,0");
    model.predict(tf.tensor2d([0, 0], [1, 2])).print();
    console.log("Testing 0,1");
    model.predict(tf.tensor2d([0, 1], [1, 2])).print();
    console.log("Testing 1,0");
    model.predict(tf.tensor2d([1, 0], [1, 2])).print();
    console.log("Testing 1,1");
    model.predict(tf.tensor2d([1, 1], [1, 2])).print();
});

添加代码后,从命令行运行yarn start。对我来说,运行这个模型大约需要 60 秒。当模型完成时,您应该看到以下类似输出。请注意,ANNs 和随机梯度下降优化器在初始化和处理时使用随机值,因此模型的某些运行可能不成功,具体取决于特定的随机初始条件。以下是将获得的输出:

Done training. Evaluating model...
 Loss:
 Tensor
 0.00011571444338187575
 Accuracy:
 Tensor
 1
 Testing 0, 0
 Tensor
 [[0.0001664],]
 Testing 0, 1
 Tensor
 [[0.9999378],]
 Testing 1, 0
 Tensor
 [[0.9999322],]
 Testing 1, 1
 Tensor
 [[0.0001664],]

前面的输出显示模型已经学会了模拟 XOR。损失值非常低,而准确率为 1.0,这对于这样一个简单的问题来说是必需的。在现实世界的问题中,80-90%的准确率更为现实。此外,程序的输出显示了四个测试案例的每个案例的单独预测。您可以看到 sigmoid 激活函数的影响,值非常接近 0 和 1,但并未完全达到。内部,TensorFlow 将这些值四舍五入,以确定分类是否正确。

到目前为止,你应该对网络参数进行一些实验。如果你减少训练的 epoch 数量会发生什么?如果你将 ReLU 层切换为 sigmoid 层会发生什么?如果你将前两层中的单元/神经元数量减少到两个会发生什么?如果你增加训练的 epoch 数量,会发生什么?学习率对训练过程有什么影响?这些都是最好通过试错而不是讲座来发现的事情。这是一个无限灵活的神经网络模型,能够处理比简单的 XOR 示例更复杂的问题,因此你应该通过实验和研究来深入了解所有这些属性和参数。

虽然这个例子只是一个简单的异或(XOR)样本,但这种方法也可以用于许多其他类型的 ANN 问题。我们已经创建了一个三层二进制分类器,它可以自动训练和评估自己——这是终极的纯神经网络。虽然我会在下一章尝试一些高级神经网络模型,例如卷积网络和循环网络,但我将把这些概念的应用留给你们去实际操作。

摘要

本章介绍了人工神经网络的概念,并从概念角度进行了讨论。你了解到神经网络由单个神经元组成,这些神经元是简单的加权加法机,可以对它们的输出应用激活函数。你还了解到神经网络可以有多种拓扑结构,网络中的拓扑以及神经元之间的权重和偏置才是实际工作的部分。你还学习了反向传播算法,这是神经网络自动训练的方法。

我们还研究了经典的 XOR 问题,并通过神经网络的角度来审视它。我们讨论了使用 ANN 解决 XOR 的挑战和解决方法,甚至亲手构建了一个完全训练好的 ANN 来解决 XOR 问题。然后我们介绍了TensorFlow.js库,并使用它构建了一个纯神经网络,并成功使用该神经网络训练和解决 XOR 问题。

在下一章,我们将更深入地探讨高级 ANN 拓扑结构。特别是,我们将讨论卷积神经网络CNN),它在图像处理中广泛使用,我们还将查看循环神经网络RNN),它在人工智能和自然语言任务中常用。

第九章:深度神经网络

在上一章中,我们讨论了神经网络及其基本操作。具体来说,我们讨论了全连接前馈神经网络,这只是众多可能的 ANN 拓扑结构中的一种简单拓扑。在本章中,我们将重点关注两种高级拓扑:卷积神经网络CNN)和一种称为长短期记忆LSTM)网络的循环神经网络。CNNs 通常用于图像处理任务,如目标检测和图像分类。LSTM 网络常用于自然语言处理或语言建模问题。

这些异构的 ANN 拓扑被认为是深度神经网络DNNs)。虽然这个术语没有很好地定义,但 DNNs 通常被理解为在输入层和输出层之间有多个隐藏层的 ANN。卷积网络架构可以非常深,网络中有十个或更多的层。循环架构也可以很深,然而,它们的大部分深度来自于信息可以通过网络向前或向后流动的事实。

在本章中,我们将探讨 TensorFlow 在 CNN 和 RNN 架构方面的能力。我们将讨论 TensorFlow 自己提供的这些拓扑示例,并查看它们在实际中的应用。特别是,我们将讨论以下主题:

  • CNNs

  • 简单 RNN

  • 门控循环单元网络

  • LSTM 网络

  • 用于高级应用的 CNN-LSTM 网络

让我们从查看一个经典的机器学习ML)问题开始:从图像中识别手写数字。

卷积神经网络

为了说明 CNNs,让我们首先想象一下我们如何使用标准的全连接前馈 ANN 来处理图像分类任务。我们从一个 600 x 600 像素大小、有三个颜色通道的图像开始。这样的图像中编码了 1,080,000 条信息(600 x 600 x 3),因此我们的输入层需要 1,080,000 个神经元。如果网络中的下一层包含 1,000 个神经元,我们只需要在第一层和第二层之间维护十亿个权重。显然,问题已经变得难以承受。

假设本例中的 ANN 可以训练,我们还会遇到规模和位置不变性的问题。如果你的任务是识别图像中是否包含街道标志,网络可能难以理解街道标志可以位于图像的任何位置。网络在颜色上也可能有问题;如果大多数街道标志是绿色的,它可能难以识别蓝色标志。这样的网络需要许多训练示例来解决规模、颜色和位置变化的问题。

在卷积神经网络(CNN)变得流行之前,许多研究人员将这个问题视为一个降维问题。一种常见的策略是将所有图像转换为灰度,通过三倍的比例减少数据量。另一种策略是将图像下采样到更易于管理的尺寸,例如 100 x 100 像素,甚至更小,这取决于所需的处理类型。将我们的 600 x 600 像素图像转换为灰度并缩小到 100 x 100 像素将使输入神经元的数量减少 100 倍,从一百万减少到一万,并将输入层和包含 1000 个神经元的隐藏层之间的权重数量从十亿减少到只有一千万。

即使使用了这些降维技术,我们仍然需要一个包含数千万个权重的非常大的网络。在处理之前将图像转换为灰度可以避免颜色检测问题,但仍然不能解决尺度和位置变化问题。我们仍然在解决一个非常复杂的问题,因为阴影、梯度以及图像的整体变化性需要我们使用一个非常大的训练集。

另一种常见的预处理策略是对图像执行各种操作,例如噪声减少、边缘检测和平滑处理。通过减少阴影并强调边缘,ANN 可以得到更清晰的信号来学习。这种方法的缺点是预处理任务通常是低效的;相同的边缘检测算法被应用于集合中的每一张图像,无论该特定的边缘检测算法是否对特定图像有效。

因此,挑战在于将图像预处理任务直接集成到人工神经网络(ANN)中。如果 ANN 本身管理预处理任务,网络就可以学习最佳且最有效的方法来预处理图像,以优化网络的准确性。回想一下第八章,《人工神经网络算法》,我们可以在神经元中使用任何激活函数,只要我们能对激活函数进行微分并使用其在反向传播算法中的梯度。

简而言之,CNN 是一个包含多个——可能很多——预处理层的 ANN,这些预处理层在最终到达一个或两个执行实际分类的完全连接层之前对图像进行转换。通过将预处理任务集成到网络中,反向传播算法可以将预处理任务作为网络训练的一部分进行调整。网络不仅会学习如何分类图像,还会学习如何为你的任务预处理图像。

除了标准的 ANN 层类型外,卷积网络还包含几种不同的层类型。这两种类型的网络都包含输入层、输出层和一个或多个完全连接层。然而,CNN 还结合了卷积层、ReLU 层和池化层。让我们逐一看看每个层。

卷积和卷积层

卷积是一种数学工具,它将两个函数组合成一个新的函数;具体来说,新函数表示当另一个函数在其上扫过时,一个函数点乘产生的曲线下的面积。如果这很难想象,不要担心;最简单的方法是将其想象成动画,不幸的是,我们无法在书中打印动画。本章中卷积的数学细节并不重要,但我确实鼓励你阅读有关该主题的额外资料。

大多数图像过滤器——如模糊、锐化、边缘检测和浮雕——都可以通过卷积操作实现。在图像上下文中,卷积由一个卷积矩阵表示,这通常是一个小矩阵(3 x 3、5 x 5 或类似)。卷积矩阵比要处理的图像小得多,卷积矩阵在图像上扫过,因此卷积应用于整个图像的输出构建了一个应用了效果的新图像。

考虑以下梵高的睡莲图像。这是原始图像:

图片

我可以使用我的图像编辑器的卷积矩阵过滤器来创建锐化效果。这与图像编辑器的锐化过滤器有相同的效果,只不过我是手动编写卷积矩阵的:

图片

结果是原始图像的锐化版本:

图片

我也可以编写一个使图像模糊的卷积矩阵:

图片

这会产生以下图像。效果微妙,因为油画本身有点模糊,但效果是有的:

图片

卷积也可以用来浮雕或检测边缘:

图片

前面的矩阵会产生以下效果:

图片

CNN 使用多个卷积层,每个层包含多个卷积过滤器,来构建图像模型。卷积层和卷积过滤器本身是通过反向传播算法进行训练的,网络最终会发现正确的过滤器来增强网络试图识别的特征。与所有学习问题一样,CNN 开发的过滤器类型可能不一定能被人类轻易理解或解释,但在许多情况下,你会发现你的网络开发了许多执行模糊、边缘检测、颜色隔离和梯度检测的卷积过滤器。

除了从图像中提取有用特征外,卷积操作实际上提供了特征的空间和位置独立性。卷积层不是完全连接的,因此能够检查图像的特定区域。这减少了层间权重的维度要求,并有助于我们避免对特征空间位置的依赖。

这些操作中涉及的数据量仍然很大,因此卷积层通常紧接着是池化层,这本质上是对图像进行下采样。最常见的是使用 2 x 2 最大池化,这意味着对于源特征中的每个 2 x 2 像素区域,池化层将下采样 2 x 2 区域到一个像素,该像素具有源 2 x 2 区域中最大像素的值。因此,2 x 2 池化层将图像大小减少到原来的四分之一;因为卷积操作(可能也会降低维度)已经发生,这种下采样通常可以减少计算需求而不会丢失太多信息。

在某些情况下,卷积神经网络将使用简单的 ReLU 激活函数直接跟随卷积操作并直接在池化之前;这些 ReLU 函数有助于避免图像或卷积操作产生的特征图过度饱和。

简单卷积神经网络的一个典型架构看起来可能是这样的:

  • 输入层,具有宽度 x 高度 x 颜色深度神经元

  • 卷积层,具有 M x M 大小的 N 个卷积滤波器

  • 最大池化层

  • 第二个卷积层

  • 第二个最大池化层

  • 全连接输出层

CNN 的更复杂架构通常包括更多的卷积和池化层组合,并且可能在达到池化层之前涉及两个连续的卷积层。

网络中每个后续的卷积层在其之前的卷积层之上运行。第一个卷积层只能执行简单的卷积,例如边缘检测、平滑和模糊。然而,下一个卷积层能够将先前卷积的结果组合成更高层次的特征,例如基本形状或颜色模式。第三个卷积层可以进一步结合先前层的信息来检测复杂特征,例如轮子、路标和手提包。最后的全连接层或层则类似于标准的前馈人工神经网络,并根据卷积层所隔离的高层次特征对图像进行实际分类。

现在我们尝试使用 TensorFlow.js 在 MNIST 手写数字数据集上实际应用这项技术。

示例 - MNIST 手写数字

我们不如从构建一个从第一原理开始的示例,而是通过一个优秀的 TensorFlow.js MNIST 示例来逐步了解。此示例的目标是训练一个卷积神经网络(CNN)来对手写数字图像进行分类。更具体地说,此示例的目标是在 MNIST 手写数字数据集上实现高准确率。在本节中,我们将通过在代码上执行实验并观察其结果来了解代码和算法。

当前版本的此示例可以在 TensorFlow.js 的 GitHub 上找到:github.com/tensorflow/tfjs-examples/tree/master/mnist。然而,由于在撰写本文后存储库可能已更新,我还在本书的示例存储库中添加了我使用的版本作为 Git 子模块。如果您正在使用本书的存储库并且尚未这样做,请从存储库目录中的命令行运行 git submodule initgit submodule update

在终端中,导航到 Ch5-CNN。此路径是一个符号链接,因此如果您的系统上不起作用,您可以改用导航到 tfjs-examples/mnist

接下来,从命令行运行 yarn 来构建代码,最后运行 yarn watch,这将启动一个本地服务器并将您的浏览器打开到 http://localhost:1234。如果您有使用该端口的任何其他程序,您必须首先终止它们。

页面将首先从 Google 的服务器下载 MNIST 图像。然后,它将训练一个 CNN 进行 150 个周期,定期更新显示损失和准确性的两个图表。回想一下,损失通常是像均方误差(MSE)这样的指标,而准确率是正确预测的百分比。最后,页面将显示一些示例预测,突出显示正确与错误的预测。

我对这个页面的测试运行产生了一个准确率约为 92% 的 CNN:

图片

通常,错误的预测是可以理解的。在这个例子中,数字 1 看起来确实有点像数字 2。尽管我遇到过我也会预测错误的例子,但不太可能有人会犯这个特定的错误:

图片

打开 index.js,我们可以在文件顶部看到网络的拓扑结构:

model.add(tf.layers.conv2d({
  inputShape: [28, 28, 1],
  kernelSize: 5,
  filters: 8,
  strides: 1,
  activation: 'relu',
  kernelInitializer: 'varianceScaling'
}));
model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [2, 2]}));
model.add(tf.layers.conv2d({
  kernelSize: 5,
  filters: 16,
  strides: 1,
  activation: 'relu',
  kernelInitializer: 'varianceScaling'
}));
model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [2, 2]}));
model.add(tf.layers.flatten());
model.add(tf.layers.dense(
    {units: 10, kernelInitializer: 'varianceScaling', activation: 'softmax'}));

这个网络有两个卷积层,每个卷积层后面跟着一个池化层,然后是一个单一的用于预测的全连接层。这两个卷积层都使用kernelSize5,这意味着卷积滤波器是一个 5 x 5 的矩阵。第一个卷积层使用八个过滤器,而第二个使用 16 个。这意味着第一层将创建并使用八个不同的卷积滤波器,因此识别图像的八个不同的图形特征。这些特征可能是抽象的,但在第一层中,常见的特征是表示边缘检测、模糊或锐化,或者梯度识别。

第二个卷积层使用 16 个特征,这些特征可能比第一层的特征更高级。这一层可能试图识别直线、圆形、曲线、波浪线等。高级特征比低级特征多,因此第一层使用比第二层少的过滤器是有意义的。

最终的密集层是一个包含 10 个神经元的全连接层,每个神经元代表一个数字。softmax 激活函数确保输出被归一化到 1。这个最终层的输入是第二个池化层的展平版本。数据需要展平,因为卷积和池化层通常是多维的。卷积和池化层使用代表高度、宽度和颜色深度的矩阵,这些矩阵本身又是通过卷积滤波器使用的结果堆叠在一起的。例如,第一个卷积层的输出将是一个体积为[28 x 28 x 1] x 8 大小的体积。括号内的部分是单个卷积操作(即滤波后的图像)的结果,并且已经生成了八个这样的操作。当将此数据连接到向量层,如标准的密集层或全连接层时,它也必须被展平成一个向量。

进入最终密集层的数据比第一层输出的数据小得多。最大池化层的作用是降低图像的尺寸。poolSize参数为[2, 2]意味着一个 2 x 2 像素窗口将被减少到一个单一值;由于我们使用的是最大池化,这将是在该集合中最大的值(最亮的像素)。strides参数意味着池化窗口将以每次两个像素的步长移动。这种池化将图像的高度和宽度都减半,这意味着图像和数据在面积上减少了四倍。第一次池化操作后,图像被减少到 14 x 14,第二次后变为 7 x 7。由于第二个卷积层有 16 个过滤器,这意味着展平层将有7 * 7 * 16 = 784个神经元。

让我们看看通过在输出之前添加另一个全连接层,我们是否能从这个模型中挤出更多准确度。在最佳情况下,增加另一个层将使我们能够更好地解释卷积产生的 16 个特征的相互作用。

然而,增加另一个层会增加所需的训练时间,并且它也可能不会提高结果。完全有可能,通过增加另一个层,我们不再能发现更多信息。始终记住,人工神经网络只是构建和导航数学景观,寻找数据中的形状。如果数据不是高度多维的,增加我们能力的一个维度可能只是不必要的。

在代码的最终密集层之前添加以下行:

model.add(tf.layers.dense(
    {units: 100, kernelInitializer: 'varianceScaling', activation: 'sigmoid'}));

在上下文中,代码现在应该看起来像这样,新行被突出显示:

model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [2, 2]}));
model.add(tf.layers.flatten());
model.add(tf.layers.dense(
 {units: 100, kernelInitializer: 'varianceScaling', activation: 'sigmoid'}));
model.add(tf.layers.dense(
    {units: 10, kernelInitializer: 'varianceScaling', activation: 'softmax'}));

const LEARNING_RATE = 0.15;

由于您已从命令行发出yarn watch,代码应自动重建。刷新页面并观察结果:

算法的学习速度比原始版本慢,这是预期的,因为我们增加了一个新层,因此增加了模型的复杂性。让我们稍微增加训练限制。

找到TRAIN_BATCHES变量并将其更新为300。该行现在应如下所示:

const TRAIN_BATCHES = 300;

保存文件以触发重建并重新加载页面。让我们看看我们是否能打败基线:

看起来我们确实打败了 92%的基线分数,然而我必须谨慎地提醒,不要过于乐观。有可能我们已经过度训练和过度拟合了模型,并且有可能它在现实生活中表现不佳。此外,由于训练和验证是随机的,这个网络的真正准确度可能与基线相当。确实,92%已经是一个非常好的结果,我不期望任何模型能做得更好。然而,这仍然是一个鼓舞人心的结果,因为新层增加的负担并不大。

在这个阶段,请撤销您的更改,以便您使用文件的原始副本进行工作。让我们进行一个不同的实验。看看我们能否在不损失太多准确度的情况下将网络规模缩小到多小,这将会很有趣。

首先,让我们减少第二个卷积层使用的卷积滤波器数量。我的理由是数字使用相当简单的形状:圆形、线条和曲线。也许我们不需要捕捉 16 种不同的特征。也许 8 个就足够了。在第二个卷积层中,将filters: 8更改为filters: 2。您的代码现在应该如下所示:

...
model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [2, 2]}));
model.add(tf.layers.conv2d({
  kernelSize: 5,
  filters: 2,
  strides: 1,
  activation: 'relu',
  kernelInitializer: 'varianceScaling'
}));
model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [2, 2]}));
...

重新运行代码,我们看到我们仍然得到了相当准确的度,尽管方差略高于基线:

这支持了这样一个总体观点,即使用的形状和特征相对较少。然而,当我们查看测试示例时,我们也发现错误比以前更难以理解。也许我们没有损失很多准确率,但我们的模型变得更加抽象:

图片

我鼓励你继续探索和实验这个示例,因为通过阅读代码你可以学到很多东西。我想特别指出这个示例的一个方面,那就是data.js文件,它负责处理 MNIST 数据集。在你的实际应用中,你可能会需要采用类似的方法,因为你的训练数据不一定总是在本地机器上。这个文件处理从远程源下载数据,将其分为测试集和验证集,并为训练算法维护请求的批次。如果你需要从远程源获取训练数据,这是一个很好的、轻量级的方法。我们将在第十一章深入讨论这个主题,在实时应用中使用机器学习

这里有一些你可以尝试的实验想法:

  • 在保持 90%以上准确率的同时,尽可能使网络规模最小化。

  • 在保持 85%以上准确率的同时,使网络尽可能小。

  • 在少于 50 个 epoch 中将模型训练到 90%以上的准确率。

  • 发现实现 90%以上准确率所需的最少训练示例数量(在data.js中将NUM_TRAIN_ELEMENTS的值减少以使用更少的训练示例)

在下一节中,我们将探讨使用循环神经网络进行序列预测。

循环神经网络

在许多情况下,神经网络需要记忆。例如,当建模自然语言上下文很重要时,也就是说,句子中较晚出现的单词的意义受到句子中较早出现的单词的意义的影响。这与朴素贝叶斯分类器使用的做法形成对比,朴素贝叶斯分类器只考虑单词袋,不考虑它们的顺序。同样,时间序列数据可能需要一些记忆才能做出准确的预测,因为未来的值可能与当前或过去的值相关。

RNN 是一组 ANN 拓扑结构,其中信息不一定只在一个方向上流动。与前馈神经网络相比,RNN 允许神经元的输出反向输入到它们的输入中,从而创建一个反馈循环。循环网络几乎总是时间相关的。然而,时间概念是灵活的;句子中的有序单词可以被认为是时间相关的,因为一个单词必须跟在另一个单词之后。RNN 的时间相关性不一定与时钟上实际时间的流逝相关。

在最简单的情况下,对 RNN 的要求仅仅是神经元的输出值需要连接——通常是通过权重或衰减因子——不仅连接到下一层的神经元,而且也连接回自己的输入。如果你熟悉数字信号处理中的有限脉冲响应FIR)滤波器,这种类型的神经元可以被视为 FIR 滤波器的一种变体。这种类型的反馈会产生一种记忆,因为之前的激活值部分保留并用作神经元下一个周期的输入。你可以将这想象成神经元产生的回声,变得越来越微弱,直到回声不再可闻。因此,以这种方式设计的网络将具有有限的记忆,因为最终回声会消失得无影无踪。

另一种 RNN 的风格是全循环 RNN,其中每个神经元都与网络中的每个其他神经元相连,无论是正向还是反向。在这种情况下,不仅仅是单个神经元可以听到自己的回声;网络中的每个神经元都可以听到其他每个神经元的回声。

虽然这些类型的网络功能强大,但在许多情况下,网络需要比回声持续更长时间的内存。为了解决长期记忆的问题,发明了一种非常强大、异类的拓扑结构,称为LSTM。LSTM 拓扑使用一种称为 LSTM 单元的异类神经元,能够存储所有之前的输入和激活值,并在计算未来激活值时回忆它们。当 LSTM 网络首次推出时,它打破了令人印象深刻的一系列记录,尤其是在语音识别、语言建模和视频处理方面。

在下一节中,我们将简要讨论 TensorFlow.js 提供的三种不同类型的 RNN 拓扑:SimpleRNN(或全循环 RNN)、门控循环单元GRU)网络和 LSTM 网络。

SimpleRNN

TensorFlow.js提供的第一个 RNN 层是 SimpleRNN 层类型,它由一个 SimpleRNNCell 神经元组成。这是一种异类的神经元,可以将自己的输出反馈到输入。这种神经元的输入是一个时间依赖值的向量;每个输入值的激活输出被反馈到下一个值的输入,依此类推。可以指定一个介于 0 和 1 之间的dropout因子;这个值代表每个回声的强度。以这种方式设计的神经元在许多方面类似于 FIR 滤波器。

实际上,这种 RNN 架构是由数字信号处理领域关于 FIR 滤波器的前期工作所实现的。这种架构的优势在于数学原理已被充分理解。可以展开一个 RNN,这意味着可以创建一个多层前馈 ANN,其结果与较少层的 RNN 相同。这是因为神经元反馈的回声是有限的。如果一个神经元已知回声 20 次,那么这个神经元可以被建模为 21 个前馈神经元(包括源神经元)。训练这些网络的初步努力受到了 FIR 滤波器工作的启发,因为分析非常相似。

考虑以下由弗朗索瓦·德洛什(François Deloche)创作的图像(原创作品,CC BY-SA 4.0),它说明了循环神经元的展开:

标记为V的循环表示神经元的反馈操作。当给神经元提供未来输入值(X)时,前一次激活的输出达到输入并成为输入因子。如图所示,这可以建模为一系列简单的神经元的线性序列。

从 TensorFlow 的角度来看,循环层的操作被 TensorFlow 层 API 抽象化。让我们看看 TensorFlow.js 的另一个示例,该示例说明了各种 RNN 架构的可互换性。

从本书的 GitHub 仓库中,导航到Ch9-RNN目录,这再次是一个指向tfjs-examples/addition-rnn目录的符号链接。(如果你仍然在运行之前的 RNN 示例,你需要在运行 yarn watch 命令的终端中按Ctrl + C来停止它。)首先,运行yarn命令来构建代码,然后运行yarn watch再次启动本地服务器并导航到http://localhost:1234

这个特定的例子旨在通过示例教授 RNN 整数加法。训练数据将是一系列问题,如24 + 2214 + 54,以字符串形式表示,网络需要能够解码字符串,将其数值化,学习答案,并将知识扩展到新的示例。

当页面加载时,你会看到以下表单。保持默认设置并点击Train Model按钮:

你将看到类似于以下损失和准确度图,这表明在 100 个训练周期后,该模型的准确度为 93.8%:

损失和相似度图

你还会看到模型针对随机测试输入的测试结果:

让我们更仔细地看看这是如何在底层工作的。打开 index.js 文件并找到 createAndCompileModel 函数。我将假设您为这个示例选择了 SimpleRNN 网络类型,并省略了处理 GRU 和 LSTM 架构的 switch/case 语句:

function createAndCompileModel(
    layers, hiddenSize, rnnType, digits, vocabularySize) {
    const maxLen = digits + 1 + digits;

    const model = tf.sequential();
    model.add(tf.layers.simpleRNN({
        units: hiddenSize,
        recurrentInitializer: 'glorotNormal',
        inputShape: [maxLen, vocabularySize]
    }));
    model.add(tf.layers.repeatVector({n: digits + 1}));
    model.add(tf.layers.simpleRNN({
        units: hiddenSize,
        recurrentInitializer: 'glorotNormal',
        returnSequences: true
    }));
    model.add(tf.layers.timeDistributed(
        {layer: tf.layers.dense({units: vocabularySize})}));
    model.add(tf.layers.activation({activation: 'softmax'}));
    model.compile({
        loss: 'categoricalCrossentropy',
        optimizer: 'adam',
        metrics: ['accuracy']
    });
    return model;
}

这段代码构建了一个包含两个循环层、一个时间分布的全连接层和输出层的模型。vocabularySize 参数表示涉及的总唯一字符数,这些字符是数字 0-9、加号和空格字符。maxLen 参数表示输入字符串的最大长度;对于两位数加法问题,maxLen 将是五个字符,因为必须包括加号。

在这个例子中,特别值得注意的是 timeDistributed 层类型。这是 TensorFlow API 中的一个层包装器,旨在在层中创建一个神经元体积,其中每个切片代表一个时间切片。这与前一个例子中 CNN 使用的体积在精神上相似,其中体积的深度代表一个单独的卷积操作。然而,在这个例子中,体积的深度代表一个时间切片。

timeDistributed 包装器允许每个时间切片由一个单独的密集层或全连接层处理,而不是仅用单个神经元向量来尝试解释时间依赖性数据,在这种情况下,时间数据可能会丢失。timeDistributed 包装器是必需的,因为之前的 simpleRNN 层使用了 returnSequences: true 参数,这导致层不仅输出当前时间步,还输出层历史中遇到的所有时间步。

接下来,让我们看看 GRU 架构。

门控循环单元

GRU 架构由特殊、异质的神经元组成,这些神经元使用几个内部机制来控制神经元的记忆和反馈。GRU 是一项较新的发明,仅在 2014 年作为 LSTM 神经元的简化版本被开发出来。虽然 GRU 比 LSTM 更新,但我首先介绍它,因为它稍微简单一些。

在 GRU 和 LSTM 神经元中,输入信号被发送到多个激活函数。每个内部激活函数可以被认为是一个标准的 ANN 神经元;这些内部神经元被组合起来,以赋予整体神经元其记忆能力。从外部看,GRU 和 LSTM 神经元都看起来像是能够接收时间依赖性输入的神经元。从内部看,这些异质神经元使用更简单的神经元来控制从前一个激活中衰减或增强多少反馈,以及将多少当前信号存储到内存中。

GRU 和 LSTM 神经元相较于简单的 RNN 神经元有两个主要优势。首先,这些神经元的记忆不会像简单 RNN 神经元的回声那样随时间衰减。其次,记忆是可配置和自学习的,也就是说,神经元可以通过训练学习到特定记忆对当前激活的重要性。

考虑以下插图,也是由 François Deloche(本人作品,CC BY-SA 4.0)提供的:

图片

流程图一开始可能有点难以理解。**Z[t]信号是一个向量,它控制了多少激活被存储到记忆中并传递给未来的值,而R[t]**信号控制了应该从记忆中遗忘多少先前值。这些信号都连接到标准的激活函数,而这些激活函数又都有自己的权重。从某种意义上说,GRU 本身就是一个微型的神经网络。

在这一点上,可能会有人好奇为什么神经元的记忆不能简单地通过编程来实现,例如,使用神经元可以查询的键/值存储。这些架构之所以被使用,是因为反向传播算法需要数学可微性。即使是像 RNN 这样的异构拓扑,也是通过数学方法如梯度下降进行训练的,因此整个系统必须是数学上可表示的。因此,研究人员需要使用前面的技术来创建一个网络,其中每个组件都是数学上可分析和可微的。

http://localhost:1234的测试页面上,将RNN 类型参数更改为 GRU,同时保持所有其他参数不变,然后再次点击训练模型。图表将更新,你应该会看到以下内容:

图片

在这种情况下,训练过程花费了更长的时间,但准确性从 SimpleRNN 类型的 92%提高到了 95%。增加的训练时间并不令人惊讶,因为 GRU 架构实际上将网络使用的激活函数数量增加了三倍。

虽然许多因素会影响网络的准确性,但有两个明显的因素脱颖而出。首先,GRU 拓扑具有长期记忆,而 SimpleRNN 最终会忘记先前值,因为它们的回声衰减。其次,GRU 对激活信号输入未来激活以及保留信息的控制更加精确。这些网络的参数是通过反向传播算法训练的,因此神经元的遗忘性本身是通过训练进行优化的。

接下来,让我们看看那个启发 GRU 并开辟了全新研究领域拓扑结构:LSTM。

长短期记忆

LSTM 在 1997 年被引入,由于其在解决历史难题方面的出色准确率,在学术人工神经网络社区中引起了轰动。特别是,LSTM 在许多自然语言处理任务、手写识别和语音识别方面表现出色。在许多情况下,LSTM 网络以很大的差距打破了之前的准确率记录。许多处于语音识别和语言建模前沿的系统都使用了 LSTM 网络。很可能是像苹果的 Siri 和谷歌的 Assistant 这样的系统,在它们的语音识别和语言解析模型中都使用了 LSTM 网络。

LSTM 网络之所以得名,是因为它能够长时间保留短期记忆(例如,句子中较早使用过的单词的记忆)。在训练过程中,这避免了被称为“梯度消失”的问题,这是简单 RNN 在先前激活的回声逐渐消失时所遭受的问题。

与 GRU 一样,LSTM 神经元是一种具有复杂内部工作的异类神经元细胞。具体来说,LSTM 神经元有三个内部使用的:一个输入门,它控制允许进入神经元的值的量;一个遗忘门,它管理神经元的记忆;以及一个输出门,它控制允许进入神经元输出的信号的量。门的组合,加上神经元之间都是相互连接的,使得 LSTM 对神经元记住哪些信号以及如何使用这些信号具有非常精细的控制。与 GRU 中的门一样,LSTM 中的门也可以被视为具有自己权重的独立标准神经元。

考虑以下由弗朗索瓦·德洛什(François Deloche)制作的图形(本人作品,CC BY-SA 4.0):

图片

**I[t]**信号控制允许进入细胞的输入信号的比例。**O[t]信号控制允许从细胞中输出的输出量,而F[t]**信号控制细胞保留先前值的量。记住,这些都是矢量量,因此输入、输出和记忆可以按元素进行控制。

LSTM 在需要记忆和先前值知识的任务上表现出色,尽管细胞复杂的内部工作(涉及五个不同的激活函数)导致训练时间更长。回到你的浏览器中的测试页面,将RNN 类型切换为 LSTM,然后点击训练模型

图片

LSTM(长短期记忆网络)的准确率达到了近 98%,超过了 SimpleRNN 和 GRU RNN 拓扑结构。当然,这个网络训练时间比其他两个都要长,因为简单的事实是,需要训练的神经元(在 LSTM 细胞内部)更多。

LSTM 网络有许多最先进的用途。它们在音频分析中非常受欢迎,如语音识别,因为音频高度依赖于时间。单个音频样本本身是没有意义的;只有当数千个音频样本在上下文中一起取用时,音频剪辑才开始有意义。一个用于识别语音的 LSTM 首先会被训练来将短音频剪辑(大约 0.1-0.25 秒)解码成音素,即语音声音的文本表示。然后,另一个 LSTM 层会被训练来将音素序列连接起来,以确定最可能说出的短语。第一层 LSTM 依赖于时间依赖性来解释原始音频信号。第二层 LSTM 依赖于时间依赖性为自然语言提供上下文——例如,使用上下文和语法来确定是说了在哪里还是我们在哪里

LSTM 的另一个最先进用例是 CNN-LSTM。这种网络拓扑结合了 CNN 和 LSTM;一个典型的应用将是视频剪辑中的动作检测。模型的 CNN 部分分析单个视频帧(就像它们是独立的图像一样),以识别对象及其位置或状态。模型的 LSTM 部分将单个帧组合在一起,并围绕它们生成一个时间依赖的上下文。如果没有 LSTM 部分,模型将无法判断棒球是静止的还是运动的,例如。是 CNN 检测到的对象先前状态的记忆为确定视频中发生的动作提供了上下文。模型的 CNN 部分识别出棒球,然后 LSTM 部分理解球是在移动的,可能被扔出或击中。

CNN-LSTM 的另一种变体用于自动描述图像。可以给 CNN-LSTM 展示一张站在湖边码头上的女人的图像。模型的 CNN 部分会单独识别图像中的女人、码头和湖作为对象。然后,LSTM 部分可以根据 CNN 收集到的信息生成图像的自然语言描述;是 LSTM 部分在语法上编译了描述,“湖边的码头上的女人”。记住,自然语言描述是时间依赖的,因为单词的顺序很重要。

关于 LSTM 网络的一个最后注意事项与 LSTM 单元中使用的有关。虽然输入、遗忘和输出门通常使用标准的激活神经元,但也可以使用整个神经网络作为门本身。以这种方式,LSTM 可以使用其他模型作为其知识和记忆的一部分。这种方法的典型用例将是自动语言翻译。例如,单个 LSTM 可以用来模拟英语和法语,而一个整体的 LSTM 可以管理两者之间的翻译。

我个人的信念是,LSTM 网络,或其变体,如 GRU 拓扑结构,将在通往通用人工智能(AGI)的道路上扮演关键角色。在尝试模拟通用人类智能时,拥有强大的记忆能力是一个基本要求,而 LSTM 非常适合这种用例。这些网络拓扑结构是人工神经网络(ANN)研究的前沿,因此预计在未来几年内将看到重大进展。

摘要

在本章中,我们讨论了两种高级神经网络拓扑结构:卷积神经网络(CNN)和循环神经网络(RNN)。我们以图像识别的背景讨论了 CNN,特别是手写数字识别的问题。在探索 CNN 的同时,我们还讨论了图像滤波背景下的卷积操作本身。

我们还讨论了如何通过 RNN 架构使神经网络保持记忆。我们了解到 RNN 有许多应用,从时间序列分析到自然语言建模。我们讨论了几种 RNN 架构类型,例如简单的全循环网络和 GRU 网络。最后,我们讨论了最先进的 LSTM 拓扑结构,以及它如何用于语言建模和其他高级问题,如图像标题或视频注释。

在下一章中,我们将探讨一些自然语言处理的实际方法,特别是与机器学习算法最常结合使用的技术。