
Github:@wizardforcel 简书:@ApacheCN_飞龙 微博:@龙雀 CSDN:@wizardforcel ApacheCN 官网:apachecn.org 机器学习交流群:629470233
版权声明:License CC BY-NC-SA 4.0 https://blog.csdn.net/wizardforcel/article/details/83345186 线性回归 令 z=wTx+bz = w^T x + bz=wTx+b,得到: y=z+ϵ, ϵ∼N(0,σ2)y = z + \epsilon, \, \epsilon \sim N(0, \sigma^2)y=z+ϵ,ϵ∼N(0,σ2) 于是: y∣x∼N(z,σ2)y|x \sim N(z, \sigma^2)y∣x∼N(z,σ2) 为啥是 y∣xy|xy∣x,因为判别模型的输出只能是 y∣xy|xy∣x。 它的概率密度函数: fY∣X(y)=12πσexp(−(y−z)22σ2)=Aexp(−B(y−z)2), A,B>0f_{Y|X}(y)=\frac{1}{\sqrt{2 \pi} \sigma} \exp(\frac{-(y -z)^2}{2\sigma^2}) \\ = A \exp(-B (y - z)^2), \, A, B > 0fY∣X(y)=2πσ1exp(2σ2−(y−z)2)=Aexp(−B(y−z)2),A,B>0 计算损失函数: L=−∑ilogfY∣X(y(i))=−∑i(logA−B(y(i)−z(i))2)=B∑i(y(i)−z(i))2+CL = -\sum_i \log f_{Y|X}(y^{(i)}) \\ = -\sum_i(\log A - B(y^{(i)} - z^{(i)})^2) \\ = B \sum_i(y^{(i)} - z^{(i)})^2 + CL=−∑ilogfY∣X(y(i))=−∑i(logA−B(y(i)−z(i))2)=B∑i(y(i)−z(i))2+C 所以 minL\min LminL 就相当于 min(y(i)−z(i))2\min (y^{(i)} - z^{(i)})^2min(y(i)−z(i))2。结果和最小二乘是一样的。 逻辑回归 令 z=wTx+b,a=σ(z)z = w^T x + b, a = \sigma(z)z=wTx+b,a=σ(z),我们观察到在假设中: P(y=1∣x)=aP(y=0∣x)=1−aP(y=1|x) = a \\ P(y=0|x) = 1 - aP(y=1∣x)=aP(y=0∣x)=1−a 也就是说: y∣x∼B(1,a)y|x \sim B(1, a)y∣x∼B(1,a) 其实任何二分类器的输出都是伯努利分布。因为变量只能取两个值,加起来得一,所以只有一种分布。 它的概率质量函数(因为是离散分布,只有概率质量函数,不过无所谓): pY∣X(y)=ay(1−a)1−yp_{Y|X}(y) = a^y(1-a)^{1-y}pY∣X(y)=ay(1−a)1−y 然后计算损失函数: L=−∑ilogpY∣X(y(i))=−∑i(y(i)loga(i)+(1−y(i))log(1−a(i)))L = -\sum_i \log p_{Y|X}(y^{(i)}) \\ = -\sum_i(y^{(i)} \log a^{(i)} + (1-y^{(i)})\log(1-a^{(i)}))L=−∑ilogpY∣X(y(i))=−∑i(y(i)loga(i)+(1−y(i))log(1−a(i))) 和交叉熵是一致的。 可以看出,在线性回归的场景下,MLE 等价于最小二乘,在逻辑回归的场景下,MLE 等价于交叉熵。但不一定 MLE 在所有模型中都是这样。
原文:Deep Learning 2: Part 1 Lesson 5 作者:Hiromi Suenaga 课程论坛 一,引言 没有足够的关于结构化深度学习的出版物,但它肯定出现在行业中: 结构化深度学习,作者:Kerem Turgutlu @datascience.com 你可以使用此工具从 Google 下载图片并解决自己的问题: 小型图像数据集的乐趣(第2部分),作者:Nikhil B @datascience.com 如何训练神经网络的介绍(一篇伟大的技术写作): 我们如何“训练”神经网络?,由 Vitaly Bushaev @datascience.com 学生们在 Kaggle 幼苗分类比赛中与 Jeremy 竞争。 II. 协同过滤 - 使用 MovieLens 数据集 讨论的笔记本可以在这里找到(lesson5-movielens.ipynb)。 我们来看看数据。 我们将使用userId (类别), movieId (类别)和rating(因变量)进行建模。 ratings = pd.read_csv(path+'ratings.csv') ratings.head() 为 Excel 创建子集 我们创建了最受欢迎的电影和大多数电影狂热粉的交叉表,我们将其复制到 Excel 中进行可视化。 g=ratings.groupby('userId')['rating'].count() topUsers=g.sort_values(ascending=False)[:15] g=ratings.groupby('movieId')['rating'].count() topMovies=g.sort_values(ascending=False)[:15] top_r = ratings.join(topUsers, rsuffix='_r', how='inner', on='userId') top_r = top_r.join(topMovies, rsuffix='_r', how='inner', on='movieId') pd.crosstab(top_r.userId, top_r.movieId, top_r.rating, aggfunc=np.sum) 这是包含上述信息的 excel 文件。 首先,我们将使用矩阵分解而不构建神经网络。 蓝色单元格 - 实际评级 紫色单元格 - 我们的预测 红色单元格 - 我们的损失函数即均方根误差(RMSE) 绿色单元格 - 电影嵌入(随机初始化) 橙色单元格 - 用户嵌入(随机初始化) 每个预测是电影嵌入向量和用户嵌入向量的点积。 在线性代数术语中,它等于矩阵乘积,因为一个是行,一个是列。 如果没有实际评级,我们将预测设置为零(将其视为测试数据 - 而不是训练数据)。 然后我们使用梯度下降来减少损失。 Microsoft Excel 在加载项中有一个“求解器”,可以通过更改所选单元格来最小化变量(GRG Nonlinear是你要使用的方法)。 这可称为“浅学习”(与深度学习相反),因为没有非线性层或第二线性层。 那么我们直觉上做了什么呢? 每部电影的五个数字称为“嵌入”(潜在因式) - 第一个数字可能代表科幻和幻想的程度,第二个数字可能是电影使用了多少特效,第三个可能是对话驱动的程度。与之类似,每个用户还有 5 个数字,例如,表示用户喜欢幻想,特效和对话驱动的电影的程度。 我们的预测是这些向量的叉乘。 由于我们没有每个用户的每个电影评论,因此我们试图找出哪些电影与这部电影相似,以及其他用户评价其他电影,如何与这个用户评价这个电影类似(因此称为“协同”)。 我们如何处理新用户或新电影 - 我们是否需要重新训练模型? 我们现在没有时间来讨论这个问题,但基本上你需要有一个新的用户模型或最初会使用的新电影模型,随着时间的推移你需要重新训练模型。 阅读更多
原文:Deep Learning 2: Part 1 Lesson 4 作者:Hiromi Suenaga 课程论坛 学生的文章: 改善学习率的方式 循环学习率技术 探索带有重启动的随机梯度下降(SGDR) 使用差异学习率的迁移学习 让计算机看得比人类更好 Dropout [04:59] learn = ConvLearner.pretrained(arch, data, ps=0.5, precompute=True) precompute=True :预计算来自最后一个卷积层的激活。 请记住,激活是一个数字,它是根据构成内核/过滤器的一些权重/参数计算出来的,它们会应用于上一层的激活或输入。 learn Sequential( (0): BatchNorm1d(1024, eps=1e-05, momentum=0.1, affine=True) (1): Dropout(p=0.5) (2): Linear(in_features=1024, out_features=512) (3): ReLU() (4): BatchNorm1d(512, eps=1e-05, momentum=0.1, affine=True) (5): Dropout(p=0.5) (6): Linear(in_features=512, out_features=120) (7): LogSoftmax() ) learn - 这将显示我们最后添加的层。 这些是我们在precompute=True时训练的层 (0),(4): BatchNorm将在上一课中介绍 (1),(5):Dropout (2): Linear层仅仅是矩阵乘法。 这是一个包含 1024 行和 512 列的矩阵,因此它将接受 1024 个激活并输出 512 个激活。 (3): ReLU - 只是用零替换负数 (6): Linear - 第二个线性层,从前一个线性层获取 512 个激活并将它们乘以 512 乘 120 的新矩阵并输出 120 个激活 (7): Softmax - 激活函数,返回最大为 1 的数字,每个数字在 0 和 1 之间: 出于较小的数值精度原因,事实证明最好直接使用 softmax 的 log 而不是 softmax [15:03]。这就是为什么当我们从模型中得到预测时,我们必须执行np.exp(log_preds)。 阅读更多
原文:Deep Learning 2: Part 1 Lesson 3 作者:Hiromi Suenaga 课程论坛 学生创建的有用材料: AWS 操作方法 TMUX 第 2 课总结 学习率查找器 PyTorch 学习率与批量大小 误差表面的平滑区域与泛化 5 分钟搞定卷积神经网络 解码 ResNet 架构 另一个 ResNet 教程 我们离开的地方: 回顾 [08:24] : Kaggle CLI:如何下载数据 1: 从 Kaggle 下载时, Kaggle CLI 是一个很好的工具。 因为它从 Kaggle 网站下载数据(通过屏幕抓取),它会在网站更改时损坏。 当发生这种情况时,运行pip install kaggle-cli --upgrade 。 然后你可以运行: $ kg download -u <username> -p <password> -c <competition> 将<username>,<password>替换为你的凭证,<competition>是 URL 中的/c/。 例如,如果你尝试从https://www.kaggle.com/c/dog-breed-identification下载狗品种数据,该命令将如下所示: $ kg download -u john.doe -p mypassword -c dog-breed-identification 确保你曾从计算机上单击过Download按钮并接受以下规则: CurWget(Chrome 扩展程序):如何下载数据 2: 简单的猫狗识别 [13:39] from fastai.conv_learner import * PATH = 'data/dogscats/' sz=224 bs=64 笔记本通常假设你的数据位于data文件夹中。 但也许你想把它们放在其他地方。 在这种情况下,你可以使用符号链接(简称symlink): 这是一个端到端的过程,用于获得猫狗识别的最新结果: 简单的猫狗识别 阅读更多
原文:Deep Learning 2: Part 1 Lesson 2 作者:Hiromi Suenaga 论坛 笔记本 回顾上一课 [01:02] 我们使用 3 行代码来构建图像分类器。 为了训练模型,需要在PATH下以某种方式组织数据(在本例中为data/dogscats/ ): 应该有train文件夹和valid文件夹,并且在每个文件夹下都有带有分类标签的文件夹(例如本例中的cats ),其中包含相应的图像。 训练输出: [epoch #, training loss, validation loss, accuracy] [ 0\. 0.04955 0.02605 0.98975] 学习率 [4:54] 学习率的基本思想是,它将决定我们解决方案改进的速度。 如果学习率太小,则需要很长时间才能达到最低点。 如果学习率太大,它可能会在底部摆动。 学习率查找器( learn.lr_find )将在每个小批量之后提高学习率。 最终,学习率太高,损失会变得更糟。 然后,我们查看学习率与损失的关系曲线,确定最低点并返回一个幅度,并选择它作为学习率(下例中为1e-2 )。 小批量是我们每次查看的一组图像,因此我们有效地使用 GPU 的并行处理能力(通常一次 64 或 128 个图像) 在 Python 中: 通过调整这一个数字,你应该能够获得相当不错的结果。 fast.ai 库为你选择其余的超参数。 但随着课程的进展,我们将了解到还有一些我们可以调整的东西,可以获得更好的结果。 但学习率是我们设定的关键数字。 学习率查找器位于其他优化器(例如动量,Adam 等)的上层,并根据你正在使用的调整(例如高级优化器但不限于优化器)帮助你选择最佳学习率。 问题:在迭代期间改变学习率,优化器会发生什么? 这个查找器是否选择了初始学习率? [14:05] 我们稍后会详细了解优化器,但基本答案是否定的。 即使 Adam 的学习率也会除以先前的平均梯度以及最近的梯度的平方和。 即使那些所谓的“动态学习率”方法也具有学习率。 使模型更好的最重要的事情是,为它提供更多数据。 由于这些模型有数百万个参数,如果你训练它们一段时间,它们就会开始所谓的“过拟合”。 过拟合 - 模型开始在训练集中看到图像的具体细节,而不是学习可以传递到验证集的一般内容。 我们可以收集更多数据,但另一种简单方法是数据增强。 阅读更多
原文:Deep Learning 2: Part 1 Lesson 1 作者:Hiromi Suenaga 课程论坛 入门 [0:00]: 为了训练神经网络,你肯定需要图形处理单元(GPU) - 特别是 NVIDIA GPU,因为它是唯一支持 CUDA(几乎所有深度学习库和从业者都使用的语言和框架)的设备。 租用 GPU 有几种方法:Crestle [04:06] ,Paperspace [06:10] Jupyter 笔记本和猫狗识别的介绍 [12:39] 你可以通过选择它并按下shift+enter来运行单元格(你可以按住shift并多次按enter键来继续下拉单元格),或者你可以单击顶部的“运行”按钮。单元格可以包含代码,文本,图片,视频等。 Fast.ai 需要 Python 3 %reload_ext autoreload %autoreload 2 %matplotlib inline # This file contains all the main external libs we'll use from fastai.imports import * from fastai.transforms import * from fastai.conv_learner import * from fastai.model import * from fastai.dataset import * from fastai.sgdr import * from fastai.plots import * PATH = "data/dogscats/" sz=224 先看图片 [15:39] !ls {PATH} models sample test1 tmp train valid !表明使用 bash(shell)而不是 python 如果你不熟悉训练集和验证集,请查看 Practical Machine Learning 课程(或阅读 Rachel 的博客) !ls {PATH}valid cats dogs files = !ls {PATH}valid/cats | head files ['cat.10016.jpg', 'cat.1001.jpg', 'cat.10026.jpg', 'cat.10048.jpg', 'cat.10050.jpg', 'cat.10064.jpg', 'cat.10071.jpg', 'cat.10091.jpg', 'cat.10103.jpg', 'cat.10104.jpg'] 此文件夹结构是共享和提供图像分类数据集的最常用方法。 每个文件夹都会告诉你标签(例如dogs或cats)。 img = plt.imread(f' {PATH} valid/cats/ {files[0]} ') plt.imshow(img); f'{PATH}valid/cats/{files[0]}' - 这是一个 Python 3.6 格式化字符串,可以方便地格式化字符串。 img.shape (198, 179, 3) img[:4,:4] array([[[ 29, 20, 23], [ 31, 22, 25], [ 34, 25, 28], [ 37, 28, 31]], [[ 60, 51, 54], [ 58, 49, 52], [ 56, 47, 50], [ 55, 46, 49]], [[ 93, 84, 87], [ 89, 80, 83], [ 85, 76, 79], [ 81, 72, 75]], [[104, 95, 98], [103, 94, 97], [102, 93, 96], [102, 93, 96]]], dtype=uint8) img是一个三维数组(又名 3 维张量) 这三个维度(例如[29, 20, 23])表示 0 到 255 之间的红绿蓝像素值 我们的想法是利用这些数字来预测这些数字是代表猫还是狗,基于查看猫和狗的大量图片。 这个数据集来自 Kaggle 竞赛,当它发布时(早在 2013 年),最先进的技术准确率为 80%。 让我们训练一个模型 [20:21] 以下是训练模型所需的三行代码: data = ImageClassifierData.from_paths(PATH, tfms=tfms_from_model(resnet34, sz)) learn = ConvLearner.pretrained(resnet34, data, precompute= True ) learn.fit (0.01, 3) [ 0. 0.04955 0.02605 0.98975] [ 1. 0.03977 0.02916 0.99219] [ 2. 0.03372 0.02929 0.98975] 这将执行 3 个迭代,这意味着它将三次查看整个图像集。 输出中的三个数字中的最后一个是验证集上的准确度。 前两个是训练集和验证集的损失函数值(在这种情况下是交叉熵损失)。 开始(例如,1.)是迭代数。 我们通过 3 行代码在 17 秒内达到了 ~99% (这将在 2013 年赢得 Kaggle 比赛)![21:49] 很多人都认为深度学习需要大量的时间,大量的资源和大量的数据 - 一般来说,这不是真的! 阅读更多
原文:LearningTensorFlow.com 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 自定义函数 Conway 的生命游戏是一个有趣的计算机科学模拟,它在地图上发生,有许多正方形的单元格,就像棋盘一样。 模拟以特定的时间步骤进行,并且板上的每个单元可以是 1(生存)或 0(死亡)。 经过特定的时间步骤后,每个单元格都处于生存状态或死亡状态: 如果细胞是活着的,但是有一个或零个邻居,它会由于“人口不足”而“死亡”。 如果细胞存活并且有两个或三个邻居,它就会活着。 如果细胞有三个以上的邻居,它就会因人口过多而死亡。 任何有三个邻居的死细胞都会再生。 虽然这些规则似乎非常病态,但实际的模拟非常简单,创造了非常有趣的模式。 我们将创建一个 TensorFlow 程序来管理 Conway 的生命游戏,并在此过程中了解自定义py_func函数,并生成如下动画: http://learningtensorflow.com/images/game.mp4 首先,让我们生成地图。 这是非常基本的,因为它只是一个 0 和 1 的矩阵。 我们随机生成初始地图,每次运行时都会提供不同的地图: import tensorflow as tf from matplotlib import pyplot as plt shape = (50, 50) initial_board = tf.random_uniform(shape, minval=0, maxval=2, dtype=tf.int32) with tf.Session() as session: X = session.run(initial_board) fig = plt.figure() plot = plt.imshow(X, cmap='Greys', interpolation='nearest') plt.show() 我们生成一个随机选择的 0 和 1 的initial_board,然后运行它来获取值。 然后我们使用matplotlib.pyplot来显示它,使用imshow函数,它基本上只根据一些cmap颜色方案绘制矩阵中的值。 在这种情况下,使用'Greys'会产生黑白矩阵,以及我们生命游戏的单个初始起点: 更新地图的状态 由于生命游戏的地图状态表示为矩阵,因此使用矩阵运算符更新它是有意义的。 这应该提供一种快速方法,更新给定时间点的状态。 非常有才华的 Jake VanderPlas 在使用 SciPy 和 NumPy 更新生命游戏中的特定状态方面做了一些出色的工作。 他的写作值得一读,可以在[这里]找到。 如果你对以下代码的工作原理感兴趣,我建议你阅读 Jake 的说明。 简而言之,convolve2d那行标识每个单元有多少邻居(这是计算机视觉中的常见操作符)。 我稍微更新了代码以减少行数,请参阅下面的更新后的函数: def update_board(X): # Check out the details at: https://jakevdp.github.io/blog/2013/08/07/conways-game-of-life/ # Compute number of neighbours, N = convolve2d(X, np.ones((3, 3)), mode='same', boundary='wrap') - X # Apply rules of the game X = (N == 3) | (X & (N == 2)) return X update_board函数是 NumPy 数组的函数。 它不适用于张量,迄今为止,在 TensorFlow 中没有一种好方法可以做到这一点(虽然你可以使用现有的工具自己编写它,它不是直截了当的)。 在 TensorFlow 的 0.7 版本中,添加了一个新函数py_func,它接受 python 函数并将其转换为 TensorFlow 中的节点。 在撰写本文时(3 月 22 日),0.6 是正式版,并且它没有py_func。 我建议按照 TensorFlow 的 Github 页面上的说明为你的系统安装每晚构建。 例如,对于 Ubuntu 用户,你下载相关的 wheel 文件(python 安装文件)并安装它: python -m wheel install --force ~/Downloads/tensorflow-0.7.1-cp34-cp34m-linux_x86_64.whl 请记住,你需要正确激活 TensorFlow 源(如果你愿意的话)。 最终结果应该是你安装了 TensorFlow 的 0.7 或更高版本。 你可以通过在终端中运行此代码来检查: python -c "import tensorflow as tf; print(tf.__version__)" 结果将是版本号,在编写时为 0.7.1。 在代码上: board = tf.placeholder(tf.int32, shape=shape, name='board') board_update = tf.py_func(update_board, [board], [tf.int32]) 从这里开始,你可以像往常一样,对张量操作节点(即board_update)运行初始地图。 要记住的一点是,运行board_update的结果是一个矩阵列表,即使我们的函数只定义了一个返回值。 我们通过在行尾添加[0]来获取第一个结果,我们更新的地图存储在X中。 with tf.Session() as session: initial_board_values = session.run(initial_board) X = session.run(board_update, feed_dict={board: initial_board_values})[0] 所得值X是初始配置之后更新的地图。 它看起来很像一个初始随机地图,但我们从未显示初始的(虽然你可以更新代码来绘制两个值) 循环 这是事情变得非常有趣的地方,尽管从 TensorFlow 的角度来看,我们已经为本节做了很多努力。 我们可以使用matplotlib来显示和动画,因此显示时间步骤中的模拟状态,就像我们的原始 GIF 一样。 matplotlib动画的复杂性有点棘手,但是你创建一个更新并返回绘图的函数,并使用该函数调用动画代码: import matplotlib.animation as animation def game_of_life(*args): X = session.run(board_update, feed_dict={board: X})[0] plot.set_array(X) return plot, ani = animation.FuncAnimation(fig, game_of_life, interval=200, blit=True) plt.show() 提示:你需要从早期代码中删除plt.show()才能运行! 我将把拼图的各个部分作为练习留给读者,但最终结果将是一个窗口出现,游戏状态每 200 毫秒更新一次。 如果你实现了,请给我们发消息! 1)获取完整的代码示例,使用matplotlib和 TensorFlow 生成游戏的动画 2)康威的生命游戏已被广泛研究,并有许多有趣的模式。 创建一个从文件加载模式的函数,并使用它们而不是随机地图。 我建议从 Gosper 的滑翔枪开始。 3)生命游戏的一个问题(特征?)是地图可以重复,导致循环永远不会停止。 编写一些跟踪之前游戏状态的代码,并在游戏状态重复时停止循环。 使用 GPU GPU(图形处理单元)是大多数现代计算机的组件,旨在执行 3D 图形所需的计算。 它们最常见的用途是为视频游戏执行这些操作,计算多边形向用户显示游戏。 总的来说,GPU 基本上是一大批小型处理器,执行高度并行化的计算。 你现在基本上有了一个迷你超级计算机! 注意:不是真正的超级计算机,但在许多方面有些相似。 虽然 GPU 中的每个“CPU”都很慢,但它们中有很多并且它们专门用于数字处理。 这意味着 GPU 可以同时执行许多简单的数字处理任务。 幸运的是,这正是许多机器学习算法需要做的事情。 没有 GPU 吗? 大多数现代(最近10年)的计算机都有某种形式的 GPU,即使它内置在你的主板上。 出于本教程的目的,这就足够了。 你需要知道你有什么类型的显卡。 Windows 用户可以遵循这些说明,其他系统的用户需要查阅他们系统的文档。 非 N 卡用户 虽然其他显卡可能是受支持的,但本教程仅在最近的 NVidia 显卡上进行测试。 如果你的显卡属于不同类型,我建议你寻找 NVidia 显卡来学习,购买或者借用。 如果这对你来说真的很难,请联系你当地的大学或学校,看看他们是否可以提供帮助。 如果你仍然遇到问题,请随意阅读以及使用标准 CPU 进行操作。 你将能够在以后迁移所学的东西。 安装 GPU 版的 TensorFlow 如果你之前没有安装支持 GPU 的 TensorFlow,那么我们首先需要这样做。我们在第 1 课中没有说明,所以如果你没有按照你的方式启用 GPU 支持,那就是没有了。 我建议你为此创建一个新的 Anaconda 环境,而不是尝试更新以前的环境。 在你开始之前 前往 TensorFlow 官方安装说明,并遵循 Anaconda 安装说明。这与我们在第 1 课中所做的主要区别在于,你需要为你的系统启用支持 GPU 的 TensorFlow 版本。但是,在将 TensorFlow 安装到此环境之前,你需要使用 CUDA 和 CuDNN,将计算机设置为启用 GPU 的。TensorFlow 官方文档逐步概述了这一点,但如果你尝试设置最近的 Ubuntu 安装,我推荐本教程。主要原因是,在撰写本文时(2016 年 7 月),尚未为最新的 Ubuntu 版本构建 CUDA,这意味着该过程更加手动。 使用你的 GPU 真的很简单。 至少是字面上。 只需将这个: # 起步操作 with tf.Session() as sess: # 运行你的代码 改为这个: with tf.device("/gpu:0"): # 起步操作 with tf.Session() as sess: # 运行你的代码 这个新行将创建一个新的上下文管理器,告诉 TensorFlow 在 GPU 上执行这些操作。 我们来看一个具体的例子。 下面的代码创建一个随机矩阵,其大小在命令行中提供。 我们可以使用命令行选项在 CPU 或 GPU 上运行代码: import sys import numpy as np import tensorflow as tf from datetime import datetime device_name = sys.argv[1] # Choose device from cmd line. Options: gpu or cpu shape = (int(sys.argv[2]), int(sys.argv[2])) if device_name == "gpu": device_name = "/gpu:0" else: device_name = "/cpu:0" with tf.device(device_name): random_matrix = tf.random_uniform(shape=shape, minval=0, maxval=1) dot_operation = tf.matmul(random_matrix, tf.transpose(random_matrix)) sum_operation = tf.reduce_sum(dot_operation) startTime = datetime.now() with tf.Session(config=tf.ConfigProto(log_device_placement=True)) as session: result = session.run(sum_operation) print(result) # 很难在终端上看到具有大量输出的结果 - 添加一些换行符以提高可读性。 print("\n" * 5) print("Shape:", shape, "Device:", device_name) print("Time taken:", datetime.now() - startTime) print("\n" * 5) 你可以在命令行运行此命令: python matmul.py gpu 1500 这将使用 GPU 和大小为 1500 平方的矩阵。 使用以下命令在 CPU 上执行相同的操作: python matmul.py cpu 1500 与普通的 TensorFlow 脚本相比,在运行支持 GPU 的代码时,你会注意到的第一件事是输出大幅增加。 这是我的计算机在打印出任何操作结果之前打印出来的内容。 I tensorflow/stream_executor/dso_loader.cc:108] successfully opened CUDA library libcublas.so locally I tensorflow/stream_executor/dso_loader.cc:108] successfully opened CUDA library libcudnn.so.5 locally I tensorflow/stream_executor/dso_loader.cc:108] successfully opened CUDA library libcufft.so locally I tensorflow/stream_executor/dso_loader.cc:108] successfully opened CUDA library libcuda.so.1 locally I tensorflow/stream_executor/dso_loader.cc:108] successfully opened CUDA library libcurand.so locally I tensorflow/stream_executor/cuda/cuda_gpu_executor.cc:925] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero I tensorflow/core/common_runtime/gpu/gpu_init.cc:102] Found device 0 with properties: name: GeForce GTX 950M major: 5 minor: 0 memoryClockRate (GHz) 1.124 pciBusID 0000:01:00.0 Total memory: 3.95GiB Free memory: 3.50GiB I tensorflow/core/common_runtime/gpu/gpu_init.cc:126] DMA: 0 I tensorflow/core/common_runtime/gpu/gpu_init.cc:136] 0: Y I tensorflow/core/common_runtime/gpu/gpu_device.cc:838] Creating TensorFlow device (/gpu:0) -> (device: 0, name: GeForce GTX 950M, pci bus id: 0000:01:00.0) 如果你的代码没有产生与此类似的输出,那么你没有运行支持 GPU 的 Tensorflow。或者,如果你收到ImportError: libcudart.so.7.5: cannot open shared object file: No such file or directory这样的错误,那么你还没有正确安装 CUDA 库。在这种情况下,你需要返回,遵循指南来在你的系统上安装 CUDA。 尝试在 CPU 和 GPU 上运行上面的代码,慢慢增加数量。从 1500 开始,然后尝试 3000,然后是 4500,依此类推。你会发现 CPU 开始需要相当长的时间,而 GPU 在这个操作中真的非常快! 如果你有多个 GPU,则可以使用其中任何一个。 GPU 是从零索引的 - 上面的代码访问第一个 GPU。将设备更改为gpu:1使用第二个 GPU,依此类推。你还可以将部分计算发送到一个 GPU,然后是另一个 GPU。此外,你可以以类似的方式访问计算机的 CPU - 只需使用cpu:0(或其他数字)。 我应该把什么样的操作发送给 GPU? 通常,如果该过程的步骤可以描述,例如“执行该数学运算数千次”,则将其发送到 GPU。 示例包括矩阵乘法和计算矩阵的逆。 实际上,许多基本矩阵运算是 GPU 的拿手好戏。 作为一个过于宽泛和简单的规则,应该在 CPU 上执行其他操作。 更换设备和使用 GPU 还需要付出代价。 GPU 无法直接访问你计算机的其余部分(当然,除了显示器)。 因此,如果你在 GPU 上运行命令,则需要先将所有数据复制到 GPU,然后执行操作,然后将结果复制回计算机的主存。 TensorFlow 在背后处理这个问题,因此代码很简单,但仍需要执行工作。 并非所有操作都可以在 GPU 上完成。 如果你收到以下错误,你正在尝试执行无法在 GPU 上执行的操作: Cannot assign a device to node ‘PyFunc’: Could not satisfy explicit device specification ‘/device:GPU:1’ because no devices matching that specification are registered in this process; 如果是这种情况,你可以手动将设备更改为 CPU 来执行此函数,或者设置 TensorFlow,以便在这种情况下自动更改设备。 为此,请在配置中设置allow_soft_placement为True,作为创建会话的一部分。 原型看起来像这样: with tf.Session(config=tf.ConfigProto(allow_soft_placement=True)): # 在这里运行你的图 我还建议在使用 GPU 时记录设备的放置,这样可以轻松调试与不同设备使用情况相关的问题。 这会将设备的使用情况打印到日志中,从而可以查看设备何时更改以及它对图的影响。 with tf.Session(config=tf.ConfigProto(allow_soft_placement=True, log_device_placement=True)): # 在这里运行你的图 1)设置你的计算机,将 GPU 用于 TensorFlow(或者如果你最近没有 GPU,就借一台)。 2)尝试在 GPU 上运行以前的练习的解决方案。 哪些操作可以在 GPU 上执行,哪些不可以? 3)构建一个在 GPU 和 CPU 上都使用操作的程序。 使用我们在第 5 课中看到的性能分析代码,来估计向 GPU 发送数据和从 GPU 获取数据的影响。 4)把你的代码发给我! 我很乐意看到你的代码示例,如何使用 Tensorflow,以及你找到的任何技巧。 分布式计算 TensorFlow 支持分布式计算,允许在不同的进程上计算图的部分,这些进程可能位于完全不同的服务器上! 此外,这可用于将计算分发到具有强大 GPU 的服务器,并在具有更多内存的服务器上完成其他计算,依此类推。 虽然接口有点棘手,所以让我们从头开始构建。 这是我们的第一个脚本,我们将在单个进程上运行,然后转移到多个进程。 import tensorflow as tf x = tf.constant(2) y1 = x + 300 y2 = x - 66 y = y1 + y2 with tf.Session() as sess: result = sess.run(y) print(result) 到现在为止,这个脚本不应该特别吓到你。 我们有一个常数和三个基本方程。 结果(238)最后打印出来。 TensorFlow 有点像服务器 - 客户端模型。 这个想法是你创造了一大堆能够完成繁重任务的工作器。 然后,你可以在其中一个工作器上创建会话,它将计算图,可能将其中的一部分分发到服务器上的其他集群。 为此,主工作器,主机,需要了解其他工作器。 这是通过创建ClusterSpec来完成的,你需要将其传递给所有工作器。 ClusterSpec使用字典构建,其中键是“作业名称”,每个任务包含许多工作器。 下面是这个图表看上去的样子。 以下代码创建一个ClusterSpect,其作业名称为local,和两个工作器进程。 请注意,这些代码不会启动这些进程,只会创建一个将启动它们的引用。 import tensorflow as tf cluster = tf.train.ClusterSpec({"local": ["localhost:2222", "localhost:2223"]}) 接下来,我们启动进程。 为此,我们绘制其中一个工作器的图,并启动它: server = tf.train.Server(cluster, job_name="local", task_index=1) 上面的代码在local作业下启动localhost:2223工作器。 下面是一个脚本,你可以从命令行运行来启动这两个进程。 将代码在你的计算机上保存为create_worker.py并运行python create_worker.py 0然后运行python create_worker.py 1。你需要单独的终端来执行此操作,因为脚本不会自己停止(他们正在等待指令)。 # 从命令行获取任务编号 import sys task_number = int(sys.argv[1]) import tensorflow as tf cluster = tf.train.ClusterSpec({"local": ["localhost:2222", "localhost:2223"]}) server = tf.train.Server(cluster, job_name="local", task_index=task_number) print("Starting server #{}".format(task_number)) server.start() server.join() 执行此操作后,你将发现服务器运行在两个终端上。 我们准备分发! “分发”作业的最简单方法是在其中一个进程上创建一个会话,然后在那里执行图。 只需将上面的session行更改为: with tf.Session("grpc://localhost:2222") as sess: 现在,这并没有真正分发,不足以将作业发送到该服务器。 TensorFlow 可以将进程分发到集群中的其他资源,但可能不会。 我们可以通过指定设备来强制执行此操作(就像我们在上一课中对 GPU 所做的那样): import tensorflow as tf cluster = tf.train.ClusterSpec({"local": ["localhost:2222", "localhost:2223"]}) x = tf.constant(2) with tf.device("/job:local/task:1"): y2 = x - 66 with tf.device("/job:local/task:0"): y1 = x + 300 y = y1 + y2 with tf.Session("grpc://localhost:2222") as sess: result = sess.run(y) print(result) 现在我们正在分发! 这可以通过根据名称和任务编号,为工作器分配任务来实现。 格式为: /job:JOB_NAME/task:TASK_NUMBER 通过多个作业(即识别具有大型 GPU 的计算机),我们可以以多种不同方式分发进程。 映射和归约 MapReduce 是执行大型操作的流行范式。 它由两个主要步骤组成(虽然在实践中还有一些步骤)。 第一步称为映射,意思是“获取列表,并将函数应用于每个元素”。 你可以在普通的 python 中执行这样的映射: def myfunction(x): return x + 5 map_result = map(myfunction, [1, 2, 3]) print(list(map_result)) 第二步是归约,这意味着“获取列表,并使用函数将它们组合”。 常见的归约操作是求和 - 即“获取数字列表并通过将它们全部加起来组合它们”,这可以通过创建相加两个数字的函数来执行。 reduce的原理是获取列表的前两个值,执行函数,获取结果,然后使用结果和下一个值执行函数。 总之,我们将前两个数字相加,取结果,加上下一个数字,依此类推,直到我们到达列表的末尾。 同样,reduce是普通 python 的一部分(尽管它不是分布式的): from functools import reduce def add(a, b): return a + b print(reduce(add, [1, 2, 3])) 译者注:原作者这里的话并不值得推荐,比如for你更应该使用reduce,因为它更安全。 回到分布式 TensorFlow,执行map和reduce操作是许多非平凡程序的关键构建块。 例如,集成学习可以将单独的机器学习模型发送给多个工作器,然后组合分类结果来形成最终结果。另一个例子是一个进程。 这是我们将分发的另一个基本脚本: import numpy as np import tensorflow as tf x = tf.placeholder(tf.float32, 100) mean = tf.reduce_mean(x) with tf.Session() as sess: result = sess.run(mean, feed_dict={x: np.random.random(100)}) print(result) import numpy as np import tensorflow as tf x = tf.placeholder(tf.float32, 100) mean = tf.reduce_mean(x) with tf.Session() as sess: result = sess.run(mean, feed_dict={x: np.random.random(100)}) print(result) 转换为分布式版本只是对先前转换的更改: import numpy as np import tensorflow as tf cluster = tf.train.ClusterSpec({"local": ["localhost:2222", "localhost:2223"]}) x = tf.placeholder(tf.float32, 100) with tf.device("/job:local/task:1"): first_batch = tf.slice(x, [0], [50]) mean1 = tf.reduce_mean(first_batch) with tf.device("/job:local/task:0"): second_batch = tf.slice(x, [50], [-1]) mean2 = tf.reduce_mean(second_batch) mean = (mean1 + mean2) / 2 with tf.Session("grpc://localhost:2222") as sess: result = sess.run(mean, feed_dict={x: np.random.random(100)}) print(result) 如果你从映射和归约的角度来考虑它,你会发现分发计算更容易。 首先,“我怎样才能将这个问题分解成可以独立解决的子问题?” - 这就是你的映射。 第二,“我如何将答案结合起来来形成最终结果?” - 这就是你的归约。 在机器学习中,映射最常用的场景就是分割数据集。 线性模型和神经网络通常都非常合适,因为它们可以单独训练,然后再进行组合。 1)将ClusterSpec中的local更改为其他内容。 你还需要在脚本中进行哪些更改才能使其正常工作? 2)计算平均的脚本目前依赖于切片大小相同的事实。 尝试使用不同大小的切片并观察错误。 通过使用tf.size和以下公式来组合切片的平均值来解决此问题: overall_average = ((size_slice_1 * mean_slice_1) + (size_slice_2 * mean_slice_2) + ...) / total_size 3)你可以通过修改设备字符串来指定远程计算机上的设备。 例如,/job:local/task:0/gpu:0会定位local作业的 GPU。 创建一个使用远程 GPU 的作业。 如果你有备用的第二台计算机,请尝试通过网络执行此操作。
原文:LearningTensorFlow.com 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 广播 当我们操作不同维度的数组时,它们可以以不同的方式组合,无论是逐元素还是通过广播。 让我们从头开始,构建更复杂的例子。 在下面的示例中,我们有表示单个数字的 TensorFlow 常量。 import tensorflow as tf a = tf.constant(3, name='a') with tf.Session() as session: print(session.run(a)) 这里没什么惊喜! 我们也可以进行计算,例如将其加上另一个数字: a = tf.constant(3, name='a') b = tf.constant(4, name='b') add_op = a + b with tf.Session() as session: print(session.run(add_op)) 让我们将这个概念扩展到一个数字列表。 首先,让我们创建一个包含三个数字的列表,然后创建另一个数字列表: a = tf.constant([1, 2, 3], name='a') b = tf.constant([4, 5, 6], name='b') add_op = a + b with tf.Session() as session: print(session.run(add_op)) 这称为逐元素操作,其中依次考虑每个列表中的元素,将它们相加,然后合并结果。 如果我们将这个列表和仅仅一个数字相加,会发生什么? a = tf.constant([1, 2, 3], name='a') b = tf.constant(4, name='b') add_op = a + b with tf.Session() as session: print(session.run(add_op)) 这是你所期望的吗? 这被称为广播操作。 我们的主要对象引用是a,它是一个数字列表,也称为数组或一维向量。 与单个数字(称为标量)相加会产生广播操作,其中标量将与列表的每个元素相加。 现在让我们看一个扩展,它是一个二维数组,也称为矩阵。 这个额外的维度可以被认为是“列表的列表”。 换句话说,列表是标量的组合,矩阵是列表的列表。 也就是说,矩阵上的操作如何工作? a = tf.constant([[1, 2, 3], [4, 5, 6]], name='a') b = tf.constant([[1, 2, 3], [4, 5, 6]], name='b') add_op = a + b with tf.Session() as session: print(session.run(add_op)) 这是逐元素的。 如果我们加上一个标量,结果是可以预测的: a = tf.constant([[1, 2, 3], [4, 5, 6]], name='a') b = tf.constant(100, name='b') add_op = a + b with tf.Session() as session: print(session.run(add_op)) 事情开始变得棘手。 如果我们将一维数组与二维矩阵相加会发生什么? a = tf.constant([[1, 2, 3], [4, 5, 6]], name='a') b = tf.constant([100, 101, 102], name='b') add_op = a + b with tf.Session() as session: print(session.run(add_op)) 在这种情况下,数组被广播为矩阵的形状,导致数组与矩阵的每一行相加。 使用此术语,矩阵是行的列表。 如果我们不想要这个,而是想将矩阵的列与b相加呢? a = tf.constant([[1, 2, 3], [4, 5, 6]], name='a') b = tf.constant([100, 101,], name='b') add_op = a + b with tf.Session() as session: print(session.run(add_op)) 这不起作用,因为 TensorFlow 试图按照行广播。 它不能这样做,因为b中的值的数量(2)与每行中的标量数量(3)不同。 我们可以通过从列表中创建一个新矩阵来执行此操作。 a = tf.constant([[1, 2, 3], [4, 5, 6]], name='a') b = tf.constant([[100], [101]], name='b') add_op = a + b with tf.Session() as session: print(session.run(add_op)) 这里发生了什么? 要理解这一点,让我们看一下矩阵形状。 a.shape TensorShape([Dimension(2), Dimension(3)]) b.shape TensorShape([Dimension(2), Dimension(1)]) 你可以从这两个示例中看到a有两个维度,第一个大小为 2,第二个大小为 3。换句话说,它有两行,每行有三个标量。 我们的常数b也有两个维度,两行,每行一个标量。如果有一行两个标量,这与列表不同,也与矩阵不同。 由于形状在第一维匹配,而第二维不匹配的事实,广播发生在列而不是行中。 广播规则的更多信息请参见此处。 创建一个三维矩阵。 如果将其与标量,数组或矩阵相加,会发生什么? 使用tf.shape(这是一个操作)在图的操作期间获得常量的形状。 考虑更高维矩阵的用例。 换句话说,在哪里你可能需要 4D 矩阵,甚至是 5D 矩阵? 提示:考虑集合而不是单个对象。 随机性 机器学习模型是许多变量的复杂集合,但必须经过训练才能找到好的值。这也意味着必须将这些“权重”设置为初始值。一种选择是从所有权重为零开始。但是,这会在算法上引起问题 - 基本上,错误的梯度无法修复错误。相反,我们经常将这些权重设置为随机值。然后,模型学习并调整。 TensorFlow 有许多用于生成随机数的内置方法。这包括我们熟悉的分布,如“均匀”,以及你可能听说过的其他分布,如“正态”分布。均匀分布就像你掷骰子时得到的东西那样 - 有一组值,它们都是等可能的。正态分布是统计课程中教授的标准,其中数据具有更可能的平均值,以及围绕它的“钟形”曲线。我们将看到的,其他的也包括在内。 在本节中,我们将创建一个基本的辅助函数,它只运行一个 TensorFlow 变量。这个小函数非常有用!它创建一个会话,初始化变量并为我们运行它。它仅限于单个变量,因此对于较大的程序可能没有用。 import tensorflow as tf def run_variable(variable): tf.initialize_all_variables() with tf.Session() as sess: return sess.run(variable) 希望现在这对你来说都很熟悉。 如果没有,请再看看第一章,开始吧。 让我们从一个基本的分布开始,均匀分布。 my_distribution = tf.random_uniform((6, 4), seed=42) uniform = run_variable(my_distribution) 这为我们提供了一个 6 乘 4 的张量(随机值的更多信息,请参阅上一节)。为了可视化,我们可以使用直方图: from matplotlib import pyplot as plt plt.hist(uniform.flatten()) plt.show() 请注意,如果你使用的是 Jupyter 笔记本,请使用%matplotlib inline并删除plt.show()行。 所得图像显示了图片,虽然还不是很清楚… 此直方图显示可能的值介于 0 和 1 之间。每个值应该是等可能的,但它看起来并不是那样。 原因是我们只选择了少量的值。 如果我们增加数组的大小,它会变得更加均匀。 large_normal = tf.random_uniform((600, 400), seed=42) large_uniform = run_variable(large_normal) plt.hist(large_uniform.flatten()) plt.show() 更均匀了! 如果你没有任何其他信息,对于在机器学习模型中初始化权重,均匀分布非常有用。 它也是一个“有界”分布,它具有设定的最小值和最大值,随机值不能超出该范围。 要更改范围,例如更改为 0 和 10,请乘以范围并添加最小值。 在课程结束时有一个练习。 另一种常用的分布是正态分布,在 TensorFlow 中实现为random_normal函数: distribution = tf.random_normal((600, 4), seed=42) normal = run_variable(distribution) plt.hist(normal.flatten()) plt.show() 默认情况下,此分布的平均值约为 0,标准差为 1。这些值不受限制,但越来越不可能偏离平均值,标准差设置了可能性减小的速率。 在实践中,大约 60% 的值落在距离平均值一个标准差的“半径”内,并且 99% 落在 4 个标准差内。 均值和标准差是random_normal函数的参数。 例如,身高可近似建模为正态分布,平均值约为 170cm,标准差约为 15cm。 distribution = tf.random_normal((10000,), seed=42, mean=170, stddev=15) normal = run_variable(distribution) plt.hist(normal.flatten()) plt.show() 到目前为止,我们的直方图使用matplotlib生成。 我们也可以使用 TensorFlow 来创建它们!histogram_fixed_width函数接受值的列表(如我们的随机值),范围和要计算的桶数。 然后计算每个桶的范围内有多少个值,并将结果作为数组返回。 import numpy as np bins = tf.histogram_fixed_width(normal, (normal.min(), normal.max()), nbins=20) histogram_bins = run_variable(bins) x_values = np.linspace(normal.min(), normal.max(), len(histogram_bins)) plt.bar(x_values, histogram_bins,) 在plt.bar调用中,我们再次手动生成bin值,然后使用条形图将这些值绘制为x值,并使用histogram_bins作为高度。 这是正确的,但看起来不对。 直方图的值在那里,但宽度非常窄(我们的箱桶仅由单个值表示)。 我们来解决这个问题: bar_width = (normal.max() - normal.min()) / len(histogram_bins) plt.bar(x_values, histogram_bins, width=bar_width) 使用均匀分布建模单次掷骰子。 绘制结果来确保其符合你的期望 使用单个图中的纯 TensorFlow 调用替换本课程的最后一个代码块。 换句话说,使用 TensorFlow 概念来替换.min(),.max()和len调用。 只有绘图在没有 TensorFlow 的情况下进行! 线性方程 通过tf.solve函数,TensorFlow 可以求解线性方程组。 你可能会将这些视为连接的方程,如下所示: 这些类型的线性方程用于数学中的许多问题,从优化工厂输出到几何。 你可以使用多种方法解决这些方程,但在本课中,我们将了解如何使用tf.solve为我们执行此操作。 我将专注于几何。 这是位于二维(x, y)空间的两个点,p1和p2: 这是他们在图上的样子: 要在 TensorFlow 中执行此操作,我们首先设置线性方程组,我们的点位于中心。 首先,我们创建我们的点矩阵。 第一行对应于第一个点,第二行对应于第二个点。 同样,第一列是x值,而第二列是y值。 import tensorflow as tf # 点 1 x1 = tf.constant(2, dtype=tf.float32) y1 = tf.constant(9, dtype=tf.float32) point1 = tf.stack([x1, y1]) # 点 2 x2 = tf.constant(-1, dtype=tf.float32) y2 = tf.constant(3, dtype=tf.float32) point2 = tf.stack([x2, y2]) # 将点组合为数组 X = tf.transpose(tf.stack([point1, point2])) 直线的方程是: 重新排列方程(5),使x和y在同一侧,我们得到以下结果: 我们的任务是在给定观测点的情况下,找到上面的方程中的a和b的值。 我们可以通过取点数组的逆并将其乘以一个矩阵,来轻易做到这一点。 使用矩阵(因为我们使用的是 TensorFlow),如果X是我们观察点的矩阵,而A是我们需要学习的参数,我们设置一个系统: 接下来要学习的参数就是: 矩阵B很简单,适当广播的数字 1,它源于上面方程的右侧。 矩阵A是上面方程 3 中的参数。 B = tf.ones((1, 2), dtype=tf.float32) parameters = tf.matmul(B, tf.matrix_inverse(X)) with tf.Session() as session: A = session.run(parameters) 最后一步是从上面的方程(5)中找到我们的a和b值,即从这些参数转换(符合方程(7))。 b = 1 / A[0][1] a = -b * A[0][0] print("Equation: y = {a}x + {b}".format(a=a, b=b)) 这个解决方案很好地包含在tf.solve函数中。 为了看到它,让我们看另一个例子。 这是一个圆圈: 以下是圆圈上的三个观察点: 圆的规范方程是: 为了求解参数d,e和f,我们创建另一个点数组,并用 1 填充它来创建一个方阵。 我们正在寻找三个参数,因此我们的A矩阵必须具有形状(3, 3)。 由于这个方程的平方部分没有参数,当我们有x和y的观测值时,我们的方程变得有点不同: 因此,我们的A矩阵由x和y值(以及另一列 1)组成,我们的B矩阵是负的x和y的平方和。 import tensorflow as tf points = tf.constant([[2, 1], [0, 5], [-1, 2]], dtype=tf.float64) A = tf.constant([ [2, 1, 1], [0, 5, 1], [-1, 2, 1] ], dtype='float64') B = -tf.constant([[5], [25], [5]]) 然后我们使用tf.matrix_solve来找到我们的X数组,这是我们方程的参数。 在会话中运行它,我们得到三个值,即D,E和F。 X = tf.matrix_solve(A, B) with tf.Session() as session: result = session.run(X) D, E, F = result.flatten() print("Equation: x**2 + y**2 + {D}x + {E}y + {F} = 0".format(**locals())) 1)求解包含以下三点的圆:P(2,1), Q(0,5), R(-1,2) 2)下面给出椭圆的一般形式。 解决以下几点(解决这个方程需要五点): 椭圆的一般形式: 观测点: 3D 中的 TensorFlow TensorFlow 不仅仅是一个深度学习库 - 它是一个但数值操作库,因此它可以执行许多其他库可以执行的任务。 在本课中,我们将介绍如何使用 TensorFlow 对 3D 对象执行操作。 3D 对象可以被建模为三维空间中的一系列三角形,我们通常将其称为(x, y, z)。 这些名称不是必需的,但通常使用。 从这些 3D 点中的三个创建三角形。 点本身可以表示为大小为(3,)的向量。 这些数组是一个大小为(n, 3),的矩阵,其中n是我们拥有的点数。 让我们深入去看一个基本的立方体。 我们稍后将需要此功能,所以让我们创建一个绘制基本形状的函数: from mpl_toolkits.mplot3d import Axes3D import numpy as np from matplotlib import cm import matplotlib.pyplot as plt from scipy.spatial import Delaunay def plot_basic_object(points): """绘制一个基本对象,假设它是凸的而不是太复杂""" tri = Delaunay(points).convex_hull fig = plt.figure(figsize=(8, 8)) ax = fig.add_subplot(111, projection='3d') S = ax.plot_trisurf(points[:,0], points[:,1], points[:,2], triangles=tri, shade=True, cmap=cm.Blues,lw=0.5) ax.set_xlim3d(-5, 5) ax.set_ylim3d(-5, 5) ax.set_zlim3d(-5, 5) plt.show() 如果你正在使用 Jupyter 笔记本,我建议运行这一行代码,它为你提供了一个非常棒的交互式 3D 绘图。 左键单击并拖动来左右移动,右键单击并拖动来放大或缩小。 %matplotlib notebook 现在让我们创建一个形状。 下面的函数将返回组成立方体的六个点。 如果你回到上一个函数,你将看到 Delaunay 线,它将这些点转换成三角形,以便我们可以渲染它们。 import numpy as np def create_cube(bottom_lower=(0, 0, 0), side_length=5): """从给定的左下角点(最小的 x,y,z 值)开始创建一个立方体""" bottom_lower = np.array(bottom_lower) points = np.vstack([ bottom_lower, bottom_lower + [0, side_length, 0], bottom_lower + [side_length, side_length, 0], bottom_lower + [side_length, 0, 0], bottom_lower + [0, 0, side_length], bottom_lower + [0, side_length, side_length], bottom_lower + [side_length, side_length, side_length], bottom_lower + [side_length, 0, side_length], bottom_lower, ]) return points 现在让我们把这些碎片放在一起,看看它是什么样的: cube_1 = create_cube(side_length=2) plot_basic_object(cube_1) 我只是在这里显示一个图像,但是你可以看到立方体,它已被我们的代码变成三角形并且颜色不同(取决于z值)。 这很好,但现在让我们使用 TensorFlow 对此进行一些操作。 平移 平移是一个简单的动作:向上/向下,向左/向右,向前/向后,或这些的某种组合。 它是通过简单地向每个点添加一个向量来创建的。 如果向所有点添加相同的向量,则整个对象将一致地移动。 查看我们关于广播的章节,了解当我们将大小为(3,)的平移向量添加到大小(n, 3)的点矩阵时会发生什么。 import tensorflow as tf def translate(points, amount): return tf.add(points, amount) points = tf.constant(cube_1, dtype=tf.float32) # 更新此处的值来移动多维数据集。 translation_amount = tf.constant([3, -3, 0], dtype=tf.float32) translate_op = translate(points, translation_amount) with tf.Session() as session: translated_cube = session.run(translate_op) plot_basic_object(translated_cube) 旋转 通过创建点积或旋转矩阵和原点来形成旋转。 旋转对象首先需要你确定要旋转的轴。 要围绕特定轴旋转,请将该轴的值设置为 0,相关轴中的值为 1。 你需要三个矩阵: 沿x轴旋转 [[1, 0, 0], [0, cos \theta, sin \theta], [0, -sin \theta, cos \theta]] 沿y轴旋转 [[cos \theta, 0, -sin \theta], [0, 1, 0], [sin \theta, 0, cos \theta]] 沿z轴旋转 [[cos \theta, sin \theta, 0], [-sin \theta, cos \theta, 0], [0, 0, 1]] def rotate_around_z(points, theta): theta = float(theta) rotation_matrix = tf.stack([[tf.cos(theta), tf.sin(theta), 0], [-tf.sin(theta), tf.cos(theta), 0], [0, 0, 1]]) return tf.matmul(tf.to_float(points), tf.to_float(rotation_matrix)) with tf.Session() as session: result = session.run(rotate_around_z(cube_1, 75)) plot_basic_object(result) 通过这些简单,但是可以大规模组合的矩阵操作,你可以像这样为 3D 对象创建一系列的变换。 此概念可以用于实现剪切,缩放,交叉等。 GPU 非常擅长进行这些转换,这些转换恰好与数据分析工作(如深度学习)所需的相同类型的转换相关。 因此,TensorFlow 可以很好地配合 GPU,处理 3D 对象以及深度学习任务。 创建不同的对象,例如四棱锥或者六棱柱。 如果你不确定如何开始,请先从棱柱开始,然后先在2D中创建它。 围绕x轴和y轴旋转对象。 你可以将旋转组合到单个变换矩阵中。 为此,只需计算旋转的点积。 对于问题 3,顺序是否重要? 剪切矩阵是具有非对角线值的单位矩阵。 一个例子如下。 创建剪切矩阵并测试不同的值。 [[1, 0.5, 0], [0, 1, 0], [0, 0, 1]] 线性模型的分类 在本课中,我们将了解使用 TensorFlow 进行机器学习。 我们将创建自己的线性分类器,并使用 TensorFlow 的内置优化算法来训练它。 首先,我们将查看数据以及我们要做的事情。 对于那些刚接触机器学习的人来说,我们尝试执行的任务称为监督机器学习或分类。 任务是尝试计算一些输入数据和输出值之间的关系。 实际上,输入数据可以是测量值,例如高度或重量,输出值可以是预期的预测值,例如“cat”或“dog”。 这里的课程扩展自我们的课程“收敛”,在后面的章节中。 我建议你先完成那个课程。 让我们创建并可视化一些数据: from sklearn.datasets import make_blobs import numpy as np from sklearn.preprocessing import OneHotEncoder X_values, y_flat = make_blobs(n_features=2, n_samples=800, centers=3, random_state=500) y = OneHotEncoder().fit_transform(y_flat.reshape(-1, 1)).todense() y = np.array(y) %matplotlib inline from matplotlib import pyplot as plt # 可选的行:将默认数字大小设置得稍大。 plt.rcParams['figure.figsize'] = (24, 10) plt.scatter(X_values[:,0], X_values[:,1], c=y_flat, alpha=0.4, s=150) 在这里,我们有三种数据,黄色,蓝色和紫色。 它们绘制在两个维度上,我们称之为x0x0和x1x1。 这些值存储在X数组中。 当我们执行机器学习时,有必要将数据拆分为我们用于创建模型的训练集和用于评估它的测试集。 如果我们不这样做,那么我们可以简单地创建一个“作弊分类器”,只记得我们的训练数据。 通过拆分,我们的分类器必须学习输入(绘图上的位置)和输出之间的关系。 from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test, y_train_flat, y_test_flat = train_test_split(X_values, y, y_flat) X_test += np.random.randn(*X_test.shape) * 1.5 现在我们绘制测试数据。 从训练数据中学习位置和颜色之间的关系之后,将给予分类器以下几个点,并且将评估它对点着色的准确度。 #plt.scatter(X_train[:,0], X_train[:,1], c=y_train_flat, alpha=0.3, s=150) plt.plot(X_test[:,0], X_test[:,1], 'rx', markersize=20) 创建模型 我们的模型将是一个简单的线性分类器。 这意味着它将在三种颜色之间绘制直线。 一条线上方的点被赋予一种颜色,而一条线下方的点被赋予另一种颜色。 我们将这些称为决策直线,尽管它们通常被称为决策边界,因为其他模型可以学习比线更复杂的形状。 为了在数学上表示我们的模型,我们使用以下等式: Y = XW + b 我们的权重W是(n_features, n_classes)矩阵,表示我们模型中的学习权重。 它决定了决策直线的位置。 X是(n_rows, n_features)矩阵,并且是位置数据 - 给定点位于图上。 最后,b是(1, n_classes)向量,并且是偏差。 我们需要这样,以便我们的线不必经过点(0,0),使我们能够在图上的任何位置“绘制”直线。 X中的点是固定的 - 这些是训练或测试数据,称为观测数据。 W和b的值是我们模型中的参数,我们可以控制这些值。 为这些值选择好的值,可以为我们提供良好的决策线。 在我们的模型中为参数选择好的值的过程,称为训练算法,并且是机器学习中的“学习”。 让我们从上面得到我们的数学模型,并将其转换为 TensorFlow 操作。 import tensorflow as tf n_features = X_values.shape[1] n_classes = len(set(y_flat)) weights_shape = (n_features, n_classes) W = tf.Variable(dtype=tf.float32, initial_value=tf.random_normal(weights_shape)) # Weights of the model X = tf.placeholder(dtype=tf.float32) Y_true = tf.placeholder(dtype=tf.float32) bias_shape = (1, n_classes) b = tf.Variable(dtype=tf.float32, initial_value=tf.random_normal(bias_shape)) Y_pred = tf.matmul(X, W) + b 上面的Y_pred张量代表我们的数学模型。通过传入观测数据(X),我们可以得到预期值,在我们的例子中,是给定点的预期颜色。请注意偏差使用广播在所有预测中应用。 Y_pred中的实际值由“似然”组成,模型将为给定点选择每个类的似然,生成(n_rows, n_classes)大小的矩阵。它们不是真正的似然,但我们可以通过找到最大值,来找出我们的模型认为的最有可能的类。 接下来,我们需要定义一个函数来评估给定权重集的好坏程度。请注意,我们尚未学习权重,只是给出了随机值。 TensorFlow 具有内置的损失函数,可以接受预测的输出的(即模型产生的值)与实际值(我们首次创建测试集时创建的真实情况)。我们比较它们,并评估我们的模型表现如何。我们称之为损失函数,因为我们做得越差,值越高 - 我们试图将损失最小化。 loss_function = tf.losses.softmax_cross_entropy(Y_true, Y_pred) 最后一步是创建一个优化步骤,该步骤接受我们的损失函数,并找到给定变量的最小化损失的值。 请注意,loss函数引用Y_true,后者又引用W和b。 TensorFlow 选择此关系,并更改这些变量中的值来寻找良好的值。 learner = tf.train.GradientDescentOptimizer(0.1).minimize(loss_function) 现在开始训练了! 我们在循环中遍历学习器,来找到最佳权重。 每次循环中,前一循环的学习权重会在下一个循环中略有改善。 前一行代码中的0.1是学习率。 如果增加该值,算法学得更快。 但是,较小的值通常会收敛到更好的值。 当你查看模型的其他方面时,值为0.1是一个很好的起点。 每次循环中,我们通过占位符将我们的训练数据传递给学习器。 每隔 100 个循环,我们通过将测试数据直接传递给损失函数,来了解我们的模型是如何学习的。 with tf.Session() as sess: sess.run(tf.global_variables_initializer()) for i in range(5000): result = sess.run(learner, {X: X_train, Y_true: y_train}) if i % 100 == 0: print("Iteration {}:\tLoss={:.6f}".format(i, sess.run(loss_function, {X: X_test, Y_true: y_test}))) y_pred = sess.run(Y_pred, {X: X_test}) W_final, b_final = sess.run([W, b]) predicted_y_values = np.argmax(y_pred, axis=1) predicted_y_values h = 1 x_min, x_max = X_values[:, 0].min() - 2 * h, X_values[:, 0].max() + 2 * h y_min, y_max = X_values[:, 1].min() - 2 * h, X_values[:, 1].max() + 2 * h x_0, x_1 = np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h)) decision_points = np.c_[x_0.ravel(), x_1.ravel()] 有点复杂,但我们正在有效地创建一个二维网格,涵盖x0和x1的可能值。 # 我们在 NumPy 中重建我们的模型 Z = np.argmax(decision_points @ W_final[[0,1]] + b_final, axis=1) # 创建 x_0 和 x_1 值的等高线图 Z = Z.reshape(xx.shape) plt.contourf(x_0, x_1, Z, alpha=0.1) plt.scatter(X_train[:,0], X_train[:,1], c=y_train_flat, alpha=0.3) plt.scatter(X_test[:,0], X_test[:,1], c=predicted_y_values, marker='x', s=200) plt.xlim(x_0.min(), x_0.max()) plt.ylim(x_1.min(), x_1.max()) 你就实现了它! 我们的模型会将黄色区域中的任何东西分类为黄色,依此类推。 如果覆盖实际测试值(存储在y_test_flat中),则可以高亮任何差异。 绘制迭代和损失之间的关系。 出现什么样的形状,你认为它将如何继续? 使用 TensorBoard,将图写入文件,并查看 TensorBoard 中变量的值。 更多信息请参阅其余教程。 通过在传递到线性模型之前对X执行一些变换来创建非线性模型。 这可以通过多种方式完成,你的模型的准确性将根据你的选择而改变。 使用以下代码加载 64 维(称为数字)的数据集,并将其传递给分类器。 你得到了什么预测准确度? from sklearn.datasets import load_digits digits = load_digits() X = digits.data y = digits.target
原文:LearningTensorFlow.com 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 变量 TensorFlow 是一种表示计算的方式,直到请求时才实际执行。 从这个意义上讲,它是一种延迟计算形式,它能够极大改善代码的运行: 更快地计算复杂变量 跨多个系统的分布式计算,包括 GPU。 减少了某些计算中的冗余 我们来看看实际情况。 首先,一个非常基本的 python 脚本: x = 35 y = x + 5 print(y) 这个脚本基本上只是“创建一个值为35的变量x,将新变量y的值设置为它加上5,当前为40,并将其打印出来”。 运行此程序时将打印出值40。 如果你不熟悉 python,请创建一个名为basic_script.py的新文本文件,并将该代码复制到该文件中。将其保存在你的计算机上并运行它: python basic_script.py 请注意,路径(即basic_script.py)必须指向该文件,因此如果它位于Code文件夹中,则使用: python Code/basic_script.py 此外,请确保已激活 Anaconda 虚拟环境。 在 Linux 上,这将使你的提示符看起来像: (tensorenv)username@computername:~$ 如果起作用,让我们将其转换为 TensorFlow 等价形式。 import tensorflow as tf x = tf.constant(35, name='x') y = tf.Variable(x + 5, name='y') print(y) 运行之后,你会得到一个非常有趣的输出,类似于<tensorflow.python.ops.variables.Variable object at 0x7f074bfd9ef0>。 这显然不是40的值。 原因在于,我们的程序实际上与前一个程序完全不同。 这里的代码执行以下操作: 导入tensorflow模块并将其命名为tf 创建一个名为x的常量值,并为其赋值35 创建一个名为y的变量,并将其定义为等式x + 5 打印y的等式对象 微妙的区别是,y没有像我们之前的程序那样,给出x + 5的当前值”。 相反,它实际上是一个等式,意思是“当计算这个变量时,取x的值(就像那样)并将它加上5”。 y值的计算在上述程序中从未实际执行。 我们来解决这个问题: import tensorflow as tf x = tf.constant(35, name='x') y = tf.Variable(x + 5, name='y') model = tf.global_variables_initializer() with tf.Session() as session: session.run(model) print(session.run(y)) 我们删除了print(y)语句,而是创建了一个会话,并实际计算了y的值。这里有相当多的样板,但它的工作原理如下: 导入tensorflow模块并将其命名为tf 创建一个名为x的常量值,并为其赋值35 创建一个名为y的变量,并将其定义为等式x + 5 使用tf.global_variables_initializer()初始化变量(我们将在此详细介绍) 创建用于计算值的会话 运行第四步中创建的模型 仅运行变量y并打印出其当前值 上面的第四步是一些魔术发生的地方。在此步骤中,将创建变量之间的依赖关系的图。在这种情况下,变量y取决于变量x,并且通过向其添加5来转换它的值。请记住,直到第七步才计算该值,在此之前,仅计算等式和关系。 1)常量也可以是数组。预测此代码将执行的操作,然后运行它来确认: import tensorflow as tf x = tf.constant([35, 40, 45], name='x') y = tf.Variable(x + 5, name='y') model = tf.global_variables_initializer() with tf.Session() as session: session.run(model) print(session.run(y)) 生成包含 10,000 个随机数的 NumPy 数组(称为x),并创建一个存储等式的变量。 你可以使用以下代码生成 NumPy 数组: import numpy as np data = np.random.randint(1000, size=10000) 然后可以使用data变量代替上面问题 1 中的列表。 作为一般规则,NumPy 应该用于更大的列表/数字数组,因为它具有比列表更高的内存效率和更快的计算速度。 它还提供了大量的函数(例如计算均值),通常不可用于列表。 3)你还可以在循环更新的变量,稍后我们将这些变量用于机器学习。 看看这段代码,预测它会做什么(然后运行它来检查): import tensorflow as tf x = tf.Variable(0, name='x') model = tf.global_variables_initializer() with tf.Session() as session: session.run(model) for i in range(5): x = x + 1 print(session.run(x)) 4)使用上面(2)和(3)中的代码,创建一个程序,计算以下代码行的“滑动”平均值:np.random.randint(1000)。 换句话说,保持循环,并在每个循环中,调用np.random.randint(1000)一次,并将当前平均值存储在在每个循环中不断更新变量中。 5)使用 TensorBoard 可视化其中一些示例的图。 要运行 TensorBoard,请使用以下命令:tensorboard --logdir=path/to/log-directory。 import tensorflow as tf x = tf.constant(35, name='x') print(x) y = tf.Variable(x + 5, name='y') with tf.Session() as session: merged = tf.summary.merge_all() writer = tf.summary.FileWriter("/tmp/basic", session.graph) model = tf.global_variables_initializer() session.run(model) print(session.run(y)) 要了解 Tensorboard 的更多信息,请访问我们的可视化课程。 数组 在本教程中,我们将处理图像,以便可视化数组的更改。 数组是强大的结构,我们在前面的教程中简要介绍了它。 生成有趣的数组可能很困难,但图像提供了很好的选择。 首先,下载此图像到你的计算机(右键单击,并寻找选项“图片另存为”)。 此图片来自维基共享的用户 Uoaei1。 要处理图像,我们需要matplotlib。 我们还需要pillow库,它会覆盖已弃用的 PIL 库来处理图像。 你可以使用 Anaconda 的安装方法在你的环境中安装它们: conda install matplotlib pillow 要加载图像,我们使用matplotlib的图像模块: import matplotlib.image as mpimg import os # 首先加载图像 dir_path = os.path.dirname(os.path.realpath(__file__)) filename = dir_path + "/MarshOrchid.jpg" # 加载图像 image = mpimg.imread(filename) # 打印它的形状 print(image.shape) 上面的代码将图像作为 NumPy 数组读入,并打印出大小。 请注意,文件名必须是下载的图像文件的完整路径(绝对路径或相对路径)。 你会看到输出,即(5528, 3685, 3)。 这意味着图像高 5528 像素,宽 3685 像素,3 种颜色“深”。 你可以使用pyplot查看当前图像,如下所示: import matplotlib.pyplot as plt plt.imshow(image) plt.show() 现在我们有了图像,让我们使用 TensorFlow 对它进行一些更改。 几何操作 我们将要执行的第一个转换是转置,将图像逆时针旋转 90 度。 完整的程序如下,其中大部分是你见过的。 import tensorflow as tf import matplotlib.image as mpimg import matplotlib.pyplot as plt import os # 再次加载图像 dir_path = os.path.dirname(os.path.realpath(__file__)) filename = dir_path + "/MarshOrchid.jpg" image = mpimg.imread(filename) # 创建 TF 变量 x = tf.Variable(image, name='x') model = tf.global_variables_initializer() with tf.Session() as session: x = tf.transpose(x, perm=[1, 0, 2]) session.run(model) result = session.run(x) plt.imshow(result) plt.show() 转置操作的结果: 新东西是这一行: x = tf.transpose(x, perm=[1, 0, 2]) 该行使用 TensorFlow 的transpose方法,使用perm参数交换轴 0 和 1(轴 2 保持原样)。 我们将要做的下一个操作是(左右)翻转,将像素从一侧交换到另一侧。 TensorFlow 有一个称为reverse_sequence的方法,但签名有点奇怪。 这是文档所说的内容(来自该页面): tf.reverse_sequence( input, seq_lengths, seq_axis=None, batch_axis=None, name=None, seq_dim=None, batch_dim=None ) 反转可变长度切片。 这个操作首先沿着维度batch_axis对input却偏,并且对于每个切片i,沿着维度seq_axis反转第一个seq_lengths [i]元素。 seq_lengths的元素必须满足seq_lengths [i] <= input.dims [seq_dim],而seq_lengths必须是长度为input.dims [batch_dim]的向量。 然后,输入切片i给出了沿维度batch_axis的输出切片i,其中第一个seq_lengths [i]切片沿着维度seq_axis被反转。 对于这个函数,最好将其视为: 根据batch_dim迭代数组。 设置batch_dim = 0意味着我们遍历行(从上到下)。 对于迭代中的每个项目 对第二维切片,用seq_dim表示。 设置seq_dim = 1意味着我们遍历列(从左到右)。 迭代中第n项的切片由seq_lengths中的第n项表示 让我们实际看看它: import numpy as np import tensorflow as tf import matplotlib.image as mpimg import matplotlib.pyplot as plt import os # First, load the image again dir_path = os.path.dirname(os.path.realpath(__file__)) filename = dir_path + "/MarshOrchid.jpg" image = mpimg.imread(filename) height, width, depth = image.shape # Create a TensorFlow Variable x = tf.Variable(image, name='x') model = tf.global_variables_initializer() with tf.Session() as session: x = tf.reverse_sequence(x, [width] * height, 1, batch_dim=0) session.run(model) result = session.run(x) print(result.shape) plt.imshow(result) plt.show() 新东西是这一行: x = tf.reverse_sequence(x, np.ones((height,)) * width, 1, batch_dim=0) 它从上到下(沿着它的高度)迭代图像,并从左到右(沿着它的宽度)切片。 从这里开始,它选取大小为width的切片,其中width是图像的宽度。 译者注: 还有两个函数用于实现切片操作。一个是tf.reverse,另一个是张量的下标和切片运算符(和 NumPy 用法一样)。 代码np.ones((height,)) * width创建一个填充值width的 NumPy 数组。 这不是很有效! 不幸的是,在编写本文时,似乎此函数不允许你仅指定单个值。 “翻转”操作的结果: 1)将转置与翻转代码组合来顺时针旋转。 2)目前,翻转代码(使用reverse_sequence)需要预先计算宽度。 查看tf.shape函数的文档,并使用它在会话中计算x变量的宽度。 3)执行“翻转”,从上到下翻转图像。 4)计算“镜像”,复制图像的前半部分,(左右)翻转然后复制到后半部分。 占位符 到目前为止,我们已经使用Variables来管理我们的数据,但是有一个更基本的结构,即占位符。 占位符只是一个变量,我们将在以后向它分配数据。 它允许我们创建我们的操作,并构建我们的计算图,而不需要数据。 在 TensorFlow 术语中,我们随后通过这些占位符,将数据提供给图。 import tensorflow as tf x = tf.placeholder("float", None) y = x * 2 with tf.Session() as session: result = session.run(y, feed_dict={x: [1, 2, 3]}) print(result) 这个例子与我们之前的例子略有不同,让我们分解它。 首先,我们正常导入tensorflow。然后我们创建一个名为x的placeholder,即我们稍后将存储值的内存中的位置。 然后,我们创建一个Tensor,它是x乘以 2 的运算。注意我们还没有为x定义任何初始值。 我们现在定义了一个操作(y),现在可以在会话中运行它。我们创建一个会话对象,然后只运行y变量。请注意,这意味着,如果我们定义了更大的操作图,我们只能运行图的一小部分。这个子图求值实际上是 TensorFlow 的一个卖点,而且许多其他类似的东西都没有。 运行y需要了解x的值。我们在feed_dict参数中定义这些来运行。我们在这里声明x的值是[1,2,3]。我们运行y,给了我们结果[2,4,6]。 占位符不需要静态大小。让我们更新我们的程序,让x可以接受任何长度。将x的定义更改为: x = tf.placeholder("float", None) 现在,当我们在feed_dict中定义x的值时,我们可以有任意维度的值。 代码应该仍然有效,并给出相同的答案,但现在它也可以处理feed_dict中的任意维度的值。 占位符也可以有多个维度,允许存储数组。 在下面的示例中,我们创建一个 3 乘 2 的矩阵,并在其中存储一些数字。 然后,我们使用与以前相同的操作,来逐元素加倍数字。 import tensorflow as tf x = tf.placeholder("float", [None, 3]) y = x * 2 with tf.Session() as session: x_data = [[1, 2, 3], [4, 5, 6],] result = session.run(y, feed_dict={x: x_data}) print(result) 占位符的第一个维度是None,这意味着我们可以有任意数量的行。 第二个维度固定为 3,这意味着每行需要有三列数据。 我们可以扩展它来接受任意数量的None维度。 在此示例中,我们加载来自上一课的图像,然后创建一个存储该图像切片的占位符。 切片是图像的 2D 片段,但每个“像素”具有三个分量(红色,绿色,蓝色)。 因此,对于前两个维度,我们需要None,但是对于最后一个维度,需要 3(或None也能用)。 然后,我们使用 TensorFlow 的切片方法从图像中取出一个子片段来操作。 import tensorflow as tf import matplotlib.image as mpimg import matplotlib.pyplot as plt import os # First, load the image again dir_path = os.path.dirname(os.path.realpath(__file__)) filename = dir_path + "/MarshOrchid.jpg" raw_image_data = mpimg.imread(filename) image = tf.placeholder("uint8", [None, None, 3]) slice = tf.slice(image, [1000, 0, 0], [3000, -1, -1]) with tf.Session() as session: result = session.run(slice, feed_dict={image: raw_image_data}) print(result.shape) plt.imshow(result) plt.show() 译者注:使用下标和切片运算符也可以实现切片。 结果是图像的子片段: 1)在官方文档中查看 TensorFlow 中的其他数组函数。 2)将图像分成四个“角”,然后再将它拼在一起。 3)将图像转换为灰度。 一种方法是只采用一个颜色通道并显示。 另一种方法是将三个通道的平均值作为灰色。 交互式会话 现在我们有了一些例子,让我们更仔细地看看发生了什么。 正如我们之前已经确定的那样,TensorFlow 允许我们创建操作和变量图。这些变量称为张量,表示数据,无论是单个数字,字符串,矩阵还是其他内容。张量通过操作来组合,整个过程以图来建模。 首先,确保激活了tensorenv虚拟环境,一旦激活,请输入conda install jupyter来安装jupter books。 然后,运行jupyter notebook以启动 Jupyter Notebook(以前称为 IPython Notebook)的浏览器会话。 (如果你的浏览器没有打开,请打开它并在浏览器的地址栏中输入localhost:8888。) 单击New(新建),然后单击Notebooks(笔记本)下的Python 3(Python 3)。这将启动一个新的浏览器选项卡。通过单击顶部的Untitled(无标题)为该笔记本命名,并为其命名(我使用Interactive TensorFlow)。 如果你以前从未使用过 Jupyter 笔记本(或 IPython 笔记本),请查看此站点来获得简介。 接下来,和以前一样,让我们创建一个基本的 TensorFlow 程序。 一个主要的变化是使用InteractiveSession,它允许我们运行变量,而不需要经常引用会话对象(减少输入!)。 下面的代码块分为不同的单元格。 如果你看到代码中断,则需要先运行上一个单元格。 此外,如果你不自信,请确保在运行之前将给定块中的所有代码键入单元格。 import tensorflow as tf session = tf.InteractiveSession() x = tf.constant(list(range(10))) 在这段代码中,我们创建了一个InteractiveSession,然后定义一个常量值,就像一个占位符,但具有设置的值(不会改变)。 在下一个单元格中,我们可以求解此常量并打印结果。 print(x.eval()) 下面我们关闭打开的会话。 session.close() 关闭会话非常重要,并且很容易忘记。 出于这个原因,我们在之前的教程中使用with关键字来处理这个问题。 当with块完成执行时,会话将被关闭(如果发生错误也会发生这种情况 - 会话仍然关闭)。 现在让我们来看更大的例子。 在这个例子中,我们将使用一个非常大的矩阵并对其进行计算,跟踪何时使用内存。 首先,让我们看看我们的 Python 会话当前使用了多少内存: import resource print("{} Kb".format(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)) 在我的系统上,运行上面的代码之后,使用了 78496 千字节。 现在,创建一个新会话,并定义两个矩阵: import numpy as np session = tf.InteractiveSession() X = tf.constant(np.eye(10000)) Y = tf.constant(np.random.randn(10000, 300)) 让我们再看一下我们的内存使用情况: print("{} Kb".format(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)) 在我的系统上,内存使用率跃升至 885,220 Kb - 那些矩阵很大! 现在,让我们使用matmul将这些矩阵相乘: Z = tf.matmul(X, Y) 如果我们现在检查我们的内存使用情况,我们发现没有使用更多的内存 - 没有实际的Z的计算。 只有当我们求解操作时,我们才真正计算。 对于交互式会话,你可以使用Z.eval(),而不是运行session.run(Z)。 请注意,你不能总是依赖.eval(),因为这是使用“默认”会话的快捷方式,不一定是你要使用的会话。 如果你的计算机比较低级(例如,ram 低于 3Gb),那么不要运行此代码 - 相信我! Z.eval() 你的计算机会考虑很长一段时间,因为现在它才实际执行这些矩阵相乘。 之后检查内存使用情况会发现此计算已经发生,因为它现在使用了接近 3Gb! print("{} Kb".format(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)) 别忘了关闭你的会话! session.close() 注意:我建议使用新的 Jupyter Notebook,因为上面的示例代码可能会被意外再次执行,可能导致计算机崩溃! 1)创建一个整数值的大矩阵(至少 10,000,000)(例如,使用 NumPy 的randint函数)。 创建矩阵后检查内存使用情况。 然后,使用 TensorFlow 的to_float函数将矩阵转换为浮点值。 再次检查内存使用情况,看到内存使用量增加超过两倍。 “加倍”是由创建矩阵的副本引起的,但是“额外增加”的原因是什么? 执行此实验后,你可以使用此代码显示图像。 from PIL import Image from io import BytesIO # 从字符串读取数据 im = Image.open(BytesIO(result)) im 提示:确保在每一步之后仔细测量内存使用情况,因为只是导入 TensorFlow 就会使用相当多的内存。 2)使用 TensorFlow 的图像函数将上一个教程中的图像(或其他图像)转换为 JPEG 并记录内存使用情况。 可视化 在本课中,我们将介绍如何使用 TensorBoard 创建和可视化图。 我们在第一课变量中简要地浏览了 TensorBoard 那么什么是 TensorBoard 以及我们为什么要使用它呢? TensorBoard 是一套 Web 应用程序,用于检查和理解你的 TensorFlow 运行和图。 TensorBoard 目前支持五种可视化:标量,图像,音频,直方图和图。 你将在 TensorFlow 中的计算用于训练大型深度神经网络,可能相当复杂且令人困惑,TensorBoard 将使你更容易理解,调试和优化 TensorFlow 程序。 要实际查看 TensorBoard,请单击此处。 这就是 TensorBoard 图的样子: 基本的脚本 下面我们有了构建 TensorBoard 图的基本脚本。 现在,如果你在 python 解释器中运行它,会返回 63。 import tensorflow as tf a = tf.add(1, 2,) b = tf.multiply(a, 3) c = tf.add(4, 5,) d = tf.multiply(c, 6,) e = tf.multiply(4, 5,) f = tf.div(c, 6,) g = tf.add(b, d) h = tf.multiply(g, f) with tf.Session() as sess: print(sess.run(h)) 现在我们在代码末尾添加一个SummaryWriter,这将在给定目录中创建一个文件夹,其中包含 TensorBoard 用于构建图的信息。 with tf.Session() as sess: writer = tf.summary.FileWriter("output", sess.graph) print(sess.run(h)) writer.close() 如果你现在运行 TensorBoard,使用tensorboard --logdir=path/to/logs/directory,你会看到在你给定的目录中,你得到一个名为output的文件夹。 如果你在终端中访问 IP 地址,它将带你到 TensorBoard,然后如果你点击图,你将看到你的图。 在这一点上,图遍布各处,并且相当难以阅读。 因此,请命名一些部分来其更更加可读。 添加名称 在下面的代码中,我们只添加了parameter几次。name=[something]。 这个parameter将接受所选区域并在图形上为其命名。 a = tf.add(1, 2, name="Add_these_numbers") b = tf.multiply(a, 3) c = tf.add(4, 5, name="And_These_ones") d = tf.multiply(c, 6, name="Multiply_these_numbers") e = tf.multiply(4, 5, name="B_add") f = tf.div(c, 6, name="B_mul") g = tf.add(b, d) h = tf.multiply(g, f) 现在,如果你重新运行 python 文件,然后再次运行tensorboard --logdir=path/to/logs/directory,你现在将看到,在你命名的特定部分上,你的图有了一些名称。 然而,它仍然非常混乱,如果这是一个巨大的神经网络,它几乎是不可读的。 创建作用域 如果我们通过键入tf.name_scope("MyOperationGroup"):给图命名:并使用with tf.name_scope("Scope_A"):给图这样的作用域,当你重新运行你的 TensorBoard 时,你会看到一些非常不同的东西。 图现在更容易阅读,你可以看到它都在图的标题下,这里是MyOperationGroup,然后你有你的作用域A和B,其中有操作。 # 这里我们定义图的名称,作用域 A,B 和 C。 with tf.name_scope("MyOperationGroup"): with tf.name_scope("Scope_A"): a = tf.add(1, 2, name="Add_these_numbers") b = tf.multiply(a, 3) with tf.name_scope("Scope_B"): c = tf.add(4, 5, name="And_These_ones") d = tf.multiply(c, 6, name="Multiply_these_numbers") with tf.name_scope("Scope_C"): e = tf.multiply(4, 5, name="B_add") f = tf.div(c, 6, name="B_mul") g = tf.add(b, d) h = tf.multiply(g, f) 如你所见,图现在更容易阅读。 TensorBoard 具有广泛的功能,其中一些我们将在未来的课程中介绍。 如果你想深入了解,请先观看 2017 年 TensorFlow 开发者大会的视频。 在本课中,我们研究了: TensorBoard 图的基本布局 添加摘要编写器来构建 TensorBoard 将名称添加到 TensorBoard 图 将名称和作用域添加到 TensorBoard 有一个很棒的第三方工具叫做 TensorDebugger(TDB),TBD 就像它所谓的调试器一样。 但是与 TensorBoard 中内置的标准调试器不同,TBD 直接与 TensorFlow 图的执行交互,并允许一次执行一个节点。 由于标准 TensorBoard 调试器不能在运行 TensorFlow 图时同时使用,因此必须先写日志文件。 从这里安装 TBD 并阅读材料(试试 Demo!)。 将 TBD 与此梯度下降代码一起使用,绘制一个图表,通过结果显示调试器的工作,并打印预测模型。 (注意:这仅仅与 2.7 兼容) import tensorflow as tf import numpy as np # x 和 y 是我们的训练数据的占位符 x = tf.placeholder("float") y = tf.placeholder("float") # w 是存储我们的值的变量。 它使用“猜测”来初始化 # w[0] 是我们方程中的“a”,w[1] 是“b” w = tf.Variable([1.0, 2.0], name="w") # 我们的模型是 y = a*x + b y_model = tf.multiply(x, w[0]) + w[1] # 我们的误差定义为差异的平方 error = tf.square(y - y_model) # GradientDescentOptimizer 完成繁重的工作 train_op = tf.train.GradientDescentOptimizer(0.01).minimize(error) # TensorFlow 常规 - 初始化值,创建会话并运行模型 model = tf.global_variables_initializer() with tf.Session() as session: session.run(model) for i in range(1000): x_value = np.random.rand() y_value = x_value * 2 + 6 session.run(train_op, feed_dict={x: x_value, y: y_value}) w_value = session.run(w) print("Predicted model: {a:.3f}x + {b:.3f}".format(a=w_value[0], b=w_value[1])) 这些特殊图标用于常量和摘要节点。 读取文件 TensorFlow 支持读取更大的数据集,特别是这样,数据永远不能一次全部保存在内存中(如果有这个限制则不会非常有用)。 你可以使用一些函数和选项,从标准 Python 一直到特定的操作。 TensorFlow 还支持编写自定义数据处理程序,如果你有一个包含大量数据的非常大的项目,这是值得研究的。 编写自定义数据加载是前期的一点努力,但以后可以节省大量时间。 此主题的更多信息,请查看此处的官方文档。 在本课程中,我们将介绍使用 TensorFlow 读取 CSV 文件,以及在图中使用数据的基础知识。 占位符 读取数据的最基本方法是使用标准 python 代码读取它。 让我们来看一个基本的例子,从这个 2016 年奥运会奖牌统计数据中读取数据。 首先,我们创建我们的图,它接受一行数据,并累计总奖牌。 import tensorflow as tf import os dir_path = os.path.dirname(os.path.realpath(__file__)) filename = dir_path + "/olympics2016.csv" features = tf.placeholder(tf.int32, shape=[3], name='features') country = tf.placeholder(tf.string, name='country') total = tf.reduce_sum(features, name='total') 接下来,我将介绍一个名为Print的新操作,它打印出图形上某些节点的当前值。 它是一个单位元素,这意味着它将操作作为输入,只返回与输出相同的值。 printerop = tf.Print(total, [country, features, total], name='printer') 当你求解打印操作时会发生什么? 它基本上将当前值记录在第二个参数中(在本例中为列表[country, features, total])并返回第一个值(total)。 但它被认为是一个变量,因此我们需要在启动会话时初始化所有变量。 接下来,我们启动会话,然后打开文件来读取。 请注意,文件读取完全是在 python 中完成的 - 我们只是在执行图形的同时读取它。 with tf.Session() as sess: sess.run( tf.global_variables_initializer()) with open(filename) as inf: # 跳过标题 next(inf) for line in inf: # 使用 python 将数据读入我们的特征 country_name, code, gold, silver, bronze, total = line.strip().split(",") gold = int(gold) silver = int(silver) bronze = int(bronze) # 运行打印操作 total = sess.run(printerop, feed_dict={features: [gold, silver, bronze], country:country_name}) print(country_name, total) 在循环的内部部分,我们读取文件的一行,用逗号分割,将值转换为整数,然后将数据作为占位符值提供给feed_dict。 如果你不确定这里发生了什么,请查看之前的占位符教程。 当你运行它时,你会在每一行看到两个输出。 第一个输出将是打印操作的结果,看起来有点像这样: I tensorflow/core/kernels/logging_ops.cc:79] [\"France\"][10 18 14][42] 下一个输出将是print(country_name, total)行的结果,该行打印当前国家/地区名称(python 变量)和运行打印操作的结果。 由于打印操作是一个单位函数,因此调用它的结果只是求值total的结果,这会将金,银和铜的数量相加。 它通常以类似的方式工作得很好。 创建占位符,将一些数据加载到内存中,计算它,然后循环使用新数据。 毕竟,这是占位符的用途。 读取 CSV TensorFlow 支持将数据直接读入张量,但格式有点笨重。 我将通过一种方式逐步完成此操作,但我选择了一种特殊的通用方法,我希望你可以将它用于你自己的项目。 步骤是创建要读取的文件名的队列(列表),然后创建稍后将执行读取的读取器操作。 从这个阅读器操作中,创建在图执行阶段执行时用实际值替换的变量。 让我们来看看该过程的最后几个步骤: def create_file_reader_ops(filename_queue): reader = tf.TextLineReader(skip_header_lines=1) _, csv_row = reader.read(filename_queue) record_defaults = [[""], [""], [0], [0], [0], [0]] country, code, gold, silver, bronze, total = tf.decode_csv(csv_row, record_defaults=record_defaults) features = tf.pack([gold, silver, bronze]) return features, country 这里的读取器在技术上采用队列对象,而不是普通的 Python 列表,所以我们需要在将它传递给函数之前构建一个: filename_queue = tf.train.string_input_producer(filenames, num_epochs=1, shuffle=False) example, country = create_file_reader_ops(filename_queue) 由该函数调用产生的那些操作,稍后将表示来自我们的数据集的单个条目。 运行这些需要比平常更多的工作。 原因是队列本身不像正常操作那样位于图上,因此我们需要一个Coordinator来管理队列中的运行。 每次求值示例和标签时,此协调器将在数据集中递增,因为它们有效地从文件中提取数据。 with tf.Session() as sess: tf.global_variables_initializer().run() coord = tf.train.Coordinator() threads = tf.train.start_queue_runners(coord=coord) while True: try: example_data, country_name = sess.run([example, country]) print(example_data, country_name) except tf.errors.OutOfRangeError: break 内部while循环保持循环,直到我们遇到OutOfRangeError,表明没有更多数据要还原。 有了这段代码,我们现在从数据集中一次得到一行,直接加载到我们的图形中。 还有其他用于创建批量和打乱的功能 - 如果你想了解这些参数的更多信息,请查看tf.train.string_input_producer和tf.train.shuffle_batch中的一些参数。 在本课中,我们研究了: 在执行 TensorFlow 图时使用 Python 读取数据 tf.Print操作 将数据直接读入 TensorFlow 图/变量 队列对象 更新第二个示例的代码(直接将文件读入 TensorFlow),使用与 python-version 相同的方式输出总和(即打印出来并使用tf.Print) 在create_file_reader_ops中解包特征操作,即不执行tf.pack行。 更改代码的其余部分来满足一下情况,特征作为三个单独的特征返回,而不是单个打包的特征。 需要改变什么? 将数据文件拆分为几个不同的文件(可以使用文本编辑器完成)并更新队列来全部读取它们。 使用tf.train.shuffle_batch将多行合成一个变量。 这对于较大的数据集比逐行读取更有用。 对于问题4,一个好的目标是在一个批量中加载尽可能多的数据,但不要太多以至于它会使计算机的 RAM 过载。 这对于这个数据集无关紧要,但以后请记住。 另外,使用批量时不会返回所有数据 - 如果批量未满,则不会返回。 迁移到 AWS 在很多情况下,运行代码可能非常耗时,特别是如果你正在运行机器学习或神经网络。除非你在计算机上花费了大量资金,否则转向基于云的服务可能是最好的方法。 在本教程中,我们将采用一些 Tensorflow 代码并将其移至 Amazon Web 服务(AWS)弹性计算云实例(EC2)。 亚马逊网络服务(AWS)是一个安全的云服务平台,提供计算能力,数据库存储,内容交付和其他功能,来帮助企业扩展和发展。此外,亚马逊弹性计算云(Amazon EC2)是一种 Web 服务,可在云中提供可调整大小的计算能力。它旨在使 Web 级云计算对开发人员更轻松。 这样做的好处是,亚马逊拥有大量基于云的服务器,其背后有很多功能。这将允许你在网络上运行代码的时间,只有你能够从本地计算机运行代码的一半。这也意味着如果它是一个需要 5-8 个小时才能完成的大型文件,你可以在 EC2 实例上运行它,并将其保留在后台而不使用你的整个计算机资源。 创建一个 EC2 环境会花费你的钱,但它是一个非常少,8 小时可能大约 4.00 美元。 一旦你停止使用它,将不会收取你的费用。请访问此链接来查看价格。 创建 EC2 实例 首先,访问 AWS 控制台。 使用你的亚马逊帐户登录。如果你没有,则会提示你创建一个,你需要执行此操作才能继续。 接下来,请访问 EC2 服务控制台。 单击Launch Instance并在右上角的下拉菜单中选择你的地区(例如sydney, N california)作为你的位置。 接下来转到社区 AMI 并搜索 Ubuntu x64 AMI 和 TensorFlow(GPU),它已准备好通过 GPU 运行代码,但它也足以在其上运行基本或大型 Tensorflow 脚本,而且优势是 Tensorflow 已安装。 此时,将向你收取费用,因此请务必在完成后关闭机器。 你可以转到 EC2 服务,选择机器并停止它。 你不需要为未运行的机器付费。 系统将提示你如何连接到实例的一些信息。 如果你之前未使用过 AWS,则可能需要创建一个新密钥对才能安全地连接到你的实例。 在这种情况下,为你的密钥对命名,下载 pemfile,并将其存储在安全的地方 - 如果丢失,你将无法再次连接到你的实例! 单击“连接”来获取使用 pem 文件连接到实例的信息。 最可能的情况是你将使用以下命令来使用ssh: ssh -i <certificante_name>.pem ubuntu@<server_ip_address> 将你的代码移动到 AWS EC2 我们将使用以下示例继续我们的 EC2 实例,这来自前面的章节: import tensorflow as tf import numpy as np # x 和 y 是我们的训练数据的占位符 x = tf.placeholder("float") y = tf.placeholder("float") # w 是存储我们的值的变量。 它使用“猜测”来初始化 # w[0] 是我们方程中的“a”,w[1] 是“b” w = tf.Variable([1.0, 2.0], name="w") # 我们的模型是 y = a*x + b y_model = tf.multiply(x, w[0]) + w[1] # 我们的误差定义为差异的平方 error = tf.square(y - y_model) # GradientDescentOptimizer 完成繁重的工作 train_op = tf.train.GradientDescentOptimizer(0.01).minimize(error) # TensorFlow 常规 - 初始化值,创建会话并运行模型 model = tf.global_variables_initializer() with tf.Session() as session: session.run(model) for i in range(1000): x_value = np.random.rand() y_value = x_value * 2 + 6 session.run(train_op, feed_dict={x: x_value, y: y_value}) w_value = session.run(w) print("Predicted model: {a:.3f}x + {b:.3f}".format(a=w_value[0], b=w_value[1])) 有很多方法可以将此文件放到EC2实例上,但最简单的方法之一就是复制并粘贴内容。 首先,按Ctrl + A高亮以上所有代码,然后使用Ctrl + C复制所有代码 在 Amazon 虚拟机上,移动到主目录并使用新文件名打开nano,我们将在此示例中调用basic.py(以下是终端命令): $ cd~/ $ nano <nameofscript>.py nano程序将打开,这是一个命令行文本编辑器。 打开此程序后,将剪贴板的内容粘贴到此文件中。 在某些系统上,你可能需要使用ssh程序的文件选项,而不是按Ctrl + V进行粘贴。 在nano中,按Ctrl + O将文件保存在磁盘上,我们将其命名为basic.py,然后按Ctrl + X退出程序。 一旦你退出nano,输入python basic.py就可以了! 你现在应该看到终端中弹出代码的结果,因为你很可能会发现,这可能是一种执行大型数据程序的更好方法。 Facenet 是一款利用 Tensorflow 的人脸识别程序,它提供了预先训练的模型,供你下载和运行来查看其工作原理。 1)访问此链接并下载预先训练的人脸识别模型 2)使用上面的教程,将代码上传到 EC2 实例并使其运行。
原文:SciPy 2018 Scikit-learn Tutorial 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 十三、交叉验证和得分方法 在前面的章节和笔记本中,我们将数据集分为两部分:训练集和测试集。 我们使用训练集来拟合我们的模型,并且我们使用测试集来评估其泛化能力 - 它对新的,没见过的数据的表现情况。 然而,(标记的)数据通常是宝贵的,这种方法让我们只将约 3/4 的数据用于行训练。 另一方面,我们只会尝试将我们的 1/4 数据应用于测试。 使用更多数据来构建模型,并且获得更加鲁棒的泛化能力估计,常用方法是交叉验证。 在交叉验证中,数据被重复拆分为非重叠的训练和测试集,并为每对建立单独的模型。 然后聚合测试集的得分来获得更鲁棒的估计。 进行交叉验证的最常用方法是k折交叉验证,其中数据首先被分成k(通常是 5 或 10)个相等大小的折叠,然后对于每次迭代,使用k折中的一个作为测试数据,其余作为训练数据: 这样,每个数据点只在测试集中一次,我们可以使用第k个数据之外的所有数据进行训练。 让我们应用这种技术,在鸢尾花数据集上评估KNeighborsClassifier算法: from sklearn.datasets import load_iris from sklearn.neighbors import KNeighborsClassifier iris = load_iris() X, y = iris.data, iris.target classifier = KNeighborsClassifier() 鸢尾花中的标签是有序的,这意味着如果我们像上面那样拆分数据,第一个折叠只有标签 0,而最后一个只有标签 2: y 为了在评估中避免这个问题,我们首先将我们的数据打乱: import numpy as np rng = np.random.RandomState(0) permutation = rng.permutation(len(X)) X, y = X[permutation], y[permutation] print(y) 现在实现交叉验证很简单: k = 5 n_samples = len(X) fold_size = n_samples // k scores = [] masks = [] for fold in range(k): # 为此折叠中的测试集生成一个布尔掩码 test_mask = np.zeros(n_samples, dtype=bool) test_mask[fold * fold_size : (fold + 1) * fold_size] = True # 为可视化存储掩码 masks.append(test_mask) # 使用此掩码创建训练和测试集 X_test, y_test = X[test_mask], y[test_mask] X_train, y_train = X[~test_mask], y[~test_mask] # 拟合分类器 classifier.fit(X_train, y_train) # 计算得分并记录 scores.append(classifier.score(X_test, y_test)) 让我们检查一下我们的测试掩码是否正确: import matplotlib.pyplot as plt %matplotlib inline plt.matshow(masks, cmap='gray_r') 现在让我们看一下我们计算出的得分: print(scores) print(np.mean(scores)) 正如你所看到的,得分广泛分布于 90% 正确到 100% 正确。 如果我们只进行一次分割,我们可能会得到任何答案。 由于交叉验证是机器学习中常见的模式,有个函数执行上面的操作,带有更多灵活性和更少代码。sklearn.model_selection模块具有交叉验证相关的所有函数。 最简单的函数是cross_val_score,它接受估计器和数据集,并将为你完成所有拆分: from sklearn.model_selection import cross_val_score scores = cross_val_score(classifier, X, y) print('Scores on each CV fold: %s' % scores) print('Mean score: %0.3f' % np.mean(scores)) 如你所见,该函数默认使用三个折叠。 你可以使用cv参数更改折叠数: cross_val_score(classifier, X, y, cv=5) 交叉验证模块中还有辅助对象,它们将为你生成各种不同交叉验证方法的索引,包括 k-fold: from sklearn.model_selection import KFold, StratifiedKFold, ShuffleSplit 默认情况下,cross_val_score将StratifiedKFold用于分类,这可确保数据集中的类比例反映在每个折叠中。 如果你有一个二分类数据集,其中 90% 的数据点属于类 0,那么这意味着在每个折叠中,90% 的数据点将属于类 0。如果你只是使用KFold交叉验证,你可能会生成一个只包含类 0 的分割。每当你进行分类时,通常最好使用StratifiedKFold。 StratifiedKFold也消除了我们打乱鸢尾花的需要。 让我们看看在未打乱的鸢尾花数据集上,它生成什么类型的折叠。 每个交叉验证类都是训练和测试索引的集合的生成器: cv = StratifiedKFold(n_splits=5) for train, test in cv.split(iris.data, iris.target): print(test) 正如你所看到的,在每个折叠中,在开始,中间,和结束位置,都有一些样本。 这样,保留了类别比例。 让我们观察一下split: def plot_cv(cv, features, labels): masks = [] for train, test in cv.split(features, labels): mask = np.zeros(len(labels), dtype=bool) mask[test] = 1 masks.append(mask) plt.matshow(masks, cmap='gray_r') plot_cv(StratifiedKFold(n_splits=5), iris.data, iris.target) 为了比较,仍旧是标准KFold,忽略标签: plot_cv(KFold(n_splits=5), iris.data, iris.target) 请记住,增加折叠数量会为你提供更大的训练数据集,但会导致更多重复,因此评估速度会变慢: plot_cv(KFold(n_splits=10), iris.data, iris.target) 另一个有用的交叉验证生成器是ShuffleSplit。 该生成器简单地重复分割数据的随机部分。 这允许用户独立指定重复次数和训练集大小: plot_cv(ShuffleSplit(n_splits=5, test_size=.2), iris.data, iris.target) 如果你想要更鲁棒的估计,你可以增加分割数量: plot_cv(ShuffleSplit(n_splits=20, test_size=.2), iris.data, iris.target) 你可以使用cross_val_score方法来使用所有这些交叉验证生成器: cv = ShuffleSplit(n_splits=5, test_size=.2) cross_val_score(classifier, X, y, cv=cv) 练习 在鸢尾花数据集上,使用KFold类进行三折交叉验证,而不打乱数据。你能解释一下结果吗? # %load solutions/13_cross_validation.py 十四、参数选择、验证和测试 大多数模型的参数会影响他们可以学习的模型的复杂程度。回忆使用KNeighborsRegressor的时候。 如果我们改变我们考虑的邻居数量,我们会得到更平滑的预测: 在上图中,我们看到n_neighbors的三个不同值。对于n_neighbors = 2,数据过拟合,模型过于灵活,可以适应训练数据中的噪声。对于n_neighbors = 20,模型不够灵活,无法合理建模数据中的变化。 在中间,对于n_neighbors = 5,我们找到了一个很好的中点。它非常适合数据,并且不会受到任何一个图中所见的,过拟合或欠拟合问题的影响。我们想要的是一种定量识别过拟合和欠拟合的方法,并优化超参数(这种情况是多项式次数d)来确定最佳算法。 我们要权衡过多记录训练数据的特殊性和噪声,或者没有建模足够的可变性。这是一个需要在基本上每个机器学习应用中做出的权衡,并且是一个核心概念,称为偏差 - 方差 - 权衡或“过拟合与欠拟合”。 超参数、过拟合和欠拟合 遗憾的是,没有找到最佳位置的一般规则,因此机器学习实践者必须通过尝试几个超参数设置,来找到模型复杂性和泛化的最佳权衡。 超参数是机器学习算法的内部旋钮或可调参数(与算法从训练数据中学习的模型参数相反 - 例如,线性回归模型的权重系数);K 近邻中的k的数量是这样的超参数。 最常见的是,这种“超参数调整”是使用暴力搜索完成的,例如在多个n_neighbors值上: from sklearn.model_selection import cross_val_score, KFold from sklearn.neighbors import KNeighborsRegressor # 生成玩具数据集 x = np.linspace(-3, 3, 100) rng = np.random.RandomState(42) y = np.sin(4 * x) + x + rng.normal(size=len(x)) X = x[:, np.newaxis] cv = KFold(shuffle=True) # 对每个参数设置执行交叉验证 for n_neighbors in [1, 3, 5, 10, 20]: scores = cross_val_score(KNeighborsRegressor(n_neighbors=n_neighbors), X, y, cv=cv) print("n_neighbors: %d, average score: %f" % (n_neighbors, np.mean(scores))) scikit-learn 中有一个函数,称为validation_plot,用于重现上面的卡通图。 它根据训练和验证误差(使用交叉验证)绘制一个参数,例如邻居的数量: from sklearn.model_selection import validation_curve n_neighbors = [1, 3, 5, 10, 20, 50] train_scores, test_scores = validation_curve(KNeighborsRegressor(), X, y, param_name="n_neighbors", param_range=n_neighbors, cv=cv) plt.plot(n_neighbors, train_scores.mean(axis=1), label="train accuracy") plt.plot(n_neighbors, test_scores.mean(axis=1), label="test accuracy") plt.ylabel('Accuracy') plt.xlabel('Number of neighbors') plt.xlim([50, 0]) plt.legend(loc="best"); 请注意,许多邻居意味着“平滑”或“简单”的模型,因此绘图使用还原的x轴。 如果多个参数很重要,例如 SVM 中的参数C和gamma(稍后会详细介绍),则尝试所有可能的组合: from sklearn.model_selection import cross_val_score, KFold from sklearn.svm import SVR # 对每个参数设置执行交叉验证 for C in [0.001, 0.01, 0.1, 1, 10]: for gamma in [0.001, 0.01, 0.1, 1]: scores = cross_val_score(SVR(C=C, gamma=gamma), X, y, cv=cv) print("C: %f, gamma: %f, average score: %f" % (C, gamma, np.mean(scores))) 由于这是一种非常常见的模式,因此在 scikit-learn 中有一个内置类GridSearchCV。 GridSearchCV接受描述应该尝试的参数的字典,和一个要训练的模型。 参数网格被定义为字典,其中键是参数,值是要测试的设置。 要检查不同折叠的训练得分,请将参数return_train_score设置为True。 from sklearn.model_selection import GridSearchCV param_grid = {'C': [0.001, 0.01, 0.1, 1, 10], 'gamma': [0.001, 0.01, 0.1, 1]} grid = GridSearchCV(SVR(), param_grid=param_grid, cv=cv, verbose=3, return_train_score=True) GridSearchCV的一大优点是它是一个元估计器。 它需要像上面的 SVR 这样的估计器,并创建一个新的估计器,其行为完全相同 - 在这种情况下,就像一个回归器。 所以我们可以调用它的fit来训练: grid.fit(X, y) fit所做的比我们上面做的复杂得多。 首先,它使用交叉验证运行相同的循环,来找到最佳参数组合。 一旦它具有最佳组合,它在所有传给fit的数据上再次执行fit(无交叉验证),来使用最佳参数设置构建单个新模型。 然后,与所有模型一样,我们可以使用predict或score: grid.predict(X) 你可以在best_params_属性中检查GridSearchCV找到的最佳参数,以及best_score_属性中的最佳得分: print(grid.best_score_) print(grid.best_params_) 但是,你可以通过访问cv_results_属性来调查每组参数值的表现和更多信息。 cv_results_属性是一个字典,其中每个键都是字符串,每个值都是数组。 因此,它可以用于制作pandas DataFrame。 type(grid.cv_results_) print(grid.cv_results_.keys()) import pandas as pd cv_results = pd.DataFrame(grid.cv_results_) cv_results.head() cv_results_tiny = cv_results[['param_C', 'param_gamma', 'mean_test_score']] cv_results_tiny.sort_values(by='mean_test_score', ascending=False).head() 但是,将这个得分用于评估存在问题。 你可能会犯所谓的多假设检验错误。 如果你尝试了很多参数设置,其中一些参数设置只是偶然表现很好,而你获得的得分可能无法反映你的模型对新的没见过的数据的表现。 因此,在执行网格搜索之前拆分单独的测试集是很好的。 这种模式可以看作是训练-验证-测试分割,在机器学习中很常见: 我们可以非常容易地实现,通过使用train_test_split分割一些测试数据,在训练集上训练GridSearchCV,并将score方法应用于测试集: from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=1) param_grid = {'C': [0.001, 0.01, 0.1, 1, 10], 'gamma': [0.001, 0.01, 0.1, 1]} cv = KFold(n_splits=10, shuffle=True) grid = GridSearchCV(SVR(), param_grid=param_grid, cv=cv) grid.fit(X_train, y_train) grid.score(X_test, y_test) 我们还可以查看所选的参数: grid.best_params_ 一些实践者采用更简单的方案,将数据简单地分为三个部分,即训练,验证和测试。 如果你的训练集非常大,或者使用交叉验证训练许多模型是不可行的,因为训练模型需要很长时间,这是一种可能的替代方案。 你可以使用 scikit-learn 执行此操作,例如通过拆分测试集,然后将GridSearchCV与ShuffleSplit交叉验证应用于单次迭代: from sklearn.model_selection import train_test_split, ShuffleSplit X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=1) param_grid = {'C': [0.001, 0.01, 0.1, 1, 10], 'gamma': [0.001, 0.01, 0.1, 1]} single_split_cv = ShuffleSplit(n_splits=1) grid = GridSearchCV(SVR(), param_grid=param_grid, cv=single_split_cv, verbose=3) grid.fit(X_train, y_train) grid.score(X_test, y_test) 这要快得多,但可能会产生更糟糕的超参数,从而产生更糟糕的结果。 clf = GridSearchCV(SVR(), param_grid=param_grid) clf.fit(X_train, y_train) clf.score(X_test, y_test) 练习 应用网格搜索来查找KNeighborsClassifier中邻居数量的最佳设置,并将其应用于数字数据集。 十五、估计器流水线 在本节中,我们将研究如何链接不同的估计器。 简单示例:估计器之前的特征提取和选择 特征提取:向量化器 对于某些类型的数据,例如文本数据,必须应用特征提取步骤将其转换为数值特征。 为了说明,我们加载我们之前使用的 SMS 垃圾邮件数据集。 import os with open(os.path.join("datasets", "smsspam", "SMSSpamCollection")) as f: lines = [line.strip().split("\t") for line in f.readlines()] text = [x[1] for x in lines] y = [x[0] == "ham" for x in lines] from sklearn.model_selection import train_test_split text_train, text_test, y_train, y_test = train_test_split(text, y) 以前,我们手动应用了特征提取,如下所示: from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression vectorizer = TfidfVectorizer() vectorizer.fit(text_train) X_train = vectorizer.transform(text_train) X_test = vectorizer.transform(text_test) clf = LogisticRegression() clf.fit(X_train, y_train) clf.score(X_test, y_test) 我们学习转换然后将其应用于测试数据的情况,在机器学习中非常常见。 因此 scikit-learn 有一个快捷方式,称为流水线: from sklearn.pipeline import make_pipeline pipeline = make_pipeline(TfidfVectorizer(), LogisticRegression()) pipeline.fit(text_train, y_train) pipeline.score(text_test, y_test) 如你所见,这使代码更短,更容易处理。 在背后,与上面完全相同。 当在水流上调用fit时,它将依次调用每个步骤的fit。 在第一步的fit之后,它将使用第一步的transform方法来创建新的表示。 然后将其用于下一步的fit,依此类推。 最后,在最后一步,只调用fit。 如果我们调用score,那么每一步都只会调用transform - 毕竟这可能是测试集! 然后,在最后一步,使用新的表示调用score。 predict也是如此。 流水线的构建不仅简化了代码,而且对于模型选择也很重要。 假设我们想要网格搜索C来调整上面的 Logistic 回归。 让我们假设我们这样做: # This illustrates a common mistake. Don't use this code! from sklearn.model_selection import GridSearchCV vectorizer = TfidfVectorizer() vectorizer.fit(text_train) X_train = vectorizer.transform(text_train) X_test = vectorizer.transform(text_test) clf = LogisticRegression() grid = GridSearchCV(clf, param_grid={'C': [.1, 1, 10, 100]}, cv=5) grid.fit(X_train, y_train) 我们哪里做错了? 在这里,我们使用X_train上的交叉验证进行了网格搜索。 然而,当应用TfidfVectorizer时,它看到了所有的X_train,而不仅仅是训练折叠! 因此,它可以使用测试折叠中单词频率的知识。 这被称为测试集的“污染”,并且使泛化性能或错误选择的参数的估计过于乐观。 我们可以通过流水线解决这个问题: from sklearn.model_selection import GridSearchCV pipeline = make_pipeline(TfidfVectorizer(), LogisticRegression()) grid = GridSearchCV(pipeline, param_grid={'logisticregression__C': [.1, 1, 10, 100]}, cv=5) grid.fit(text_train, y_train) grid.score(text_test, y_test) 请注意,我们需要告诉流水线我们要在哪一步设置参数C。我们可以使用特殊的__语法来完成此操作。 __之前的名称只是类的名称,__之后的部分是我们想要使用网格搜索设置的参数。 使用流水线的另一个好处是,我们现在还可以使用GridSearchCV搜索特征提取的参数: from sklearn.model_selection import GridSearchCV pipeline = make_pipeline(TfidfVectorizer(), LogisticRegression()) params = {'logisticregression__C': [.1, 1, 10, 100], "tfidfvectorizer__ngram_range": [(1, 1), (1, 2), (2, 2)]} grid = GridSearchCV(pipeline, param_grid=params, cv=5) grid.fit(text_train, y_train) print(grid.best_params_) grid.score(text_test, y_test) 练习 使用StandardScaler和RidgeRegression创建流水线,并将其应用于波士顿住房数据集(使用sklearn.datasets.load_boston加载)。 尝试添加sklearn.preprocessing.PolynomialFeatures变换器作为第二个预处理步骤,并网格搜索多项式的次数(尝试 1,2 和 3)。 # %load solutions/15A_ridge_grid.py 十六、模型评估、得分指标和处理不平衡类别 在之前的笔记本中,我们已经详细介绍了如何评估模型,以及如何选择最佳模型。 到目前为止,我们假设我们得到了表现的度量,它度量模型的质量。 但是,应该使用什么度量标准并不总是显而易见的。 scikit-learn 中的默认分数,对于分类是准确率,即正确分类的样本的比例,对于回归是 r2 得分,是确定系数。 在许多情况下,这些是合理的默认选择;但是,根据我们的任务,这些并不总是最终或推荐的选择。 让我们更详细地看一下分类,回到手写数字分类的应用。 那么,如何训练分类器并使用不同的方式进行评估呢? Scikit-learn 在 sklearn.metrics 模块中有许多有用的方法,可以帮助我们完成这项任务: %matplotlib inline import matplotlib.pyplot as plt import numpy as np np.set_printoptions(precision=2) from sklearn.datasets import load_digits from sklearn.model_selection import train_test_split from sklearn.svm import LinearSVC digits = load_digits() X, y = digits.data, digits.target X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=1, stratify=y, test_size=0.25) classifier = LinearSVC(random_state=1).fit(X_train, y_train) y_test_pred = classifier.predict(X_test) print("Accuracy: {}".format(classifier.score(X_test, y_test))) 在这里,我们正确预测了 95.3% 的样本。 对于多类问题,通常很有趣的是,知道哪些类很难预测,哪些类很容易,或哪些类混淆了。 获取错误分类的更多信息的一种方法,是confusion_matrix,它为每个真正的类显示给定预测结果的频率。 from sklearn.metrics import confusion_matrix confusion_matrix(y_test, y_test_pred) 绘图有时更可读: plt.matshow(confusion_matrix(y_test, y_test_pred), cmap="Blues") plt.colorbar(shrink=0.8) plt.xticks(range(10)) plt.yticks(range(10)) plt.xlabel("Predicted label") plt.ylabel("True label"); 我们可以看到大多数条目都在对角线上,这意味着我们正确地预测了几乎所有样本。 非对角线的条目向我们显示许多 8 被归类为 1,并且 9 很可能与许多其他类混淆。 另一个有用的函数是classification_report,它为所有类提供精确率,召回率,f 得分和支持度。 精确率是一个类有多少预测实际上是那个类。 TP,FP,TN,FN 分别代表“真正例”,“假正例”,“真负例”和“假负例”: Precision = TP / (TP + FP) 召回率是有多少真正例被复原: Recall = TP / (TP + FN) F1 得分是二者的调和均值: F1 = 2 x (precision x recall) / (precision + recall) 上述所有这些值的值都在闭区间[0,1]中,其中 1 表示完美得分。 from sklearn.metrics import classification_report print(classification_report(y_test, y_test_pred)) 这些指标有助于实践中经常出现的两种特殊情况: 不平衡类别,即一个类可能比另一个类更频繁。 非对称成本,即一种错误比另一种更“昂贵”。 首先我们来看看第一个。 假设我们有 1:9 的不平衡类别,这是相当温和的(想想广告点击预测,只有 0.001% 的广告可能会被点击): np.bincount(y) / y.shape[0] 作为一个玩具示例,假设我们想要划分数字三和所有其他数字: X, y = digits.data, digits.target == 3 现在我们在分类器上运行交叉验证,看看它有多好: 我们的分类器准确率为 90%。 这样好吗? 还是不好? 请记住,90% 的数据“不是三”。 因此,让我们看看虚拟分类器的表现如何,它始终预测最频繁的类: from sklearn.dummy import DummyClassifier cross_val_score(DummyClassifier("most_frequent"), X, y) 也是 90%(正如预期的那样)! 所以有一种可能,我们的分类器不是很好,它并不比一个甚至不看数据的简单策略更好。 不过,这个判断太快了。 准确性根本不是评估不平衡数据集的分类器的好方法! np.bincount(y) / y.shape[0] ROC 曲线 更好的衡量标准是使用所谓的 ROC(受试者工作特性)曲线。 ROC 曲线处理分类器的不确定性输出,比如我们上面训练的 SVC 的“决策函数”。 它不是在 0 处截断并查看分类结果,而是查看每个可能的截断值并记录有多少真正例预测,以及有多少假正例预测。 下图比较了在“三和其它”任务上,我们的分类器的三个参数设置的 roc 曲线。 from sklearn.metrics import roc_curve, roc_auc_score X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42) for gamma in [.05, 0.1, 0.5]: plt.xlabel("False Positive Rate") plt.ylabel("True Positive Rate (recall)") svm = SVC(gamma=gamma).fit(X_train, y_train) decision_function = svm.decision_function(X_test) fpr, tpr, _ = roc_curve(y_test, decision_function) acc = svm.score(X_test, y_test) auc = roc_auc_score(y_test, svm.decision_function(X_test)) label = "gamma: %0.3f, acc:%.2f auc:%.2f" % (gamma, acc, auc) plt.plot(fpr, tpr, label=label, linewidth=3) plt.legend(loc="best"); 由于决策阈值非常小,假正例很低,但假负例也很少,但是阈值非常高的话,真正例率和假负例率都很高。所以一般来说,曲线将从左下角到右上角。对角线反映了机会表现,而目标是尽可能在左上角。这意味着与任何负样本相比,为所有正样本提供更高的decision_function值。 在这个意义上,该曲线仅考虑正样本和负样本的排名,而不是实际值。从图例中的曲线和准确率值可以看出,即使所有分类器具有相同的准确率,89%,甚至低于虚拟分类器,其中一个具有完美的 roc 曲线,而其中一个表现出机会水平。 对于网格搜索和交叉验证,我们通常希望将模型评估压缩为单个数字。使用 roc 曲线的一个好方法是使用曲线下面积(AUC)。我们可以通过指定scoring ="roc_auc"在cross_val_score中使用它: from sklearn.model_selection import cross_val_score cross_val_score(SVC(gamma='auto'), X, y, scoring="roc_auc", cv=5) 内建和自定义的得分函数 还有更多可用的评分方法,可用于不同类型的任务。 你可以在SCORERS字典中找到它们。 唯一的文档解释了所有这些。 from sklearn.metrics.scorer import SCORERS print(SCORERS.keys()) 你也可以定义自己的得分指标。 你可以提供一个可调用对象作为scoring参数,而不是字符串,即具有__call__方法对象或函数。 它需要接受模型,测试集特征X_test和测试集标签y_test,并返回一个浮点数。 更高的浮点意味着更好的模型。 让我们重新实现标准准确率得分: def my_accuracy_scoring(est, X, y): return np.mean(est.predict(X) == y) cross_val_score(SVC(), X, y, scoring=my_accuracy_scoring) 练习 在前面的章节中,我们通常使用准确率度量来评估分类器的表现。 我们还没有谈到的相关措施是平均每类准确率(APCA)。 我们记得,准确性定义为: ACC = (TP + TN) / n 其中n是样本总数。 这可以推广为: ACC = T / N 其中T是多类设置中所有正确预测的数量。 给定以下“真实”类标签和预测类标签数组,你是否可以实现一个函数,使用准确率度量来计算平均每类准确率,如下所示? y_true = np.array([0, 0, 0, 1, 1, 1, 1, 1, 2, 2]) y_pred = np.array([0, 1, 1, 0, 1, 1, 2, 2, 2, 2]) confusion_matrix(y_true, y_pred) # %load solutions/16A_avg_per_class_acc.py 十七、深入:线性模型 线性模型在可用的数据很少时非常有用,或者对于文本分类中的非常大的特征空间很有用。 此外,它们是正则化的良好研究案例。 用于回归的线性模型 用于回归的所有线性模型学习系数参数coef_和偏移intercept_,来使用线性特征组合做出预测: y_pred = x_test[0] * coef_[0] + ... + x_test[n_features-1] * coef_[n_features-1] + intercept_ 回归的线性模型之间的差异在于,除了很好地拟合训练数据之外,对系数施加什么样的限制或惩罚,作为正则化。 最标准的线性模型是“普通最小二乘回归”,通常简称为“线性回归”。 它没有对coef_施加任何额外限制,因此当特征数量很大时,它会变得行为异常,并且模型会过拟合。 让我们生成一个简单的模拟,以查看这些模型的行为。 from sklearn.datasets import make_regression from sklearn.model_selection import train_test_split X, y, true_coefficient = make_regression(n_samples=200, n_features=30, n_informative=10, noise=100, coef=True, random_state=5) X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=5, train_size=60, test_size=140) print(X_train.shape) print(y_train.shape) 线性回归 from sklearn.linear_model import LinearRegression linear_regression = LinearRegression().fit(X_train, y_train) print("R^2 on training set: %f" % linear_regression.score(X_train, y_train)) print("R^2 on test set: %f" % linear_regression.score(X_test, y_test)) from sklearn.metrics import r2_score print(r2_score(np.dot(X, true_coefficient), y)) plt.figure(figsize=(10, 5)) coefficient_sorting = np.argsort(true_coefficient)[::-1] plt.plot(true_coefficient[coefficient_sorting], "o", label="true") plt.plot(linear_regression.coef_[coefficient_sorting], "o", label="linear regression") plt.legend() from sklearn.model_selection import learning_curve def plot_learning_curve(est, X, y): training_set_size, train_scores, test_scores = learning_curve(est, X, y, train_sizes=np.linspace(.1, 1, 20)) estimator_name = est.__class__.__name__ line = plt.plot(training_set_size, train_scores.mean(axis=1), '--', label="training scores " + estimator_name) plt.plot(training_set_size, test_scores.mean(axis=1), '-', label="test scores " + estimator_name, c=line[0].get_color()) plt.xlabel('Training set size') plt.legend(loc='best') plt.ylim(-0.1, 1.1) plt.figure() plot_learning_curve(LinearRegression(), X, y) 岭回归(L2 惩罚) 岭估计器是普通LinearRegression的简单正则化(称为 l2 惩罚)。 特别是,它具有的优点是,在计算上不比普通的最小二乘估计更昂贵。 正则化的总数通过Ridge的alpha参数设置。 from sklearn.linear_model import Ridge ridge_models = {} training_scores = [] test_scores = [] for alpha in [100, 10, 1, .01]: ridge = Ridge(alpha=alpha).fit(X_train, y_train) training_scores.append(ridge.score(X_train, y_train)) test_scores.append(ridge.score(X_test, y_test)) ridge_models[alpha] = ridge plt.figure() plt.plot(training_scores, label="training scores") plt.plot(test_scores, label="test scores") plt.xticks(range(4), [100, 10, 1, .01]) plt.xlabel('alpha') plt.legend(loc="best") plt.figure(figsize=(10, 5)) plt.plot(true_coefficient[coefficient_sorting], "o", label="true", c='b') for i, alpha in enumerate([100, 10, 1, .01]): plt.plot(ridge_models[alpha].coef_[coefficient_sorting], "o", label="alpha = %.2f" % alpha, c=plt.cm.viridis(i / 3.)) plt.legend(loc="best") 调整alpha对表现至关重要。 plt.figure() plot_learning_curve(LinearRegression(), X, y) plot_learning_curve(Ridge(alpha=10), X, y) Lasso(L1 惩罚) Lasso估计器可用于对系数施加稀疏性。 换句话说,如果我们认为许多特征不相关,那么我们会更喜欢它。 这是通过所谓的 l1 惩罚来完成的。 from sklearn.linear_model import Lasso lasso_models = {} training_scores = [] test_scores = [] for alpha in [30, 10, 1, .01]: lasso = Lasso(alpha=alpha).fit(X_train, y_train) training_scores.append(lasso.score(X_train, y_train)) test_scores.append(lasso.score(X_test, y_test)) lasso_models[alpha] = lasso plt.figure() plt.plot(training_scores, label="training scores") plt.plot(test_scores, label="test scores") plt.xticks(range(4), [30, 10, 1, .01]) plt.legend(loc="best") plt.figure(figsize=(10, 5)) plt.plot(true_coefficient[coefficient_sorting], "o", label="true", c='b') for i, alpha in enumerate([30, 10, 1, .01]): plt.plot(lasso_models[alpha].coef_[coefficient_sorting], "o", label="alpha = %.2f" % alpha, c=plt.cm.viridis(i / 3.)) plt.legend(loc="best") plt.figure(figsize=(10, 5)) plot_learning_curve(LinearRegression(), X, y) plot_learning_curve(Ridge(alpha=10), X, y) plot_learning_curve(Lasso(alpha=10), X, y) 你也可以使用ElasticNet,而不是选择Ridge或Lasso,它使用两种形式的正则化,并提供一个参数来指定它们之间的权重。ElasticNet通常在这些模型中表现最佳。 用于分类的线性模型 用于分类的所有线性模型学习系数参数coef_和偏移intercept_,来使用线性特征组合做出预测: y_pred = x_test[0] * coef_[0] + ... + x_test[n_features-1] * coef_[n_features-1] + intercept_ > 0 如你所见,这与回归非常相似,只是应用了零处的阈值。 同样,用于分类的线性模型之间的区别是,对coef_和intercept_施加什么类型的正则化,但是在如何测量训练集的拟合(所谓的损失函数)方面也存在微小差异。 线性分类的两种最常见的模型是LinearSVC实现的线性 SVM,和LogisticRegression。 线性分类器的正则化的良好直觉是,使用高正则化,如果大多数点被正确分类就足够了。 但使用较少的正则化,每个数据点的重要性也越来越高。这里使用具有不同C值的线性 SVM 来说明。 LinearSVC中C的影响 在LinearSVC中,C参数控制模型中的正则化。 较低的C产生更多的正则化和更简单的模型,而较高的C产生较少的正则化和来自各个数据点的更多影响。 from figures import plot_linear_svc_regularization plot_linear_svc_regularization() 与Ridge/Lasso划分类似,你可以将penalty参数设置为'l1'来强制系数的稀疏性(类似于Lasso)或'l2'来鼓励更小的系数(类似于Ridge)。 多类线性分类 from sklearn.datasets import make_blobs plt.figure() X, y = make_blobs(random_state=42) plt.figure(figsize=(8, 8)) plt.scatter(X[:, 0], X[:, 1], c=plt.cm.tab10(y)) from sklearn.svm import LinearSVC linear_svm = LinearSVC().fit(X, y) print(linear_svm.coef_.shape) print(linear_svm.intercept_.shape) plt.figure(figsize=(8, 8)) plt.scatter(X[:, 0], X[:, 1], c=plt.cm.tab10(y)) line = np.linspace(-15, 15) for coef, intercept in zip(linear_svm.coef_, linear_svm.intercept_): plt.plot(line, -(line * coef[0] + intercept) / coef[1]) plt.ylim(-10, 15) plt.xlim(-10, 8); 点以一对多(OVR)的方式分类(又名 OVA),我们将测试点分配给模型对测试点具有最高置信度的类(在 SVM 情况下,与分隔超平面的距离最大)。 练习 使用LogisticRegression来分类数字数据集,并网格搜索C参数。 当你增加或减少alpha时,你认为上面的学习曲线如何变化? 尝试更改岭和Lasso中的alpha参数,看看你的直觉是否正确。 from sklearn.datasets import load_digits from sklearn.linear_model import LogisticRegression digits = load_digits() X_digits, y_digits = digits.data, digits.target # split the dataset, apply grid-search # %load solutions/17A_logreg_grid.py # %load solutions/17B_learning_curve_alpha.py 十八、深入:决策树与森林 在这里,我们将探索一类基于决策树的算法。 最基本决策树非常直观。 它们编码一系列if和else选项,类似于一个人如何做出决定。 但是,从数据中完全可以了解要问的问题以及如何处理每个答案。 例如,如果你想创建一个识别自然界中发现的动物的指南,你可能会问以下一系列问题: 动物是大于还是小于一米? 较大:动物有角吗? 是的:角长是否超过十厘米? 不是:动物有项圈吗? 较小:动物有两条腿还是四条腿? 二:动物有翅膀吗? 四:动物有浓密的尾巴吗? 等等。 这种问题的二元分裂是决策树的本质。 基于树的模型的主要好处之一是它们几乎不需要数据预处理。 它们可以处理不同类型的变量(连续和离散),并且对特征的缩放不变。 另一个好处是基于树的模型被称为“非参数”,这意味着他们没有一套固定的参数需要学习。 相反,如果给出更多数据,树模型可以变得越来越灵活。 换句话说,自由参数的数量随着样本量而增长并且不是固定的,例如在线性模型中。 决策树回归 决策树是一种简单的二元分类树,类似于最近邻分类。 它可以这样使用: from figures import make_dataset x, y = make_dataset() X = x.reshape(-1, 1) plt.figure() plt.xlabel('Feature X') plt.ylabel('Target y') plt.scatter(X, y); from sklearn.tree import DecisionTreeRegressor reg = DecisionTreeRegressor(max_depth=5) reg.fit(X, y) X_fit = np.linspace(-3, 3, 1000).reshape((-1, 1)) y_fit_1 = reg.predict(X_fit) plt.figure() plt.plot(X_fit.ravel(), y_fit_1, color='tab:blue', label="prediction") plt.plot(X.ravel(), y, 'C7.', label="training data") plt.legend(loc="best"); 单个决策树允许我们以非参数方式估计标签,但显然存在一些问题。 在某些地区,该模型表现出高偏差并且对数据欠拟合。 (请见不遵循数据轮廓的长扁形线条),而在其他区域,模型表现高方差并且过拟合数据(反映为受单点噪声影响的窄峰形)。 决策树分类 决策树分类原理非常相似,通过将叶子中的多数类分配给叶子中的所有点: from sklearn.datasets import make_blobs from sklearn.model_selection import train_test_split from sklearn.tree import DecisionTreeClassifier from figures import plot_2d_separator from figures import cm2 X, y = make_blobs(centers=[[0, 0], [1, 1]], random_state=61526, n_samples=100) X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42) clf = DecisionTreeClassifier(max_depth=5) clf.fit(X_train, y_train) plt.figure() plot_2d_separator(clf, X, fill=True) plt.scatter(X_train[:, 0], X_train[:, 1], c=y_train, cmap=cm2, s=60, alpha=.7, edgecolor='k') plt.scatter(X_test[:, 0], X_test[:, 1], c=y_test, cmap=cm2, s=60, edgecolor='k'); 有许多参数可以控制树的复杂性,但最容易理解的是最大深度。这限制了树可以对输入空间进行划分的精确度,或者在决定样本所在的类之前,可以询问多少if-else问题。 此参数对于调整树和基于树的模型非常重要。下面的交互式图表显示了该模型的欠拟合和过拟合。 max_depth为 1 显然是一个欠拟合的模型,而 7 或 8 的深度明显过拟合。对于该数据集,树可以生长的最大深度是 8,此时每个叶仅包含来自单个类的样本。这被称为所有叶子都是“纯的”。 在下面的交互式图中,区域被指定为蓝色和红色,来表明该区域的预测类。颜色的阴影表示该类的预测概率(较暗为较高概率),而黄色区域表示任一类的预测概率相等。 from figures import plot_tree max_depth = 3 plot_tree(max_depth=max_depth) 决策树训练快,易于理解,并且经常产生可解释的模型。 但是,单个树通常倾向于过拟合训练数据。 使用上面的滑块,你可能会注意到,即使在类之间有良好的分隔之前,模型也会开始过拟合。 因此,在实践中,更常见的是组合多个树来产生更好泛化的模型。 组合树的最常用方法是随机森林和梯度提升树。 随机森林 随机森林只是许多树,建立在数据的不同随机子集(带放回抽样)上,并对于每个分裂,使用特征的不同随机子集(无放回抽样)。 这使得树彼此不同,并使它们过拟合不同的方面。 然后,他们的预测被平均,产生更平稳的估计,更少过拟合。 from figures import plot_forest max_depth = 3 plot_forest(max_depth=max_depth) 通过交叉验证选择最优估计 from sklearn.model_selection import GridSearchCV from sklearn.datasets import load_digits from sklearn.ensemble import RandomForestClassifier digits = load_digits() X, y = digits.data, digits.target X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42) rf = RandomForestClassifier(n_estimators=200) parameters = {'max_features':['sqrt', 'log2', 10], 'max_depth':[5, 7, 9]} clf_grid = GridSearchCV(rf, parameters, n_jobs=-1) clf_grid.fit(X_train, y_train) clf_grid.score(X_train, y_train) clf_grid.score(X_test, y_test) 另一个选项:梯度提升 可能有用的另一种集合方法是提升:在这里,我们构建了一个由 200 个估计器组成的链,它迭代地改进了先前估计器的结果,而不是查看(比方说)200 个并行估计器。 我们的想法是,通过顺序应用非常快速,简单的模型,我们可以获得比任何单个部分更好的总模型误差。 from sklearn.ensemble import GradientBoostingRegressor clf = GradientBoostingRegressor(n_estimators=100, max_depth=5, learning_rate=.2) clf.fit(X_train, y_train) print(clf.score(X_train, y_train)) print(clf.score(X_test, y_test)) 练习:梯度提升的交叉验证 使用网格搜索在数字数据集上优化梯度提升树learning_rate和max_depth。 from sklearn.datasets import load_digits from sklearn.ensemble import GradientBoostingClassifier digits = load_digits() X_digits, y_digits = digits.data, digits.target # split the dataset, apply grid-search # %load solutions/18_gbc_grid.py 特征的重要性 RandomForest和GradientBoosting对象在拟合之后都会提供feature_importances_属性。 此属性是这些模型最强大的功能之一。 它们基本上量化了在不同树的节点中,每个特征对表现的贡献程度。 X, y = X_digits[y_digits < 2], y_digits[y_digits < 2] rf = RandomForestClassifier(n_estimators=300, n_jobs=1) rf.fit(X, y) print(rf.feature_importances_) # one value per feature plt.figure() plt.imshow(rf.feature_importances_.reshape(8, 8), cmap=plt.cm.viridis, interpolation='nearest') 十九、自动特征选择 我们经常收集许多可能与监督预测任务相关的特征,但我们不知道它们中的哪一个实际上是预测性的。 为了提高可解释性,有时还提高泛化表现,我们可以使用自动特征选择来选择原始特征的子集。 有几种可用的特征选择方法,我们将按照复杂性的升序来解释。 对于给定的监督模型,最佳特征选择策略是尝试每个可能的特征子集,并使用该子集评估泛化表现。 但是,特征子集是指数级,因此这种详尽的搜索通常是不可行的。 下面讨论的策略可以被认为是这种不可行计算的替代。 单变量统计 选择要素的最简单方法是使用单变量统计,即通过单独查看每个特征并运行统计检验,来查看它是否与目标相关。 这种检验也称为方差分析(ANOVA)。 我们创建了一个人造数据集,其中包含乳腺癌数据和另外 50 个完全随机的特征。 from sklearn.datasets import load_breast_cancer, load_digits from sklearn.model_selection import train_test_split cancer = load_breast_cancer() # get deterministic random numbers rng = np.random.RandomState(42) noise = rng.normal(size=(len(cancer.data), 50)) # add noise features to the data # the first 30 features are from the dataset, the next 50 are noise X_w_noise = np.hstack([cancer.data, noise]) X_train, X_test, y_train, y_test = train_test_split(X_w_noise, cancer.target, random_state=0, test_size=.5) 我们必须在统计检验的 p 值上定义一个阈值,来决定要保留多少特征。 在 scikit-learn 中实现了几种策略,一种直接的策略是SelectPercentile,它选择原始特征的百分位数(下面我们选择 50%): from sklearn.feature_selection import SelectPercentile # use f_classif (the default) and SelectPercentile to select 50% of features: select = SelectPercentile(percentile=50) select.fit(X_train, y_train) # transform training set: X_train_selected = select.transform(X_train) print(X_train.shape) print(X_train_selected.shape) 我们还可以直接使用检验统计量,来查看每个特征的相关性。 由于乳腺癌数据集是一项分类任务,我们使用f_classif,F 检验用于分类。 下面我们绘制 p 值,与 80 个特征中的每一个相关(30 个原始特征和 50 个噪声特征)。 低 p 值表示信息性特征。 from sklearn.feature_selection import f_classif, f_regression, chi2 F, p = f_classif(X_train, y_train) plt.figure() plt.plot(p, 'o') 显然,前 30 个特征中的大多数具有非常小的 p 值。 回到SelectPercentile转换器,我们可以使用get_support方法获得所选特征: mask = select.get_support() print(mask) # 展示掩码。黑色是真,白色是假 plt.matshow(mask.reshape(1, -1), cmap='gray_r') 几乎所有最初的 30 个特征都被还原了。 我们还可以通过在数据上训练监督模型,来分析特征选择的效果。 仅在训练集上学习特征选择非常重要! from sklearn.linear_model import LogisticRegression # 转换测试数据 X_test_selected = select.transform(X_test) lr = LogisticRegression() lr.fit(X_train, y_train) print("Score with all features: %f" % lr.score(X_test, y_test)) lr.fit(X_train_selected, y_train) print("Score with only selected features: %f" % lr.score(X_test_selected, y_test)) 基于模型的特征选择 用于特征选择的稍微复杂的方法,是使用监督机器学习模型,并基于模型认为它们的重要性来选择特征。 这要求模型提供某种方法,按重要性对特征进行排名。 这适用于所有基于树的模型(实现get_feature_importances)和所有线性模型,系数可用于确定特征对结果的影响程度。 任何这些模型都可以制作成变换器,通过使用SelectFromModel类包装它,用于特征选择: from sklearn.feature_selection import SelectFromModel from sklearn.ensemble import RandomForestClassifier select = SelectFromModel(RandomForestClassifier(n_estimators=100, random_state=42), threshold="median") select.fit(X_train, y_train) X_train_rf = select.transform(X_train) print(X_train.shape) print(X_train_rf.shape) mask = select.get_support() # 展示掩码。黑色是真,白色是假 plt.matshow(mask.reshape(1, -1), cmap='gray_r') X_test_rf = select.transform(X_test) LogisticRegression().fit(X_train_rf, y_train).score(X_test_rf, y_test) 此方法构建单个模型(在本例中为随机森林)并使用此模型中的特征重要性。 我们可以通过在数据子集上训练多个模型,来进行更精细的搜索。 一种特殊的策略是递归特征消除: 递归特征消除 递归特征消除在整个特征集上构建模型,类似于上述方法,选择模型认为最重要的特征子集。 但是,通常只会从数据集中删除单个要素,并使用其余要素构建新模型。 重复删除特征和模型构建的过程,直到只剩下预定数量的特征: from sklearn.feature_selection import RFE select = RFE(RandomForestClassifier(n_estimators=100, random_state=42), n_features_to_select=40) select.fit(X_train, y_train) # 可视化所选特征 mask = select.get_support() plt.matshow(mask.reshape(1, -1), cmap='gray_r') X_train_rfe = select.transform(X_train) X_test_rfe = select.transform(X_test) LogisticRegression().fit(X_train_rfe, y_train).score(X_test_rfe, y_test) select.score(X_test, y_test) 练习 创建“XOR”数据集,如下面的第一个单元格: 添加随机特征,并使用随机森林,在还原原始特征时,比较单变量选择与基于模型的选择。 import numpy as np rng = np.random.RandomState(1) # 在 [0,1] 范围内生成 400 个随机整数 X = rng.randint(0, 2, (200, 2)) y = np.logical_xor(X[:, 0] > 0, X[:, 1] > 0) # XOR creation plt.scatter(X[:, 0], X[:, 1], c=plt.cm.tab10(y)) # %load solutions/19_univariate_vs_mb_selection.py 二十、无监督学习:层次和基于密度的聚类算法 在第八章中,我们介绍了一种必不可少且广泛使用的聚类算法 K-means。 K-means 的一个优点是它非常容易实现,并且与其他聚类算法相比,它在计算上也非常有效。 然而,我们已经看到 K-Means 的一个缺点是它只有在数据可以分组为球形时才能正常工作。 此外,我们必须事先指定簇的数量k - 如果我们没有我们期望找到多少个簇的先验知识,这可能是一个问题。 在本笔记本中,我们将介绍两种可选的聚类方法,层次聚类和基于密度的聚类。 层次聚类 层次聚类的一个很好的特性是,我们可以将结果可视化为树状图,即层次树。 使用可视化,我们可以通过设置“深度”阈值来决定我们希望数据集的簇有多“深”。 或者换句话说,我们不需要预先决定簇的数量。 聚合和分裂的层次聚类 此外,我们可以区分两种主要的层次聚类方法:分裂聚类和聚合聚类。 在聚合聚类中,我们从数据集中的单个样本开始,并迭代地将其与其他样本合并以形成簇 - 我们可以将其视为构建簇的树状图的自底向上的方法。 然而,在分裂聚类中,我们从作为一个簇的整个数据集开始,并且我们迭代地将其拆分成更小的子簇 - 自顶向下的方法。 在这个笔记本中,我们将使用聚合聚类。 单个和完整链接 现在,下一个问题是我们如何测量样本之间的相似性。 一种方法是我们已经在 K-Means 算法中使用的,熟悉的欧几里德距离度量。 作为回顾,两个m维向量p和q之间的距离可以计算为: 然而,这是两个个样本之间的距离。 现在,我们如何计算样本子集之间的相似性,以便在构建树状图时决定合并哪些簇? 即,我们的目标是迭代地合并最相似的一对簇,直到只剩下一个大簇。 有许多不同的方法,例如单个和完整链接。 在单个链接中,我们在每两个簇中选取一对最相似的样本(例如,基于欧几里德距离),并将具有最相似的两个成员的两个簇合并为一个新的更大的簇。 在完整链接中,我们比较每两个簇的两个最不相似的成员,并且我们合并两个簇,其中两个最不相似的成员之间的距离最小。 译者注:还有比较两个簇形心的方法,算是一种折中。 为了看到实际的聚合层次聚类方法,让我们加载熟悉的鸢尾花数据集 - 我们假装不知道真正的类标签,并想要找出它包含多少不同的物种: from sklearn.datasets import load_iris from figures import cm3 iris = load_iris() X = iris.data[:, [2, 3]] y = iris.target n_samples, n_features = X.shape plt.scatter(X[:, 0], X[:, 1], c=y, cmap=cm3) 首先,我们从一些探索性聚类开始,使用 SciPy 的linkage和dendrogram函数来可视化簇的树状图: from scipy.cluster.hierarchy import linkage from scipy.cluster.hierarchy import dendrogram clusters = linkage(X, metric='euclidean', method='complete') dendr = dendrogram(clusters) plt.ylabel('Euclidean Distance') 接下来,让我们使用来自 scikit-learn 的AgglomerativeClustering估计器,并将数据集划分为 3 个簇。你能猜出它会重现的树状图中有哪 3 个簇吗? from sklearn.cluster import AgglomerativeClustering ac = AgglomerativeClustering(n_clusters=3, affinity='euclidean', linkage='complete') prediction = ac.fit_predict(X) print('Cluster labels: %s\n' % prediction) plt.scatter(X[:, 0], X[:, 1], c=prediction, cmap=cm3) 基于密度的聚类 - DBSCAN 另一种有用的聚类方法是“具有噪声的基于密度的聚类方法”(DBSCAN)。 本质上,我们可以将 DBSCAN 视为一种算法,该算法根据密集的点区域将数据集划分为子分组。 在 DBSCAN 中,我们区分了 3 种不同的“点”: 核心点:核心点是一个点,在其半径epsilon内,至少具有最小数量(MinPts)的其他点。 边界点:边界点是一个点,它不是核心点,因为它的邻域中没有足够的MinPts,但位于核心点的半径epsilon内。 噪点:所有其他的点,既不是核心点也不是边界点。 DBSCAN 的一个很好的特性是我们不必预先指定多少个簇。 但是,它需要设置其他超参数,例如MinPts的值和半径epsilon。 from sklearn.datasets import make_moons X, y = make_moons(n_samples=400, noise=0.1, random_state=1) plt.scatter(X[:,0], X[:,1]) plt.show() from sklearn.cluster import DBSCAN db = DBSCAN(eps=0.2, min_samples=10, metric='euclidean') prediction = db.fit_predict(X) print("Predicted labels:\n", prediction) plt.scatter(X[:, 0], X[:, 1], c=prediction, cmap=cm3) 练习 使用以下玩具数据集,两个同心圆,尝试我们到目前为止使用的三种不同的聚类算法:KMeans,AgglomerativeClustering和DBSCAN。 哪种聚类算法能够最好地再现或发现隐藏的结构(假装我们不知道y)? 你能解释为什么这个特殊的算法是一个不错的选择,而另外两个“失败”了? from sklearn.datasets import make_circles X, y = make_circles(n_samples=1500, factor=.4, noise=.05) plt.scatter(X[:, 0], X[:, 1], c=y); # %load solutions/20_clustering_comparison.py 二十一、无监督学习:非线性降维 流形学习 PCA 的一个弱点是它无法检测到非线性特征。 已经开发了一组称为流形学习的算法,来解决这个缺陷。流形学习中使用的规范数据集是 S 曲线: from sklearn.datasets import make_s_curve X, y = make_s_curve(n_samples=1000) from mpl_toolkits.mplot3d import Axes3D ax = plt.axes(projection='3d') ax.scatter3D(X[:, 0], X[:, 1], X[:, 2], c=y) ax.view_init(10, -60); 这是一个嵌入三维的二维数据集,但它以某种方式嵌入,PCA 无法发现底层数据方向: from sklearn.decomposition import PCA X_pca = PCA(n_components=2).fit_transform(X) plt.scatter(X_pca[:, 0], X_pca[:, 1], c=y); 然而,sklearn.manifold子模块中可用的流形学习算法能够还原底层的二维流形: from sklearn.manifold import Isomap iso = Isomap(n_neighbors=15, n_components=2) X_iso = iso.fit_transform(X) plt.scatter(X_iso[:, 0], X_iso[:, 1], c=y); 数字数据上的流形学习 我们可以将流形学习技术应用于更高维度的数据集,例如我们之前看到的数字数据: from sklearn.datasets import load_digits digits = load_digits() fig, axes = plt.subplots(2, 5, figsize=(10, 5), subplot_kw={'xticks':(), 'yticks': ()}) for ax, img in zip(axes.ravel(), digits.images): ax.imshow(img, interpolation="none", cmap="gray") 我们可以使用线性技术(例如 PCA)可视化数据集。 我们看到这已经提供了一些数据的直觉: # 构建 PCA 模型 pca = PCA(n_components=2) pca.fit(digits.data) # 将数字数据转换为前两个主成分 digits_pca = pca.transform(digits.data) colors = ["#476A2A", "#7851B8", "#BD3430", "#4A2D4E", "#875525", "#A83683", "#4E655E", "#853541", "#3A3120","#535D8E"] plt.figure(figsize=(10, 10)) plt.xlim(digits_pca[:, 0].min(), digits_pca[:, 0].max() + 1) plt.ylim(digits_pca[:, 1].min(), digits_pca[:, 1].max() + 1) for i in range(len(digits.data)): # 实际上将数字绘制为文本而不是使用散点图 plt.text(digits_pca[i, 0], digits_pca[i, 1], str(digits.target[i]), color = colors[digits.target[i]], fontdict={'weight': 'bold', 'size': 9}) plt.xlabel("first principal component") plt.ylabel("second principal component"); 但是,使用更强大的非线性技术可以提供更好的可视化效果。 在这里,我们使用 t-SNE 流形学习方法: from sklearn.manifold import TSNE tsne = TSNE(random_state=42) # 使用 fit_transform 而不是 fit,因为 TSNE 没有 fit 方法 digits_tsne = tsne.fit_transform(digits.data) plt.figure(figsize=(10, 10)) plt.xlim(digits_tsne[:, 0].min(), digits_tsne[:, 0].max() + 1) plt.ylim(digits_tsne[:, 1].min(), digits_tsne[:, 1].max() + 1) for i in range(len(digits.data)): # 实际上将数字绘制为文本而不是使用散点图 plt.text(digits_tsne[i, 0], digits_tsne[i, 1], str(digits.target[i]), color = colors[digits.target[i]], fontdict={'weight': 'bold', 'size': 9}) t-SNE 比其他流形学习算法运行时间更长,但结果非常惊人。 请记住,此算法纯粹是无监督的,并且不知道类标签。 它仍然能够很好地分离类别(尽管类 4 和 类 9 已被分成多个分组)。 练习 将 isomap 应用于数字数据集的结果与 PCA 和 t-SNE 的结果进行比较。 你认为哪个结果看起来最好? 鉴于 t-SNE 很好地将类别分开,人们可能会试图将这个处理过程用于分类。 尝试在使用 t-SNE 转换的数字数据上,训练 K 最近邻分类器,并与没有任何转换的数据集上的准确性比较。 # %load solutions/21A_isomap_digits.py # %load solutions/21B_tsne_classification.py 二十二、无监督学习:异常检测 常检测是一种机器学习任务,包括发现所谓的异常值。 “异常值是一种数据集中的观测值,似乎与该组数据的其余部分不一致。”-- Johnson 1992 “异常值是一种观测值,与其他观测值有很大差异,引起人们怀疑它是由不同的机制产生的。”-- Outlier/Anomaly Hawkins 1980 异常检测设定的类型 监督 AD 标签可用于正常和异常数据 类似于稀有类挖掘/不平衡分类 半监督 AD(新奇检测) 只有正常的数据可供训练 该算法仅学习正常数据 无监督 AD(异常值检测) 没有标签,训练集 = 正常 + 异常数据 假设:异常非常罕见 %matplotlib inline import warnings warnings.filterwarnings("ignore") import numpy as np import matplotlib import matplotlib.pyplot as plt 让我们首先熟悉不同的无监督异常检测方法和算法。 为了可视化不同算法的输出,我们考虑包含二维高斯混合的玩具数据集。 生成数据集 from sklearn.datasets import make_blobs X, y = make_blobs(n_features=2, centers=3, n_samples=500, random_state=42) X.shape plt.figure() plt.scatter(X[:, 0], X[:, 1]) plt.show() 使用密度估计的异常检测 from sklearn.neighbors.kde import KernelDensity # 用高斯核密度估计器估算密度 kde = KernelDensity(kernel='gaussian') kde = kde.fit(X) kde kde_X = kde.score_samples(X) print(kde_X.shape) # 包含数据的对数似然。 越小样本越罕见 from scipy.stats.mstats import mquantiles alpha_set = 0.95 tau_kde = mquantiles(kde_X, 1. - alpha_set) n_samples, n_features = X.shape X_range = np.zeros((n_features, 2)) X_range[:, 0] = np.min(X, axis=0) - 1. X_range[:, 1] = np.max(X, axis=0) + 1. h = 0.1 # step size of the mesh x_min, x_max = X_range[0] y_min, y_max = X_range[1] xx, yy = np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h)) grid = np.c_[xx.ravel(), yy.ravel()] Z_kde = kde.score_samples(grid) Z_kde = Z_kde.reshape(xx.shape) plt.figure() c_0 = plt.contour(xx, yy, Z_kde, levels=tau_kde, colors='red', linewidths=3) plt.clabel(c_0, inline=1, fontsize=15, fmt={tau_kde[0]: str(alpha_set)}) plt.scatter(X[:, 0], X[:, 1]) plt.show() 单类 SVM 基于密度的估计的问题在于,当数据的维数增加时,它们往往变得低效。 这就是所谓的维度灾难,尤其会影响密度估算算法。 在这种情况下可以使用单类 SVM 算法。 from sklearn.svm import OneClassSVM nu = 0.05 # theory says it should be an upper bound of the fraction of outliers ocsvm = OneClassSVM(kernel='rbf', gamma=0.05, nu=nu) ocsvm.fit(X) X_outliers = X[ocsvm.predict(X) == -1] Z_ocsvm = ocsvm.decision_function(grid) Z_ocsvm = Z_ocsvm.reshape(xx.shape) plt.figure() c_0 = plt.contour(xx, yy, Z_ocsvm, levels=[0], colors='red', linewidths=3) plt.clabel(c_0, inline=1, fontsize=15, fmt={0: str(alpha_set)}) plt.scatter(X[:, 0], X[:, 1]) plt.scatter(X_outliers[:, 0], X_outliers[:, 1], color='red') plt.show() 支持向量 - 离群点 所谓的单类 SVM 的支持向量形成离群点。 X_SV = X[ocsvm.support_] n_SV = len(X_SV) n_outliers = len(X_outliers) print('{0:.2f} <= {1:.2f} <= {2:.2f}?'.format(1./n_samples*n_outliers, nu, 1./n_samples*n_SV)) 只有支持向量涉及单类 SVM 的决策函数。 绘制单类 SVM 决策函数的级别集,就像我们对真实密度所做的那样。 突出支持向量。 plt.figure() plt.contourf(xx, yy, Z_ocsvm, 10, cmap=plt.cm.Blues_r) plt.scatter(X[:, 0], X[:, 1], s=1.) plt.scatter(X_SV[:, 0], X_SV[:, 1], color='orange') plt.show() 练习 更改`gamma``参数并查看它对决策函数平滑度的影响。 # %load solutions/22_A-anomaly_ocsvm_gamma.py 隔离森林 隔离森林是一种基于树的异常检测算法。 该算法构建了许多随机树,其基本原理是,如果样本被隔离,在非常少量的随机分割之后,它应该单独存在于叶子中。 隔离森林根据样本最终所在的树的深度建立异常得分。 from sklearn.ensemble import IsolationForest iforest = IsolationForest(n_estimators=300, contamination=0.10) iforest = iforest.fit(X) Z_iforest = iforest.decision_function(grid) Z_iforest = Z_iforest.reshape(xx.shape) plt.figure() c_0 = plt.contour(xx, yy, Z_iforest, levels=[iforest.threshold_], colors='red', linewidths=3) plt.clabel(c_0, inline=1, fontsize=15, fmt={iforest.threshold_: str(alpha_set)}) plt.scatter(X[:, 0], X[:, 1], s=1.) plt.show() 练习 以图形方式说明树的数量对决策函数平滑度的影响。 # %load solutions/22_B-anomaly_iforest_n_trees.py 数字数据集上的图解 我们现在将应用IsolationForest算法来查找以非常规方式编写的数字。 from sklearn.datasets import load_digits digits = load_digits() 数字数据集包括8×8的数字图像。 images = digits.images labels = digits.target images.shape i = 102 plt.figure(figsize=(2, 2)) plt.title('{0}'.format(labels[i])) plt.axis('off') plt.imshow(images[i], cmap=plt.cm.gray_r, interpolation='nearest') plt.show() 要将图像用作训练集,我们需要将图像展开。 n_samples = len(digits.images) data = digits.images.reshape((n_samples, -1)) data.shape X = data y = digits.target X.shape 让我们关注数字 5。 X_5 = X[y == 5] X_5.shape fig, axes = plt.subplots(1, 5, figsize=(10, 4)) for ax, x in zip(axes, X_5[:5]): img = x.reshape(8, 8) ax.imshow(img, cmap=plt.cm.gray_r, interpolation='nearest') ax.axis('off') 让我们使用IsolationForest来查找前 5% 最异常的图像。 让我们绘制他们吧! from sklearn.ensemble import IsolationForest iforest = IsolationForest(contamination=0.05) iforest = iforest.fit(X_5) 使用iforest.decision_function计算“异常”的级别。越低就越异常。 iforest_X = iforest.decision_function(X_5) plt.hist(iforest_X); 让我们绘制最强的正常值。 X_strong_inliers = X_5[np.argsort(iforest_X)[-10:]] fig, axes = plt.subplots(2, 5, figsize=(10, 5)) for i, ax in zip(range(len(X_strong_inliers)), axes.ravel()): ax.imshow(X_strong_inliers[i].reshape((8, 8)), cmap=plt.cm.gray_r, interpolation='nearest') ax.axis('off') 让我们绘制最强的异常值。 fig, axes = plt.subplots(2, 5, figsize=(10, 5)) X_outliers = X_5[iforest.predict(X_5) == -1] for i, ax in zip(range(len(X_outliers)), axes.ravel()): ax.imshow(X_outliers[i].reshape((8, 8)), cmap=plt.cm.gray_r, interpolation='nearest') ax.axis('off') 练习 用所有其他数字重新运行相同的分析。 # %load solutions/22_C-anomaly_digits.py 二十三、核外学习 - 用于语义分析的大规模文本分类 可扩展性问题 sklearn.feature_extraction.text.CountVectorizer和sklearn.feature_extraction.text.TfidfVectorizer类受到许多可伸缩性问题的困扰,这些问题都源于vocabulary_属性(Python 字典)的内部使用,它用于将 unicode 字符串特征名称映射为整数特征索引。 主要的可扩展性问题是: 文本向量化程序的内存使用情况:所有特征的字符串表示形式都加载到内存中 文本特征提取的并行化问题:vocabulary_是一个共享状态:复杂的同步和开销 不可能进行在线或核外/流式学习:vocabulary_需要从数据中学习:在遍历一次整个数据集之前无法知道其大小 为了更好地理解这个问题,让我们看一下vocabulary_属性的工作原理。 在fit的时候,语料库的标记由整数索引唯一标识,并且该映射存储在词汇表中: from sklearn.feature_extraction.text import CountVectorizer vectorizer = CountVectorizer(min_df=1) vectorizer.fit([ "The cat sat on the mat.", ]) vectorizer.vocabulary_ 在transform的时候,使用词汇表来构建出现矩阵: X = vectorizer.transform([ "The cat sat on the mat.", "This cat is a nice cat.", ]).toarray() print(len(vectorizer.vocabulary_)) print(vectorizer.get_feature_names()) print(X) 让我们用稍大的语料库重新拟合: vectorizer = CountVectorizer(min_df=1) vectorizer.fit([ "The cat sat on the mat.", "The quick brown fox jumps over the lazy dog.", ]) vectorizer.vocabulary_ vocabulary_随着训练语料库的大小而(以对数方式)增长。 请注意,我们无法在 2 个文本文档上并行构建词汇表,因为它们共享一些单词,因此需要某种共享数据结构或同步障碍,这对于设定来说很复杂,特别是如果我们想要将处理过程分发给集群的时候。 有了这个新的词汇表,输出空间的维度现在变大了: X = vectorizer.transform([ "The cat sat on the mat.", "This cat is a nice cat.", ]).toarray() print(len(vectorizer.vocabulary_)) print(vectorizer.get_feature_names()) print(X) IMDB 电影数据集 为了说明基于词汇的向量化器的可扩展性问题,让我们为经典文本分类任务加载更真实的数据集:文本文档的情感分析。目标是从互联网电影数据库(IMDb)中区分出积极的电影评论。 在接下来的章节中,使用了 Maas 等人收集的来自 IMDb 的电影评论的大型子集。 A. L. Maas, R. E. Daly, P. T. Pham, D. Huang, A. Y. Ng, and C. Potts. Learning Word Vectors for Sentiment Analysis. In the proceedings of the 49th Annual Meeting of the Association for Computational Linguistics: Human Language Technologies, pages 142–150, Portland, Oregon, USA, June 2011. Association for Computational Linguistics. 该数据集包含 50,000 个电影评论,分为 25,000 个培训样本和 25,000 个测试样本。评论标记为负面(neg)或正面(pos)。此外,正面意味着电影在 IMDb 上收到> 6星;负面意味着电影收到<5星。 假设../fetch_data.py脚本成功运行,以下文件应该可用: import os train_path = os.path.join('datasets', 'IMDb', 'aclImdb', 'train') test_path = os.path.join('datasets', 'IMDb', 'aclImdb', 'test') 现在,让我们通过 scikit-learn 的load_files函数,将它们加载到我们的活动会话中: from sklearn.datasets import load_files train = load_files(container_path=(train_path), categories=['pos', 'neg']) test = load_files(container_path=(test_path), categories=['pos', 'neg']) 注 由于电影数据集由 50,000 个单独的文本文件组成,因此执行上面的代码片段可能需要约 20 秒或更长时间。 load_files函数将数据集加载到sklearn.datasets.base.Bunch对象中,这些对象是 Python 字典: train.keys() 特别是,我们只对data和target数组感兴趣。 import numpy as np for label, data in zip(('TRAINING', 'TEST'), (train, test)): print('\n\n%s' % label) print('Number of documents:', len(data['data'])) print('\n1st document:\n', data['data'][0]) print('\n1st label:', data['target'][0]) print('\nClass names:', data['target_names']) print('Class count:', np.unique(data['target']), ' -> ', np.bincount(data['target'])) 正如我们在上面所看到的,target数组由整数 0 和 1 组成,其中 0 代表负面,1 代表正面。 哈希技巧 回忆一下,使用基于词汇表的向量化器的词袋表示: 要解决基于词汇表的向量化器的局限性,可以使用散列技巧。 我们可以使用散列函数和模运算,而不是在 Python 字典中构建和存储特征名称到特征索引的显式映射: 对于哈希技巧的原始论文的更多信息和参考,请见以下网站,以及特定于语言的描述请见这里。 from sklearn.utils.murmurhash import murmurhash3_bytes_u32 # encode for python 3 compatibility for word in "the cat sat on the mat".encode("utf-8").split(): print("{0} => {1}".format( word, murmurhash3_bytes_u32(word, 0) % 2 ** 20)) 这种映射完全是无状态的,并且输出空间的维度预先明确固定(这里我们使用2 ** 20的模,这意味着大约 1M 的维度)。 这使得有可能解决基于词汇表的向量化器的局限性,既可用于并行化,也可用于在线/核外学习。 HashingVectorizer类是CountVectorizer(或use_idf=False的TfidfVectorizer类)的替代品,它在内部使用 murmurhash 哈希函数: from sklearn.feature_extraction.text import HashingVectorizer h_vectorizer = HashingVectorizer(encoding='latin-1') h_vectorizer 它共享相同的“预处理器”,“分词器”和“分析器”基础结构: analyzer = h_vectorizer.build_analyzer() analyzer('This is a test sentence.') 我们可以将数据集向量化为scipy稀疏矩阵,就像我们使用CountVectorizer或TfidfVectorizer一样,除了我们可以直接调用transform方法:没有必要拟合,因为HashingVectorizer是无状态变换器: docs_train, y_train = train['data'], train['target'] docs_valid, y_valid = test['data'][:12500], test['target'][:12500] docs_test, y_test = test['data'][12500:], test['target'][12500:] 默认情况下,输出的维度事先固定为n_features = 2 ** 20(接近 1M 个特征),来最大限度地减少大多数分类问题的碰撞率,同时具有合理大小的线性模型(coef_属性中的 1M 权重): h_vectorizer.transform(docs_train) 现在,让我们将HashingVectorizer的计算效率与CountVectorizer进行比较: h_vec = HashingVectorizer(encoding='latin-1') %timeit -n 1 -r 3 h_vec.fit(docs_train, y_train) count_vec = CountVectorizer(encoding='latin-1') %timeit -n 1 -r 3 count_vec.fit(docs_train, y_train) 我们可以看到,在这种情况下,HashingVectorizer比Countvectorizer快得多。 最后,让我们在 IMDb 训练子集上训练一个LogisticRegression分类器: from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline h_pipeline = Pipeline([ ('vec', HashingVectorizer(encoding='latin-1')), ('clf', LogisticRegression(random_state=1)), ]) h_pipeline.fit(docs_train, y_train) print('Train accuracy', h_pipeline.score(docs_train, y_train)) print('Validation accuracy', h_pipeline.score(docs_valid, y_valid)) import gc del count_vec del h_pipeline gc.collect() 核外学习 核外学习是在不放不进内存或 RAM 的数据集上训练机器学习模型的任务。 这需要以下条件: 具有固定输出维度的特征提取层 提前知道所有类别的列表(在这种情况下,我们只有正面和负面的评论) 支持增量学习的机器学习算法(scikit-learn 中的partial_fit方法)。 在以下部分中,我们将建立一个简单的批量训练函数来迭代地训练SGDClassifier。 但首先,让我们将文件名加载到 Python 列表中: train_path = os.path.join('datasets', 'IMDb', 'aclImdb', 'train') train_pos = os.path.join(train_path, 'pos') train_neg = os.path.join(train_path, 'neg') fnames = [os.path.join(train_pos, f) for f in os.listdir(train_pos)] +\ [os.path.join(train_neg, f) for f in os.listdir(train_neg)] fnames[:3] 接下来,让我们创建目标标签数组: y_train = np.zeros((len(fnames), ), dtype=int) y_train[:12500] = 1 np.bincount(y_train) 现在,我们实现batch_train函数,如下所示: from sklearn.base import clone def batch_train(clf, fnames, labels, iterations=25, batchsize=1000, random_seed=1): vec = HashingVectorizer(encoding='latin-1') idx = np.arange(labels.shape[0]) c_clf = clone(clf) rng = np.random.RandomState(seed=random_seed) for i in range(iterations): rnd_idx = rng.choice(idx, size=batchsize) documents = [] for i in rnd_idx: with open(fnames[i], 'r', encoding='latin-1') as f: documents.append(f.read()) X_batch = vec.transform(documents) batch_labels = labels[rnd_idx] c_clf.partial_fit(X=X_batch, y=batch_labels, classes=[0, 1]) return c_clf 请注意,我们没有像上一节中那样使用LogisticRegression,但我们将使用具有 logistic 成本函数的SGDClassifier。 SGD代表随机梯度下降,这是一种优化算法,它逐样本迭代地优化权重系数,这允许我们一块一块地将数据馈送给分类器。 我们训练SGDClassifier;使用batch_train函数的默认设置,它将在25 * 1000 = 25000个文档上训练分类器。 (根据你的机器,这可能需要>2分钟) from sklearn.linear_model import SGDClassifier sgd = SGDClassifier(loss='log', random_state=1, max_iter=1000) sgd = batch_train(clf=sgd, fnames=fnames, labels=y_train) 最后,让我们评估一下它的表现: vec = HashingVectorizer(encoding='latin-1') sgd.score(vec.transform(docs_test), y_test) 哈希向量化器的限制 使用Hashing Vectorizer可以实现流式和并行文本分类,但也可能会引入一些问题: 碰撞会在数据中引入太多噪声并降低预测质量, HashingVectorizer不提供“反向文档频率”重新加权(缺少use_idf=True选项)。 没有反转映射,和从特征索引中查找特征名称的简单方法。 可以通过增加n_features参数来控制冲突问题。 可以通过在向量化器的输出上附加TfidfTransformer实例来重新引入 IDF 加权。然而,用于特征重新加权的idf_统计量的计算,需要在能够开始训练分类器之前,额外遍历训练集至少一次:这打破了在线学习方案。 缺少逆映射(TfidfVectorizer的get_feature_names()方法)更难以解决。这将需要扩展HashingVectorizer类来添加“跟踪”模式,来记录最重要特征的映射,来提供统计调试信息。 在调试特征提取问题的同时,建议在数据集的小型子集上使用TfidfVectorizer(use_idf=False),来模拟具有get_feature_names()方法且没有冲突问题的HashingVectorizer()实例。 练习 在我们上面的batch_train函数的实现中,我们在每次迭代中随机抽取k个训练样本作为批量,这可以被视为带放回的随机子采样。 你可以修改batch_train函数,使它无放回地迭代文档,即它在每次迭代中使用每个文档一次。 # %load solutions/23_batchtrain.py
原文:Bag of Words Meets Bags of Popcorn 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 描述 在本教程竞赛中,我们对情感分析进行了一些“深入”研究。谷歌的 Word2Vec 是一种受深度学习启发的方法,专注于单词的含义。 Word2Vec 试图理解单词之间的意义和语义关系。它的工作方式类似于深度方法,例如循环神经网络或深度神经网络,但计算效率更高。本教程重点介绍用于情感分析的 Word2Vec。 情感分析是机器学习中的一个挑战性课题。人们用语言来表达自己的情感,这种语言经常被讽刺,二义性和文字游戏所掩盖,所有这些都会对人类和计算机产生误导。还有另一个 Kaggle 电影评论情绪分析竞赛。在本教程中,我们将探讨如何将 Word2Vec 应用于类似的问题。 在过去的几年里,深度学习在新闻中大量出现,甚至进入纽约时报的头版。这些机器学习技术受到人类大脑架构的启发,并且由于计算能力的最新进展而实现,由于图像识别,语音处理和自然语言任务的突破性结果,已经成为浪潮。最近,深度学习方法赢得了几项 Kaggle 比赛,包括药物发现任务和猫狗图像识别。 教程概览 本教程将帮助你开始使用 Word2Vec 进行自然语言处理。 它有两个目标: 基本自然语言处理:本教程的第 1 部分适用于初学者,涵盖了本教程后续部分所需的基本自然语言处理技术。 文本理解的深度学习:在第 2 部分和第 3 部分中,我们深入研究如何使用 Word2Vec 训练模型以及如何使用生成的单词向量进行情感分析。 由于深度学习是一个快速发展的领域,大量的工作尚未发表,或仅作为学术论文存在。 本教程的第 3 部分比说明性更具探索性 - 我们尝试了几种使用 Word2Vec 的方法,而不是为你提供使用输出的方法。 为了实现这些目标,我们依靠 IMDB 情绪分析数据集,其中包含 100,000 个多段电影评论,包括正面和负面。 致谢 此数据集是与以下出版物一起收集的: Andrew L. Maas, Raymond E. Daly, Peter T. Pham, Dan Huang, Andrew Y. Ng, and Christopher Potts. (2011). “Learning Word Vectors for Sentiment Analysis.” The 49th Annual Meeting of the Association for Computational Linguistics (ACL 2011). 如果你将数据用于任何研究应用,请发送电子邮件给该论文的作者。 该教程由 Angela Chapman 在 2014 年夏天在 Kaggle 实习期间开发。 什么是深度学习 术语“深度学习”是在2006年创造的,指的是具有多个非线性层并且可以学习特征层次结构的机器学习算法[1]。 大多数现代机器学习依赖于特征工程或某种级别的领域知识来获得良好的结果。 在深度学习系统中,情况并非如此 - 相反,算法可以自动学习特征层次结构,这些层次结构表示抽象级别增加的对象。 虽然许多深度学习算法的基本要素已存在多年,但由于计算能力的提高,计算硬件成本的下降以及机器学习研究的进步,它们目前正日益受到欢迎。 深度学习算法可以按其架构(前馈,反馈或双向)和训练协议(监督,混合或无监督)进行分类[2]。 一些好的背景材料包括: [1] “Deep Learning for Signal and Information Processing”, by Li Deng and Dong Yu (out of Microsoft) [2] “Deep Learning Tutorial” (2013 Presentation by Yann LeCun and Marc’Aurelio Ranzato) Word2Vec 适合哪里? Word2Vec的工作方式类似于深度方法,如循环神经网络或深度神经网络,但它实现了某些算法,例如分层 softmax,使计算效率更高。 对于 Word2Vec 以及本文的更多信息,请参阅本教程的第 2 部分,以及这篇论文:Efficient Estimation of Word Representations in Vector Space 在本教程中,我们使用混合方法进行训练 - 由无监督的片段(Word2Vec)和监督学习(随机森林)组成。 库和包 以下列表并不是详尽无遗的。 Python 中: Theano 提供非常底层的基本功能,用于构建深度学习系统。 你还可以在他们的网站上找到一些很好的教程。 Caffe 是 Berkeley 视觉和学习中心的深度学习框架。 Pylearn2 包装了 Theano,似乎更加用户友好。 OverFeat 用于赢得 Kaggle 猫和狗的比赛。 Lua 中: Torch 是一个受欢迎的包,并附带一个教程。 R 中: 截至 2014 年 8 月,有一些软件包刚刚开始开发,但没有可以在教程中使用的,非常成熟的包。 其他语言也可能有很好的包,但我们还没有对它们进行过研究。 更多教程 O’Reilly 博客有一系列深度学习文章和教程: 什么是深度学习,为什么要关心?如何构建和运行你的第一个深度学习网络网络广播:如何起步深入学习计算机视觉 还有几个使用 Theano 的教程。 如果你想从零开始创建神经网络,请查看 Geoffrey Hinton 的 Coursera 课程。 对于 NLP,请查看斯坦福大学最近的这个讲座:没有魔法的 NLP 的深度学习。 这本免费的在线书籍还介绍了用于深度学习的神经网络:神经网络和深度学习。 配置你的系统 如果你之前没有安装过 Python 模块,请查看此教程,提供了从终端(在 Mac / Linux 中)或命令提示符(在 Windows 中)安装模块的指南。 运行本教程需要安装以下软件包。 在大多数(或所有)情况下,我们建议你使用pip来安装软件包。 pandas numpy scipy scikit-learn Beautiful Soup NLTK Cython gensim Word2Vec 可以在gensim包中找到。 请注意,到目前为止,我们只在 Mac OS X 上成功运行了本教程,而不是 Windows。 如果你在 Mac Mavericks(10.9)上安装软件包时遇到问题,本教程包含正确配置系统的说明。 本教程中的代码是为 Python 2.7 开发的。
原文:Bag of Words Meets Bags of Popcorn 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 第三部分:词向量的更多乐趣 代码 第三部分的代码在这里。 单词的数值表示 现在我们有了训练好的模型,对单词有一些语义理解,我们应该如何使用它? 如果你看它的背后,第 2 部分训练的 Word2Vec 模型由词汇表中每个单词的特征向量组成,存储在一个名为syn0的numpy数组中: >>> # Load the model that we created in Part 2 >>> from gensim.models import Word2Vec >>> model = Word2Vec.load("300features_40minwords_10context") 2014-08-03 14:50:15,126 : INFO : loading Word2Vec object from 300features_40min_word_count_10context 2014-08-03 14:50:15,777 : INFO : setting ignored attribute syn0norm to None >>> type(model.syn0) <type 'numpy.ndarray'> >>> model.syn0.shape (16492, 300) syn0中的行数是模型词汇表中的单词数,列数对应于我们在第 2 部分中设置的特征向量的大小。将最小单词计数设置为 40 ,总词汇量为 16,492 个单词,每个词有 300 个特征。 可以通过以下方式访问单个单词向量: >>> model["flower"] …返回一个 1x300 的numpy数组。 从单词到段落,尝试 1:向量平均 IMDB 数据集的一个挑战是可变长度评论。 我们需要找到一种方法来获取单个单词向量并将它们转换为每个评论的长度相同的特征集。 由于每个单词都是 300 维空间中的向量,我们可以使用向量运算来组合每个评论中的单词。 我们尝试的一种方法是简单地平均给定的评论中的单词向量(为此,我们删除了停止词,这只会增加噪音)。 以下代码基于第 2 部分的代码构建了特征向量的平均值。 import numpy as np # Make sure that numpy is imported def makeFeatureVec(words, model, num_features): # 用于平均给定段落中的所有单词向量的函数 # # 预初始化一个空的 numpy 数组(为了速度) featureVec = np.zeros((num_features,),dtype="float32") # nwords = 0. # # Index2word 是一个列表,包含模型词汇表中的单词名称。 # 为了获得速度,将其转换为集合。 index2word_set = set(model.index2word) # # 遍历评论中的每个单词,如果它在模型的词汇表中, # 则将其特征向量加到 total for word in words: if word in index2word_set: nwords = nwords + 1. featureVec = np.add(featureVec,model[word]) # # 将结果除以单词数来获得平均值 featureVec = np.divide(featureVec,nwords) return featureVec def getAvgFeatureVecs(reviews, model, num_features): # 给定一组评论(每个评论都是单词列表),计算每个评论的平均特征向量并返回2D numpy数组 # # 初始化计数器 counter = 0. # # 为了速度,预分配 2D numpy 数组 reviewFeatureVecs = np.zeros((len(reviews),num_features),dtype="float32") # # 遍历评论 for review in reviews: # # 每 1000 个评论打印一次状态消息 if counter%1000. == 0.: print "Review %d of %d" % (counter, len(reviews)) # # 调用生成平均特征向量的函数(定义如上) reviewFeatureVecs[counter] = makeFeatureVec(review, model, \ num_features) # # 增加计数器 counter = counter + 1. return reviewFeatureVecs 现在,我们可以调用这些函数来为每个段落创建平均向量。 以下操作将需要几分钟: # **************************************************************** # 使用我们在上面定义的函数, # 计算训练和测试集的平均特征向量。 # 请注意,我们现在删除停止词。 clean_train_reviews = [] for review in train["review"]: clean_train_reviews.append( review_to_wordlist( review, \ remove_stopwords=True )) trainDataVecs = getAvgFeatureVecs( clean_train_reviews, model, num_features ) print "Creating average feature vecs for test reviews" clean_test_reviews = [] for review in test["review"]: clean_test_reviews.append( review_to_wordlist( review, \ remove_stopwords=True )) testDataVecs = getAvgFeatureVecs( clean_test_reviews, model, num_features ) 接下来,使用平均段落向量来训练随机森林。 请注意,与第 1 部分一样,我们只能使用标记的训练评论来训练模型。 # 使用 100 棵树让随机森林拟合训练数据 from sklearn.ensemble import RandomForestClassifier forest = RandomForestClassifier( n_estimators = 100 ) print "Fitting a random forest to labeled training data..." forest = forest.fit( trainDataVecs, train["sentiment"] ) # 测试和提取结果 result = forest.predict( testDataVecs ) # 写出测试结果 output = pd.DataFrame( data={"id":test["id"], "sentiment":result} ) output.to_csv( "Word2Vec_AverageVectors.csv", index=False, quoting=3 ) 我们发现这产生了比偶然更好的结果,但是表现比词袋低了几个百分点。 由于向量的元素平均值没有产生惊人的结果,或许我们可以以更聪明的方式实现? 加权单词向量的标准方法是应用“tf-idf”权重,它衡量给定单词在给定文档集中的重要程度。 在 Python 中提取 tf-idf 权重的一种方法,是使用 scikit-learn 的TfidfVectorizer,它具有类似于我们在第 1 部分中使用的CountVectorizer的接口。但是,当我们尝试以这种方式加权我们的单词向量时,我们发现没有实质的性能改善。 从单词到段落,尝试 2:聚类 Word2Vec 创建语义相关单词的簇,因此另一种可能的方法是利用簇中单词的相似性。 以这种方式来分组向量称为“向量量化”。 为了实现它,我们首先需要找到单词簇的中心,我们可以通过使用聚类算法(如 K-Means)来完成。 在 K-Means 中,我们需要设置的一个参数是“K”,或者是簇的数量。 我们应该如何决定要创建多少个簇? 试错法表明,每个簇平均只有5个单词左右的小簇,比具有多个词的大簇产生更好的结果。 聚类代码如下。 我们使用 scikit-learn 来执行我们的 K-Means。 具有较大 K 的 K-Means 聚类可能非常慢;以下代码在我的计算机上花了 40 多分钟。 下面,我们给 K-Means 函数设置一个计时器,看看它需要多长时间。 from sklearn.cluster import KMeans import time start = time.time() # Start time # 将“k”(num_clusters)设置为词汇量大小的 1/5,或每个簇平均 5 个单词 word_vectors = model.syn0 num_clusters = word_vectors.shape[0] / 5 # 初始化 k-means 对象并使用它来提取质心 kmeans_clustering = KMeans( n_clusters = num_clusters ) idx = kmeans_clustering.fit_predict( word_vectors ) # 获取结束时间并打印该过程所需的时间 end = time.time() elapsed = end - start print "Time taken for K Means clustering: ", elapsed, "seconds." 现在,每个单词的聚类分布都存储在idx中,而原始 Word2Vec 模型中的词汇表仍存储在model.index2word中。 为方便起见,我们将它们压缩成一个字典,如下所示: # 创建单词/下标字典,将每个词汇表单词映射为簇编号 word_centroid_map = dict(zip( model.index2word, idx )) 这有点抽象,所以让我们仔细看看我们的簇包含什么。 你的簇可能会有所不同,因为 Word2Vec 依赖于随机数种子。 这是一个循环,打印出簇 0 到 9 的单词: # 对于前 10 个簇 for cluster in xrange(0,10): # # 打印簇编号 print "\nCluster %d" % cluster # # 找到该簇编号的所有单词,然后将其打印出来 words = [] for i in xrange(0,len(word_centroid_map.values())): if( word_centroid_map.values()[i] == cluster ): words.append(word_centroid_map.keys()[i]) print words 结果很有意思: Cluster 0 [u'passport', u'penthouse', u'suite', u'seattle', u'apple'] Cluster 1 [u'unnoticed'] Cluster 2 [u'midst', u'forming', u'forefront', u'feud', u'bonds', u'merge', u'collide', u'dispute', u'rivalry', u'hostile', u'torn', u'advancing', u'aftermath', u'clans', u'ongoing', u'paths', u'opposing', u'sexes', u'factions', u'journeys'] Cluster 3 [u'lori', u'denholm', u'sheffer', u'howell', u'elton', u'gladys', u'menjou', u'caroline', u'polly', u'isabella', u'rossi', u'nora', u'bailey', u'mackenzie', u'bobbie', u'kathleen', u'bianca', u'jacqueline', u'reid', u'joyce', u'bennett', u'fay', u'alexis', u'jayne', u'roland', u'davenport', u'linden', u'trevor', u'seymour', u'craig', u'windsor', u'fletcher', u'barrie', u'deborah', u'hayward', u'samantha', u'debra', u'frances', u'hildy', u'rhonda', u'archer', u'lesley', u'dolores', u'elsie', u'harper', u'carlson', u'ella', u'preston', u'allison', u'sutton', u'yvonne', u'jo', u'bellamy', u'conte', u'stella', u'edmund', u'cuthbert', u'maude', u'ellen', u'hilary', u'phyllis', u'wray', u'darren', u'morton', u'withers', u'bain', u'keller', u'martha', u'henderson', u'madeline', u'kay', u'lacey', u'topper', u'wilding', u'jessie', u'theresa', u'auteuil', u'dane', u'jeanne', u'kathryn', u'bentley', u'valerie', u'suzanne', u'abigail'] Cluster 4 [u'fest', u'flick'] Cluster 5 [u'lobster', u'deer'] Cluster 6 [u'humorless', u'dopey', u'limp'] Cluster 7 [u'enlightening', u'truthful'] Cluster 8 [u'dominates', u'showcases', u'electrifying', u'powerhouse', u'standout', u'versatility', u'astounding'] Cluster 9 [u'succumbs', u'comatose', u'humiliating', u'temper', u'looses', u'leans'] 我们可以看到这些簇的质量各不相同。 有些是有道理的 - 簇 3 主要包含名称,而簇 6- 8包含相关的形容词(簇 6 是我最喜欢的)。 另一方面,簇 5 有点神秘:龙虾和鹿有什么共同之处(除了是两只动物)? 簇 0 更糟糕:阁楼和套房似乎属于一个东西,但它们似乎不属于苹果和护照。 簇 2 包含…可能与战争有关的词? 也许我们的算法在形容词上效果最好。 无论如何,现在我们为每个单词分配了一个簇(或“质心”),我们可以定义一个函数将评论转换为质心袋。 这就像词袋一样,但使用语义相关的簇而不是单个单词: def create_bag_of_centroids( wordlist, word_centroid_map ): # # 簇的数量等于单词/质心映射中的最大的簇索引 num_centroids = max( word_centroid_map.values() ) + 1 # # 预分配质心向量袋(为了速度) bag_of_centroids = np.zeros( num_centroids, dtype="float32" ) # # 遍历评论中的单词。如果单词在词汇表中, # 找到它所属的簇,并将该簇的计数增加 1 for word in wordlist: if word in word_centroid_map: index = word_centroid_map[word] bag_of_centroids[index] += 1 # # 返回“质心袋” return bag_of_centroids 上面的函数将为每个评论提供一个numpy数组,每个数组的特征都与簇数相等。 最后,我们为训练和测试集创建了质心袋,然后训练随机森林并提取结果: # 为训练集质心预分配一个数组(为了速度) train_centroids = np.zeros( (train["review"].size, num_clusters), \ dtype="float32" ) # 将训练集评论转换为质心袋 counter = 0 for review in clean_train_reviews: train_centroids[counter] = create_bag_of_centroids( review, \ word_centroid_map ) counter += 1 # 对测试评论重复 test_centroids = np.zeros(( test["review"].size, num_clusters), \ dtype="float32" ) counter = 0 for review in clean_test_reviews: test_centroids[counter] = create_bag_of_centroids( review, \ word_centroid_map ) counter += 1 # 拟合随机森林并提取预测 forest = RandomForestClassifier(n_estimators = 100) # 拟合可能需要几分钟 print "Fitting a random forest to labeled training data..." forest = forest.fit(train_centroids,train["sentiment"]) result = forest.predict(test_centroids) # 写出测试结果 output = pd.DataFrame(data={"id":test["id"], "sentiment":result}) output.to_csv( "BagOfCentroids.csv", index=False, quoting=3 ) 我们发现与第 1 部分中的词袋相比,上面的代码给出了相同(或略差)的结果。 深度和非深度学习方法的比较 你可能会问:为什么词袋更好? 最大的原因是,在我们的教程中,平均向量和使用质心会失去单词的顺序,这使得它与词袋的概念非常相似。性能相似(在标准误差范围内)的事实使得所有三种方法实际上相同。 一些要尝试的事情: 首先,在更多文本上训练 Word2Vec 应该会大大提高性能。谷歌的结果基于从超过十亿字的语料库中学到的单词向量;我们标记和未标记的训练集合在一起只有 1800 万字左右。方便的是,Word2Vec 提供了加载由谷歌原始 C 工具输出的任何预训练模型的函数,因此也可以用 C 训练模型然后将其导入 Python。 其次,在已发表的文献中,分布式单词向量技术已被证明优于词袋模型。在本文中,在 IMDB 数据集上使用了一种名为段落向量的算法,来生成迄今为止最先进的一些结果。在某种程度上,它比我们在这里尝试的方法更好,因为向量平均和聚类会丢失单词顺序,而段落向量会保留单词顺序信息。
原文:Bag of Words Meets Bags of Popcorn 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 第二部分:词向量 代码 第二部分的教程代码在这里。 分布式词向量简介 本教程的这一部分将重点介绍使用 Word2Vec 算法创建分布式单词向量。 (深度学习的概述,以及其他一些教程的链接,请参阅“什么是深度学习?”页面)。 第 2 部分和第 3 部分比第 1 部分假设你更熟悉Python。我们在双核 Macbook Pro 上开发了以下代码,但是,我们还没有在 Windows 上成功运行代码。如果你是 Windows 用户并且使其正常运行,请在论坛中留言如何进行操作!更多详细信息,请参阅“配置系统”页面。 Word2vec,由 Google 于 2013 年发表,是一种神经网络实现,可以学习单词的分布式表示。在此之前已经提出了用于学习单词表示的其他深度或循环神经网络架构,但是这些的主要问题是训练模型所需时长间。 Word2vec 相对于其他模型学习得快。 Word2Vec 不需要标签来创建有意义的表示。这很有用,因为现实世界中的大多数数据都是未标记的。如果给网络足够的训练数据(数百亿个单词),它会产生特征极好的单词向量。具有相似含义的词出现在簇中,并且簇具有间隔,使得可以使用向量数学来再现诸如类比的一些词关系。着名的例子是,通过训练好的单词向量,“国王 - 男人 + 女人 = 女王”。 查看 Google 的代码,文章和附带的论文。 此演示也很有帮助。 原始代码是 C 写的,但它已被移植到其他语言,包括 Python。 我们鼓励你使用原始 C 工具,但如果你是初学程序员(我们必须手动编辑头文件来编译),请注意它不是用户友好的。 最近斯坦福大学的工作也将深度学习应用于情感分析;他们的代码以 Java 提供。 但是,他们的方法依赖于句子解析,不能直接应用于任意长度的段落。 分布式词向量强大,可用于许多应用,尤其是单词预测和转换。 在这里,我们将尝试将它们应用于情感分析。 在 Python 中使用 word2vec 在 Python 中,我们将使用gensim包中的 word2vec 的优秀实现。 如果你还没有安装gensim,则需要安装它。 这里有一个包含 Python Word2Vec 实现的优秀教程。 虽然 Word2Vec 不像许多深度学习算法那样需要图形处理单元(GPU),但它是计算密集型的。 Google 的版本和 Python 版本都依赖于多线程(在你的计算机上并行运行多个进程以节省时间)。 为了在合理的时间内训练你的模型,你需要安装 cython(这里是指南)。 Word2Vec 可在没有安装 cython 的情况下运行,但运行它需要几天而不是几分钟。 为训练模型做准备 现在到了细节! 首先,我们使用pandas读取数据,就像我们在第 1 部分中所做的那样。与第 1 部分不同,我们现在使用unlabeledTrain.tsv,其中包含 50,000 个额外的评论,没有标签。 当我们在第 1 部分中构建词袋模型时,额外的未标记的训练评论没有用。 但是,由于 Word2Vec 可以从未标记的数据中学习,现在可以使用这些额外的 50,000 条评论。 import pandas as pd # 从文件读取数据 train = pd.read_csv( "labeledTrainData.tsv", header=0, delimiter="\t", quoting=3 ) test = pd.read_csv( "testData.tsv", header=0, delimiter="\t", quoting=3 ) unlabeled_train = pd.read_csv( "unlabeledTrainData.tsv", header=0, delimiter="\t", quoting=3 ) # 验证已读取的评论数量(总共 100,000 个) print "Read %d labeled train reviews, %d labeled test reviews, " \ "and %d unlabeled reviews\n" % (train["review"].size, test["review"].size, unlabeled_train["review"].size ) 我们为清理数据而编写的函数也与第 1 部分类似,尽管现在存在一些差异。 首先,为了训练 Word2Vec,最好不要删除停止词,因为算法依赖于句子的更广泛的上下文,以便产生高质量的词向量。 因此,我们将在下面的函数中,将停止词删除变成可选的。 最好不要删除数字,但我们将其留作读者的练习。 # Import various modules for string cleaning from bs4 import BeautifulSoup import re from nltk.corpus import stopwords def review_to_wordlist( review, remove_stopwords=False ): # 将文档转换为单词序列的函数,可选地删除停止词。 返回单词列表。 # # 1. 移除 HTML review_text = BeautifulSoup(review).get_text() # # 2. 移除非字母 review_text = re.sub("[^a-zA-Z]"," ", review_text) # # 3. 将单词转换为小写并将其拆分 words = review_text.lower().split() # # 4. 可选地删除停止词(默认为 false) if remove_stopwords: stops = set(stopwords.words("english")) words = [w for w in words if not w in stops] # # 5. 返回单词列表 return(words) 接下来,我们需要一种特定的输入格式。 Word2Vec 需要单个句子,每个句子都是一列单词。 换句话说,输入格式是列表的列表。 如何将一个段落分成句子并不简单。 自然语言中有各种各样的问题。 英语句子可能以“?”,“!”,“"”或“.”等结尾,并且间距和大写也不是可靠的标志。因此,我们将使用 NLTK 的punkt分词器进行句子分割。为了使用它,你需要安装 NLTK 并使用nltk.download()下载punkt的相关训练文件。 # 为句子拆分下载 punkt 分词器 import nltk.data nltk.download() # 加载 punkt 分词器 tokenizer = nltk.data.load('tokenizers/punkt/english.pickle') # 定义一个函数将评论拆分为已解析的句子 def review_to_sentences( review, tokenizer, remove_stopwords=False ): # 将评论拆分为已解析句子的函数。 # 返回句子列表,其中每个句子都是单词列表 # 1. 使用 NLTK 分词器将段落拆分为句子 raw_sentences = tokenizer.tokenize(review.strip()) # # 2. 遍历每个句子 sentences = [] for raw_sentence in raw_sentences: # 如果句子为空,则跳过 if len(raw_sentence) > 0: # 否则,调用 review_to_wordlist 来获取单词列表 sentences.append( review_to_wordlist( raw_sentence, \ remove_stopwords )) # 返回句子列表(每个句子都是单词列表, # 因此返回列表的列表) return sentences 现在我们可以应用此函数,来准备 Word2Vec 的输入数据(这将需要几分钟): sentences = [] # 初始化空的句子列表 print "Parsing sentences from training set" for review in train["review"]: sentences += review_to_sentences(review, tokenizer) print "Parsing sentences from unlabeled set" for review in unlabeled_train["review"]: sentences += review_to_sentences(review, tokenizer) 你可能会从BeautifulSoup那里得到一些关于句子中 URL 的警告。 这些都不用担心(尽管你可能需要考虑在清理文本时删除 URL)。 我们可以看一下输出,看看它与第 1 部分的不同之处: >>> # 检查我们总共有多少句子 - 应该是 850,000+ 左右 ... print len(sentences) 857234 >>> print sentences[0] [u'with', u'all', u'this', u'stuff', u'going', u'down', u'at', u'the', u'moment', u'with', u'mj', u'i', u've', u'started', u'listening', u'to', u'his', u'music', u'watching', u'the', u'odd', u'documentary', u'here', u'and', u'there', u'watched', u'the', u'wiz', u'and', u'watched', u'moonwalker', u'again'] >>> print sentences[1] [u'maybe', u'i', u'just', u'want', u'to', u'get', u'a', u'certain', u'insight', u'into', u'this', u'guy', u'who', u'i', u'thought', u'was', u'really', u'cool', u'in', u'the', u'eighties', u'just', u'to', u'maybe', u'make', u'up', u'my', u'mind', u'whether', u'he', u'is', u'guilty', u'or', u'innocent'] 需要注意的一个小细节是 Python 列表中+=和append之间的区别。 在许多应用中,这两者是可以互换的,但在这里它们不是。 如果要将列表列表附加到另一个列表列表,append仅仅附加外层列表; 你需要使用+=才能连接所有内层列表。 译者注:原文中这里的解释有误,已修改。 训练并保存你的模型 使用精心解析的句子列表,我们已准备好训练模型。 有许多参数选项会影响运行时间和生成的最终模型的质量。 以下算法的详细信息,请参阅 word2vec API 文档以及 Google 文档。 架构:架构选项是 skip-gram(默认)或 CBOW。 我们发现 skip-gram 非常慢,但产生了更好的结果。 训练算法:分层 softmax(默认)或负采样。 对我们来说,默认效果很好。 对频繁词汇进行下采样:Google 文档建议值介于.00001和.001之间。 对我们来说,接近0.001的值似乎可以提高最终模型的准确性。 单词向量维度:更多特征会产生更长的运行时间,并且通常(但并非总是)会产生更好的模型。 合理的值可能介于几十到几百;我们用了 300。 上下文/窗口大小:训练算法应考虑多少个上下文单词? 10 似乎适用于分层 softmax(越多越好,达到一定程度)。 工作线程:要运行的并行进程数。 这是特定于计算机的,但 4 到 6 之间应该适用于大多数系统。 最小词数:这有助于将词汇量的大小限制为有意义的单词。 在所有文档中,至少没有出现这个次数的任何单词都将被忽略。 合理的值可以在 10 到 100 之间。在这种情况下,由于每个电影出现 30 次,我们将最小字数设置为 40,来避免过分重视单个电影标题。 这导致了整体词汇量大约为 15,000 个单词。 较高的值也有助于限制运行时间。 选择参数并不容易,但是一旦我们选择了参数,创建 Word2Vec 模型就很简单: # 导入内置日志记录模块并配置它,以便 Word2Vec 创建良好的输出消息 import logging logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s',\ level=logging.INFO) # 设置各种参数的值 num_features = 300 # 词向量维度 min_word_count = 40 # 最小单词数 num_workers = 4 # 并行运行的线程数 context = 10 # 上下文窗口大小 downsampling = 1e-3 # 为频繁词设置下采样 # 初始化并训练模型(这需要一些时间) from gensim.models import word2vec print "Training model..." model = word2vec.Word2Vec(sentences, workers=num_workers, \ size=num_features, min_count = min_word_count, \ window = context, sample = downsampling) # 如果你不打算再进一步训练模型, # 则调用 init_sims 将使模型更具内存效率。 model.init_sims(replace=True) # 创建有意义的模型名称并保存模型以供以后使用会很有帮助。 # 你可以稍后使用 Word2Vec.load() 加载它 model_name = "300features_40minwords_10context" model.save(model_name) 在双核 Macbook Pro 上,使用 4 个工作线程来运行,花费不到 15 分钟。 但是,它会因你的计算机而异。 幸运的是,日志记录功能可以打印带有信息的消息。 如果你使用的是 Mac 或 Linux 系统,则可以使用终端内(而不是来自 Python 内部)的top命令,来查看你的系统是否在模型训练时成功并行化。 键入: > top -o cpu 在模型训练时进入终端窗口。 对于 4 个 worker,列表中的第一个进程应该是 Python,它应该显示 300-400% 的 CPU 使用率。 如果你的 CPU 使用率较低,则可能是你的计算机上的 cython 无法正常运行。 探索模型结果 恭喜你到目前为止成功通过了一切! 让我们来看看我们在 75,000 个训练评论中创建的模型。 doesnt_match函数将尝试推断集合中哪个单词与其他单词最不相似: >>> model.doesnt_match("man woman child kitchen".split()) 'kitchen' 我们的模型能够区分意义上的差异! 它知道男人,女人和孩子彼此更相似,而不是厨房。 更多的探索表明,该模型对意义上更微妙的差异敏感,例如国家和城市之间的差异: >>> model.doesnt_match("france england germany berlin".split()) 'berlin' …虽然我们使用的训练集相对较小,但肯定不完美: >>> model.doesnt_match("paris berlin london austria".split()) 'paris' 我们还可以使用most_similar函数来深入了解模型的单词簇: >>> model.most_similar("man") [(u'woman', 0.6056041121482849), (u'guy', 0.4935004413127899), (u'boy', 0.48933547735214233), (u'men', 0.4632953703403473), (u'person', 0.45742249488830566), (u'lady', 0.4487500488758087), (u'himself', 0.4288588762283325), (u'girl', 0.4166809320449829), (u'his', 0.3853422999382019), (u'he', 0.38293731212615967)] >>> model.most_similar("queen") [(u'princess', 0.519856333732605), (u'latifah', 0.47644317150115967), (u'prince', 0.45914226770401), (u'king', 0.4466976821422577), (u'elizabeth', 0.4134873151779175), (u'antoinette', 0.41033703088760376), (u'marie', 0.4061327874660492), (u'stepmother', 0.4040161967277527), (u'belle', 0.38827288150787354), (u'lovely', 0.38668593764305115)] 鉴于我们特定的训练集,“Latifah”与“女王”的相似性最高,也就不足为奇了。 或者,与情感分析更相关: >>> model.most_similar("awful") [(u'terrible', 0.6812670230865479), (u'horrible', 0.62867271900177), (u'dreadful', 0.5879652500152588), (u'laughable', 0.5469599962234497), (u'horrendous', 0.5167273283004761), (u'atrocious', 0.5115568041801453), (u'ridiculous', 0.5104714632034302), (u'abysmal', 0.5015234351158142), (u'pathetic', 0.4880446791648865), (u'embarrassing', 0.48272213339805603)] 因此,似乎我们有相当好的语义意义模型 - 至少和词袋一样好。 但是,我们如何才能将这些花哨的分布式单词向量用于监督学习呢? 下一节将对此进行一次尝试。
原文:Bag of Words Meets Bags of Popcorn 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 第一部分:写给入门者的词袋 什么是 NLP NLP(自然语言处理)是一组用于处理文本问题的技术。这个页面将帮助你从加载和清理IMDB电影评论来起步,然后应用一个简单的词袋模型,来获得令人惊讶的准确预测,评论是点赞还是点踩。 在你开始之前 本教程使用 Python。如果你之前没有使用过 Python,我们建议你前往泰坦尼克号竞赛 Python 教程,熟悉一下(查看随机森林介绍)。 如果你已熟悉 Python 并使用基本的 NLP 技术,则可能需要跳到第 2 部分。 本教程的这一部分不依赖于平台。在本教程中,我们将使用各种 Python 模块进行文本处理,深度学习,随机森林和其他应用。详细信息请参阅“配置你的系统”页面。 有很多很好的教程,以及实际上用 Python 写的关于 NLP 和文本处理的整本书。本教程绝不是详尽无遗的 - 只是为了帮助你以电影评论起步。 代码 第 1 部分的教程代码就在这里。 读取数据 可以从“数据”页面下载必要的文件。你需要的第一个文件是unlabeledTrainData,其中包含 25,000 个 IMDB 电影评论,每个评论都带有正面或负面情感标签。 接下来,将制表符分隔文件读入 Python。为此,我们可以使用泰坦尼克号教程中介绍的pandas包,它提供了read_csv函数,用于轻松读取和写入数据文件。如果你之前没有使用过pandas,则可能需要安装它。 # 导入 pandas 包,然后使用 "read_csv" 函数读取标记的训练数据 import pandas as pd train = pd.read_csv("labeledTrainData.tsv", header=0, \ delimiter="\t", quoting=3) 这里,header=0表示文件的第一行包含列名,delimiter=\t表示字段由制表符分隔,quoting=3让 Python 忽略双引号,否则试图读取文件时,可能会遇到错误。 我们可以确保读取 25,000 行和 3 列,如下所示: >>> train.shape (25000, 3) >>> train.columns.values array([id, sentiment, review], dtype=object) 这三列被称为"id","sentiment"和"array"。 现在你已经读取了培训集,请查看几条评论: print train["review"][0] 提醒一下,这将显示名为"review"的列中的第一个电影评论。 你应该看到一个像这样开头的评论: "With all this stuff going down at the moment with MJ i've started listening to his music, watching the odd documentary here and there, watched The Wiz and watched Moonwalker again. Maybe i just want to get a certain insight into this guy who i thought was really cool in the eighties just to maybe make up my mind whether he is guilty or innocent. Moonwalker is part biography, part feature film which i remember going to see at the cinema when it was originally released. Some of it has subtle messages about MJ's feeling towards the press and also the obvious message of drugs are bad m'kay. <br/><br/>..." 有 HTML 标签,如"<br/>",缩写,标点符号 - 处理在线文本时的所有常见问题。 花一些时间来查看训练集中的其他评论 - 下一节将讨论如何为机器学习整理文本。 数据清理和文本预处理 删除 HTML 标记:BeautifulSoup包 首先,我们将删除 HTML 标记。 为此,我们将使用BeautifulSoup库。 如果你没有安装,请从命令行(不是从 Python 内部)执行以下操作: $ sudo pip install BeautifulSoup4 然后,从 Python 中加载包并使用它从评论中提取文本: # Import BeautifulSoup into your workspace from bs4 import BeautifulSoup # Initialize the BeautifulSoup object on a single movie review example1 = BeautifulSoup(train["review"][0]) # Print the raw review and then the output of get_text(), for # comparison print train["review"][0] print example1.get_text() 调用get_text()会为你提供不带标签的评论文本。如果你浏览BeautifulSoup文档,你会发现它是一个非常强大的库 - 比我们对此数据集所需的功能更强大。但是,使用正则表达式删除标记并不是一种可靠的做法,因此即使对于像这样简单的应用程序,通常最好使用像BeautifulSoup这样的包。 处理标点符号,数字和停止词:NLTK 和正则表达式 在考虑如何清理文本时,我们应该考虑我们试图解决的数据问题。对于许多问题,删除标点符号是有意义的。另一方面,在这种情况下,我们正在解决情感分析问题,并且有可能"!!!"或者":-("可以带有情感,应该被视为单词。在本教程中,为简单起见,我们完全删除了标点符号,但这是你可以自己玩的东西。 与之相似,在本教程中我们将删除数字,但还有其他方法可以处理它们,这些方法同样有意义。例如,我们可以将它们视为单词,或者使用占位符字符串(例如"NUM")替换它们。 要删除标点符号和数字,我们将使用一个包来处理正则表达式,称为re。Python 内置了该软件包;无需安装任何东西。对于正则表达式如何工作的详细说明,请参阅包文档。现在,尝试以下方法: import re # 使用正则表达式执行查找和替换 letters_only = re.sub("[^a-zA-Z]", # 要查找的模式串 " ", # 要替换成的模式串 example1.get_text() ) # 要从中查找的字符串 print letters_only 正则表达式的完整概述超出了本教程的范围,但是现在知道[]表示分组成员而^表示“不”就足够了。 换句话说,上面的re.sub()语句说:“查找任何不是小写字母(a-z)或大写字母(A-Z)的内容,并用空格替换它。” 我们还将我们的评论转换为小写并将它们分成单个单词(在 NLP 术语中称为“分词”): lower_case = letters_only.lower() # 转换为小写 words = lower_case.split() # 分割为单词 最后,我们需要决定如何处理那些没有多大意义的经常出现的单词。 这样的词被称为“停止词”;在英语中,它们包括诸如“a”,“and”,“is”和“the”之类的单词。方便的是,Python 包中内置了停止词列表。让我们从 Python 自然语言工具包(NLTK)导入停止词列表。 如果你的计算机上还没有该库,则需要安装该库;你还需要安装附带的数据包,如下所示: import nltk nltk.download() # 下载文本数据集,包含停止词 现在我们可以使用nltk来获取停止词列表: from nltk.corpus import stopwords # 导入停止词列表 print stopwords.words("english") 这将允许你查看英语停止词列表。 要从我们的电影评论中删除停止词,请执行: # 从 "words" 中移除停止词 words = [w for w in words if not w in stopwords.words("english")] print words 这会查看words列表中的每个单词,并丢弃在停止词列表中找到的任何内容。 完成所有这些步骤后,你的评论现在应该是这样的: [u'stuff', u'going', u'moment', u'mj', u've', u'started', u'listening', u'music', u'watching', u'odd', u'documentary', u'watched', u'wiz', u'watched', u'moonwalker', u'maybe', u'want', u'get', u'certain', u'insight', u'guy', u'thought', u'really', u'cool', u'eighties', u'maybe', u'make', u'mind', u'whether', u'guilty', u'innocent', u'moonwalker', u'part', u'biography', u'part', u'feature', u'film', u'remember', u'going', u'see', u'cinema', u'originally', u'released', u'subtle', u'messages', u'mj', u'feeling', u'towards', u'press', u'also', u'obvious', u'message', u'drugs', u'bad', u'm', u'kay',.....] 不要担心在每个单词之前的u;它只是表明 Python 在内部将每个单词表示为 unicode 字符串。 我们可以对数据做很多其他的事情 - 例如,Porter Stemming(词干提取)和 Lemmatizing(词形还原)(都在 NLTK 中提供)将允许我们将"messages","message"和"messaging"视为同一个词,这当然可能很有用。 但是,为简单起见,本教程将就此打住。 把它们放在一起 现在我们有了清理评论的代码 - 但我们需要清理 25,000 个训练评论! 为了使我们的代码可重用,让我们创建一个可以多次调用的函数: def review_to_words( raw_review ): # 将原始评论转换为单词字符串的函数 # 输入是单个字符串(原始电影评论), # 输出是单个字符串(预处理过的电影评论) # 1. 移除 HTML review_text = BeautifulSoup(raw_review).get_text() # # 2. 移除非字母 letters_only = re.sub("[^a-zA-Z]", " ", review_text) # # 3. 转换为小写,分成单个单词 words = letters_only.lower().split() # # 4. 在Python中,搜索集合比搜索列表快得多, # 所以将停止词转换为一个集合 stops = set(stopwords.words("english")) # # 5. 删除停止词 meaningful_words = [w for w in words if not w in stops] # # 6. 将单词连接成由空格分隔的字符串, # 并返回结果。 return( " ".join( meaningful_words )) 这里有两个新元素:首先,我们将停止词列表转换为不同的数据类型,即集合。 这是为了速度;因为我们将调用这个函数数万次,所以它需要很快,而 Python 中的搜索集合比搜索列表要快得多。 其次,我们将这些单词合并为一段。 这是为了使输出更容易在我们的词袋中使用,在下面。 定义上述函数后,如果你为单个评论调用该函数: clean_review = review_to_words( train["review"][0] ) print clean_review 它应该为你提供与前面教程部分中所做的所有单独步骤完全相同的输出。 现在让我们遍历并立即清理所有训练集(这可能需要几分钟,具体取决于你的计算机): # 根据 dataframe 列大小获取评论数 num_reviews = train["review"].size # 初始化空列表来保存清理后的评论 clean_train_reviews = [] # 遍历每个评论;创建索引 i # 范围是 0 到电影评论列表长度 for i in xrange( 0, num_reviews ): # 为每个评论调用我们的函数, # 并将结果添加到清理后评论列表中 clean_train_reviews.append( review_to_words( train["review"][i] ) ) 有时等待冗长的代码的运行会很烦人。 编写提供状态更新的代码会很有帮助。 要让 Python 在其处理每 1000 个评论后打印状态更新,请尝试在上面的代码中添加一两行: print "Cleaning and parsing the training set movie reviews...\n" clean_train_reviews = [] for i in xrange( 0, num_reviews ): # 如果索引被 1000 整除,打印消息 if( (i+1)%1000 == 0 ): print "Review %d of %d\n" % ( i+1, num_reviews ) clean_train_reviews.append( review_to_words( train["review"][i] )) 从词袋创建特征(使用sklearn) 现在我们已经整理了我们的训练评论,我们如何将它们转换为机器学习的某种数字表示?一种常见的方法叫做词袋。词袋模型从所有文档中学习词汇表,然后通过计算每个单词出现的次数对每个文档进行建模。例如,考虑以下两句话: 句子1:"The cat sat on the hat" 句子2:"The dog ate the cat and the hat" 从这两个句子中,我们的词汇如下: { the, cat, sat, on, hat, dog, ate, and } 为了得到我们的词袋,我们计算每个单词出现在每个句子中的次数。在句子 1 中,“the”出现两次,“cat”,“sat”,“on”和“hat”每次出现一次,因此句子 1 的特征向量是: { the, cat, sat, on, hat, dog, ate, and } 句子 1:{ 2, 1, 1, 1, 1, 0, 0, 0 } 同样,句子 2 的特征是:{ 3, 1, 0, 0, 1, 1, 1, 1} 在 IMDB 数据中,我们有大量的评论,这将为我们提供大量的词汇。要限制特征向量的大小,我们应该选择最大词汇量。下面,我们使用 5000 个最常用的单词(记住已经删除了停止词)。 我们将使用 scikit-learn 中的feature_extraction模块来创建词袋特征。如果你学习了泰坦尼克号竞赛中的随机森林教程,那么你应该已经安装了 scikit-learn;否则你需要安装它。 print "Creating the bag of words...\n" from sklearn.feature_extraction.text import CountVectorizer # 初始化 "CountVectorizer" 对象, # 这是 scikit-learn 的一个词袋工具。 vectorizer = CountVectorizer(analyzer = "word", \ tokenizer = None, \ preprocessor = None, \ stop_words = None, \ max_features = 5000) # fit_transform() 有两个功能: # 首先,它拟合模型并学习词汇; # 第二,它将我们的训练数据转换为特征向量。 # fit_transform 的输入应该是字符串列表。 train_data_features = vectorizer.fit_transform(clean_train_reviews) # Numpy 数组很容易使用,因此将结果转换为数组 train_data_features = train_data_features.toarray() 要查看训练数据数组现在的样子,请执行以下操作: >>> print train_data_features.shape (25000, 5000) 它有 25,000 行和 5,000 个特征(每个词汇一个)。 请注意,CountVectorizer有自己的选项来自动执行预处理,标记化和停止词删除 - 对于其中的每一个,我们不指定None,可以使用内置方法或指定我们自己的函数来使用。 详细信息请参阅函数文档。 但是,我们想在本教程中编写我们自己的数据清理函数,来向你展示如何逐步完成它。 现在词袋模型已经训练好了,让我们来看看词汇表: # 看看词汇表中的单词 vocab = vectorizer.get_feature_names() print vocab 如果你有兴趣,还可以打印词汇表中每个单词的计数: import numpy as np # 求和词汇表中每个单词的计数 dist = np.sum(train_data_features, axis=0) # 对于每个词,打印它和它在训练集中的出现次数 for tag, count in zip(vocab, dist): print count, tag 随机森林 到了这里,我们有词袋的数字训练特征和每个特征向量的原始情感标签,所以让我们做一些监督学习! 在这里,我们将使用我们在泰坦尼克号教程中介绍的随机森林分类器。 随机森林算法包含在 scikit-learn 中(随机森林使用许多基于树的分类器来进行预测,因此是“森林”)。 下面,我们将树的数量设置为 100 作为合理的默认值。 更多树可能(或可能不)表现更好,但肯定需要更长时间来运行。 同样,每个评论所包含的特征越多,所需的时间就越长。 print "Training the random forest..." from sklearn.ensemble import RandomForestClassifier # 使用 100 棵树初始化随机森林分类器 forest = RandomForestClassifier(n_estimators = 100) # 使用词袋作为特征并将情感标签作为响应变量,使森林拟合训练集 # 这可能需要几分钟来运行 forest = forest.fit( train_data_features, train["sentiment"] ) 创建提交 剩下的就是在我们的测试集上运行训练好的随机森林并创建一个提交文件。 如果你还没有这样做,请从“数据”页面下载testData.tsv。 此文件包含另外 25,000 条评论和标签;我们的任务是预测情感标签。 请注意,当我们使用词袋作为测试集时,我们只调用transform,而不是像训练集那样调用fit_transform。 在机器学习中,你不应该使用测试集来拟合你的模型,否则你将面临过拟合的风险。 出于这个原因,我们将测试集保持在禁止状态,直到我们准备好进行预测。 # 读取测试数据 test = pd.read_csv("testData.tsv", header=0, delimiter="\t", \ quoting=3 ) # 验证有 25,000 行和 2 列 print test.shape # 创建一个空列表并逐个附加干净的评论 num_reviews = len(test["review"]) clean_test_reviews = [] print "Cleaning and parsing the test set movie reviews...\n" for i in xrange(0,num_reviews): if( (i+1) % 1000 == 0 ): print "Review %d of %d\n" % (i+1, num_reviews) clean_review = review_to_words( test["review"][i] ) clean_test_reviews.append( clean_review ) # 获取测试集的词袋,并转换为 numpy 数组 test_data_features = vectorizer.transform(clean_test_reviews) test_data_features = test_data_features.toarray() # 使用随机森林进行情感标签预测 result = forest.predict(test_data_features) # 将结果复制到带有 "id" 列和 "sentiment" 列的 pandas dataframe output = pd.DataFrame( data={"id":test["id"], "sentiment":result} ) # 使用 pandas 编写逗号分隔的输出文件 output.to_csv( "Bag_of_Words_model.csv", index=False, quoting=3 ) 恭喜,你已准备好第一次提交! 尝试不同的事情,看看你的结果如何变化。 你可以以不同方式清理评论,为词袋表示选择不同数量的词汇表单词,尝试 Porter Stemming,不同的分类器或任何其他的东西。 要在不同的数据集上试用你的 NLP 招式,你还可以参加我们的烂番茄比赛。 或者,如果你为完全不同的东西做好了准备,请访问深度学习和词向量页面。
记录日期:2018.9.27 标题 测试开始于 Censored* google.com 3月 2011 100% youtube.com 2月 2011 100% facebook.com 2月 2011 100% twitter.com 2月 2011 100% instagram.com 5月 2012 100% google.co.in 2月 2011 100% blogspot.com 2月 2011 100% pornhub.com 3月 2011 100% google.ru 3月 2011 100% google.co.jp 2月 2011 100% google.com.hk 2月 2011 100% google.de 2月 2011 100% google.fr 3月 2011 100% xvideos.com 3月 2011 100% t.co 5月 2011 100% google.com.br 2月 2011 100% google.ca 3月 2011 100% whatsapp.com 5月 2012 100% tumblr.com 2月 2011 100% google.co.uk 3月 2011 100% google.it 3月 2011 100% google.es 3月 2011 100% xnxx.com 3月 2011 100% xhamster.com 2月 2011 100% pinterest.com 8月 2011 100% thestartmagazine.com 3月 2018 100% google.com.mx 2月 2011 100% bongacams.com 10月 2012 100% txxx.com 11月 2015 100% google.com.tw 3月 2011 100% google.pl 3月 2011 100% pixnet.net 5月 2011 100% google.com.tr 3月 2011 100% bbc.com 3月 2011 100% google.com.au 3月 2011 100% google.co.kr 3月 2011 100% dropbox.com 4月 2011 100% thepiratebay.org 2月 2011 100% soundcloud.com 5月 2011 100% nytimes.com 3月 2011 100% chaturbate.com 5月 2012 100% dailymotion.com 2月 2011 100% google.co.id 3月 2011 100% slideshare.net 2月 2011 100% dkn.tv 12月 2017 100% ok.ru 10月 2014 100% google.co.th 3月 2011 100% bbc.co.uk 2月 2011 100% vimeo.com 2月 2011 100% w3schools.com 3月 2011 100% google.com.pk 3月 2011 100% google.com.sa 2月 2011 100% google.com.ar 3月 2011 100% google.com.eg 3月 2011 100% google.az 10月 2011 100% google.gr 3月 2011 100% flipkart.com 10月 2011 100% fc2.com 2月 2011 100% google.nl 3月 2011 100% scribd.com 2月 2011 100% redtube.com 3月 2011 100% google.com.ua 2月 2011 100% spankbang.com 9月 2012 100% youporn.com 3月 2011 100% mega.nz 1月 2015 100% setn.com 9月 2014 100% upornia.com 7月 2015 100% blogger.com 3月 2011 100% google.co.za 3月 2011 100% google.com.vn 3月 2011 100% google.com.sg 3月 2011 100% ask.com 2月 2011 100% google.be 3月 2011 100% steamcommunity.com 5月 2012 100% google.co.ve 3月 2011 100% google.se 2月 2011 100% canva.com 6月 2016 100% google.cl 3月 2011 100% doubleclick.net 7月 2014 100% duckduckgo.com 5月 2012 100% google.pt 3月 2011 100% google.com.co 3月 2011 100% bet365.com 10月 2011 100% google.ae 3月 2011 100% google.com.ph 3月 2011 100% archive.org 2月 2011 100% google.ro 2月 2011 100% google.dz 5月 2012 100% google.ch 2月 2011 100% google.com.pe 3月 2011 100% hclips.com 6月 2015 100% google.at 3月 2011 100% forbes.com 3月 2011 100% shutterstock.com 3月 2011 100% gismeteo.ru 11月 2011 100% medium.com 1月 2014 100% line.me 1月 2014 100% sex.com 5月 2012 100% google.com.ng 3月 2011 100% myway.com 5月 2012 100% telegram.org 8月 2014 100% pinimg.com 3月 2014 100% redd.it 9月 2017 100% google.co.ao 7月 2014 100% flickr.com 3月 2011 100% livedoor.jp 10月 2014 100% google.cz 7月 2011 100% hdzog.com 4月 2015 100% googlevideo.com 8月 2012 100% zippyshare.com 5月 2012 100% google.kz 5月 2012 100% ltn.com.tw 4月 2014 100% beeg.com 10月 2011 100% tube8.com 3月 2011 100% google.hu 10月 2014 100% avgle.com 8月 2017 100% elpais.com 3月 2011 100% google.sk 10月 2011 100% google.dk 3月 2011 100% homedepot.com 10月 2011 100% bloomberg.com 3月 2011 100% google.co.il 6月 2011 100% android.com 6月 2011 100% nhentai.net 12月 2014 100% wsj.com 3月 2011 100% blog.jp 1月 2015 100% wattpad.com 5月 2012 100% appledaily.com 2月 2013 100% google.lk 10月 2011 100% reuters.com 3月 2011 100% google.by 5月 2012 100% t66y.com 5月 2011 100% eyny.com 4月 2011 100% google.bg 10月 2011 100% google.rs 5月 2012 100% eksisozluk.com 2月 2014 100% umblr.com 4月 2016 100% lenta.ru 3月 2011 100% e-hentai.org 5月 2012 100% bestadbid.com 12月 2014 100% google.com.kw 10月 2011 100% google.co.nz 10月 2011 100% gstatic.com 5月 2012 100% 4shared.com 3月 2011 100% xtube.com 10月 2011 100% porn.com 10月 2011 100% google.tn 5月 2012 100% google.iq 5月 2012 100% youjizz.com 3月 2011 100% epochtimes.com 4月 2011 100% badoo.com 3月 2011 100% hitomi.la 7月 2015 100% state.gov 10月 2011 100% vporn.com 9月 2012 100% google.hr 5月 2012 100% google.com.do 10月 2011 100% t.me 7月 2017 100% cam4.com 2月 2011 100% milliyet.com.tr 3月 2011 100% turbobit.net 6月 2011 100% mobile01.com 5月 2012 100% discuss.com.hk 5月 2011 100% apkpure.com 9月 2015 100% humblebundle.com 6月 2012 100% google.com.ly 10月 2011 100% gotporn.com 2月 2016 100% pchome.com.tw 5月 2012 100% tnaflix.com 3月 2011 100% ruten.com.tw 5月 2012 100% google.com.gt 5月 2012 100% google.com.my 3月 2011 100% google.com.ec 11月 2011 100% ixxx.com 6月 2012 100% ck101.com 5月 2011 100% perfectgirls.net 11月 2011 100% google.com.af 5月 2012 100% startpage.com 5月 2012 100% hootsuite.com 3月 2011 100% amazon.co.jp 3月 2011 97% weibo.com 4月 2011 100% discordapp.com 11月 2015 95% reddit.com 3月 2011 89% blogspot.com.es 3月 2012 100% pron.tv 9月 2016 100% quora.com 11月 2011 86% asus.com 5月 2012 100% skype.com 3月 2011 100% twitch.tv 5月 2012 59% bing.com 2月 2011 50% baidu.com 3月 2011 0% wikipedia.org 3月 2011 0% yahoo.com 3月 2011 0% qq.com 3月 2011 0% tmall.com 3月 2011 0% jd.com 3月 2013 50% liputan6.com 6月 2012 50% amazon.com 2月 2011 0% sohu.com 3月 2011 0% vk.com 3月 2011 0% live.com 3月 2011 0% taobao.com 3月 2011 0% bilibili.com 9月 2014 0% yandex.ru 2月 2011 0% 360.cn 3月 2011 0% netflix.com 2月 2011 0% linkedin.com 2月 2011 0% sina.com.cn 3月 2011 0% csdn.net 3月 2011 0% office.com 12月 2014 0% mail.ru 3月 2011 0% alipay.com 3月 2011 0% ebay.com 2月 2011 0% microsoftonline.com 10月 2011 0% microsoft.com 3月 2011 0% aliexpress.com 10月 2011 0% apple.com 2月 2011 0% msn.com 3月 2011 0% naver.com 3月 2011 0% yahoo.co.jp 3月 2011 0% livejasmin.com 3月 2011 0% stackoverflow.com 3月 2011 0% wordpress.com 3月 2011 0% imdb.com 2月 2011 0% tribunnews.com 5月 2012 0% github.com 4月 2011 0% paypal.com 2月 2011 0% hao123.com 3月 2011 0% porn555.com 3月 2017 0% imgur.com 3月 2011 0% wikia.com 3月 2011 0% espn.com 4月 2013 0% adobe.com 2月 2011 0% amazon.in 12月 2013 0% googleusercontent.com 2月 2011 0% xinhuanet.com 3月 2011 0% fbcdn.net 2月 2011 0% amazon.de 3月 2011 0% cnn.com 3月 2011 5% so.com 9月 2012 0% booking.com 3月 2011 0% aparat.com 5月 2012 0% salesforce.com 3月 2011 0% tianya.cn 3月 2011 0% coccoc.com 11月 2013 0% soso.com 3月 2011 0% detik.com 10月 2011 0% spotify.com 5月 2012 0% amazonaws.com 2月 2011 0% babytree.com 10月 2011 0% popads.net 5月 2012 0% tokopedia.com 5月 2012 0% nih.gov 3月 2011 0% zhihu.com 5月 2012 0% chase.com 3月 2011 0% nicovideo.jp 2月 2011 0% 1688.com 10月 2014 0% craigslist.org 2月 2011 0% ettoday.net 5月 2012 0% onlinesbi.com 1月 2012 0% npr.org 10月 2011 33% avito.ru 10月 2011 0% stackexchange.com 2月 2012 0% panda.tv 7月 2018 0% google.cn 3月 2011 0% ebay.de 3月 2011 0% theguardian.com 8月 2013 1% amazon.co.uk 3月 2011 0% openload.co 12月 2015 0% softonic.com 3月 2011 0% globo.com 3月 2011 0% roblox.com 5月 2012 0% alibaba.com 3月 2011 0% bukalapak.com 1月 2016 0% indeed.com 3月 2011 0% youth.cn 7月 2012 0% nownews.com 5月 2012 0% iqiyi.com 12月 2011 0% cnet.com 3月 2011 0% godaddy.com 3月 2011 0% vice.com 5月 2012 0% mediafire.com 2月 2011 0% walmart.com 3月 2011 0% deviantart.com 3月 2011 0% wikihow.com 10月 2011 0% cnblogs.com 10月 2011 0% mama.cn 1月 2014 0% etsy.com 3月 2011 0% mozilla.org 3月 2011 0% trello.com 5月 2012 0% weather.com 3月 2011 0% amazon.fr 9月 2011 0% ladbible.com 8月 2017 0% rakuten.co.jp 2月 2011 0% gmw.cn 1月 2012 0% tripadvisor.com 3月 2011 0% daum.net 3月 2011 0% onlinevideoconverter.com 1月 2018 0% indiatimes.com 3月 2011 0% sogou.com 3月 2011 0% kompas.com 10月 2011 0% amazon.it 12月 2011 0% genius.com 12月 2014 0% sciencedirect.com 5月 2012 0% youm7.com 3月 2011 0% huanqiu.com 3月 2011 0% washingtonpost.com 3月 2011 0% yelp.com 2月 2011 0% twimg.com 4月 2011 0% freepik.com 5月 2012 0% livejournal.com 3月 2011 0% ebay.co.uk 3月 2011 0% rambler.ru 3月 2011 0% hulu.com 2月 2011 0% bankofamerica.com 3月 2011 0% ebay-kleinanzeigen.de 2月 2016 0% foxnews.com 2月 2011 0% savefrom.net 5月 2012 0% speedtest.net 10月 2011 0% amazon.cn 3月 2011 0% diply.com 10月 2014 0% zillow.com 10月 2011 0% airbnb.com 5月 2012 0% people.com.cn 2月 2011 0% digikala.com 5月 2012 0% obozrevatel.com 5月 2012 0% uol.com.br 2月 2011 0% abs-cbn.com 11月 2015 0% cloudfront.net 1月 2012 0% wellsfargo.com 2月 2011 0% breitbart.com 5月 2012 0% newsprofin.com 7月 2018 0% myshopify.com 6月 2012 0% steampowered.com 10月 2011 0% mercadolivre.com.br 3月 2011 0% outbrain.com 3月 2011 0% researchgate.net 5月 2012 0% varzesh3.com 10月 2011 0% 1337x.to 7月 2015 0% quizlet.com 5月 2012 0% haber7.com 5月 2012 0% 360doc.com 3月 2011 0% bet9ja.com 6月 2017 0% xfinity.com 2月 2015 0% amazon.ca 5月 2012 0% caijing.com.cn 5月 2012 0% aliyun.com 5月 2012 0% hotstar.com 3月 2016 0% wowhead.com 3月 2012 0% yy.com 5月 2012 0% bldaily.com 1月 2018 0% gamespot.com 3月 2011 0% eastday.com 3月 2012 0% nfl.com 8月 2011 0% ebc.net.tw 6月 2018 0% nextoptim.com 2月 2018 0% china.com.cn 12月 2011 0% siteadvisor.com 10月 2011 0% udemy.com 5月 2012 0% amazon.es 5月 2012 0% rednet.cn 5月 2012 0% 163.com 2月 2011 0% huffingtonpost.com 3月 2011 0% ikea.com 3月 2011 0% ci123.com 5月 2012 0% buzzfeed.com 10月 2011 0% dailymail.co.uk 3月 2011 0% ameblo.jp 3月 2011 0% messenger.com 4月 2015 0% jf71qh5v14.com 5月 2018 0% tistory.com 10月 2011 0% businessinsider.com 5月 2011 0% gfycat.com 2月 2014 0% ups.com 2月 2011 0% wetransfer.com 5月 2012 0% mailchimp.com 5月 2011 0% coinmarketcap.com 7月 2015 0% allegro.pl 3月 2011 0% weebly.com 3月 2011 0% aol.com 3月 2011 0% capitalone.com 10月 2011 0% kinopoisk.ru 10月 2011 0% rutracker.org 2月 2011 0% livedoor.com 3月 2011 0% orange.fr 3月 2011 0% glassdoor.com 5月 2012 0% leboncoin.fr 2月 2011 0% fedex.com 3月 2011 0% rt.com 12月 2011 0% zoom.us 3月 2015 0% patreon.com 11月 2014 0% gamepedia.com 3月 2017 0% ign.com 3月 2011 0% hp.com 3月 2011 0% wikimedia.org 3月 2011 0% albawabhnews.com 4月 2015 0% behance.net 5月 2012 0% intuit.com 3月 2011 0% douban.com 3月 2011 0% google.ie 3月 2011 0% okdiario.com 9月 2018 0% baike.com 5月 2015 0% doublepimp.com 5月 2012 0% leagueoflegends.com 5月 2012 0% americanexpress.com 3月 2011 0% sourceforge.net 3月 2011 0% adexchangemachine.com 6月 2018 0% exoclick.com 7月 2011 0% kakaku.com 3月 2011 0% icloud.com 5月 2012 0% uzone.id 2月 2018 0% kooora.com 3月 2011 0% digitaldsp.com 3月 2018 0% 51sole.com 5月 2012 0% gearbest.com 3月 2016 0% goodreads.com 5月 2011 0% uptodown.com 5月 2012 0% battle.net 7月 2011 0% ifeng.com 3月 2011 0% reverso.net 3月 2012 0% subscene.com 10月 2011 0% asos.com 10月 2011 0% olx.ua 5月 2018 0% bestbuy.com 3月 2011 0% google.no 3月 2011 0% accuweather.com 10月 2011 0% sberbank.ru 5月 2012 0% thewhizmarketing.com 9月 2016 0% ndtv.com 10月 2011 0% wordreference.com 3月 2011 0% wp.pl 3月 2011 0% 1tv.ru 5月 2012 0% op.gg 11月 2017 0% ouo.io 10月 2015 0% fiverr.com 3月 2011 0% onet.pl 3月 2011 0% filehippo.com 10月 2011 0% oracle.com 10月 2011 0% ensonhaber.com 5月 2012 0% samsung.com 4月 2011 0% playstation.com 5月 2011 0% hdfcbank.com 9月 2011 0% k618.cn 7月 2016 0% mi.com 1月 2015 0% slickdeals.net 10月 2011 0% olx.pl 3月 2016 0% taboola.com 6月 2014 0% bitauto.com 10月 2011 0% list-manage.com 10月 2011 0% box.com 12月 2011 0% wordpress.org 3月 2011 0% cambridge.org 5月 2012 0% pixiv.net 10月 2011 0% mmoframes.com 2月 2018 0% sabah.com.tr 1月 2012 0% taleo.net 10月 2011 0% force.com 10月 2011 0% kissanime.ru 3月 2017 0% google.fi 3月 2011 0% marca.com 3月 2011 0% chinadaily.com.cn 5月 2012 0% youdao.com 3月 2011 0% free.fr 2月 2011 0% userapi.com 5月 2012 0% wiktionary.org 10月 2011 0% evernote.com 3月 2012 0% namu.wiki 2月 2016 0% rediff.com 3月 2011 0% wix.com 10月 2011 0% 3dmgame.com 5月 2012 0% target.com 3月 2011 0% themeforest.net 2月 2011 0% douyu.com 5月 2016 0% zol.com.cn 2月 2011 0% icicibank.com 10月 2011 0% slack.com 6月 2014 0% dcinside.com 5月 2012 0% thesaurus.com 1月 2013 0% billdesk.com 5月 2012 0% gmx.net 3月 2011 0% rottentomatoes.com 10月 2011 0% spotscenered.info 6月 2017 0% seznam.cz 3月 2011 0% uod2quk646.com 8月 2018 0% torrentz2.eu 3月 2017 0% autohome.com.cn 3月 2011 0% taringa.net 3月 2011 0% springer.com 8月 2012 0% discogs.com 5月 2012 0% cnbc.com 8月 2011 0% pixabay.com 10月 2012 0% tvbs.com.tw 2月 2016 0% adp.com 5月 2012 0% usatoday.com 3月 2011 0% utorrent.com 5月 2012 0% prezi.com 5月 2012 0% urbandictionary.com 10月 2011 0% wiley.com 5月 2012 0% stockstar.com 5月 2012 0% hola.com 5月 2012 0% bleacherreport.com 10月 2011 0% instructure.com 9月 2012 0% ria.ru 3月 2012 0% 9gag.com 10月 2011 0% blackboard.com 5月 2012 0% epicgames.com 3月 2015 0% webex.com 5月 2012 0% popcash.net 10月 2014 0% momoshop.com.tw 7月 2012 0% nyaa.si 8月 2017 0% shein.com 5月 2018 0% smallpdf.com 6月 2015 0% dell.com 3月 2011 0% nike.com 3月 2012 0% feng.com 9月 2018 0% office365.com 11月 2012 0% ebay.it 3月 2011 0% bitly.com 7月 2011 0% giphy.com 6月 2014 0% naukri.com 5月 2011 0% spiegel.de 3月 2011 0% ouedkniss.com 5月 2012 0% citi.com 5月 2012 0% 17ok.com 7月 2012 0% xda-developers.com 10月 2011 0% convert2mp3.net 6月 2016 0% as.com 10月 2011 0% repubblica.it 3月 2011 0% friv.com 5月 2012 0% youku.com 2月 2011 0% pikabu.ru 5月 2012 0% engadget.com 3月 2011 0% gosuslugi.ru 5月 2012 0% crptentry.com 8月 2018 0% libero.it 3月 2011 0% mlb.com 4月 2011 0% web.de 3月 2011 0% usps.com 2月 2011 0% wish.com 4月 2015 0% kaskus.co.id 5月 2012 0% go.com 3月 2011 0% drom.ru 5月 2012 0% thefreedictionary.com 3月 2011 0% zendesk.com 10月 2011 0% bandcamp.com 5月 2012 0% manoramaonline.com 5月 2012 0% ptt.cc 5月 2012 0% mercadolibre.com.ar 10月 2011 0% yandex.kz 11月 2011 0% hespress.com 5月 2012 0% inquirer.net 5月 2012 0% souq.com 5月 2012 0% nur.kz 5月 2012 0% zoho.com 10月 2011 0% paytm.com 5月 2012 0% hatenablog.com 6月 2012 0% cqnews.net 5月 2012 0% mit.edu 5月 2012 0% sahibinden.com 10月 2011 0% hm.com 10月 2011 0% mercadolibre.com.mx 10月 2011 0% pantip.com 10月 2011 0% weblio.jp 1月 2012 0% gizmodo.com 10月 2011 0% kickstarter.com 5月 2012 0% groupon.com 3月 2011 0% banggood.com 2月 2017 0% chegg.com 5月 2012 0% dmm.co.jp 3月 2011 0% bookmyshow.com 5月 2012 0% cisco.com 5月 2012 0% gsmarena.com 6月 2011 0% emol.com 5月 2012 0% kapanlagi.com 5月 2012 0% kissasian.sh 8月 2018 0% squarespace.com 5月 2011 0% fidelity.com 5月 2012 0% tamilrockers.cl 8月 2018 0% ultimate-guitar.com 10月 2011 0% expedia.com 3月 2011 0% att.com 3月 2011 0% flvto.biz 8月 2015 0% 4chan.org 10月 2011 0% oschina.net 5月 2012 0% cbssports.com 4月 2011 0% drudgereport.com 3月 2011 0% ibm.com 3月 2011 0% newegg.com 3月 2011 0% autodesk.com 5月 2012 0% beytoote.com 5月 2012 0% ca.gov 10月 2011 0% 4399.com 3月 2011 0% bild.de 3月 2011 0% sportbible.com 7月 2017 0% discover.com 5月 2012 0% irctc.co.in 10月 2011 0% vnexpress.net 3月 2011 0% agoda.com 11月 2011 0% huawei.com 5月 2012 0% patch.com 10月 2011 0% interia.pl 10月 2011 0% mathrubhumi.com 5月 2012 0% lenovo.com 10月 2011 0% uploaded.net 8月 2012 0% jrj.com.cn 5月 2012 0% kissasian.ch 12月 2017 0% techradar.com 5月 2012 0% zimuzu.tv 12月 2014 0% indianexpress.com 5月 2012 0% trustpilot.com 6月 2012 0% webmd.com 10月 2011 0% chip.de 3月 2011 0% gyazo.com 5月 2012 0% motorsport.com 12月 2017 0% freejobalert.com 5月 2012 0% verizonwireless.com 3月 2011 0% upwork.com 5月 2015 0% hupu.com 5月 2012 0% t-online.de 3月 2011 0% crunchyroll.com 5月 2012 0% sputniknews.com 11月 2014 0% pandora.com 3月 2011 0% mobile.de 10月 2011 0% elmundo.es 3月 2011 0% udn.com 5月 2011 0% dmm.com 5月 2012 0% sabq.org 12月 2011 0% nypost.com 10月 2011 0% japanpost.jp 10月 2011 0% bancodevenezuela.com 6月 2012 0% merdeka.com 5月 2012 0% drive2.ru 5月 2012 0% 126.com 3月 2011 0% hotels.com 10月 2011 0% tandfonline.com 5月 2012 0% grammarly.com 5月 2012 0% shopify.com 5月 2012 0% superuser.com 5月 2012 0% lefigaro.fr 10月 2011 0% okezone.com 5月 2012 0% coursera.org 7月 2012 0% kijiji.ca 10月 2011 0% theverge.com 5月 2012 0% artstation.com 2月 2016 0% yadi.sk 5月 2015 0% wunderground.com 8月 2011 0% chess.com 5月 2012 0% merriam-webster.com 4月 2014 0% feedly.com 5月 2012 0% intel.com 10月 2011 0% hurriyet.com.tr 3月 2011 0% tutorialspoint.com 5月 2012 0% tradingview.com 5月 2017 0% dictionary.com 2月 2015 0% urdupoint.com 7月 2016 0% zara.com 5月 2012 0% yaplakal.com 5月 2012 0% debate.com.mx 5月 2012 0% jw.org 7月 2012 0% lowes.com 10月 2011 0% uptobox.com 5月 2012 0% 6.cn 10月 2011 0% ticketmaster.com 10月 2011 0% xe.com 3月 2011 0% kp.ru 5月 2012 0% redfin.com 5月 2012 0% macys.com 10月 2011 0% blog.me 5月 2012 0% el-nacional.com 5月 2012 0% espncricinfo.com 2月 2011 0% fmovies.se 1月 2017 0% nbcnews.com 7月 2012 0% vidio.com 8月 2017 0% cdiscount.com 5月 2012 0% perfecttoolmedia.com 1月 2017 0% asana.com 5月 2012 0% tomshardware.com 1月 2012 0% ebay.com.au 3月 2011 0% costco.com 11月 2011 0% 5ch.net 3月 2018 0% mobafire.com 5月 2012 0% gamer.com.tw 5月 2011 0% archiveofourown.org 12月 2011 0% readms.net 3月 2018 0% addthis.com 3月 2011 0% getpocket.com 5月 2012 0% dafont.com 10月 2011 0% kinozal.tv 5月 2012 0% qingdaonews.com 5月 2012 0% strava.com 5月 2012 0% rutube.ru 5月 2011 0% motherless.com 5月 2012 0% y8.com 10月 2011 0% issuu.com 5月 2011 0% surveymonkey.com 4月 2011 0% mgid.com 3月 2011 0% delta.com 10月 2011 0% zing.vn 3月 2011 0% animeflv.net 10月 2015 0% vesti.ru 5月 2012 0% rapidgator.net 5月 2012 0% gazeta.ru 12月 2011 0% avast.com 10月 2011 0% exhentai.org 6月 2012 0% pole-emploi.fr 5月 2012 0% nature.com 5月 2012 0% pornpics.com 7月 2017 0% google.lt 5月 2012 0% gmarket.co.kr 5月 2012 0% td.com 10月 2011 0% allocine.fr 10月 2011 0% viva.co.id 3月 2012 0% subito.it 10月 2011 0% rbc.ru 3月 2011 0% chinaz.com 3月 2011 0% thehill.com 7月 2015 0% academia.edu 5月 2012 0% usnews.com 5月 2012 0% investopedia.com 5月 2012 0% okcupid.com 10月 2011 0% wildberries.ru 5月 2012 0% alicdn.com 9月 2016 0% realtor.com 10月 2011 0% infusionsoft.com 10月 2011 0% meetup.com 4月 2011 0% myfreecams.com 10月 2011 0% dailycaller.com 5月 2012 0% ctrip.com 10月 2011 0% gmanetwork.com 11月 2012 0% myntra.com 5月 2012 0% imagetwist.com 5月 2012 0% wayfair.com 5月 2012 0% digitaltrends.com 5月 2012 0% ebay.fr 3月 2011 0% cricbuzz.com 5月 2012 0% marketwatch.com 8月 2011 0% nvidia.com 5月 2012 0% zapmeta.ws 10月 2017 0% donga.com 5月 2012 0% ecosia.org 3月 2014 0% rumble.com 6月 2017 0% techcrunch.com 3月 2011 0% farfetch.com 5月 2012 0% lemonde.fr 5月 2011 0% kayak.com 10月 2011 0% goal.com 3月 2011 0% tabelog.com 8月 2011 0% telegraph.co.uk 2月 2011 0% nordstrom.com 11月 2011 0% naver.jp 10月 2011 0% livescore.com 3月 2011 0% fishki.net 5月 2012 0% howtogeek.com 5月 2012 0% v2ex.com 5月 2012 0% lifebuzz.com 7月 2016 0% ukr.net 5月 2012 0% bankmellat.ir 5月 2012 0% 4pda.ru 5月 2012 0% ninisite.com 5月 2012 0% prnt.sc 11月 2016 0% sinoptik.ua 6月 2012 0% prom.ua 5月 2012 0% dmv.org 5月 2012 0% 17track.net 10月 2012 0% cnzz.com 3月 2011 0% admaimai.com 5月 2012 0% elbalad.news 2月 2017 0% python.org 3月 2012 0% unsplash.com 9月 2018 0% 178.com 10月 2011 0% corriere.it 3月 2011 0% sports.ru 5月 2012 0% biblegateway.com 5月 2012 0% tencent.com 5月 2012 0% zhaopin.com 3月 2011 0% ask.fm 5月 2012 0% norton.com 10月 2011 0% dangdang.com 3月 2011 0% jqw.com 5月 2012 0% lapatilla.com 5月 2012 0% usaa.com 5月 2012 0% knowyourmeme.com 5月 2012 0% constantcontact.com 2月 2011 0% duolingo.com 4月 2013 0% ieee.org 5月 2012 0% billboard.com 5月 2012 0% skyscanner.net 5月 2012 0% hamariweb.com 5月 2012 0% ivi.ru 5月 2012 0% jeuxvideo.com 10月 2011 0% gome.com.cn 5月 2012 0% inven.co.kr 5月 2012 0% independent.co.uk 10月 2011 0% olx.com.br 5月 2012 0% sapo.pt 10月 2011 0% voyeurhit.com 8月 2014 0% harvard.edu 5月 2012 0% stanford.edu 10月 2011 0% chatwork.com 5月 2012 0% mydrivers.com 5月 2012 0% mercadolibre.com.ve 10月 2011 0% lifedaily.com 1月 2018 0% hubspot.com 10月 2011 0% thesun.co.uk 6月 2011 0% theatlantic.com 5月 2012 0% eventbrite.com 10月 2011 0% argaam.com 5月 2012 0% xiaomi.com 1月 2012 0% news.com.au 4月 2011 0% velocecdn.com 2月 2018 0% reclameaqui.com.br 5月 2012 0% postbank.de 10月 2011 0% americanas.com.br 5月 2012 0% aa.com 5月 2012 0% oup.com 10月 2012 0% seasonvar.ru 5月 2012 0% bitbucket.org 5月 2012 0% yandex.com.tr 5月 2012 0% olx.in 1月 2012 0% fbsbx.com 10月 2014 0% scribol.com 5月 2012 0% vlive.tv 7月 2017 0% firefox.com 10月 2014 0% bittrex.com 7月 2015 0% thebalance.com 2月 2018 0% fifa.com 5月 2012 0% getadblock.com 1月 2017 0% hibapress.com 5月 2012 0%
原文:A Comprehensive Survey of Graph Embedding: Problems, Techniques and Applications (arxiv 1709.07604) 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 基于边重构的优化问题 总体见解: 基于节点嵌入建立的边应尽可能与输入图中的边相似。 第三类图嵌入技术通过最大化边重建概率,或最小化边重建损失,来直接优化基于边重建的目标函数。 后者进一步分为基于距离的损失和基于边距的排名损失。 接下来,我们逐一介绍这三种类型。 最大化边重建概率 见解: 良好的节点嵌入最大化了在图中观察到的边的生成概率。 良好的节点嵌入应该能够重新建立原始输入图中的边。 这可以通过使用节点嵌入最大化所有观察到的边(即,节点成对接近)的生成概率来实现。 节点对 和 之间的直接边,表示它们的一阶邻近度 ,可以使用嵌入来计算 和 的联合概率: (13) 上述一阶邻近度存在于图中的任何一对连接节点之间。 为了学习嵌入,我们最大化了在图中观察这些邻域的对数似然。 然后将目标函数定义为: (14) 同样, 和 的二阶邻近度是条件概率 由 使用 和 生成: (15) 它可以被解释为在图中随机游走的概率,它开始于 结束于 。 因此图嵌入目标函数是: (16) 其中 是从图中采样的路径中, 的集合。即来自每个采样路径的两个端节点。 这模拟了二阶邻近度,作为从 到 的随机游走的概率。 最小化基于距离的损失 见解: 基于节点嵌入计算的节点邻近度,应尽可能接近基于观察到的边计算的节点邻近度。 具体来说,可以基于节点嵌入来计算节点邻近度,或者可以基于观察到的边凭经验计算节点邻近度。 最小化两种类型的邻近度之间的差异,保持了相应的邻近度。 对于一阶邻近度,可以使用公式 13 中定义的节点嵌入来计算它。 经验概率是 ,其中 是边 的权重。 和 两者之间的距离越小,就能保持更好的一阶邻近度。 使用KL-散度作为距离函数来计算 和 间的差异,并且省略了一些常量,在图嵌入中保留一阶邻近度的目标函数是: (17) 同样, 和 的二阶邻近度是由节点 生成的条件概率 (公式 15)。 的经验概率计算为 ,其中 是节点的出度(无向图的情况中是度) 。与公式 10 相似,计算公式 15 非常昂贵。 再次将负采样用于近似计算来提高效率。 通过最小化 和 之间的 KL 差异,保持二阶邻近度的目标函数是: (18) **表8:**基于边重建的图嵌入。 是指公式 14,16~19 之一。例如 , (word-label)是指 公式 18,带有单词节点和标签节点。 表示节点 的类型。 GE算法 目标 邻近度阶数 PALE [18] (节点,节点) 1 NRCL [4] (节点,邻居节点)+ (属性损失) PTE [124] (单词,单词)+ (单词,文档)+ (单词,标签) APP [3] (节点,节点)) GraphEmbed [83] (单词,单词)+ (单词,时间)+ (单词,位置)+ (时间,地点)+ (位置,位置)+ (时间,时间) 2 [41,42] (车站,公司), (车站,角色), (目的地,出发地) PLE [84] (提示,类型)+ (提示,特性)+ (类型,类型) IONE [26] (节点,节点)+ (锚对齐) HEBE [45] (节点,超边中的其他节点) GAKE [38] (节点,邻居上下文)+ (节点,路径上下文)+ (节点,边上下文) CSIF [64] (用户对,扩散内容) ESR [69] (实体,作者)+ (实体,实体)+ (实体,单词)+ (实体,场地) LINE [27] (节点,节点)+ (节点,节点)) EBPR [71] (AUC 排名)+ (节点,节点)+ (节点,节点上下文) 1 和 2 [94] (问题,答案) 1,2 和 更高 最小化基于边距的排名损失 在基于边距的排名损失优化中,输入图的边指代节点对之间的相关性。 图中的一些节点通常与一组相关节点相关联。 例如,在cQA网站中,一组答案被标记为与给定问题相关。 对损失的见解是直截了当的。 见解: 节点的嵌入更类似于相关节点的嵌入,而不是任何其他不相关节点的嵌入。 表示节点 和 的相似性得分, 表示与 相关的节点集, 表示不相关的节点集。 基于边距的排名损失定义为: (19) 其中 是边距。 减少损失排名,可以促进 和 之间的巨大边距,从而保证 的嵌入更接近其相关节点而不是任何其他不相关节点。 在表 8 中 ,我们基于其目标函数和保留的节点邻近度,总结了基于边重建的现有图嵌入方法。 通常,大多数方法使用上述目标函数之一(公式 14,16~19)。 [71]优化 AUC 排名损失,这是基于边距的排名损失的替代损失(公式 19 )。 请注意,当在图嵌入期间同时优化另一个任务时,该任务特定的目标将被纳入总体目标中。 例如,[26]旨在对齐两个图。 因此,网络对齐的目标函数与 (公式 18)一起优化。 值得注意的是,大多数现有知识图嵌入方法选择优化基于边距的排名损失。 回想一下知识图 由三元组 组成,表示头部实体 通过关系 链接到尾部实体 。 嵌入 可以解释为,保留真正三元组的排名 ,优于 中不存在的假的三元组 。 特别是在知识图嵌入中,类似于公式 19 的 ,能量函数 为三元组 而设计。 这两个函数之间略有不同。 表示节点嵌入 和 之间的相似性得分,而 是嵌入 和 在关系 方面的距离得分。 的一个例子是 ,其中关系表示为嵌入空间中的变换 [91]。 的其他选项总结在表 9 中。 因此,对于知识图嵌入,公式 19 变为: (20) 其中 是输入知识图中的三元组。 现有的知识图嵌入方法主要是在他们的工作中优化公式 20。它们之间的区别在于 的定义,如表 9 所示。 知识图嵌入相关工作的更多细节,已在 [13] 中进行了详细的回顾。 **表9:**使用基于边距的排名损失的知识图嵌入。 GE算法 能量函数 TransE [91] TKRL [53] TransR [15] CTransR [15] TransH [14] SePLi [39] TransD [125] TranSparse [126] m-TransH [127] DKRL [128] ManifoldE [129] 球面: 超平面: 是希尔伯特空间的映射函数 TransA [130] puTransE [43] KGE-LDA [60] SE [90] SME [92]线性 SME [92]双线性 SSP [59] , NTN [131] HOLE [132] ,其中 是环形相关度 MTransE [133] 请注意,一些研究联合优化排名损失(公式式20 )和其他目标来保留更多信息。 例如,SSP [59]使用公式 20 联合优化了主题模型的丢失,将文本节点描述用于嵌入。 [133]对单语关系进行分类,并使用线性变换来学习实体和关系的跨语言对齐。 还存在一些工作,为三元组 定义匹配度分数而不是能量函数。 例如,[134]定义了双线性分数函数 它增加了常态约束和交换约束,在嵌入之间加入类比结构。 ComplEx [135]将嵌入扩展到复数域并将 的实部定义为得分。 总结:基于边重建的优化适用于大多数图嵌入设定。 据我们所知,只有非关系数据(第 3.1.4 节)和整图嵌入(第 3.2.4 节)尚未尝试过。 原因是重建手动构造的边不像其他图那样直观。 此外,由于该技术侧重于直接观察到的局部边,因此不适合于整图嵌入。 图核 见解: 整个图结构可以表示为一个向量,包含从中分解的基本子结构的数量。 图核是 R-convolution 核的一个实例[136],它是定义离散复合对象上的核的通用方法,通过递归地将结构化对象分解为“原子”子结构,并比较它们的所有对[93]。 图核将每个图示为向量,并且使用两个向量的内积来比较两个图。 图核中通常定义了三种类型的“原子”子结构。 Graphlet。graphlet 是一个大小为 K 的感应的和非同构子图 [93]。 假设图 被分解为一组 graphlet 。然后 嵌入为标准化计数的d维向量(表示为 )。 该 的维度 是 中 Graphlet 的出现频率。 子树模式。 在此核中,图被分解为其子树模式。 一个例子是 Weisfeiler-Lehman 子树[49]。 特别是,在标记图(即,具有离散节点标签的图)上进行重新标记的迭代过程。 在每次迭代中,基于节点及其邻居的标签生成多集标签。 新生成的多集标签是一个压缩标签,表示子树模式,然后用于下一次迭代。 基于图同构的 Weisfeiler-Lehman 检验,计算图中标签的出现等同于计算相应的子树结构。 假设 在图上执行重新标记的迭代 。 它的嵌入 包含 块。 该 中的维度 第一块 是频率 -th标签被分配给一个节点 第二次迭代。 随机游走 。 在第三种类型的图核中,图被分解为随机游走或路径,并表示为随机游走的出现次数[137]或其中的路径[138]。 以路径为例,假设图 被分解成 个最短路径。将第i个路径表示为三元组 ,其中 和 是起始节点和结束节点的标签, 是路径的长度。 然后 表示为d维向量 ,其中第i个维度是 中第i个三元组的频率。 简介:图核专为整图嵌入(Sec.3.2.4)而设计,因为它捕获整个图的全局属性。 输入图的类型通常是同构图(第 3.1.1 节)[93]或带有辅助信息的图(第 3.1.3 节)[49]。 生成模型 生成模型可以通过规定输入特征和类标签的联合分布来定义,以一组参数为条件[139]。 一个例子是 Latent Dirichlet Allocation(LDA),其中文档被解释为主题上的分布,主题是单词上的分布[140]。 采用生成模型进行图嵌入有以下两种方法。 潜在语义空间中的图嵌入 见解: 节点嵌入到潜在的语义空间中,节点之间的距离解释了观察到的图结构。 第一种基于生成模型的图嵌入方法,直接在潜在空间中嵌入图。 每个节点表示为潜在变量的向量。 换句话说,它将观察到的图视为由模型生成的。 例如,在LDA中,文档嵌入在“主题”空间中,其中具有相似单词的文档具有类似的主题向量表示。 [70]设计了类似LDA的模型来嵌入基于位置的社交网络(LBSN)图。 具体来说,输入是位置(文档),每个位置包含访问该位置的一组用户(单词)。 由于某些活动(主题),用户访问相同的位置(单词出现在同一文档中)。 然后,模型被设计为将位置表示为活动的分布,其中每个活动具有对用户的吸引力分布。 因此,用户和位置都表示为“活动”空间中的向量。 包含潜在语义的图嵌入 见解: 图中接近且具有相似语义的节点的嵌入应该更紧密。 可以通过生成模型,从节点描述中检测节点语义。 在这一系列方法中,潜在语义用于利用辅助节点信息进行图嵌入。 嵌入不仅由图结构信息决定,而且由从其他节点信息源发现的潜在语义决定。 例如,[58]提出了一个统一的框架,它共同集成了主题建模和图嵌入。 其原理是如果嵌入空间中两个节点接近,它们也具有相似的主题分布。 设计从嵌入空间到主题语义空间的映射函数,以便关联两个空间。 [141]提出了一种生成模型(贝叶斯非参数无限混合嵌入模型),以解决知识图嵌入中的多关系语义问题。 它发现了关系的潜在语义,并利用混合关系组件进行嵌入。 [59]从知识图三元组和实体和关系的文本描述中嵌入知识图。 它使用主题建模来学习文本的语义表示,并将三元组嵌入限制在语义子空间中。 上述两种方法的区别在于嵌入空间是第一种方式的潜在空间。相反,在第二种方式中,潜在空间用于整合来自不同来源的信息,并有助于将图嵌入到另一个空间。 简介:生成模型可用于节点嵌入(Sec.3.2.1)[70]和边嵌入(Sec.3.2.2)[141]。 在考虑节点语义时,输入图通常是异构图(第 3.1.2 节)[70]或带有辅助信息的图(第 3.1.3 节)[59]。 混合技术和其它 有时在一项研究中结合了多种技术。 例如,[4]通过最小化基于边的排序损失来学习基于边的嵌入(第 4.3 节),并通过矩阵分解来学习基于属性的嵌入(第 4.1 节)。 [51]优化基于边距的排名损失(第 4.3 节),基于矩阵分解的损失(第 4.1 节)作为正则化项。 [32]使用LSTM(第 4.2节)来学习cQAs的句子的嵌入,以及基于边际的排名损失(第4.3节)来结合好友关系。 [142]采用CBOW / SkipGram(第 4.2 节)进行知识图实体嵌入,然后通过最小化基于边际的排名损失来微调嵌入(第 4.3 节)。 [61]使用word2vec(第 4.2 节)嵌入文本上下文和TransH(第 4.3 节)嵌入实体/关系,以便在知识图嵌入中利用丰富的上下文信息。 [143]利用知识库中的异构信息来提高推荐效果。 它使用TransR(第 4.3 节)进行网络嵌入,并使用自编码器进行文本和视觉嵌入(第 4.2 节)。 最后,提出了一个生成框架(第 4.5 节),结合协同过滤与项目的语义表示。 除了引入的五类技术之外,还存在其他方法。 [95]提出了根据原型图距离的图的嵌入。 [16]首先使用成对最短路径距离嵌入一些标志性节点。 然后嵌入其他节点,使得它们到标志性子集的距离尽可能接近真实的最短路径。 [4]联合优化基于链接的损失(最大化节点的邻居而不是非邻居的观测似然)和基于属性的损失(基于基于链接的表示学习线性投影)。 KR-EAR [144]将知识图中的关系区分为基于属性和基于关系的关系。 它构造了一个关系三元编码器(TransE,TransR)来嵌入实体和关系之间的相关性,以及一个属性三元编码器来嵌入实体和属性之间的相关性。 Struct2vec [145]根据用于节点嵌入的分层指标,来考虑节点的结构性标识。 [146]通过近似高阶邻近矩阵提供快速嵌入方法。 总结 我们现在总结并比较表10中所有五类图嵌入技术的优缺点。 **表10:**图嵌入技术的比较。 类别 子类别 优点 缺点 矩阵分解 图拉普拉斯算子 考虑全局节点邻近度 大量的时间和空间开销 节点邻近矩阵分解 深度学习 带有随机游走 有效而强大, a)仅考虑路径中的局部上下文 b)难以发现最优采样策略 没有随机游走 高计算开销 边重构 最大化边重建概率 仅使用观察到的局部信息来优化 最小化基于距离的损失 相对有效的的训练 例如边(一跳的邻居) 最小化基于边距的排名损失 或者排序节点对 图核 基于graphlet 有效,只计算所需的原子子结构 a)子结构不是独立的 基于子树模式 b)嵌入维度指数性增长 基于随机游走 生成模型 在潜在的空间中嵌入图 可解释的嵌入 a)难以证明分布的选择 将潜在语义合并到图嵌入中 自然地利用多个信息源 b)需要大量训练数据 基于矩阵分解的图嵌入,基于全局成对相似性的统计量学习表示。 因此,它可以胜过某些任务中基于深度学习的图嵌入(涉及随机游走),因为后者依赖于单独的局部上下文窗口 [147,148]。 然而,邻近度矩阵构造或矩阵的特征分解时间和空间开销大[149],使得矩阵分解效率低且对于大图不可扩展。 深度学习(DL)已经在不同的图嵌入方法中显示出有希望的结果。 我们认为DL适合于图嵌入,因为它能够自动识别复杂图结构中的有用表示。 例如,具有随机游走的DL(例如,DeepWalk [17],node2vec [28],metapath2vec [46])可以通过图上的采样路径自动利用邻域结构。 没有随机游走的DL可以模拟同构图中可变大小的子图结构(例如,GCN [72],struc2vec [145],GraphSAGE [150]),或者异构图中类型灵活的节点之间的丰富交互(例如,HNE [33],TransE [91],ProxEmbed [44]),变为有用的表示。 另一方面,DL也有其局限性。 对于具有随机游走的DL,它通常观测同一路径中的节点的局部邻居,从而忽略全局结构信息。 此外,很难找到“最优采样策略”,因为嵌入和路径采样不是在统一框架中联合优化的。 对于没有随机游走的DL,计算成本通常很高。 传统的深度学习架构假设输入数据在1D或2D网格上,来利用GPU [117]。 然而,图没有这样的网格结构,因此需要不同的解决方案来提高效率[117]。 基于边重建的图嵌入,基于观察到的边或排序三元组来优化目标函数。 与前两类图嵌入相比,它更有效。 然而,使用直接观察到的局部信息来训练这一系列方法,因此所获得的嵌入缺乏对全局图结构的认识。 基于图核的图嵌入将图转换为单个向量,以便于图级别的分析任务,例如图分类。 它比其他类别的技术更有效,因为它只需要在图中枚举所需的原子子结构。 然而,这种“基于结构袋”的方法有两个局限[93]。 首先,子结构不是独立的。 例如,大小为k+1的 graphlet 可以从大小为k graphlet 的派生,通过添加新节点和一些边。 这意味着图表示中存在冗余信息。 其次,当子结构的大小增加时,嵌入维度通常呈指数增长,导致嵌入中的稀疏问题。 基于生成模型的图嵌入可以自然地在统一模型中利用来自不同源(例如,图结构,节点属性)的信息。 直接将图嵌入到潜在语义空间中,会生成可以使用语义解释的嵌入。 但是使用某些分布对观察进行建模的假设很难证明是正确的。 此外,生成方法需要大量的训练数据来估计适合数据的适当模型。 因此,它可能不适用于小图或少量图。
原文:A Comprehensive Survey of Graph Embedding: Problems, Techniques and Applications (arxiv 1709.07604) 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 四、图嵌入技术 在本节中,我们基于所使用的技术对图嵌入方法进行分类。 通常,图嵌入旨在在低维空间中表示图,保留尽可能多的图属性信息。 不同图嵌入算法之间的区别在于,它们如何定义要保留的图属性。 不同的算法对节点(边、子结构、整图)的相似性,以及如何在嵌入空间中保留它们,有不同的见解。 接下来,我们将介绍每种图嵌入技术的见解,以及它们如何量化图属性并解决图嵌入问题。 矩阵分解 基于矩阵分解的图嵌入,以矩阵的形式表示图特性(例如,节点成对相似性)并对该矩阵进行分解来获得节点嵌入[11]。 图嵌入的开创性研究通常以这种方式解决图嵌入问题。 在大多数情况下,输入是由非关系高维数据特征构成的图,如第 3.1.4 节中所介绍的。输出是一组节点嵌入(Sec.3.2.1)。 因此,图嵌入的问题可以被视为保持结构的降维问题,其假定输入数据位于低维流形中。 有两种类型的基于矩阵分解的图嵌入。 一种是分解图的拉普拉斯特征映射 ,另一种是直接分解节点邻近矩阵 。 图的拉普拉斯算子 见解: 要保留的图属性可以解释为成对节点的相似性。 因此,如果两个具有较大相似性的节点相距很远,则会施加较大的惩罚。 **表4:**基于图的拉普拉斯特征映射的图嵌入。 GE算法 目标函数 MDS [74] 欧氏距离 公式 2 Isomap [78] KNN, 是沿着 到 最短路径的边权重之和 公式 2 LE [96] KNN, 公式 2 LPP [97] KNN, 公式 4 AgLPP [79] 锚图, , , LGRM [98] KNN, ARE [88] KNN, , <6244> SR [99] KNN, <6248> HSL [87] ,其中 是归一化的超图的拉普拉斯算子 ,圣 MVU [100] KNN, ,圣 , 和 , <6255> SLE [86] KNN, <6259> MSHLRR [76] 一般图:KNN, 公式 2 超图: 是一个夸张的重量 , [77] PUFS [75] KNN, 公式 4 +(must 和 cannot 链接约束) RF-Semi-NMF-PCA [101] KNN, 公式 2 + (PCA)+ (k均值) 基于以上见解,最优的嵌入 可以由以下目标函数[99]导出。 (1) 其中 是节点 和 之间的“定义的”相似性; 是图的拉普拉斯。 是对角矩阵,其中 。 的值越大, 就更重要[97]。 约束 通常加于 Eq.1,来删除嵌入中的任意缩放因子。 Eq.1 然后化简为: (2) 最优的 是特征问题 的最大特征值对应的特征向量。 上面的图嵌入是渐进式的,因为它只能嵌入训练集中存在的节点。 在实践中,它可能还需要嵌入未在训练中看到的新节点。 一种解决方案是设计线性函数 这样只要提供了节点特征,就可以导出嵌入。 因此,对于归纳性的图嵌入,Eq.1 变为在以下目标函数中找到最的 : (3) 与 Eq.2 相似,通过添加约束 ,公式 3 中的问题变成: (4) 最优的 是 的解的最大特征值的特征向量。 现有研究的差异主要在于它们如何计算成对节点的相似性 ,以及它们是否使用线性函数 或不。 已经进行了一些尝试[85,81]以使用一般框架总结现有的基于拉普拉斯特征图的图嵌入方法。 但他们的综述只涵盖了有限的工作量。 在表 4 中 ,我们总结了现有的基于拉普拉斯特征图的图嵌入研究,并比较了它们的 的计算方法,以及他们采用了什么样的目标函数。 最初的研究 MDS [74]直接采用了两个特征向量 和 之间的欧几里德距离,作为 。公式 2 用于找到 的最佳嵌入。 MDS不考虑节点的邻域,即,任何一对训练实例都被认为是连接的。 后续研究(例如,[78,102,96,97])通过首先从数据特征构建 k 最近邻(KNN)图来克服该问题。 每个节点仅与其前 k 个相似的邻居连接。 之后,利用不同的方法来计算相似度矩阵 ,以便尽可能多地保留所需的图属性。 最近设计了一些更高级的模型。 例如,AgLPP [79]引入了锚图,显着提高早期矩阵分解模型 LPP 的效率。 LGRM [98]学习局部回归模型来掌握图结构,和样本外数据外插值的全局回归项。 最后,与以前的工作保留局部几何不同,LSE [103]使用局部样条回归来保持全局几何。 当辅助信息(例如,标签,属性)可用时,调整目标函数以保留更丰富的信息。 例如,[99]构造邻接图 和标记图 。 目标函数由两部分组成,一部分侧重于保留数据集的局部几何结构,如LPP [97],另一部分试图在标记的训练数据上获得具有最佳类的可分性的嵌入。 类似地,[88]也构造了两个图,即邻接图 编码局部几何结构,反馈关系图 编码用户相关反馈中的成对关系。 RF-Semi-NMF-PCA [101]通过构建由三个部分组成的目标函数:PCA,k-means和图的拉普拉斯正则化,同时考虑聚类,降维和图嵌入。 其他一些工作认为 不能通过容易枚举成对节点关系来构造。 相反,他们采用半定规划(SDP)来学习 。 具体而言,SDP [104]的目的是找到一个内积矩阵,它最大化在图中没有连接的任何两个输入之间的成对距离,同时保留最近的邻居距离。 MVU [100]构造这样的矩阵,然后在习得的内积矩阵上应用MDS [74]。 [2]证明正则化LPP [97]相当于正则化SR [99],如果 是对称的,双随机的,PSD并且秩为 。 它构造了这种相似矩阵,从而有效地解决了类似LPP的问题。 **表5:**基于节点邻近矩阵分解的图嵌入。O(*)表示目标函数;例如,O(SVM分类器)表示SVM分类器的目标函数。 GE算法 目标函数 [50] 公式 5 SPE [105] KNN, ,约束为 公式 5 HOPE [106] Katz 指数 ; 个性化的 Pagerank 公式 5 GraRep [21] ,其中 , 公式 5 CMF [43] PPMI 公式 5 TADW [56] PMI 公式 5 和文本特征矩阵 [24] A MMDW [48] PMI 公式 5 + O(SVM分类器) HSCA [57] PMI O(MMDW)+( 一阶邻近度约束) MVE [107] KNN, 公式 5 M-NMF [1] 公式 5 + O(社区检测) ULGE [2] ,其中 LLE [102] KNN, RESCAL [108] FONPE [109] KNN, ,约束为 节点邻近矩阵分解 除了解决上述广义特征值问题外,另一系列研究试图直接分解节点邻近矩阵。 见解: 使用矩阵分解可以在低维空间中近似节点邻近度。 保持节点邻近度的目标是最小化近似的损失。 给定节点邻近矩阵 ,目标是: (5) 其中 是节点嵌入,和 是上下文节点的嵌入[21]。 公式 5 旨在找到一个最优的秩为d的邻近度矩阵W的近似( 是嵌入的维度)。 一种流行的解决方案是对 应用 SVD(奇异值分解)[110]。从形式上看, (6) 其中 是按降序排序的奇异值, 和 是 的奇异向量 。 最佳嵌入使用最大的d个奇异值获得 ,相应的奇异向量如下: (7) 根据是否保留非对称属性,节点 的嵌入是 [21,50],或 和 连接,即 [106]。 公式 5 存在其他解决方案,如正则化高斯矩阵分解[24],低秩矩阵分解[56],并加入其他正则化器来施加更多约束[48]。 我们总结了表 5 中所有基于节点邻近度矩阵分解的图嵌入。 总结:矩阵分解(MF)主要用于嵌入由非关系数据构建的图(第 3.1.4 节),用于节点嵌入(第 3.2.1 节),这是图的拉普拉斯特征映射问题的典型设定。 MF也用于嵌入同构图[50,24](第 3.1.1 节)。 深度学习 深度学习(DL)在各种研究领域表现出色,如计算机视觉,语言建模等。基于DL的图嵌入在图上应用DL模型。 这些模型要么直接来自其他领域,要么是专门为嵌入图数据设计的新神经网络模型。 输入是从图中采样的路径或整个图本身。 因此,我们基于是否采用随机游走来从图中采样路径,将基于DL的图嵌入分为两类。 带有随机游走的基于 DL 的图嵌入 见解: 通过最大化以自身嵌入为条件的,节点邻域的观测概率,可以在嵌入空间中保留图中的二阶邻近度。 在第一类基于深度学习的图嵌入中,图被表示为从其采样的一组随机游走路径。 然后将深度学习方法应用于用于图嵌入的采样路径,保留路径所承载的图属性。 鉴于上述见解,DeepWalk [17]采用神经语言模型(SkipGram)进行图嵌入。 SkipGram [111]旨在最大化窗口内出现的单词之间的共现概率 。 DeepWalk首先使用截断的随机游走,从输入图中采样一组路径(即,均匀地采样最后访问节点的邻居,直到达到最大长度)。 从图中采样的每个路径相当于来自语料库的句子,其中节点相当于单词。 然后将SkipGram应用于路径,最大化节点邻域的观测概率,以自身嵌入为条件。 以这种方式,邻域相似(二阶邻近度较大)的节点的嵌入相似。DeepWalk的目标函数如下: (8) 其中 是窗口大小,它限制随机游走上下文的大小。 SkipGram删除了排序约束,并且 公式 8转换为: (9) 其中 使用softmax函数定义: (10) 请注意,计算公式 10 是昂贵的,因为标准化因子(即,图中每个节点的所有内积的总和),所以图 10 的方法是不可行的。 通常有两种解近似完全softmax的解决方案:分层softmax [112]和负采样[112]。 分层softmax :有为了效地解决中公式 10,构造二叉树,其中节点被分配给叶子。 不像公式 10 那样枚举所有节点,仅需要求解从根到相应叶子的路径。 优化问题变得最大化树中特定路径的概率。 假设到叶子 的路径是一系列节点 ,其中b0为根, 。 公式 10 然后变成: (11) 其中 是二分类器:。 表示 S 形函数。 是树节点 的父节点的嵌入 。 分层softmax减少了SkipGram的时间复杂度,从 至 。 负采样 : 负采样的关键思想是,使用逻辑回归将目标节点与噪声区分开来。 即,对于一个节点 ,我们想区分它的邻居 来自其他节点。 噪音分布 用于绘制节点的负样本 。公式 9 中的每个 然后计算为: (12) 其中 是采样的负节点数。 是一种噪声分布,例如均匀分布()。 具有负采样的SkipGram的时间复杂度是 。 **表6:**带有随机游走路径的基于深度学习的图嵌入。 GE算法 随机游走方法 保留的邻近度 DL模型 DeepWalk [17] 截断随机游走 SkipGram 和 分层 softmax(公式 11) [34] 截断随机游走 (词语-图像) 同上 GenVector [66] 截断随机游走 (用户 - 用户和概念 - 概念) 同上 受限制的DeepWalk [25] 边权重采样 同上 DDRW [47] 截断随机游走 +分类一致性 同上 TriDNR [73] 截断随机游走 (节点,单词和标签之间) 同上 node2vec [28] BFS + DFS SkipGram 和负采样(公式 12) UPP-SNE [113] 截断随机游走 (用户 - 用户和个人资料 - 个人资料) 同上 Planetoid [62] 按标签和结构对节点对进行采样 +标签标识 同上 NBNE [19] 对节点的直接邻居进行采样 同上 DGK [93] graphlet 核:随机采样[114] (通过graphlet) SkipGram(公式11 - 12 ) metapath2vec [46] 基于元路径的随机游走 异构 SkipGram ProxEmbed [44] 截断随机游走 节点排名元组 LSTM HSNL [29] 截断随机游走 + QA排名元组 LSTM RMNL [30] 截断随机游走 +用户问题质量排名 LSTM DeepCas [63] 基于马尔可夫链的随机游走 信息级联序列 GRU MRW-MN [36] 截断随机游走 +跨模态特征差异 DCNN + SkipGram DeepWalk [17]的成功激发了许多后续研究,这些研究将深度学习模型(例如,SkipGram或长短期记忆(LSTM)[115])应用于图嵌入的采样路径。 我们在表 6中对它们进行了总结。 如表中所示,大多数研究遵循DeepWalk的想法,但改变随机游戏的采样方法([25,28,62,62])或要保留的邻近度(定义 5和定义 6)的设定([34,66,47,73,62])。 [46]设计基于元路径的随机游走来处理异构图和异构 SkipGram,它最大化了给定节点具有异构上下文的概率。 除了SkipGram之外,LSTM是图嵌入中采用的另一种流行的深度学习模型。 请注意,SkipGram只能嵌入一个节点。 然而,有时我们可能需要将一系列节点嵌入为固定长度向量,例如,将句子(即,一系列单词)表示为一个向量,就要在这种情况下采用LSTM来嵌入节点序列。 例如,[29]和[30]嵌入cQA站点中的问题/答案中的句子,[44]在两个节点之间嵌入一系列节点,用于邻近度嵌入。 在这些工作中优化排名损失函数,来保持训练数据中的排名分数。 在[63]中,GRU [116](即,类似于LSTM的递归神经网络模型)用于嵌入信息级联路径。 不带随机游走的基于 DL 的图嵌入 见解: 多层学习架构是一种强大而有效的解决方案,可将图编码为低维空间。 第二类基于深度学习的图嵌入方法直接在整个图(或整个图的邻近矩阵)上应用深度模型。 以下是图嵌入中使用的一些流行的深度学习模型。 自编码器 :自编码器旨在最小化其编码器输入和解码器输出的重建误差。 编码器和解码器都包含多个非线性函数。 编码器将输入数据映射到表示空间,并且解码器将表示空间映射到重建空间。 采用自编码器进行图嵌入的思想,与邻域保持方面的节点邻近矩阵分解(Sec.4.1.2)相似。 具体而言,邻接矩阵捕获节点的邻域。 如果我们将邻接矩阵输入到自编码器,则重建过程将使具有相似邻域的节点具有类似的嵌入。 深度神经网络 :作为一种流行的深度学习模型,卷积神经网络(CNN)及其变体已广泛应用于图嵌入。 一方面,他们中的一些人直接使用为欧几里德域设计的原始CNN模型,并重新格式化输入图以适应它。 例如,[55]使用图标记,从图中选择固定长度的节点序列,然后使用 CNN 模型,组装节点的邻域来学习邻域表示。 另一方面,一些其他工作试图将深度神经模型推广到非欧几里德域(例如,图)。 [117]在他们的综述中总结了代表性研究。 通常,这些方法之间的差异在于,它们在图上形成类似卷积的操作的方公式 一种方法是模拟卷积定理以定义谱域中的卷积 [118,119]。 另一种方法是将卷积视为空域中的邻域匹配 [82,72,120]。 其他 :还有一些其他类型的基于深度学习的图嵌入方法。 例如,[35]提出了DUIF,它使用分层softmax作为前向传播来最大化模块性。 HNE [33]利用深度学习技术来捕获异构成分之间的交互,例如,用于图像的CNN和用于文本的FC层。 ProjE [40]设计了一个具有组合层和投影层的神经网络。 它定义了知识图嵌入的逐点损失(类似于多分类)和列表损失(即softmax回归损失)。 我们在表 7 中总结了所有基于深度学习的图嵌入方法(没有随机游走),并比较了它们使用的模型以及每个模型的输入。 **表7:**基于深度学习的图嵌入, 没有随机游走路径。 GE 算法 深度学习模型 模型输入 SDNE [20] 自编码器 DNGR [23] 堆叠去噪自编码器 PPMI SAE [22] 稀疏自编码器 [55] CNN 节点序列 SCNN [118] 谱 CNN 图 [119] 带有光滑谱乘法器的谱 CNN 图 MoNet [80] 混合模型网络 图 ChebNet [82] 图CNN又名ChebNet 图 GCN [72] 图卷积网络 图 GNN [120] 图神经网络 图 [121] 自适应图神经网络 分子图 GGS-NNs [122] 自适应图神经网络 图 HNE [33] CNN + FC 带图像和文本的图 DUIF [35] 分层深度模型 社会管理网络 ProjE [40] 神经网络模型 知识图 TIGraNet [123] 图卷积网络 从图像构造的图 总结:由于它的威力和效率,深度学习已广泛应用于图嵌入。 在基于深度学习的图嵌入方法中,已经观察到三种类型的输入图(除了从非关系数据构建的图(第 3.1.4 节))和所有四种类型的嵌入输出。
原文:A Comprehensive Survey of Graph Embedding: Problems, Techniques and Applications (arxiv 1709.07604) 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 三、图嵌入的问题设定 在本节中,我们从问题设定的角度比较现有的图嵌入工作,其中包括嵌入输入和嵌入输出。 对于每个设定,我们首先介绍不同类型的图嵌入输入或输出,然后总结每个设定最后面临的挑战。 我们从图嵌入输入开始。 由于图嵌入设定由输入和输出组成,我们在介绍不同类型的输入期间,使用节点嵌入作为嵌入输出设定的示例。 原因在于尽管存在各种类型的嵌入输出,但是大多数图嵌入研究集中于节点嵌入,即,将节点嵌入到低维空间中,其中保持输入图中的节点相似性。 有关节点嵌入和其他类型嵌入输出的更多详细信息,请参见 3.2 节。 图嵌入输入 图嵌入的输入是图。 在本综述中,我们将图嵌入输入分为四类:同构图,异构图,辅助信息图和构造图。 每种类型的图对图嵌入提出了不同的挑战。 接下来,我们介绍这四种类型的输入图,并总结每种输入设定所面临的挑战。 同构图 **图4:**加权和有向图的示例。 第一类输入图是同构图(定义 2),其中节点和边分别属于单一类型。 同构图可以进一步分类为加权(或定向)和无权(或无向)图,如图 4 所示的示例。 无向和无权的同构图是最基本的图嵌入输入设定。 许多研究都在这种情况下进行,例如 [1,16,17,18,19]。 它们平等地处理所有节点和边,因为只有输入图的基本结构信息可用。 直观上,边的权重和方向提供了图的更多信息,并且有助于在嵌入空间中更准确地表示图。 例如,在图 4(a)中, 应该比 更接近 ,因为边的权重 更高。 同样在图 4(b)中, 应该比 更接近 ,因为 和 在两个方向相连。 上述信息在无权和无向图中丢失。 注意到利用图边的权重和方向属性的优点,图嵌入社区开始探索加权和/或有向图。 其中一些只关注一个图属性,即边权重或边方向。 一方面,[20,21,22,23,24,25] 考虑了加权图 。 通过较高加权边连接的节点彼此靠得更近。 但是,他们的工作仍局限于无向图。 另一方面,一些工作在嵌入过程中区分边的方向并且保持嵌入空间中的方向信息。 有向图的一个例子是社交网络图,例如[26]。 每个用户都有其他用户的粉丝和关注关系。 但是,权重信息不适用于社交用户链接。 最近,提出了一种更通用的图嵌入算法,其中考虑了权重和方向属性。 换句话说,这些算法(例如,[27,3,28])可以处理有向和无向,以及加权和无权图 。 挑战: 如何捕获图中观察到的连接模式的多样性? 由于在同构图中只有结构信息可用,因此同构图嵌入的挑战在于,如何在嵌入期间保留输入图中观察到的这些连通模式。 异构图 第二类输入是异构图(定义 3),主要存在于以下三种情形中。 基于社区的问答(cQA)网站。 cQA 是一种基于互联网的众包服务,使用户能够在网站上发布问题,然后由其他用户回答[29]。 直观上,cQA 图中存在不同类型的节点,例如问题,答案,用户。 现有的 cQA 图嵌入方法在它们利用的链接方面彼此区分,如表 2 所示 ,其中 表示 由用户j提供的问题i的答案k,比用户p的答案o获得更多的投票(即点赞)。 表2: cQA站点的图嵌入算法 GE算法 利用的链接 [30] 用户 - 用户,用户 - 问题 [31] 用户 - 用户,用户 - 问题,问题 - 回答 [29] 用户 - 用户,问题 - 回答,用户 - 回答 [32] 用户的不对称关注链接,有序的元组 多媒体网络。 多媒体网络是包含多媒体数据的网络,例如图像,文本等。例如,[33]和[34]都嵌入了包含两种节点(图像和文本)和三种链接(图像 - 图像,文本 - 文本,文本 - 图像)的图。 [35]使用用户节点和图像节点处理社交管理。 它利用用户图像链接将用户和图像嵌入到同一空间中,以便可以直接比较它们来进行图像推荐。 在[36]中,考虑了包含图像和文本查询的点击图。 图像 - 查询边表示给定查询的图像的点击,其中点击计数用作边权重。 知识图。 在知识图(定义 4)中,实体(节点)和关系(边)通常是不同类型的。 例如,在从Freebase [37]构建的电影相关知识图中,实体的类型可以是“导演”,“演员”,“电影”等。关系的类型可以是“制作”,“导演”, “参演”。 已经投入了大量精力来嵌入知识图(例如,[38,39,40])。 我们将在 4.3.3 节中详细介绍它们。。 其他异构图也存在。 例如,[41]和[42]从事于移动数据图,其中车站(s),角色(r)和公司(c)节点由三种类型的链接(ss,sr,sc)连接。 [43]嵌入了维基百科图,具有三种类型的节点(实体(e),类别(c)和单词(w))和三种类型的边(ee,ec,ww)。 除了上面的图之外,还有一些通用的异构图,其中节点和边的类型没有明确定义 [44,45,46]。 挑战: 如何探索不同类型对象之间的全局一致性,以及如何处理属于不同类型的对象的不平衡(如果有的话)? 在异构图嵌入中将不同类型的对象(例如,节点,边)嵌入到相同空间中。 如何探索它们之间的全局一致性是一个问题。 而且,不同类型的对象之间可能存在不平衡。 嵌入时应考虑此数据偏差。 带有辅助信息的图 第三类输入图除了包含节点的结构关系外,还包含节点/边/整图的辅助信息(即 )。 通常,表 3 中列出了五种不同类型的辅助信息。 表3:图中不同类型的辅助信息的比较 辅助信息 描述 标签 节点/边的类别值,例如,类别信息 属性 节点/边的类别或连续值,例如,属性信息 节点特性 节点的文本或图像特性 信息传播 信息在图中的传播路径 知识库 与知识概念相关的文本或事实 标签 :具有不同标签的节点应嵌入到彼此远离的位置。 为了实现这一点,[47]和[48]联合优化嵌入目标函数和分类器函数。 [49]对具有不同标签的节点之间的相似性进行了惩罚。 [50]在计算不同的图核时考虑节点标签和边标签。 [51]和[52]嵌入了知识图,其中实体(节点)具有语义类别。 [53]嵌入了更复杂的知识图,它的实体类别在层次结构中,例如,类别“书”具有两个子类别“作者”和“编写工作”。 属性:与标签相反,属性值可以是离散的或连续的。 例如,[54]嵌入具有离散节点属性值的图(例如,分子中的原子序数)。 相反,[4]将节点属性表示为连续的高维向量(例如,社交网络中的用户属性特征)。 [55]处理节点和边的离散和连续属性。 节点特征:大多数节点特征是文本,它们作为每个节点的特征向量 [56,57] 或作为文档 [58,59,60,61] 提供。 对于后者,文档被进一步处理,来使用诸如词袋 [58],主题建模 [59,60] 或将“单词”视为一种节点 [61] 的技术来提取特征向量。 其他类型的节点特征,例如图像特征[33]也是可能的。 节点特征通过提供丰富的非结构化信息来增强图嵌入表现,这在许多实际图中都可用。 此外,它使归纳的图嵌入成为可能[62]。 信息传播 : 信息传播的一个例子是Twitter中的“转推”。 在[63]中,给出了数据图 ,一个级联图 为每个级联构建 ,其中 是拥有 的节点, 是 两端的边。 然后他们嵌入了 来预测级联大小的增量。 与之不同,[64]旨在嵌入用户和内容信息,使得它们的嵌入之间的相似性表示扩散概率。 Topo-LSTM [65]认为级联不仅仅是一个节点序列,而是一个用于嵌入的动态有向非循环图。 知识库:流行的知识库包括维基百科[66],Freebase [37],YAGO [67],DBpedia [68]等。以维基百科为例,概念是用户提出的实体,文本是与之相关的文章。 [66]使用知识库通过将每个社交网络用户,链接到一组给定的知识概念,来从社交网络学习社交知识图。 [69]表示实体空间中的查询和文档(由知识库提供),以便学术搜索引擎可以理解查询中研究概念的含义。 其他类型的辅助信息包括用户登记数据(用户 - 位置)[70],用户项目偏好排序列表[71]等。注意,辅助信息不仅限于一种类型。 例如,[62]和[72]同时考虑标签和节点特征信息。 [73]利用节点内容和标签来辅助图嵌入过程。 挑战: 如何整合丰富的非结构化信息,以便学习的嵌入既代表拓扑结构又代表辅助信息中的不同? 除了图结构信息之外,辅助信息还有助于定义节点相似性。 使用辅助信息嵌入图的挑战,是如何组合这两个信息源以定义要保留的节点相似性。 从非关系数据构造的图 输入图的最后一类从未提供,而是通过不同策略从非关系输入数据构造。 当假设输入数据位于低维流形中时,通常会发生这种情况。 在大多数情况下,输入是特征矩阵 ,其中每一行 是一个 维的特征向量,表示第 个训练实例。相似度矩阵 是( , )使用之间的相似性,通过计算 构建的。 通常有两种方法来从 构建图 。 一种直接的方式是直接将 看做无形图的邻接矩阵 [74]。 然而,[74]基于欧几里德距离,并且在计算 时不考虑相邻节点。 如果 位于弯曲的流形上或附近, 和 之间的距离,在流形上方比欧几里德距离大得多[12]。 为了解决这些问题,其他方法(例如,[75,76,77])首先从 构造了一个 K 最近邻(KNN)图 ,并基于 KNN 图估计邻接矩阵 。 例如,Isomap [78] 在 中包含了测地距离。 它首先构造一个 KNN 图 ,然后找到两个节点之间的最短路径作为它们之间的测地距离。[79] 降低 KNN 图构建的成本( ),构造一个了锚图,在时间和空间消耗方面,其成本是 。 他们首先获得一组簇中心作为虚拟锚点,并找到每个节点的 K 个最近的锚点,用于构建锚图。 图构造的另一种方式是基于节点的共现,在节点之间建立边。 例如,为了促进与图像相关的应用(例如,图像分割,图像分类),研究人员(例如,[80,81,82])通过将像素视为节点并将像素之间的空间关系视为边来从每个图像构建图。 [83]从GTMS记录中提取三种类型的节点(位置,时间和消息),因此在这些节点之间形成六种类型的边。 [84]使用实体提示,目标类型和文本特征作为节点来生成图,并建立三种边:提示-类型,提示-特征和类型-类型。 除了基于上述成对相似性和基于节点共现的方法之外,还针对不同目的设计了其他图构建策略。 例如,[85]构造一个内在图来捕获类内紧致性,以及一个惩罚图来表示类间可分性。 前者是通过将每个数据点与同一类的邻居连接而构建的,而后者则连接不同类别的边点。 [86]构造一个有符号的图来利用标签信息。 如果两个节点属于同一个类,则它们通过正边连接,如果它们来自两个类,则为负边。 [87]将包含相同标签的所有实例包含在一个超边中,来捕获它们的联合相似性。 在[88]中,构建了两个反馈图,将相关的对聚集在一起,并在嵌入之后远离不相关的对。 在正图中,如果两个节点是相关的,则连接它们。 在负图中,仅当一个节点相关而另一个节点不相关时,才连接两个节点。 挑战: 如何构建一个图来编码实例之间的成对关系,以及如何在嵌入空间中保留生成的节点邻近度矩阵? 嵌入由非关系数据构建的图所面临的第一个挑战是,如何计算非关系数据之间的关系并构建这样的图。 在构建图之后,挑战变得与其他输入图中的相同,即,如何在嵌入空间中保持所构造的图的节点接近度。 图嵌入输出 图嵌入的输出是表示图(的一部分)的(一组)低维向量。 基于输出粒度,我们将图嵌入输出分为四类,包括节点嵌入,边嵌入,混合嵌入和整图嵌入。 不同类型的嵌入有助于不同的应用。 与固定和给定的嵌入输入不同,嵌入输出是任务驱动的。 例如,节点嵌入可以使各种节点相关的图分析任务受益。 通过将每个节点表示为向量,可以在时间和空间方面有效地执行诸如节点聚类,节点分类之类的节点相关任务。 但是,图分析任务并不总是节点级别的。 在某些情况下,任务可能与图的更高粒度有关,例如节点对,子图,甚至整图。 因此,嵌入输出方面的第一个挑战,是如何找到满足特定应用任务需求的合适类型的嵌入输出。 节点嵌入 作为最常见的嵌入输出设定,节点嵌入将每个节点表示为低维空间中的向量。 嵌入图中“接近”的节点具有类似的向量表示。 各种图嵌入方法之间的区别,在于它们如何定义两个节点之间的“接近度”。 一阶接近度(定义 5)和二阶接近度(定义 6)是成对节点相似度计算的两个通常采用的度量。 在某些工作中,也在一定程度上探索了高阶接近度。 例如,[21] 在潜入中捕获了 步()邻居关系。 [1]和[89]都认为属于同一社区的两个节点的嵌入更近。 挑战: 如何在各种类型的输入图中定义成对节点接近度,以及如何在习得的嵌入中编码接近度? 节点嵌入的挑战主要来自于在输入图中定义节点接近度。 在第 3.1 节中,我们详细阐述了带有不同类型的输入图的节点嵌入的挑战。 接下来,我们将介绍其他类型的嵌入输出以及这些输出带来的新挑战。 边嵌入 与节点嵌入相反,边嵌入旨在将边表示为低维向量。 边嵌入在以下两种方案中很有用。 首先,知识图嵌入(例如,[90,91,92])学习节点和边的嵌入。 每个边都是三元组 (定义 4)。嵌入的学习是为了在嵌入空间中保存 和 之间的 ,以便在给定 的其他两个成分的情况下,可以正确预测缺失的实体/关系 。 其次,一些工作(例如,[28,64])将节点对嵌入为向量特征,使节点对与其他节点可比,或预测两个节点之间的链接。 例如,[64]提出了一种内容社交影响特征来预测给定内容的用户 - 用户交互概率。 它将用户对和内容嵌入到同一空间中。 [28]在节点嵌入上,使用自举方法嵌入一对节点,以便于预测图中两个节点之间是否存在链接。 总之,边嵌入有利于边(/节点对)相关的图分析,例如链接预测,知识图实体/关系预测等。 挑战: 如何定义边级别的相似性,以及如何模拟边的不对称属性(如果有的话)? 边的接近度与节点接近度不同,因为边包含一对节点并且通常表示成对节点关系。 而且,与节点不同,边可以是有向的。 这种不对称属性应该编码在习得的表示中。 混合嵌入 混合嵌入是不同类型的图成分的组合的嵌入,例如,节点+边(即子结构),节点+社区。 在大量工作中子结构嵌入已经得到了研究。 例如,[44] 嵌入了两个可能很远的节点之间的图结构,来支持语义邻近搜索。 [93]学习子图(例如,graphlet)的嵌入,以便定义用于图分类的图核。 [94]利用知识库来丰富有关答案的信息。 它将问题实体中的路径和子图嵌入到答案实体中。 与子图嵌入相比,社区嵌入仅受到有限的关注。 [1]建议考虑用于节点嵌入的社区感知邻近度,使得节点的嵌入类似于其社区的嵌入。 ComE [89]还联合解决了节点嵌入,社区检测和社区嵌入。 它不是将社区表示为向量,而是将每个社区嵌入定义为多元高斯分布,以便表示其成员节点的分布方式。 子结构或社区的嵌入也可以通过聚合单个节点和其中的边嵌入来导出。 然而,这种“间接”方法没有为表示结构而优化。 此外,节点嵌入和社区嵌入可以相互促进。 通过结合社区感知的高阶邻近度来学习更好的节点嵌入,而当生成更准确的节点嵌入时,可以检测到更好的社区。 挑战: 如何生成目标子结构以及如何在一个公共空间中嵌入不同类型的图成分? 与其他类型的嵌入输出相比,没有给出混合嵌入(例如,子图,社区)的嵌入目标。 因此,第一个挑战是如何生成这种嵌入目标结构。 此外,不同类型的目标(例如,社区,节点)可以同时嵌入一个公共空间中。 如何解决嵌入目标类型的异构性是一个问题。 整图嵌入 最后一种类型的输出是通常针对小图的整图嵌入,例如蛋白质,分子等。在这种情况下,图被表示为一个向量,并且两个相似的图的嵌入更接近。 整图嵌入有助于图分类任务,为计算图相似性提供了一种简单有效的解决方案 [55,49,95]。 为了在嵌入时间(效率)和保存信息的能力(表现力)之间建立折衷,[95]设计了一个分层的图嵌入框架。 它认为准确理解整图信息需要处理不同尺度的子结构。 形成图金字塔,其中每个级别是不同比例的图的汇总。 该图在所有级别上嵌入,然后连接成一个向量。 [63]学习整个级联图的嵌入,然后训练一个多层感知器来预测未来级联图的大小增量。 挑战: 如何捕捉整图的属性,以及如何在表现力和效率之间进行权衡? 整图嵌入需要捕获整图的属性,因此与其他类型的嵌入相比更耗时。 整图嵌入的关键挑战是,如何在习得的嵌入的表现力和嵌入算法的效率之间做出选择。
版权声明:License CC BY-NC-SA 4.0 https://blog.csdn.net/wizardforcel/article/details/82730437 GeeksForGeeks 是计算机科学百科,涵盖了所有计算机科学核心课程。 本项目的目标是翻译 GeeksForGeeks 站点内的一部分教程。 这些教程适用于: APCS 本科专业课 研究生考试 计算机三、四级 不适用于: ACM/OI CTF 大数据竞赛 高中信息技术 软考 由于工作量非常大,我们不得不使用谷歌翻译来辅助。 目前已上传的章节有: 数据库 计算理论 编译 数字逻辑 组成原理 操作系统 计算机网络 贡献指南 这些教程需要校对,我们日后可能会组织校对活动。 欢迎任何人参与和完善:一个人可以走的很快,但是一群人却可以走的更远。 ApacheCN 组织资源 深度学习 机器学习 大数据 运维工具 TensorFlow R1.2 中文文档 机器学习实战-教学 Spark 2.2.0和2.0.2 中文文档 Zeppelin 0.7.2 中文文档 Pytorch 0.3 中文文档 Sklearn 0.19 中文文档 Storm 1.1.0和1.0.1 中文文档 Kibana 5.2 中文文档 LightGBM 中文文档 Kudu 1.4.0 中文文档 XGBoost 中文文档 Elasticsearch 5.4 中文文档 kaggle: 机器学习竞赛 Beam 中文文档 Sklearn 与 TensorFlow 机器学习实用指南 面向机器学习的特征工程
原文:A Comprehensive Survey of Graph Embedding: Problems, Techniques and Applications (arxiv 1709.07604) 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 一、引言 图自然存在于各种各样的现实世界场景中,例如,社交媒体网络中的社交图/扩散图,研究领域中的引用图,电子商务区中的用户兴趣图,知识图等。这些图的分析提供了如何充分利用隐藏在图中的信息,因此在过去的几十年中受到了极大的关注。 有效的图分析可以使许多应用受益,例如节点分类 [1],节点聚类 [2],节点检索/推荐 [3],链接预测 [4]等。例如,通过分析基于的构建图。在社交网络中的用户交互(例如,在Twitter中转发/评论/关注),我们可以对用户进行分类,检测社区,推荐朋友,并预测两个用户之间是否会发生交互。 尽管图分析是实用且必不可少的,但是大多数现有的图分析方法具有高计算和空间成本。 许多研究工作致力于有效地进行昂贵的图分析。 示例包括分布式图数据处理框架(例如,GraphX [5],GraphLab [6]),新的节省空间的图存储,它可以加速 I/O 和计算成本[7],等等。 除了上述策略之外,图嵌入提供了一种有效的方法来解决图分析问题。 具体而言,图嵌入将图转换为保存图信息的低维空间。 通过将图表示为(或一组)低维向量,图算法可以有效地计算。 存在不同类型的图(例如,同构图,异构图,属性图等),因此图嵌入的输入在不同场景中变化。 图嵌入的输出是表示图(或整个图)的一部分的低维向量。 图1展示了以不同粒度将图嵌入2D空间的玩具示例。 即,根据不同的需要,我们可以将节点/边/子结构/整图表示为低维向量。 有关不同类型的图嵌入输入和输出的更多详细信息,请参见第3节。 在21世纪初期,图嵌入算法主要是通过假设数据位于低维流形中,来减少非关系数据的高维度。 给定一组非关系高维数据特征,基于成对特征相似性构建相似性图。 然后,图中的每个节点被嵌入到低维空间中,其中连接的节点彼此更接近。4.1 节介绍了这一系列研究的例子。。 自2010年以来,随着图在各个领域的激增,图嵌入的研究开始以图作为输入并利用辅助信息(如果有的话)来促进嵌入。 一方面,它们中的一些专注于将图的一部分(例如,节点,边,子结构)(图1(b)- 1(d))表示为一个向量。 为了获得这种嵌入,他们要么采用最先进的深度学习技术(第 4.2 节),要么设计一个目标函数来优化边重建概率(第 4.3 节)。 另一方面,还有一些工作集中在将整个图嵌入一个向量,用于图级应用(图 1(e))。 图核(第 4.4 节)通常旨在满足此需求。 **图1:**将图嵌入具有不同粒度的2D空间的玩具示例。 图嵌入问题与两个传统的研究问题有关,即图分析[8]和表示学习[9]。 特别是,图嵌入旨在将图示为低维向量,同时保留图结构。 一方面,图分析旨在从图数据中挖掘有用信息。 另一方面,表示学习获得数据表示,使得在构建分类器或其他预测变量时,更容易提取有用信息[9]。 图嵌入在两个问题上重叠,并侧重于学习低维表示。 请注意,我们在此综述中区分了图学习和图嵌入。 图表示学习不要求学习的表示是低维的。 例如,[10]将每个节点表示为向量,其维数等于输入图中的节点数。 每个维度表示节点与图中每个其他节点的测地距离。 将图嵌入低维空间并非易事。 图嵌入的挑战取决于问题设定 ,包括嵌入输入和嵌入输出。 在本综述中,我们将输入图分为四类,包括同构图,异构图,辅助信息 图和非关系数据图 。 不同类型的嵌入输入携带不同的信息来保留在嵌入空间中,因此对图嵌入的问题提出了不同的挑战。 例如,当仅嵌入具有结构信息的图时,节点之间的连接是要保留的目标。 但是,对于具有节点标签或属性信息的图,辅助信息从其他视角提供图属性,因此在嵌入期间也可以考虑。 与给定和固定的嵌入输入不同, 嵌入输出是任务驱动的。 例如,最常见的嵌入输出类型是节点嵌入,其将邻近节点表示为类似向量。 节点嵌入可以使节点相关的任务受益,例如节点分类,节点聚类等。然而,在某些情况下,任务可能与图的更高粒度有关,例如,节点对,子图,整个图。 因此,在嵌入输出方面的第一个挑战,是为感兴趣的应用找到合适的嵌入输出类型。 我们对四种类型的图嵌入输出进行了分类,包括节点嵌入,边嵌入,混合嵌入和整图嵌入 。 不同的输出粒度对于“良好”嵌入具有不同的标准并且面临不同的挑战。 例如,良好的节点嵌入保持嵌入空间中的相邻节点的相似性。 相反,良好的整图嵌入将整个图示为向量,从而保持图级相似性。 在不同问题设定中面临的挑战的观察中,我们通过基于问题设定和嵌入技术对图嵌入文献进行分类,提出了两种图嵌入工作的分类法。 这两种分类法对应于图嵌入中存在的挑战以及现有研究如何应对这些挑战。 特别是,我们首先介绍图嵌入问题的不同设置以及每个设置中面临的挑战。 然后,我们描述现有研究如何在他们的工作中应对这些挑战,包括他们的见解和技术解决方案。 请注意,虽然已经有了一些图嵌入的综述([11,12,13]),但它们有以下两个限制。 首先,他们通常只提出一种图嵌入技术的分类。 他们都没有从问题设定的角度分析图嵌入工作,也没有总结每个环境中的挑战。 其次,现有的图嵌入综述仅涉及有限数量的相关工作。 例如,[11]主要介绍了十二种代表性的图嵌入算法,[13]只关注知识图嵌入。 此外,没有分析每种图嵌入技术背后的洞察力。 对现有图嵌入工作的全面回顾,以及对每种嵌入技术的洞察力的高级抽象,可以促进该领域的未来研究。 下面,我们总结了本次综述的主要贡献。 我们提出了基于问题设定的图嵌入分类法,并总结了每个环境中面临的挑战。 我们是第一个基于问题设定对图嵌入工作进行分类的综述,它为理解现有工作带来了新的视角。 我们提供了图嵌入技术的详细分析。 与现有的图嵌入综述相比,我们不仅研究了更全面的图嵌入工作集,而且还总结了每种技术背后的见解。 与简单地列出过去如何解决图嵌入相反,总结的见解回答了为什么可以以某种方式解决图嵌入的问题。 这可以作为未来研究的深刻见解。 我们系统地对使用图嵌入的应用进行分类,并将应用划分为节点相关,边相关和图相关。 对于每个类别,我们提供详细的应用场景作为参考。 我们建议了图嵌入领域的四个有前途的未来研究方向,包括计算效率,问题设定,解决方案技术和应用。 对于每个方向,我们对其在当前工作中的缺点(不足)进行全面分析,并提出未来的研究方向。 本次综述的其余部分安排如下。 在第二节中,我们介绍理解图嵌入问题所需的基本概念的定义,然后提供图嵌入的形式问题定义。 在接下来的两节中,我们提供了两种图嵌入分类法,其中分类法结构如图 2 所示。第 3 节基于问题设定比较了相关工作,并总结了每个设置中面临的挑战。 在第 4 节中,我们基于嵌入技术对文献进行分类。抽象出每种技术背后的见解,并在最后提供不同技术的详细比较。 之后在第 5 节中,我们将介绍图嵌入的应用。 然后,我们在第六节中将讨论中未来的四个研究方向,并在第七节结束本综述。 **图 2:**根据问题和技术的图嵌入分类法。 二、问题形式化 在本节中,我们首先介绍图嵌入中基本概念的定义,然后提供图嵌入问题的正式定义。 符号和定义 本综述中使用的符号的详细说明见表 1 。 **表1:**本文中使用的符号。 符号 说明 集合的基数 = 带有节点 和边 的图 = 图 的子结构,其中 , 节点 和连接 和 的边 接邻矩阵 矩阵 A 的第 i 行向量 矩阵 A 的第 i 行第 j 列 , 节点 类型和边 的类型 , 节点类型集和边类型集 节点 的k个最近邻居 特征矩阵,每行 是 的 维向量 , , 节点 ,边 和结构 的嵌入 嵌入的维度 <h, r, t> 知识图三元组,具有头部实体 , 尾部实体 以及他们之间的关系 , 节点 和 之间的一阶和二阶邻近度 信息级联 拥有级联 的级联图 图是 = ,其中 是一个节点, 是一个边。 关联节点类型的映射函数 和边类型的映射函数 。 和 分别表示节点类型和边类型的集合。 每个节点 属于一种特定类型,即 。 同样,对于 ,。 同构图 =_ 是一个图,满足 。 中的所有节点属于单一类型,所有边都属于单一类型。 异构图 = 是一个图,满足 和/或 。 知识图 = 是一个有向图,其节点是实体 ,边是主体 - 属性 - 客体三元组。形式为( 头部实体 , 关系,尾部实体)的每个边(表示为 )表示关系 来自实体 到实体 。 是实体, 是关系。 在本综述中,我们将 称作知识图三元组。 例如,在图 3 中,有两个三元组: 和 。 请注意,知识图中的实体和关系通常具有不同的类型[14,15]。因此,知识图可以被视为异构图的实例。 **图3:**知识图的玩具示例。 通常采用以下邻近度量来量化要在嵌入空间中保留的图属性。 一阶邻近度是仅由边连接的节点之间的局部成对相似性。 它比较节点对之间的直接连接强度。 从形式上看, 节点 和节点 之间的一阶邻近度是边 的权重,即 。 如果两个节点由具有较大权重的边连接,则它们更相似。 表示节点 和 之间的一阶邻近度, 我们有 。 让 表示 和其他节点的一阶邻近度。 以图 1(a)中的图为例, 和 的一阶邻近度是边的权重 ,表示为 。 记录 和图中的其他节点的边的权重,即 。 二阶邻近度比较节点的邻域结构的相似性。 两个节点的邻域越相似,它们之间的二阶邻近度越大。从形式上看, 节点 和 之间的二阶邻近度 是 的邻居_ _ 和 的邻居 的相似度。 再次,以图1(a)为例: 是 和 的相似度。 如前所述, 并且 。 让我们考虑余弦相似度 和 。 我们可以看到 和 之间的邻近度等于零, 和 没有任何共同邻居。 和 有一个共同的邻居 ,因此他们的二阶邻近度 大于零。 可以同样定义更高阶的邻近度 。 例如,节点 和 之间的 K 阶邻近度是 和 的相似度。 请注意,有时高阶邻近度也使用其他一些指标来定义,例如 Katz Index,Rooted PageRank,Adamic Adar 等[11]。 值得注意的是,在一些工作中,一阶和二阶邻近度是基于两个节点的联合概率和条件概率凭经验计算的。 更多细节将在 4.3.2 中讨论。 图嵌入:给定图的输入 = ,以及嵌入的预定义维度 (),图嵌入的问题是,将 转换为一个 维空间,其中尽可能保留图属性。可以使用诸如一阶和更高阶邻近度来量化图特性。每个图都表示为 维向量(对于整图)或一组 维向量,每个向量表示图的一部分的嵌入(例如,节点,边,子结构)。 图1显示了嵌入图的玩具示例 。 给定一个输入图(图1(a) ),图嵌入算法用于将节点(图1(b) )/边(图1(c) ),子结构(图1(d)) )/整图(图1(e) )转换为2D向量(即2D空间中的点)。 在接下来的两节中,我们分别基于问题设定和嵌入技术,对图嵌入文献进行分类,提供了两种图嵌入分类法。
版权声明:License CC BY-NC-SA 4.0 https://blog.csdn.net/wizardforcel/article/details/82588279 TutorialsPoint AWT 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Angular 2 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Apache Kafka 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Apache Pig 介绍 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Android 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Apex 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Access 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Arduino 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint C# 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint C++ 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Ant 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint AppML 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Apache Storm教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint COBOL 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint CoffeeScript 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint C语言参考 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint C语言教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint DB2 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint D语言教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint EJB 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint EasyMock 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Euphoria教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Excel 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Flex 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Ext.js 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Fortran 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Git 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Django 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Docker 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Go语言 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Groovy 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Guava 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Hadoop 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Hibernate 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Hive 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Ionic 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint JFreeChart 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint JDBC 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint JOGL 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint JPA 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Jackson 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint JMeter 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint JasperReports 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint HBase 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Java Lang 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Java Mail 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Java IO 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Java Math 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Java Util 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Java 实例教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Java 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Java XML 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint JavaFX 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Julia 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Linq 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Lisp 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Lua 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Lucene 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint MangoDB教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Log4j 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Matlab 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint MariaDB 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Memcached 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint MyBatis 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint MySQL 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Node.js教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint NumPy 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint OAuth 2.0 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Objective-C 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint PLSQL 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint POI 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Pandas 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Python教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Laravel教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Maven 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Quertz 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Rust 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint React 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint SQL Server 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Ruby教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint SVN教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint SAS 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Scala 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint SQLite教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Redis 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Sass教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Socket 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Spring 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Sed 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Swift 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Swing 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Tableau 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Struts2 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Servlet & JSP 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Tika 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Tcl 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint TestNG 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Tk 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint VB.Net 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint WCF 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint VBA 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Vue.js教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Yii 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint WordPress 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint XStream 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint iBatis 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint impala 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint 其它教程 20160126 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint 正则表达式教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint 汇编教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint 设计模式教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint ios教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Awk 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Backbone.js 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Bootstrap 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Cassandra 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Cordova 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint CouchDB 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Drools 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Elasticsearch 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Erlang 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Foundation 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Gradle 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Highcharts 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint IP 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint AngularJS教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint JSF 教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint jQuery Mobile 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint IntelliJ IDEA教程 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint vim 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint JSoup 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint JUnit 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Java 面向对象设计教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Jenkins 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Kotlin 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Less 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint MIS教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Java 并发教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Nginx 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint PHP7 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Perl 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint QC 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint RESTful 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint R语言教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Scrapy 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Selenium 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Java 数据类型教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint PostgreSQL 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Meteor 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Solr 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Spring Boot 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Smarty 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Shell 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Spring Security 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint WebGL 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint WxPython 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Zookeeper 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint jQuery UI教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint 数据挖掘教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint 数据结构和算法教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint 计算机基础教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Spring MVC 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Spring JDBC 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint Spring AOP 教程 Gitee 下载 Github 下载 SourceForge 下载 Tutorialspoint 软件工程教程 Gitee 下载 Github 下载 SourceForge 下载
版权声明:License CC BY-NC-SA 4.0 https://blog.csdn.net/wizardforcel/article/details/82588261 《安全参考》2013年6月刊_总第6期 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》2013年7月刊_总第7期 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201308-8 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201309-9 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201310-10 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201311-11 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201312-12 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201401-13 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201402-14 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201403-15 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201404-16 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201405-17 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201406-18 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201407-19 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201408-20 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201409-21 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201410-22 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201411-23 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201412-24 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201501-25 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201502-26 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201503-27 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201504-28 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201505-29 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201506-30 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》HACKCTO-201507-31-渗透测试上 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》2013年1月刊总第1期 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》2013年2月刊总第2期 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》2013年3月刊总第3期 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》2013年4月刊总第4期 Gitee 下载 Github 下载 SourceForge 下载 《安全参考》2013年5月刊总第5期 Gitee 下载 Github 下载 SourceForge 下载 书安-第一期 Gitee 下载 Github 下载 SourceForge 下载 书安-第七期 Gitee 下载 Github 下载 SourceForge 下载 书安-第三期 Gitee 下载 Github 下载 SourceForge 下载 书安-第九期 Gitee 下载 Github 下载 SourceForge 下载 书安-第二期 Gitee 下载 Github 下载 SourceForge 下载 书安-第五期 Gitee 下载 Github 下载 SourceForge 下载 书安-第八期 Gitee 下载 Github 下载 SourceForge 下载 书安-第六期 Gitee 下载 Github 下载 SourceForge 下载 书安-第四期 Gitee 下载 Github 下载 SourceForge 下载
版权声明:License CC BY-NC-SA 4.0 https://blog.csdn.net/wizardforcel/article/details/82588192 Aircrack-ng 教程及文档(部分) Gitee 下载 Github 下载 SourceForge 下载 CEH v9 Links Gitee 下载 Github 下载 SourceForge 下载 CTF Writeups 2016.9.29 Gitee 下载 Github 下载 SourceForge 下载 Django 中文文档 1.8 20170215 Gitee 下载 Github 下载 SourceForge 下载 Exploit 编写系列教程 1 ~ 11 Gitee 下载 Github 下载 SourceForge 下载 Exploring ES2016 and ES2017 Gitee 下载 Github 下载 SourceForge 下载 Exploring ES6 Gitee 下载 Github 下载 SourceForge 下载 FuzzySecurity Windows Exploit 开发系列教程 1 ~ 8 Gitee 下载 Github 下载 SourceForge 下载 Go 简易教程 Gitee 下载 Github 下载 SourceForge 下载 IDA Pro 5.4 中文帮助手册 Gitee 下载 Github 下载 SourceForge 下载 MDN JavaScript 参考文档 2017.2.6 Gitee 下载 Github 下载 SourceForge 下载 Matplotlib 入门教程 Gitee 下载 Github 下载 SourceForge 下载 Matplotlib 用户指南 Gitee 下载 Github 下载 SourceForge 下载 Metasploit 新手指南 Gitee 下载 Github 下载 SourceForge 下载 Microsoft可移植可执行文件和通用目标文件格式文件规范 Gitee 下载 Github 下载 SourceForge 下载 NumPy 中文文档 1.11 20170215 Gitee 下载 Github 下载 SourceForge 下载 OllyDbg 中文帮助手册 2.0.1 Gitee 下载 Github 下载 SourceForge 下载 OllyDbg 完全教程 Gitee 下载 Github 下载 SourceForge 下载 Programming Languages Application and Interpretation Gitee 下载 Github 下载 SourceForge 下载 Python 之旅 Gitee 下载 Github 下载 SourceForge 下载 R4ndom 破解教程翻译 1~20a by QQSniper Gitee 下载 Github 下载 SourceForge 下载 Rails 指南 Gitee 下载 Github 下载 SourceForge 下载 SQLAlchemy 1.1 Documentation Gitee 下载 Github 下载 SourceForge 下载 Scapy 中文文档 Gitee 下载 Github 下载 SourceForge 下载 Scikit Learn 官方文档:示例 Gitee 下载 Github 下载 SourceForge 下载 Shark恒零基础破解教程之图文版 Gitee 下载 Github 下载 SourceForge 下载 Spring-Data-Elasticsearch 中文文档 Gitee 下载 Github 下载 SourceForge 下载 T00ls技术文章350篇 Gitee 下载 Github 下载 SourceForge 下载 TIPI:深入理解PHP内核 Gitee 下载 Github 下载 SourceForge 下载 Windows Exploit 开发教程(Massimiliano Tomassoli) Gitee 下载 Github 下载 SourceForge 下载 c_cpp_ref_html_book_20161029 Gitee 下载 Github 下载 SourceForge 下载 problem-solving-with-algorithms-and-data-structure-using-python 中文版 Gitee 下载 Github 下载 SourceForge 下载 theano Documentation 0.8.2 Gitee 下载 Github 下载 SourceForge 下载 乌云月爆(全十期) Gitee 下载 Github 下载 SourceForge 下载 从逆向工程的角度来看 C++(看雪) Gitee 下载 Github 下载 SourceForge 下载 使用 OllyDbg 从零开始 Cracking Gitee 下载 Github 下载 SourceForge 下载 吾爱破解『我是用户』破解实战 Gitee 下载 Github 下载 SourceForge 下载 商朝子的解密入门教学 Gitee 下载 Github 下载 SourceForge 下载 安全客 2016 年刊 Gitee 下载 Github 下载 SourceForge 下载 安全档案 第二期 Gitee 下载 Github 下载 SourceForge 下载 斯坦福 CS229 机器学习讲义中文版 1~5 Gitee 下载 Github 下载 SourceForge 下载 斯坦福 CS229 机器学习讲义英文版(全) Gitee 下载 Github 下载 SourceForge 下载 斯坦福 cs224d 深度学习与自然语言处理讲义 Gitee 下载 Github 下载 SourceForge 下载 斯坦福机器学习个人笔记 v4.2 - 黄海广 Gitee 下载 Github 下载 SourceForge 下载 新手指南:DVWA 1.9 全级别教程 Gitee 下载 Github 下载 SourceForge 下载 法克论坛一周年提权文集 Gitee 下载 Github 下载 SourceForge 下载 漏洞利用的艺术:攻击JavaScript引擎 Gitee 下载 Github 下载 SourceForge 下载 社工研究组文章整理 2017.2.23 Gitee 下载 Github 下载 SourceForge 下载 软件构建实践 0.1 Gitee 下载 Github 下载 SourceForge 下载 软件漏洞分析入门专题 Gitee 下载 Github 下载 SourceForge 下载 逆向工程入门指南 Gitee 下载 Github 下载 SourceForge 下载 邪恶八进制技术黑皮书第4版 Gitee 下载 Github 下载 SourceForge 下载 阿里巴巴 Java 开发手册(正式版) Gitee 下载 Github 下载 SourceForge 下载 30 天学习 30 种新技术系列 Gitee 下载 Github 下载 SourceForge 下载 3天入门MySQL(千峰教育) Gitee 下载 Github 下载 SourceForge 下载 7天学会PHP(千峰教育) Gitee 下载 Github 下载 SourceForge 下载 Biopython 教程与手册 Gitee 下载 Github 下载 SourceForge 下载 C#编程语言与面向对象基础精简教程(金旭亮) Gitee 下载 Github 下载 SourceForge 下载 C++ Qt5 范例开发大全 Gitee 下载 Github 下载 SourceForge 下载 C++ 基础教程 Beta 版(Juan Soulie) Gitee 下载 Github 下载 SourceForge 下载 C++ 库函数查询手册(龙马工作室) Gitee 下载 Github 下载 SourceForge 下载 C++Primer第4版课后习题解答(第1-18章)完整版 Gitee 下载 Github 下载 SourceForge 下载 C++程序设计课程学习(希赛教育) Gitee 下载 Github 下载 SourceForge 下载 C++编程入门系列(鸡啄米) Gitee 下载 Github 下载 SourceForge 下载 Caffe官方教程中译本 CaffeCN社区翻译 Gitee 下载 Github 下载 SourceForge 下载 GDI+ SDK参考(翻译版本) Gitee 下载 Github 下载 SourceForge 下载 GTK+程序设计中文版 Gitee 下载 Github 下载 SourceForge 下载 Getting Started with Storm 中文版 Gitee 下载 Github 下载 SourceForge 下载 Java SE 6 类库查询手册(龙马工作室) Gitee 下载 Github 下载 SourceForge 下载 Java 培训教程(千峰教育) Gitee 下载 Github 下载 SourceForge 下载 JavaScript 教程(YYM) Gitee 下载 Github 下载 SourceForge 下载 JavaScript基础教程(张陈斌) Gitee 下载 Github 下载 SourceForge 下载 Java基础教程(李兴华) Gitee 下载 Github 下载 SourceForge 下载 Java问题定位技术 Gitee 下载 Github 下载 SourceForge 下载 Java面向对象程序设计(?) Gitee 下载 Github 下载 SourceForge 下载 Java面试宝典2016版 Gitee 下载 Github 下载 SourceForge 下载 Linux 运维入门到高级全套系列 Gitee 下载 Github 下载 SourceForge 下载 Linux命令全集(Linhan) Gitee 下载 Github 下载 SourceForge 下载 Linux基础命令教程豪华版 Gitee 下载 Github 下载 SourceForge 下载 Linux操作系统下C语言编程入门(007xiong) Gitee 下载 Github 下载 SourceForge 下载 Linux菜鸟过关 v3.0 Gitee 下载 Github 下载 SourceForge 下载 MySQL 5.0存储过程 Gitee 下载 Github 下载 SourceForge 下载 MySQL 5.5 新特性详解及参数优化 蓝皮书 Gitee 下载 Github 下载 SourceForge 下载 MySQL 基础教程(大漠孤烟) Gitee 下载 Github 下载 SourceForge 下载 MySQL存储过程编程(1~5) Gitee 下载 Github 下载 SourceForge 下载 MySQL性能调优与架构设计(JesseLZJ) Gitee 下载 Github 下载 SourceForge 下载 OpenCV入门教程(于仕琪) Gitee 下载 Github 下载 SourceForge 下载 PHP 完全中文手册(sadly) Gitee 下载 Github 下载 SourceForge 下载 PHP 调试技术手册 Gitee 下载 Github 下载 SourceForge 下载 PHP5面向对象初步(刀客羽朋) Gitee 下载 Github 下载 SourceForge 下载 PHP内核介绍及扩展开发指南 Gitee 下载 Github 下载 SourceForge 下载 PHP教程从零开始学设计模式(千峰教育) Gitee 下载 Github 下载 SourceForge 下载 PHP设计模式范例 Gitee 下载 Github 下载 SourceForge 下载 PyQt4 精彩实例分析 Gitee 下载 Github 下载 SourceForge 下载 Python 自然语言处理 第二版 20170304 Gitee 下载 Github 下载 SourceForge 下载 Python基础教程(crossin全60课) Gitee 下载 Github 下载 SourceForge 下载 Python学习笔记(王纯业) Gitee 下载 Github 下载 SourceForge 下载 Python精要参考第二版 Gitee 下载 Github 下载 SourceForge 下载 Theano 中文文档 0.9 20170304 Gitee 下载 Github 下载 SourceForge 下载 VS2010 MFC编程入门(鸡啄米) Gitee 下载 Github 下载 SourceForge 下载 WordPress 主题教程 Gitee 下载 Github 下载 SourceForge 下载 WordPress 高级教程(晋级版) Gitee 下载 Github 下载 SourceForge 下载 YAF 用户手册 Gitee 下载 Github 下载 SourceForge 下载 c++课程讲义(传智扫地僧) Gitee 下载 Github 下载 SourceForge 下载 ejb 3.0 实例教程(黎活明) Gitee 下载 Github 下载 SourceForge 下载 java基础教程(Java快车) Gitee 下载 Github 下载 SourceForge 下载 java工程师实例参考手册(廖清远) Gitee 下载 Github 下载 SourceForge 下载 java并发的艺术(InfoQ) Gitee 下载 Github 下载 SourceForge 下载 linux常用命令集(知博网) Gitee 下载 Github 下载 SourceForge 下载 linux系统管理手册0.6.1 Gitee 下载 Github 下载 SourceForge 下载 linux零基础初级教程(红联) Gitee 下载 Github 下载 SourceForge 下载 为生物信息学设计的Python教程 Gitee 下载 Github 下载 SourceForge 下载 北航 991 数据结构与C语言 1998~2015 真题 Gitee 下载 Github 下载 SourceForge 下载 北航数据结构、组成原理、操作系统、计算机网络课件 Gitee 下载 Github 下载 SourceForge 下载 史上最强的 WordPress 初级-中级-高级教程 Gitee 下载 Github 下载 SourceForge 下载 常用数据挖掘算法总结及Python实现 Gitee 下载 Github 下载 SourceForge 下载 开源 PHP 开发框架 Yii 全方位教程 Gitee 下载 Github 下载 SourceForge 下载 挑战30天C++入门极限 Gitee 下载 Github 下载 SourceForge 下载 数据结构与算法 Java 版(周鹏) Gitee 下载 Github 下载 SourceForge 下载 易学C++(第一版) Gitee 下载 Github 下载 SourceForge 下载 深入浅出MySQL数据库开发、优化与管理维护(网易) Gitee 下载 Github 下载 SourceForge 下载 深入浅出设计模式(AI92) Gitee 下载 Github 下载 SourceForge 下载 深度学习教程 0.1(LISA 实验室)20170304 Gitee 下载 Github 下载 SourceForge 下载 笨兔兔的故事 Gitee 下载 Github 下载 SourceForge 下载 设计模式精解-GoF 23种设计模式解析附C++实现源码 Gitee 下载 Github 下载 SourceForge 下载 辛星 JDBC、MyBatis、Spring、Yaf、LESS、Symfony、Markdown、SQLite、Codeigniter 教程 2016 Gitee 下载 Github 下载 SourceForge 下载 辛星 MySQL、Linux、Node、Nginx、Redis、CouchDB、Git 教程 2015 Gitee 下载 Github 下载 SourceForge 下载 辛星 PHP 系列教程 2014(十一本) Gitee 下载 Github 下载 SourceForge 下载 辛星 python 系列教程 2014(九本) Gitee 下载 Github 下载 SourceForge 下载 辛星 web 前端系列教程 2014(九本) Gitee 下载 Github 下载 SourceForge 下载 高级Bash脚本编程指南 3.9.1 (杨春敏 黄毅 译) Gitee 下载 Github 下载 SourceForge 下载 高质量 C C++ 编程指南(林锐) Gitee 下载 Github 下载 SourceForge 下载 A Second Course in Algorithms Lecture Notes (Stanford CS261) Gitee 下载 Github 下载 SourceForge 下载 Advanced Algorithm Design Lecture Notes (Princeton COS521) Gitee 下载 Github 下载 SourceForge 下载 Advanced Algorithms Lecture Notes (MIT 6.854J) Gitee 下载 Github 下载 SourceForge 下载 Advanced Data Flow Analysis Lecture Notes (Cambridge L111) Gitee 下载 Github 下载 SourceForge 下载 Advanced Data Structures Lecture Notes (MIT 6.851) Gitee 下载 Github 下载 SourceForge 下载 Algebraic Graph Algorithms Lecture Notes (Stanford CS367) Gitee 下载 Github 下载 SourceForge 下载 Algorithmic Aspects of Machine Learning (MIT 18.409) Gitee 下载 Github 下载 SourceForge 下载 Algorithmic Game Theory Lecture Notes (Cornell CS6840) Gitee 下载 Github 下载 SourceForge 下载 Algorithms & Models of Computation Lecture Notes (UIUC CS374) Gitee 下载 Github 下载 SourceForge 下载 Algorithms Jeff Erickson (UIUC CS473 573) Gitee 下载 Github 下载 SourceForge 下载 Algorithms for Big Data Lecture Notes (Harvard CS229r) Gitee 下载 Github 下载 SourceForge 下载 Algorithms for Big Data Lecture Notes (UIUC CS598CSC) Gitee 下载 Github 下载 SourceForge 下载 Approximation Algorithms Lecture Notes (UIUC CS598CSC) Gitee 下载 Github 下载 SourceForge 下载 Aukland COMPSCI 111 Practical Computing Reference Manual Gitee 下载 Github 下载 SourceForge 下载 Building Blocks for Theoretical Computer Science (UIUC CS173) Gitee 下载 Github 下载 SourceForge 下载 Combinatorial Optimization Lecture Notes (MIT 18.433) Gitee 下载 Github 下载 SourceForge 下载 Combinatorial Optimization Lecture Notes (UIUC CS598CSC) Gitee 下载 Github 下载 SourceForge 下载 Compiler Design Lecture Notes (CMU 15-411) Gitee 下载 Github 下载 SourceForge 下载 Computer System Engineering Lecture Notes (MIT 6.033) Gitee 下载 Github 下载 SourceForge 下载 Convex Optimization (Stanford CVX101) Gitee 下载 Github 下载 SourceForge 下载 Deep Learning Methods and Application Gitee 下载 Github 下载 SourceForge 下载 Deep Learning in Neural Networks - An Overview Gitee 下载 Github 下载 SourceForge 下载 Design and Analysis of Algorithms Lecture Notes (MIT 6.046J) Gitee 下载 Github 下载 SourceForge 下载 Foundations of Programming Languages Lecture Notes (CMU 15-312) Gitee 下载 Github 下载 SourceForge 下载 Generic Programming with Adjunctions Gitee 下载 Github 下载 SourceForge 下载 Great Ideas in Theoretical Computer Science Lecture Notes (MIT 6.045) Gitee 下载 Github 下载 SourceForge 下载 Homotopy Type Theory Lecture Notes (CMU 15-819) Gitee 下载 Github 下载 SourceForge 下载 Introduction to Algorithms Lecture Notes (MIT 6.006) Gitee 下载 Github 下载 SourceForge 下载 Introduction to Analysis of Algorithms Lecture Notes (Cornell CS4820) Gitee 下载 Github 下载 SourceForge 下载 Introduction to Scientific Computing Lecture Notes (Cornell CS3220) Gitee 下载 Github 下载 SourceForge 下载 Intuitionistic Type Theory Gitee 下载 Github 下载 SourceForge 下载 Learning Deep Architectures for AI 中文版 Gitee 下载 Github 下载 SourceForge 下载 MARKOV CHAIN MONTE CARLO FOUNDATIONS & APPLICATIONS Lecture Notes (UCB CS294) Gitee 下载 Github 下载 SourceForge 下载 Machine Learning for Natural Language Processing Lecture Notes (Columbia E6998) Gitee 下载 Github 下载 SourceForge 下载 Mining Massive Data Sets Reading Material (Stanford CS246) Gitee 下载 Github 下载 SourceForge 下载 Natural Language Processing Lecture Notes (Columbia CS4705) Gitee 下载 Github 下载 SourceForge 下载 Notes on Programming (Alexander Stepanov) Gitee 下载 Github 下载 SourceForge 下载 Operating Systems - Three Easy Pieces Gitee 下载 Github 下载 SourceForge 下载 Operating Systems and Middleware - Supporting Controlled Interaction Gitee 下载 Github 下载 SourceForge 下载 Overcoming Intractability in Machine Learning Lecture Notes (Princeton COS598D) Gitee 下载 Github 下载 SourceForge 下载 Pandas 0.19.2 中文文档 20170305 Gitee 下载 Github 下载 SourceForge 下载 Parch 学习笔记系列(十七本) Gitee 下载 Github 下载 SourceForge 下载 Probabilistic Graphical Models Lecture Notes (CMU 10.708) Gitee 下载 Github 下载 SourceForge 下载 Program Analysis Lecture Notes (CMU 15-819O) Gitee 下载 Github 下载 SourceForge 下载 Programming Languages Lecture Notes (Washington CSE341) Gitee 下载 Github 下载 SourceForge 下载 Programming Languages and Logics Lecture Notes (Cornell CS4110) Gitee 下载 Github 下载 SourceForge 下载 Programming and Programming Languages (Brown Univ) Gitee 下载 Github 下载 SourceForge 下载 Quantum Computation Lecture Notes (Caltech CS219) Gitee 下载 Github 下载 SourceForge 下载 Quantum Computing Lecture Notes (Amsterdam) Gitee 下载 Github 下载 SourceForge 下载 Quantum algorithms Lecture Notes (Waterloo CO781) Gitee 下载 Github 下载 SourceForge 下载 Random Algorithm (Sariel Har-Peled) Gitee 下载 Github 下载 SourceForge 下载 SICP 2e (MIT 6.001) Gitee 下载 Github 下载 SourceForge 下载 Software Analysis and Design I Lecture Notes (CUNY CSci135) Gitee 下载 Github 下载 SourceForge 下载 Software Design and Analysis II Lecture Notes (CUNY CSci235) Gitee 下载 Github 下载 SourceForge 下载 Software Design and Analysis III Lecture Notes (CUNY CSci335) Gitee 下载 Github 下载 SourceForge 下载 Static Program Analysis Reading List (UCLA CS232) Gitee 下载 Github 下载 SourceForge 下载 Static Program Analysis Gitee 下载 Github 下载 SourceForge 下载 Statistical Learning Theory Lecture Notes (Stanford CS229t) Gitee 下载 Github 下载 SourceForge 下载 The Datacenter as a Computer Gitee 下载 Github 下载 SourceForge 下载 Theoretical Machine Learning Lecture Notes (Princeton COS511) Gitee 下载 Github 下载 SourceForge 下载 Theory of Quantum Information Gitee 下载 Github 下载 SourceForge 下载 UCB CS61AS SICP with Racket Gitee 下载 Github 下载 SourceForge 下载 Vectors, Matrices, and Least Squares (Stanford EE103) Gitee 下载 Github 下载 SourceForge 下载 xv6 中文文档 Gitee 下载 Github 下载 SourceForge 下载 传智播客 mybatis 框架课程讲义 Gitee 下载 Github 下载 SourceForge 下载 北航 961 计算机综合 2015 真题 Gitee 下载 Github 下载 SourceForge 下载 北航计算机复试上机真题 2006~2016 Gitee 下载 Github 下载 SourceForge 下载 应用概率统计基础及算法(于江生) Gitee 下载 Github 下载 SourceForge 下载 换个角度看线性代数(风萧萧) Gitee 下载 Github 下载 SourceForge 下载 数学之美、浪潮之巅(谷歌黑板报) Gitee 下载 Github 下载 SourceForge 下载 概率论与数理统计习题参考解答(西安邮电) Gitee 下载 Github 下载 SourceForge 下载 概率论与数理统计讲义(复旦 徐勤丰) Gitee 下载 Github 下载 SourceForge 下载 神经网络与深度学习 Gitee 下载 Github 下载 SourceForge 下载 神经网络与深度学习讲义(复旦 邱锡鹏) Gitee 下载 Github 下载 SourceForge 下载 科大概率统计讲义(USTC 概率统计教研室) Gitee 下载 Github 下载 SourceForge 下载 线性代数总结与复习(武汉大学 黄正华) Gitee 下载 Github 下载 SourceForge 下载 线性代数的几何意义–图解线性代数 Gitee 下载 Github 下载 SourceForge 下载 线性代数讲义(武汉大学 马涛) Gitee 下载 Github 下载 SourceForge 下载 随机过程讲义(中科院 孙应飞) Gitee 下载 Github 下载 SourceForge 下载 随机过程讲义(南开大学) Gitee 下载 Github 下载 SourceForge 下载 高等数学讲义之积分表公式推导(Daniel Lau) Gitee 下载 Github 下载 SourceForge 下载 高等数学(CCP Maths 165) Gitee 下载 Github 下载 SourceForge 下载 Abstract Interpretation Gitee 下载 Github 下载 SourceForge 下载 Advanced Topics in Compilers Reading List (Stanford CS343) Gitee 下载 Github 下载 SourceForge 下载 Advanced Topics in Computer Systems Lecture Notes (UCB CS262A) Gitee 下载 Github 下载 SourceForge 下载 Advanced Topics in Computer Systems Reading List (UCB CS262A) Gitee 下载 Github 下载 SourceForge 下载 Architecture of a Database System Gitee 下载 Github 下载 SourceForge 下载 C C++ socket编程教程:1天玩转socket通信技术 Gitee 下载 Github 下载 SourceForge 下载 Category Theory for Scientists (MIT 18.S996) Gitee 下载 Github 下载 SourceForge 下载 Computational Biology (MIT 6.047) Gitee 下载 Github 下载 SourceForge 下载 Data Structures and Functional Programming Lecture Notes (Cornell CS3110) Gitee 下载 Github 下载 SourceForge 下载 Data Structures and Object Oriented Design Lecture Notes (USC CSCI104) Gitee 下载 Github 下载 SourceForge 下载 Discrete Stochastic Processes Lecture Notes (MIT 6.262) Gitee 下载 Github 下载 SourceForge 下载 Distributed Systems Engineering Lecture notes (MIT 6.824) Gitee 下载 Github 下载 SourceForge 下载 Dynamic Systems and Control Lecture Notes (MIT 6.241J) Gitee 下载 Github 下载 SourceForge 下载 Electromagnetics and Applications Lecture Notes (MIT 6.013) Gitee 下载 Github 下载 SourceForge 下载 Electronic Feedback Systems (MIT 6.010) Gitee 下载 Github 下载 SourceForge 下载 Exploiting Format String Vulnerabilities Gitee 下载 Github 下载 SourceForge 下载 Functional Programming Lecture Notes (Chicago CS223) Gitee 下载 Github 下载 SourceForge 下载 Functional Systems In Haskell Lecture Notes(Stanford CS240h) Gitee 下载 Github 下载 SourceForge 下载 Graduate Computer Graphics (NYU CSCI-GA.2270-001) Gitee 下载 Github 下载 SourceForge 下载 How to Design Programs, Second Edition Gitee 下载 Github 下载 SourceForge 下载 Information and Entropy (MIT 6.050J) Gitee 下载 Github 下载 SourceForge 下载 Introduction to Arithmetic Geometry Lecture Notes (MIT 18.782) Gitee 下载 Github 下载 SourceForge 下载 Introduction to Communication, Control, and Signal Processing Lecture Notes (MIT 6.011) Gitee 下载 Github 下载 SourceForge 下载 Introduction to EECS II Digital Communication Systems (MIT 6.02) Gitee 下载 Github 下载 SourceForge 下载 Introduction to Electric Power Systems Lecture Notes (MIT 6.061) Gitee 下载 Github 下载 SourceForge 下载 Introduction to Information Retrieval Gitee 下载 Github 下载 SourceForge 下载 Introduction to Nanoelectronics (MIT 6.701) Gitee 下载 Github 下载 SourceForge 下载 Introduction to Probability (Dartmouth) Gitee 下载 Github 下载 SourceForge 下载 Introduction to Theory of Computing Lecture Notes (Cornell CS4810) Gitee 下载 Github 下载 SourceForge 下载 Inventions and Patents Lecture Notes (MIT 6.901) Gitee 下载 Github 下载 SourceForge 下载 Linux Shell脚本教程:30分钟玩转Shell脚本编程 Gitee 下载 Github 下载 SourceForge 下载 Linux 系统运维系列(unixhot) Gitee 下载 Github 下载 SourceForge 下载 MATLAB Tutorial (MIT 10.34) Gitee 下载 Github 下载 SourceForge 下载 MFC入门教程:1天深入浅出MFC Gitee 下载 Github 下载 SourceForge 下载 Mathematics for Computer Science (MIT 6.042J) Gitee 下载 Github 下载 SourceForge 下载 MySQL 教程(C语言中文网) Gitee 下载 Github 下载 SourceForge 下载 Operating Systems Lecture Notes (Stanford CS140) Gitee 下载 Github 下载 SourceForge 下载 Operating Systems Lecture Notes (UMD CMSC412) Gitee 下载 Github 下载 SourceForge 下载 Principles of Computer System Design An Introduction Part II (MIT 6.004) Gitee 下载 Github 下载 SourceForge 下载 Programming Languages Lecture Notes (NEU CS4400) Gitee 下载 Github 下载 SourceForge 下载 Programming Practice Tutorials (KAIST CS109) Gitee 下载 Github 下载 SourceForge 下载 Real-Time Programming Lecture Notes (Waterloo CS452) Gitee 下载 Github 下载 SourceForge 下载 Signals and Systems Lecture Notes (MIT 6.003) Gitee 下载 Github 下载 SourceForge 下载 Software Construction Lecture Notes (MIT 6.005) Gitee 下载 Github 下载 SourceForge 下载 Software Design and Implementation Lecture Notes (Washington CSE331) Gitee 下载 Github 下载 SourceForge 下载 Software Foundations Gitee 下载 Github 下载 SourceForge 下载 Street-Fighting Mathematics Lecture Notes (MIT 6.009) Gitee 下载 Github 下载 SourceForge 下载 The Art of Approximation in Science and Engineering Lecture Notes (MIT 6.055J) Gitee 下载 Github 下载 SourceForge 下载 The Scheme Programming Language 4e Gitee 下载 Github 下载 SourceForge 下载 Transition to OO Programming Lecture Notes (Cornell CS1130) Gitee 下载 Github 下载 SourceForge 下载 UIUC Crowd-Sourced System Programming Book Gitee 下载 Github 下载 SourceForge 下载 Ultrafast Optics Lecture Notes (MIT 6.977) Gitee 下载 Github 下载 SourceForge 下载 Web Hacking 101 中文版 Gitee 下载 Github 下载 SourceForge 下载 Web Hacking 101 Gitee 下载 Github 下载 SourceForge 下载 Windows编程入门:Windows程序设计1小时入门教程 Gitee 下载 Github 下载 SourceForge 下载 black Hat 议题摘要翻译 Gitee 下载 Github 下载 SourceForge 下载 coolfire黑客入门8篇 Gitee 下载 Github 下载 SourceForge 下载 三月机器学习在线班笔记(frank_shaw) Gitee 下载 Github 下载 SourceForge 下载 偏微分方程引论(翟健) Gitee 下载 Github 下载 SourceForge 下载 偏微分方程简明教程(ZJU) Gitee 下载 Github 下载 SourceForge 下载 偏微分方程讲义(张振宇) Gitee 下载 Github 下载 SourceForge 下载 关于C++编程的42条建议 Gitee 下载 Github 下载 SourceForge 下载 北航编译原理课件 Gitee 下载 Github 下载 SourceForge 下载 单片机教程(C语言编程网) Gitee 下载 Github 下载 SourceForge 下载 如何学python(李三思) Gitee 下载 Github 下载 SourceForge 下载 常微分方程(冯巍) Gitee 下载 Github 下载 SourceForge 下载 操作系统教程(C语言编程网) Gitee 下载 Github 下载 SourceForge 下载 数据结构教程(C语言编程网) Gitee 下载 Github 下载 SourceForge 下载 正则表达式教程:30分钟让你精通正则表达式语法 Gitee 下载 Github 下载 SourceForge 下载 深入学习Web安全(万年死宅) Gitee 下载 Github 下载 SourceForge 下载 深度学习中文版(dlbook) v0.5b Gitee 下载 Github 下载 SourceForge 下载 米斯特白帽培训讲义 v1.5 Gitee 下载 Github 下载 SourceForge 下载 编程随想博客匿名术文集 2009~2015 Gitee 下载 Github 下载 SourceForge 下载 80x86保护模式系列教程 Gitee 下载 Github 下载 SourceForge 下载 Advanced SQL Injection In SQL Server Applications Gitee 下载 Github 下载 SourceForge 下载 Android开发教程&笔记(张亚飞) Gitee 下载 Github 下载 SourceForge 下载 C++ 工程实践(陈硕) Gitee 下载 Github 下载 SourceForge 下载 CALCULUS MADE EASY Gitee 下载 Github 下载 SourceForge 下载 CSDN 博客 - 高性能服务系统构建与实践 2017.5.13 Gitee 下载 Github 下载 SourceForge 下载 Computer Security Reading List Gitee 下载 Github 下载 SourceForge 下载 Computer and Internet Security Lecture Notes (Syracuse CIS643 644) Gitee 下载 Github 下载 SourceForge 下载 Computer and Network Security by Avinash Kak Gitee 下载 Github 下载 SourceForge 下载 C语言基础教程(Lellansin) Gitee 下载 Github 下载 SourceForge 下载 C语言深度剖析 解开程序员面试笔试的秘密 Gitee 下载 Github 下载 SourceForge 下载 Deep Residual Learning for Image Recognition(七月在线DL翻译组2017.4) Gitee 下载 Github 下载 SourceForge 下载 EXT 中文手册(pjq) Gitee 下载 Github 下载 SourceForge 下载 GTK+ 2.0 教程 Gitee 下载 Github 下载 SourceForge 下载 Hibernate 开发指南(夏昕) Gitee 下载 Github 下载 SourceForge 下载 Information Security Lecture Notes (Dixie IT4500) Gitee 下载 Github 下载 SourceForge 下载 Intel® 64 and IA-32 Architectures Volume 1 Gitee 下载 Github 下载 SourceForge 下载 Invent Your Own Computer Games with Python 2nd Gitee 下载 Github 下载 SourceForge 下载 JS函数式编程指南 Gitee 下载 Github 下载 SourceForge 下载 Java 开发利器 Myeclipse 全面详解(北风网) Gitee 下载 Github 下载 SourceForge 下载 Kubernetes Handbook Gitee 下载 Github 下载 SourceForge 下载 Linux入门教程(C语言编程网) Gitee 下载 Github 下载 SourceForge 下载 MySQL 超新手入门 Gitee 下载 Github 下载 SourceForge 下载 Objective-C基础教程:1天玩转Objective-C语法 Gitee 下载 Github 下载 SourceForge 下载 Pandas 官方教程(10 分钟搞定 Pandas、Pandas 秘籍、学习 Pandas) Gitee 下载 Github 下载 SourceForge 下载 Parsing Techniques A Practical Guide Gitee 下载 Github 下载 SourceForge 下载 Perl 语言编程 Gitee 下载 Github 下载 SourceForge 下载 Plotly 绘图模块中文指南第1期 Gitee 下载 Github 下载 SourceForge 下载 Probabilistic Machine Learning (Duke STA561) Gitee 下载 Github 下载 SourceForge 下载 PyAlgoTrade用户手册中文版 Gitee 下载 Github 下载 SourceForge 下载 Python 机器学习 Gitee 下载 Github 下载 SourceForge 下载 Python 黑帽编程 1.1~4.2(玄魂工作室) Gitee 下载 Github 下载 SourceForge 下载 React.js 小书 20170503 Gitee 下载 Github 下载 SourceForge 下载 Ruby 中文文档、RGSS 中文文档 Gitee 下载 Github 下载 SourceForge 下载 Scikit-learn 秘籍 Gitee 下载 Github 下载 SourceForge 下载 Secure Programs HOWTO Gitee 下载 Github 下载 SourceForge 下载 Security Engineering — The Book V2 Gitee 下载 Github 下载 SourceForge 下载 SploitFun Linux x86 Exploit 开发系列教程 Gitee 下载 Github 下载 SourceForge 下载 Spring 2.3.2 开发简明教程(张勇) Gitee 下载 Github 下载 SourceForge 下载 Spring 快速入门教程(Hantsy) Gitee 下载 Github 下载 SourceForge 下载 System Security Lecture Notes (StonyBrook CSE509) Gitee 下载 Github 下载 SourceForge 下载 Type Systems(Luca Cardelli) Gitee 下载 Github 下载 SourceForge 下载 Windows XP 2003 堆溢出实战 Gitee 下载 Github 下载 SourceForge 下载 Windows 平台下的堆溢出、格式化字符串漏洞利用技术 Gitee 下载 Github 下载 SourceForge 下载 c语言也能干大事 Gitee 下载 Github 下载 SourceForge 下载 iOS App Reverse Engineering Gitee 下载 Github 下载 SourceForge 下载 seaborn统计绘图模块中文指南 Gitee 下载 Github 下载 SourceForge 下载 the beginner’s guide to idapython 中文版 Gitee 下载 Github 下载 SourceForge 下载 tushare函数手册中文版 Gitee 下载 Github 下载 SourceForge 下载 zw Python 量化分析课件 Gitee 下载 Github 下载 SourceForge 下载 七月在线 DL 论文翻译组译文 2017.3 Gitee 下载 Github 下载 SourceForge 下载 中山大学编译原理讲义 Gitee 下载 Github 下载 SourceForge 下载 从lex&yacc说到编译器 Gitee 下载 Github 下载 SourceForge 下载 从零开始学 ReactJS(ReactJS 101) Gitee 下载 Github 下载 SourceForge 下载 代码能有多难?——简单的网页代码书 Gitee 下载 Github 下载 SourceForge 下载 你好,C++! Gitee 下载 Github 下载 SourceForge 下载 借助开源项目,学习软件开发 Gitee 下载 Github 下载 SourceForge 下载 北航程序设计语言原理教材(共18章) Gitee 下载 Github 下载 SourceForge 下载 大道至简(周爱民) Gitee 下载 Github 下载 SourceForge 下载 天书夜读(试读版) Gitee 下载 Github 下载 SourceForge 下载 安全客2017Q1 Gitee 下载 Github 下载 SourceForge 下载 微学苑 C++ 教程 Gitee 下载 Github 下载 SourceForge 下载 微学苑 Java 教程 Gitee 下载 Github 下载 SourceForge 下载 微学苑设计模式教程 Gitee 下载 Github 下载 SourceForge 下载 我的职业是前端工程师 Gitee 下载 Github 下载 SourceForge 下载 探索 ES6(未完) Gitee 下载 Github 下载 SourceForge 下载 数据挖掘开源书(肖凯) Gitee 下载 Github 下载 SourceForge 下载 数据结构与算法分析学习笔记(罗聪) Gitee 下载 Github 下载 SourceForge 下载 斯坦福机器学习笔记(JerryLead) Gitee 下载 Github 下载 SourceForge 下载 格式化字符串漏洞利用 Gitee 下载 Github 下载 SourceForge 下载 楚狂人Windows驱动编程基础教程 Gitee 下载 Github 下载 SourceForge 下载 模式识别与机器学习中文版(马春鹏) Gitee 下载 Github 下载 SourceForge 下载 汇编语言全接触 Gitee 下载 Github 下载 SourceForge 下载 漏洞银行“大咖面对面”分享汇总 1~14 期 Gitee 下载 Github 下载 SourceForge 下载 算法艺术与信息学竞赛指导 Gitee 下载 Github 下载 SourceForge 下载 组合语言之艺术 Gitee 下载 Github 下载 SourceForge 下载 莫烦 Git 教程 2017.5.10 Gitee 下载 Github 下载 SourceForge 下载 莫烦 Python 教程 2017.5.10 Gitee 下载 Github 下载 SourceForge 下载 莫烦数据处理教程 2017.5.10 Gitee 下载 Github 下载 SourceForge 下载 莫烦机器学习教程 2017.5.10 Gitee 下载 Github 下载 SourceForge 下载 雪城大学计算机与网络安全讲义(CIS643&644)v0.1 Gitee 下载 Github 下载 SourceForge 下载 面向对象的脚本语言Ruby Gitee 下载 Github 下载 SourceForge 下载 飞龙的安卓逆向教程 v1.0 Gitee 下载 Github 下载 SourceForge 下载 A Brief Introduction to Machine Learning for Engineers Gitee 下载 Github 下载 SourceForge 下载 A Graduate Course in Applied Cryptography v0.3 Gitee 下载 Github 下载 SourceForge 下载 An Introduction to Statistical Learning Gitee 下载 Github 下载 SourceForge 下载 Analysis of Algorithms Lecture Notes (Cornell CS6820) Gitee 下载 Github 下载 SourceForge 下载 BigQuant 量化教程 Gitee 下载 Github 下载 SourceForge 下载 CEH v8 Labs Module All-In-One Gitee 下载 Github 下载 SourceForge 下载 CS for All Gitee 下载 Github 下载 SourceForge 下载 CTF-All-In-One 20171015 Gitee 下载 Github 下载 SourceForge 下载 Calculus for Beginners (MIT) Gitee 下载 Github 下载 SourceForge 下载 Chrome 开发工具指南 Gitee 下载 Github 下载 SourceForge 下载 Computational and Inferential Thinking (UCB Data8) Gitee 下载 Github 下载 SourceForge 下载 CrossApp 中文文档 Gitee 下载 Github 下载 SourceForge 下载 Dart 编码风格指南 Gitee 下载 Github 下载 SourceForge 下载 Data Structures Lecture Notes (UCB CS61b) Gitee 下载 Github 下载 SourceForge 下载 ECMAScript 6 入门(第三版) Gitee 下载 Github 下载 SourceForge 下载 Ember.js 参考文档 Gitee 下载 Github 下载 SourceForge 下载 Excel 教程:实用技巧系列 Gitee 下载 Github 下载 SourceForge 下载 Fast.ai Computational Linear Algebra Textbook Gitee 下载 Github 下载 SourceForge 下载 Go By Example 中文版 Gitee 下载 Github 下载 SourceForge 下载 HBuilder 使用教程 Gitee 下载 Github 下载 SourceForge 下载 Hadoop 笔记(PennyWong) Gitee 下载 Github 下载 SourceForge 下载 HomeKit App 开发指南 Gitee 下载 Github 下载 SourceForge 下载 Java 编程思想 Gitee 下载 Github 下载 SourceForge 下载 JavaScript 轻量级函数式编程 Gitee 下载 Github 下载 SourceForge 下载 JavaScript正则表达式迷你书 Gitee 下载 Github 下载 SourceForge 下载 Kali Linux Revealed Gitee 下载 Github 下载 SourceForge 下载 Keras 中文文档 Gitee 下载 Github 下载 SourceForge 下载 MDN HTML 文档 20170730 Gitee 下载 Github 下载 SourceForge 下载 MDN 文件 API 中文文档 Gitee 下载 Github 下载 SourceForge 下载 MIT 线性代数笔记(子实) Gitee 下载 Github 下载 SourceForge 下载 Markdown 语法说明 Gitee 下载 Github 下载 SourceForge 下载 Office技巧1000例 Gitee 下载 Github 下载 SourceForge 下载 Operating System Lecture Notes (MIT 6.828) Gitee 下载 Github 下载 SourceForge 下载 Photoshop 教程:必学技巧 Gitee 下载 Github 下载 SourceForge 下载 QmlBook 中文版 Gitee 下载 Github 下载 SourceForge 下载 Revel 中文文档 Gitee 下载 Github 下载 SourceForge 下载 RiceQuant 量化教程 Gitee 下载 Github 下载 SourceForge 下载 SSDB 数据库使用手册 Gitee 下载 Github 下载 SourceForge 下载 Scala 概述(瑞士洛桑联邦理工) Gitee 下载 Github 下载 SourceForge 下载 Serving Machine Learning Models Gitee 下载 Github 下载 SourceForge 下载 Spring 2.0 核心技术与最佳实践 Gitee 下载 Github 下载 SourceForge 下载 Sublime Text 中文文档、使用手册 Gitee 下载 Github 下载 SourceForge 下载 Swoole 中文文档 Gitee 下载 Github 下载 SourceForge 下载 TensorFlow 教程(Hvass) Gitee 下载 Github 下载 SourceForge 下载 The Design and Analysis of Algorithms Gitee 下载 Github 下载 SourceForge 下载 The Elements of Statistical Learning 2e Gitee 下载 Github 下载 SourceForge 下载 The Matrix Cookbook Gitee 下载 Github 下载 SourceForge 下载 Three.js 入门指南 Gitee 下载 Github 下载 SourceForge 下载 TutorialsPoint Apex 教程 Gitee 下载 Github 下载 SourceForge 下载 calculus with applications (MIT 18.013A) Gitee 下载 Github 下载 SourceForge 下载 imobilebbs CodeSmith 教程 Gitee 下载 Github 下载 SourceForge 下载 imobilebbs Slick 教程 Gitee 下载 Github 下载 SourceForge 下载 一种针对特定网站类别的网页指纹识别方法 (南邮 CN105281973A) Gitee 下载 Github 下载 SourceForge 下载 不周山之读薄 CSAPP Gitee 下载 Github 下载 SourceForge 下载 以太坊白皮书 Gitee 下载 Github 下载 SourceForge 下载 以太坊黄皮书 Gitee 下载 Github 下载 SourceForge 下载 使⽤ MXNet Gluon 来动⼿学深度学习 0.4 Gitee 下载 Github 下载 SourceForge 下载 写给人类的机器学习 Gitee 下载 Github 下载 SourceForge 下载 初等算法(算法新解)0.6180339887498949 Gitee 下载 Github 下载 SourceForge 下载 前端开发者指南(2017) Gitee 下载 Github 下载 SourceForge 下载 台湾大学林轩田机器学习基石笔记(红色石头) Gitee 下载 Github 下载 SourceForge 下载 微信公众平台开发文档 Gitee 下载 Github 下载 SourceForge 下载 微信小程序开发文档 Gitee 下载 Github 下载 SourceForge 下载 探索 ES2016 与 ES2017 Gitee 下载 Github 下载 SourceForge 下载 支持向量机通俗导论(理解SVM的三层境界)Latex版 Gitee 下载 Github 下载 SourceForge 下载 数据结构就该这样学 Gitee 下载 Github 下载 SourceForge 下载 数据结构思维中文版 Gitee 下载 Github 下载 SourceForge 下载 斯坦福 CS183b YC 创业课文字版(创见) Gitee 下载 Github 下载 SourceForge 下载 机器学习实战(ApacheCN) Gitee 下载 Github 下载 SourceForge 下载 架构师之路(58沈剑) Gitee 下载 Github 下载 SourceForge 下载 演算法笔记 201710 Gitee 下载 Github 下载 SourceForge 下载 笨办法学 Linux 中文版 Gitee 下载 Github 下载 SourceForge 下载 笨办法学 Python · 续 Gitee 下载 Github 下载 SourceForge 下载 精通 Omi 框架 Gitee 下载 Github 下载 SourceForge 下载 统计思维:程序员数学之概率统计 · GitBook Gitee 下载 Github 下载 SourceForge 下载 绿盟 Android APP 测试指南 Gitee 下载 Github 下载 SourceForge 下载 绿盟 iOS APP 测试指南 Gitee 下载 Github 下载 SourceForge 下载 计算机程序的思维逻辑(Java 描述) Gitee 下载 Github 下载 SourceForge 下载 通过例子学习 Go Web 编程 Gitee 下载 Github 下载 SourceForge 下载 阿布量化课堂 Gitee 下载 Github 下载 SourceForge 下载 阿里巴巴Java开发手册(终极版) Gitee 下载 Github 下载 SourceForge 下载 零基础入门深度学习(hanbingtao) Gitee 下载 Github 下载 SourceForge 下载 高级Bash脚本编程指南 3.9.1 Gitee 下载 Github 下载 SourceForge 下载 2017阿里技术年度精选集 Gitee 下载 Github 下载 SourceForge 下载 An Introduction to Elm Gitee 下载 Github 下载 SourceForge 下载 Apache Kudu 1.4.0 中文文档 Gitee 下载 Github 下载 SourceForge 下载 Apache Zeppelin 0.7.2 中文文档 Gitee 下载 Github 下载 SourceForge 下载 C 参考手册 2017.12.20 Gitee 下载 Github 下载 SourceForge 下载 C++ Best Practices Gitee 下载 Github 下载 SourceForge 下载 C++ 参考手册 第一部分 2017.12.20 Gitee 下载 Github 下载 SourceForge 下载 C++ 参考手册 第三部分 2017.12.20 Gitee 下载 Github 下载 SourceForge 下载 C++ 参考手册 第二部分 2017.12.20 Gitee 下载 Github 下载 SourceForge 下载 Computer Science Field Guide Gitee 下载 Github 下载 SourceForge 下载 DOM Enlightenment Gitee 下载 Github 下载 SourceForge 下载 Databricks Spark Reference Applications Gitee 下载 Github 下载 SourceForge 下载 DeepLearningAI 笔记 v5.1(黄海广) Gitee 下载 Github 下载 SourceForge 下载 Elasticsearch 5.4 中文文档 Gitee 下载 Github 下载 SourceForge 下载 Elasticsearch Java 手册 Gitee 下载 Github 下载 SourceForge 下载 FPGA实战手册 Gitee 下载 Github 下载 SourceForge 下载 Gainlo 面试指南 Gitee 下载 Github 下载 SourceForge 下载 HBase 0.97 中文文档 Gitee 下载 Github 下载 SourceForge 下载 HTTP2 讲解 Gitee 下载 Github 下载 SourceForge 下载 Hadoop 2.7.1 中文文档 Gitee 下载 Github 下载 SourceForge 下载 Hyperledger 源码分析之 Fabric Gitee 下载 Github 下载 SourceForge 下载 Implementing a language with LLVM Gitee 下载 Github 下载 SourceForge 下载 Java API 版本的Vert.x Core 手册 Gitee 下载 Github 下载 SourceForge 下载 Java for small teams Gitee 下载 Github 下载 SourceForge 下载 JavaScript Enlightenment Gitee 下载 Github 下载 SourceForge 下载 Kibana 5.2 中文文档 Gitee 下载 Github 下载 SourceForge 下载 MDN Canvas API 中文文档 20171202 Gitee 下载 Github 下载 SourceForge 下载 MDN HTTP 中文文档 20171202 Gitee 下载 Github 下载 SourceForge 下载 MQTT协议中文版 Gitee 下载 Github 下载 SourceForge 下载 Mastering Django Core Gitee 下载 Github 下载 SourceForge 下载 Mininet 应用与源码剖析 Gitee 下载 Github 下载 SourceForge 下载 MongoDB 3.4 中文文档 2017.5.6 Gitee 下载 Github 下载 SourceForge 下载 OpenCL 2.0 异构计算中文第三版 Gitee 下载 Github 下载 SourceForge 下载 OpenCV 中文文档 Gitee 下载 Github 下载 SourceForge 下载 OpenCV 官方教程中文版(Python) Gitee 下载 Github 下载 SourceForge 下载 PHP2Python API 参考 2017.9.25 Gitee 下载 Github 下载 SourceForge 下载 PHP7内核剖析 Gitee 下载 Github 下载 SourceForge 下载 PyTorch 中文文档 Gitee 下载 Github 下载 SourceForge 下载 Python Data Science Handbook (Jupyter Notebook Version) Gitee 下载 Github 下载 SourceForge 下载 React Bits 中文版 Gitee 下载 Github 下载 SourceForge 下载 React Enlightenment Gitee 下载 Github 下载 SourceForge 下载 React In-depth Gitee 下载 Github 下载 SourceForge 下载 React 入门教程 Gitee 下载 Github 下载 SourceForge 下载 Reinforcement Learning An Introduction (Complete Draft) Gitee 下载 Github 下载 SourceForge 下载 SQL for Web Nerds Gitee 下载 Github 下载 SourceForge 下载 Software Engineering for Internet Applications Gitee 下载 Github 下载 SourceForge 下载 Spark 2.0.2 中文文档 Gitee 下载 Github 下载 SourceForge 下载 Spring Boot 中文文档 1.5.2 Gitee 下载 Github 下载 SourceForge 下载 Spring Boot 入门教程(翟永超) Gitee 下载 Github 下载 SourceForge 下载 Spring Cloud 教程(翟永超) Gitee 下载 Github 下载 SourceForge 下载 Spring MVC 4.2.4.RELEASE 中文文档 Gitee 下载 Github 下载 SourceForge 下载 Stanford CS109 Probability for Computer Scientists Lecture Notes Gitee 下载 Github 下载 SourceForge 下载 Storm 1.1.0 中文文档 Gitee 下载 Github 下载 SourceForge 下载 SymPy 1.1.1 documentation Gitee 下载 Github 下载 SourceForge 下载 TensorFlow 官方文档中文版 Gitee 下载 Github 下载 SourceForge 下载 The Art of Data Science Gitee 下载 Github 下载 SourceForge 下载 Tiny Python & ES6 Notebook Gitee 下载 Github 下载 SourceForge 下载 TypeScript Deep Dive Gitee 下载 Github 下载 SourceForge 下载 Zetcode PyQt4 教程 Gitee 下载 Github 下载 SourceForge 下载 kafka 中文文档 0.10.0 Gitee 下载 Github 下载 SourceForge 下载 《SciPy and NumPy》中文精要 Gitee 下载 Github 下载 SourceForge 下载 【spark论文】大型集群上的快速和通用数据处理架构(修正版) Gitee 下载 Github 下载 SourceForge 下载 利用 Python 进行数据分析 第二版 中文精要 Gitee 下载 Github 下载 SourceForge 下载 剑指 Offer 题解(JeanCheng) Gitee 下载 Github 下载 SourceForge 下载 北京动力节点 Reyco郭 Spring4 SpringMVC4 Mybatis3 讲义 Gitee 下载 Github 下载 SourceForge 下载 区块链技术指南 0.9.0 Gitee 下载 Github 下载 SourceForge 下载 增强学习导论中文版(RLAI)2~10 Gitee 下载 Github 下载 SourceForge 下载 安全客 2017 Q2 Gitee 下载 Github 下载 SourceForge 下载 安全客 2017 Q3 Gitee 下载 Github 下载 SourceForge 下载 微软官方 .NET Core 指南 20171224 Gitee 下载 Github 下载 SourceForge 下载 微软官方 .NET 指南 20171224 Gitee 下载 Github 下载 SourceForge 下载 微软官方 C# 指南 20171224 Gitee 下载 Github 下载 SourceForge 下载 微软官方 F# 指南 20171224 Gitee 下载 Github 下载 SourceForge 下载 微软官方 VB 指南 20171224 Gitee 下载 Github 下载 SourceForge 下载 我的数学分析积木(修订版) Gitee 下载 Github 下载 SourceForge 下载 比特币开发者指南 Gitee 下载 Github 下载 SourceForge 下载 深入 Python 编程 0.3(雨痕) Gitee 下载 Github 下载 SourceForge 下载 系统设计入门 Gitee 下载 Github 下载 SourceForge 下载 轻松学习微积分(译言古登堡) Gitee 下载 Github 下载 SourceForge 下载 非监督特征学习与深度学习 中文教程(UFLDL) Gitee 下载 Github 下载 SourceForge 下载
为了更好地与中国的创业公司交流,硅谷创业孵化器 Y Combinator(YC)首次登陆中国,并于 5 月 19 日在清华大学举办“Startup School 北京”。 “Startup School 北京”活动当天, YC 主席 Sam Altman、Airbnb 创始人、Strikingly 创始人、Stripe 创始人、中国的 Raven Tech 创始人都出席本次活动,并与现场 300 位创业者分享创业理念和心得。 过往参加过“Startup School”的嘉宾包含 Facebook 的创始人、LinkedIn 的创始人等。Startup School 是创始人开启创业之旅的第一站,在这里你将学到如何创建和运营一个成功科技公司。 YC 方面表示: “自 2013 年第一支创业团队从 YC 毕业后,越来越多的中国团队从 YC 启航和毕业。YC 认为中国是目前发展速度最快的科技生态体系和大型创新中心,越来越多的创业公司在中国诞生,因此 YC 非常希望有机会可以与中国的创业者交流,帮助并推动创业者的创业进程。” 为了本次活动的顺利举办,YC 选择了自己孵化的第一支中国团队 Strikingly 作为联合主办方,参与活动的策划和执行。同时,清华大学也作为另一个联合主办方提供了场地和支持。 课程主页 视频 下载(m3u8 格式)
通往财富自由之路是李笑来在得到上发布的课程,涉及认知和投资。 笔记由本人亲自整理,涵盖了第一季的所有音频内容(讨论我没有放进去,可以在这里查看)。 下载: Gitee Github 还有一个小福利,知笔墨上面有《原则》的中译本,由刘念翻译。这本书据说是《财富自由之路》的灵感来源之一。
从今天开始,我会在博客里转载优秀的非技术类课程的笔记。 【5 分钟商学院】是刘润在得到上发布的课程,一共 260 课。 笔记由【洋_葱头】整理,涵盖了前 185 课。 下载: CSDN Github
Github 仓库:https://github.com/apachecn/prob140-textbook-zh 整体进度:https://github.com/apachecn/prob140-textbook-zh/issues/2 贡献指南:https://github.com/apachecn/prob140-textbook-zh/blob/master/CONTRIBUTING.md
TensorFlow 高效编程 原文:vahidk/EffectiveTensorflow 译者:FesianXu、飞龙 协议:CC BY-NC-SA 4.0 一、TensorFlow 基础 TensorFlow 和其他数字计算库(如 numpy)之间最明显的区别在于 TensorFlow 中操作的是符号。这是一个强大的功能,这保证了 TensorFlow 可以做很多其他库(例如 numpy)不能完成的事情(例如自动区分)。这可能也是它更复杂的原因。今天我们来一步步探秘 TensorFlow,并为更有效地使用 TensorFlow 提供了一些指导方针和最佳实践。 我们从一个简单的例子开始,我们要乘以两个随机矩阵。首先我们来看一下在 numpy 中如何实现: import numpy as np x = np.random.normal(size=[10, 10]) y = np.random.normal(size=[10, 10]) z = np.dot(x, y) print(z) 现在我们使用 TensorFlow 中执行完全相同的计算: import TensorFlow as tf x = tf.random_normal([10, 10]) y = tf.random_normal([10, 10]) z = tf.matmul(x, y) sess = tf.Session() z_val = sess.run(z) print(z_val) 与立即执行计算并将结果复制给输出变量z的 numpy 不同,TensorFlow 只给我们一个可以操作的张量类型。如果我们尝试直接打印z的值,我们得到这样的东西: Tensor("MatMul:0", shape=(10, 10), dtype=float32) 由于两个输入都是已经定义的类型,TensorFlow 能够推断张量的符号及其类型。为了计算张量的值,我们需要创建一个会话并使用Session.run方法进行评估。 要了解如此强大的符号计算到底是什么,我们可以看看另一个例子。假设我们有一个曲线的样本(例如f(x)= 5x ^ 2 + 3),并且我们要估计f(x)在不知道它的参数的前提下。我们定义参数函数为g(x,w)= w0 x ^ 2 + w1 x + w2,它是输入x和潜在参数w的函数,我们的目标是找到潜在参数,使得g(x, w)≈f(x)。这可以通过最小化损失函数来完成:L(w)=(f(x)-g(x,w))^ 2。虽然这问题有一个简单的封闭式的解决方案,但是我们选择使用一种更为通用的方法,可以应用于任何可以区分的任务,那就是使用随机梯度下降。我们在一组采样点上简单地计算相对于w的L(w)的平均梯度,并沿相反方向移动。 以下是在 TensorFlow 中如何完成: import numpy as np import TensorFlow as tf x = tf.placeholder(tf.float32) y = tf.placeholder(tf.float32) w = tf.get_variable("w", shape=[3, 1]) f = tf.stack([tf.square(x), x, tf.ones_like(x)], 1) yhat = tf.squeeze(tf.matmul(f, w), 1) loss = tf.nn.l2_loss(yhat - y) + 0.1 * tf.nn.l2_loss(w) train_op = tf.train.AdamOptimizer(0.1).minimize(loss) def generate_data(): x_val = np.random.uniform(-10.0, 10.0, size=100) y_val = 5 * np.square(x_val) + 3 return x_val, y_val sess = tf.Session() sess.run(tf.global_variables_initializer()) for _ in range(1000): x_val, y_val = generate_data() _, loss_val = sess.run([train_op, loss], {x: x_val, y: y_val}) print(loss_val) print(sess.run([w])) 通过运行这段代码,我们可以看到下面这组数据: [4.9924135, 0.00040895029, 3.4504161] 这与我们的参数已经相当接近。 这只是 TensorFlow 可以做的冰山一角。许多问题,如优化具有数百万个参数的大型神经网络,都可以在 TensorFlow 中使用短短的几行代码高效地实现。而且 TensorFlow 可以跨多个设备和线程进行扩展,并支持各种平台。 二、理解静态和动态形状 在 TensorFlow 中,tensor有一个在图构建过程中就被决定的静态形状属性, 这个静态形状可以是未规定的,比如,我们可以定一个具有形状[None, 128]大小的tensor。 import TensorFlow as tf a = tf.placeholder(tf.float32, [None, 128]) 这意味着tensor的第一个维度可以是任何尺寸,这个将会在Session.run()中被动态定义。当然,你可以查询一个tensor的静态形状,如: static_shape = a.shape.as_list() # returns [None, 128] 为了得到一个tensor的动态形状,你可以调用tf.shape操作,这将会返回指定tensor的形状,如: dynamic_shape = tf.shape(a) tensor的静态形状可以通过方法Tensor_name.set_shape()设定,如: a.set_shape([32, 128]) # static shape of a is [32, 128] a.set_shape([None, 128]) # first dimension of a is determined dynamically 调用tf.reshape()方法,你可以动态地重塑一个tensor的形状,如: a = tf.reshape(a, [32, 128]) 可以定义一个函数,当静态形状的时候返回其静态形状,当静态形状不存在时,返回其动态形状,如: def get_shape(tensor): static_shape = tensor.shape.as_list() dynamic_shape = tf.unstack(tf.shape(tensor)) dims = [s[1] if s[0] is None else s[0] for s in zip(static_shape, dynamic_shape)] return dims 现在,如果我们需要将一个三阶的tensor转变为 2 阶的tensor,通过折叠第二维和第三维成一个维度,我们可以通过我们刚才定义的get_shape()方法进行,如: b = tf.placeholder(tf.float32, [None, 10, 32]) shape = get_shape(b) b = tf.reshape(b, [shape[0], shape[1] * shape[2]]) 注意到无论这个tensor的形状是静态指定的还是动态指定的,这个代码都是有效的。事实上,我们可以写出一个通用的reshape函数,用于折叠维度的任意列表: import TensorFlow as tf import numpy as np def reshape(tensor, dims_list): shape = get_shape(tensor) dims_prod = [] for dims in dims_list: if isinstance(dims, int): dims_prod.append(shape[dims]) elif all([isinstance(shape[d], int) for d in dims]): dims_prod.append(np.prod([shape[d] for d in dims])) else: dims_prod.append(tf.prod([shape[d] for d in dims])) tensor = tf.reshape(tensor, dims_prod) return tensor 然后折叠第二个维度就变得特别简单了。 b = tf.placeholder(tf.float32, [None, 10, 32]) b = reshape(b, [0, [1, 2]]) 三、作用域和何时使用它 在 TensorFlow 中,变量和张量有一个名字属性,用于作为他们在图中的标识。如果你在创造变量或者张量的时候,不给他们显式地指定一个名字,那么 TF 将会自动地,隐式地给他们分配名字,如: a = tf.constant(1) print(a.name) # prints "Const:0" b = tf.Variable(1) print(b.name) # prints "Variable:0" 你也可以在定义的时候,通过显式地给变量或者张量命名,这样将会重写他们的默认名,如: a = tf.constant(1, name="a") print(a.name) # prints "b:0" b = tf.Variable(1, name="b") print(b.name) # prints "b:0" TF 引进了两个不同的上下文管理器,用于更改张量或者变量的名字,第一个就是tf.name_scope,如: with tf.name_scope("scope"): a = tf.constant(1, name="a") print(a.name) # prints "scope/a:0" b = tf.Variable(1, name="b") print(b.name) # prints "scope/b:0" c = tf.get_variable(name="c", shape=[]) print(c.name) # prints "c:0" 我们注意到,在 TF 中,我们有两种方式去定义一个新的变量,通过tf.Variable()或者调用tf.get_variable()。在调用tf.get_variable()的时候,给予一个新的名字,将会创建一个新的变量,但是如果这个名字并不是一个新的名字,而是已经存在过这个变量作用域中的,那么就会抛出一个ValueError异常,意味着重复声明一个变量是不被允许的。 tf.name_scope()只会影响到通过调用tf.Variable创建的张量和变量的名字,而不会影响到通过调用tf.get_variable()创建的变量和张量。 和tf.name_scope()不同,tf.variable_scope()也会修改,影响通过tf.get_variable()创建的变量和张量,如: with tf.variable_scope("scope"): a = tf.constant(1, name="a") print(a.name) # prints "scope/a:0" b = tf.Variable(1, name="b") print(b.name) # prints "scope/b:0" c = tf.get_variable(name="c", shape=[]) print(c.name) # prints "scope/c:0" with tf.variable_scope("scope"): a1 = tf.get_variable(name="a", shape=[]) a2 = tf.get_variable(name="a", shape=[]) # Disallowed 但是如果我们真的想要重复使用一个先前声明过了变量怎么办呢?变量管理器同样提供了一套机制去实现这个需求: with tf.variable_scope("scope"): a1 = tf.get_variable(name="a", shape=[]) with tf.variable_scope("scope", reuse=True): a2 = tf.get_variable(name="a", shape=[]) # OK This becomes handy for example when using built-in neural network layers: features1 = tf.layers.conv2d(image1, filters=32, kernel_size=3) # Use the same convolution weights to process the second image: with tf.variable_scope(tf.get_variable_scope(), reuse=True): features2 = tf.layers.conv2d(image2, filters=32, kernel_size=3) 这个语法可能看起来并不是特别的清晰明了。特别是,如果你在模型中想要实现一大堆的变量共享,你需要追踪各个变量,比如说什么时候定义新的变量,什么时候要复用他们,这些将会变得特别麻烦而且容易出错,因此 TF 提供了 TF 模版自动解决变量共享的问题: conv3x32 = tf.make_template("conv3x32", lambda x: tf.layers.conv2d(x, 32, 3)) features1 = conv3x32(image1) features2 = conv3x32(image2) # Will reuse the convolution weights. 你可以将任何函数都转换为 TF 模版。当第一次调用这个模版的时候,在这个函数内声明的变量将会被定义,同时在接下来的连续调用中,这些变量都将自动地复用。 四、广播的优缺点 TensorFlow 支持广播机制,可以广播逐元素操作。正常情况下,当你想要进行一些操作如加法,乘法时,你需要确保操作数的形状是相匹配的,如:你不能将一个具有形状[3, 2]的张量和一个具有[3,4]形状的张量相加。但是,这里有一个特殊情况,那就是当你的其中一个操作数是一个某个维度为一的张量的时候,TF 会隐式地填充它的单一维度方向,以确保和另一个操作数的形状相匹配。所以,对一个[3,2]的张量和一个[3,1]的张量相加在 TF 中是合法的。 import TensorFlow as tf a = tf.constant([[1., 2.], [3., 4.]]) b = tf.constant([[1.], [2.]]) # c = a + tf.tile(b, [1, 2]) c = a + b 广播机制允许我们在隐式情况下进行填充,而这可以使得我们的代码更加简洁,并且更有效率地利用内存,因为我们不需要另外储存填充操作的结果。一个可以表现这个优势的应用场景就是在结合具有不同长度的特征向量的时候。为了拼接具有不同长度的特征向量,我们一般都先填充输入向量,拼接这个结果然后进行之后的一系列非线性操作等。这是一大类神经网络架构的共同模式。 a = tf.random_uniform([5, 3, 5]) b = tf.random_uniform([5, 1, 6]) # concat a and b and apply nonlinearity tiled_b = tf.tile(b, [1, 3, 1]) c = tf.concat([a, tiled_b], 2) d = tf.layers.dense(c, 10, activation=tf.nn.relu) 但是这个可以通过广播机制更有效地完成。我们利用事实f(m(x+y))=f(mx+my)f(m(x+y))=f(mx+my)f(m(x+y))=f(mx+my),简化我们的填充操作。因此,我们可以分离地进行这个线性操作,利用广播机制隐式地完成拼接操作。 pa = tf.layers.dense(a, 10, activation=None) pb = tf.layers.dense(b, 10, activation=None) d = tf.nn.relu(pa + pb) 事实上,这个代码足够通用,并且可以在具有任意形状的张量间应用: def merge(a, b, units, activation=tf.nn.relu): pa = tf.layers.dense(a, units, activation=None) pb = tf.layers.dense(b, units, activation=None) c = pa + pb if activation is not None: c = activation(c) return c 一个更为通用函数形式如上所述: 目前为止,我们讨论了广播机制的优点,但是同样的广播机制也有其缺点,隐式假设几乎总是使得调试变得更加困难,考虑下面的例子: a = tf.constant([[1.], [2.]]) b = tf.constant([1., 2.]) c = tf.reduce_sum(a + b) 你猜这个结果是多少?如果你说是 6,那么你就错了,答案应该是 12。这是因为当两个张量的阶数不匹配的时候,在进行元素间操作之前,TF 将会自动地在更低阶数的张量的第一个维度开始扩展,所以这个加法的结果将会变为[[2, 3], [3, 4]],所以这个reduce的结果是12. 解决这种麻烦的方法就是尽可能地显式使用。我们在需要reduce某些张量的时候,显式地指定维度,然后寻找这个 bug 就会变得简单: a = tf.constant([[1.], [2.]]) b = tf.constant([1., 2.]) c = tf.reduce_sum(a + b, 0) 这样,c的值就是[5, 7],我们就容易猜到其出错的原因。一个更通用的法则就是总是在reduce操作和在使用tf.squeeze中指定维度。 五、向 TensorFlow 投喂数据 TensorFlow 被设计可以在大规模的数据情况下高效地运行。所以你需要记住千万不要“饿着”你的 TF 模型,这样才能得到最好的表现。一般来说,一共有三种方法可以“投喂”你的模型。 常数方式(tf.constant) 最简单的方式莫过于直接将数据当成常数嵌入你的计算图中,如: import TensorFlow as tf import numpy as np actual_data = np.random.normal(size=[100]) data = tf.constant(actual_data) 这个方式非常地高效,但是却不灵活。这个方式存在一个大问题就是为了在其他数据集上复用你的模型,你必须要重写你的计算图,而且你必须同时加载所有数据,并且一直保存在内存里,这意味着这个方式仅仅适用于小数剧集的情况。 占位符方式(tf.placeholder) 可以通过占位符的方式解决刚才常数投喂网络的问题,如: import TensorFlow as tf import numpy as np data = tf.placeholder(tf.float32) prediction = tf.square(data) + 1 actual_data = np.random.normal(size=[100]) tf.Session().run(prediction, feed_dict={data: actual_data}) 占位符操作符返回一个张量,他的值在会话(session)中通过人工指定的feed_dict参数得到。 python 操作(tf.py_func) 还可以通过利用 python 操作投喂数据: def py_input_fn(): actual_data = np.random.normal(size=[100]) return actual_data data = tf.py_func(py_input_fn, [], (tf.float32)) python 操作允许你将一个常规的 python 函数转换成一个 TF 的操作。 利用 TF 的自带数据集 API 最值得推荐的方式就是通过 TF 自带的数据集 API 进行投喂数据,如: actual_data = np.random.normal(size=[100]) dataset = tf.contrib.data.Dataset.from_tensor_slices(actual_data) data = dataset.make_one_shot_iterator().get_next() 如果你需要从文件中读入数据,你可能需要将文件转化为TFrecord格式,这将会使得整个过程更加有效 dataset = tf.contrib.data.Dataset.TFRecordDataset(path_to_data) 查看官方文档,了解如何将你的数据集转化为TFrecord格式。 dataset = ... dataset = dataset.cache() if mode == tf.estimator.ModeKeys.TRAIN: dataset = dataset.repeat() dataset = dataset.shuffle(batch_size * 5) dataset = dataset.map(parse, num_threads=8) dataset = dataset.batch(batch_size) 在读入了数据之后,我们使用Dataset.cache()方法,将其缓存到内存中,以求更高的效率。在训练模式中,我们不断地重复数据集,这使得我们可以多次处理整个数据集。我们也需要打乱数据集得到批量,这个批量将会有不同的样本分布。下一步,我们使用Dataset.map()方法,对原始数据进行预处理,将数据转换成一个模型可以识别,利用的格式。然后,我们就通过Dataset.batch(),创造样本的批量了。 六、利用运算符重载 和 Numpy 一样,TensorFlow 重载了很多 python 中的运算符,使得构建计算图更加地简单,并且使得代码具有可读性。 切片操作是重载的诸多运算符中的一个,它可以使得索引张量变得很容易: z = x[begin:end] # z = tf.slice(x, [begin], [end-begin]) 但是在使用它的过程中,你还是需要非常地小心。切片操作非常低效,因此最好避免使用,特别是在切片的数量很大的时候。为了更好地理解这个操作符有多么地低效,我们先观察一个例子。我们想要人工实现一个对矩阵的行进行reduce操作的代码: import TensorFlow as tf import time x = tf.random_uniform([500, 10]) z = tf.zeros([10]) for i in range(500): z += x[i] sess = tf.Session() start = time.time() sess.run(z) print("Took %f seconds." % (time.time() - start)) 在笔者的 MacBook Pro 上,这个代码花费了 2.67 秒!那么耗时的原因是我们调用了切片操作 500 次,这个运行起来超级慢的!一个更好的选择是使用tf.unstack()操作去将一个矩阵切成一个向量的列表,而这只需要一次就行! z = tf.zeros([10]) for x_i in tf.unstack(x): z += x_i 这个操作花费了 0.18 秒,当然,最正确的方式去实现这个需求是使用tf.reduce_sum()操作: z = tf.reduce_sum(x, axis=0) 这个仅仅使用了 0.008 秒,是原始实现的 300 倍! TensorFlow 除了切片操作,也重载了一系列的数学逻辑运算,如: z = -x # z = tf.negative(x) z = x + y # z = tf.add(x, y) z = x - y # z = tf.subtract(x, y) z = x * y # z = tf.mul(x, y) z = x / y # z = tf.div(x, y) z = x // y # z = tf.floordiv(x, y) z = x % y # z = tf.mod(x, y) z = x ** y # z = tf.pow(x, y) z = x @ y # z = tf.matmul(x, y) z = x > y # z = tf.greater(x, y) z = x >= y # z = tf.greater_equal(x, y) z = x < y # z = tf.less(x, y) z = x <= y # z = tf.less_equal(x, y) z = abs(x) # z = tf.abs(x) z = x & y # z = tf.logical_and(x, y) z = x | y # z = tf.logical_or(x, y) z = x ^ y # z = tf.logical_xor(x, y) z = ~x # z = tf.logical_not(x) 你也可以使用这些操作符的增广版本,如 x += y和x **=2同样是合法的。 注意到 python 不允许重载and,or和not等关键字。 TensorFlow 也不允许把张量当成boolean类型使用,因为这个很容易出错: x = tf.constant(1.) if x: # 这个将会抛出TypeError错误 ... 如果你想要检查这个张量的值的话,你也可以使用tf.cond(x,...),或者使用if x is None去检查这个变量的值。 有些操作是不支持的,比如说等于判断==和不等于判断!=运算符,这些在 numpy 中得到了重载,但在 TF 中没有重载。如果需要使用,请使用这些功能的函数版本tf.equal()和tf.not_equal()。 七、理解执行顺序和控制依赖 我们知道,TensorFlow 是属于符号式编程的,它不会直接运行定义了的操作,而是在计算图中创造一个相关的节点,这个节点可以用Session.run()进行执行。这个使得 TF 可以在优化过程中决定优化的顺序,并且在运算中剔除一些不需要使用的节点,而这一切都发生在运行中。如果你只是在计算图中使用tf.Tensors,你就不需要担心依赖问题,但是你更可能会使用tf.Variable(),这个操作使得问题变得更加困难。笔者的建议是如果张量不能满足这个工作需求,那么仅仅使用Variables就足够了。这个可能不够直观,我们不妨先观察一个例子: import TensorFlow as tf a = tf.constant(1) b = tf.constant(2) a = a + b tf.Session().run(a) 计算a将会返回 3,就像期望中的一样。注意到我们现在有 3 个张量,两个常数张量和一个储存加法结果的张量。注意到我们不能重写一个张量的值,如果我们想要改变张量的值,我们就必须要创建一个新的张量,就像我们刚才做的那样。 小提示:如果你没有显式地定义一个新的计算图,TF 将会自动地为你构建一个默认的计算图。你可以使用tf.get_default_graph()去获得一个计算图的句柄,然后,你就可以查看这个计算图了。比如,可以打印属于这个计算图的所有张量之类的的操作都是可以的。如: print(tf.contrib.graph_editor.get_tensors(tf.get_default_graph())) 不像张量,变量可以更新,所以让我们用变量去实现我们刚才的需求: a = tf.Variable(1) b = tf.constant(2) assign = tf.assign(a, a + b) sess = tf.Session() sess.run(tf.global_variables_initializer()) print(sess.run(assign)) 同样,我们得到了 3,正如预期一样。注意到tf.assign()返回的代表这个赋值操作的张量。目前为止,所有事情都显得很棒,但是让我们观察一个稍微有点复杂的例子吧: a = tf.Variable(1) b = tf.constant(2) c = a + b assign = tf.assign(a, 5) sess = tf.Session() for i in range(10): sess.run(tf.global_variables_initializer()) print(sess.run([assign, c])) 注意到,张量c并没有一个确定性的值。这个值可能是 3 或者 7,取决于加法和赋值操作谁先运行。 你应该也注意到了,你在代码中定义操作的顺序是不会影响到在 TF 运行时的执行顺序的,唯一会影响到执行顺序的是控制依赖。控制依赖对于张量来说是直接的。每一次你在操作中使用一个张量时,操作将会定义一个对于这个张量来说的隐式的依赖。但是如果你同时也使用了变量,事情就变得更糟糕了,因为变量可以取很多值。 当处理这些变量时,你可能需要显式地去通过使用tf.control_dependencies()去控制依赖,如: a = tf.Variable(1) b = tf.constant(2) c = a + b with tf.control_dependencies([c]): assign = tf.assign(a, 5) sess = tf.Session() for i in range(10): sess.run(tf.global_variables_initializer()) print(sess.run([assign, c])) 这会确保赋值操作在加法操作之后被调用。 八、控制流操作:条件和循环 在构建复杂模型(如循环神经网络)时,你可能需要通过条件和循环来控制操作流。 在本节中,我们将介绍一些常用的控制流操作。 假设你要根据谓词决定,是否相乘或相加两个给定的张量。这可以简单地用tf.cond实现,它充当 python “if” 函数: a = tf.constant(1) b = tf.constant(2) p = tf.constant(True) x = tf.cond(p, lambda: a + b, lambda: a * b) print(tf.Session().run(x)) 由于在这种情况下谓词为True,因此输出将是加法的结果,即 3。 大多数情况下,使用 TensorFlow 时,你使用的是大型张量,并希望批量执行操作。 相关的条件操作是tf.where,类似于tf.cond,它接受谓词,但是基于批量中的条件来选择输出。 a = tf.constant([1, 1]) b = tf.constant([2, 2]) p = tf.constant([True, False]) x = tf.where(p, a + b, a * b) print(tf.Session().run(x)) 这将返回[3,2]。 另一种广泛使用的控制流操作是tf.while_loop。 它允许在 TensorFlow 中构建动态循环,这些循环操作可变长度的序列。 让我们看看如何使用tf.while_loops生成斐波那契序列: n = tf.constant(5) def cond(i, a, b): return i < n def body(i, a, b): return i + 1, b, a + b i, a, b = tf.while_loop(cond, body, (2, 1, 1)) print(tf.Session().run(b)) 这将打印 5。除了循环变量的初始值之外,tf.while_loops还接受条件函数和循环体函数。 然后通过多次调用循环体函数来更新这些循环变量,直到条件返回False。 现在想象我们想要保留整个斐波那契序列。 我们可以更新我们的循环体来记录当前值的历史: n = tf.constant(5) def cond(i, a, b, c): return i < n def body(i, a, b, c): return i + 1, b, a + b, tf.concat([c, [a + b]], 0) i, a, b, c = tf.while_loop(cond, body, (2, 1, 1, tf.constant([1, 1]))) print(tf.Session().run(c)) 现在,如果你尝试运行它,TensorFlow 会报错,第四个循环变量的形状改变了。 因此,你必须明确指出它是有意的: i, a, b, c = tf.while_loop( cond, body, (2, 1, 1, tf.constant([1, 1])), shape_invariants=(tf.TensorShape([]), tf.TensorShape([]), tf.TensorShape([]), tf.TensorShape([None]))) 这不仅变得丑陋,而且效率也有些低下。 请注意,我们正在构建许多我们不使用的中间张量。 TensorFlow 为这种不断增长的阵列提供了更好的解决方案。 看看tf.TensorArray。 让我们这次用张量数组做同样的事情: n = tf.constant(5) c = tf.TensorArray(tf.int32, n) c = c.write(0, 1) c = c.write(1, 1) def cond(i, a, b, c): return i < n def body(i, a, b, c): c = c.write(i, a + b) return i + 1, b, a + b, c i, a, b, c = tf.while_loop(cond, body, (2, 1, 1, c)) c = c.stack() print(tf.Session().run(c)) TensorFlow while 循环和张量数组是构建复杂的循环神经网络的基本工具。 作为练习,尝试使用tf.while_loops实现集束搜索(beam search)。 使用张量数组可以使效率更高吗? 九、使用 Python 操作设计核心和高级可视化 TensorFlow 中的操作核心完全用 C++ 编写,用于提高效率。 但是用 C++ 编写 TensorFlow 核心可能会非常痛苦。因此,在花费数小时实现核心之前,你可能希望快速创建原型,但效率低下。使用tf.py_func(),你可以将任何一段 python 代码转换为 TensorFlow 操作。 例如,这就是如何在 TensorFlow 中将一个简单的 ReLU 非线性核心实现为 python 操作: import numpy as np import tensorflow as tf import uuid def relu(inputs): # Define the op in python def _relu(x): return np.maximum(x, 0.) # Define the op's gradient in python def _relu_grad(x): return np.float32(x > 0) # An adapter that defines a gradient op compatible with TensorFlow def _relu_grad_op(op, grad): x = op.inputs[0] x_grad = grad * tf.py_func(_relu_grad, [x], tf.float32) return x_grad # Register the gradient with a unique id grad_name = "MyReluGrad_" + str(uuid.uuid4()) tf.RegisterGradient(grad_name)(_relu_grad_op) # Override the gradient of the custom op g = tf.get_default_graph() with g.gradient_override_map({"PyFunc": grad_name}): output = tf.py_func(_relu, [inputs], tf.float32) return output 要验证梯度是否正确,可以使用 TensorFlow 的梯度检查器: x = tf.random_normal([10]) y = relu(x * x) with tf.Session(): diff = tf.test.compute_gradient_error(x, [10], y, [10]) print(diff) compute_gradient_error()以数值方式计算梯度,并返回提供的梯度的差。 我们想要的是非常低的差。 请注意,此实现效率非常低,仅适用于原型设计,因为 python 代码不可并行化,不能在 GPU 上运行。 一旦验证了你的想法,你肯定会想把它写成 C++ 核心。 在实践中,我们通常使用 python 操作在 Tensorboard 上进行可视化。 考虑你正在构建图像分类模型,并希望在训练期间可视化模型的预测情况。TensorFlow 允许使用tf.summary.image()函数可视化图像: image = tf.placeholder(tf.float32) tf.summary.image("image", image) 但这只能显示输入图像。 为了显示预测,你必须找到一种向图像添加注释的方法,这对现有操作几乎是不可能的。 更简单的方法是在 python 中绘制,并将其包装在 python 操作中: import io import matplotlib.pyplot as plt import numpy as np import PIL import tensorflow as tf def visualize_labeled_images(images, labels, max_outputs=3, name="image"): def _visualize_image(image, label): # Do the actual drawing in python fig = plt.figure(figsize=(3, 3), dpi=80) ax = fig.add_subplot(111) ax.imshow(image[::-1,...]) ax.text(0, 0, str(label), horizontalalignment="left", verticalalignment="top") fig.canvas.draw() # Write the plot as a memory file. buf = io.BytesIO() data = fig.savefig(buf, format="png") buf.seek(0) # Read the image and convert to numpy array img = PIL.Image.open(buf) return np.array(img.getdata()).reshape(img.size[0], img.size[1], -1) def _visualize_images(images, labels): # Only display the given number of examples in the batch outputs = [] for i in range(max_outputs): output = _visualize_image(images[i], labels[i]) outputs.append(output) return np.array(outputs, dtype=np.uint8) # Run the python op. figs = tf.py_func(_visualize_images, [images, labels], tf.uint8) return tf.summary.image(name, figs) 请注意,由于摘要通常仅仅偶尔(不是每步)求值一次,因此可以在实践中使用此实现而不必担心效率。 十、多 GPU 和数据并行 如果你使用 C++ 等语言为单个 CPU 核心编写软件,并使其在多个 GPU 上并行运行,则需要从头开始重写软件。 但TensorFlow并非如此。 由于其象征性,TensorFlow 可以隐藏所有这些复杂性,使得无需在多个 CPU 和 GPU 上扩展程序。 让我们以在 CPU 上相加两个向量的简单示例开始: import tensorflow as tf with tf.device(tf.DeviceSpec(device_type="CPU", device_index=0)): a = tf.random_uniform([1000, 100]) b = tf.random_uniform([1000, 100]) c = a + b tf.Session().run(c) GPU 上可以做相同的事情: with tf.device(tf.DeviceSpec(device_type="GPU", device_index=0)): a = tf.random_uniform([1000, 100]) b = tf.random_uniform([1000, 100]) c = a + b 但是,如果我们有两个 GPU 并且想要同时使用它们呢? 为此,我们可以拆分数据并使用单独的 GPU 来处理每一半: split_a = tf.split(a, 2) split_b = tf.split(b, 2) split_c = [] for i in range(2): with tf.device(tf.DeviceSpec(device_type="GPU", device_index=i)): split_c.append(split_a[i] + split_b[i]) c = tf.concat(split_c, axis=0) 让我们以更一般的形式重写它,以便我们可以用任何其他操作替换加法: def make_parallel(fn, num_gpus, **kwargs): in_splits = {} for k, v in kwargs.items(): in_splits[k] = tf.split(v, num_gpus) out_split = [] for i in range(num_gpus): with tf.device(tf.DeviceSpec(device_type="GPU", device_index=i)): with tf.variable_scope(tf.get_variable_scope(), reuse=i > 0): out_split.append(fn(**{k : v[i] for k, v in in_splits.items()})) return tf.concat(out_split, axis=0) def model(a, b): return a + b c = make_parallel(model, 2, a=a, b=b) 你可以使用任何接受一组张量作为输入的函数替换模型,并在输入和输出都是批量的条件下,返回张量作为结果。请注意,我们还添加了一个变量作用域并将复用设置为True。这确保我们使用相同的变量来处理两个分割。在我们的下一个例子中,这将变得很方便。 让我们看一个稍微更实际的例子。我们想在多个 GPU 上训练神经网络。在训练期间,我们不仅需要计算正向传播,还需要计算反向传播(梯度)。但是我们如何并行计算梯度呢? 事实证明这很简单。 回想一下第一节,我们想要将二次多项式拟合到一组样本。我们重新组织了一些代码,以便在模型函数中进行大量操作: import numpy as np import tensorflow as tf def model(x, y): w = tf.get_variable("w", shape=[3, 1]) f = tf.stack([tf.square(x), x, tf.ones_like(x)], 1) yhat = tf.squeeze(tf.matmul(f, w), 1) loss = tf.square(yhat - y) return loss x = tf.placeholder(tf.float32) y = tf.placeholder(tf.float32) loss = model(x, y) train_op = tf.train.AdamOptimizer(0.1).minimize( tf.reduce_mean(loss)) def generate_data(): x_val = np.random.uniform(-10.0, 10.0, size=100) y_val = 5 * np.square(x_val) + 3 return x_val, y_val sess = tf.Session() sess.run(tf.global_variables_initializer()) for _ in range(1000): x_val, y_val = generate_data() _, loss_val = sess.run([train_op, loss], {x: x_val, y: y_val}) _, loss_val = sess.run([train_op, loss], {x: x_val, y: y_val}) print(sess.run(tf.contrib.framework.get_variables_by_name("w"))) 现在让我们使用我们刚刚编写的make_parallel来并行化它。我们只需要从上面的代码中更改两行代码: loss = make_parallel(model, 2, x=x, y=y) train_op = tf.train.AdamOptimizer(0.1).minimize( tf.reduce_mean(loss), colocate_gradients_with_ops=True) 为了更改为梯度的并行化反向传播,我们需要的唯一的东西是,将colocate_gradients_with_ops标志设置为True。这可确保梯度操作和原始操作在相同的设备上运行。 十一、调试 TensorFlow 模型 与常规 python 代码相比,TensorFlow 的符号性质使调试 TensorFlow 代码变得相对困难。 在这里,我们介绍 TensorFlow 的一些附带工具,使调试更容易。 使用 TensorFlow 时可能出现的最常见错误,可能是将形状错误的张量传递给操作。 许多 TensorFlow 操作可以操作不同维度和形状的张量。 这在使用 API 时很方便,但在出现问题时可能会导致额外的麻烦。 例如,考虑tf.matmul操作,它可以相乘两个矩阵: a = tf.random_uniform([2, 3]) b = tf.random_uniform([3, 4]) c = tf.matmul(a, b) # c is a tensor of shape [2, 4] 但同样的函数也可以进行批量矩阵乘法: a = tf.random_uniform([10, 2, 3]) b = tf.random_uniform([10, 3, 4]) tf.matmul(a, b) # c is a tensor of shape [10, 2, 4] 我们之前在广播部分谈到的另一个例子,是支持广播的加法操作: a = tf.constant([[1.], [2.]]) b = tf.constant([1., 2.]) c = a + b # c is a tensor of shape [2, 2] 使用tf.assert*操作验证你的张量 减少不必要行为的可能性的一种方法,是使用tf.assert*操作,明确验证中间张量的维度或形状。 a = tf.constant([[1.], [2.]]) b = tf.constant([1., 2.]) check_a = tf.assert_rank(a, 1) # This will raise an InvalidArgumentError exception check_b = tf.assert_rank(b, 1) with tf.control_dependencies([check_a, check_b]): c = a + b # c is a tensor of shape [2, 2] 请记住,断言节点像其他操作一样,是图形的一部分,如果不进行求值,则会在Session.run()期间进行修剪。 因此,请确保为断言操作创建显式依赖,来强制 TensorFlow 执行它们。 你还可以使用断言,在运行时验证张量的值: check_pos = tf.assert_positive(a) 断言操作的完整列表请见官方文档。 使用tf.Print记录张量的值 用于调试的另一个有用的内置函数是tf.Print,它将给定的张量记录到标准错误: input_copy = tf.Print(input, tensors_to_print_list) 请注意,tf.Print返回第一个参数的副本作为输出。强制tf.Print运行的一种方法,是将其输出传递给另一个执行的操作。 例如,如果我们想在添加张量a和b之前,打印它们的值,我们可以这样做: a = ... b = ... a = tf.Print(a, [a, b]) c = a + b 或者,我们可以手动定义控制依赖。 使用tf.compute_gradient_error检查梯度 TensorFlow 中并非所有操作都带有梯度,并且很容易在无意中构建 TensorFlow 无法计算梯度的图形。 我们来看一个例子: import tensorflow as tf def non_differentiable_entropy(logits): probs = tf.nn.softmax(logits) return tf.nn.softmax_cross_entropy_with_logits(labels=probs, logits=logits) w = tf.get_variable("w", shape=[5]) y = -non_differentiable_entropy(w) opt = tf.train.AdamOptimizer() train_op = opt.minimize(y) sess = tf.Session() sess.run(tf.global_variables_initializer()) for i in range(10000): sess.run(train_op) print(sess.run(tf.nn.softmax(w))) 我们使用tf.nn.softmax_cross_entropy_with_logits来定义类别分布的熵。然后我们使用 Adam 优化器来找到具有最大熵的权重。如果你通过了信息论课程,你就会知道均匀分布的熵最大。 所以你期望结果是[0.2,0.2,0.2,0.2,0.2]。 但如果你运行这个,你可能会得到意想不到的结果: [ 0.34081486 0.24287023 0.23465775 0.08935683 0.09230034] 事实证明,tf.nn.softmax_cross_entropy_with_logits的梯度对标签是未定义的! 但如果我们不知道,我们怎么能发现它? 幸运的是,TensorFlow 带有一个数值微分器,可用于查找符号梯度误差。 让我们看看我们如何使用它: with tf.Session(): diff = tf.test.compute_gradient_error(w, [5], y, []) print(diff) 如果你运行它,你会发现数值和符号梯度之间的差异非常大(在我的尝试中为0.06 - 0.1)。 现在让我们使用熵的可导版本,来修复我们的函数并再次检查: import tensorflow as tf import numpy as np def entropy(logits, dim=-1): probs = tf.nn.softmax(logits, dim) nplogp = probs * (tf.reduce_logsumexp(logits, dim, keep_dims=True) - logits) return tf.reduce_sum(nplogp, dim) w = tf.get_variable("w", shape=[5]) y = -entropy(w) print(w.get_shape()) print(y.get_shape()) with tf.Session() as sess: diff = tf.test.compute_gradient_error(w, [5], y, []) print(diff) 差应该约为 0.0001,看起来好多了。 现在,如果再次使用正确的版本运行优化器,你可以看到最终权重为: [ 0.2 0.2 0.2 0.2 0.2] 这正是我们想要的。 TensorFlow 摘要和 tfdbg(TensorFlow 调试器)是可用于调试的其他工具。 请参阅官方文档来了解更多信息。 十二、TensorFlow 中的数值稳定性 当使用任何数值计算库(如 NumPy 或 TensorFlow)时,重要的是要注意,编写数学上正确的代码并不一定能产生正确的结果。 你还需要确保计算稳定。 让我们从一个简单的例子开始吧。 从小学我们知道x * y / y等于x的任何非零值。 但是,让我们看看在实践中是否总是如此: import numpy as np x = np.float32(1) y = np.float32(1e-50) # y would be stored as zero z = x * y / y print(z) # prints nan 结果不正确的原因是y对于float32类型来说太小了。当y太大时会出现类似的问题: y = np.float32(1e39) # y would be stored as inf z = x * y / y print(z) # prints 0 float32类型可以表示的最小正值是1.4013e-45,低于该值的任何值都将存储为零。 此外,任何超过3.40282e+38的数字都将存储为inf。 print(np.nextafter(np.float32(0), np.float32(1))) # prints 1.4013e-45 print(np.finfo(np.float32).max) # print 3.40282e+38 为确保计算稳定,你需要避免使用绝对值非常小或大的值。这可能听起来非常明显,但这些问题可能变得非常难以调试,尤其是在 TensorFlow 中进行梯度下降时。这是因为你不仅需要确保正向传播中的所有值都在数据类型的有效范围内,而且还需要确保反向传播也相同(在梯度计算期间)。 让我们看一个真实的例子。 我们想要在logits向量上计算 softmax。 一个朴素的实现看起来像这样: import tensorflow as tf def unstable_softmax(logits): exp = tf.exp(logits) return exp / tf.reduce_sum(exp) tf.Session().run(unstable_softmax([1000., 0.])) # prints [ nan, 0.] 请注意,计算logits中相对较小数字的指数会产生浮点范围之外的巨大结果。 我们的初始 softmax 实现的最大有效logit是ln(3.40282e + 38)= 88.7,除此之外的任何东西都会产生nan结果。 但是我们怎样才能让它更稳定呢? 解决方案相当简单。 很容易看出exp(x - c)/Σexp(x - c)= exp(x)/Σexp(x)。 因此,我们可以从logits中减去任何常量,结果将保持不变。 我们选择此常量作为logits的最大值。 这样,指数函数的定义域将被限制为[-inf,0],因此其值域将是[0.0,1.0],这是预期的: import tensorflow as tf def softmax(logits): exp = tf.exp(logits - tf.reduce_max(logits)) return exp / tf.reduce_sum(exp) tf.Session().run(softmax([1000., 0.])) # prints [ 1., 0.] 让我们来看一个更复杂的案例。 考虑一下我们的分类问题。 我们使用 softmax 函数从我们的logits中产生概率。 然后,我们将损失函数定义为,我们的预测和标签之间的交叉熵。回想一下,分类分布的交叉熵可以简单地定义为xe(p, q) = -∑ p_i log(q_i)。 所以交叉熵的朴素实现看起来像这样: def unstable_softmax_cross_entropy(labels, logits): logits = tf.log(softmax(logits)) return -tf.reduce_sum(labels * logits) labels = tf.constant([0.5, 0.5]) logits = tf.constant([1000., 0.]) xe = unstable_softmax_cross_entropy(labels, logits) print(tf.Session().run(xe)) # prints inf 注意,在此实现中,当 softmax 输出接近零时,log的输出接近无穷大,这导致我们的计算不稳定。 我们可以通过扩展 softmax 并进行一些简化来重写它: def softmax_cross_entropy(labels, logits): scaled_logits = logits - tf.reduce_max(logits) normalized_logits = scaled_logits - tf.reduce_logsumexp(scaled_logits) return -tf.reduce_sum(labels * normalized_logits) labels = tf.constant([0.5, 0.5]) logits = tf.constant([1000., 0.]) xe = softmax_cross_entropy(labels, logits) print(tf.Session().run(xe)) # prints 500.0 我们还可以验证梯度是否也计算正确: g = tf.gradients(xe, logits) print(tf.Session().run(g)) # prints [0.5, -0.5] 是正确的。 让我再次提醒一下,在进行梯度下降时必须格外小心,来确保函数范围以及每层的梯度都在有效范围内。 指数和对数函数在朴素使用时尤其成问题,因为它们可以将小数字映射到大数字,反之亦然。 十三、使用学习 API 构建神经网络训练框架 为简单起见,在这里的大多数示例中,我们手动创建会话,我们不关心保存和加载检查点,但这不是我们通常在实践中做的事情。你最有可能希望使用学习 API 来处理会话管理和日志记录。 我们提供了一个简单但实用的框架,用于使用 TensorFlow 训练神经网络。在本节中,我们将解释此框架的工作原理。 在试验神经网络模型时,你通常需要进行训练/测试分割。你希望在训练集上训练你的模型,之后在测试集上评估它并计算一些指标。你还需要将模型参数存储为检查点,理想情况下,你希望能够停止和恢复训练。TensorFlow 的学习 API 旨在使这项工作更容易,让我们专注于开发实际模型。 使用tf.learn API 的最基本方法是直接使用tf.Estimator对象。 你需要定义模型函数,它定义了损失函数,训练操作,一个或一组预测,以及一组用于求值的可选的指标操作: import tensorflow as tf def model_fn(features, labels, mode, params): predictions = ... loss = ... train_op = ... metric_ops = ... return tf.estimator.EstimatorSpec( mode=mode, predictions=predictions, loss=loss, train_op=train_op, eval_metric_ops=metric_ops) params = ... run_config = tf.contrib.learn.RunConfig(model_dir=FLAGS.output_dir) estimator = tf.estimator.Estimator( model_fn=model_fn, config=run_config, params=params) 要训练模型,你只需调用Estimator.train(0函数,同时提供读取数据的输入函数。 def input_fn(): features = ... labels = ... return features, labels estimator.train(input_fn=input_fn, max_steps=...) 要评估模型,只需调用Estimator.evaluate(): estimator.evaluate(input_fn=input_fn) 对于简单的情况,Estimator对象可能已经足够好了,但 TensorFlow 提供了一个名为Experiment的更高级别的对象,它提供了一些额外的有用功能。创建实验对象非常简单: experiment = tf.contrib.learn.Experiment( estimator=estimator, train_input_fn=train_input_fn, eval_input_fn=eval_input_fn, eval_metrics=eval_metrics) 现在我们可以调用train_and_evaluate函数来计算训练时的指标。 experiment.train_and_evaluate() 更高级别的运行实验的方法,是使用learn_runner.run()函数。以下是我们的主函数在提供的框架中的样子: import tensorflow as tf tf.flags.DEFINE_string("output_dir", "", "Optional output dir.") tf.flags.DEFINE_string("schedule", "train_and_evaluate", "Schedule.") tf.flags.DEFINE_string("hparams", "", "Hyper parameters.") FLAGS = tf.flags.FLAGS def experiment_fn(run_config, hparams): estimator = tf.estimator.Estimator( model_fn=make_model_fn(), config=run_config, params=hparams) return tf.contrib.learn.Experiment( estimator=estimator, train_input_fn=make_input_fn(tf.estimator.ModeKeys.TRAIN, hparams), eval_input_fn=make_input_fn(tf.estimator.ModeKeys.EVAL, hparams), eval_metrics=eval_metrics_fn(hparams)) def main(unused_argv): run_config = tf.contrib.learn.RunConfig(model_dir=FLAGS.output_dir) hparams = tf.contrib.training.HParams() hparams.parse(FLAGS.hparams) estimator = tf.contrib.learn.learn_runner.run( experiment_fn=experiment_fn, run_config=run_config, schedule=FLAGS.schedule, hparams=hparams) if __name__ == "__main__": tf.app.run() schedule标志决定调用Experiment对象的哪个成员函数。 因此,如果你将schedule设置为train_and_evaluate,则会调用experiment.train_and_evaluate()。 输入函数可以返回两个张量(或张量的字典),提供要传递给模型的特征和标签。 def input_fn(): features = ... labels = ... return features, labels 对于如何使用数据集 API 读取数据的示例,请参阅mnist.py。要了解在 TensorFlow 中阅读数据的各种方法,请参阅这里。 该框架还附带了一个简单的卷积网络分类器,在cnn_classifier.py中,其中包含一个示例模型。 就是这样! 这就是开始使用 TensorFlow 学习 API 所需的全部内容。我建议你查看框架源代码并查看官方 python API 来了解学习 API 的更多信息。 十四、TensorFlow 秘籍 本节包括在 TensorFlow 中实现的一组常用操作。 集束搜索 import tensorflow as tf def get_shape(tensor): """Returns static shape if available and dynamic shape otherwise.""" static_shape = tensor.shape.as_list() dynamic_shape = tf.unstack(tf.shape(tensor)) dims = [s[1] if s[0] is None else s[0] for s in zip(static_shape, dynamic_shape)] return dims def log_prob_from_logits(logits, axis=-1): """Normalize the log-probabilities so that probabilities sum to one.""" return logits - tf.reduce_logsumexp(logits, axis=axis, keep_dims=True) def batch_gather(tensor, indices): """Gather in batch from a tensor of arbitrary size. In pseudocode this module will produce the following: output[i] = tf.gather(tensor[i], indices[i]) Args: tensor: Tensor of arbitrary size. indices: Vector of indices. Returns: output: A tensor of gathered values. """ shape = get_shape(tensor) flat_first = tf.reshape(tensor, [shape[0] * shape[1]] + shape[2:]) indices = tf.convert_to_tensor(indices) offset_shape = [shape[0]] + [1] * (indices.shape.ndims - 1) offset = tf.reshape(tf.range(shape[0]) * shape[1], offset_shape) output = tf.gather(flat_first, indices + offset) return output def rnn_beam_search(update_fn, initial_state, sequence_length, beam_width, begin_token_id, end_token_id, name="rnn"): """Beam-search decoder for recurrent models. Args: update_fn: Function to compute the next state and logits given the current state and ids. initial_state: Recurrent model states. sequence_length: Length of the generated sequence. beam_width: Beam width. begin_token_id: Begin token id. end_token_id: End token id. name: Scope of the variables. Returns: ids: Output indices. logprobs: Output log probabilities probabilities. """ batch_size = initial_state.shape.as_list()[0] state = tf.tile(tf.expand_dims(initial_state, axis=1), [1, beam_width, 1]) sel_sum_logprobs = tf.log([[1.] + [0.] * (beam_width - 1)]) ids = tf.tile([[begin_token_id]], [batch_size, beam_width]) sel_ids = tf.expand_dims(ids, axis=2) mask = tf.ones([batch_size, beam_width], dtype=tf.float32) for i in range(sequence_length): with tf.variable_scope(name, reuse=True if i > 0 else None): state, logits = update_fn(state, ids) logits = log_prob_from_logits(logits) sum_logprobs = ( tf.expand_dims(sel_sum_logprobs, axis=2) + (logits * tf.expand_dims(mask, axis=2))) num_classes = logits.shape.as_list()[-1] sel_sum_logprobs, indices = tf.nn.top_k( tf.reshape(sum_logprobs, [batch_size, num_classes * beam_width]), k=beam_width) ids = indices % num_classes beam_ids = indices // num_classes state = batch_gather(state, beam_ids) sel_ids = tf.concat([batch_gather(sel_ids, beam_ids), tf.expand_dims(ids, axis=2)], axis=2) mask = (batch_gather(mask, beam_ids) * tf.to_float(tf.not_equal(ids, end_token_id))) return sel_ids, sel_sum_logprobs 合并 import tensorflow as tf def merge(tensors, units, activation=tf.nn.relu, name=None, **kwargs): """Merge features with broadcasting support. This operation concatenates multiple features of varying length and applies non-linear transformation to the outcome. Example: a = tf.zeros([m, 1, d1]) b = tf.zeros([1, n, d2]) c = merge([a, b], d3) # shape of c would be [m, n, d3]. Args: tensors: A list of tensor with the same rank. units: Number of units in the projection function. """ with tf.variable_scope(name, default_name="merge"): # Apply linear projection to input tensors. projs = [] for i, tensor in enumerate(tensors): proj = tf.layers.dense( tensor, units, activation=None, name="proj_%d" % i, **kwargs) projs.append(proj) # Compute sum of tensors. result = projs.pop() for proj in projs: result = result + proj # Apply nonlinearity. if activation: result = activation(result) return result 熵 import tensorflow as tf def softmax(logits, dims=-1): """Compute softmax over specified dimensions.""" exp = tf.exp(logits - tf.reduce_max(logits, dims, keep_dims=True)) return exp / tf.reduce_sum(exp, dims, keep_dims=True) def entropy(logits, dims=-1): """Compute entropy over specified dimensions.""" probs = softmax(logits, dims) nplogp = probs * (tf.reduce_logsumexp(logits, dims, keep_dims=True) - logits) return tf.reduce_sum(nplogp, dims) KL 散度 def gaussian_kl(q, p=(0., 0.)): """Computes KL divergence between two isotropic Gaussian distributions. To ensure numerical stability, this op uses mu, log(sigma^2) to represent the distribution. If q is not provided, it's assumed to be unit Gaussian. Args: q: A tuple (mu, log(sigma^2)) representing a multi-variatie Gaussian. p: A tuple (mu, log(sigma^2)) representing a multi-variatie Gaussian. Returns: A tensor representing KL(q, p). """ mu1, log_sigma1_sq = q mu2, log_sigma2_sq = p return tf.reduce_sum( 0.5 * (log_sigma2_sq - log_sigma1_sq + tf.exp(log_sigma1_sq - log_sigma2_sq) + tf.square(mu1 - mu2) / tf.exp(log_sigma2_sq) - 1), axis=-1) 并行化 def make_parallel(fn, num_gpus, **kwargs): """Parallelize given model on multiple gpu devices. Args: fn: Arbitrary function that takes a set of input tensors and outputs a single tensor. First dimension of inputs and output tensor are assumed to be batch dimension. num_gpus: Number of GPU devices. **kwargs: Keyword arguments to be passed to the model. Returns: A tensor corresponding to the model output. """ in_splits = {} for k, v in kwargs.items(): in_splits[k] = tf.split(v, num_gpus) out_split = [] for i in range(num_gpus): with tf.device(tf.DeviceSpec(device_type="GPU", device_index=i)): with tf.variable_scope(tf.get_variable_scope(), reuse=i > 0): out_split.append(fn(**{k : v[i] for k, v in in_splits.items()})) return tf.concat(out_split, axis=0)
三、随机变量 原文:prob140/textbook/notebooks/ch03 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 许多数据科学涉及数值变量,它的观察值取决于几率。其他值提供的变量的预测值,随机样本中观察到的不同类别个体的数量,以及自举样本的中值,仅仅是几个例子。 你在 Data8 中看到了更多例子。 在概率论中,随机变量是在结果空间上定义的数值函数。 也就是说,函数的定义域是Ω,它的值域是实数行。 随机变量通常用靠后的字母表示,如X和Y。 结果空间上的函数 随机抽样可以看做重复的随机试验,因此许多结果空间由序列组成。代表硬币投掷两次的结果空间是: 如果你投掷 10 次,结果空间将包含 10 个元素的 210 个序列,其中每个元素是H或T。手动列出结果比较痛苦,但计算机善于为我们避免这种痛苦。 乘积空间 两个集合A和B的乘积是所有偶对(a, b)的集合,其中a ∈ A和b ∈ B。 这个概念正是我们需要的,用于描述代表多个试验的空间。 例如,表示一枚硬币投掷结果的空间是 。 与其本身的乘积是偶对的集合(H, H), (H, T), (T, H), (T, T),你可以认出这是硬币投掷的结果。 这个新空间和 的乘积是代表三次投掷的空间,以此类推。 Python 模块itertools包含构造乘积空间的函数product。 让我们导入它。 from itertools import product 要了解product是如何工作的,我们将从投掷硬币的结果开始。我们正在使用make_array创建一个数组,但你可以使用任何其他方式创建数组或列表。 one_toss = make_array('H', 'T') 为了使用product,我们必须指定基本空间和重复次数,然后将结果转换为列表。 two_tosses = list(product(one_toss, repeat=2)) two_tosses # [('H', 'H'), ('H', 'T'), ('T', 'H'), ('T', 'T')] 对于三次投掷,只需改变重复次数: three_tosses = list(product(one_toss, repeat=3)) three_tosses ''' [('H', 'H', 'H'), ('H', 'H', 'T'), ('H', 'T', 'H'), ('H', 'T', 'T'), ('T', 'H', 'H'), ('T', 'H', 'T'), ('T', 'T', 'H'), ('T', 'T', 'T')] ''' 概率空间是结果空间,带有所有结果的概率。 如果假设三次投掷的八次结果是等可能的,则概率均为 1/8: three_toss_probs = (1/8)*np.ones(8) 相应的概率空间: three_toss_space = Table().with_columns( 'omega', three_tosses, 'P(omega)', three_toss_probs ) three_toss_space omega P(omega) ['H' 'H' 'H'] 0.125 ['H' 'H' 'T'] 0.125 ['H' 'T' 'H'] 0.125 ['H' 'T' 'T'] 0.125 ['T' 'H' 'H'] 0.125 ['T' 'H' 'T'] 0.125 ['T' 'T' 'H'] 0.125 ['T' 'T' 'T'] 0.125 乘积空间增长得非常快。 如果你投掷 5 次,将会有近 8000 种可能的结果: 6**5 # 7776 但是我们有product,所以我们仍然可以列出所有乘积! 这是一个表示 5 次骰子投掷的概率空间。 die = np.arange(1, 7, 1) five_rolls = list(product(die, repeat=5)) # All possible results of 5 rolls five_roll_probs = (1/6**5)**np.ones(6**5) # Each result has chance 1/6**5 five_roll_space = Table().with_columns( 'omega', five_rolls, 'P(omega)', five_roll_probs ) five_roll_space omega P(omega) [1 1 1 1 1] 0.000128601 [1 1 1 1 2] 0.000128601 [1 1 1 1 3] 0.000128601 [1 1 1 1 4] 0.000128601 [1 1 1 1 5] 0.000128601 [1 1 1 1 6] 0.000128601 [1 1 1 2 1] 0.000128601 [1 1 1 2 2] 0.000128601 [1 1 1 2 3] 0.000128601 [1 1 1 2 4] 0.000128601 … (7766 rows omitted) 结果空间上的函数 假设你投掷一个骰子五次,并将你看到的点数加起来。如果这看起来不清楚,请耐心等待一会儿,你很快就会明白为什么它很有趣。 点数的总和是五个点数的结果空间Ω上的数值函数。 总和是一个随机变量。我们称它为S。然后,在形式上, S的范围是 5 到 30 的整数,因为每个骰子至少有一个点,最多六个点。 我们也可以使用相同的符号: 从计算的角度来看,Ω的元素位于five_roll_space的omega列中。让我们应用这个函数并创建一个更大的表格。 five_rolls_sum = Table().with_columns( 'omega', five_rolls, 'S(omega)', five_roll_space.apply(sum, 'omega'), 'P(omega)', five_roll_probs ) five_rolls_sum omega S(omega) P(omega) [1 1 1 1 1] 5 0.000128601 [1 1 1 1 2] 6 0.000128601 [1 1 1 1 3] 7 0.000128601 [1 1 1 1 4] 8 0.000128601 [1 1 1 1 5] 9 0.000128601 [1 1 1 1 6] 10 0.000128601 [1 1 1 2 1] 6 0.000128601 [1 1 1 2 2] 7 0.000128601 [1 1 1 2 3] 8 0.000128601 [1 1 1 2 4] 9 0.000128601 … (7766 rows omitted) 我们现在有五次投掷的所有可能的结果,以及它的总点数。你可以看到表格的第一行显示了尽可能少的点数,对应于所有投掷都显示 1 点。 第 7776 行显示了最大的: five_rolls_sum.take(7775) omega S(omega) P(omega) [6 6 6 6 6] 30 0.000128601 S的所有其他值都在这两个极端之间。 随机变量的函数 随机变量是Ω上的数值函数。 因此,通过复合,随机变量的数值函数也是随机变量。 例如, 是一个随机变量,计算如下: 所以 。 由S确定的事件 从表five_rolls_sum中,很难判断有多少行显示 6 或 10 或其他任何值。 为了更好地理解S的属性,我们必须组织five_rolls_sum中的信息。 对于S中的任何子集A,定义事件{S∈A}为: 在特殊情况下尝试这个定义。令A = {5,30}。 然后{S∈A},当且仅当所有点数都是 1 点或 6 点。 所以: 询问总和是否为某个特定值的几率是很自然的,例如 10。读取表格并不容易,但我们可以访问相应的行: five_rolls_sum.where('S(omega)', are.equal_to(10)) … (116 rows omitted) S(ω)=10的ω有 126 个值。由于所有的ω都相同,因此S的值为 10 的几率是 126/7776。 非正式情况下,我们通常会用符号表示,写成{S = 10}而不是{S∈{10}}。 分布 我们的空间是骰子的五次投掷的结果,而我们的随机变量S是五次投掷的点数总数。 five_rolls_sum omega S(omega) P(omega) [1 1 1 1 1] 5 0.000128601 [1 1 1 1 2] 6 0.000128601 [1 1 1 1 3] 7 0.000128601 [1 1 1 1 4] 8 0.000128601 [1 1 1 1 5] 9 0.000128601 [1 1 1 1 6] 10 0.000128601 [1 1 1 2 1] 6 0.000128601 [1 1 1 2 2] 7 0.000128601 [1 1 1 2 3] 8 0.000128601 [1 1 1 2 4] 9 0.000128601 … (7766 rows omitted) 在最后一节中,我们找到了P(S = 10)。我们可以使用相同的过程,为每个可能的s值查找P(S = s)。group方法允许我们在同一时间为所有s这样做。 为此,我们首先丢掉omega列。 然后,我们将按S(omega)的不同值对表格进行分组,并使用sum来将每组中的所有概率相加。 dist_S = five_rolls_sum.drop('omega').group('S(omega)', sum) dist_S S(omega) P(omega) sum 5 0.000128601 6 0.000643004 7 0.00192901 8 0.00450103 9 0.00900206 10 0.0162037 11 0.0263632 12 0.0392233 13 0.0540123 14 0.0694444 … (16 rows omitted) 该表格显示了所有可能的S值及其所有概率。它被称为S的概率分布表。 表中的内容 - 随机变量的所有可能值及其所有概率 - 称为S的概率分布,或者简称为S的分布。该分布显示了 100% 的总概率如何分布在S的所有可能值上。 让我们来检查一下,以确保结果空间中的所有ω都已经在概率一列中得到了解释。 dist_S.column(1).sum() # 0.99999999999999911 它在计算环境中是 1。这是任何概率分布的一个特征: 分布的概率是非负的,总和为 1。 展示分布 在 Data8 中,你使用datascience库来处理数据分布。prob140库建立在它上面,为处理概率分布和事件提供了一些便利的工具。 首先,我们将构造一个概率分布对象,虽然它看起来非常像上面的表格,但它的第二列中预计会有概率分布,并且如果它发现了其他任何东西,就会报错。 为了使代码易于阅读,让我们以数组的形式分别提取可能的值和概率: s = dist_S.column(0) p_s = dist_S.column(1) 要将这些转换为概率分布对象,请从空表开始,然后使用表的values和probability方法。values的参数是可能值的列表或数组,而probability的参数是相应概率的列表或数组。 dist_S = Table().values(s).probability(p_s) dist_S Value Probability 5 0.000128601 6 0.000643004 7 0.00192901 8 0.00450103 9 0.00900206 10 0.0162037 11 0.0263632 12 0.0392233 13 0.0540123 14 0.0694444 … (16 rows omitted) 除了列标签更具可读性之外,这看起来与我们之前的表完全相同。但是这是好处:在直方图中展示分布,只需使用prob140的Plot方法,如下。 Plot(dist_S) Plot的注解 回想一下,datascience库中的hist显示原始数据的直方图,包含在表格的列中。prob140库中的Plot显示概率直方图,基于概率分布作为输入。 Plot仅适用于概率分布对象,使用values和probability方法创建的。 它不适用于Table类的普通成员。 Plot适用于具有整数值的随机变量。 你将在接下来的几章中遇到的许多随机变量是整数值。 为了展示其他随机变量的分布,分箱决策更加复杂。 S的分布的注解 在这里,五次投掷的点数总和的分布曲线出现了钟形。 注意这个直方图和你在 Data 8 中看到的钟形分布之间的差异。 这个显示确切的分布。它是根据实验的所有可能结果进行计算的。这不是一个近似值也不是一个经验直方图。 Data8 中的中心极限定理的表述表明,大型随机样本总和的分布大致是正态的。但是在这里你看到的只是五次投掷的总和呈现钟形分布。如果你从均匀的分布开始(这是单次投掷的分布),那么在总和的概率分布变成正态之前,你不需要大型样本。 展示事件的概率 从 Data8 中可知,钟形曲线拐点之间的区间约占曲线面积的 68%。 虽然上面的直方图并不完全是一个钟形曲线 - 它是一个只有 26 个条形的离散直方图 - 但它非常接近。 拐点似乎大约是 14 和 21。 Plot的event参数可让你可视化事件的概率,如下所示。 Plot(dist_S, event = np.arange(14, 22, 1)) 金色区域是P(14 <= S <= 21)。 prob_event方法操作概率分布对象,来返回事件的概率。为了找到P(14 <= S <= 21),请按如下所示使用它。 dist_S.prob_event(np.arange(14, 22, 1)) # 0.6959876543209863 几率是 69.6%,离 68% 并不远。 数学和代码的对应 P(14 <= S <= 21)可以通过将事件划分为 14 到 21 范围内的事件{S = s}的并集,然后使用加法规则来找到。 请小心使用小写字母s作为通用可能值,与大写字母S作为随机变量相对应;不这样做会使公式含义非常混乱。 这意味着: 首先为 14 到 21 范围内的每个s值抽取事件{S = s}: event_table = dist_S.where(0, are.between(14, 22)) event_table Value Probability 14 0.0694444 15 0.0837191 16 0.0945216 17 0.100309 18 0.100309 19 0.0945216 20 0.0837191 21 0.0694444 然后将所有这些事件的概率相加。 event_table.column('Probability').sum() # 0.6959876543209863 prob_event方法一步完成所有这些。 在这里再次进行比较。 dist_S.prob_event(np.arange(14, 22, 1)) # 0.6959876543209863 你可以通过各种方式,使用相同的基本方法来查找由S确定的任何事件的概率。这里有两个例子。 示例 1: 示例 2: 一个查找数值的简便方法: dist_S.prob_event(np.arange(20, 31, 1)) # 0.30516975308642047 示例 3: dist_S.prob_event(np.arange(4, 17, 1)) # 0.39969135802469169 相等性 我们知道两个数字相等意味着什么。 然而,随机变量的相等可能不止一种。 相同 如果相同结果空间上定义的两个随机变量X和Y的值,对于空间中的每个结果都是相同的,那么它们是相同的。符号X = Y意味着 。非正式来说,无论结果如何, 如果X是10,那么Y也必须是 10;如果X是11,Y必须是 11,依此类推。 一个例子会把它说清楚。 假设 是三次硬币投掷的正面数量,并且 是相同的三次投掷的背面数量。 那么两个随机变量 和 是相等的。 对于三次投掷的每一种可能结果, 的值等于 的值。 我们简单地写成 。 同分布 如上所述, 和 不相等。例如, coin = make_array('H', 'T') three_tosses = list(product(coin, repeat=3)) three_tosses ''' [('H', 'H', 'H'), ('H', 'H', 'T'), ('H', 'T', 'H'), ('H', 'T', 'T'), ('T', 'H', 'H'), ('T', 'H', 'T'), ('T', 'T', 'H'), ('T', 'T', 'T')] ''' 只有 8 个结果,因此很容易检查上表并写出 和 的分布。它们都取值为 0, 1, 2 和 3,概率分别为 1/8,3/8,3/8 和 1/8。该分布如下表所示。 dist = Table().values(np.arange(4)).probability(make_array(1, 3, 3, 1)/8) dist Value Probability 0 0.125 1 0.375 2 0.375 3 0.125 我们说 和 是同分布的。 一般而言,如果两个随机变量具有相同的概率分布,则它们是同分布的。 这表示为 。 相等性之间的关系 相同比同分布更强。如果两个随机变量在结果层面上相同,那么它们必须具有相同的分布,因为它们在结果空间上是相同的函数。 也就是说,对于任意两个随机变量X和Y,。 但三次投掷的正面和反面的例子表明,反面不一定是正确的。 示例:来自小牌组的两张牌 一个牌组包含 10 张牌,分别标记为1,2,2,3,3,3,4,4,4,4。两张牌是不放回随机发放的。让 为第一张卡上的标记, 为第二张卡上的标记。 问题 1。 和 是否相同? 答案是否定的,因为结果可能是 31,在这种情况下 和 。 问题 2。 和 是否同分布? 回答 2。让我们找到两个分布并进行比较。显然,每种情况下可能的值是 1,2,3 和 4。 的分布很简单:。当分布由这样的公式定义时,你可以定义一个函数来表示公式所说的内容: def prob1(i): return i/10 然后,你可以像之前一样,使用value创建一个概率分布对象,但现在使用probability_function,它将函数的名称作为其参数: possible_i = np.arange(1, 5, 1) dist_X1 = Table().values(possible_i).probability_function(prob1) dist_X1 Value Probability 1 0.1 2 0.2 3 0.3 4 0.4 相信下面的函数prob2会为每个i返回P(X_2 = i)。事件已根据 的值进行划分。 dist_X2 = Table().values(possible_i).probability_function(prob2) dist_X2 Value Probability 1 0.1 2 0.2 3 0.3 4 0.4 这两个分布是相同的!这是另一个不放回抽样的对称性的例子。 结论是 。
二、计算几率 原文:prob140/textbook/notebooks/ch02 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 一旦你开始处理概率问题,你很快就会意识到所有可能结果是等可能的假设并不总是合理的。例如,如果你认为硬币有偏差,那么你就不会认为它的正反面具有相同的几率。 为了处理一些情况,其中某些结果比其他结果几率更高,需要更普遍的理论。在 20 世纪 30 年代,俄罗斯数学家安德烈科尔莫戈罗夫(Andrey Kolmogorov,1903-1987)提出了一些基本规则,称为公理,涵盖了丰富的情况,并成为现代概率论的基础。 公理从结果空间Ω开始。我们现在假设Ω是有限的。概率是一个定义在事件上的函数P,正如你所知,它是Ω的子集。前两个公理只是设置了度量的尺度:他们将概率定义为 0 和 1 之间的数字。 概率是非负的:对于每个事件A,。 整个空间的概率为 1:。 第三个也是最后一个公理,它是概率成为事件“测度”的关键。在我们制定了一些相关术语后,我们会研究它。 加法 第三个公理关于互斥事件。非正式来讲,如果最多只有一个事件发生,则两个事件A和B是互斥的;换句话说,它们不能同时发生。 例如,假设你从一门课中随机抽取一名学生,其中 40% 的学生是新生,20% 是大二学生。每个学生既可以是大一学生,也可以是大二学生,也可以什么都不是;但没有一个学生既是大一学生,也是大二学生。所以如果A是“所选学生是新生”而B是事件“所选学生是二年级”的事件,则A和B是相斥的。 互斥事件有什么大不了的?要理解这一点,首先考虑所选学生是大一学生或大二学生的事件。在集合论的语言中,这是“新生”和“大二”两个事件的结合。使用维恩图来显示事件是一个好主意。在下图中,将A和B设想为两个互斥的事件,显示为蓝色和金色圆圈。因为事件是互斥的,所以相应的圆不重叠。并集是两个圆圈中所有点的集合。 def show_disjoint_union(): plt.figure(figsize=(10, 20)) # create the circles with shapely a = sg.Point(1.4,2.5).buffer(1.0) b = sg.Point(3.3,2.5).buffer(0.75) # use descartes to create the matplotlib patches ax = plt.subplot(121) ax.add_patch(descartes.PolygonPatch(a, fc='darkblue', ec='k', alpha=0.8)) ax.add_patch(descartes.PolygonPatch(b, fc='gold', ec='k', alpha=0.6)) ax.annotate('A', [1.4, 2.5]) ax.annotate('B', [3.3, 2.5]) # control display plt.title('Mutually Exclusive Events') plt.axis('off') ax.set_xlim(0, 5); ax.set_ylim(0, 5) ax.set_aspect('equal') # use descartes to create the matplotlib patches ax = plt.subplot(122) ax.add_patch(descartes.PolygonPatch(a, fc='blue', ec='k', alpha=0.8)) ax.add_patch(descartes.PolygonPatch(b, fc='blue', ec='k', alpha=0.8)) # control display plt.title('Disjoint Union') plt.axis('off') ax.set_xlim(0, 5); ax.set_ylim(0, 5) ax.set_aspect('equal') show_disjoint_union() 学生是大一或大二的几率是多少? 在总体中,40% 是大一,20% 是大二,所以自然答案是 60%。 这是满足我们“大一或大二”标准的学生的百分比。 简单的加法是有效的,因为两组不相交。 科尔莫戈罗夫用这个想法来形成第三个,也是最重要的概率公理。正式来讲,如果交集为空,则A和B是互斥事件: 第三个公理,加法规则 在有限结果空间的背景下,公理表明: 如果A和B是互斥事件,那么 。 你将在练习中表明这个公理蕴涵着更一般的东西: 对于任何固定的n,如果 是互斥的(也就是对于所有 ,),那么: 有时叫做有限可加性公理。 这个看似简单的公理具有巨大的力量,特别是当它扩展到无数个互斥的事件时。首先,它可以用来创建一些方便的计算工具。 嵌套事件 假设一个班级中有 50% 的学生将数据科学作为他们的专业之一,40% 的学生主修数据科学和计算机科学(CS)。 如果你随机选择一个学生,那么该学生主修数据科学,但不是 CS 的几率是什么? 下面的维恩图显示了一个对应于事件A(数据科学作为专业之一)的深蓝色圆圈,和一个对应B(主修数据科学和 CS)的金色圆圈(未按比例绘制)。这两个事件是嵌套的,因为B是A的一个子集:B中的每个人都把数据科学作为他们的专业之一。 所以 ,那些主修数据科学但不是 CS 的人是A与B的差: 其中 是B的补集。差是右侧浅蓝色的圆圈。 def show_difference(): plt.figure(figsize=(10, 20)) # create the circles with shapely a = sg.Point(2,2.5).buffer(1.0) b = sg.Point(2,2.5).buffer(0.75) # compute the 2 parts left = a.difference(b) middle = a.intersection(b) # use descartes to create the matplotlib patches ax = plt.subplot(121) ax.add_patch(descartes.PolygonPatch(left, fc='darkblue', ec='k', alpha=0.8)) ax.add_patch(descartes.PolygonPatch(middle, fc='olive', ec='k', alpha=0.8)) # control display plt.title('Nested Events') plt.axis('off') ax.set_xlim(0, 5); ax.set_ylim(0, 5) ax.set_aspect('equal') # use descartes to create the matplotlib patches ax = plt.subplot(122) ax.add_patch(descartes.PolygonPatch(left, fc='blue', ec='k', alpha=0.8)) ax.add_patch(descartes.PolygonPatch(middle, fc='None', ec='k', alpha=0.8)) # control display plt.title('The Difference') plt.axis('off') ax.set_xlim(0, 5); ax.set_ylim(0, 5) ax.set_aspect('equal') show_difference() 这个学生在浅蓝色的差中的几率是多少呢? 如果你回答“50% - 40% = 10%”,你是对的,你的直觉说概率的行为就像区域一样。他们是这样。 事实上,这个计算是从可加性的公理出发的,我们也通过查看这些区域来受它们启发。 减法规则 假设A和B是事件,。那么 。 证明。由于 , 这是个不相交集合,根据加法公理: 所以, 补集 如果一个事件的几率是 40%,它不会发生的几率是多少? 60% 的“明显”答案是减法规则的特例。 补集规则 对于任何事件B,。 证明。 下面的维恩图显示了要做什么。 在减法公式取A = Ω,记住第二个公理 。或者,在这种特殊情况下为减法规则重新取参数。 def show_complement(): plt.figure(figsize=(10, 20)) # create the square and circle with shapely a = sg.box(0, 0, 4.5, 4.5) b = sg.Point(2.25,2.5).buffer(1) # compute the 2 parts left = a.difference(b) middle = a.intersection(b) # use descartes to create the matplotlib patches ax = plt.subplot(121) ax.add_patch(descartes.PolygonPatch(left, fc='None', ec='k', alpha=0.8)) ax.add_patch(descartes.PolygonPatch(middle, fc='darkblue', ec='k', alpha=0.8)) # control display plt.title('An Event (Square = Omega)') plt.axis('off') ax.set_xlim(0, 5); ax.set_ylim(0, 5) ax.set_aspect('equal') # use descartes to create the matplotlib patches ax = plt.subplot(122) ax.add_patch(descartes.PolygonPatch(left, fc='blue', ec='k', alpha=0.8)) ax.add_patch(descartes.PolygonPatch(middle, fc='None', ec='k', alpha=0.8)) # control display plt.title('The Complement') plt.axis('off') ax.set_xlim(0, 5); ax.set_ylim(0, 5) ax.set_aspect('equal') show_complement() 当你在概率计算中看到减号时,就像在上面的补集规则中一样,你会经常发现减号是由于在附加规则的应用中,术语的重新排列。 当你加或减概率时,你就隐式地将一个事件分解成不相交的部分。这被称为划分事件,是需要掌握的一项基本的重要技术。在随后的章节中,你将看到很多划分的用法。 示例 让我们看看我们是否可以使用我们开发的结果来计算一些几率。一些步骤不计算也能清楚;其他东西需要更多的工作。 示例 1:n次投掷中的正面和反面 一枚硬币被抛出n次,以使所有 种可能的正反面序列是等可能的。 问题。获得至少一个正面和至少一个反面的几率是多少? 回答。许多序列中每一面至少出现一次。例如,如果n = 4,则这样的序列包括HTTT,HTHT,TTHT等等。 方法 - 补集:当一个事件可能以多种不同的方式发生时,查看它不会发生的方式可能是一个好主意,因为这样情况较少。 对于n = 4,每个面没有至少出现一次的唯一序列是HHHH和TTTT。事实上,对于任何n,只有两个序列,我们不能从中得到两个面:所有都是正面和所有都是反面。这些是所有元素都相同的两个序列。 让A成为事件“我们得到至少一个正面和至少一个反面”。问题要求P(A)。因为 是事件“序列的所有元素都相同”,所以我们有: 根据补集规则: 请注意,随着n变大,答案趋于 1。随着大量的投掷,你几乎肯定可以看到正面和反面。 示例 2:骰子的 12 次投掷的最大值 一个骰子投掷了 12 次,所以所有 个点数序列是等可能的。将 12 个投掷的最大值定义为 12 个点数中出现的最大值。 例如,序列354222143351的最大值是 5。 问题 1。最大值小于 5 的概率是多少? 答案 1。关键是观察事件“最大值小于 5”与事件“所有 12 个面都小于 5”相同。 为了发生这种情况,12 个点数中的每一个都必须具有四个值 1 到 4 之一。所以: 是的,我们可以进一步简化,但我们不打算,因为很快就会明白原因。 问题 2。最大值小于 4 的概率是多少? 答案 2。这里没有什么新东西,除了在问题 1 中将 5 替换成 4。 问题 3。最大值等于 4 的概率是多少? 答案 3:写下所有最大值等于 4 的序列的并不容易。 让我们看看,我们是否可以使用我们已经知道的。 最大值等于 4: 最大值必须小于 5, 并且不能小于 4。 我们将集合{4}看作一个差:{1,2,3,4} - {1,2,3}。 所以通过减法规则, 12 次投掷没有什么特别之处。你可以在整个过程中用n代替 12,并且参数将如上所述。 最大值是一个极值的例子,另一个是最小值。 解决问题的技巧:当你使用极值时,请记住我们在本例中使用的观察结果:说最大值很小等同于说所有元素都很小。类似地,说最小值很大等同于说所有元素都很大。 示例 3:大于第一个随机数的第二个随机数 一个随机数生成器产生两个数字,因此所有 100 对数字都是等可能的。 问题。第二位数字大于第一位的可能性是多少? 答案,方法一 - 划分:制定事件发生的所有方式的组织清单。 列出第二个数字大于第一个数字的一个好方法是,根据第一个数字的值来划分它们: 第一位数字 0,第二位 1 到 9 第一位数字 1,第二位 2 到 9 第一位数字 2,第二位 3 到 9 等等,直到 第一位数字 8,第二位 9 这个划分使其很容易计算,在 100 个可能的偶对中,第二个数字大于第一个数字的所有偶对:有9 + 8 + 7 + 6 + 5 + 4 + 3 + 2 + 1 = (9×10) / 2 = 45种。所以答案是 0.45。 答案,方法二 - 对称性:用一些对称性说服自己:第二个数字大于第一个数字的几率与第一个数字大于第二个数字的几率相同。一种方法是根据第二个数字的值,来划分第二个事件,并注意与方法一中的划分的对应关系。 所以如果 ,加法规则表明: 因为有 10 对相等的数字:00, 11, 22, ..., 99。现在求解p: 像之前一样。 学习这两种方法是一个好主意。划分和对称将在整个课程中使用。 乘法 概率的主要公理有关互斥事件,事实证明,我们不需要任何其他公理来处理相交的事件。 def show_intersection(): plt.figure(figsize=(8, 16)) # create the circles with shapely a = sg.Point(-.5,0).buffer(1.0) b = sg.Point(0.5,0).buffer(0.75) # compute the 3 parts left = a.difference(b) right = b.difference(a) middle = a.intersection(b) # use descartes to create the matplotlib patches ax = plt.subplot(121) ax.add_patch(descartes.PolygonPatch(left, fc='darkblue', ec='k', alpha=0.8)) ax.add_patch(descartes.PolygonPatch(right, fc='gold', ec='k', alpha=0.6)) ax.add_patch(descartes.PolygonPatch(middle, fc='olive', ec='k', alpha=0.8)) ax.annotate('A', [-0.5, 0]) ax.annotate('B', [0.5, 0]) # control display plt.title('Two Events') plt.axis('off') ax.set_xlim(-2, 2); ax.set_ylim(-2, 2) ax.set_aspect('equal') # use descartes to create the matplotlib patches ax = plt.subplot(122) ax.add_patch(descartes.PolygonPatch(left, fc='None', ec='k', alpha=0.8)) ax.add_patch(descartes.PolygonPatch(right, fc='None', ec='k', alpha=0.8)) ax.add_patch(descartes.PolygonPatch(middle, fc='blue', ec='k', alpha=0.8)) # control display plt.title('The Intersection') plt.axis('off') ax.set_xlim(-2, 2); ax.set_ylim(-2, 2) ax.set_aspect('equal') show_intersection() 让A和B为两个事件。 交集A ∩ B是A和B都是发生的事件,右侧的维恩图中以亮蓝色显示。 因为我们会一直遇到交集,所以在我们的表示法中我们会有点偷懒:我们将使用AB来表示交集,而不会写入交集符号∩。你必须记住AB是一个事件,而不是乘积。 这里有一个例子可以帮助解释我们即将制定的一些定义。 无放回随机抽取 假设我有一个小牌组,由一张红色,一张绿色和一张蓝色的牌组成。假设我洗牌,抽一张,给剩下的两张洗牌,然后从中抽出一张。这被称为在不从牌组替换的情况下,随机抽取两张牌。 一个合理的结果空间是Ω = {RG, RB, GB, GR, BR, BG},其中所有六个元素的等可能的。 我们首先获得绿色,然后是红色的牌的几率,是单个序列GR的几率: 简单的计算包含更有趣的东西。 注意: 第二个因子 1/2 是什么?要理解这一点,只要看看G排在第一位的偶对。 其中,只有一个的下一张牌是R。乘积的第二个因子是: 这个分数被称为,在G是第一个的条件下,R是第二个的条件概率。 它表示为P(second card R∣first card G)。这是垂直的条形,不是倾斜的。 现在我们对这张牌的原始计算可以写成一次一张牌: 条件概率 像上面这样的计算启发了一个新的定义。让A和B为两个事件。那么B在A条件下的条件概率定义为: 除法规则: 这里有一些滥用符号的情况。B|A不是一个事件。 但是符号很方便。整个左侧应被理解为“在发生A的情况下的B发生的概率”。 定义表明:给定A发生了,所以把你的注意力限制在A发生的结果上。 这就是你现在的整个空间,所以所有的几率必须相对于P(A)来计算。 现在B发生的几率是什么?答案是P(AB)/P(A)。 我们除以P(A),你们之间更加小心可能会想知道如果P(A) = 0会发生什么。 那么,在这种情况下,我们不会给定A发生,因为A不会发生。所以我们不必担心这种情况。 乘法规则: 这只是条件概率定义的重新排列,但它也许是所有规则中最常用的规则。 让A和B为两个事件。 那么他们都发生的几率是: 注意答案是“小部分的小部分”。A和B都发生的几率小于A发生的几率 - 事件上条件越多,发生的概率就越小。 由于AB ⊆ B,你知道P(AB)小于P(B)。你也检查一下: 我们将以一些简单的例子结束本节。 下一节包含一些需要更多工作的例子。 示例 1:两张牌中的两个 A 标准牌组由 52 张牌组成,其中 4 张是 A。两张牌无放回地随机发放。 问题 1。给定第一张牌是 A,第二张牌是 A 的几率是多少? 答案 1。3/51,因为现在你的套牌有 51 张牌,其中 3 张是 A。 问题 2。两张牌都是 A 的机会是多少? 答案 2:通过乘法规则和答案 1,答案是: 问题 3。如果带放回地发牌,问题 1 和问题 2 的答案如何改变? 答案 3(究竟谁带放回地发牌?仅仅在概率班里面……)在抽出第二张牌之前,你放回了牌。在这个假设下,你每次都从完全相同的牌组上进行抽取,所以: 无论第一张牌是什么,答案都是一样的。同时: 注意,改变随机性的性质不会改变你是否乘以几率。你仍然在寻找交集的几率,所以你打算做乘法。假设的改变只会改变你的相乘方式。 示例 2:99 岁 根据你在 Data8 中看到的人口普查估计,2014 年美国人口为 318,857,056。 共有 9,037 名 99 岁男性和 32,791 名 99 岁女性。 问题。假设你在 2014 年从美国人口中随机挑选了一个人,那个人是 99 岁。根据这些信息,这个人是女性的几率是多少? 回答。答案自然是 99 岁中的女性百分比: 这与条件概率的定义一致,即你应该计算: 美国整体人口数字并不需要;它可以消去。 这是条件的重要观察。当你随机抽样并且你知道你的选择位于特定的子组中时,该子组内的数字都是重要的。 给定 99 岁的年龄,这个人的女性的概率几乎是男性的四倍。但正如你在 Data 8 中看到的,在我们最年轻的居民中 - 新生儿 - 男性多于女性。 更多示例 仅仅是一个加法规则和一个乘法规则 - 这就是所需要的一切。以下是标准问题求解技巧的一些示例。 示例 1:基本方法 - 从划分开始 一个盒子包含 6 个黑巧克力和 4 个牛奶巧克力。 我随机挑选了两个,不带放回。 问题。我得到每一种的几率是多少? 回答。你会注意到这个问题没有说明第一个是黑的还是牛奶。两个都可能发生。因此,请列出事件发生的不同方式,即事件划分: 第一个是黑的然后是牛奶:根据乘法规则,几率为(6/10)·(4/9) 第一个是牛奶然后是黑的:几率为(4/10)·(6/9) (啊!这两项是相同的!为无放回抽样中的更多这样的对称性做好准备。) 现在将两个几率相加。 答案是2·(6/10)·(4/9)。 这种方法应该像呼吸一样自然。 你应该在不自然的假设下重做这个问题,即巧克力是带放回抽样的,看看什么变化了,什么保持不变。 示例 2:波利亚坛子模型 一个盒子包含b个黑球和w个白球。随机抽取一个球,然后把它放回,并放入d个同颜色更多的球。然后从坛子中随机抽出一个球。 问题 1。第一个抽出的球是黑色的几率是多少? 答案 1。不需要太多努力。 问题 2:第二个抽出的球是黑色的几率是多少? 答案 2。你自然而然会想,第一个球是什么,所以根据那个球的颜色进行划分,然后相加。基本方法再次发挥作用。 这与第一个球是黑色的几率是一样的,不管d是什么。这个规律很有趣! 问题 3:给定第一个球是黑色的,第二个球是黑色的概率是多少? 答案 3。我们已经在上面的计算中使用了它。 “随时间前进”的条件概率通常可以从问题中的信息中读出,例如: P(second ball black∣first ball black)=b+db+w+dP(second ball black∣first ball black)=b+db+w+d 问题 4:给定第二个球是黑色的,第一个球是黑色的几率是多少? 答案 4,这种“时光倒流”的条件概率不易读出。这是除法规则的所在。 这个确实取决于d,但它与答案3一样。前后颠倒似乎没有什么区别。 现在你开始明白,为什么这个规律带有着名的创始人乔治波利亚(George Polya,1887-1985)的名字。你可以继续重复这个规律 - 用d个另一个颜色的球代替抽出的球,然后再次抽出 - 获得一个过程,具有美丽和有用的属性,以便在数据进入时更新观点。我们将在课程的后面看到。 更新概率 数据改变了思想。我们可能会从世界如何运作的一系列假设开始,但随着我们收集更多数据,我们可能需要根据数据中看到的内容更新我们的观点。 观点可以通过概率来反映,而这些观点也可以在信息进入时更新。在本节中,我们将建立一个给定数据情况下的概率更新方法。我们将从一个例子开始,然后我们将更广泛地陈述该方法。 示例:真阳性 人口中有一种罕见的疾病:只有 0.4% 的人拥有它。有一种针对这种疾病的检验,用于拥有这种病的人,有 99% 的几率返回阳性结果。用于没有疾病的人,它有 99.5% 的机会返回阴性结果。总的来说,这是一个相当不错的检验。 从这个人口中随机挑选一个人。给定这个人的测试结果为阳性,这个人患病的概率有多大? 以下是我们在 Data8 中绘制的树状图,用于总结问题中的信息。 为了解决这个问题,我们将使用除法规则。 让D为患者拥有疾病的事件,并且在一些数学符号被滥用的情况下,让+成为患者测试结果为阳性的事件。 那么我们要找的是P(D | +)。按照除法规则, P(D∣+)=P(D and +)P(+)=0.004⋅0.990.004⋅0.99+0.996⋅0.005=44.3%P(D∣+)=P(D and +)P(+)=0.004⋅0.990.004⋅0.99+0.996⋅0.005=44.3% (.004*.99)/(0.004*.99 + 0.996*.005) # 0.44295302013422816 贝叶斯规则 一般来说,如果整个结果空间可以划分为事件 ,B是一个正概率事件,那么对于每个i, 这种计算称为贝叶斯规则,是一个环境下的除法规则的应用,其中事件 可以看做“较早”阶段的结果,并且B是“较晚”阶段的结果。通过计算,我们可以求出给定较晚事件的,较早事件的“时光倒流”的条件概率,通过写出给定较早事件的,较晚事件的“随时间前进”的条件概率。 先验的影响 让我们仔细看看在我们在例子中得到的答案的数值。这有点令人不安。它说,即使这个人结果为阳性,他们患病的几率也不到 50%。这似乎很奇怪,因为测试的准确率非常高。 这不是测试或贝叶斯规则的错误。这是因为我们的前提是“这个人是随机从人群中挑选的”。这种疾病非常罕见,患有该疾病并且是阳性的人的比例,实际上比没有该疾病并且测试结果错误的人少一些。这解释了为什么随机挑选的人的答案少于 50%。 但是做疾病测试的人,通常由于他们或他们的医生认为他们应该做。在这种情况下,他们不再是“随机挑选”的人口成员。 对于这样的人,我们必须重新思考我们对随机性的假设。如果一个人认为他们可能患有这种疾病,那么他们患这种疾病的主观概率,应该大于随机成员的概率。让我们执行以下步骤,看看之前的差异有多大。 我们将把疾病的“先验概率”从 0.004 改为其他值;“无疾病”的先验概率将相应做出改变。 我们将保持测试准确率不变。 我们将观察对于先验的不同值,给定某人是阳性,疾病的“后验概率”的变化。 prior = make_array(0.004, 0.01, 0.05, 0.1, 0.5) Table().with_columns( 'Prior P(D)', prior, 'Posterior P(D|+)', (prior*0.99)/(prior*0.99 + (1-prior)*0.005) ) 先验P(D) 后验`P(D +)` 0.004 0.442953 0.01 0.666667 0.05 0.912442 0.1 0.956522 0.5 0.994975 该表格显示,给定测试结果为阳性,这个人患病的后验几率,很大程度上取决于先验。例如,如果这个人认为他们甚至有 10% 的几率患病,那么,给定他们测试为阳性,他们患病的概率会更新为 95% 以上。
来源:ApacheCN《面向机器学习的特征工程》翻译项目 译者:@cn-Wziv 校对:@HeYun 通过自动数据收集和特征生成技术,可以快速获得大量特征,但并非所有这些都有用。在第 3 章和 在第 4 章中,我们讨论了基于频率的滤波和特征缩放修剪无信息的特征。现在我们来仔细讨论一下使用主成分分析(PCA)进行数据降维。 本章标志着进入基于模型的特征工程技术。在这之前,大多数技术可以在不参考数据的情况下定义。对于实例中,基于频率的过滤可能会说“删除所有小于n的计数“,这个程序可以在没有进一步输入的情况下进行数据本身。 另一方面,基于模型的技术则需要来自数据的信息。例如,PCA 是围绕数据的主轴定义的。 在之前的技术中,数据,功能和模型之间从来没有明确的界限。从这一点前进,差异变得越来越模糊。这正是目前关于特征学习研究的兴奋之处。 阅读全文
来源:ApacheCN《面向机器学习的特征工程》翻译项目 译者:@kkejili 校对:@HeYun 如果让你来设计一个算法来分析以下段落,你会怎么做? Emma knocked on the door. No answer. She knocked again and waited. There was a large maple tree next to the house. Emma looked up the tree and saw a giant raven perched at the treetop. Under the afternoon sun, the raven gleamed magnificently. Its beak was hard and pointed, its claws sharp and strong. It looked regal and imposing. It reigned the tree it stood on. The raven was looking straight at Emma with its beady black eyes. Emma felt slightly intimidated. She took a step back from the door and tentatively said, “hello?” 该段包含很多信息。我们知道它谈到了到一个名叫Emma的人和一只乌鸦。这里有一座房子和一棵树,艾玛正想进屋,却看到了乌鸦。这只华丽的乌鸦注意到艾玛,她有点害怕,但正在尝试交流。 那么,这些信息的哪些部分是我们应该提取的显着特征?首先,提取主要角色艾玛和乌鸦的名字似乎是个好主意。接下来,注意房子,门和树的布置可能也很好。关于乌鸦的描述呢?Emma的行为呢,敲门,退后一步,打招呼呢? 本章介绍文本特征工程的基础知识。我们从词袋(bags of words)开始,这是基于字数统计的最简单的文本功能。一个非常相关的变换是 tf-idf,它本质上是一种特征缩放技术。它将被我在(下一篇)章节进行全面讨论。本章首先讨论文本特征提取,然后讨论如何过滤和清洗这些特征。 阅读全文
来源:ApacheCN《面向机器学习的特征工程》翻译项目 译者:@ZhipengYe 校对:(虚位以待) 机器学习将数据拟合到数学模型中来获得结论或者做出预测。这些模型吸纳特征作为输入。特征就是原始数据某方面的数学表现。在机器学习流水线中特征位于数据和模型之间。特征工程是一项从数据中提取特征,然后转换成适合机器学习模型的格式的艺术。这是机器学习流水线关键的一步,因为正确的特征可以减轻建模的难度,并因此使流水线能输出更高质量的结果。从业者们认为构建机器学习流水线的绝大多数时间都花在特征工程和数据清洗上。然后,尽管它很重要,这个话题却很少单独讨论。也许是因为正确的特征只能在模型和数据的背景中定义。由于数据和模型如此多样化,所以很难概括项目中特征工程的实践。 尽管如此,特征工程不仅仅是一种临时实践。工作中有更深层的原则,最好就地进行说明。本书的每一章都针对一个数据问题:如何表示文本数据或图像数据,如何降低自动生成的特征的维度,何时以及如何规范化等等。把它看作是一个相互联系的短篇小说集,而不是一本长篇小说。每章都提供了大量现有特征工程技术的插图。它们一起阐明了总体原则。 掌握主题不仅仅是了解定义并能够推导出公式。仅仅知道这个机制是如何工作的以及它可以做什么是不够的。它还必须包括理解为什么要这样设计,它如何与其他技术相关联,以及每种方法的优缺点是什么。掌握就是要准确地知道如何完成某件事,对底层原则有一个感觉,并将其整合到我们已知的知识网络中。一个人通过读一本相关的书并不会成为某个东西的主人,尽管一本好书可以打开新的门。它必须涉及实践——将想法用于实践,这是一个反复的过程。随着每一次迭代,我们都会更好地了解这些想法,并在应用这些想法时变得越来越娴熟和富有创造性。本书的目标是促进其思想的应用。 本书首先尝试传授感觉,其次是数学。我们不是只讨论如何完成某些事情,而是试图引导发现原因。我们的目标是提供观点背后的感觉,以便读者了解如何以及何时应用它们。对于以不同方式学习的人们来说,有大量的描述和图片。提出数学公式是为了使感觉更加精确,并且还可以将本书与其他现有的知识结合起来。 本书中的代码示例在 Python 中给出,使用各种免费和开源软件包。NumPy 库提供数字向量和矩阵操作。Pandas 是一个强大的数据框架,是 Python 中数据科学的基石。Scikit-learn 是一个通用机器学习软件包,涵盖了广泛的模型和特征变换器。Matplotlib 和 Seaborn 的样式库提供了绘图和可视化。你可以在我们的 github 仓库中找到这些例子作为 Jupyter notebooks。 前几章开始较缓慢,为刚刚开始使用数据科学和机器学习的人们提供了一个桥梁。第 1 章从数字数据的基本特征工程开始:过滤,合并,缩放,日志转换和能量转换以及交互功能。第 2 章和第 3 章深入探讨了自然文本的特征工程:bag-of-words,n-gram 和短语检测。第 4 章将 tf-idf 作为特征缩放的例子,并讨论它的工作原理。围绕第 5 章讨论分类变量的高效编码技术,包括特征哈希和 bin-counting,步伐开始加速。当我们在第 6 章中进行主成分分析时,我们深入机器学习的领域。第 7 章将 k-means 看作一种特征化技术,它说明了模型堆叠的有效理论。第 8 章都是关于图像的,在特征提取方面比文本数据更具挑战性。在得出深度学习是最新图像特征提取技术的解释之前,我们着眼于两种手动特征提取技术 SIFT 和 HOG。我们在第 9 章中完成了一个端到端示例中的几种不同技术,为学术论文数据集创建了一个推荐器。 阅读全文
一、基础 原文:prob140/textbook/notebooks/ch01 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 究竟是什么概率,一直是有争议的辩论主题。有些人认为概率是长期的频率,只适用于在相同条件下可能反复发生的事件。其他人则认为概率量化了个体对任何事件的不确定性的主观程度,并且可能因人而异。还有一些人并不严格属于这些分组。 概率含义的争论使伟大的概率论者吉米·萨维奇(Jimmie Savage,1917-1971)观察到“自从巴别塔以来很少有这种完全的分歧和争论。” 现在,频率论者和主观主义者之间的分歧,并不像以前那么广泛。在 Prob140 中,对于概率的含义,欢迎你自己做出决定。 无论哲学上的争论如何,概率的基本组合方式都可以通过考虑比例来理解。这就是我们将在前两章中探讨的内容。我们先来介绍一些概率论的标准术语。 结果空间和事件 任何涉及随机性的实验都会产生许多可能的结果之一。结果空间是所有这些结果的集合。 形式上,结果空间只是一个集合,通常用 Ω 表示。这是大写的希腊字母 Omega。 现在我们将假设 Ω 是有限的。从某种意义上说,这不是限制性的,因为即使是最大的数据集也是有限的,而且功能最强大的计算机每个任务都执行许多有限操作。但是,我们很快就会看到,允许无限的可能结果,不仅会产生丰富而优雅的理论,而且会让我们更深入地了解涉及有限结果空间的问题。因此,一旦我们理清了有限的情况,那么 Ω 是有限的假设将在后面的章节中被解除。 结果 ω 是结果空间 Ω 的一个元素。虽然 ω 看起来像字母 w,但它是小写的希腊 omega,通常比 w 更圆润。 事件是 Ω 的一个子集。允许空集 φ 和整个空间 Ω 作为子集。按照惯例,像 A 和 B 这样的前面几个字母通常用作事件的符号。 示例一:排列 假设你正在对三张牌洗牌,分别是a,b和c。 那么所有可能的结果空间是: 事件{abc, acb}可以被描述为“首先出现a”。 通过将事件定义为子集,事件的这种口头描述变得正式。这是发展精确而一致的理论的第一步,同时也应用于自然语言中。 事件 口头描述 子集 A a首先出现 {abc, acb} B b和c不挨着 {bac, cab} C 字母是字母表中的顺序 {abc} D a首先出现,b其次,但是c不是第三个 ϕ E c是第一个,第二个或者第三个 Ω F 字母来自于表示"taxi"的单词 {cab} “类型”的注解:结果ω = cab与事件F = {cab}不同。结果是结果空间的一部分,事件是结果空间的一个子集。 这个子集碰巧只包含一个结果,但它仍然是一个子集,而不是一个元素。 你可以把它看作类似 Python 中的不同类型:'cab'是一个字符串,而['cab']是一个列表。 该表包含六个事件,你可以想出更多。 对于每一个,看看你是否可以提供一个有趣的口头描述。 当你为游戏洗牌时,目标是使牌的顺序变得“随机”。 最好是,你希望任何排列与其他排列可能性相同。 那么让我们开始研究等可能的结果。 等可能结果 “如果投掷一枚硬币,那么它是正面的几率是多少呢?”提出这个问题,你会得到的最常见的答案是 1/2。如果你询问理由,没有意外会听到,“因为硬币有两面。”一枚硬币确实有两面,但是注意到一个隐藏在你所得到的“推理”中的假设:两面中的每一面都与另一面相同。 等可能的结果的假设是一种简单而古老的随机性模型。它将概率定义为比例。Ω 是有限的假设,使得易于将比例识别为结果总数的一小部分。 对于一些n>1,令 Ω 包含 n 个结果。让A⊆Ω成为一个事件。将#(A)定义为子集 A 中结果的数量。因此,对于任何其他事件,#(Ω)= n,#(φ) = 0,并且0 < #(A) < n。 对于事件 A,设P(A)表示 A 发生的概率或几率。我们将同义地使用“probability”和“chance”两个词(翻译为“概率”或“几率”),并且我们通常会使用“happens”而不是更正式的“occurs”(都翻译为“发生”)。 等可能的结果空间中的概率 假设 Ω 中的所有 n 个结果是等可能的,则事件 A 发生的概率由下式定义: 这种概率是比例的想法是许多计算的核心。 你将会看到,比例的组合规则成为概率的组合规则,无论所有结果是否是等可能的。 但是现在我们将在结果可能性相同的自然假设下开展工作。 示例一:随机排列 设 Ω 是字母a,b和c的所有排列的空间。 那么 Ω 包含n = 6个结果: 如果我们假设所有六种排列是等可能的,我们着手于三个字母的随机排列。 在这个假设下,我们可以用一列几率来扩展我们的事件表。 事件 口头描述 子集 概率 A a首先出现 {abc, acb} 2/6 = 1/3 B b和c不挨着 {bac, cab} 1/3 C 字母是字母表中的顺序 {abc} 1/6 D a首先出现,b其次,但是c不是第三个 ϕ 0 E c是第一个,第二个或者第三个 Ω 1 F 字母来自于表示"taxi"的单词 {cab} 1/6 要注意: 因此,所有排列等可能的假设,使得所有三个位置是等可能的。你应该检查b和c的位置也是如此。 示例二:随机数生成 假设一个随机数生成器从00,01,02,...,98,99的 100 个偶对 中返回一对数字,使得所有偶对等可能返回。 你会注意到这些偶对与 0 到 99 的 100 个整数相对应。在下面的内容中,乘法法则会很有用: 第一个数字有 10 个选项:0,1,2,3,4,5,6,7,8,9。 对应于第一位数字的每个选择,第二位数字有 10 个选择。 所以总共有10×10 = 100对数字。 这里“偶对”是两个数字的序列,一个接一个。偶对 27 与 72 不同。它们有时称为“有序对”。在本文中,所有序列都是有序的。 现在我们来计算一些事件的概率。通过假设,所有偶对都是等可能的。因此,每个答案将包括计算事件中的偶对数量,然后除以总数,即 100。 (1)偶对由两个不同的数字组成的概率是多少? 我们必须计算a≠b的偶对ab的数量。数字a可以按 10 种方式选择;对于每种方式,只有 9 种方法用于选择b,因为b必须与a不同。所以答案是: (2)两个数字相同的几率是多少? 让我们尝试使用我们对(1)的回答。 在 100 对中的每一对中,两个数字相同或不同。 没有一对可以属于两个类别,所以按照我们的比例规则: 为了通过计数来检查这一点,你必须统计aa形式的偶对。 有 10 种方法可供选择,之后就没有更多的选择了。 所以答案是10/100 = 0.1,证实了以上我们的计算。 散列中的碰撞 在计算机科学中,散列函数将一个称为散列值的代码分配给一组个体中的每一个。为每个个体分配一个独特的值是很重要的。如果相同的值分配给了两个个体,则会发生碰撞,这会产生认证问题。然而,跟踪哪些散列值已分配或未分配是很麻烦的,因为散列值和个体的数量可能非常大。 如果散列值只是随机分配,而并不考虑哪些已经分配了呢?如果存在大量不同的值和相对较少的个体,那么认为碰撞的可能性很小,似乎是合理的。例如,如果有 1,000 个可用的散列值并且只有 5 个个体,那么如果你为这 5 个个体选择了 5 个值的随机序列,则似乎不太可能会发生冲突。 让我们对随机性做一些假设,找出没有碰撞的概率。假设有 N 个散列值和 n 个个体,并且假设你的散列函数是这样的,那么对个体的所有 个赋值都是等可能的。赋值是序列 ,其中,对于每个i,将散列值 分配给个体i。 请注意,我们假定 n 个个体中的每一个,都可以被分配 N 个值中的任何一个,而不管分配给其他人的是什么。这包括了不幸的概率,所有 n 个个体被赋予相同值。 无碰撞 无碰撞的概率是什么? 如果个体数量 n 大于散列值 N 的数量,则答案为 0。如果个体数量多于个人数量,那么你将不得不重复使用某些值,因此无法避免碰撞。 但是我们对 n 很小的情况感兴趣,所以假设n≤N,我们没有问题。 如果回顾前一部分中,随机数生成器的例子中的第(1)部分,你会发现,在N = 10且n = 2的情况下,它与我们当前的问题相同。 我们可以按照相同的流程来获得我们的答案。 根据假设,所有 个可能的赋值都是等可能的。其中一些赋值不包含碰撞。我们的工作是统计它有多少。 你熟悉 Python 的从 0 开始的索引系统,它在这里派上用场。 我们必须计算序列 的数量,其中每个 是 N 个哈希值之一,并且所有 都彼此不同。 有 N 个选项。 对于每一种选择, 都有N-1个选项,因为 必须与 不同。 因此,有N(N-1)种方式填充位置 0 和 1 而避免碰撞。 对于这些选择 和 的N(N-1)种方法, 有N-2个选择。 这是因为 必须不同于彼此不同的 和 。 因此,有N(N-1)(N-2)种填充位置 0, 1 和 2 的方式。 请注意,对于每个i,与位置i对应的乘积中的项是N-i。这使序列容易延续到最后,即位置(n-1)。 “延续序列”是一个需要数学证明的非正式过程。 你可以通过归纳法来证明。 分子中的乘积有 n 项,分母中有 n 个因子。 这使我们可以用不同的方式编写公式,作为 n 个分数的乘积: 符号 表示求积,就像 表示求和。 现在是坏消息了: 至少一个碰撞 每个序列要么至少有一次碰撞,要么没有碰撞。 没有序列可以位于这两个类别中,所以按照我们的比例规则: 我们有了公式。这很棒!但是答案很大,还是很小?仅通过观察公式不容易分辨。那么让我们以不同的方式开始检验答案。 第一种方法是数字。为此,我们必须处理 N 和 n 的数值。我们在一个背景中会实现它,这个背景让这个计算变得著名。 生日问题 一个经典的概率问题是生日的“碰撞”。这个生日问题由理查德·冯·米塞斯和其他数学家提出 - 它的起源并不完善。主要问题是,“如果一个房间里有 n 个人,那么他们中的一些人有相同的生日的几率是多少?” 随机性假设 这个问题通常在每年 365 天的假设下得到解决,并且无论其他人的生日如何,每个人都有可能在 365 天中的任何一天出生。 你可以看到,这些假设忽略了闰年以及多胎(例如双胞胎)以及一年中出生分布不均匀的情况。这些假设使得计算更简单,但可能并不能反映人口中的生日的实际情况。数据科学家必须小心他们的假设 - 如果假设没有反映真相,那么结论也不会。 所以让我们注意,我们正在根据简化的假设进行工作,在对特定的群体做出结论之前我们应该检查一下。在任何情况下,忽略闰年和多胎都不应对结论产生重大影响。如果在一年中的某些时候,出生比其他时候更可能发生,那么就证明了生日相同的几率将大于我们在假设下得到的答案。 生日问题有很多变化,但我们会专注于经典问题。 匹配的概率 我们将简洁地陈述我们的假设,因为“所有 个生日序列是等可能的”。 你可以看到,这使得生日问题与上一节的碰撞问题相同,其中N = 365。 如前所述,唯一有趣的情况是当n≤N时,为此: 计算几率 当 N 固定在 365 时,函数p_no_match以n为参数并返回在n个生日之中不存在匹配的概率。 代码的其余部分在一个表中显示所有结果。该表还包含一列,包含存在碰撞的几率: N = 365 def p_no_match(n): individuals_array = np.arange(n) return np.prod( (N - individuals_array)/N ) results = Table().with_column('Trials', np.arange(1, N+1, 1)) different = results.apply(p_no_match, 'Trials') results = results.with_columns( 'P(all different)', different, 'P(at least one match)', 1 - different ) results Trials P(all different) P(at least one match) 1 1 0 2 0.99726 0.00273973 3 0.991796 0.00820417 4 0.983644 0.0163559 5 0.972864 0.0271356 6 0.959538 0.0404625 7 0.943764 0.0562357 8 0.925665 0.0743353 9 0.905376 0.0946238 10 0.883052 0.116948 … (355 rows omitted) 表中首先要注意的是,使用标签Trials来表示人。在概率中,通常将随机试验看作是试验序列,其中每个试验的结果取决于旅几率。 在生日问题中,每个人都被认为是一个试验,我们正在研究所有试验中是否至少有一对匹配的生日。 接下来,请注意,在只有一个人的无聊情况下,不能存在一对匹配的生日,因此P(no match)定义为 1。在许多问题中存在这样的“边界情况”,必须单独处理。 最后,请注意,当人数很少时,他们生日不同的几率很大。这与我们的直觉是一致的,即如果个体数量相对于可用散列值的数量较小,并且随机给个人赋值,那么碰撞的几率很小。 生日“悖论” 但是碰撞几率随人数增加而增加。实际上,它增加得很快。 results.scatter('Trials', 'P(at least one match)') plt.xlim(0, N/3) plt.ylim(0, 1); 你可以看到,如果有超过 50 人,那么生日相同的几率就接近 1。 为了使碰撞几率超过 50%,必须有多少人? 让我们看看我们能否找到这种情况发生的最少人数。 results.where('P(at least one match)', are.between(0.5, 0.51)) Trials P(all different) P(at least one match) 23 0.492703 0.507297 仅仅是 23 人,碰撞的可能性就大于不碰撞。 这让那些没有做计算的人感到惊讶,因此被称为生日悖论。 但事实上,它根本就没有任何矛盾或矛盾之处。 这与生日相同几率随着人数的增加而增长的方式有关。 我们已经完成了N = 365的计算,但如果 N 是其他数字,函数的增长有多快? 如果我们要在生日以外的案例中应用我们的结果,我们需要知道它。 为了解决这个问题,我们可以重新编写各种不同 N 值的代码,并查看输出告诉我们的这些值的结果。 但是使用数学更加高效和富有洞察力,这是我们将在下一节中做的事情。 指数近似 本节的目标是,了解当有 N 个散列值且 N 大于 n 时,至少有一次碰撞的几率,如何表现为个体数 n 的函数。 我们知道几率是: 虽然这给出了准确的几率公式,但它并不能让我们了解函数如何增长。让我们看看我们是否可以开发一个近似值,它的形式更简单,因此更容易学习。 近似中的主要步骤将在本课程中重复使用,因此我们将在这里详细介绍它们。 步骤 1:仅仅近似需要近似的项 虽然这看起来很明显,但值得注意的是,它可以节省大量不必要的操作。 我们正在尝试近似: 所以我们需要近似的所有东西,就是: 最后我们可以将 1 减去近似值。 换句话说,我们将近似P(no collision)。 步骤 2:使用对数将乘法变成加法 我们的公式是乘法,但使用加法要好得多。 对数函数可帮助我们将乘积变成和: 一旦我们有了log(P(no collision))的近似值,我们就可以使用指数将其转换为我们想要的近似值,即P(no collision)。 步骤 3:使用对数的性质 这通常是主要计算的步骤。 请记住对于较小的x,,其中符号 表示当x变为 0 时,双方的比例变为 1。对于较大的x,近似值可能不是很好,但无论如何让我们尝试一下。 根据前n-1个正整数的和的公式。 步骤 4:按需转换来完成近似 艰苦的工作已经完成,现在我们只需要清理干净。 第 3 步给了我们: 对两边取指数,我们得到: 最后: 现在你可以看到,作为人数的函数,为什么P(at least one collision)迅速上升。 记住 N 是固定的,n 在 1 和 N 之间变化。随着 n 增加,(n-1)n快速增加,基本上类似n^2。 所以-n2 / 2N快速下降,使得 迅速下降;这让 飞了起来。 值得注意的是,在整个计算中只有一个近似值:它在步骤 3 的中间,我们使用log(1 + x)~x表示较小的x。我们会在课程中多次遇到这个近似值。 近似值有多好 为了查看指数近似值与确切概率的相比如何,让我们在生日的背景下开展工作;如果你更喜欢不同的配置,你可以在代码中更改 N。 为了查看整个步骤序列,我们将重新进行精确计算并用一列近似值扩展它们。 我们将使用上述两者的更精细的近似。 N = 365 def p_no_match(n): individuals_array = np.arange(n) return np.prod((N - individuals_array)/N) trials = np.arange(1, N+1, 1) results = Table().with_column('Trials', trials) different = results.apply(p_no_match, 'Trials') results = results.with_columns( 'P(at least one match)', 1 - different, 'Exponential Approximation', 1 - np.e**( -(trials - 1)*trials/(2*N) ) ) results Trials P(at least one match) Exponential Approximation 1 0 0 2 0.00273973 0.00273598 3 0.00820417 0.00818549 4 0.0163559 0.016304 5 0.0271356 0.0270254 6 0.0404625 0.0402629 7 0.0562357 0.0559104 8 0.0743353 0.0738438 9 0.0946238 0.0939222 10 0.116948 0.115991 … (355 rows omitted) 前10个近似值看起来不错。 让我们来看看更多。 results.scatter('Trials') plt.xlim(0, N/3) plt.ylim(0, 1); 在这张图的尺度上,蓝点(精确值)与金点(我们的指数近似值)几乎没有区别。 你可以再次运行代码,使用不精确的近似法,它将(n-1)n替换为n^2,并看到近似值仍然很好。 我们从近似的第二种形式中学到,n 个指定值中至少有一次碰撞的几率,大致是 ,其中c是正的常数。 当我们稍后在课程中研究瑞利(Rayleigh)分布时,我们将再次遇到函数 。
来源:ApacheCN《Sklearn 与 TensorFlow 机器学习实用指南》翻译项目 译者:@Lisanaaa @y3534365 校对:@飞龙 和支持向量机一样, 决策树是一种多功能机器学习算法, 即可以执行分类任务也可以执行回归任务, 甚至包括多输出(multioutput)任务. 它是一种功能很强大的算法,可以对很复杂的数据集进行拟合。例如,在第二章中我们对加利福尼亚住房数据集使用决策树回归模型进行训练,就很好的拟合了数据集(实际上是过拟合)。 决策树也是随机森林的基本组成部分(见第 7 章),而随机森林是当今最强大的机器学习算法之一。 在本章中,我们将首先讨论如何使用决策树进行训练,可视化和预测。 然后我们会学习在 Scikit-learn 上面使用 CART 算法,并且探讨如何调整决策树让它可以用于执行回归任务。 最后,我们当然也需要讨论一下决策树目前存在的一些局限性。 阅读全文
五、探索性数据分析 原文:DS-100/textbook/notebooks/ch05 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 探索性数据分析是一种态度,一种灵活的状态,一种寻找那些我们认为不存在和存在的东西的心愿。 John Tukey 在探索性数据分析(EDA),也就是数据科学生命周期的第三步中,我们总结,展示和转换数据,以便更深入地理解它。 特别是,通过 EDA,我们发现数据中的潜在问题,并发现可用于进一步分析的趋势。 我们试图了解我们数据的以下属性: 结构:我们数据文件的格式。 粒度:每行和每列的精细程度。 范围:我们的数据有多么完整或不完整。 时间性:数据是不是当时的情况。 忠实度:数据捕捉“现实”有多好。 尽管我们分别介绍了数据清理和 EDA 来有助于组织本书,但在实践中,你经常会在两者之间切换。 例如,列的可视化可能会向你展示,应使用数据清理技术进行处理的格式错误的值。 考虑到这一点,我们回顾伯克利警察局的数据集来进行探索。 结构和连接 结构 数据集的结构指的是数据文件的“形状”。 基本上,这指的是输入数据的格式。例如,我们看到呼叫数据集是 CSV(逗号分隔值)文件: !head data/Berkeley_PD_-_Calls_for_Service.csv CASENO,OFFENSE,EVENTDT,EVENTTM,CVLEGEND,CVDOW,InDbDate,Block_Location,BLKADDR,City,State 17091420,BURGLARY AUTO,07/23/2017 12:00:00 AM,06:00,BURGLARY - VEHICLE,0,08/29/2017 08:28:05 AM,"2500 LE CONTE AVE Berkeley, CA (37.876965, -122.260544)",2500 LE CONTE AVE,Berkeley,CA 17020462,THEFT FROM PERSON,04/13/2017 12:00:00 AM,08:45,LARCENY,4,08/29/2017 08:28:00 AM,"2200 SHATTUCK AVE Berkeley, CA (37.869363, -122.268028)",2200 SHATTUCK AVE,Berkeley,CA 17050275,BURGLARY AUTO,08/24/2017 12:00:00 AM,18:30,BURGLARY - VEHICLE,4,08/29/2017 08:28:06 AM,"200 UNIVERSITY AVE Berkeley, CA (37.865491, -122.310065)",200 UNIVERSITY AVE,Berkeley,CA 另一方面,截停数据集是 JSON(JavaScript 对象表示法)文件。 # Show first and last 5 lines of file !head -n 5 data/stops.json !echo '...' !tail -n 5 data/stops.json { "meta" : { "view" : { "id" : "6e9j-pj9p", "name" : "Berkeley PD - Stop Data", ... , [ 31079, "C2B606ED-7872-4B0B-BC9B-4EF45149F34B", 31079, 1496269085, "932858", 1496269085, "932858", null, "2017-00024245", "2017-04-30T22:59:26", " UNIVERSITY AVE/6TH ST", "T", "BM2TWN; ", null, null ] , [ 31080, "8FADF18D-7FE9-441D-8709-7BFEABDACA7A", 31080, 1496269085, "932858", 1496269085, "932858", null, "2017-00024250", "2017-04-30T23:19:27", " UNIVERSITY AVE / WEST ST", "T", "HM4TCS; ", "37.8698757000001", "-122.286550846" ] , [ 31081, "F60BD2A4-8C47-4BE7-B1C6-4934BE9DF838", 31081, 1496269085, "932858", 1496269085, "932858", null, "2017-00024254", "2017-04-30T23:38:34", " CHANNING WAY / BOWDITCH ST", "1194", "AR; ", "37.867207539", "-122.256529377" ] ] } 当然,还有很多其他类型的数据格式。 以下是最常见格式的列表: 逗号分隔值(CSV)和制表符分隔值(TSV)。 这些文件包含由逗号(CSV)或制表符(\t,TSV)分隔的表格数据。 这些文件通常很容易处理,因为数据的输入格式与DataFrame类似。 JavaScript 对象表示法(JSON)。 这些文件包含嵌套字典格式的数据。 通常我们必须将整个文件读为 Python 字典,然后弄清楚如何从字典中为DataFrame提取字段。 可扩展标记语言(XML)或超文本标记语言(HTML)。 这些文件也包含嵌套格式的数据,例如: <?xml version="1.0" encoding="UTF-8"?> <note> <to>Tove</to> <from>Jani</from> <heading>Reminder</heading> <body>Don't forget me this weekend!</body> </note> 在后面的章节中,我们将使用 XPath 从这些类型的文件中提取数据。 日志数据。许多应用在运行时会以非结构化文本格式输出一些数据,例如: 2005-03-23 23:47:11,663 - sa - INFO - creating an instance of aux_module.Aux 2005-03-23 23:47:11,665 - sa.aux.Aux - INFO - creating an instance of Aux 2005-03-23 23:47:11,665 - sa - INFO - created an instance of aux_module.Aux 2005-03-23 23:47:11,668 - sa - INFO - calling aux_module.Aux.do_something 2005-03-23 23:47:11,668 - sa.aux.Aux - INFO - doing something 在后面的章节中,我们将使用正则表达式从这些类型的文件中提取数据。 连接(Join) 数据通常会分成多个表格。 例如,一张表可能描述一些人的个人信息,而另一张表可能包含他们的电子邮件: personal information while another will contain their emails: people = pd.DataFrame( [["Joey", "blue", 42, "M"], ["Weiwei", "blue", 50, "F"], ["Joey", "green", 8, "M"], ["Karina", "green", 7, "F"], ["Nhi", "blue", 3, "F"], ["Sam", "pink", -42, "M"]], columns = ["Name", "Color", "Number", "Sex"]) people Name Color Number Sex 0 Joey blue 42 1 Weiwei blue 50 2 Joey green 8 3 Karina green 7 4 Fernando pink -9 5 Nhi blue 3 6 Sam pink -42 email = pd.DataFrame( [["Deb", "deborah_nolan@berkeley.edu"], ["Sam", "samlau95@berkeley.edu"], ["John", "doe@nope.com"], ["Joey", "jegonzal@cs.berkeley.edu"], ["Weiwei", "weiwzhang@berkeley.edu"], ["Weiwei", "weiwzhang+123@berkeley.edu"], ["Karina", "kgoot@berkeley.edu"]], columns = ["User Name", "Email"]) email User Name Email 0 Deb 1 Sam 2 John 3 Joey 4 Weiwei 5 Weiwei 6 Karina 为了使每个人匹配他或她的电子邮件,我们可以在包含用户名的列上连接两个表。 然后,我们必须决定,如何处理出现在一张表上而没有在另一张表上的人。 例如,Fernando出现在people表中,但不出现在email表中。 我们有几种类型的连接,用于每个匹配缺失值的策略。 最常见的连接之一是内连接,其中任何不匹配的行都不放入最终结果中: # Fernando, Nhi, Deb, and John don't appear people.merge(email, how='inner', left_on='Name', right_on='User Name') Name Color Number Sex User Name Email 0 Joey blue 42 M Joey 1 Joey green 8 M Joey 2 Weiwei blue 50 F Weiwei 3 Weiwei blue 50 F Weiwei 4 Karina green 7 F Karina 5 Sam pink -42 M Sam 这是我们经常使用的四个基本连接:内连接,全连接(有时称为“外连接”),左连接和右连接。 以下是个图表,展示了这些类型的连接之间的区别。 运行下面的代码,并使用生成的下拉菜单,来展示people和email表格的四种不同的连接的结果。 注意对于外,左和右连接,哪些行包含了NaN值。 # HIDDEN def join_demo(join_type): display(HTML('people and email tables:')) display_two(people, email) display(HTML('<br>')) display(HTML('Joined table:')) display(people.merge(email, how=join_type, left_on='Name', right_on='User Name')) interact(join_demo, join_type=['inner', 'outer', 'left', 'right']); 结构检查清单 查看数据集的结构之后,你应该回答以下问题。我们将根据呼叫和截停数据集回答它们。 数据是标准格式还是编码过的? 标准格式包括: 表格数据:CSV,TSV,Excel,SQL 嵌套数据:JSON,XML 呼叫数据集采用 CSV 格式,而截停数据集采用 JSON 格式。 数据是组织为记录形式(例如行)的吗?如果不是,我们可以通过解析数据来定义记录吗? 呼叫数据集按行出现;我们从截停数据集中提取记录。 数据是否嵌套?如果是这样,我们是否可以适当地提取非嵌套的数据? 呼叫数据集不是嵌套的;我们不必过于费力从截停数据集中获取非嵌套的数据。 数据是否引用了其他数据?如果是这样,我们可以连接数据吗? 呼叫数据集引用了星期表。连接这两张表让我们知道数据集中每个事件的星期。截取数据集没有明显的引用。 每个记录中的字段(例如,列)是什么?每列的类型是什么? 呼叫和截停数据集的字段,在每个数据集的“数据清理”一节中介绍。 粒度 数据的粒度是数据中每条记录代表什么。 例如,在呼叫数据集中,每条记录代表一次警务呼叫。 # HIDDEN calls = pd.read_csv('data/calls.csv') calls.head() CASENO OFFENSE CVLEGEND BLKADDR EVENTDTTM Latitude Longitude Day 0 17091420 BURGLARY AUTO BURGLARY - VEHICLE 2500 LE CONTE AVE 2017-07-23 06:00:00 37.876965 -122.260544 1 17038302 BURGLARY AUTO BURGLARY - VEHICLE BOWDITCH STREET & CHANNING WAY 2017-07-02 22:00:00 37.867209 -122.256554 2 17049346 THEFT MISD. (UNDER $950) LARCENY 2900 CHANNING WAY 2017-08-20 23:20:00 37.867948 -122.250664 3 17091319 THEFT MISD. (UNDER $950) LARCENY 2100 RUSSELL ST 2017-07-09 04:15:00 37.856719 -122.266672 4 17044238 DISTURBANCE DISORDERLY CONDUCT TELEGRAPH AVENUE & DURANT AVE 2017-07-30 01:16:00 37.867816 -122.258994 在截停数据集中,每条记录代表一次警务截停事件。 # HIDDEN stops = pd.read_csv('data/stops.csv', parse_dates=[1], infer_datetime_format=True) stops.head() Incident Number Call Date/Time Location Incident Type Dispositions Location - Latitude Location - Longitude 0 2015-00004825 2015-01-26 00:10:00 SAN PABLO AVE / MARIN AVE T M NaN 1 2015-00004829 2015-01-26 00:50:00 SAN PABLO AVE / CHANNING WAY T M NaN 2 2015-00004831 2015-01-26 01:03:00 UNIVERSITY AVE / NINTH ST T M NaN 3 2015-00004848 2015-01-26 07:16:00 2000 BLOCK BERKELEY WAY 1194 BM4ICN NaN 4 2015-00004849 2015-01-26 07:43:00 1700 BLOCK SAN PABLO AVE 1194 BM4ICN NaN 另一方面,我们可能以下列格式收到接受数据: # HIDDEN (stops .groupby(stops['Call Date/Time'].dt.date) .size() .rename('Num Incidents') .to_frame() ) Num Incidents Call Date/Time 2015-01-26 2015-01-27 2015-01-28 … 2017-04-28 2017-04-29 2017-04-30 825 rows × 1 columns 在这种情况下,表格中的每个记录对应于单个日期而不是单个事件。 我们会将此表描述为,它具有比上述更粗的粒度。 了解数据的粒度非常重要,因为它决定了你可以执行哪种分析。 一般来说,细粒度由于粗粒度;虽然我们可以使用分组和旋转将细粒度变为粗粒度,但我们没有几个工具可以由粗到精。 粒度检查清单 查看数据集的粒度后,你应该回答以下问题。我们将根据呼叫和截停数据集回答他们。 一条记录代表了什么? 在呼叫数据集中,每条记录代表一次警务呼叫。在截停数据集中,每条记录代表一次警务截停事件。 所有记录的粒度是否在同一级别? (有时一个表格将包含汇总行。) 是的,对于呼叫和截停数据集是如此。 如果数据是聚合的,聚合是如何进行的?采样和平均是常见的聚合。 就有印象记住,在两个数据集中,位置都是输入为街区,而不是特定的地址。 我们可以对数据执行什么类型的聚合? 例如,随着时间的推移,将个体聚合为人口统计分组,或个体事件聚合为总数。 在这种情况下,我们可以聚合为不同的日期或时间粒度。例如,我们可以使用聚合,找到事件最常见的一天的某个小时。我们也可能能够按照事件地点聚合,来发现事件最多的伯克利地区。 范围 数据集的范围是指数据集的覆盖面,与我们有兴趣分析的东西相关。我们试图回答我们数据范围的以下问题: 数据是否涵盖了感兴趣的话题? 例如,呼叫和截停数据集包含在伯克利发生的呼叫和截停事件。然而,如果我们对加利福尼亚州的犯罪事件感兴趣,那么这些数据集的范围将会过于有限。 一般来说,较大的范围比较小的范围更有用,因为我们可以将较大的范围过滤为较小的范围,但通常不能从较小的范围转到较大的范围。例如,如果我们有美国的警务截停数据集,我们可以取数据集的子集,来调查伯克利。 请记住,范围是一个广义术语,并不总是用于描述地理位置。例如,它也可以指时间覆盖面 - 呼叫数据集仅包含 180 天的数据。 在调查数据生成的过程中,我们经常会处理数据集的范围,并在 EDA 期间确认数据集的范围。让我们来确认呼叫数据集的地理和时间范围。 calls CASENO OFFENSE CVLEGEND BLKADDR EVENTDTTM Latitude Longitude Day 0 17091420 BURGLARY AUTO BURGLARY - VEHICLE 2500 LE CONTE AVE 2017-07-23 06:00:00 37.876965 -122.260544 1 17038302 BURGLARY AUTO BURGLARY - VEHICLE BOWDITCH STREET & CHANNING WAY 2017-07-02 22:00:00 37.867209 -122.256554 2 17049346 THEFT MISD. (UNDER $950) LARCENY 2900 CHANNING WAY 2017-08-20 23:20:00 37.867948 -122.250664 … … … … … … … … 5505 17021604 IDENTITY THEFT FRAUD 100 MONTROSE RD 2017-03-31 00:00:00 37.896218 -122.270671 5506 17033201 DISTURBANCE DISORDERLY CONDUCT 2300 COLLEGE AVE 2017-06-09 22:34:00 37.868957 -122.254552 5507 17047247 BURGLARY AUTO BURGLARY - VEHICLE UNIVERSITY AVENUE & CHESTNUT ST 2017-08-11 20:00:00 37.869679 -122.288038 5508 rows × 8 columns # Shows earliest and latest dates in calls calls['EVENTDTTM'].dt.date.sort_values() ''' 1384 2017-03-02 1264 2017-03-02 1408 2017-03-02 ... 3516 2017-08-28 3409 2017-08-28 3631 2017-08-28 Name: EVENTDTTM, Length: 5508, dtype: object ''' calls['EVENTDTTM'].dt.date.max() - calls['EVENTDTTM'].dt.date.min() # datetime.timedelta(179) 该表格包含 179 天的时间段的数据,该时间段足够接近数据描述中的 180 天,我们可以假设 2017 年 4 月 14 日或 2017 年 8 月 29 日没有呼叫。 为了检查地理范围,我们可以使用地图: import folium # Use the Folium Javascript Map Library import folium.plugins SF_COORDINATES = (37.87, -122.28) sf_map = folium.Map(location=SF_COORDINATES, zoom_start=13) locs = calls[['Latitude', 'Longitude']].astype('float').dropna().as_matrix() heatmap = folium.plugins.HeatMap(locs.tolist(), radius = 10) sf_map.add_child(heatmap) 除少数例外情况外,呼叫数据集覆盖了伯克利地区。 我们可以看到,大多数警务呼叫发生在伯克利市中心和 UCB 校区的南部。 现在我们来确认截停数据集的时间和地理范围: stops Incident Number Call Date/Time Location Incident Type Dispositions Location - Latitude Location - Longitude 0 2015-00004825 2015-01-26 00:10:00 SAN PABLO AVE / MARIN AVE T M NaN 1 2015-00004829 2015-01-26 00:50:00 SAN PABLO AVE / CHANNING WAY T M NaN 2 2015-00004831 2015-01-26 01:03:00 UNIVERSITY AVE / NINTH ST T M NaN … … … … … … … 29205 2017-00024245 2017-04-30 22:59:26 UNIVERSITY AVE/6TH ST T BM2TWN NaN 29206 2017-00024250 2017-04-30 23:19:27 UNIVERSITY AVE / WEST ST T HM4TCS 37.869876 29207 2017-00024254 2017-04-30 23:38:34 CHANNING WAY / BOWDITCH ST 1194 AR 37.867208 29208 rows × 7 columns stops['Call Date/Time'].dt.date.sort_values() ''' 0 2015-01-26 25 2015-01-26 26 2015-01-26 ... 29175 2017-04-30 29177 2017-04-30 29207 2017-04-30 Name: Call Date/Time, Length: 29208, dtype: object ''' 如承诺的那样,数据收集工作从 2015 年 1 月 26 日开始。因为它在 2017 年 4 月 30 日起停止,数据似乎在 2017 年 5 月初左右下载。让我们绘制地图来查看地理数据: SF_COORDINATES = (37.87, -122.28) sf_map = folium.Map(location=SF_COORDINATES, zoom_start=13) locs = stops[['Location - Latitude', 'Location - Longitude']].astype('float').dropna().as_matrix() heatmap = folium.plugins.HeatMap(locs.tolist(), radius = 10) sf_map.add_child(heatmap) 我们可以证实,数据集中在伯克利发生的警务截停,以及大多数警务呼叫,都发生在伯克利市中心和伯克利西部地区。 时间性 时间性是指数据在时间上如何表示,特别是数据集中的日期和时间字段。我们试图通过这些字段来了解以下特征: 数据集中日期和时间字段的含义是什么? 在呼叫和截停数据集中,日期时间字段表示警务呼叫或截停的时间。然而,截停数据集最初还有一个日期时间字段,记录案件什么时候输入到数据库,我们在数据清理过程中将其移除,因为我们认为它不适用于分析。 另外,我们应该注意日期时间字段的时区和夏令时,特别是在处理来自多个位置的数据的时候。 日期和时间字段在数据中有什么表示形式? 虽然美国使用MM/DD/YYYY格式,但许多其他国家使用DD/MM/YYYY格式。仍有更多格式在世界各地使用,分析数据时认识到这些差异非常重要。 在呼叫和截停数据集中,日期显示为MM/DD/YYYY格式。 是否有奇怪的时间戳,它可能代表空值? 某些程序使用占位符而不是空值。例如,Excel 的默认日期是 1990 年 1 月 1 日,而 Mac 上的 Excel 则是 1904 年 1 月 1 日。许多应用将生成 1970 年 1 月 1 日 12:00 或 1969 年 12 月 31 日 11:59 pm 的默认日期时间,因为这是用于时间戳的 Unix 纪元。如果你在数据中注意到这些时间戳的多个实例,则应该谨慎并仔细检查数据源。 呼叫或截停数据集都不包含任何这些可疑值。 忠实度 如果我们相信它能准确捕捉现实,我们将数据集描述为“忠实的”。通常,不可信的数据集包含: 不切实际或不正确的值 例如,未来的日期,不存在的位置,负数或较大离群值。 明显违反的依赖关系 例如,个人的年龄和生日不匹配。 手动输入的数据 我们看到,这些通常充满了拼写错误和不一致。 明显的数据伪造迹象 例如,重复的名称,伪造的电子邮件地址,或重复使用不常见的名称或字段。 注意与数据清理的许多相似之处。 我们提到,我们经常在数据清理和 EDA 之间来回切换,特别是在确定数据忠实度的时候。 例如,可视化经常帮助我们识别数据中的奇怪条目。 calls = pd.read_csv('data/calls.csv') calls.head() CASENO OFFENSE EVENTDT EVENTTM … BLKADDR Latitude Longitude Day 0 17091420 BURGLARY AUTO 07/23/2017 12:00:00 AM 06:00 … 2500 LE CONTE AVE 37.876965 -122.260544 1 17038302 BURGLARY AUTO 07/02/2017 12:00:00 AM 22:00 … BOWDITCH STREET & CHANNING WAY 37.867209 -122.256554 2 17049346 THEFT MISD. (UNDER $950) 08/20/2017 12:00:00 AM 23:20 … 2900 CHANNING WAY 37.867948 -122.250664 3 17091319 THEFT MISD. (UNDER $950) 07/09/2017 12:00:00 AM 04:15 … 2100 RUSSELL ST 37.856719 -122.266672 4 17044238 DISTURBANCE 07/30/2017 12:00:00 AM 01:16 … TELEGRAPH AVENUE & DURANT AVE 37.867816 -122.258994 5 rows × 9 columns calls['CASENO'].plot.hist(bins=30) # <matplotlib.axes._subplots.AxesSubplot at 0x1a1ebb2898> 请注意17030000和17090000处的非预期的簇。通过绘制案例编号的分布,我们可以很快查看数据中的异常。 在这种情况下,我们可能会猜测,两个不同的警察团队为他们的呼叫使用不同的案件编号。 数据探索通常会发现异常情况;如果可以修复,我们可以使用数据清理技术。
三、处理表格数据 原文:DS-100/textbook/notebooks/ch03 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 索引、切片和排序 起步 在本章的每一节中,我们将使用第一章中的婴儿名称数据集。我们将提出一个问题,将问题分解为大体步骤,然后使用pandas DataFrame将每个步骤转换为 Python 代码。 我们从导入pandas开始: # pd is a common shorthand for pandas import pandas as pd 现在我们可以使用pd.read_csv读取数据。 baby = pd.read_csv('babynames.csv') baby Name Sex Count Year 0 Mary F 9217 1 Anna F 3860 2 Emma F 2587 … … … … 1891891 Verna M 5 1891892 Winnie M 5 1891893 Winthrop M 5 1891894 行 × 4 列 请注意,为了使上述代码正常工作,babynames.csv文件必须位于这个笔记本的相同目录中。 通过在笔记本单元格中运行ls,我们可以检查当前文件夹中的文件: ls # babynames.csv indexes_slicing_sorting.ipynb 当我们使用熊猫来读取数据时,我们得到一个DataFrame。 DataFrame是一个表格数据结构,其中每列都有标签(这里是'Name', 'Sex', 'Count', 'Year'),并且每一行都有标签(这里是0,1,2, ..., 1891893)。 然而,Data8 中引入的表格仅包含列标签。 DataFrame的标签称为DataFrame的索引,并使许多数据操作更容易。 索引、切片和排序 让我们使用pandas来回答以下问题: 2016 年的五个最受欢迎的婴儿名字是? 拆分问题 我们可以将这个问题分解成以下更简单的表格操作: 分割出 2016 年的行。 按照计数对行降序排序。 现在,我们可以在pandas中表达这些步骤。 使用.loc切片 为了选择DataFrame的子集,我们使用.loc切片语法。 第一个参数是行标签,第二个参数是列标签: baby Name Sex Count Year 0 Mary F 9217 1 Anna F 3860 2 Emma F 2587 … … … … 1891891 Verna M 5 1891892 Winnie M 5 1891893 Winthrop M 5 1891894 行 × 4 列 baby.loc[1, 'Name'] # Row labeled 1, Column labeled 'Name' # 'Anna' 要分割出多行或多列,我们可以使用:。 请注意.loc切片是包容性的,与 Python 的切片不同。 # Get rows 1 through 5, columns Name through Count inclusive baby.loc[1:5, 'Name':'Count'] Name Sex Count 1 Anna F 2 Emma F 3 Elizabeth F 4 Minnie F 5 Margaret F 我们通常需要DataFrame中的单个列: baby.loc[:, 'Year'] ''' 0 1884 1 1884 2 1884 ... 1891891 1883 1891892 1883 1891893 1883 Name: Year, Length: 1891894, dtype: int64 ''' 请注意,当我们选择一列时,我们会得到一个pandas序列。 序列就像一维 NumPy 数组,因为我们可以一次在所有元素上执行算术运算。 baby.loc[:, 'Year'] * 2 ''' 0 3768 1 3768 2 3768 ... 1891891 3766 1891892 3766 1891893 3766 Name: Year, Length: 1891894, dtype: int64 ''' 为了选择特定的列,我们可以将列表传递给.loc切片: # This is a DataFrame again baby.loc[:, ['Name', 'Year']] Name Year 0 Mary 1 Anna 2 Emma … … 1891891 Verna 1891892 Winnie 1891893 Winthrop 1891894 行 × 2 列 选择列很常见,所以存在简写。 # Shorthand for baby.loc[:, 'Name'] baby['Name'] ''' 0 Mary 1 Anna 2 Emma ... 1891891 Verna 1891892 Winnie 1891893 Winthrop Name: Name, Length: 1891894, dtype: object ''' # Shorthand for baby.loc[:, ['Name', 'Count']] baby[['Name', 'Count']] Name Count 0 Mary 1 Anna 2 Emma … … 1891891 Verna 1891892 Winnie 1891893 Winthrop 1891894 行 × 2 列 使用谓词对行切片 为了分割出 2016 年的行,我们将首先创建一个序列,其中每个想要保留的行为True,每个想要删除的行为False。 这很简单,因为序列上的数学和布尔运算符,应用于序列中的每个元素。 # Series of years baby['Year'] ''' 0 1884 1 1884 2 1884 ... 1891891 1883 1891892 1883 1891893 1883 Name: Year, Length: 1891894, dtype: int64 ''' # Compare each year with 2016 baby['Year'] == 2016 ''' 0 False 1 False 2 False ... 1891891 False 1891892 False 1891893 False Name: Year, Length: 1891894, dtype: bool ''' 一旦我们有了这个True和False的序列,我们就可以将它传递给.loc。 # We are slicing rows, so the boolean Series goes in the first # argument to .loc baby_2016 = baby.loc[baby['Year'] == 2016, :] baby_2016 Name Sex Count Year 1850880 Emma F 19414 1850881 Olivia F 19246 1850882 Ava F 16237 … … … … 1883745 Zyahir M 5 1883746 Zyel M 5 1883747 Zylyn M 5 32868 行 × 4 列 对行排序 下一步是按'Count'对行降序排序。 我们可以使用sort_values()函数。 sorted_2016 = baby_2016.sort_values('Count', ascending=False) sorted_2016 Name Sex Count Year 1850880 Emma F 19414 1850881 Olivia F 19246 1869637 Noah M 19015 … … … … 1868752 Mikaelyn F 5 1868751 Miette F 5 1883747 Zylyn M 5 32868 行 × 4 列 最后,我们将使用.iloc分割出DataFrame的前五行。 .iloc的工作方式类似.loc,但接受数字索引而不是标签。 它的切片中没有包含右边界,就像 Python 的列表切片。 # Get the value in the zeroth row, zeroth column sorted_2016.iloc[0, 0] # Get the first five rows sorted_2016.iloc[0:5] Name Sex Count Year 1850880 Emma F 19414 1850881 Olivia F 19246 1869637 Noah M 19015 1869638 Liam M 18138 1850882 Ava F 16237 总结 我们现在拥有了 2016 年的五个最受欢迎的婴儿名称,并且学会了在pandas中表达以下操作: 操作 pandas 读取 CSV 文件 pd.read_csv() 使用标签或索引来切片 .loc和.iloc 使用谓词对行切片 在.loc中使用布尔值的序列 对行排序 .sort_values() 分组和透视 在本节中,我们将回答这个问题: 每年最受欢迎的男性和女性名称是什么? 这里再次展示了婴儿名称数据集: baby = pd.read_csv('babynames.csv') baby.head() # the .head() method outputs the first five rows of the DataFrame Name Sex Count Year 0 Mary F 9217 1 Anna F 3860 2 Emma F 2587 3 Elizabeth F 2549 4 Minnie F 2243 拆分问题 我们应该首先注意到,上一节中的问题与这个问题有相似之处;上一节中的问题将名称限制为 2016 年出生的婴儿,而这个问题要求所有年份的名称。 我们再次将这个问题分解成更简单的表格操作。 将baby表按'Year'和'Sex'分组。 对于每一组,计算最流行的名称。 认识到每个问题需要哪种操作,有时很棘手。通常,一系列复杂的步骤会告诉你,可能有更简单的方式来表达你想要的东西。例如,如果我们没有立即意识到需要分组,我们可能会编写如下步骤: 遍历每个特定的年份。 对于每一年,遍历每个特定的性别。 对于每一个特定年份和性别,找到最常见的名字。 几乎总是有一种更好的替代方法,用于遍历pandas DataFrame。特别是,遍历DataFrame的特定值,通常应该替换为分组。 分组 为了在pandas中进行分组。 我们使用.groupby()方法。 baby.groupby('Year') # <pandas.core.groupby.DataFrameGroupBy object at 0x1a14e21f60> .groupby()返回一个奇怪的DataFrameGroupBy对象。 我们可以使用聚合函数,在该对象上调用.agg()来获得熟悉的输出: # The aggregation function takes in a series of values for each group # and outputs a single value def length(series): return len(series) # Count up number of values for each year. This is equivalent to # counting the number of rows where each year appears. baby.groupby('Year').agg(length) Name Sex Count Year 1880 2000 2000 1881 1935 1935 1882 2127 2127 … … … 2014 33206 33206 2015 33063 33063 2016 32868 32868 137 行 × 3 列 你可能会注意到length函数只是简单调用了len函数,所以我们可以简化上面的代码。 baby.groupby('Year').agg(len) Name Sex Count Year 1880 2000 2000 1881 1935 1935 1882 2127 2127 … … … 2014 33206 33206 2015 33063 33063 2016 32868 32868 137 行 × 3 列 聚合应用于DataFrame的每一列,从而产生冗余信息。 我们可以在分组之前使用切片限制输出列。 year_rows = baby[['Year', 'Count']].groupby('Year').agg(len) year_rows # A further shorthand to accomplish the same result: # # year_counts = baby[['Year', 'Count']].groupby('Year').count() # # pandas has shorthands for common aggregation functions, including # count, sum, and mean. Count Year 1880 1881 1882 … 2014 2015 2016 137 行 × 1 列 请注意,生成的DataFrame的索引现在包含特定年份,因此我们可以像以前一样,使用.loc分割出年份的子集: # Every twentieth year starting at 1880 year_rows.loc[1880:2016:20, :] Count Year 1880 1900 1920 1940 1960 1980 2000 多个列的分组 我们在 Data8 中看到,我们可以按照多个列分组,基于唯一值来获取分组。 为此,请将列标签列表传递到.groupby()。 grouped_counts = baby.groupby(['Year', 'Sex']).sum() grouped_counts Count Year Sex 1880 F M 110491 1881 F … … 2015 M 2016 F M 1880674 274 行 × 1 列 上面的代码计算每年每个性别出生的婴儿总数。 现在让我们使用多列分组,来计算每年和每个性别的最流行的名称。 由于数据已按照年和性别的递减顺序排序,因此我们可以定义一个聚合函数,该函数返回每个序列中的第一个值。 (如果数据没有排序,我们可以先调用sort_values()。) # The most popular name is simply the first one that appears in the series def most_popular(series): return series.iloc[0] baby_pop = baby.groupby(['Year', 'Sex']).agg(most_popular) baby_pop Name Count Year Sex 1880 F Mary M John 9655 1881 F Mary … … … 2015 M Noah 2016 F Emma M Noah 19015 274 行 × 2 列 注意,多列分组会导致每行有多个标签。 这被称为“多级索引”,并且很难处理。 需要知道的重要事情是,.loc接受行索引的元组,而不是单个值: baby_pop.loc[(2000, 'F'), 'Name'] # 'Emily' 但.iloc的行为与往常一样,因为它使用索引而不是标签: baby_pop.iloc[10:15, :] Name Count Year Sex 1885 F Mary M John 8756 1886 F Mary M John 9026 1887 F Mary 透视 如果按两列分组,则通常可以使用数据透视表,以更方便的格式显示数据。 数据透视表可以使用一组分组标签,作为结果表的列。 为了透视,使用pd.pivot_table()函数。 pd.pivot_table(baby, index='Year', # Index for rows columns='Sex', # Columns values='Name', # Values in table aggfunc=most_popular) # Aggregation function Sex F M Year 1880 Mary John 1881 Mary John 1882 Mary John … … … 2014 Emma Noah 2015 Emma Noah 2016 Emma Noah 137 行 × 2 列 将此结果与我们使用.groupby()计算的baby_pop表进行比较。 我们可以看到baby_pop中的Sex索引成为了数据透视表的列。 baby_pop Name Count Year Sex 1880 F Mary M John 9655 1881 F Mary … … … 2015 M Noah 2016 F Emma M Noah 19015 274 行 × 2 列 总结 我们现在有了数据集中每个性别和年份的最受欢迎的婴儿名称,并学会了在pandas中表达以下操作: 操作 pandas 分组 df.groupby(label) 多列分组 df.groupby([label1, label2]) 分组和聚合 df.groupby(label).agg(func) 透视 pd.pivot_table() 应用、字符串和绘图 在本节中,我们将回答这个问题: 我们可以用名字的最后一个字母来预测婴儿的性别吗? 这里再次展示了婴儿名称数据集: baby = pd.read_csv('babynames.csv') baby.head() # the .head() method outputs the first five rows of the DataFrame Name Sex Count Year 0 Mary F 9217 1 Anna F 3860 2 Emma F 2587 3 Elizabeth F 2549 4 Minnie F 2243 拆解问题 虽然有很多方法可以预测是否可能,但我们将在本节中使用绘图。 我们可以将这个问题分解为两个步骤: 计算每个名称的最后一个字母。 按照最后一个字母和性别分组,使用计数来聚合。 绘制每个性别和字母的计数。 应用 pandas序列包含.apply()方法,它接受一个函数并将其应用于序列中的每个值。 names = baby['Name'] names.apply(len) ''' 0 4 1 4 2 4 .. 1891891 5 1891892 6 1891893 8 Name: Name, Length: 1891894, dtype: int64 ''' 为了提取每个名字的最后一个字母,我们可以定义我们自己的函数来传入.apply(): def last_letter(string): return string[-1] names.apply(last_letter) ''' 0 y 1 a 2 a .. 1891891 a 1891892 e 1891893 p Name: Name, Length: 1891894, dtype: object ''' 字符串操作 虽然.apply()是灵活的,但在处理文本数据时,在使用pandas内置的字符串操作函数通常会更快。 pandas通过序列的.str属性,提供字符串操作函数。 names = baby['Name'] names.str.len() ''' 0 4 1 4 2 4 .. 1891891 5 1891892 6 1891893 8 Name: Name, Length: 1891894, dtype: int64 ''' 我们可以用类似的方式,直接分离出每个名字的最后一个字母。 names.str[-1] ''' 0 y 1 a 2 a .. 1891891 a 1891892 e 1891893 p Name: Name, Length: 1891894, dtype: object ''' 我们建议查看文档来获取字符串方法的完整列表。 我们现在可以将最后一个字母的这一列添加到我们的婴儿数据帧中。 baby['Last'] = names.str[-1] baby Name Sex Count Year Last 0 Mary F 9217 1884 1 Anna F 3860 1884 2 Emma F 2587 1884 … … … … … 1891891 Verna M 5 1883 1891892 Winnie M 5 1883 1891893 Winthrop M 5 1883 1891894 行 × 5 列 分组 为了计算每个最后一个字母的性别分布,我们需要按Last和Sex分组。 # Shorthand for baby.groupby(['Last', 'Sex']).agg(np.sum) baby.groupby(['Last', 'Sex']).sum() Count Year Last Sex a F 58079486 M 1931630 53566324 b F 17376 … … … y M 18569388 z F 142023 M 120123 9649274 52 行 × 2 列 请注意,因为每个没有用于分组的列都传递到聚合函数中,所以也求和了年份。 为避免这种情况,我们可以在调用.groupby()之前选择所需的列。 # When lines get long, you can wrap the entire expression in parentheses # and insert newlines before each method call letter_dist = ( baby[['Last', 'Sex', 'Count']] .groupby(['Last', 'Sex']) .sum() ) letter_dist Count Last Sex a F M 1931630 b F … … y M z F M 120123 52 行 × 1 列 绘图 pandas为大多数基本绘图提供了内置的绘图函数,包括条形图,直方图,折线图和散点图。 为了从DataFrame中绘制图形,请使用.plot属性: # We use the figsize option to make the plot larger letter_dist.plot.barh(figsize=(10, 10)) # <matplotlib.axes._subplots.AxesSubplot at 0x1a17af4780> 虽然这个绘图显示了字母和性别的分布,但是男性和女性的条形很难分开。 通过在pandas文档中查看绘图,我们了解到pandas将DataFrame的一行中的列绘制为一组条形,并将每列显示为不同颜色的条形。 这意味着letter_dist表的透视版本将具有正确的格式。 letter_pivot = pd.pivot_table( baby, index='Last', columns='Sex', values='Count', aggfunc='sum' ) letter_pivot Sex F M Last a 58079486 1931630 b 17376 1435939 c 30262 1672407 … … … x 37381 644092 y 24877638 18569388 z 142023 120123 26 行 × 2 列 letter_pivot.plot.barh(figsize=(10, 10)) # <matplotlib.axes._subplots.AxesSubplot at 0x1a17c36978> 请注意,pandas为我们生成了图例,这很方便 但是,这仍然难以解释。 我们为每个字母和性别绘制了计数,这些计数会导致一些条形看起来很长,而另一些几乎看不见。 相反,我们应该绘制每个最后一个字母的男性和女性的比例。 total_for_each_letter = letter_pivot['F'] + letter_pivot['M'] letter_pivot['F prop'] = letter_pivot['F'] / total_for_each_letter letter_pivot['M prop'] = letter_pivot['M'] / total_for_each_letter letter_pivot Sex F M F prop M prop Last a 58079486 1931630 0.967812 0.032188 b 17376 1435939 0.011956 0.988044 c 30262 1672407 0.017773 0.982227 … … … … … x 37381 644092 0.054853 0.945147 y 24877638 18569388 0.572597 0.427403 z 142023 120123 0.541771 0.458229 26 行 × 4 列 (letter_pivot[['F prop', 'M prop']] .sort_values('M prop') # Sorting orders the plotted bars .plot.barh(figsize=(10, 10)) ) # <matplotlib.axes._subplots.AxesSubplot at 0x1a18194b70> 总结 我们可以看到几乎所有以'p'结尾的名字都是男性,以'a'结尾的名字都是女性! 一般来说,许多字母的条形长度之间的差异意味着,如果我们只知道他们的名字的最后一个字母,我们往往可以准确猜测一个人的性别。 我们已经学会在pandas中表达以下操作: 操作 pandas 逐元素应用函数 series.apply(func) 字符串操作 series.str.func() 绘图 df.plot.func()
JavaScript 编程精解 中文第三版 原书:Eloquent JavaScript 3rd edition 译者:飞龙 自豪地采用谷歌翻译 部分参考了《JavaScript 编程精解(第 2 版)》 在线阅读 PDF格式 EPUB格式 MOBI格式 代码仓库 赞助我 协议 CC BY-NC-SA 4.0
二十一、项目:技能分享网站 原文:Project: Skill-Sharing Website 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 部分参考了《JavaScript 编程精解(第 2 版)》 If you have knowledge, let others light their candles at it. Margaret Fuller 技能分享会是一个活动,其中兴趣相同的人聚在一起,针对他们所知的事情进行小型非正式的展示。在园艺技能分享会上,可以解释如何耕作芹菜。如果在编程技能分享小组中,你可以顺便给每个人讲讲 Node.js。 在计算机领域中,这类聚会往往名为用户小组,是开阔眼界、了解行业新动态或仅仅接触兴趣相同的人的好方法。许多大城市都会有 JavaScript 聚会。这类聚会往往是可以免费参加的,而且我发现我参加过的那些聚会都非常友好热情。 在最后的项目章节中,我们的目标是建立网站,管理特定技能分享会的讨论内容。假设一个小组的人会在成员办公室中定期举办关于独轮车的聚会。上一个组织者搬到了另一个城市,并且没人可以站出来接下来他的任务。我们需要一个系统,让参与者可以在系统中发言并相互讨论,这样就不需要一个中心组织人员了。 就像上一章一样,本章中的一些代码是为 Node.js 编写的,并且直接在你正在查看的 HTML页面中运行它不太可行。 该项目的完整代码可以从eloquentjavascript.net/code/skillsharing.zip下载。 设计 本项目的服务器部分为 Node.js 编写,客户端部分则为浏览器编写。服务器存储系统数据并将其提供给客户端。它也提供实现客户端系统的文件。 服务器保存了为下次聚会提出的对话列表。每个对话包括参与人员姓名、标题和该对话的相关评论。客户端允许用户提出新的对话(将对话添加到列表中)、删除对话和评论已存在的对话。每当用户做了修改时,客户端会向服务器发送关于更改的 HTTP 请求。 我们创建应用来展示一个实时视图,来展示目前已经提出的对话和评论。每当某些人在某些地点提交了新的对话或添加新评论时,所有在浏览器中打开页面的人都应该立即看到变化。这个特性略有挑战,网络服务器无法建立到客户端的连接,也没有好方法来知道有哪些客户端现在在查看特定网站。 该问题的一个解决方案叫作长时间轮询,这恰巧是 Node 的设计动机之一。 长轮询 为了能够立即提示客户端某些信息发生了改变,我们需要建立到客户端的连接。由于通常浏览器无法接受连接,而且客户端通常在路由后面,它无论如何都会拒绝这类连接,因此由服务器初始化连接是不切实际的。 我们可以安排客户端来打开连接并保持该连接,因此服务器可以使用该连接在必要时传送信息。 但 HTTP 请求只是简单的信息流:客户端发送请求,服务器返回一条响应,就是这样。有一种名为 WebSocket 的技术,受到现代浏览器的支持,是的我们可以建立连接并进行任意的数据交换。但如何正确运用这项技术是较为复杂的。 本章我们将会使用一种相对简单的技术:长轮询(Long Polling)。客户端会连续使用定时的 HTTP 请求向服务器询问新信息,而当没有新信息需要报告时服务器会简单地推迟响应。 只要客户端确保其可以持续不断地建立轮询请求,就可以在信息可用之后,从服务器快速地接收到信息。例如,若 Fatma 在浏览器中打开了技能分享程序,浏览器会发送请求询问是否有更新,且等待请求的响应。当 Iman 在自己的浏览器中提交了关于“极限降滑独轮车”的对话之后。服务器发现 Fatma 在等待更新请求,并将新的对话作为响应发送给待处理的请求。Fatma 的浏览器将会接收到数据并更新屏幕展示对话内容。 为了防止连接超时(因为连接一定时间不活跃后会被中断),长轮询技术常常为每个请求设置一个最大等待时间,只要超过了这个时间,即使没人有任何需要报告的信息也会返回响应,在此之后,客户端会建立一个新的请求。定期重新发送请求也使得这种技术更具鲁棒性,允许客户端从临时的连接失败或服务器问题中恢复。 使用了长轮询技术的繁忙的服务器,可以有成百上千个等待的请求,因此也就有这么多个 TCP 连接处于打开状态。Node简化了多连接的管理工作,而不是建立单独线程来控制每个连接,这对这样的系统是非常合适的。 HTTP 接口 在我们设计服务器或客户端的代码之前,让我们先来思考一下两者均会涉及的一点:双方通信的 HTTP 接口。 我们会使用 JSON 作为请求和响应正文的格式,就像第二十章中的文件服务器一样,我们尝试充分利用 HTTP 方法。所有接口均以/talks路径为中心。不以/talks开头的路径则用于提供静态文件服务,即用于实现客户端系统的 HTML 和 JavaScript 代码。 访问/talks的GET请求会返回如下所示的 JSON 文档。 [{"title": "Unituning", "presenter": "Jamal", "summary": "Modifying your cycle for extra style", "comment": []}] 我们可以发送PUT请求到类似于/talks/Unituning之类的 URL 上来创建新对话,在第二个斜杠后的那部分是对话的名称。PUT请求正文应当包含一个 JSON 对象,其中有一个presenter属性和一个summary属性。 因为对话标题可以包含空格和其他无法正常出现在 URL 中的字符,因此我们必须使用encodeURIComponent函数来编码标题字符串,并构建 URL。 console.log("/talks/" + encodeURIComponent("How to Idle")); // → /talks/How%20to%20Idle 下面这个请求用于创建关于“空转”的对话。 PUT /talks/How%20to%20Idle HTTP/1.1 Content-Type: application/json Content-Length: 92 {"presenter": "Maureen", "summary": "Standing still on a unicycle"} 我们也可以使用GET请求通过这些 URL 获取对话的 JSON 数据,或使用DELETE请求通过这些 URL 删除对话。 为了在对话中添加一条评论,可以向诸如/talks/Unituning/comments的 URL 发送POST请求,JSON 正文包含author属性和message属性。 POST /talks/Unituning/comments HTTP/1.1 Content-Type: application/json Content-Length: 72 {"author": "Iman", "message": "Will you talk about raising a cycle?"} 为了支持长轮询,如果没有新的信息可用,发送到/talks的GET请求可能会包含额外的标题,通知服务器延迟响应。 我们将使用通常用于管理缓存的一对协议头:ETag和If-None-Match。 服务器可能在响应中包含ETag(“实体标签”)协议头。 它的值是标识资源当前版本的字符串。 当客户稍后再次请求该资源时,可以通过包含一个If-None-Match头来进行条件请求,该头的值保存相同的字符串。 如果资源没有改变,服务器将响应状态码 304,这意味着“未修改”,告诉客户端它的缓存版本仍然是最新的。 当标签与服务器不匹配时,服务器正常响应。 我们需要这样的东西,通过它客户端可以告诉服务器它有哪个版本的对话列表,仅当列表发生变化时,服务器才会响应。 但服务器不是立即返回 304 响应,它应该停止响应,并且仅当有新东西的可用,或已经过去了给定的时间时才返回。 为了将长轮询请求与常规条件请求区分开来,我们给他们另一个标头Prefer: wait=90,告诉服务器客户端最多等待 90 秒的响应。 服务器将保留版本号,每次对话更改时更新,并将其用作ETag值。 客户端可以在对话变更时通知此类要求: GET /talks HTTP/1.1 If-None-Match: "4" Prefer: wait=90 (time passes) HTTP/1.1 200 OK Content-Type: application/json ETag: "5" Content-Length: 295 [....] 这里描述的协议并没有任何访问控制。每个人都可以评论、修改对话或删除对话。因为因特网中充满了流氓,因此将这类没有进一步保护的系统放在网络上最后可能并不是很好。 服务器 让我们开始构建程序的服务器部分。本节的代码可以在 Node.js 中执行。 路由 我们的服务器会使用createServer来启动 HTTP 服务器。在处理新请求的函数中,我们必须区分我们支持的请求的类型(根据方法和路径确定)。我们可以使用一长串的if语句完成该任务,但还存在一种更优雅的方式。 路由可以作为帮助把请求调度传给能处理该请求的函数。路径匹配正则表达式/^\/talks\/([^\/]+)$/(/talks/带着对话名称)的PUT请求,应当由指定函数处理。此外,路由可以帮助我们提取路径中有意义的部分,在本例中会将对话的标题(包裹在正则表达式的括号之中)传递给处理器函数。 在 NPM 中有许多优秀的路由包,但这里我们自己编写一个路由来展示其原理。 这里给出router.js,我们随后将在服务器模块中使用require获取该模块。 const {parse} = require("url"); module.exports = class Router { constructor() { this.routes = []; } add(method, url, handler) { this.routes.push({method, url, handler}); } resolve(context, request) { let path = parse(request.url).pathname; for (let {method, url, handler} of this.routes) { let match = url.exec(path); if (!match || request.method != method) continue; let urlParts = match.slice(1).map(decodeURIComponent); return handler(context, ...urlParts, request); } return null; } }; 该模块导出Router类。我们可以使用路由对象的add方法来注册一个新的处理器,并使用resolve方法解析请求。 找到处理器之后,后者会返回一个响应,否则为null。它会逐个尝试路由(根据定义顺序排序),当找到一个匹配的路由时返回true。 路由会使用context值调用处理器函数(这里是服务器实例),将请求对象中的字符串,与已定义分组中的正则表达式匹配。传递给处理器的字符串必须进行 URL 解码,因为原始 URL 中可能包含%20风格的代码。 文件服务 当请求无法匹配路由中定义的任何请求类型时,服务器必须将其解释为请求位于public目录下的某个文件。服务器可以使用第二十章中定义的文件服务器来提供文件服务,但我们并不需要也不想对文件支持 PUT 和 DELETE 请求,且我们想支持类似于缓存等高级特性。因此让我们使用 NPM 中更为可靠且经过充分测试的静态文件服务器。 我选择了ecstatic。它并不是 NPM 中唯一的此类服务,但它能够完美工作且符合我们的意图。ecstatic模块导出了一个函数,我们可以调用该函数,并传递一个配置对象来生成一个请求处理函数。我们使用root选项告知服务器文件搜索位置。 const {createServer} = require("http"); const Router = require("./router"); const ecstatic = require("ecstatic"); const router = new Router(); const defaultHeaders = {"Content-Type": "text/plain"}; class SkillShareServer { constructor(talks) { this.talks = talks; this.version = 0; this.waiting = []; let fileServer = ecstatic({root: "./public"}); this.server = createServer((request, response) => { let resolved = router.resolve(this, request); if (resolved) { resolved.catch(error => { if (error.status != null) return error; return {body: String(error), status: 500}; }).then(({body, status = 200, headers = defaultHeaders}) => { response.writeHead(status, headers); response.end(body); }); } else { fileServer(request, response); } }); } start(port) { this.server.listen(port); } stop() { this.server.close(); } } 它使用上一章中的文件服务器的类似约定来处理响应 - 处理器返回Promise,可解析为描述响应的对象。 它将服务器包装在一个对象中,它也维护它的状态。 作为资源的对话 已提出的对话存储在服务器的talks属性中,这是一个对象,属性名称是对话标题。这些对话会展现为/talks/[title]下的 HTTP 资源,因此我们需要将处理器添加我们的路由中供客户端选择,来实现不同的方法。 获取(GET)单个对话的请求处理器,必须查找对话并使用对话的 JSON 数据作为响应,若不存在则返回 404 错误响应码。 const talkPath = /^\/talks\/([^\/]+)$/; router.add("GET", talkPath, async (server, title) => { if (title in server.talks) { return {body: JSON.stringify(server.talks[title]), headers: {"Content-Type": "application/json"}}; } else { return {status: 404, body: `No talk '${title}' found`}; } }); 删除对话时,将其从talks对象中删除即可。 router.add("DELETE", talkPath, async (server, title) => { if (title in server.talks) { delete server.talks[title]; server.updated(); } return {status: 204}; }); 我们将在稍后定义updated方法,它通知等待有关更改的长轮询请求。 为了获取请求正文的内容,我们定义一个名为readStream的函数,从可读流中读取所有内容,并返回解析为字符串的Promise。 function readStream(stream) { return new Promise((resolve, reject) => { let data = ""; stream.on("error", reject); stream.on("data", chunk => data += chunk.toString()); stream.on("end", () => resolve(data)); }); } 需要读取响应正文的函数是PUT的处理器,用户使用它创建新对话。该函数需要检查数据中是否有presenter和summary属性,这些属性都是字符串。任何来自外部的数据都可能是无意义的,我们不希望错误请求到达时会破坏我们的内部数据模型,或者导致服务崩溃。 若数据看起来合法,处理器会将对话转化为对象,存储在talks对象中,如果有标题相同的对话存在则覆盖,并再次调用updated。 router.add("PUT", talkPath, async (server, title, request) => { let requestBody = await readStream(request); let talk; try { talk = JSON.parse(requestBody); } catch (_) { return {status: 400, body: "Invalid JSON"}; } if (!talk || typeof talk.presenter != "string" || typeof talk.summary != "string") { return {status: 400, body: "Bad talk data"}; } server.talks[title] = {title, presenter: talk.presenter, summary: talk.summary, comments: []}; server.updated(); return {status: 204}; }); 在对话中添加评论也是类似的。我们使用readStream来获取请求内容,验证请求数据,若看上去合法,则将其存储为评论。 router.add("POST", /^\/talks\/([^\/]+)\/comments$/, async (server, title, request) => { let requestBody = await readStream(request); let comment; try { comment = JSON.parse(requestBody); } catch (_) { return {status: 400, body: "Invalid JSON"}; } if (!comment || typeof comment.author != "string" || typeof comment.message != "string") { return {status: 400, body: "Bad comment data"}; } else if (title in server.talks) { server.talks[title].comments.push(comment); server.updated(); return {status: 204}; } else { return {status: 404, body: `No talk '${title}' found`}; } }); 尝试向不存在的对话中添加评论会返回 404 错误。 长轮询支持 服务器中最值得探讨的方面是处理长轮询的部分代码。当 URL 为/talks的GET请求到来时,它可能是一个常规请求或一个长轮询请求。 我们可能在很多地方,将对话列表发送给客户端,因此我们首先定义一个简单的辅助函数,它构建这样一个数组,并在响应中包含ETag协议头。 SkillShareServer.prototype.talkResponse = function() { let talks = []; for (let title of Object.keys(this.talks)) { talks.push(this.talks[title]); } return { body: JSON.stringify(talks), headers: {"Content-Type": "application/json", "ETag": `"${this.version}"`} }; }; 处理器本身需要查看请求头,来查看是否存在If-None-Match和Prefer标头。 Node 在其小写名称下存储协议头,根据规定其名称是不区分大小写的。 router.add("GET", /^\/talks$/, async (server, request) => { let tag = /"(.*)"/.exec(request.headers["if-none-match"]); let wait = /\bwait=(\d+)/.exec(request.headers["prefer"]); if (!tag || tag[1] != server.version) { return server.talkResponse(); } else if (!wait) { return {status: 304}; } else { return server.waitForChanges(Number(wait[1])); } }); 如果没有给出标签,或者给出的标签与服务器的当前版本不匹配,则处理器使用对话列表来响应。 如果请求是有条件的,并且对话没有变化,我们查阅Prefer标题来查看,是否应该延迟响应或立即响应。 用于延迟请求的回调函数存储在服务器的waiting数组中,以便在发生事件时通知它们。 waitForChanges方法也会立即设置一个定时器,当请求等待了足够长时,以 304 状态来响应。 SkillShareServer.prototype.waitForChanges = function(time) { return new Promise(resolve => { this.waiting.push(resolve); setTimeout(() => { if (!this.waiting.includes(resolve)) return; this.waiting = this.waiting.filter(r => r != resolve); resolve({status: 304}); }, time * 1000); }); }; 使用updated注册一个更改,会增加version属性并唤醒所有等待的请求。 var changes = []; SkillShareServer.prototype.updated = function() { this.version++; let response = this.talkResponse(); this.waiting.forEach(resolve => resolve(response)); this.waiting = []; }; 服务器代码这样就完成了。 如果我们创建一个SkillShareServer的实例,并在端口 8000 上启动它,那么生成的 HTTP 服务器,将服务于public子目录中的文件,以及/ talksURL 下的一个对话管理界面。 new SkillShareServer(Object.create(null)).start(8000); 客户端 技能分享网站的客户端部分由三个文件组成:微型 HTML 页面、样式表以及 JavaScript 文件。 HTML 在网络服务器提供文件服务时,有一种广为使用的约定是:当请求直接访问与目录对应的路径时,返回名为index.html的文件。我们使用的文件服务模块ecstatic就支持这种约定。当请求路径为/时,服务器会搜索文件./public/index.html(./public是我们赋予的根目录),若文件存在则返回文件。 因此,若我们希望浏览器指向我们服务器时展示某个特定页面,我们将其放在public/index.html中。这就是我们的index文件。 <!doctype html> <meta charset="utf-8"> <title>Skill Sharing</title> <link rel="stylesheet" href="skillsharing.css"> <h1>Skill Sharing</h1> <script src="skillsharing_client.js"></script> 它定义了文档标题并包含一个样式表,除了其它东西,它定义了几种样式,确保对话之间有一定的空间。 最后,它在页面顶部添加标题,并加载包含客户端应用的脚本。 动作 应用状态由对话列表和用户名称组成,我们将它存储在一个{talks, user}对象中。 我们不允许用户界面直接操作状态或发送 HTTP 请求。 反之,它可能会触发动作,它描述用户正在尝试做什么。 function handleAction(state, action) { if (action.type == "setUser") { localStorage.setItem("userName", action.user); return Object.assign({}, state, {user: action.user}); } else if (action.type == "setTalks") { return Object.assign({}, state, {talks: action.talks}); } else if (action.type == "newTalk") { fetchOK(talkURL(action.title), { method: "PUT", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ presenter: state.user, summary: action.summary }) }).catch(reportError); } else if (action.type == "deleteTalk") { fetchOK(talkURL(action.talk), {method: "DELETE"}) .catch(reportError); } else if (action.type == "newComment") { fetchOK(talkURL(action.talk) + "/comments", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ author: state.user, message: action.message }) }).catch(reportError); } return state; } 我们将用户的名字存储在localStorage中,以便在页面加载时恢复。 需要涉及服务器的操作使用fetch,将网络请求发送到前面描述的 HTTP 接口。 我们使用包装函数fetchOK,它确保当服务器返回错误代码时,拒绝返回的Promise。 function fetchOK(url, options) { return fetch(url, options).then(response => { if (response.status < 400) return response; else throw new Error(response.statusText); }); } 这个辅助函数用于为某个对话,使用给定标题建立 URL。 function talkURL(title) { return "talks/" + encodeURIComponent(title); } 当请求失败时,我们不希望我们的页面丝毫不变,不给予任何提示。因此我们定义一个函数,名为reportError,至少在发生错误时向用户展示一个对话框。 function reportError(error) { alert(String(error)); } 渲染组件 我们将使用一个方法,类似于我们在第十九章中所见,将应用拆分为组件。 但由于某些组件不需要更新,或者在更新时总是完全重新绘制,所以我们不将它们定义为类,而是直接返回 DOM 节点的函数。 例如,下面是一个组件,显示用户可以向它输入名称的字段的: function renderUserField(name, dispatch) { return elt("label", {}, "Your name: ", elt("input", { type: "text", value: name, onchange(event) { dispatch({type: "setUser", user: event.target.value}); } })); } 用于构建 DOM 元素的elt函数是我们在第十九章中使用的函数。 类似的函数用于渲染对话,包括评论列表和添加新评论的表单。 function renderTalk(talk, dispatch) { return elt( "section", {className: "talk"}, elt("h2", null, talk.title, " ", elt("button", { type: "button", onclick() { dispatch({type: "deleteTalk", talk: talk.title}); } }, "Delete")), elt("div", null, "by ", elt("strong", null, talk.presenter)), elt("p", null, talk.summary), ...talk.comments.map(renderComment), elt("form", { onsubmit(event) { event.preventDefault(); let form = event.target; dispatch({type: "newComment", talk: talk.title, message: form.elements.comment.value}); form.reset(); } }, elt("input", {type: "text", name: "comment"}), " ", elt("button", {type: "submit"}, "Add comment"))); } submit事件处理器调用form.reset,在创建"newComment"动作后清除表单的内容。 在创建适度复杂的 DOM 片段时,这种编程风格开始显得相当混乱。 有一个广泛使用的(非标准的)JavaScript 扩展叫做 JSX,它允许你直接在你的脚本中编写 HTML,这可以使这样的代码更漂亮(取决于你认为漂亮是什么)。 在实际运行这种代码之前,必须在脚本上运行一个程序,将伪 HTML 转换为 JavaScript 函数调用,就像我们在这里用的东西。 评论更容易渲染。 function renderComment(comment) { return elt("p", {className: "comment"}, elt("strong", null, comment.author), ": ", comment.message); } 最后,用户可以使用表单创建新对话,它渲染为这样。 function renderTalkForm(dispatch) { let title = elt("input", {type: "text"}); let summary = elt("input", {type: "text"}); return elt("form", { onsubmit(event) { event.preventDefault(); dispatch({type: "newTalk", title: title.value, summary: summary.value}); event.target.reset(); } }, elt("h3", null, "Submit a Talk"), elt("label", null, "Title: ", title), elt("label", null, "Summary: ", summary), elt("button", {type: "submit"}, "Submit")); } 轮询 为了启动应用,我们需要对话的当前列表。 由于初始加载与长轮询过程密切相关 – 轮询时必须使用来自加载的ETag – 我们将编写一个函数来不断轮询服务器的/ talks,并且在新的对话集可用时,调用回调函数。 async function pollTalks(update) { let tag = undefined; for (;;) { let response; try { response = await fetchOK("/talks", { headers: tag && {"If-None-Match": tag, "Prefer": "wait=90"} }); } catch (e) { console.log("Request failed: " + e); await new Promise(resolve => setTimeout(resolve, 500)); continue; } if (response.status == 304) continue; tag = response.headers.get("ETag"); update(await response.json()); } } 这是一个async函数,因此循环和等待请求更容易。 它运行一个无限循环,每次迭代中,通常检索对话列表。或者,如果这不是第一个请求,则带有使其成为长轮询请求的协议头。 当请求失败时,函数会等待一会儿,然后再次尝试。 这样,如果你的网络连接断了一段时间然后又恢复,应用可以恢复并继续更新。 通过setTimeout解析的Promise,是强制async函数等待的方法。 当服务器回复 304 响应时,这意味着长轮询请求超时,所以函数应该立即启动下一个请求。 如果响应是普通的 200 响应,它的正文将当做 JSON 而读取并传递给回调函数,并且它的ETag协议头的值为下一次迭代而存储。 应用 以下组件将整个用户界面结合在一起。 class SkillShareApp { constructor(state, dispatch) { this.dispatch = dispatch; this.talkDOM = elt("div", {className: "talks"}); this.dom = elt("div", null, renderUserField(state.user, dispatch), this.talkDOM, renderTalkForm(dispatch)); this.setState(state); } setState(state) { if (state.talks != this.talks) { this.talkDOM.textContent = ""; for (let talk of state.talks) { this.talkDOM.appendChild( renderTalk(talk, this.dispatch)); } this.talks = state.talks; } } } 当对话改变时,这个组件重新绘制所有这些组件。 这很简单,但也是浪费。 我们将在练习中回顾一下。 我们可以像这样启动应用: function runApp() { let user = localStorage.getItem("userName") || "Anon"; let state, app; function dispatch(action) { state = handleAction(state, action); app.setState(state); } pollTalks(talks => { if (!app) { state = {user, talks}; app = new SkillShareApp(state, dispatch); document.body.appendChild(app.dom); } else { dispatch({type: "setTalks", talks}); } }).catch(reportError); } runApp(); 若你执行服务器并同时为localhost:8000/打开两个浏览器窗口,你可以看到在一个窗口中执行动作时,另一个窗口中会立即做出反应。 习题 下面的习题涉及修改本章中定义的系统。为了使用该系统进行工作,请确保首先下载代码,安装了 Node,并使用npm install安装了项目的所有依赖。 磁盘持久化 技能分享服务只将数据存储在内存中。这就意味着当服务崩溃或以为任何原因重启时,所有的对话和评论都会丢失。 扩展服务使得其将对话数据存储到磁盘上,并在程序重启时自动重新加载数据。不要担心效率,只要用最简单的代码让其可以工作即可。 重置评论字段 由于我们常常无法在 DOM 节点中找到唯一替换的位置,因此整批地重绘对话是个很好的工作机制。但这里有个例外,若你开始在对话的评论字段中输入一些文字,而在另一个窗口向同一条对话添加了一条评论,那么第一个窗口中的字段就会被重绘,会移除掉其内容和焦点。 在激烈的讨论中,多人同时添加评论,这将是非常烦人的。 你能想出办法解决它吗?
零、前言 原文:Introduction 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 部分参考了《JavaScript 编程精解(第 2 版)》 We think we are creating the system for our own purposes. We believe we are making it in our own image… But the computer is not really like us. It is a projection of a very slim part of ourselves: that portion devoted to logic, order, rule, and clarity. Ellen Ullman,《Close to the Machine: Technophilia and its Discontents》 这是一本关于指导电脑的书。时至今日,计算机就像螺丝刀一样随处可见,但相比于螺丝刀而言,计算机更复杂一些,并且,让他们做你想让他们做的事情,并不总是那么容易。 如果你让计算机执行的任务是常见的,易于理解的任务,例如向你显示你的电子邮件,或像计算器一样工作,则可以打开相应的应用并开始工作。 但对于独特的或开放式的任务,应用可能不存在。 这就是编程可能出现的地方。编程是构建一个程序的行为 - 它是一组精确的指令,告诉计算机做什么。 由于计算机是愚蠢的,迂腐的野兽,编程从根本上是乏味和令人沮丧的。 幸运的是,如果你可以克服这个事实,并且甚至可以享受愚蠢机器可以处理的严谨思维,那么编程可以是非常有益的。 它可以让你在几秒钟内完成手动操作。 这是一种方法,让你的电脑工具去做它以前做不到的事情。 它也提供了抽象思维的优秀练习。 大多数编程都是用编程语言完成的。 编程语言是一种人工构建的语言,用于指导计算机。 有趣的是,我们发现与电脑沟通的最有效的方式,与我们彼此沟通的方式相差太大。 与人类语言一样,计算机语言可以以新的方式组合词语和词组,从而可以表达新的概念。 在某种程度上,基于语言的界面,例如 80 年代和 90 年代的 BASIC 和 DOS 提示符,是与计算机交互的主要方法。 这些已经在很大程度上被视觉界面取代,这些视觉界面更容易学习,但提供更少的自由。 计算机语言仍然存在,如果你知道在哪里看到。 每种现代 Web 浏览器都内置了一种这样的语言,即 JavaScript,因此几乎可以在所有设备上使用。 本书将试图让你足够了解这门语言,从而完成有用和有趣的东西。 关于程序设计 除了讲解 JavaScript 之外,本书也会介绍一些程序设计的基本原则。程序设计还是比较复杂的。编程的基本规则简单清晰,但在这些基本规则之上构建的程序却容易变得复杂,导致程序产生了自己的规则和复杂性。即便程序是按照你自己的思路去构建的,你也有可能迷失在代码之间。 在阅读本书时,你有可能会觉得书中的概念难以理解。如果你刚刚开始学习编程,那么你估计还有不少东西需要掌握呢。如果你想将所学知识融会贯通,那么就需要去多参考和学习一些资料。 是否付出必要的努力完全取决于你自己。当你阅读本书的时候发现任何难点,千万不要轻易就对自己的能力下结论。只要能坚持下去,你就是好样的。稍做休息,复习一下所学的知识点,始终确保自己阅读并理解了示例程序和相关的练习。学习是一项艰巨的任务,但你掌握的所有知识都属于你自己,而且今后的学习道路会愈加轻松。 当行动无利可图时,就收集信息;当信息无利可图时,就休息。 Ursula K. Le Guin,《The Left Hand of Darkness》 一个程序有很多含义:它是开发人员编写的一段文本、计算机执行的一段指令集合、计算机内存当中的数据以及控制内存中数据的操作集合。我们通常很难将程序与我们日常生活中熟悉的事物进行对比。有一种表面上比较恰当的比喻,即将程序视作包含许多组件的机器,为了让机器正常工作,这些组件通过内部通信来实现整个机器的正常运转。 计算机是一台物理机器,充当这些非物质机器的载体。计算机本身并不能实现多么复杂的功能,但计算机之所以有用是因为它们的运算速度非常快。而程序的作用就是将这些看似简单的动作组合起来,然后实现复杂的功能。 程序是思想的结晶。编写程序不需要什么物质投入,它很轻量级,通过我们的双手创造。 但如果不稍加注意,程序的体积和复杂度就会失去控制,甚至代码的编写者也会感到迷惑。在可控的范围内编写程序是编程过程中首要解决的问题。当程序运行时,一切都是那么美好。编程的精粹就在于如何更好地控制复杂度。质量高的程序的复杂度都不会太高。 很多开发人员认为,控制程序复杂度的最好方法就是避免使用不熟悉的技术。他们制定了严格的规则(“最佳实践”),并小心翼翼地呆在他们安全区内。 这不仅无聊,而且也是无效的。新问题往往需要新的解决方案。编程领域还很年轻,仍然在迅速发展,并且多样到足以为各种不同的方法留出空间。在程序设计中有许多可怕的错误,你应该继续犯错,以便你能理解它们。好的程序看起来是什么样的感觉,是在实践中发展的,而不是从一系列规则中学到的。 为什么编语言重要 在计算技术发展伊始,并没有编程语言这个概念。程序看起来就像这样: 00110001 00000000 00000000 00110001 00000001 00000001 00110011 00000001 00000010 01010001 00001011 00000010 00100010 00000010 00001000 01000011 00000001 00000000 01000001 00000001 00000001 00010000 00000010 00000000 01100010 00000000 00000000 该程序计算数字 1~10 之和,并打印出结果:1+2+...+10=55。该程序可以运行在一个简单的机器上。在早期计算机上编程时,我们需要在正确的位置设置大量开关阵列,或在纸带上穿孔并将纸带输入计算机中。你可以想象这个过程是多么冗长乏味且易于出错。即便是编写非常简单的程序,也需要有经验的人耗费很大精力才能完成。编写复杂的程序则更是难上加难。 当然了,手动输入这些晦涩难懂的位序列(1 和 0)来编写程序的确能让程序员感到很有成就感,而且能给你的职业带来极大的满足感。 在上面的程序中,每行都包含一条指令。我们可以用中文来描述这些指令: 将数字 0 存储在内存地址中的位置 0。 将数字 1 存储在内存地址的位置 1。 将内存地址的位置 1 中的值存储在内存地址的位置 2。 将内存地址的位置 2 中的值减去数字 11。 如果内存地址的位置 2 中的值是 0,则跳转到指令 9。 将内存地址的位置 1 中的值加到内存地址的位置 0。 将内存地址的位置 1 中的值加上数字 1。 跳转到指令 3。 输出内存地址的位置 0 中的值。 虽说这已经比一大堆位序列要好读了许多,但仍然不清晰。使用名称而不是数字用于指令和存储位置有所帮助: Set “total” to 0. Set “count” to 1. [loop] Set “compare” to “count”. Subtract 11 from “compare”. If “compare” is zero, continue at [end]. Add “count” to “total”. Add 1 to “count”. Continue at [loop]. [end] Output “total”. 现在你能看出该程序是如何工作的吗?前两行代码初始化两个内存位置的值:total用于保存累加计算结果,而count则用于记录当前数字。你可能觉得compare的那行代码看起来有些奇怪。程序想根据count是否等于 11 来决定是否应该停止运行。因为我们的机器相当原始,所以只能测试一个数字是否为 0,并根据它做出决策。因此程序用名为compare的内存位置存放count–11的值,并根据该值是否为 0 决定是否跳转。接下来两行将count的值累加到结果上,并将count加 1,直到count等于11为止。 下面使用 JavaScript 重新编写了上面的程序: let total = 0, count = 1; while (count <= 10) { total += count; count += 1; } console.log(total); // → 55 这个版本的程序得到了一些改进。更为重要的是,我们再也不需要指定程序如何来回跳转了,而是由while结构负责完成这个任务。只要我们给予的条件成立,while语句就会不停地执行其下方的语句块(包裹在大括号中)。而我们给予的条件是count<=10,意思是“count小于等于 10”。我们再也不需要创建临时的值并将其与 0 比较,那样的代码十分烦琐。编程语言的一项职责就是,能够帮助我们处理这些烦琐无趣的逻辑。 在程序的结尾,也就是while语句结束后,我们使用console.log操作来输出结果。 最后,我们恰好有range和sum这类方便的操作。下面代码中的range函数用于创建数字集合,sum函数用于计算数字集合之和: console.log(sum(range(1, 10))); // → 55 我们可以从这里了解到,同一个程序的长度可长可短,可读性可高可低。第一个版本的程序晦涩难懂,而最后一个版本的程序则接近于人类语言的表达方式:将 1~10 范围内的数字之和记录下来(我们会在后面的章节中详细介绍如何编写sum和range这样的函数)。 优秀的编程语言可以为开发人员提供更高层次的抽象,使用类似于人类语言的方式来与计算机进行交互。它有助于省略细节,提供便捷的积木(比如while和console.log),允许你定义自己的积木(比如sum和range函数),并使这些积木易于编写。。 什么是 JavaScript JavaScript 诞生于 1995 年。起初,Netscape Navigator 浏览器将其运用在网页上添加程序。自此以后,各类主流图形网页浏览器均采用了 JavaScript。JavaScript 使得现代网页应用程序成为可能 —— 使用 JavaScript 可以直接与用户交互,从而避免每一个动作都需要重新载入页面。但有许多传统网站也会使用 JavaScript 来提供实时交互以及更加智能的表单功能。 JavaScript 其实和名为Java的程序设计语言没有任何关系。起了这么一个相似的名字完全是市场考虑使然,这并非是一个明智的决定。当 JavaScript 出现时,Java 语言已在市场上得到大力推广且拥有了极高人气,因此某些人觉得依附于 Java 的成功是个不错的主意。而我们现在已经无法摆脱这个名字了。 在 JavaScript 被广泛采用之后,ECMA 国际制订了一份标准文档来描述 JavaScript 的工作行为,以便所有声称支持 JavaScript 的软件都使用同一种语言。标准化完成后,该标准被称为 ECMAScript 标准。实际上,术语 ECMAScript 和 JavaScript 可以交换使用。它们不过是同一种语言的两个名字而已。 许多人会说 JavaScript 语言的坏话。这其中有很多这样的言论都是正确的。当被要求第一次使用 JavaScript 编写代码时,我当时就觉得这门语言难以驾驭。JavaScript 接受我输入的任何代码,但是又使用和我的想法完全不同的方式来解释代码。由于我没有任何线索知道我之前做了什么,因此我需要做出更多工作,但这也就存在一个实际问题:我们可以自由使用 JavaScript,而这种自由却几乎没有限度。这种设计其实是希望初学者更容易使用 JavaScript 编写程序。但实际上,系统不会指出我们错在何处,因此从程序中找出问题变得更加棘手。 但这种自由性也有其优势,许多技术在更为严格的语言中不可能实现,而在 JavaScript 中则留下了实现的余地,正如你看到的那样(比如第十章),有些优势可以弥补 JavaScript 的一些缺点。在正确地学习 JavaScript 并使用它工作了一段时间后,我真正喜欢上了 JavaScript。 JavaScript 版本众多。大约在 2000~2010 年间,这正是 JavaScript 飞速发展的时期,浏览器支持最多的是 ECMAScript 3。在此期间,ECMA 着手制定 ECMAScript 4,这是一个雄心勃勃的版本,ECMA 计划在这个版本中加入许多彻底的改进与扩展。但由于 ECMAScript 3 被广泛使用,这种过于激进的修改必然会遭遇重重阻碍,最后 ECMA 不得不于 2008 年放弃了版本 4 的制定。这就产生了不那么雄心勃勃的版本 5,这只是一些没有争议的改进,出现在 2009 年。 然后版本 6 在 2015 年诞生,这是一个重大的更新,其中包括计划用于版本 4 的一些想法。从那以后,每年都会有新的更新。 语言不断发展的事实意味着,浏览器必须不断跟上,如果你使用的是较老的浏览器,它可能不支持每个特性。 语言设计师会注意,不要做任何可能破坏现有程序的改变,所以新的浏览器仍然可以运行旧的程序。 在本书中,我使用的是 2017 版的 JavaScript。 Web 浏览器并不是唯一一个可以运行 JavaScript 的平台。有些数据库,比如 MongoDB 和 CouchDB,也使用 JavaScript 作为脚本语言和查询语言。一些桌面和服务器开发的平台,特别是 Node.js 项目(第二十章介绍),为浏览器以外的 JavaScript 编程提供了一个环境。 代码及相关工作 代码是程序的文本内容。本书多数章节都介绍了大量代码。我相信阅读代码和编写代码是学习编程不可或缺的部分。尝试不要仅仅看一眼示例,而应该认真阅读并理解每个示例。刚开始使用这种方式可能会速度较慢并为代码所困惑,但我坚信你很快就可以熟能生巧。对待习题的方法也应该一样。除非你确实已经编写代码解决了问题,否则不要假设你已经理解了问题。 建议读者应尝试在实际的 JavaScript 解释器中执行习题代码。这样一来,你就可以马上获知代码工作情况的反馈,而且我希望读者去做更多的试验,而不仅仅局限于习题的要求。 可以在 http://eloquentjavascript.net/ 中查阅本书的在线版本,并运行和实验本书中的代码。也可以在在线版本中点击任何代码示例来编辑、运行并查看其产生的输出。在做习题时,你可以访问 http://eloquentjavascript.net/code/,该网址会提供每个习题的初始代码,让你专心于解答习题。 如果想要在本书提供的沙箱以外执行本书代码,需要稍加注意。许多的示例是独立的,而且可以在任何 JavaScript 环境下运行。但后续章节的代码大多数都是为特定环境(浏览器或者 Node.js)编写的,而且只能在这些特定环境下执行代码。此外,许多章节定义了更大的程序,这些章节中出现的代码片段会互相依赖或是依赖于一些外部文件。本书网站的沙箱提供了 zip 压缩文件的链接,该文件包含了所有运行特定章节代码所需的脚本和数据文件。 本书概览 本书包括三个部分。前十二章讨论 JavaScript 语言本身的一些特性。接下来的 8 章讨论网页浏览器和 JavaScript 在网页编程中的实践。最后两章专门讲解另一个使用 JavaScript 编程的环境 —— Node.js。 纵观本书,共有 5 个项目实战章,用于讲解规模较大的示例程序,你可以通过这些章来仔细品味真实的编程过程。根据项目出现次序,我们会陆续构建递送机器人(7)、程序设计语言(12)、平台游戏(16)、像素绘图程序(19)和一个动态网站(21)。 本书介绍编程语言时,首先使用4章来介绍 JavaScript 语言的基本结构,包括第二章控制结构(比如在本前言中看到的while单词)、第三章函数(编写你自己的积木)和第四章数据结构。此后你就可以编写简单的程序了。接下来,第五章和第六章介绍函数和对象的运用技术,以编写更加抽象的代码并以此来控制复杂度。 介绍完第一个项目实战(7)之后,将会继续讲解语言部分,例如第八章错误处理和 bug 修复、第九章正则表达式(处理文本数据的重要工具)、第十章模块化(解决复杂度的问题)以及第十一章异步编程(处理需要时间的事件)。第二个项目实战章节(12)则是对本书第一部分的总结。 第二部分(第十三章到第十九章),阐述了浏览器 JavaScript 中的一些工具。你将会学到在屏幕上显示某些元素的方法(第十四章与第十七章),响应用户输入的方法(第十五章)和通过网络通信的方法(第十八章)。这部分又有两个项目实战章节。 此后,第二十章阐述 Node.js,而第二十一章使用该工具构建一个简单的网页系统。 本书版式约定 本书中存在大量代码,程序(包括你迄今为止看到的一些示例)代码的字体如下所示: function factorial(n) { if (n == 0) { return 1; } else { return factorial(n - 1) * n; } } 为了展示程序产生的输出,本书常在代码后编写代码期望输出,输出结果前会加上两个反斜杠和一个箭头。 console.log(factorial(8)); // → 40320 祝好运!
十六、项目:平台游戏 原文:Project: A Platform Game 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 部分参考了《JavaScript 编程精解(第 2 版)》 所有现实都是游戏。 Iain Banks,《The Player of Games》 我最初对电脑的痴迷,就像许多小孩一样,与电脑游戏有关。我沉迷在那个计算机所模拟出的小小世界中,我可以操纵这个世界,我同时也沉迷在那些尚未展开的故事之中。但我沉迷其中并不是因为游戏实际描述的故事,而是因为我可以充分发挥我的想象力,去构思故事的发展。 我并不希望任何人把编写游戏作为自己的事业。就像音乐产业中,那些希望加入这个行业的热忱年轻人与实际的人才需求之间存在巨大的鸿沟,也因此产生了一个极不健康的就业环境。不过,把编写游戏作为乐趣还是相当不错的。 本章将会介绍如何实现一个小型平台游戏。平台游戏(或者叫作“跳爬”游戏)要求玩家操纵一个角色在世界中移动,这种游戏往往是二维的,而且采用单一侧面作为观察视角,玩家可以来回跳跃。 游戏 我们游戏大致基于由 Thomas Palef 开发的 Dark Blue。我之所以选择了这个游戏,是因为这个游戏既有趣又简单,而且不需要编写大量代码。该游戏看起来如下页图所示。 黑色的方块表示玩家,玩家任务是收集黄色的方块(硬币),同时避免碰到红色素材(“岩浆”)。当玩家收集完所有硬币后就可以过关。 玩家可以使用左右方向键移动,并使用上方向键跳跃。跳跃正是这个游戏角色的特长。玩家可以跳跃到数倍于自己身高的地方,也可以在半空中改变方向。虽然这样不切实际,但这有助于玩家感觉自己在直接控制屏幕上那个自己的化身。 该游戏包含一个固定的背景,使用网格方式进行布局,可可移动元素则覆盖在背景之上。网格中的元素可能是空气、固体或岩浆。可可移动元素是玩家、硬币或者某一块岩浆。这些元素的位置不限于网格,它们的坐标可以是分数,允许平滑运动。 实现技术 我们会使用浏览器的 DOM 来展示游戏界面,我们会通过处理按键事件来读取用户输入。 与屏幕和键盘相关的代码只是实现游戏代码中的很小一部分。由于所有元素都只是彩色方块,因此绘制方法并不复杂。我们为每个元素创建对应的 DOM 元素,并使用样式来为其指定背景颜色、尺寸和位置。 由于背景是由不会改变的方块组成的网格,因此我们可以使用表格来展示背景。自由可移动元素可以使用绝对定位元素来覆盖。 游戏和某些程序应该在不产生明显延迟的情况下绘制动画并响应用户输入,性能是非常重要的。尽管 DOM 最初并非为高性能绘图而设计,但实际上 DOM 的性能表现得比我们想象中要好得多。读者已经在第 13 章中看过一些动画,在现代机器中,即使我们不怎么考虑性能优化,像这种简单的游戏也可以流畅运行。 在下一章中,我们会研究另一种浏览器技术 —— <canvas>标签。该标签提供了一种更为传统的图像绘制方式,直接处理形状和像素而非 DOM 元素。 关卡 我们需要一种人类可读的、可编辑的方法来指定关卡。因为一切最开始都可以在网格,所以我们可以使用大型字符串,其中每个字符代表一个元素,要么是背景网格的一部分,要么是可移动元素。 小型关卡的平面图可能是这样的: var simpleLevelPlan = ` ...................... ..#................#.. ..#..............=.#.. ..#.........o.o....#.. ..#.@......#####...#.. ..#####............#.. ......#++++++++++++#.. ......##############.. ......................`; 句号是空的位置,井号(#)字符是墙,加号是岩浆。玩家的起始位置是 AT 符号(@)。每个O字符都是一枚硬币,等号(=)是一块来回水平移动的岩浆块。 我们支持两种额外的可移动岩浆:管道符号(|)表示垂直移动的岩浆块,而v表示下落的岩浆块 —— 这种岩浆块也是垂直移动,但不会来回弹跳,只会向下移动,直到遇到地面才会直接回到其起始位置。 整个游戏包含了许多关卡,玩家必须完成所有关卡。每关的过关条件是玩家需要收集所有硬币。如果玩家碰到岩浆,当前关卡会恢复初始状态,而玩家可以再次尝试过关。 读取关卡 下面的类存储了关卡对象。它的参数应该是定义关卡的字符串。 class Level { constructor(plan) { let rows = plan.trim().split("\n").map(l => [...l]); this.height = rows.length; this.width = rows[0].length; this.startActors = []; this.rows = rows.map((row, y) => { return row.map((ch, x) => { let type = levelChars[ch]; if (typeof type == "string") return type; this.startActors.push( type.create(new Vec(x, y), ch)); return "empty"; }); }); } } trim方法用于移除平面图字符串起始和终止处的空白。这允许我们的示例平面图以换行开始,以便所有行都在彼此的正下方。其余的字符串由换行符拆分,每一行扩展到一个数组中,生成了字符数组。 因此,rows包含字符数组、平面图的行。我们可以从中得出水平宽度和高度。但是我们仍然必须将可移动元素与背景网格分开。我们将其称为角色(Actor)。它们将存储在一个对象数组中。背景将是字符串的数组的数组,持有字段类型,如"empty","wall",或"lava"。 为了创建这些数组,我们在行上映射,然后在它们的内容上进行映射。请记住,map将数组索引作为第二个参数传递给映射函数,它告诉我们给定字符的x和y坐标。游戏中的位置将存储为一对坐标,左上角为0, 0,并且每个背景方块为 1 单位高和宽。 为了解释平面图中的字符,Level构造器使用levelChars对象,它将背景元素映射为字符串,角色字符映射为类。当type是一个角色类时,它的create静态方法用于创建一个对象,该对象被添加到startActors,映射函数为这个背景方块返回"empty"。 角色的位置存储为一个Vec对象,它是二维向量,一个具有x和y属性的对象,像第六章一样。 当游戏运行时,角色将停在不同的地方,甚至完全消失(就像硬币被收集时)。我们将使用一个State类来跟踪正在运行的游戏的状态。 class State { constructor(level, actors, status) { this.level = level; this.actors = actors; this.status = status; } static start(level) { return new State(level, level.startActors, "playing"); } get player() { return this.actors.find(a => a.type == "player"); } } 当游戏结束时,status属性将切换为"lost"或"won"。 这又是一个持久性数据结构,更新游戏状态会创建新状态,并使旧状态保持完整。 角色 角色对象表示,游戏中给定可移动元素的当前位置和状态。所有的角色对象都遵循相同的接口。它们的pos属性保存元素的左上角坐标,它们的size属性保存其大小。 然后,他们有update方法,用于计算给定时间步长之后,他们的新状态和位置。它模拟了角色所做的事情:响应箭头键并且移动,因岩浆而来回弹跳,并返回新的更新后的角色对象。 type属性包含一个字符串,该字符串指定了角色类型:"player","coin"或者"lava"。这在绘制游戏时是有用的,为角色绘制的矩形的外观基于其类型。 角色类有一个静态的create方法,它由Level构造器使用,用于从关卡平面图中的字符中,创建一个角色。它接受字符本身及其坐标,这是必需的,因为Lava类处理几个不同的字符。 这是我们将用于二维值的Vec类,例如角色的位置和大小。 class Vec { constructor(x, y) { this.x = x; this.y = y; } plus(other) { return new Vec(this.x + other.x, this.y + other.y); } times(factor) { return new Vec(this.x * factor, this.y * factor); } } times方法用给定的数字来缩放向量。当我们需要将速度向量乘时间间隔,来获得那个时间的行走距离时,这就有用了。 不同类型的角色拥有他们自己的类,因为他们的行为非常不同。让我们定义这些类。稍后我们将看看他们的update方法。 玩家类拥有speed属性,存储了当前速度,来模拟动量和重力。 class Player { constructor(pos, speed) { this.pos = pos; this.speed = speed; } get type() { return "player"; } static create(pos) { return new Player(pos.plus(new Vec(0, -0.5)), new Vec(0, 0)); } } Player.prototype.size = new Vec(0.8, 1.5); 因为玩家高度是一个半格子,因此其初始位置相比于@字符出现的位置要高出半个格子。这样一来,玩家角色的底部就可以和其出现的方格底部对齐。 size属性对于Player的所有实例都是相同的,因此我们将其存储在原型上,而不是实例本身。我们可以使用一个类似type的读取器,但是每次读取属性时,都会创建并返回一个新的Vec对象,这将是浪费的。(字符串是不可变的,不必在每次求值时重新创建。) 构造Lava角色时,我们需要根据它所基于的字符来初始化对象。动态岩浆以其当前速度移动,直到它碰到障碍物。这个时候,如果它拥有reset属性,它会跳回到它的起始位置(滴落)。如果没有,它会反转它的速度并以另一个方向继续(弹跳)。 create方法查看Level构造器传递的字符,并创建适当的岩浆角色。 class Lava { constructor(pos, speed, reset) { this.pos = pos; this.speed = speed; this.reset = reset; } get type() { return "lava"; } static create(pos, ch) { if (ch == "=") { return new Lava(pos, new Vec(2, 0)); } else if (ch == "|") { return new Lava(pos, new Vec(0, 2)); } else if (ch == "v") { return new Lava(pos, new Vec(0, 3), pos); } } } Lava.prototype.size = new Vec(1, 1); Coin对象相对简单,大多时候只需要待在原地即可。但为了使游戏更加有趣,我们让硬币轻微摇晃,也就是会在垂直方向上小幅度来回移动。每个硬币对象都存储了其基本位置,同时使用wobble属性跟踪图像跳动幅度。这两个属性同时决定了硬币的实际位置(存储在pos属性中)。 class Coin { constructor(pos, basePos, wobble) { this.pos = pos; this.basePos = basePos; this.wobble = wobble; } get type() { return "coin"; } static create(pos) { let basePos = pos.plus(new Vec(0.2, 0.1)); return new Coin(basePos, basePos, Math.random() * Math.PI * 2); } } Coin.prototype.size = new Vec(0.6, 0.6); 第十四章中,我们知道了Math.sin可以计算出圆的y坐标。因为我们沿着圆移动,因此y坐标会以平滑的波浪形式来回移动,正弦函数在实现波浪形移动中非常实用。 为了避免出现所有硬币同时上下移动,每个硬币的初始阶段都是随机的。由Math.sin产生的波长是2π。我们可以将Math.random的返回值乘以2π,计算出硬币波形轨迹的初始位置。 现在我们可以定义levelChars对象,它将平面图字符映射为背景网格类型,或角色类。 const levelChars = { ".": "empty", "#": "wall", "+": "lava", "@": Player, "o": Coin, "=": Lava, "|": Lava, "v": Lava }; 这给了我们创建Level实例所需的所有部件。 let simpleLevel = new Level(simpleLevelPlan); console.log(`${simpleLevel.width} by ${simpleLevel.height}`); // → 22 by 9 上面一段代码的任务是将特定关卡显示在屏幕上,并构建关卡中的时间与动作。 成为负担的封装 本章中大多数代码并没有过多考虑封装。首先,封装需要耗费额外精力。封装使得程序变得更加庞大,而且会引入额外的概念和接口。我尽量将程序的体积控制在较小的范围之内,避免读者因为代码过于庞大而走神。 其次,游戏中的大量元素是紧密耦合在一起的,如果其中一个元素行为改变,其他的元素很有可能也会发生变化。我们需要根据游戏的工作细节来为元素之间设计大量接口。这使得接口的效果不是很好。每当你改变系统中的某一部分时,由于其他部分的接口可能没有考虑到新的情况,因此你需要关心这一修改是否会影响到其他部分的代码。 系统中的某些分割点可以通过严格的接口对系统进行合理的划分,但某些分割点则不是如此。尝试去封装某些本没有合理边界的代码必然会导致浪费大量精力。当你犯下这种大错之际,你就会注意到你的接口变得庞大臃肿,而且随着程序不断演化,你需要频繁修改这些接口。 我们会封装的一部分代码是绘图子系统。其原因是我们会在下一章中使用另一种方式来展示相同的游戏。通过将绘图代码隐藏在接口之后,我们可以在下一章中使用相同的游戏程序,只需要插入新的显示模块即可。 绘图 我们通过定义一个“显示器”对象来封装绘图代码,该对象显示指定关卡,以及状态。本章定义的显示器类型名为DOMDisplay,因为该类型使用简单的 DOM 元素来显示关卡。 我们会使用样式表来设定实际的颜色以及其他构建游戏中所需的固定的属性。创建这些属性时,我们可以直接对元素的style属性进行赋值,但这会使得游戏代码变得冗长。 下面的帮助函数提供了一种简洁的方法,来创建元素并赋予它一些属性和子节点: function elt(name, attrs, ...children) { let dom = document.createElement(name); for (let attr of Object.keys(attrs)) { dom.setAttribute(attr, attrs[attr]); } for (let child of children) { dom.appendChild(child); } return dom; } 我们创建显示器对象时需要指定其父元素,显示器将会创建在该父元素上,同时还需指定一个关卡对象。 class DOMDisplay { constructor(parent, level) { this.dom = elt("div", {class: "game"}, drawGrid(level)); this.actorLayer = null; parent.appendChild(this.dom); } clear() { this.dom.remove(); } } 由于关卡的背景网格不会改变,因此只需要绘制一次即可。角色则需要在每次刷新显示时进行重绘。drawFame需要使用actorLayer属性来跟踪已保存角色的动作,因此我们可以轻松移除或替换这些角色。 我们的坐标和尺寸以网格单元为单位跟踪,也就是说尺寸或距离中的 1 单元表示一个单元格。在设置像素级尺寸时,我们需要将坐标按比例放大,如果游戏中的所有元素只占据一个方格中的一个像素,那将是多么可笑。而scale绑定会给出一个单元格在屏幕上实际占据的像素数目。 const scale = 20; function drawGrid(level) { return elt("table", { class: "background", style: `width: ${level.width * scale}px` }, ...level.rows.map(row => elt("tr", {style: `height: ${scale}px`}, ...row.map(type => elt("td", {class: type}))) )); } 前文提及过,我们使用<table>元素来绘制背景。这非常符合关卡中grid属性的结构。网格中的每一行对应表格中的一行(<tr>元素)。网格中的每个字符串对应表格单元格(<td>)元素的类型名。扩展(三点)运算符用于将子节点数组作为单独的参数传给elt。 下面的 CSS 使表格看起来像我们想要的背景: .background { background: rgb(52, 166, 251); table-layout: fixed; border-spacing: 0; } .background td { padding: 0; } .lava { background: rgb(255, 100, 100); } .wall { background: white; } 其中某些属性(border-spacing和padding)用于取消一些我们不想保留的表格默认行为。我们不希望在单元格之间或单元格内部填充多余的空白。 其中background规则用于设置背景颜色。CSS中可以使用两种方式来指定颜色,一种方法是使用单词(white),另一种方法是使用形如rgb(R,G,B)的格式,其中R表示颜色中的红色成分,G表示绿色成分,B表示蓝色成分,每个数字范围均为 0 到 255。因此在rgb(52,166,251)中,红色成分为 52,绿色为 166,而蓝色是 251。由于蓝色成分数值最大,因此最后的颜色会偏向蓝色。而你可以看到.lava规则中,第一个数字(红色)是最大的。 我们绘制每个角色时需要创建其对应的 DOM 元素,并根据角色属性来设置元素坐标与尺寸。这些值都需要与scale相乘,以将游戏中的尺寸单位转换为像素。 function drawActors(actors) { return elt("div", {}, ...actors.map(actor => { let rect = elt("div", {class: `actor ${actor.type}`}); rect.style.width = `${actor.size.x * scale}px`; rect.style.height = `${actor.size.y * scale}px`; rect.style.left = `${actor.pos.x * scale}px`; rect.style.top = `${actor.pos.y * scale}px`; return rect; })); } 为了赋予一个元素多个类别,我们使用空格来分隔类名。在下面展示的 CSS 代码中,actor类会赋予角色一个绝对坐标。我们将角色的类型名称作为额外的 CSS 类来设置这些元素的颜色。我们并没有再次定义lava类,因为我们可以直接复用前文为岩浆单元格定义的规则。 .actor { position: absolute; } .coin { background: rgb(241, 229, 89); } .player { background: rgb(64, 64, 64); } setState方法用于使显示器显示给定的状态。它首先删除旧角色的图形,如果有的话,然后在他们的新位置上重新绘制角色。试图将 DOM 元素重用于角色,可能很吸引人,但是为了使它有效,我们需要大量的附加记录,来关联角色和 DOM 元素,并确保在角色消失时删除元素。因为游戏中通常只有少数角色,重新绘制它们开销并不大。 DOMDisplay.prototype.setState = function(state) { if (this.actorLayer) this.actorLayer.remove(); this.actorLayer = drawActors(state.actors); this.dom.appendChild(this.actorLayer); this.dom.className = `game ${state.status}`; this.scrollPlayerIntoView(state); }; 我们可以将关卡的当前状态作为类名添加到包装器中,这样可以根据游戏胜负与否来改变玩家角色的样式。我们只需要添加 CSS 规则,指定祖先节点包含特定类的player元素的样式即可。 .lost .player { background: rgb(160, 64, 64); } .won .player { box-shadow: -4px -7px 8px white, 4px -7px 8px white; } 在遇到岩浆之后,玩家的颜色应该变成深红色,暗示着角色被烧焦了。当玩家收集完最后一枚硬币时,我们添加两个模糊的白色阴影来创建白色的光环效果,其中一个在左上角,一个在右上角。 我们无法假定关卡总是符合视口尺寸,它是我们在其中绘制游戏的元素。所以我们需要调用scrollPlayerIntoView来确保如果关卡在视口范围之外,我们可以滚动视口,确保玩家靠近视口的中央位置。下面的 CSS 样式为包装器的DOM元素设置了一个最大尺寸,以确保任何超出视口的元素都是不可见的。我们可以将外部元素的position设置为relative,因此该元素中的角色总是相对于关卡的左上角进行定位。 .game { overflow: hidden; max-width: 600px; max-height: 450px; position: relative; } 在scrollPlayerIntoView方法中,我们找出玩家的位置并更新其包装器元素的滚动坐标。我们可以通过操作元素的scrollLeft和scrollTop属性,当玩家接近视口边界时修改滚动坐标。 DOMDisplay.prototype.scrollPlayerIntoView = function(state) { let width = this.dom.clientWidth; let height = this.dom.clientHeight; let margin = width / 3; // The viewport let left = this.dom.scrollLeft, right = left + width; let top = this.dom.scrollTop, bottom = top + height; let player = state.player; let center = player.pos.plus(player.size.times(0.5)) .times(scale); if (center.x < left + margin) { this.dom.scrollLeft = center.x - margin; } else if (center.x > right - margin) { this.dom.scrollLeft = center.x + margin - width; } if (center.y < top + margin) { this.dom.scrollTop = center.y - margin; } else if (center.y > bottom - margin) { this.dom.scrollTop = center.y + margin - height; } }; 找出玩家中心位置的代码展示了,我们如何使用Vec类型来写出相对可读的计算代码。为了找出玩家的中心位置,我们需要将左上角位置坐标加上其尺寸的一半。计算结果就是关卡坐标的中心位置。但是我们需要将结果向量乘以显示比例,以将坐标转换成像素级坐标。 接下来,我们对玩家的坐标进行一系列检测,确保其位置不会超出合法范围。这里需要注意的是这段代码有时候依然会设置无意义的滚动坐标,比如小于 0 的值或超出元素滚动区域的值。这是没问题的。DOM 会将其修改为可接受的值。如果我们将scrollLeft设置为–10,DOM 会将其修改为 0。 最简单的做法是每次重绘时都滚动视口,确保玩家总是在视口中央。但这种做法会导致画面剧烈晃动,当你跳跃时,视图会不断上下移动。比较合理的做法是在屏幕中央设置一个“中央区域”,玩家在这个区域内部移动时我们不会滚动视口。 我们现在能够显示小型关卡。 <link rel="stylesheet" href="css/game.css"> <script> let simpleLevel = new Level(simpleLevelPlan); let display = new DOMDisplay(document.body, simpleLevel); display.setState(State.start(simpleLevel)); </script> 我们可以在link标签中使用rel="stylesheet",将一个 CSS 文件加载到页面中。文件game.css包含了我们的游戏所需的样式。 动作与冲突 现在我们是时候来添加一些动作了。这是游戏中最令人着迷的一部分。实现动作的最基本的方案(也是大多数游戏采用的)是将时间划分为一个个时间段,根据角色的每一步速度和时间长度,将元素移动一段距离。我们将以秒为单位测量时间,所以速度以单元每秒来表示。 移动东西非常简单。比较困难的一部分是处理元素之间的相互作用。当玩家撞到墙壁或者地板时,不可能简单地直接穿越过去。游戏必须注意特定的动作会导致两个对象产生碰撞,并需要采取相应措施。如果玩家遇到墙壁,则必须停下来,如果遇到硬币则必须将其收集起来。 想要解决通常情况下的碰撞问题是件艰巨任务。你可以找到一些我们称之为物理引擎的库,这些库会在二维或三维空间中模拟物理对象的相互作用。我们在本章中采用更合适的方案:只处理矩形物体之间的碰撞,并采用最简单的方案进行处理。 在移动角色或岩浆块时,我们需要测试元素是否会移动到墙里面。如果会的话,我们只要取消整个动作即可。而对动作的反应则取决于移动元素类型。如果是玩家则停下来,如果是岩浆块则反弹回去。 这种方法需要保证每一步之间的时间间隔足够短,确保能够在对象实际碰撞之前取消动作。如果时间间隔太大,玩家最后会悬浮在离地面很高的地方。另一种方法明显更好但更加复杂,即寻找到精确的碰撞点并将元素移动到那个位置。我们会采取最简单的方案,并确保减少动画之间的时间间隔,以掩盖其问题。 该方法用于判断某个矩形(通过位置与尺寸限定)是否会碰到给定类型的网格。 Level.prototype.touches = function(pos, size, type) { var xStart = Math.floor(pos.x); var xEnd = Math.ceil(pos.x + size.x); var yStart = Math.floor(pos.y); var yEnd = Math.ceil(pos.y + size.y); for (var y = yStart; y < yEnd; y++) { for (var x = xStart; x < xEnd; x++) { let isOutside = x < 0 || x >= this.width || y < 0 || y >= this.height; let here = isOutside ? "wall" : this.rows[y][x]; if (here == type) return true; } } return false; }; 该方法通过对坐标使用Math.floor和Math.ceil,来计算与身体重叠的网格方块集合。记住网格方块的大小是1x1个单位。通过将盒子的边上下颠倒,我们得到盒子接触的背景方块的范围。 我们通过查找坐标遍历网格方块,并在找到匹配的方块时返回true。关卡之外的方块总是被当作"wall",来确保玩家不能离开这个世界,并且我们不会意外地尝试,在我们的“rows数组的边界之外读取。 状态的update方法使用touches来判断玩家是否接触岩浆。 State.prototype.update = function(time, keys) { let actors = this.actors .map(actor => actor.update(time, this, keys)); let newState = new State(this.level, actors, this.status); if (newState.status != "playing") return newState; let player = newState.player; if (this.level.touches(player.pos, player.size, "lava")) { return new State(this.level, actors, "lost"); } for (let actor of actors) { if (actor != player && overlap(actor, player)) { newState = actor.collide(newState); } } return newState; }; 它接受时间步长和一个数据结构,告诉它按下了哪些键。它所做的第一件事是调用所有角色的update方法,生成一组更新后的角色。角色也得到时间步长,按键,和状态,以便他们可以根据这些来更新。只有玩家才会读取按键,因为这是唯一由键盘控制的角色。 如果游戏已经结束,就不需要再做任何处理(游戏不能在输之后赢,反之亦然)。否则,该方法测试玩家是否接触背景岩浆。如果是这样的话,游戏就输了,我们就完了。最后,如果游戏实际上还在继续,它会查看其他玩家是否与玩家重叠。 overlap函数检测角色之间的重叠。它需要两个角色对象,当它们触碰时返回true,当它们沿X轴和Y轴重叠时,就是这种情况。 function overlap(actor1, actor2) { return actor1.pos.x + actor1.size.x > actor2.pos.x && actor1.pos.x < actor2.pos.x + actor2.size.x && actor1.pos.y + actor1.size.y > actor2.pos.y && actor1.pos.y < actor2.pos.y + actor2.size.y; } 如果任何角色重叠了,它的collide方法有机会更新状态。触碰岩浆角色将游戏状态设置为"lost",当你碰到硬币时,硬币就会消失,当这是最后一枚硬币时,状态就变成了"won"。 Lava.prototype.collide = function(state) { return new State(state.level, state.actors, "lost"); }; Coin.prototype.collide = function(state) { let filtered = state.actors.filter(a => a != this); let status = state.status; if (!filtered.some(a => a.type == "coin")) status = "won"; return new State(state.level, filtered, status); }; 角色的更新 角色对象的update方法接受时间步长、状态对象和keys对象作为参数。Lava角色类型忽略keys对象。 Lava.prototype.update = function(time, state) { let newPos = this.pos.plus(this.speed.times(time)); if (!state.level.touches(newPos, this.size, "wall")) { return new Lava(newPos, this.speed, this.reset); } else if (this.reset) { return new Lava(this.reset, this.speed, this.reset); } else { return new Lava(this.pos, this.speed.times(-1)); } }; 它通过将时间步长乘上当前速度,并将其加到其旧位置,来计算新的位置。如果新的位置上没有障碍,它移动到那里。如果有障碍物,其行为取决于岩浆块的类型:滴落岩浆具有reset位置,当它碰到某物时,它会跳回去。跳跃岩浆将其速度乘以-1,从而开始向相反的方向移动。 硬币使用它们的act方法来晃动。他们忽略了网格的碰撞,因为它们只是在它们自己的方块内部晃动。 const wobbleSpeed = 8, wobbleDist = 0.07; Coin.prototype.update = function(time) { let wobble = this.wobble + time * wobbleSpeed; let wobblePos = Math.sin(wobble) * wobbleDist; return new Coin(this.basePos.plus(new Vec(0, wobblePos)), this.basePos, wobble); }; 递增wobble属性来跟踪时间,然后用作Math.sin的参数,来找到波上的新位置。然后,根据其基本位置和基于波的偏移,计算硬币的当前位置。 还剩下玩家本身。玩家的运动对于每和轴单独处理,因为碰到地板不应阻止水平运动,碰到墙壁不应停止下降或跳跃运动。 const playerXSpeed = 7; const gravity = 30; const jumpSpeed = 17; Player.prototype.update = function(time, state, keys) { let xSpeed = 0; if (keys.ArrowLeft) xSpeed -= playerXSpeed; if (keys.ArrowRight) xSpeed += playerXSpeed; let pos = this.pos; let movedX = pos.plus(new Vec(xSpeed * time, 0)); if (!state.level.touches(movedX, this.size, "wall")) { pos = movedX; } let ySpeed = this.speed.y + time * gravity; let movedY = pos.plus(new Vec(0, ySpeed * time)); if (!state.level.touches(movedY, this.size, "wall")) { pos = movedY; } else if (keys.ArrowUp && ySpeed > 0) { ySpeed = -jumpSpeed; } else { ySpeed = 0; } return new Player(pos, new Vec(xSpeed, ySpeed)); }; 水平运动根据左右箭头键的状态计算。当没有墙壁阻挡由这个运动产生的新位置时,就使用它。否则,保留旧位置。 垂直运动的原理类似,但必须模拟跳跃和重力。玩家的垂直速度(ySpeed)首先考虑重力而加速。 我们再次检查墙壁。如果我们不碰到任何一个,使用新的位置。如果存在一面墙,就有两种可能的结果。当按下向上的箭头,并且我们向下移动时(意味着我们碰到的东西在我们下面),将速度设置成一个相对大的负值。这导致玩家跳跃。否则,玩家只是撞到某物上,速度就被设定为零。 重力、跳跃速度和几乎所有其他常数,在游戏中都是通过反复试验来设定的。我测试了值,直到我找到了我喜欢的组合。 跟踪按键 对于这样的游戏,我们不希望按键在每次按下时生效。相反,我们希望只要按下了它们,他们的效果(移动球员的数字)就一直有效。 我们需要设置一个键盘处理器来存储左、右、上键的当前状态。我们调用preventDefault,防止按键产生页面滚动。 下面的函数接受一个按键名称数组,返回跟踪这些按键的当前位置的对象。并注册"keydown"和"keyup"事件,当事件对应的按键代码存在于其存储的按键代码集合中时,就更新对象。 function trackKeys(keys) { let down = Object.create(null); function track(event) { if (keys.includes(event.key)) { down[event.key] = event.type == "keydown"; event.preventDefault(); } } window.addEventListener("keydown", track); window.addEventListener("keyup", track); return down; } const arrowKeys = trackKeys(["ArrowLeft", "ArrowRight", "ArrowUp"]); 两种事件类型都使用相同的处理程序函数。该处理函数根据事件对象的type属性来确定是将按键状态修改为true(“keydown”)还是false(“keyup”)。 运行游戏 我们在第十四章中看到的requestAnimationFrames函数是一种产生游戏动画的好方法。但该函数的接口有点过于原始。该函数要求我们跟踪上次调用函数的时间,并在每一帧后再次调用requestAnimationFrame方法。 我们这里定义一个辅助函数来将这部分烦人的代码包装到一个名为runAnimation的简单接口中,我们只需向其传递一个函数即可,该函数的参数是一个时间间隔,并用于绘制一帧图像。当帧函数返回false时,整个动画停止。 function runAnimation(frameFunc) { let lastTime = null; function frame(time) { let stop = false; if (lastTime != null) { let timeStep = Math.min(time - lastTime, 100) / 1000; if (frameFunc(timeStep) === false) return; } lastTime = time; requestAnimationFrame(frame); } requestAnimationFrame(frame); } 我们将每帧之间的最大时间间隔设置为 100 毫秒(十分之一秒)。当浏览器标签页或窗口隐藏时,requestAnimationFrame调用会自动暂停,并在标签页或窗口再次显示时重新开始绘制动画。在本例中,lastTime和time之差是隐藏页面的整个时间。一步一步地推进游戏看起来很傻,可能会造成奇怪的副作用,比如玩家从地板上掉下去。 该函数也会将时间单位转换成秒,相比于毫秒大家会更熟悉秒。 runLevel函数的接受Level对象和显示对象的构造器,并返回一个Promise。runLevel函数(在document.body中)显示关卡,并使得用户通过该节点操作游戏。当关卡结束时(或胜或负),runLevel会多等一秒(让用户看看发生了什么),清除关卡,并停止动画,如果我们指定了andThen函数,则runLevel会以关卡状态为参数调用该函数。 function runLevel(level, Display) { let display = new Display(document.body, level); let state = State.start(level); let ending = 1; return new Promise(resolve => { runAnimation(time => { state = state.update(time, arrowKeys); display.setState(state); if (state.status == "playing") { return true; } else if (ending > 0) { ending -= time; return true; } else { display.clear(); resolve(state.status); return false; } }); }); } 一个游戏是一个关卡序列。每当玩家死亡时就重新开始当前关卡。当完成关卡后,我们切换到下一关。我们可以使用下面的函数来完成该任务,该函数的参数为一个关卡平面图(字符串)数组和显示对象的构造器。 async function runGame(plans, Display) { for (let level = 0; level < plans.length;) { let status = await runLevel(new Level(plans[level]), Display); if (status == "won") level++; } console.log("You've won!"); } 因为我们使runLevel返回Promise,runGame可以使用async函数编写,如第十一章中所见。它返回另一个Promise,当玩家完成游戏时得到解析。 在本章的沙盒的GAME_LEVELS绑定中,有一组可用的关卡平面图。这个页面将它们提供给runGame,启动实际的游戏: <link rel="stylesheet" href="css/game.css"> <body> <script> runGame(GAME_LEVELS, DOMDisplay); </script> </body> 习题 游戏结束 按照惯例,平台游戏中玩家一开始会有有限数量的生命,每死亡一次就扣去一条生命。当玩家生命耗尽时,游戏就从头开始了。 调整runGame来实现生命机制。玩家一开始会有 3 条生命。每次启动时输出当前生命数量(使用console.log)。 <link rel="stylesheet" href="css/game.css"> <body> <script> // The old runGame function. Modify it... async function runGame(plans, Display) { for (let level = 0; level < plans.length;) { let status = await runLevel(new Level(plans[level]), Display); if (status == "won") level++; } console.log("You've won!"); } runGame(GAME_LEVELS, DOMDisplay); </script> </body> 暂停游戏 现在实现一个功能 —— 当用户按下 ESC 键时可以暂停或继续游戏。 我们可以修改runLevel函数,使用另一个键盘事件处理器来实现在玩家按下 ESC 键的时候中断或恢复动画。 乍看起来,runAnimation无法完成该任务,但如果我们使用runLevel来重新安排调度策略,也是可以实现的。 当你完成该功能后,可以尝试加入另一个功能。我们现在注册键盘事件处理器的方法多少有点问题。现在arrows对象是一个全局绑定,即使游戏没有运行时,事件处理器也是有效的。我们称之为系统泄露。请扩展tracKeys,提供一种方法来注销事件处理器,接着修改runLevel在启动游戏时注册事件处理器,并在游戏结束后注销事件处理器。 <link rel="stylesheet" href="css/game.css"> <body> <script> // The old runLevel function. Modify this... function runLevel(level, Display) { let display = new Display(document.body, level); let state = State.start(level); let ending = 1; return new Promise(resolve => { runAnimation(time => { state = state.update(time, arrowKeys); display.setState(state); if (state.status == "playing") { return true; } else if (ending > 0) { ending -= time; return true; } else { display.clear(); resolve(state.status); return false; } }); }); } runGame(GAME_LEVELS, DOMDisplay); </script> </body> 怪物 它是传统的平台游戏,里面有敌人,你可以跳到它顶上来打败它。这个练习要求你把这种角色类型添加到游戏中。 我们称之为怪物。怪物只能水平移动。你可以让它们朝着玩家的方向移动,或者像水平岩浆一样来回跳动,或者拥有你想要的任何运动模式。这个类不必处理掉落,但是它应该确保怪物不会穿过墙壁。 当怪物接触玩家时,效果取决于玩家是否跳到它们顶上。你可以通过检查玩家的底部是否接近怪物的顶部来近似它。如果是这样的话,怪物就消失了。如果没有,游戏就输了。 <link rel="stylesheet" href="css/game.css"> <style>.monster { background: purple }</style> <body> <script> // Complete the constructor, update, and collide methods class Monster { constructor(pos, /* ... */) {} get type() { return "monster"; } static create(pos) { return new Monster(pos.plus(new Vec(0, -1))); } update(time, state) {} collide(state) {} } Monster.prototype.size = new Vec(1.2, 2); levelChars["M"] = Monster; runLevel(new Level(` .................................. .################################. .#..............................#. .#..............................#. .#..............................#. .#...........................o..#. .#..@...........................#. .##########..............########. ..........#..o..o..o..o..#........ ..........#...........M..#........ ..........################........ .................................. `), DOMDisplay); </script> </body>
一、数据科学的生命周期 原文:DS-100/textbook/notebooks/ch01 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 在数据科学中,我们使用大量不同的数据集来对世界做出结论。在这个课程中,我们将通过计算和推理思维的双重视角,来讨论数据科学的关键原理和技术。实际上,这涉及以下过程: 提出一个问题 获取和清理数据 进行探索性数据分析 用预测和推理得出结论 在这个过程的最后一步之后,通常出现更多的问题,因此我们可以反复地执行这个过程,来发现我们的世界的新特征。这个正反馈的循环对我们的工作至关重要,我们称之为数据科学生命周期。 如果数据科学的生命周期与它说的一样容易进行,那么就不需要该主题的教科书了。幸运的是,生命周期中的每个步骤都包含众多挑战,这些挑战揭示了强大和通常令人惊讶的见解,它们构成了使用数据在思考后进行决策的基础。 和 Data8 一样,我们将以一个例子开始。 译者注:Data8 是 DS100 是先修课。我之前翻译了它的课本,《计算与推断思维 中文版》。 关于本书 在我们继续之前,重要的是说出我们对读者的假设。 在本书中,我们将当作你已经上完了 Data8 或者其他一些类似的东西。 特别是,我们假定你对以下主题有一定了解(同时给出 Data8 课本的页面链接)。 表格数据处理:选择,过滤,分组,连接 抽样,统计的经验分布 使用自举重采样的假设检验 最小二乘回归和回归推断 分类 另外,我们假设你已经上完了 CS61A 或者其他类似的东西,因此除了特殊情况外,不会解释 Python 的语法。 译者注:CS61A(SICP Python)是计算机科学的第一门课,中文版讲义请见《SICP Python 中文版》。 DS100 的学生 回想一下,数据科学生命周期涉及以下大致的步骤: 问题表述: 我们想知道什么,或者我们想要解决什么问题? 我们的假设是什么? 我们的成功指标是什么? 数据采集和清洗: 我们有什么数据以及需要哪些数据? 我们将如何收集更多数据? 我们如何组织数据来分析? 探索性数据分析: 我们是否有了相关数据? 数据有哪些偏差,异常或其他问题? 我们如何转换数据来实现有效的分析? 预测和推断: 这些数据说了世界的什么事情? 它回答我们的问题,还是准确地解决问题? 我们的结论有多健壮? 问题表述 我们想知道 DS100 中的学生姓名的数据,是否向我们提供了学生本身的其他信息。 虽然这是一个模糊的问题,但这足以让我们处理我们的数据,我们当然可以在问题变得更加精确的时候提出问题。 数据采集和清洗 在 DS100 中,我们将研究收集数据的各种方法。 我们首先看看我们的数据,这是我们从以前的 DS100 课程中下载的学生姓名的名单。 如果你现在不了解代码,请不要担心;我们稍后会更深入地介绍这些库。 相反,请关注我们展示的流程和图表。 import pandas as pd students = pd.read_csv('roster.csv') students Name Role 0 Keeley 1 John 2 BRYAN … … 276 Ernesto 277 Athan 278 Michael 279 行 × 2 列 我们很快可以看到,数据中有一些奇怪的东西。 例如,其中一个学生的姓名全部是大写字母。 另外,Role列的作用并不明显。 在 DS100 中,我们将研究如何识别数据中的异常并执行修正。 大写字母的差异将导致我们的程序认为'BRYAN'和'Bryan'是不同的名称,但他们对于我们的目标是相同的。 我们将所有名称转换为小写来避免这种情况。 students['Name'] = students['Name'].str.lower() students Name Role 0 keeley 1 john 2 bryan … … 276 ernesto 277 athan 278 michael 279 行 × 2 列 现在我们的数据有了更容易处理的格式,我们继续进行探索性数据分析。 探索性数据分析(EDA) 术语探索性数据分析(简称 EDA)是指发现我们的数据特征的过程,这些特征为未来的分析提供信息。 这是上一页的students表: students Name Role 0 keeley 1 john 2 bryan … … 276 ernesto 277 athan 278 michael 279 行 × 2 列 我们留下了许多问题。 这个名单中有多少名学生? Role列是什么意思? 我们进行 EDA 来更全面地了解我们的数据。 在 DS100 中,我们将研究探索性数据分析和实践,来分析新数据集。 通常,我们通过重复提出简单问题,他们有关我们想知道的数据,来探索数据。 我们将以这种方式构建我们的分析。 我们的数据集中有多少学生? print("There are", len(students), "students on the roster.") # There are 279 students on the roster. 一个自然的后续问题是,这是否是完整的学生名单。 在这种情况下,我们碰巧知道这个列表包含班级中的所有学生。 Role字段的含义是什么? 理解字段的含义,通常可以通过查看字段数据的唯一值来实现: students['Role'].value_counts().to_frame() Role Student Waitlist Student 我们可以在这里看到,我们的数据不仅包含当时注册了课程的学生,还包含等候名单上的学生。 Role列告诉我们每个学生是否注册。 那名称呢? 我们如何总结这个字段? 在 DS100 中,我们将处理许多不同类型的数据(不仅仅是数字),而且我们将研究面向不同类型的数据的技术。 好的起点可能是检查字符串的长度。 sns.distplot(students['Name'].str.len(), rug=True, axlabel="Number of Characters") # <matplotlib.axes._subplots.AxesSubplot at 0x10e6fd0b8> 这种可视化向我们展示了,大多数名称的长度在 3 到 9 个字符之间。 这给了我们一个机会,来检查我们的数据是否合理 - 如果有很多名称长度为 1 个字符,我们就有充分的理由重新检查我们的数据。 名称里面有什么? 虽然这个数据集非常简单,但我们很快就会看到,仅仅是名称就可以揭示我们班级的相当多的信息。 名称里面有什么 到目前为止,我们已经对我们的数据提出了一个大致的问题:“DS100 中的学生名称是否告诉我们该课程的任何信息?” 通过将所有名称转换为小写字母,我们完成一些数据清理工作。 在我们的探索性数据分析过程中,我们发现,我们的名单包含班级和候补名单中的大约 270 个学生姓名,而大部分名称长度在 4 到 8 个字符之间。 根据名称,我们还能发现班级的什么其他信息? 我们可能会考虑数据集中的单个名称: students['Name'][5] # 'jerry' 从这个名称中我们可以推断出,这个学生可能是一个男生。我们也可以猜测学生的年龄。例如,如果我们知道,杰里在 1998 年是一个非常受欢迎的婴儿名称,那么我们可能会猜测这个学生大约二十岁。 这个想法给了我们两个需要调查的新问题: “DS100 中的学生名称,是否告诉了我们课堂上的性别分布?” “DS100 中的第一批学生,是否告诉了我们课堂上的年龄分布?” 为了调查这些问题,我们需要一个数据集,它将姓名与性别和年份相关联。方便的是,美国社会保障部门在线提供这样一个数据集:https://www.ssa.gov/oact/babynames/index.html。他们的数据集记录了婴儿出生时的名称,因此通常称为婴儿名称数据集。 我们将从下载开始,然后将数据集加载到 Python 中。再次,不要担心理解第一章中的代码。理解整个过程更重要。 import urllib.request import os.path data_url = "https://www.ssa.gov/oact/babynames/names.zip" local_filename = "babynames.zip" if not os.path.exists(local_filename): # if the data exists don't download again with urllib.request.urlopen(data_url) as resp, open(local_filename, 'wb') as f: f.write(resp.read()) import zipfile babynames = [] with zipfile.ZipFile(local_filename, "r") as zf: data_files = [f for f in zf.filelist if f.filename[-3:] == "txt"] def extract_year_from_filename(fn): return int(fn[3:7]) for f in data_files: year = extract_year_from_filename(f.filename) with zf.open(f) as fp: df = pd.read_csv(fp, names=["Name", "Sex", "Count"]) df["Year"] = year babynames.append(df) babynames = pd.concat(babynames) babynames Name Sex Count Year 0 Mary F 9217 1 Anna F 3860 2 Emma F 2587 … … … … 2081 Verna M 5 2082 Winnie M 5 2083 Winthrop M 5 1891894 行 × 4 列 ls -alh babynames.csv # -rw-r--r-- 1 sam staff 30M Jan 22 15:31 babynames.csv 看起来,数据集包含名称,婴儿性别,具有该名称的婴儿数量以及这些婴儿的出生年份。 为了确认,我们从检查来自 SSN 的数据集描述:https://www.ssa.gov/oact/babynames/background.html。 所有名称均来自 1879 年后美国出生人口的社保卡申请。请注意,很多 1937 年以前出生的人从未申请过社保卡,所以他们的名字不包含在我们的数据中。 对于其他申请人,我们的记录可能不会显示出生地点,并且他们的姓名也不会包含在我们的数据中。 所有数据均来自截至我们的 2017 年 3 月社保卡申请记录的 100% 样本。 这个数据的一个有用的可视化,是绘制每年出生的男性和女性婴儿的数量: pivot_year_name_count = pd.pivot_table( babynames, index='Year', columns='Sex', values='Count', aggfunc=np.sum) pink_blue = ["#E188DB", "#334FFF"] with sns.color_palette(sns.color_palette(pink_blue)): pivot_year_name_count.plot(marker=".") plt.title("Registered Names vs Year Stratified by Sex") plt.ylabel('Names Registered that Year') 这个绘图让我们质疑,1880 年的美国是否有婴儿。上面引用的一句话有助于解释: 请注意,很多 1937 年以前出生的人从未申请过社保卡,所以他们的名字不包含在我们的数据中。 对于其他申请人,我们的记录可能不会显示出生地点,并且他们的姓名也不会包含在我们的数据中。 我们还可以在上图中清楚地看到婴儿潮的时期。 从名字推断性别 我们使用这个数据集来估计我们班的男女生人数。 与我们班的名单一样,我们先将名称小写: babynames['Name'] = babynames['Name'].str.lower() babynames Name Sex Count Year 0 mary F 9217 1 anna F 3860 2 emma F 2587 … … … … 2081 verna M 5 2082 winnie M 5 2083 winthrop M 5 1891894 行 × 4 列 然后,我们计算对于每个名字,共有多少个男婴和女婴出生: sex_counts = pd.pivot_table(babynames, index='Name', columns='Sex', values='Count', aggfunc='sum', fill_value=0., margins=True) sex_counts Sex F M All Name aaban 0 96 96 aabha 35 0 35 aabid 0 10 10 … … … … zyyon 0 6 6 zzyzx 0 5 5 All 170639571 173894326 344533897 96175 行 × 3 列 为了决定一个名字是男性还是女性,我们可以计算出这个名字给女性婴儿的次数比例。 prop_female = sex_counts['F'] / sex_counts['All'] sex_counts['prop_female'] = prop_female sex_counts Sex F M All prop_female Name aaban 0 96 96 0.000000 aabha 35 0 35 1.000000 aabid 0 10 10 0.000000 … … … … … zyyon 0 6 6 0.000000 zzyzx 0 5 5 0.000000 All 170639571 173894326 344533897 0.495277 96175 行 × 4 列 然后,我们可以定义一个函数,查找给定名称的女性比例。 def sex_from_name(name): if name in sex_counts.index: prop = sex_counts.loc[name, 'prop_female'] return 'F' if prop > 0.5 else 'M' else: return None sex_from_name('sam') # 'M' 尝试在这个框中输入一些名称,来查看这个函数是否输出你期望的内容: interact(sex_from_name, name='sam'); 我们在班级名单中,使用最可能的性别标记每个名称。 students['sex'] = students['Name'].apply(sex_from_name) students Name Role sex 0 keeley Student 1 john Student 2 bryan Student … … … 276 ernesto Waitlist Student 277 athan Waitlist Student 278 michael Waitlist Student 279 行 × 3 列 现在,估计我们有多少男女学生就很容易了: students['sex'].value_counts() ''' M 144 F 92 Name: sex, dtype: int64 ''' 从名称推断年龄 我们可以采用类似的方法来估计班级的年龄分布,将每个姓名映射到数据集中的平均年龄。 def avg_year(group): return np.average(group['Year'], weights=group['Count']) avg_years = ( babynames .groupby('Name') .apply(avg_year) .rename('avg_year') .to_frame() ) avg_years avg_year Name aaban aabha aabid … zyyanna zyyon zzyzx 96174 行 × 1 列 def year_from_name(name): return (avg_years.loc[name, 'avg_year'] if name in avg_years.index else None) # Generate input box for you to try some names out: interact(year_from_name, name='fernando'); students['year'] = students['Name'].apply(year_from_name) students Name Role sex year 0 keeley Student F 1 john Student M 2 bryan Student M … … … … 276 ernesto Waitlist Student M 277 athan Waitlist Student M 278 michael Waitlist Student M 279 行 × 4 列 之后,绘制年份的分布情况很容易: sns.distplot(students['year'].dropna()); 为了计算平均年份: students['year'].mean() # 1983.846741800525 这使得它看起来像是,学生平均是 35 岁。 这是一个大学本科课程,所以我们预计平均年龄在 20 岁左右。为什么我们的估计会如此之远? 作为数据科学家,我们经常遇到不符合我们预期的结果,并且必须做出判断,我们的结果是由我们的数据,我们的流程还是不正确的假设造成的。 不可能定义适用于所有情况的规则。 相反,我们将为你提供工具来重新检查数据分析的每一步,并告诉你如何使用它们。 在这种情况下,我们意想不到的结果,最可能是因为大多数名字都是旧的。 例如,在我们的数据记录中,约翰这个名字在整个历史中都相当流行,这意味着我们可能会猜测约翰出生于 1950 年左右。我们可以通过查看数据来确认: names = babynames.set_index('Name').sort_values('Year') john = names.loc['john'] john[john['Sex'] == 'M'].plot('Year', 'Count'); 如果我们相信,我们班没有人超过 40 岁或低于 10 岁(我们可以通过在课上观察我们的教室发现),我们可以通过仅检查 1978 年之间的数据,将其纳入我们的分析中。我们将很快讨论数据操作,并且你可能会重新分析这个示例,来确定纳入这一先验是否会提供更明智的结果。
来源:ApacheCN《面向机器学习的特征工程》翻译项目 译者:friedhelm739 校对:(虚位以待) 视觉和声音是人类固有的感觉输入。我们的大脑是可以迅速进化我们的能力来处理视觉和听觉信号的,一些系统甚至在出生前就对刺激做出反应。另一方面,语言技能是学习得来的。他们需要几个月或几年的时间来掌握。许多人天生就具有视力和听力的天赋,但是我们所有人都必须有意训练我们的大脑去理解和使用语言。 有趣的是,机器学习的情况是相反的。我们已经在文本分析应用方面取得了比图像或音频更多的进展。以搜索问题为例。人们在信息检索和文本检索方面已经取得了相当多年的成功,而图像和音频搜索仍在不断完善。在过去五年中,深度学习模式的突破最终预示着期待已久的图像和语音分析的革命。 进展的困难与从相应类型的数据中提取有意义特征的困难直接相关。机器学习模型需要语义上有意义的特征进行语义意义的预测。在文本分析中,特别是对于英语这样的语言,其中一个基本的语义单位(一个词)很容易提取,可以很快地取得进展。另一方面,图像和音频被记录为数字像素或波形。图像中的单个“原子”是像素。在音频数据中,它是波形强度的单一测量。它们包含的语义信息远少于数据文本。因此,在图像和音频上的特征提取和工程任务比文本更具挑战性。 在过去的二十年中,计算机视觉研究已经集中在人工标定上,用于提取良好的图像特征。在一段时间内,图像特征提取器,如 SIFT 和 HOG 是标准步骤。深度学习研究的最新发展已经扩展了传统机器学习模型的范围,将自动特征提取作为基础层。他们本质上取代手动定义的特征图像提取器与手动定义的模型,自动学习和提取特征。人工标定仍然存在,只是进一步深入到建模中去。 在本章中,我们将从流行的图像特征提取SIFT和HOG入手,深入研究本书所涵盖的最复杂的建模机制:深度学习的特征工程。 阅读全文
七、非线性特征提取和模型堆叠 来源:ApacheCN《面向机器学习的特征工程》翻译项目 译者:friedhelm739 校对:(虚位以待) 当在数据一个线性子空间像扁平饼时 PCA 是非常有用的。但是如果数据形成更复杂的形状呢?一个平面(线性子空间)可以推广到一个 流形 (非线性子空间),它可以被认为是一个被各种拉伸和滚动的表面。 如果线性子空间是平的纸张,那么卷起的纸张就是非线性流形的例子。你也可以叫它瑞士卷。(见图 7-1),一旦滚动,二维平面就会变为三维的。然而,它本质上仍是一个二维物体。换句话说,它具有低的内在维度,这是我们在“直觉”中已经接触到的一个概念。如果我们能以某种方式展开瑞士卷,我们就可以恢复到二维平面。这是非线性降维的目标,它假定流形比它所占据的全维更简单,并试图展开它。 关键是,即使当大流形看起来复杂,每个点周围的局部邻域通常可以很好地近似于一片平坦的表面。换句话说,他们学习使用局部结构对全局结构进行编码。非线性降维也被称为非线性嵌入,或流形学习。非线性嵌入可有效地将高维数据压缩成低维数据。它们通常用于 2-D 或 3-D 的可视化。 然而,特征工程的目的并不是要使特征维数尽可能低,而是要达到任务的正确特征。在这一章中,正确的特征是代表数据空间特征的特征。 聚类算法通常不是局部结构化学习的技术。但事实上也可以用他们这么做。彼此接近的点(由数据科学家使用某些度量可以定义的“接近度”)属于同一个簇。给定聚类,数据点可以由其聚类成员向量来表示。如果簇的数量小于原始的特征数,则新的表示将比原始的具有更小的维度;原始数据被压缩成较低的维度。 与非线性嵌入技术相比,聚类可以产生更多的特征。但是如果最终目标是特征工程而不是可视化,那这不是问题。 我们将提出一个使用 k 均值聚类算法来进行结构化学习的思想。它简单易懂,易于实践。与非线性流体降维相反,k 均值执行非线性流形特征提取更容易解释。如果正确使用它,它可以是特征工程的一个强大的工具。 k 均值聚类 k 均值是一种聚类算法。聚类算法根据数据在空间中的排列方式来分组数据。它们是无监督的,因为它们不需要任何类型的标签,使用算法仅基于数据本身的几何形状来推断聚类标签。 聚类算法依赖于 度量 ,它是度量数据点之间的紧密度的测量。最流行的度量是欧几里德距离或欧几里得度量。它来自欧几里得几何学并测量两点之间的直线距离。我们对它很熟悉,因为这是我们在日常现实中看到的距离。 两个向量X和Y之间的欧几里得距离是X-Y的 L2 范数。(见 L2 范数的“L2 标准化”),在数学语言中,它通常被写成‖ x - y ‖。 k 均值建立一个硬聚类,意味着每个数据点被分配给一个且只分配一个集群。该算法学习定位聚类中心,使得每个数据点和它的聚类中心之间的欧几里德距离的总和最小化。对于那些喜欢阅读公式而非语言的人来说,目标函数是: 每个簇 CiCi 包含数据点的子集。簇i的中心等于簇中所有数据点的平均值:μi=∑x∈Cix/niμi=∑x∈Cix/ni,其中 nini 表示簇i中的数据点的数目。 图 7-2 显示了 k 均值在两个不同的随机生成数据集上的工作。(a)中的数据是由具有相同方差但不同均值的随机高斯分布生成的。(c)中的数据是随机产生的。这些问题很容易解决,k 均值做得很好。(结果可能对簇的数目敏感,数目必须给算法)。这个例子的代码如例 7-1 所示。 例 7-1 import numpy as np from sklearn.cluster import KMeans from sklearn.datasets import make_blobs import matplotlib.pyplot as plt %matplotlib notebook n_data = 1000 seed = 1 n_clusters = 4 # 产生高斯随机数,运行K-均值 blobs, blob_labels = make_blobs(n_samples=n_data, n_features=2,centers=n_centers, random_state=seed) clusters_blob = KMeans(n_clusters=n_centers, random_state=seed).fit_predict(blobs) # 产生随机数,运行K-均值 uniform = np.random.rand(n_data, 2) clusters_uniform = KMeans(n_clusters=n_clusters, random_state=seed).fit_predict(uniform) # 使用Matplotlib进行结果可视化 figure = plt.figure() plt.subplot(221) plt.scatter(blobs[:, 0], blobs[:, 1], c=blob_labels, cmap='gist_rainbow') plt.title("(a) Four randomly generated blobs", fontsize=14) plt.axis('off') plt.subplot(222) plt.scatter(blobs[:, 0], blobs[:, 1], c=clusters_blob, cmap='gist_rainbow') plt.title("(b) Clusters found via K-means", fontsize=14) plt.axis('off') plt.subplot(223) plt.scatter(uniform[:, 0], uniform[:, 1]) plt.title("(c) 1000 randomly generated points", fontsize=14) plt.axis('off') plt.subplot(224) plt.scatter(uniform[:, 0], uniform[:, 1], c=clusters_uniform, cmap='gist_rainbow') plt.title("(d) Clusters found via K-means", fontsize=14) plt.axis('off') 曲面拼接聚类 应用聚类一般假定存在自然簇,即在其他空的空间中存在密集的数据区域。在这些情况下,有一个正确的聚类数的概念,人们已经发明了聚类指数用于测量数据分组的质量,以便选择k。 然而,当数据像如图 7-2(c)那样均匀分布时,不再有正确的簇数。在这种情况下,聚类算法的作用是矢量量化,即将数据划分成有限数量的块。当使用量化矢量而不是原始矢量时,可以基于可接受的近似误差来选择簇的数目。 从视觉上看,k 均值的这种用法可以被认为是如图 7-3 那样用补丁覆盖数据表面。如果在瑞士卷数据集上运行 k 均值,这确实是我们所得到的。例 7-2 使用sklearn生成瑞士卷上的嘈杂数据集,将其用 k 均值聚类,并使用 Matplotlib 可视化聚类结果。数据点根据它们的簇 ID 着色。 例 7-2 from mpl_toolkits.mplot3d import Axes3D from sklearn import manifold, datasets # 在瑞士卷训练集上产生噪声 X, color = datasets.samples_generator.make_swiss_roll(n_samples=1500) # 用100 K-均值聚类估计数据集 clusters_swiss_roll = KMeans(n_clusters=100, random_state=1).fit_predict(X) # 展示用数据集,其中颜色是K-均值聚类的id fig2 = plt.figure() ax = fig2.add_subplot(111, projection='3d') ax.scatter(X[:, 0], X[:, 1], X[:, 2], c=clusters_swiss_roll, cmap='Spectral') 在这个例子中,我们在瑞士卷表面上随机生成 1500 个点,并要求 k 均值用 100 个簇来近似它。我们提出 100 这个数字,因为它看起来相当大,使每一簇覆盖了相当小的空间。结果看起来不错;簇群确实是很小的的,并且流体的不同部分被映射到不同的簇。不错!但我们完成了吗? 问题是,如果我们选择一个太小的K,那么从多元学习的角度来看,结果不会那么好。图 7-5 显示了 k 均值用 10 个簇在瑞士卷的输出。我们可以清楚地看流体的完全的部分都被映射到相同的簇(例如黄色、紫色、绿色和品红簇)的数据。 如果数据在空间中均匀分布,则选择正确的k就被归结为球填充问题。在D维中,可以拟合半径约为R的1/r的D次幂的球。每个 k 均值聚类是一个球面,半径是用质心表示球面中的点的最大误差。因此,如果我们愿意容忍每个数据点R的最大逼近误差,那么簇的数目是O((1/R)^D),其中D是数据的原始特征空间的维数。 对于 k 均值来说,均匀分布是最坏的情况。如果数据密度不均匀,那么我们将能够用更少的簇来表示更多的数据。一般来说,很难知道数据在高维空间中是如何分布的。我们可以保守的选择更大的 K。但是它不能太大,因为K将成为下一步建模步骤的特征数量。 用于分类的 k 均值特征化 当使用 k 均值作为特征化过程时,数据点可以由它的簇成员(分类变量群组成员的稀疏独热编码)来表示,我们现在来说明。 如果目标变量也是可用的,那么我们可以选择将该信息作为对聚类过程的提示。一种合并目标信息的方法是简单地将目标变量作为 k 均值算法的附加输入特征。由于目标是最小化在所有输入维度上的总欧氏距离,所以聚类过程将试图平衡目标值和原始特征空间中的相似性。可以在聚类算法中对目标值进行缩放以获得更多或更少的关注。目标的较大差异将产生更多关注分类边界的聚类。 k 均值特征化 聚类算法分析数据的空间分布。因此,k 均值特征化创建了一个压缩的空间索引,该数据可以在下一阶段被馈送到模型中。这是模型堆叠(stacking)的一个例子。 例 7-3 显示了一个简单的 k 均值特征。它被定义为可以训练数据和变换任何新数据的类对象。为了说明在聚类时使用和不使用目标信息之间的差异,我们将特征化器应用到使用sklearn的 make——moons 函数(例 7-4)生成的合成数据集。然后我们绘制簇边界的 Voronoi 图。图 7-6 展示出了结果的比较。底部面板显示没有目标信息训练的集群。注意,许多簇跨越两个类之间的空空间。顶部面板表明,当聚类算法被给定目标信息时,聚类边界可以沿着类边界更好地对齐。 例 7-3 import numpy as np from sklearn.cluster import KMeans class KMeansFeaturizer: """将数字型数据输入k-均值聚类. 在输入数据上运行k-均值并且把每个数据点设定为它的簇id. 如果存在目标变量,则将其缩放并包含为k-均值的输入,以导出服从分类边界以及组相似点的簇。 """ def __init__(self, k=100, target_scale=5.0, random_state=None): self.k = k self.target_scale = target_scale self.random_state = random_state def fit(self, X, y=None): """在输入数据上运行k-均值,并找到中心.""" if y is None: # 没有目标变量运行k-均值 km_model = KMeans(n_clusters=self.k,n_init=20,random_state=self.random_state) km_model.fit(X) self.km_model_ = km_model self.cluster_centers_ = km_model.cluster_centers_ return self # 有目标信息,使用合适的缩减并把输入数据输入k-均值 data_with_target = np.hstack((X, y[:,np.newaxis]*self.target_scale)) # 在数据和目标上简历预训练k-均值模型 km_model_pretrain = KMeans(n_clusters=self.k,n_init=20,random_state=self.random_state) km_model_pretrain.fit(data_with_target) #运行k-均值第二次获得簇在原始空间没有目标信息。使用预先训练中发现的质心进行初始化。 #通过一个迭代的集群分配和质心重新计算。 km_model = KMeans(n_clusters=self.k,init=km_model_pretrain.cluster_centers_[:,:2],n_init=1,max_iter=1) km_model.fit(X) self.km_model = km_model self.cluster_centers_ = km_model.cluster_centers_ return self def transform(self, X, y=None): """为每个输入数据点输出最接近的簇id。""" clusters = self.km_model.predict(X) return clusters[:,np.newaxis] def fit_transform(self, X, y=None): self.fit(X, y) return self.transform(X, y) 例 7-4 from scipy.spatial import Voronoi, voronoi_plot_2d from sklearn.datasets import make_moons training_data, training_labels = make_moons(n_samples=2000, noise=0.2) kmf_hint = KMeansFeaturizer(k=100, target_scale=10).fit(training_data, training_labels) kmf_no_hint = KMeansFeaturizer(k=100, target_scale=0).fit(training_data, training_labels) def kmeans_voronoi_plot(X, y, cluster_centers, ax): """绘制与数据叠加的k-均值聚类的Voronoi图""" ax.scatter(X[:, 0], X[:, 1], c=y, cmap='Set1', alpha=0.2) vor = Voronoi(cluster_centers) voronoi_plot_2d(vor, ax=ax, show_vertices=False, alpha=0.5) 让我们测试 k 均值特征分类的有效性。例 7-5 对 k 均值簇特征增强的输入数据应用 Logistic 回归。比较了与使用径向基核的支持向量机(RBF SVM)、K 近邻(KNN)、随机森林(RF)和梯度提升树(GBT)的结果。随机森林和梯度提升树是最流行的非线性分类器,具有最先进的性能。RBF 支持向量机是欧氏空间的一种合理的非线性分类器。KNN 根据其 K 近邻的平均值对数据进行分类。(请参阅“分类器概述”来概述每个分类器。) 分类器的默认输入数据是数据的 2D 坐标。Logistic 回归也给出了簇成员特征(在图 7-7 中标注为“k 均值的 LR”)。作为基线,我们也尝试在二维坐标(标记为“LR”)上进行逻辑回归。 例 7-4 from sklearn.linear_model import LogisticRegression from sklearn.svm import SVC from sklearn.neighbors import KNeighborsClassifier from sklearn.ensemble import RandomForestClassifier,GradientBoostingClassifier #生成与训练数据相同分布的测试数据 test_data, test_labels = make_moons(n_samples=2000, noise=0.3) # 使用k-均值特技器生成簇特征 training_cluster_features = kmf_hint.transform(training_data) test_cluster_features = kmf_hint.transform(test_data) # 将新的输入特征和聚类特征整合 training_with_cluster = scipy.sparse.hstack((training_data, training_cluster_features)) test_with_cluster = scipy.sparse.hstack((test_data, test_cluster_features)) # 建立分类器 lr_cluster = LogisticRegression(random_state=seed).fit(training_with_cluster, training_labels) classifier_names = ['LR','kNN','RBF SVM','Random Forest','Boosted Trees'] classifiers = [LogisticRegression(random_state=seed),KNeighborsClassifier(5),SVC(gamma=2, C=1),RandomForestClassifier(max_depth=5, n_estimators=10, max_features=1),GradientBoostingClassifier(n_estimators=10, learning_rate=1.0, max_depth=5)] for model in classifiers: model.fit(training_data, training_labels) # 辅助函数使用ROC评估分类器性能 def test_roc(model, data, labels): if hasattr(model, "decision_function"): predictions = model.decision_function(data) else: predictions = model.predict_proba(data)[:,1] fpr, tpr, _ = sklearn.metrics.roc_curve(labels, predictions) return fpr, tpr # 显示结果 import matplotlib.pyplot as plt plt.figure() fpr_cluster, tpr_cluster = test_roc(lr_cluster, test_with_cluster, test_labels) plt.plot(fpr_cluster, tpr_cluster, 'r-', label='LR with k-means') for i, model in enumerate(classifiers): fpr, tpr = test_roc(model, test_data, test_labels) plt.plot(fpr, tpr, label=classifier_names[i]) plt.plot([0, 1], [0, 1], 'k--') plt.legend() 可选择的密集化 与独热簇相反,数据点也可以由其逆距离的密集向量表示到每个聚类中心。这比简单的二值化簇保留了更多的信息,但是现在表达是密集的。这里有一个折衷方案。一个热集群成员导致一个非常轻量级的稀疏表示,但是一个可能需要较大的K来表示复杂形状的数据。反向距离表示是密集的,这对于建模步骤可能花费更昂贵,但是这可以需要较小的K。 稀疏和密集之间的折衷是只保留最接近的簇的p的逆距离。但是现在P是一个额外的超参数需要去调整。(现在你能理解为什么特征工程需要这么多的步骤吗?),天下没有免费的午餐。 总结 使用 k 均值将空间数据转换为模型堆叠的一个例子,其中一个模型的输入是另一个模型的输出。堆叠的另一个例子是使用决策树类型模型(随机森林或梯度提升树)的输出作为线性分类器的输入。堆叠已成为近年来越来越流行的技术。非线性分类器训练和维护是昂贵的。堆叠的关键一点是将非线性引入特征,并且使用非常简单的、通常是线性的模型作为最后一层。该特征可以离线训练,这意味着可以使用昂贵的模型,这需要更多的计算能力或内存,但产生有用的特征。顶层的简单模型可以很快地适应在线数据的变化分布。这是精度和速度之间的一个很好的折衷,这经常被应用于需要快速适应改变数据分布的应用,比如目标广告。 模型堆叠的关键点 复杂的基础层(通常是昂贵的模型)产生良好的(通常是非线性的)特征,随后结合简单并且快速的顶层模型。这常常在模型精度和速度之间达到正确的平衡。 与使用非线性分类器相比,采用 logistic 回归的 k 均值更容易进行训练和存储。表 7-1 是多个机器学习模型的计算和记忆的训练和预测复杂性的图表。n表示数据点的数量,D(原始)特征的数量。 对于 k 均值,训练时间是O(nkd),因为每次迭代涉及计算每个数据点和每个质心(k)之间的d维距离。我们乐观地假设迭代次数不是n的函数,尽管并不普遍适用。预测需要计算新的数据点与质心(k)之间的距离,即O(kd)。存储空间需求是O(kd),对于K质心的坐标。 logistic 训练和预测在数据点的数量和特征维度上都是线性的。RBF SVM 训练是昂贵的,因为它涉及计算每一对输入数据的核矩阵。RBF SVM 预测比训练成本低,在支持向量S和特征维数D的数目上是线性的。改进的树模型训练和预测在数据大小和模型的大小上线性的(t个树,每个最多 2 的m次幂子叶,其中m是树的最大深度)。KNN 的实现根本不需要训练时间,因为训练数据本身本质上是模型。花费全都在预测时间,输入必须对每个原始训练点进行评估,并部分排序以检索 K 近邻。 总体而言,k 均值 +LR 是在训练和预测时间上唯一的线性组合(相对于训练数据O(nd)的大小和模型大小O(kd))。复杂度最类似于提升树,其成本在数据点的数量、特征维度和模型的大小(O(2^m*t))中是线性的。很难说 k 均值 +LR 或提升树是否会产生更小的模型,这取决于数据的空间特征。 数据泄露的潜力 那些记得我们对数据泄露的谨慎(参见“防止数据泄露(桶计数:未来的日子)”)可能会问 k 均值特化步骤中的目标变量是否也会导致这样的问题。答案是“是的”,但并不像桶计数(Bin-counting)计算的那么多。如果我们使用相同的数据集来学习聚类和建立分类模型,那么关于目标的信息将泄漏到输入变量中。因此,对训练数据的精度评估可能过于乐观,但是当在保持验证集或测试集上进行评估时,偏差会消失。此外,泄漏不会像桶计数那么糟糕(参见“桶计数”),因为聚类算法的有损压缩将抽象掉一些信息。要格外小心防止泄漏,人们可以始终保留一个单独的数据集来导出簇,就像在桶计数下一样。 k 均值特化对有实数、有界的数字特征是有用的,这些特征构成空间中密集区域的团块。团块可以是任何形状,因为我们可以增加簇的数量来近似它们。(与经典的类别聚类不同,我们不关心真正的簇数;我们只需要覆盖它们。) k 均值不能处理欧几里得距离没有意义的特征空间,也就是说,奇怪的分布式数字变量或类别变量。如果特征集包含这些变量,那么有几种处理它们的方法: 仅在实值的有界数字特征上应用 k 均值特征。 定义自定义度量(参见第?章以处理多个数据类型并使用 k 中心点算法。(k 中心点类似于 k 均值,但允许任意距离度量。) 类别变量可以转换为装箱统计(见“桶计数”),然后使用 K 均值进行特征化。 结合处理分类变量和时间序列的技术,k 均值特化可以自适应的处理经常出现在客户营销和销售分析中的丰富数据。所得到的聚类可以被认为是用户段,这对于下一个建模步骤是非常有用的特征。 我们将在下一章中讨论的深度学习,是通过将神经网络层叠在一起,将模型堆叠提升到一个全新的水平。ImageNet 挑战的两个赢家使用了 13 层和 22 层神经网络。就像 K 均值一样,较低层次的深度学习模型是无监督的。它们利用大量可用的未标记的训练图像,并寻找产生良好图像特征的像素组合。
第14章 循环神经网络 来源:ApacheCN《Sklearn 与 TensorFlow 机器学习实用指南》翻译项目 译者:@akonwang @alexcheen @飞龙 校对:@飞龙 击球手击出垒球,你会开始预测球的轨迹并立即开始奔跑。你追踪着它,不断调整你的移动步伐,最终在观众的一片雷鸣声中抓到它。无论是在听完朋友的话语还是早餐时预测咖啡的味道,你时刻在做的事就是在预测未来。在本章中,我们将讨论循环神经网络 – 一类预测未来的网络(当然,是到目前为止)。它们可以分析时间序列数据,诸如股票价格,并告诉你什么时候买入和卖出。在自动驾驶系统中,他们可以预测行车轨迹,避免发生交通意外。更一般地说,它们可在任意长度的序列上工作,而不是截止目前我们讨论的只能在固定长度的输入上工作的网络。举个例子,它们可以把语句,文件,以及语音范本作为输入,使得它们在诸如自动翻译,语音到文本或者情感分析(例如,读取电影评论并提取评论者关于该电影的感觉)的自然语言处理系统中极为有用。 更近一步,循环神经网络的预测能力使得它们具备令人惊讶的创造力。你同样可以要求它们去预测一段旋律的下几个音符,然后随机选取这些音符的其中之一并演奏它。然后要求网络给出接下来最可能的音符,演奏它,如此周而复始。在你知道它之前,你的神经网络将创作一首诸如由谷歌 Magenta 工程所创造的《The one》的歌曲。类似的,循环神经网络可以生成语句,图像标注以及更多。目前结果还不能准确得到莎士比亚或者莫扎特的作品,但谁知道几年后他们能生成什么呢? 在本章中,我们将看到循环神经网络背后的基本概念,他们所面临的主要问题(换句话说,在第11章中讨论的消失/爆炸的梯度),以及广泛用于反抗这些问题的方法:LSTM 和 GRU cell(单元)。如同以往,沿着这个方式,我们将展示如何用 TensorFlow 实现循环神经网络。最终我们将看看及其翻译系统的架构。 循环神经元 到目前为止,我们主要关注的是前馈神经网络,其中激活仅从输入层到输出层的一个方向流动(附录 E 中的几个网络除外)。 循环神经网络看起来非常像一个前馈神经网络,除了它也有连接指向后方。 让我们看一下最简单的 RNN,它由一个神经元接收输入,产生一个输出,并将输出发送回自己,如图 14-1(左)所示。 在每个时间步t(也称为一个帧),这个循环神经元接收输入 x(t)x(t) 以及它自己的前一时间步长 y(t−1)y(t−1) 的输出。 我们可以用时间轴来表示这个微小的网络,如图 14-1(右)所示。 这被称为随着时间的推移展开网络。 你可以轻松创建一个循环神经元层。 在每个时间步t,每个神经元都接收输入向量 x(t)x(t) 和前一个时间步 y(t−1)y(t−1) 的输出向量,如图 14-2 所示。 请注意,输入和输出都是向量(当只有一个神经元时,输出是一个标量)。 每个循环神经元有两组权重:一组用于输入 x(t)x(t),另一组用于前一时间步长 y(t−1)y(t−1) 的输出。 我们称这些权重向量为 wxwx 和 wywy。如公式 14-1 所示(b是偏差项,φ(·)是激活函数,例如 ReLU),可以计算单个循环神经元的输出。 就像前馈神经网络一样,我们可以使用上一个公式的向量化形式,对整个小批量计算整个层的输出(见公式 14-2)。 Y(t)是Y(t)是m \times n_{neurons}矩阵,包含在最小批次中每个实例在时间步‘t‘处的层输出(‘m‘是小批次中的实例数,矩阵,包含在最小批次中每个实例在时间步‘t‘处的层输出(‘m‘是小批次中的实例数,n_{neurons}$ 是神经元数)。 X(t)X(t) 是 m×ninputsm×ninputs 矩阵,包含所有实例的输入的 (ninputsninputs 是输入特征的数量)。 WxWx 是 ninputs×nneuronsninputs×nneurons 矩阵,包含当前时间步的输入的连接权重的。 WyWy 是 nneurons×nneuronsnneurons×nneurons 矩阵,包含上一个时间步的输出的连接权重。 权重矩阵 WxWx 和 WyWy 通常连接成单个权重矩阵W,形状为 (ninputs+nneurons)×nneurons(ninputs+nneurons)×nneurons(见公式 14-2 的第二行) b是大小为 nneuronsnneurons 的向量,包含每个神经元的偏置项。 注意,Y(t)Y(t) 是 X(t)X(t) 和 Y(t−1)Y(t−1) 的函数,它是 X(t−1)X(t−1) 和 Y(t−2)Y(t−2) 的函数,它是 X(t−2)X(t−2) 和 Y(t−3)Y(t−3) 的函数,等等。 这使得 Y(t)Y(t) 是从时间t = 0开始的所有输入(即 X(0)X(0),X(1)X(1),…,X(t)X(t))的函数。 在第一个时间步,t = 0,没有以前的输出,所以它们通常被假定为全零。 记忆单元 由于时间t的循环神经元的输出,是由所有先前时间步骤计算出来的的函数,你可以说它有一种记忆形式。一个神经网络的一部分,跨越时间步长保留一些状态,称为存储单元(或简称为单元)。单个循环神经元或循环神经元层是非常基本的单元,但本章后面我们将介绍一些更为复杂和强大的单元类型。 一般情况下,时间步t处的单元状态,记为 h(t)h(t)(h代表“隐藏”),是该时间步的某些输入和前一时间步的状态的函数:h^{(t)} = f(h^{(t-1), x^{(t)})h^{(t)} = f(h^{(t-1), x^{(t)})。 其在时间步`t`处的输出,表示为 y(t)y(t),也和前一状态和当前输入的函数有关。 在我们已经讨论过的基本单元的情况下,输出等于单元状态,但是在更复杂的单元中并不总是如此,如图 14-3 所示。 输入和输出序列 RNN 可以同时进行一系列输入并产生一系列输出(见图 14-4,左上角的网络)。 例如,这种类型的网络对于预测时间序列(如股票价格)非常有用:你在过去的N天内给出价格,并且它必须输出向未来一天移动的价格(即从N - 1天前到明天)。 或者,你可以向网络输入一系列输入,并忽略除最后一个之外的所有输出(请参阅右上角的网络)。 换句话说,这是一个向量网络的序列。 例如,你可以向网络提供与电影评论相对应的单词序列,并且网络将输出情感评分(例如,从-1 [恨]到+1 [爱])。 相反,你可以在第一个时间步中为网络提供一个输入(而在其他所有时间步中为零),然后让它输出一个序列(请参阅左下角的网络)。 这是一个向量到序列的网络。 例如,输入可以是图像,输出可以是该图像的标题。 最后,你可以有一个序列到向量网络,称为编码器,后面跟着一个称为解码器的向量到序列网络(参见右下角的网络)。 例如,这可以用于将句子从一种语言翻译成另一种语言。 你会用一种语言给网络喂一个句子,编码器会把这个句子转换成单一的向量表示,然后解码器将这个向量解码成另一种语言的句子。 这种称为编码器 - 解码器的两步模型,比用单个序列到序列的 RNN(如左上方所示的那个)快速地进行翻译要好得多,因为句子的最后一个单词可以 影响翻译的第一句话,所以你需要等到听完整个句子才能翻译。 TensorFlow 中的基本 RNN 首先,我们来实现一个非常简单的 RNN 模型,而不使用任何 TensorFlow 的 RNN 操作,以更好地理解发生了什么。 我们将使用 tanh 激活函数创建由 5 个循环神经元的循环层组成的 RNN(如图 14-2 所示的 RNN)。 我们将假设 RNN 只运行两个时间步,每个时间步输入大小为 3 的向量。 下面的代码构建了这个 RNN,展开了两个时间步骤: n_inputs = 3 n_neurons = 5 X0 = tf.placeholder(tf.float32, [None, n_inputs]) X1 = tf.placeholder(tf.float32, [None, n_inputs]) Wx = tf.Variable(tf.random_normal(shape=[n_inputs, n_neurons], dtype=tf.float32)) Wy = tf.Variable(tf.random_normal(shape=[n_neurons, n_neurons], dtype=tf.float32)) b = tf.Variable(tf.zeros([1, n_neurons], dtype=tf.float32)) Y0 = tf.tanh(tf.matmul(X0, Wx) + b) Y1 = tf.tanh(tf.matmul(Y0, Wy) + tf.matmul(X1, Wx) + b) init = tf.global_variables_initializer() 这个网络看起来很像一个双层前馈神经网络,有一些改动:首先,两个层共享相同的权重和偏差项,其次,我们在每一层都有输入,并从每个层获得输出。 为了运行模型,我们需要在两个时间步中都有输入,如下所示: # Mini-batch: instance 0,instance 1,instance 2,instance 3 X0_batch = np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8], [9, 0, 1]]) # t = 0 X1_batch = np.array([[9, 8, 7], [0, 0, 0], [6, 5, 4], [3, 2, 1]]) # t = 1 with tf.Session() as sess: init.run() Y0_val, Y1_val = sess.run([Y0, Y1], feed_dict={X0: X0_batch, X1: X1_batch}) 这个小批量包含四个实例,每个实例都有一个由两个输入组成的输入序列。 最后,Y0_val和Y1_val在所有神经元和小批量中的所有实例的两个时间步中包含网络的输出: >>> print(Y0_val) # output at t = 0 [[-0.2964572 0.82874775 -0.34216955 -0.75720584 0.19011548] # instance 0 [-0.12842922 0.99981797 0.84704727 -0.99570125 0.38665548] # instance 1 [ 0.04731077 0.99999976 0.99330056 -0.999933 0.55339795] # instance 2 [ 0.70323634 0.99309105 0.99909431 -0.85363263 0.7472108 ]] # instance 3 >>> print(Y1_val) # output at t = 1 [[ 0.51955646 1\. 0.99999022 -0.99984968 -0.24616946] # instance 0 [-0.70553327 -0.11918639 0.48885304 0.08917919 -0.26579669] # instance 1 [-0.32477224 0.99996376 0.99933046 -0.99711186 0.10981458] # instance 2 [-0.43738723 0.91517633 0.97817528 -0.91763324 0.11047263]] # instance 3 这并不难,但是当然如果你想能够运行 100 多个时间步骤的 RNN,这个图形将会非常大。 现在让我们看看如何使用 TensorFlow 的 RNN 操作创建相同的模型。 完整代码 import numpy as np import tensorflow as tf if __name__ == '__main__': n_inputs = 3 n_neurons = 5 X0 = tf.placeholder(tf.float32, [None, n_inputs]) X1 = tf.placeholder(tf.float32, [None, n_inputs]) Wx = tf.Variable(tf.random_normal(shape=[n_inputs, n_neurons], dtype=tf.float32)) Wy = tf.Variable(tf.random_normal(shape=[n_neurons, n_neurons], dtype=tf.float32)) b = tf.Variable(tf.zeros([1, n_neurons], dtype=tf.float32)) Y0 = tf.tanh(tf.matmul(X0, Wx) + b) Y1 = tf.tanh(tf.matmul(Y0, Wy) + tf.matmul(X1, Wx) + b) init = tf.global_variables_initializer() # Mini-batch: instance 0,instance 1,instance 2,instance 3 X0_batch = np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8], [9, 0, 1]]) # t = 0 X1_batch = np.array([[9, 8, 7], [0, 0, 0], [6, 5, 4], [3, 2, 1]]) # t = 1 with tf.Session() as sess: init.run() Y0_val, Y1_val = sess.run([Y0, Y1], feed_dict={X0: X0_batch, X1: X1_batch}) print(Y0_val,'\n') print(Y1_val) 时间上的静态展开 static_rnn()函数通过链接单元来创建一个展开的 RNN 网络。 下面的代码创建了与上一个完全相同的模型: X0 = tf.placeholder(tf.float32, [None, n_inputs]) X1 = tf.placeholder(tf.float32, [None, n_inputs]) basic_cell = tf.contrib.rnn.BasicRNNCell(num_units=n_neurons) output_seqs, states = tf.contrib.rnn.static_rnn(basic_cell, [X0, X1], dtype=tf.float32) Y0, Y1 = output_seqs 首先,我们像以前一样创建输入占位符。 然后,我们创建一个BasicRNNCell,你可以将其视为一个工厂,创建单元的副本以构建展开的 RNN(每个时间步一个)。 然后我们调用static_rnn(),向它提供单元工厂和输入张量,并告诉它输入的数据类型(用来创建初始状态矩阵,默认情况下是全零)。 static_rnn()函数为每个输入调用单元工厂的__call __()函数,创建单元的两个副本(每个单元包含 5 个循环神经元的循环层),并具有共享的权重和偏置项,像前面一样。static_rnn()函数返回两个对象。 第一个是包含每个时间步的输出张量的 Python 列表。 第二个是包含网络最终状态的张量。 当你使用基本的单元时,最后的状态就等于最后的输出。 如果有 50 个时间步长,则不得不定义 50 个输入占位符和 50 个输出张量。而且,在执行时,你将不得不为 50 个占位符中的每个占位符输入数据并且还要操纵 50 个输出。我们来简化一下。下面的代码再次构建相同的 RNN,但是这次它需要一个形状为[None,n_steps,n_inputs]的单个输入占位符,其中第一个维度是最小批量大小。然后提取每个时间步的输入序列列表。 X_seqs是形状为n_steps的 Python 列表,包含形状为[None,n_inputs]的张量,其中第一个维度同样是最小批量大小。为此,我们首先使用transpose()函数交换前两个维度,以便时间步骤现在是第一维度。然后,我们使 unstack()函数沿第一维(即每个时间步的一个张量)提取张量的 Python 列表。接下来的两行和以前一样。最后,我们使用stack()函数将所有输出张量合并成一个张量,然后我们交换前两个维度得到最终输出张量,形状为[None, n_steps,n_neurons](第一个维度是小批量大小)。 X = tf.placeholder(tf.float32, [None, n_steps, n_inputs]) X_seqs = tf.unstack(tf.transpose(X, perm=[1, 0, 2])) basic_cell = tf.contrib.rnn.BasicRNNCell(num_units=n_neurons) output_seqs, states = tf.contrib.rnn.static_rnn(basic_cell, X_seqs, dtype=tf.float32) outputs = tf.transpose(tf.stack(output_seqs), perm=[1, 0, 2]) 现在我们可以通过给它提供一个包含所有小批量序列的张量来运行网络: X_batch = np.array([ # t = 0 t = 1 [[0, 1, 2], [9, 8, 7]], # instance 1 [[3, 4, 5], [0, 0, 0]], # instance 2 [[6, 7, 8], [6, 5, 4]], # instance 3 [[9, 0, 1], [3, 2, 1]], # instance 4 ]) with tf.Session() as sess: init.run() outputs_val = outputs.eval(feed_dict={X: X_batch}) 我们得到所有实例,所有时间步长和所有神经元的单一outputs_val张量: 但是,这种方法仍然会建立一个每个时间步包含一个单元的图。 如果有 50 个时间步,这个图看起来会非常难看。 这有点像写一个程序而没有使用循环(例如,Y0 = f(0,X0);Y1 = f(Y0,X1);Y2 = f(Y1,X2);…;Y50 = f(Y49,X50))。 如果使用大图,在反向传播期间(特别是在 GPU 内存有限的情况下),你甚至可能会发生内存不足(OOM)错误,因为它必须在正向传递期间存储所有张量值,以便可以使用它们在反向传播期间计算梯度。 幸运的是,有一个更好的解决方案:dynamic_rnn()函数。 时间上的动态展开 dynamic_rnn()函数使用while_loop()操作,在单元上运行适当的次数,如果要在反向传播期间将 GPU内 存交换到 CPU 内存,可以设置swap_memory = True,以避免内存不足错误。 方便的是,它还可以在每个时间步(形状为[None, n_steps, n_inputs])接受所有输入的单个张量,并且在每个时间步(形状[None, n_steps, n_neurons])上输出所有输出的单个张量。 没有必要堆叠,拆散或转置。 以下代码使用dynamic_rnn()函数创建与之前相同的 RNN。 这太好了! 完整代码 import numpy as np import tensorflow as tf import pandas as pd if __name__ == '__main__': n_steps = 2 n_inputs = 3 n_neurons = 5 X = tf.placeholder(tf.float32, [None, n_steps, n_inputs]) basic_cell = tf.contrib.rnn.BasicRNNCell(num_units=n_neurons) outputs, states = tf.nn.dynamic_rnn(basic_cell, X, dtype=tf.float32) init = tf.global_variables_initializer() X_batch = np.array([ [[0, 1, 2], [9, 8, 7]], # instance 1 [[3, 4, 5], [0, 0, 0]], # instance 2 [[6, 7, 8], [6, 5, 4]], # instance 3 [[9, 0, 1], [3, 2, 1]], # instance 4 ]) with tf.Session() as sess: init.run() outputs_val = outputs.eval(feed_dict={X: X_batch}) print(outputs_val) 在反向传播期间,while_loop()操作会执行相应的步骤:在正向传递期间存储每次迭代的张量值,以便在反向传递期间使用它们来计算梯度。 处理变长输入序列 到目前为止,我们只使用固定大小的输入序列(全部正好两个步长)。 如果输入序列具有可变长度(例如,像句子)呢? 在这种情况下,你应该在调用dynamic_rnn()(或static_rnn())函数时设置sequence_length参数;它必须是一维张量,表示每个实例的输入序列的长度。 例如: n_steps = 2 n_inputs = 3 n_neurons = 5 reset_graph() X = tf.placeholder(tf.float32, [None, n_steps, n_inputs]) basic_cell = tf.contrib.rnn.BasicRNNCell(num_units=n_neurons) seq_length = tf.placeholder(tf.int32, [None]) outputs, states = tf.nn.dynamic_rnn(basic_cell, X, dtype=tf.float32, sequence_length=seq_length) 例如,假设第二个输入序列只包含一个输入而不是两个输入。 为了适应输入张量X,必须填充零向量(因为输入张量的第二维是最长序列的大小,即 2) X_batch = np.array([ # step 0 step 1 [[0, 1, 2], [9, 8, 7]], # instance 1 [[3, 4, 5], [0, 0, 0]], # instance 2 (padded with zero vectors) [[6, 7, 8], [6, 5, 4]], # instance 3 [[9, 0, 1], [3, 2, 1]], # instance 4 ]) seq_length_batch = np.array([2, 1, 2, 2]) 当然,你现在需要为两个占位符X和seq_length提供值: with tf.Session() as sess: init.run() outputs_val, states_val = sess.run( [outputs, states], feed_dict={X: X_batch, seq_length: seq_length_batch}) 现在,RNN 输出序列长度的每个时间步都会输出零向量(查看第二个时间步的第二个输出): 此外,状态张量包含每个单元的最终状态(不包括零向量): 处理变长输出序列 如果输出序列长度不一样呢? 如果事先知道每个序列的长度(例如,如果知道长度与输入序列的长度相同),那么可以按照上面所述设置sequence_length参数。 不幸的是,通常这是不可能的:例如,翻译后的句子的长度通常与输入句子的长度不同。 在这种情况下,最常见的解决方案是定义一个称为序列结束标记(EOS 标记)的特殊输出。 任何在 EOS 后面的输出应该被忽略(我们将在本章稍后讨论)。 好,现在你知道如何建立一个 RNN 网络(或者更准确地说是一个随着时间的推移而展开的 RNN 网络)。 但是你怎么训练呢? 训练 RNN 为了训练一个 RNN,诀窍是在时间上展开(就像我们刚刚做的那样),然后简单地使用常规反向传播(见图 14-5)。 这个策略被称为时间上的反向传播(BPTT)。 就像在正常的反向传播中一样,展开的网络(用虚线箭头表示)有第一个正向传递。然后使用损失函数评估输出序列 (其中 tmintmin 和 tmaxtmax 是第一个和最后一个输出时间步长,不计算忽略的输出),并且该损失函数的梯度通过展开的网络向后传播(实线箭头);最后使用在 BPTT 期间计算的梯度来更新模型参数。 请注意,梯度在损失函数所使用的所有输出中反向流动,而不仅仅通过最终输出(例如,在图 14-5 中,损失函数使用网络的最后三个输出 Y(2)Y(2),Y(3)Y(3) 和 Y(4)Y(4),所以梯度流经这三个输出,但不通过 Y(0)Y(0) 和 Y(1)Y(1))。 而且,由于在每个时间步骤使用相同的参数W和b,所以反向传播将做正确的事情并且总结所有时间步骤。 训练序列分类器 我们训练一个 RNN 来分类 MNIST 图像。 卷积神经网络将更适合于图像分类(见第 13 章),但这是一个你已经熟悉的简单例子。 我们将把每个图像视为 28 行 28 像素的序列(因为每个MNIST图像是28×28像素)。 我们将使用 150 个循环神经元的单元,再加上一个全连接层,其中包含连接到上一个时间步的输出的 10 个神经元(每个类一个),然后是一个 softmax 层(见图 14-6)。 建模阶段非常简单, 它和我们在第 10 章中建立的 MNIST 分类器几乎是一样的,只是展开的 RNN 替换了隐层。 注意,全连接层连接到状态张量,其仅包含 RNN 的最终状态(即,第 28 个输出)。 另请注意,y是目标类的占位符。 n_steps = 28 n_inputs = 28 n_neurons = 150 n_outputs = 10 learning_rate = 0.001 X = tf.placeholder(tf.float32, [None, n_steps, n_inputs]) y = tf.placeholder(tf.int32, [None]) basic_cell = tf.contrib.rnn.BasicRNNCell(num_units=n_neurons) outputs, states = tf.nn.dynamic_rnn(basic_cell, X, dtype=tf.float32) logits = tf.layers.dense(states, n_outputs) xentropy = tf.nn.sparse_softmax_cross_entropy_with_logits(labels=y, logits=logits) loss = tf.reduce_mean(xentropy) optimizer = tf.train.AdamOptimizer(learning_rate=learning_rate) training_op = optimizer.minimize(loss) correct = tf.nn.in_top_k(logits, y, 1) accuracy = tf.reduce_mean(tf.cast(correct, tf.float32)) init = tf.global_variables_initializer() 现在让我们加载 MNIST 数据,并按照网络的预期方式将测试数据重塑为[batch_size, n_steps, n_inputs]。 我们之后会关注训练数据的重塑。 from tensorflow.examples.tutorials.mnist import input_data mnist = input_data.read_data_sets("/tmp/data/") X_test = mnist.test.images.reshape((-1, n_steps, n_inputs)) y_test = mnist.test.labels 现在我们准备训练 RNN 了。 执行阶段与第 10 章中 MNIST 分类器的执行阶段完全相同,不同之处在于我们在将每个训练的批量提供给网络之前要重新调整。 batch_size = 150 with tf.Session() as sess: init.run() for epoch in range(n_epochs): for iteration in range(mnist.train.num_examples // batch_size): X_batch, y_batch = mnist.train.next_batch(batch_size) X_batch = X_batch.reshape((-1, n_steps, n_inputs)) sess.run(training_op, feed_dict={X: X_batch, y: y_batch}) acc_train = accuracy.eval(feed_dict={X: X_batch, y: y_batch}) acc_test = accuracy.eval(feed_dict={X: X_test, y: y_test}) print(epoch, "Train accuracy:", acc_train, "Test accuracy:", acc_test) 输出应该是这样的: 我们获得了超过 98% 的准确性 - 不错! 另外,通过调整超参数,使用 He 初始化初始化 RNN 权重,更长时间训练或添加一些正则化(例如,droupout),你肯定会获得更好的结果。 你可以通过将其构造代码包装在一个变量作用域内(例如,使用variable_scope("rnn", initializer = variance_scaling_initializer())来使用 He 初始化)来为 RNN 指定初始化器。 为预测时间序列而训练 现在让我们来看看如何处理时间序列,如股价,气温,脑电波模式等等。 在本节中,我们将训练一个 RNN 来预测生成的时间序列中的下一个值。 每个训练实例是从时间序列中随机选取的 20 个连续值的序列,目标序列与输入序列相同,除了向后移动一个时间步(参见图14-7)。 首先,我们来创建一个 RNN。 它将包含 100 个循环神经元,并且我们将在 20 个时间步骤上展开它,因为每个训练实例将是 20 个输入那么长。 每个输入将仅包含一个特征(在该时间的值)。 目标也是 20 个输入的序列,每个输入包含一个值。 代码与之前几乎相同: 一般来说,你将不只有一个输入功能。 例如,如果你试图预测股票价格,则你可能在每个时间步骤都会有许多其他输入功能,例如竞争股票的价格,分析师的评级或可能帮助系统进行预测的任何其他功能。 在每个时间步,我们现在有一个大小为 100 的输出向量。但是我们实际需要的是每个时间步的单个输出值。 最简单的解决方法是将单元包装在OutputProjectionWrapper中。 单元包装器就像一个普通的单元,代理每个方法调用一个底层单元,但是它也增加了一些功能。Out putProjectionWrapper在每个输出之上添加一个完全连接的线性神经元层(即没有任何激活函数)(但不影响单元状态)。 所有这些完全连接的层共享相同(可训练)的权重和偏差项。 结果 RNN 如图 14-8 所示。 包装单元是相当容易的。 让我们通过将BasicRNNCell包装到OutputProjectionWrapper中来调整前面的代码: cell =tf.contrib.rnn.OutputProjectionWrapper( tf.contrib.rnn.BasicRNNCell(num_units=n_neurons,activation=tf.nn.relu), output_size=n_outputs) 到现在为止还挺好。 现在我们需要定义损失函数。 我们将使用均方误差(MSE),就像我们在之前的回归任务中所做的那样。 接下来,我们将像往常一样创建一个 Adam 优化器,训练操作和变量初始化操作: 生成 RNN 到现在为止,我们已经训练了一个能够预测未来时刻样本值的模型,正如前文所述,可以用模型来生成新的序列。 为模型提供 长度为n_steps的种子序列, 比如全零序列,然后通过模型预测下一时刻的值;把该预测值添加到种子序列的末尾,用最后面 长度为n_steps的序列做为新的种子序列,做下一次预测,以此类推生成预测序列。 如图 14-11 所示,这个过程产生的序列会跟原始时间序列相似。 sequence = [0.] * n_steps for iteration in range(300): X_batch = np.array(sequence[-n_steps:].reshape(1, n_steps, 1) y_pred = sess.run(outputs, feed_dict={X: X_batch} sequence.append(y_pred[0, -1, 0] 如果你试图把约翰·列侬的唱片塞给一个 RNN 模型,看它能不能生成下一张《想象》专辑。 注 约翰·列侬 有一张专辑《Imagine》(1971),这里取其双关的意思 也许你需要一个更强大的 RNN 网络,它有更多的神经元,层数也更多。下面来探究一下深度 RNN。 深度 RNN 一个朴素的想法就是把一层层神经元堆叠起来,正如图 14-12 所示的那样,它呈现了一种深度 RNN。 为了用 TensorFlow 实现深度 RNN,可先创建一些神经单元,然后堆叠进MultiRNNCell。 以下代码中创建了 3 个相同的神经单元(当然也可以用不同类别的、包含不同不同数量神经元的单元) n_neurons = 100 n_layers = 3 basic_cell = tf.contrib.rnn.BasicRNNCell(num_units=n_neurons) multi_layer_cell = tf.contrib.rnn.MultiRNNCell([basic_cell] * n_layers) outputs, states = tf.nn.dynamic_rnn(multi_layer_cell, X, dtype=tf.float32) 这些代码就完成了这部分堆叠工作。status变量包含了每层的一个张量,这个张量就代表了该层神经单元的最终状态(维度为[batch_size, n_neurons])。 如果在创建MultiRNNCell时设置了state_is_tuple=False,那么status变量就变成了单个张量,它包含了每一层的状态,其在列的方向上进行了聚合,维度为[batch_size, n_layers*n_neurons]。 注意在 TensorFlow 版本 0.11.0 之前,status是单个张量是默认设置。 在多个 GPU 上分布式部署深度 RNN 网络 Dropout 的应用 对于深层深度 RNN,在训练集上很容易过拟合。Dropout 是防止过拟合的常用技术。 可以简单的在 RNN 层之前或之后添加一层 Dropout 层,但如果需要在 RNN 层之间应用 Dropout 技术就需要DropoutWrapper。 下面的代码中,每一层的 RNN 的输入前都应用了 Dropout,Dropout 的概率为 50%。 keep_prob = 0.5 cell = tf.contrib.rnn.BasicRNNCell(num_units=n_neurons) cell_drop = tf.contrib.rnn.DropoutWrapper(cell, input_keep_prob=keep_prob) multi_layer_cell = tf.contrib.rnn.MultiRNNCell([cell_drop]*n_layers) rnn_outputs, states = tf.nn.dynamic_rnn(multi_layer_cell, X, dtype=tf.float32) 同时也可以通过设置output_keep_prob来在输出应用 Dropout 技术。 然而在以上代码中存在的主要问题是,Dropout 不管是在训练还是测试时都起作用了,而我们想要的仅仅是在训练时应用 Dropout。 很不幸的是DropoutWrapper不支持is_training这样一个设置选项。因此必须自己写 Dropout 包装类,或者创建两个计算图,一个用来训练,一个用来测试。后则可通过如下面代码这样实现。 import sys is_training = (sys.argv[-1] == "train") X = tf.placeholder(tf.float32, [None, n_steps, n_inputs]) y = tf.placeholder(tf.float32, [None, n_steps, n_outputs]) cell = tf.contrib.rnn.BasicRNNCell(num_units=n_neurons) if is_training: cell = tf.contrib.rnn.DropoutWrapper(cell, input_keep_prob=keep_prob) multi_layer_cell = tf.contrib.rnn.MultiRNNCell([cell]*n_layers) rnn_outpus, status = tf.nn.dynamic_rnn(multi_layer_cell, X, dtype=tf.float32) [...] # bulid the rest of the graph init = tf.global_variables_initializer() saver = tf.train.Saver() with tf.Session() as sess: if is_training: init.run() for iteration in range(n_iterations): [...] # train the model save_path = saver.save(sess, "/tmp/my_model.ckpt") else: saver.restore(sess, "/tmp/my_model.ckpt") [...] # use the model 通过以上的方法就能够训练各种 RNN 网络了。然而对于长序列的 RNN 训练还言之过早,事情会变得有一些困难。 那么我们来探讨一下究竟这是为什么和怎么应对呢? 长时训练的困难 在训练长序列的 RNN 模型时,那么就需要把 RNN 在时间维度上展开成很深的神经网络。正如任何深度神经网络一样,其面临着梯度消失/爆炸的问题,使训练无法终止或收敛。 很多之前讨论过的缓解这种问题的技巧都可以应用在深度展开的 RNN 网络:好的参数初始化方式,非饱和的激活函数(如 ReLU),批量规范化(Batch Normalization), 梯度截断(Gradient Clipping),更快的优化器。 即便如此, RNN 在处理适中的长序列(如 100 输入序列)也在训练时表现的很慢。 最简单和常见的方法解决训练时长问题就是在训练阶段仅仅展开限定时间步长的 RNN 网络,一种称为截断时间反向传播的算法。 在 TensorFlow 中通过截断输入序列来简单实现这种功能。例如在时间序列预测问题上可以在训练时减小n_steps来实现截断。理所当然这种方法会限制模型在长期模式的学习能力。一种变通方案时确保缩短的序列中包含旧数据和新数据,从而使模型获得两者信息(如序列同时包含最近五个月的数据,最近五周的和最近五天的数据)。 问题时如何确保从去年的细分类中获取的数据有效性呢?这期间短暂但重要的事件对后世的影响,甚至时数年后这种影响是否一定要考虑在内呢(如选举结果)?这种方案有其先天的不足之处。 在长的时间训练过程中,第二个要面临的问题时第一个输入的记忆会在长时间运行的 RNN 网络中逐渐淡去。确实,通过变换的方式,数据穿流在 RNN 网络之中,每个时间步长后都有一些信息被抛弃掉了。那么在一定时间后,第一个输入实际上会在 RNN 的状态中消失于无形。 比如说,你想要分析长篇幅的影评的情感类别,影评以"I love this movie"开篇,并辅以各种改善影片的一些建议。试想一下,如果 RNN 网络逐渐忘记了开头的几个词,RNN 网络的判断完全有可能会对影评断章取义。 为了解决其中的问题,各种能够携带长时记忆的神经单元的变体被提出。这些变体是有效的,往往基本形式的神经单元就不怎么被使用了。 首先了解一下最流行的一种长时记忆神经单元:长短时记忆神经单元 LSTM。 LSTM 单元 长短时记忆单元在 1997 年由 S.H. 和 J.S. 首次提出 [3],并在接下来的几年内经过 A.G,H.S [4],W.Z [5] 等数位研究人员的改进逐渐形成。如果把 LSTM 单元看作一个黑盒,从外围看它和基本形式的记忆单元很相似,但 LSTM 单元会比基本单元性能更好,收敛更快,能够感知数据的长时依赖。TensorFlow 中通过BasicLSTMCell实现 LSTM 单元。 [3]: “Long Short-Term Memory,” S.Hochreiter and J.Schmidhuber(1997) [4]: “Long Short-Term Memory Recurrent Neural Network Architectures for Large Scale Acoustic Modeling,” H.Sak et al.(2014) [5]: “Recurrent Neural Network Regularization,” W.Zaremba et al.(2015) lstm_cell = tf.contrib.rnn.BasicLSTMCell(num_units=n_neurons) LSTM 单元的工作机制是什么呢?在图 14-13 中展示了基本 LSTM 单元的结构。 不观察 LSTM 单元内部,除了一些不同外跟常规 RNN 单元极其相似。这些不同包括 LSTM 单元状态分为两个向量:h(t)h(t) 和 c(t)c(t)(c代表 cell)。可以简单认为 h(t)h(t) 是短期记忆状态,c(t)c(t) 是长期记忆状态。 好,我们来打开盒子。LSTM 单元的核心思想是其能够学习从长期状态中存储什么,忘记什么,读取什么。长期状态 c(t−1)c(t−1) 从左向右在网络中传播,依次经过遗忘门(forget gate)时丢弃一些记忆,之后加法操作增加一些记忆(从输入门中选择一些记忆)。输出 c(t)c(t) 不经任何转换直接输出。每个单位时间步长后,都有一些记忆被抛弃,新的记忆被添加进来。另一方面,长时状态经过 tanh 激活函数通过输出门得到短时记忆 h(t)h(t),同时它也是这一时刻的单元输出结果 y(t)y(t)。接下来讨论一下新的记忆时如何产生的,门的功能是如何实现的。 首先,当前的输入向量 x(t)x(t) 和前一时刻的短时状态 h(t−1)h(t−1) 作为输入传给四个全连接层,这四个全连接层有不同的目的: 其中主要的全连接层输出 g(t)g(t),它的常规任务就是解析当前的输入 x(t)x(t) 和前一时刻的短时状态 h(t−1)h(t−1)。在基本形式的 RNN 单元中,就与这种形式一样,直接输出了 h(t)h(t) 和 y(t)y(t)。与之不同的是 LSTM 单元会将一部分 g(t)g(t) 存储在长时状态中。 其它三个全连接层被称为门控制器(gate controller)。其采用 Logistic 作为激活函数,输出范围在 0 到 1 之间。正如在结构图中所示,这三个层的输出提供给了逐元素乘法操作,当输入为 0 时门关闭,输出为 1 时门打开。分别为: 遗忘门(forget gat)由 f(t)f(t) 控制,来决定哪些长期记忆需要被擦除; 输入门(input gate) 由 i(t)i(t) 控制,它的作用是处理哪部分 g(t)g(t) 应该被添加到长时状态中,也就是为什么被称为部分存储。 输出门(output gate)由 o(t)o(t) 控制,在这一时刻的输出 h(t)h(t) 和 y(t)y(t) 就是由输出门控制的,从长时状态中读取的记忆。 简要来说,LSTM 单元能够学习到识别重要输入(输入门作用),存储进长时状态,并保存必要的时间(遗忘门功能),并学会提取当前输出所需要的记忆。 这也解释了 LSTM 单元能够在提取长时序列,长文本,录音等数据中的长期模式的惊人成功的原因。 公式 14-3 总结了如何计算单元的长时状态,短时状态,和单个输入情形时每单位步长的输出(小批量的方程形式与单输入的形式相似)。 WxiWxi,WxfWxf,WxoWxo,WxgWxg 是四个全连接层关于输入向量 x(t)x(t) 的权重。 WhiWhi,WhfWhf,WhoWho,WhgWhg 是四个全连接层关于上一时刻的短时状态 h(t−1)h(t−1) 的权重。 bibi,bfbf,bobo,bgbg 是全连接层的四个偏置项,需要注意的是 TensorFlow 将其初始化为全 1 向量,而非全 0,为了阻止网络初始训练状态下,各个门关闭从而忘记所有记忆。 窥孔连接 基本形式的 LSTM 单元中,门的控制仅有当前的输入 x(t)x(t) 和前一时刻的短时状态 h(t−1)h(t−1)。不妨让各个控制门窥视一下长时状态,获取一些上下文信息不失为一种尝试。该想法由 F.G.he J.S. 在 2000 年提出。他们提出的 LSTM 的变体拥有叫做窥孔连接的额外连接:把前一时刻的长时状态 c(t−1)c(t−1) 加入遗忘门和输入门控制的输入,当前时刻的长时状态加入输出门的控制输入。 TensorFLow 中由LSTMCell实现以上变体 LSTM,并设置use_peepholes=True。 lstm_cell = tf.contrib.rnn.LSTMCell(num_units=n_neurons, use_peepholes=True) 在众多 LSTM 变体中,一个特别流行的变体就是 GRU 单元。 GRU 单元 门控循环单元(图 14-14)在 2014 年的 K.Cho et al. 的论文中提出,并且此文也引入了前文所述的编解码网络。 门控循环单元是 LSTM 单元的简化版本,能实现同样的性能,这也说明了为什么它能越来越流行。简化主要在一下几个方面: 长时状态和短时状态合并为一个向量 h(t)h(t)。 用同一个门控制遗忘门和输入门。如果门控制输入 1,输入门打开,遗忘门关闭,反之亦然。也就是说,如果当有新的记忆需要存储,那么就必须实现在其对应位置事先擦除该处记忆。这也构成了 LSTM 本身的常见变体。 GRU 单元取消了输出门,单元的全部状态就是该时刻的单元输出。与此同时,增加了一个控制门 r(t)r(t) 来控制哪部分前一时间步的状态在该时刻的单元内呈现。 公式 14-4 总结了如何计算单个输入情形时每单位步的单元的状态。 在 TensoFlow 中创建 GRU 单元很简单: gru_cell = tf.contrib.rnn.GRUCell(n_units=n_neurons) LSTM 或 GRU 单元是近年来 RNN 成功背后的主要原因之一,特别是在自然语言处理(NLP)中的应用。 自然语言处理 现在,大多数最先进的 NLP 应用(如机器翻译,自动摘要,解析,情感分析等),现在(至少一部分)都基于 RNN。 在最后一节中,我们将快速了解机器翻译模型的概况。 TensorFlow 的很厉害的 Word2Vec 和 Seq2Seq 教程非常好地介绍了这个主题,所以你一定要阅读一下。 单词嵌入 在我们开始之前,我们需要选择一个词的表示形式。 一种选择可以是,使用单热向量表示每个词。 假设你的词汇表包含 5 万个单词,那么第n个单词将被表示为 50,000 维的向量,除了第n个位置为 1 之外,其它全部为 0。 然而,对于如此庞大的词汇表,这种稀疏表示根本就不会有效。 理想情况下,你希望相似的单词具有相似的表示形式,这使得模型可以轻松地将所学的关于单词的只是,推广到所有相似单词。 例如,如果模型被告知"I drink milk"是一个有效的句子,并且如果它知道"milk"接近于"water",而不同于"shoes",那么它会知道"I drink water" 也许是一个有效的句子,而"I drink shoes"可能不是。 但你如何提出这样一个有意义的表示呢? 最常见的解决方案是,用一个相当小且密集的向量(例如 150 维)表示词汇表中的每个单词,称为嵌入,并让神经网络在训练过程中,为每个单词学习一个良好的嵌入。 在训练开始时,嵌入只是随机选择的,但在训练过程中,反向传播会自动更新嵌入,来帮助神经网络执行任务。 通常这意味着,相似的词会逐渐彼此靠近,甚至最终以一种相当有意义的方式组织起来。 例如,嵌入可能最终沿着各种轴分布,它们代表性别,单数/复数,形容词/名词。 结果可能真的很神奇。 在TensorFlow中,首先需要创建一个变量来表示词汇表中每个词的嵌入(随机初始化): vocabulary_size = 50000 embedding_size = 150 embeddings = tf.Variable( tf.random_uniform([vocabulary_size, embedding_size], -1.0, 1.0)) 现在假设你打算将句子"I drink milk"提供给你的神经网络。 你应该首先对句子进行预处理并将其分解成已知单词的列表。 例如,你可以删除不必要的字符,用预定义的标记词(如"[UNK]")替换未知单词,用"[NUM]"替换数字值,用"[URL]"替换 URL 等。 一旦你有了一个已知单词列表,你可以在字典中查找每个单词的整数标识符(从 0 到 49999),例如[72,3335,288]。 此时,你已准备好使用占位符将这些单词标识符提供给 TensorFlow,并应用embedding_lookup()函数来获取相应的嵌入: train_inputs = tf.placeholder(tf.int32, shape=[None]) # from ids... embed = tf.nn.embedding_lookup(embeddings, train_inputs) # ...to embeddings 一旦你的模型习得了良好的词嵌入,它们实际上可以在任何 NLP 应用中高效复用:毕竟,"milk"依然接近于"water",而且不管你的应用是什么,它都不同于"shoes"。 实际上,你可能需要下载预训练的单词嵌入,而不是训练自己的单词嵌入。 就像复用预训练层(参见第 11 章)一样,你可以选择冻结预训练嵌入(例如,使用trainable=False创建嵌入变量),或者让反向传播为你的应用调整它们。 第一种选择将加速训练,但第二种选择可能会产生稍高的性能。 提示 对于表示可能拥有大量不同值的类别属性,嵌入也很有用,特别是当值之间存在复杂的相似性的时候。 例如,考虑职业,爱好,菜品,物种,品牌等。 你现在拥有了实现机器翻译系统所需的几乎所有的工具。 现在我们来看看它吧。 用于机器翻译的编解码器网络 让我们来看看简单的机器翻译模型,它将英语句子翻译成法语(参见图 14-15)。 图 14-15:简单的机器翻译模型 英语句子被送进编码器,解码器输出法语翻译。 请注意,法语翻译也被用作解码器的输入,但后退了一步。 换句话说,解码器的输入是它应该在前一步输出的字(不管它实际输出的是什么)。 对于第一个单词,提供了表示句子开始的标记("<go>")。 解码器预期以序列末尾标记(EOS)结束句子("<eos>")。 请注意,英语句子在送入编码器之前会反转。 例如,"I drink milk"与"milk drink I"相反。这确保了英语句子的开头将会最后送到编码器,这很有用,因为这通常是解码器需要翻译的第一个东西。 每个单词最初由简单整数标识符表示(例如,单词"milk"为 288)。 接下来,嵌入查找返回词的嵌入(如前所述,这是一个密集的,相当低维的向量)。 这些词的嵌入是实际送到编码器和解码器的内容。 在每个步骤中,解码器输出输出词汇表(即法语)中每个词的得分,然后 Softmax 层将这些得分转换为概率。 例如,在第一步中,单词"Je"有 20% 的概率,"Tu"有 1% 的概率,以此类推。 概率最高的词会输出。 这非常类似于常规分类任务,因此你可以使用softmax_cross_entropy_with_logits()函数来训练模型。 请注意,在推断期间(训练之后),你不再将目标句子送入解码器。 相反,只需向解码器提供它在上一步输出的单词,如图 14-16 所示(这将需要嵌入查找,它未在图中显示)。 图 14-16:在推断期间,将之前的输出单词提供为输入 好的,现在你有了大方向。 但是,如果你阅读 TensorFlow 的序列教程,并查看rnn/translate/seq2seq_model.py中的代码(在 TensorFlow 模型中),你会注意到一些重要的区别: 首先,到目前为止,我们已经假定所有输入序列(编码器和解码器的)具有恒定的长度。但显然句子长度可能会有所不同。有几种方法可以处理它 - 例如,使用static_rnn()或dynamic_rnn()函数的sequence_length参数,来指定每个句子的长度(如前所述)。然而,教程中使用了另一种方法(大概是出于性能原因):句子分到长度相似的桶中(例如,句子的单词 1 到 6 分到一个桶,单词 7 到 12 分到另一个桶,等等),并且使用特殊的填充标记(例如"<pad>")来填充较短的句子。例如,"I drink milk"变成"<pad> <pad> <pad> milk drink I",翻译成"Je bois du lait <eos> <pad>"。当然,我们希望忽略任何 EOS 标记之后的输出。为此,本教程的实现使用target_weights向量。例如,对于目标句子"Je bois du lait <eos> <pad>",权重将设置为[1.0,1.0,1.0,1.0,1.0,0.0](注意权重 0.0 对应目标句子中的填充标记)。简单地将损失乘以目标权重,将消除对应 EOS 标记之后的单词的损失。 其次,当输出词汇表很大时(就是这里的情况),输出每个可能的单词的概率将会非常慢。 如果目标词汇表包含 50,000 个法语单词,则解码器将输出 50,000 维向量,然后在这样的大向量上计算 softmax 函数,计算量将非常大。 为了避免这种情况,一种解决方案是让解码器输出更小的向量,例如 1,000 维向量,然后使用采样技术来估计损失,而不必对目标词汇表中的每个单词计算它。 这种采样 Softmax 技术是由 SébastienJean 等人在 2015 年提出的。在 TensorFlow 中,你可以使用sampled_softmax_loss()函数。 第三,教程的实现使用了一种注意力机制,让解码器能够窥视输入序列。 注意力增强的 RNN 不在本书的讨论范围之内,但如果你有兴趣,可以关注机器翻译,机器阅读和图像说明的相关论文。 最后,本教程的实现使用了tf.nn.legacy_seq2seq模块,该模块提供了轻松构建各种编解码器模型的工具。 例如,embedding_rnn_seq2seq()函数会创建一个简单的编解码器模型,它会自动为你处理单词嵌入,就像图 14-15 中所示的一样。 此代码可能会很快更新,来使用新的tf.nn.seq2seq模块。 你现在拥有了,了解所有 seq2seq 教程的实现所需的全部工具。 将它们取出,并训练你自己的英法翻译器吧! 练习 你能想象 seq2seq RNN 的几个应用吗? seq2vec 的 RNN 呢?vex2seq 的 RNN 呢? 为什么人们使用编解码器 RNN 而不是简单的 seq2seq RNN 来自动翻译? 如何将卷积神经网络与 RNN 结合,来对视频进行分类? 使用dynamic_rnn()而不是static_rnn()构建 RNN 有什么好处? 你如何处理长度可变的输入序列? 那么长度可变输出序列呢? 在多个 GPU 上分配深层 RNN 的训练和执行的常见方式是什么? Hochreiter 和 Schmidhuber 在其关于 LSTM 的文章中使用了嵌入式 Reber 语法。 它们是产生字符串,如"BPBTSXXVPSEPE"的人造语法。查看 Jenny Orr 对此主题的不错的介绍。 选择一个特定的嵌入式 Reber 语法(例如 Jenny Orr 页面上显示的语法),然后训练一个 RNN 来确定字符串是否遵循该语法。 你首先需要编写一个函数,该函数能够生成训练批量,包含大约 50% 遵循语法的字符串,以及 50% 不遵循的字符串。 解决“How much did it rain? II”(下雨下了多久 II)Kaggle 比赛。 这是一个时间序列预测任务:它为你提供极化雷达值的快照,并要求预测每小时降水量。 Luis Andre Dutra e Silva 的采访对他在比赛中获得第二名的技术,提供了一些有趣的见解。 特别是,他使用了由两个 LSTM 层组成的 RNN。 通过 TensorFlow 的 Word2Vec 教程来创建单词嵌入,然后通过 Seq2Seq 教程来训练英法翻译系统。 附录 A 提供了这些练习的答案。
第16章 强化学习 来源:ApacheCN《Sklearn 与 TensorFlow 机器学习实用指南》翻译项目 译者:@friedhelm739 校对:@飞龙 强化学习(RL)如今是机器学习的一大令人激动的领域,当然之前也是。自从 1950 年被发明出来后,它在这些年产生了一些有趣的应用,尤其是在游戏(例如 TD-Gammon,一个西洋双陆棋程序)和及其控制领域,但是从未弄出什么大新闻。直到 2013 年一个革命性的发展:来自英国的研究者发起了一项 Deepmind 项目,这个项目可以学习去玩任何从头开始的 Atari 游戏,甚至多数比人类玩的还要好,它仅适用像素作为输入并且没有游戏规则的任何先验知识。这是一系列令人惊叹的壮举,在 2016 年 3 月以他们的系统阿尔法狗战胜了世界围棋冠军李世石。没有一个程序能接近这个游戏的主宰,更不用说世界冠军了。今天,RL 的整个领域正在沸腾着新的想法,其都具有广泛的应用范围。DeepMind 在 2014 被谷歌以超过 5 亿美元收购。 那么他们是怎么做到的呢?事后看来,原理似乎相当简单:他们将深度学习运用到强化学习领域,结果却超越了他们最疯狂的设想。在本章中,我们将首先解释强化学习是什么,以及它擅长于什么,然后我们将介绍两个在深度强化学习领域最重要的技术:策略梯度和深度 Q 网络(DQN),包括讨论马尔可夫决策过程(MDP)。我们将使用这些技术来训练一个模型来平衡移动车上的杆子,另一个玩 Atari 游戏。同样的技术可以用于各种各样的任务,从步行机器人到自动驾驶汽车。 学习优化奖励 在强化学习中,智能体在环境中观察并且做出决策,随后它会得到奖励。它的目标是去学习如何行动能最大化期望的奖励。如果你不在意去拟人化的话,你可以认为正奖励是愉快,负奖励是痛苦(这样的话奖励一词就有点误导了)。简单来说,智能体在环境中行动,并且在实验和错误中去学习最大化它的愉快,最小化它的痛苦。 这是一个相当广泛的设置,可以适用于各种各样的任务。以下是几个例子(详见图 16-1): 智能体可以是控制一个机械狗的程序。在此例中,环境就是真实的世界,智能体通过许多的传感器例如摄像机或者传感器来观察,它可以通过给电机驱动信号来行动。它可以被编程设置为如果到达了目的地就得到正奖励,如果浪费时间,或者走错方向,或摔倒了就得到负奖励。 智能体可以是控制 MS.Pac-Man 的程序。在此例中,环境是 Atari 游戏的仿真,行为是 9 个操纵杆位(上下左右中间等等),观察是屏幕,回报就是游戏点数。 相似地,智能体也可以是棋盘游戏的程序例如:围棋。 智能体也可以不用去控制一个实体(或虚拟的)去移动。例如它可以是一个智能程序,当它调整到目标温度以节能时会得到正奖励,当人们需要自己去调节温度时它会得到负奖励,所以智能体必须学会预见人们的需要。 智能体也可以去观测股票市场价格以实时决定买卖。奖励的依据显然为挣钱或者赔钱。 其实没有正奖励也是可以的,例如智能体在迷宫内移动,它每分每秒都得到一个负奖励,所以它要尽可能快的找到出口!还有很多适合强化学习的领域,例如自动驾驶汽车,在网页上放广告,或者控制一个图像分类系统让它明白它应该关注于什么。 策略搜索 被智能体使用去改变它行为的算法叫做策略。例如,策略可以是一个把观测当输入,行为当做输出的神经网络。 这个策略可以是你能想到的任何算法,它甚至可以不被确定。举个例子,例如,考虑一个真空吸尘器,它的奖励是在 30 分钟内捡起的灰尘数量。它的策略可以是每秒以概率P向前移动,或者以概率1-P随机地向左或向右旋转。旋转角度将是-R和+R之间的随机角度,因为该策略涉及一些随机性,所以称为随机策略。机器人将有一个不稳定的轨迹,它保证它最终会到达任何可以到达的地方,并捡起所有的灰尘。问题是:30分钟后它会捡起多少灰尘? 你怎么训练这样的机器人?你可以调整两个策略参数:概率P和角度范围R。一个想法是这些参数尝试许多不同的值,并选择执行最佳的组合(见图 16-3)。这是一个策略搜索的例子,在这种情况下使用野蛮的方法。然而,当策略空间太大(通常情况下),以这样的方式找到一组好的参数就像是大海捞针。 另一种搜寻策略空间的方法是遗传算法。例如你可以随机创造一个包含 100 个策略的第一代基因,随后杀死 80 个坏策略,随后让 20 个幸存策略繁衍 4 代。一个后代只是它父辈基因的复制品加上一些随机变异。幸存的策略加上他们的后代共同构成了第二代。你可以继续以这种方式迭代代,直到找到一个好的策略。 另一种方法是使用优化技术,通过评估关于策略参数的奖励的梯度,然后通过跟随梯度向更高的奖励(梯度上升)调整这些参数。这种方法被称为策略梯度(PG),我们将在本章后面详细讨论。例如,回到真空吸尘器机器人,你可以稍微增加P并评估这是否增加了机器人在 30 分钟内拾起的灰尘的量;如果它相对应增加P,或者减少P。我们将使用 Tensorflow 来实现 PG 算法,但是在这之前我们需要为智能体创造一个生存的环境,所以现在是介绍 OpenAI 的时候了。 OpenAI 的介绍 强化学习的一个挑战是,为了训练智能体,首先需要有一个工作环境。如果你想设计一个可以学习 Atari 游戏的程序,你需要一个 Atari 游戏模拟器。如果你想设计一个步行机器人,那么环境就是真实的世界,你可以直接在这个环境中训练你的机器人,但是这有其局限性:如果机器人从悬崖上掉下来,你不能仅仅点击“撤消”。你也不能加快时间;增加更多的计算能力不会让机器人移动得更快。一般来说,同时训练 1000 个机器人是非常昂贵的。简而言之,训练在现实世界中是困难和缓慢的,所以你通常需要一个模拟环境,至少需要引导训练。 OpenAI gym 是一个工具包,它提供各种各样的模拟环境(Atari 游戏,棋盘游戏,2D 和 3D 物理模拟等等),所以你可以训练,比较,或开发新的 RL 算法。 让我们安装 OpenAI gym。可通过pip安装: $ pip install --upgrade gym 接下来打开 Python shell 或 Jupyter 笔记本创建您的第一个环境: >>> import gym >>> env = gym.make("CartPole-v0") [2016-10-14 16:03:23,199] Making new env: MsPacman-v0 >>> obs = env.reset() >>> obs array([-0.03799846,-0.03288115,0.02337094,0.00720711]) >>> env.render() 使用make()函数创建一个环境,在此例中是 CartPole 环境。这是一个 2D 模拟,其中推车可以被左右加速,以平衡放置在它上面的平衡杆(见图 16-4)。在创建环境之后,我们需要使用reset()初始化。这会返回第一个观察结果。观察取决于环境的类型。对于 CartPole 环境,每个观测是包含四个浮点的 1D Numpy 向量:这些浮点数代表推车的水平位置(0 为中心)、其速度、杆的角度(0 维垂直)及其角速度。最后,render()方法显示如图 16-4 所示的环境。 如果你想让render()让图像以一个 NUMPY 数组格式返回,可以将mode参数设置为rgb_array(注意其他环境可能支持不同的模式): >>> img = env.render(mode="rgb_array") >>> img.shape # height, width, channels (3=RGB) (400, 600, 3) 不幸的是,即使将mode参数设置为rgb_array,CartPole(和其他一些环境)还是会将将图像呈现到屏幕上。避免这种情况的唯一方式是使用一个 fake X 服务器,如 XVFB 或 XDimMy。例如,可以使用以下命令安装 XVFB 和启动 Python:xvfb-run -s "screen 0 1400x900x24" python。或者使用xvfbwrapper包。 让我们来询问环境什么动作是可能的: >>> env.action_space Discrete(2) Discrete(2)表示可能的动作是整数 0 和 1,表示向左(0)或右(1)的加速。其他环境可能有更多的动作,或者其他类型的动作(例如,连续的)。因为杆子向右倾斜,让我们向右加速推车: >>> action = 1 # accelerate right >>> obs, reward, done, info = env.step(action) >>> obs array([-0.03865608, 0.16189797, 0.02351508, -0.27801135]) >>> reward 1.0 >>> done False >>> info {} step()表示执行给定的动作并返回四个值: obs: 这是新的观测,小车现在正在向右走(obs[1]>0)。平衡杆仍然向右倾斜(obs[2]>0),但是他的角速度现在为负(obs[3]<0),所以它在下一步后可能会向左倾斜。 reward: 在这个环境中,无论你做什么,每一步都会得到 1.0 奖励,所以游戏的目标就是尽可能长的运行。 done: 当游戏结束时这个值会为True。当平衡单倾斜太多时会发生这种情况。之后,必须重新设置环境才能重新使用。 info: 该字典可以在其他环境中提供额外的调试信息。这些数据不应该用于训练(这是作弊)。 让我们编码一个简单的策略,当杆向左倾斜时加速左边,当杆向右倾斜时加速。我们使用这个策略来获得超过 500 步的平均回报: def basic_policy(obs): angle = obs[2] return 0 if angle < 0 else 1 totals = [] for episode in range(500): episode_rewards = 0 obs = env.reset() for step in range(1000): # 最多1000 步,我们不想让它永远运行下去 action = basic_policy(obs) obs, reward, done, info = env.step(action) episode_rewards += reward if done: break totals.append(episode_rewards) 这个代码希望能自我解释。让我们看看结果: >>> import numpy as np >>> np.mean(totals), np.std(totals), np.min(totals), np.max(totals) (42.125999999999998, 9.1237121830974033, 24.0, 68.0) 即使有 500 次尝试,这一策略从未超过 68 个连续的步骤保持平衡杆直立。这不太好。如果你看一下 Juyter Notebook 中的模拟,你会发现,推车越来越强烈地左右摆动,直到平衡杆倾斜太多。让我们看看神经网络是否能提出更好的策略。 神经网络策略 让我们创建一个神经网络策略。就像之前我们编码的策略一样,这个神经网络将把观察作为输入,输出要执行的动作。更确切地说,它将估计每个动作的概率,然后我们将根据估计的概率随机地选择一个动作(见图 16-5)。在 CartPole 环境中,只有两种可能的动作(左或右),所以我们只需要一个输出神经元。它将输出动作 0(左)的概率p,动作 1(右)的概率显然将是1 - p。 例如,如果它输出 0.7,那么我们将以 70% 的概率选择动作 0,以 30% 的概率选择动作 1。 你可能奇怪为什么我们根据神经网络给出的概率来选择随机的动作,而不是选择最高分数的动作。这种方法使这个点在探索新的行为和利用已知的工作的行动之间找到正确的平衡。举个例子:假设你第一次去餐馆,所有的菜看起来同样吸引人,所以你随机挑选一个。如果菜好吃,你可以增加下一次点它的概率,但是你不应该把这个概率提高到 100%,否则你将永远不会尝试其他菜肴,其中一些甚至比你尝试的更好。 还要注意,在这个特定的环境中,过去的动作和观察可以被安全地忽略,因为每个观察都包含环境的完整状态。如果有一些隐藏状态,那么你也需要考虑过去的行为和观察。例如,如果环境仅仅揭示了推车的位置,而不是它的速度,那么你不仅要考虑当前的观测,还要考虑先前的观测,以便估计当前的速度。另一个例子是当观测是有噪声的的,在这种情况下,通常你想用过去的观察来估计最可能的当前状态。因此,CartPole 问题是简单的;观测是无噪声的,而且它们包含环境的全状态。 import tensorflow as tf from tensorflow.contrib.layers import fully_connected # 1. 声明神经网络结构 n_inputs = 4 # == env.observation_space.shape[0] n_hidden = 4 # 这只是个简单的测试,不需要过多的隐藏层 n_outputs = 1 # 只输出向左加速的概率 initializer = tf.contrib.layers.variance_scaling_initializer() # 2. 建立神经网络 X = tf.placeholder(tf.float32, shape=[None, n_inputs]) hidden = fully_connected(X, n_hidden, activation_fn=tf.nn.elu,weights_initializer=initializer) logits = fully_connected(hidden, n_outputs, activation_fn=None,weights_initializer=initializer) outputs = tf.nn.sigmoid(logits) # 3. 在概率基础上随机选择动作 p_left_and_right = tf.concat(axis=1, values=[outputs, 1 - outputs]) action = tf.multinomial(tf.log(p_left_and_right), num_samples=1) init = tf.global_variables_initializer() 让我们通读代码: 在导入之后,我们定义了神经网络体系结构。输入的数量是观测空间的大小(在 CartPole 的情况下是 4 个),我们只有 4 个隐藏单元,并且不需要更多,并且我们只有 1 个输出概率(向左的概率)。 接下来我们构建了神经网络。在这个例子中,它是一个 vanilla 多层感知器,只有一个输出。注意,输出层使用 Logistic(Sigmoid)激活函数,以便输出从 0 到 1 的概率。如果有两个以上的可能动作,每个动作都会有一个输出神经元,你将使用 Softmax 激活函数。 最后,我们调用multinomial()函数来选择一个随机动作。该函数独立地采样一个(或多个)整数,给定每个整数的对数概率。例如,如果通过设置num_samples=5,令数组为[np.log(0.5), np.log(0.2), np.log(0.3)]来调用它,那么它将输出五个整数,每个整数都有 50% 的概率是 0,20% 为 1,30% 为 2。在我们的情况下,我们只需要一个整数来表示要采取的行动。由于输出张量仅包含向左的概率,所以我们必须首先将 1 个输出连接到它,以得到包含左和右动作的概率的张量。请注意,如果有两个以上的可能动作,神经网络将不得不输出每个动作的概率,这样你就不需要连接步骤了。 好了,现在我们有一个可以观察和输出动作的神经网络了,那我们怎么训练它呢? 评价行为:信用分配问题 如果我们知道每一步的最佳动作,我们可以像通常一样训练神经网络,通过最小化估计概率和目标概率之间的交叉熵。这只是通常的监督学习。然而,在强化学习中,智能体获得的指导的唯一途径是通过奖励,奖励通常是稀疏的和延迟的。例如,如果智能体在 100 个步骤内设法平衡杆,它怎么知道它采取的 100 个行动中的哪一个是好的,哪些是坏的?它所知道的是,在最后一次行动之后,杆子坠落了,但最后一次行动肯定不是完全负责的。这被称为信用分配问题:当智能体得到奖励时,很难知道哪些行为应该被信任(或责备)。想想一只狗在行为良好后几小时就会得到奖励,它会明白它得到了什么回报吗? 为了解决这个问题,一个通常的策略是基于这个动作后得分的总和来评估这个个动作,通常在每个步骤中应用衰减率r。例如(见图 16-6),如果一个智能点决定连续三次向右,在第一步之后得到 +10 奖励,第二步后得到 0,最后在第三步之后得到 -50,然后假设我们使用衰减率r=0.8,那么第一个动作将得到10 +r×0 + r2×(-50)=-22的分述。如果衰减率接近 0,那么与即时奖励相比,未来的奖励不会有多大意义。相反,如果衰减率接近 1,那么对未来的奖励几乎等于即时回报。典型的衰减率通常为是 0.95 或 0.99。如果衰减率为 0.95,那么未来 13 步的奖励大约是即时奖励的一半(0.9513×0.5),而当衰减率为 0.99,未来 69 步的奖励是即时奖励的一半。在 CartPole 环境下,行为具有相当短期的影响,因此选择 0.95 的折扣率是合理的。 当然,一个好的动作可能会伴随着一些坏的动作,这些动作会导致平衡杆迅速下降,从而导致一个好的动作得到一个低分数(类似的,一个好演员有时会在一部烂片中扮演主角)。然而,如果我们花足够多的时间来训练游戏,平均下来好的行为会得到比坏的更好的分数。因此,为了获得相当可靠的动作分数,我们必须运行很多次并将所有动作分数归一化(通过减去平均值并除以标准偏差)。之后,我们可以合理地假设消极得分的行为是坏的,而积极得分的行为是好的。现在我们有一个方法来评估每一个动作,我们已经准备好使用策略梯度来训练我们的第一个智能体。让我们看看如何。 策略梯度 正如前面所讨论的,PG 算法通过遵循更高回报的梯度来优化策略参数。一种流行的 PG 算法,称为增强算法,在 1929 由 Ronald Williams 提出。这是一个常见的变体: 首先,让神经网络策略玩几次游戏,并在每一步计算梯度,这使得智能点更可能选择行为,但不应用这些梯度。 运行几次后,计算每个动作的得分(使用前面段落中描述的方法)。 如果一个动作的分数是正的,这意味着动作是好的,可应用较早计算的梯度,以便将来有更大的的概率选择这个动作。但是,如果分数是负的,这意味着动作是坏的,要应用负梯度来使得这个动作在将来采取的可能性更低。我们的方法就是简单地将每个梯度向量乘以相应的动作得分。 最后,计算所有得到的梯度向量的平均值,并使用它来执行梯度下降步骤。 让我们使用 TensorFlow 实现这个算法。我们将训练我们早先建立的神经网络策略,让它学会平衡车上的平衡杆。让我们从完成之前编码的构造阶段开始,添加目标概率、代价函数和训练操作。因为我们的意愿是选择的动作是最好的动作,如果选择的动作是动作 0(左),则目标概率必须为 1,如果选择动作 1(右)则目标概率为 0: y = 1. - tf.to_float(action) 现在我们有一个目标概率,我们可以定义损失函数(交叉熵)并计算梯度: learning_rate = 0.01 cross_entropy = tf.nn.sigmoid_cross_entropy_with_logits( labels=y, logits=logits) optimizer = tf.train.AdamOptimizer(learning_rate) grads_and_vars = optimizer.compute_gradients(cross_entropy) 注意,我们正在调用优化器的compute_gradients()方法,而不是minimize()方法。这是因为我们想要在使用它们之前调整梯度。compute_gradients()方法返回梯度向量/变量对的列表(每个可训练变量一对)。让我们把所有的梯度放在一个列表中,以便方便地获得它们的值: gradients = [grad for grad, variable in grads_and_vars] 好,现在是棘手的部分。在执行阶段,算法将运行策略,并在每个步骤中评估这些梯度张量并存储它们的值。在多次运行之后,它如先前所解释的调整这些梯度(即,通过动作分数乘以它们并使它们归一化),并计算调整后的梯度的平均值。接下来,需要将结果梯度反馈到优化器,以便它可以执行优化步骤。这意味着对于每一个梯度向量我们需要一个占位符。此外,我们必须创建操作去应用更新的梯度。为此,我们将调用优化器的apply_gradients()函数,该函数采用梯度向量/变量对的列表。我们不给它原始的梯度向量,而是给它一个包含更新梯度的列表(即,通过占位符递送的梯度): gradient_placeholders = [] grads_and_vars_feed = [] for grad, variable in grads_and_vars: gradient_placeholder = tf.placeholder(tf.float32, shape=grad.get_shape()) gradient_placeholders.append(gradient_placeholder) grads_and_vars_feed.append((gradient_placeholder, variable)) training_op = optimizer.apply_gradients(grads_and_vars_feed) 让我们后退一步,看看整个运行过程: n_inputs = 4 n_hidden = 4 n_outputs = 1 initializer = tf.contrib.layers.variance_scaling_initializer() learning_rate = 0.01 X = tf.placeholder(tf.float32, shape=[None, n_inputs]) hidden = fully_connected(X, n_hidden, activation_fn=tf.nn.elu,weights_initializer=initializer) logits = fully_connected(hidden, n_outputs, activation_fn=None, weights_initializer=initializer) outputs = tf.nn.sigmoid(logits) p_left_and_right = tf.concat(axis=1, values=[outputs, 1 - outputs]) action = tf.multinomial(tf.log(p_left_and_right), num_samples=1) y = 1. - tf.to_float(action) cross_entropy = tf.nn.sigmoid_cross_entropy_with_logits(labels=y, logits=logits) optimizer = tf.train.AdamOptimizer(learning_rate) grads_and_vars = optimizer.compute_gradients(cross_entropy) gradients = [grad for grad, variable in grads_and_vars] gradient_placeholders = [] grads_and_vars_feed = [] for grad, variable in grads_and_vars: gradient_placeholder = tf.placeholder(tf.float32, shape=grad.get_shape()) gradient_placeholders.append(gradient_placeholder) grads_and_vars_feed.append((gradient_placeholder, variable)) training_op = optimizer.apply_gradients(grads_and_vars_feed) init = tf.global_variables_initializer() saver = tf.train.Saver() 到执行阶段了!我们将需要两个函数来计算总折扣奖励,给予原始奖励,以及归一化多次循环的结果: def discount_rewards(rewards, discount_rate): discounted_rewards = np.empty(len(rewards)) cumulative_rewards = 0 for step in reversed(range(len(rewards))): cumulative_rewards = rewards[step] + cumulative_rewards * discount_rate discounted_rewards[step] = cumulative_rewards return discounted_rewards def discount_and_normalize_rewards(all_rewards, discount_rate): all_discounted_rewards = [discount_rewards(rewards) for rewards in all_rewards] flat_rewards = np.concatenate(all_discounted_rewards) reward_mean = flat_rewards.mean() reward_std = flat_rewards.std() return [(discounted_rewards - reward_mean)/reward_std for discounted_rewards in all_discounted_rewards] 让我们检查一下运行的如何: >>> discount_rewards([10, 0, -50], discount_rate=0.8) array([-22., -40., -50.]) >>> discount_and_normalize_rewards([[10, 0, -50], [10, 20]], discount_rate=0.8) [array([-0.28435071, -0.86597718, -1.18910299]), array([ 1.26665318, 1.0727777 ])] 对discount_rewards()的调用正好返回我们所期望的(见图 16-6)。你也可以验证函数iscount_and_normalize_rewards()确实返回了两个步骤中每个动作的标准化分数。注意第一步比第二步差很多,所以它的归一化分数都是负的;从第一步开始的所有动作都会被认为是坏的,反之,第二步的所有动作都会被认为是好的。 我们现在有了训练策略所需的一切: n_iterations = 250 # 训练迭代次数 n_max_steps = 1000 # 每一次的最大步长 n_games_per_update = 10 # 每十次训练一次策略 save_iterations = 10 # 每十次迭代保存模型 discount_rate = 0.95 with tf.Session() as sess: init.run() for iteration in range(n_iterations): all_rewards = [] #每一次的所有奖励 all_gradients = [] #每一次的所有梯度 for game in range(n_games_per_update): current_rewards = [] #当前步的所有奖励 current_gradients = [] #当前步的所有梯度 obs = env.reset() for step in range(n_max_steps): action_val, gradients_val = sess.run([action, gradients], feed_dict={X: obs.reshape(1, n_inputs)}) # 一个obs obs, reward, done, info = env.step(action_val[0][0]) current_rewards.append(reward) current_gradients.append(gradients_val) if done: break all_rewards.append(current_rewards) all_gradients.append(current_gradients) # 此时我们每10次运行一次策略,我们已经准备好使用之前描述的算法去更新策略 all_rewards = discount_and_normalize_rewards(all_rewards) feed_dict = {} for var_index, grad_placeholder in enumerate(gradient_placeholders): # 将梯度与行为分数相乘,并计算平均值 mean_gradients = np.mean([reward * all_gradients[game_index][step][var_index] for game_index, rewards in enumerate(all_rewards) for step, reward in enumerate(rewards)],axis=0) feed_dict[grad_placeholder] = mean_gradients sess.run(training_op, feed_dict=feed_dict) if iteration % save_iterations == 0: saver.save(sess, "./my_policy_net_pg.ckpt") 每一次训练迭代都是通过运行10次的策略开始的(每次最多 1000 步,以避免永远运行)。在每一步,我们也计算梯度,假设选择的行动是最好的。在运行了这 10 次之后,我们使用discount_and_normalize_rewards()函数计算动作得分;我们遍历每个可训练变量,在所有次数和所有步骤中,通过其相应的动作分数来乘以每个梯度向量;并且我们计算结果的平均值。最后,我们运行训练操作,给它提供平均梯度(对每个可训练变量提供一个)。我们继续每 10 个训练次数保存一次模型。 我们做完了!这段代码将训练神经网络策略,它将成功地学会平衡车上的平衡杆(你可以在 Juyter notebook 上试用)。注意,实际上有两种方法可以让玩家游戏结束:要么平衡可以倾斜太大,要么车完全脱离屏幕。在 250 次训练迭代中,策略学会平衡极点,但在避免脱离屏幕方面还不够好。额外数百次的训练迭代可以解决这一问题。 研究人员试图找到一种即使当智能点最初对环境一无所知时也能很好地工作的算法。然而,除非你正在写论文,否则你应该尽可能多地将先前的知识注入到智能点中,因为它会极大地加速训练。例如,你可以添加与屏幕中心距离和极点角度成正比的负奖励。此外,如果你已经有一个相当好的策略,你可以训练神经网络模仿它,然后使用策略梯度来改进它。 尽管它相对简单,但是该算法是非常强大的。你可以用它来解决更难的问题,而不仅仅是平衡一辆手推车上的平衡杆。事实上,AlgPaGo 是基于类似的 PG 算法(加上蒙特卡罗树搜索,这超出了本书的范围)。 现在我们来看看另一个流行的算法。与 PG 算法直接尝试优化策略以增加奖励相反,我们现在看的算法是间接的:智能点学习去估计每个状态的的每个行为未来衰减奖励的期望总和,或者在每个状态的的每个行为未来衰减奖励的期望和。然后,使用这些知识来决定如何行动。为了理解这些算法,我们必须首先介绍马尔可夫决策过程(MDP)。 马尔可夫决策过程 在二十世纪初,数学家 Andrey Markov 研究了没有记忆的随机过程,称为马尔可夫链。这样的过程具有固定数量的状态,并且在每个步骤中随机地从一个状态演化到另一个状态。它从状态S演变为状态S'的概率是固定的,它只依赖于(S, S')对,而不是依赖于过去的状态(系统没有记忆)。 图 16-7 展示了一个具有四个状态的马尔可夫链的例子。假设该过程从状态S0开始,并且在下一步骤中有 70% 的概率保持在该状态不变中。最终,它必然离开那个状态,并且永远不会回来,因为没有其他状态回到S0。如果它进入状态S1,那么它很可能会进入状态S2(90% 的概率),然后立即回到状态S1(以 100% 的概率)。它可以在这两个状态之间交替多次,但最终它会落入状态S3并永远留在那里(这是一个终端状态)。马尔可夫链可以有非常不同的应用,它们在热力学、化学、统计学等方面有着广泛的应用。 马尔可夫决策过程最初是在 20 世纪 50 年代由 Richard Bellman 描述的。它们类似于马尔可夫链,但有一个连接:在每一步中,一个智能点可以选择几种可能的动作中的一个,并且转移概率取决于所选择的动作。此外,一些状态转换返回一些奖励(正或负),智能点的目标是找到一个策略,随着时间的推移将最大限度地提高奖励。 例如,图 16-8 中所示的 MDP 在每个步骤中具有三个状态和三个可能的离散动作。如果从状态S0开始,随着时间的推移可以在动作A0、A1或A2之间进行选择。如果它选择动作A1,它就保持在状态S0中,并且没有任何奖励。因此,如果愿意的话,它可以决定永远呆在那里。但是,如果它选择动作A0,它有 70% 的概率获得 10 奖励,并保持在状态S0。然后,它可以一次又一次地尝试获得尽可能多的奖励。但它将在状态S1中结束这样的行为。在状态S1中,它只有两种可能的动作:A0或A1。它可以通过反复选择动作A1来选择停留,或者它可以选择移动到状态S2并得到 -50 奖励。在状态S3中,除了采取行动A1之外,别无选择,这将最有可能引导它回到状态S0,在途中获得 40 的奖励。通过观察这个 MDP,你能猜出哪一个策略会随着时间的推移而获得最大的回报吗?在状态S0中,清楚地知道A0是最好的选择,在状态S3中,智能点别无选择,只能采取行动A1,但是在状态S1中,智能点否应该保持不动(A0)或通过火(A2),这是不明确的。 Bellman 找到了一种估计任何状态S的最佳状态值的方法,他提出了V(s),它是智能点在其达到最佳状态s后所有衰减的总和的平均期望。他表明,如果智能点的行为最佳,那么贝尔曼最优性公式适用(见公式 16-1)。这个递归公式表示,如果代理最优地运行,那么当前状态的最优值等于在采取一个最优动作之后平均得到的奖励,加上该动作可能导致的所有可能的下一个状态的期望最优值。 其中: T为智能点选择动作a时从状态s到状态s'的概率 R为智能点选择以动作a从状态s到状态s'的过程中得到的奖励 y为衰减率 这个等式直接引出了一种算法,该算法可以精确估计每个可能状态的最优状态值:首先将所有状态值估计初始化为零,然后用数值迭代算法迭代更新它们(见公式 16-2)。一个显著的结果是,给定足够的时间,这些估计保证收敛到最优状态值,对应于最优策略。 其中: V是在k次算法迭代对状态s的估计 该算法是动态规划的一个例子,它将了一个复杂的问题(在这种情况下,估计潜在的未来衰减奖励的总和)变为可处理的子问题,可以迭代地处理(在这种情况下,找到最大化平均报酬与下一个衰减状态值的和的动作) 了解最佳状态值可能是有用的,特别是评估策略,但它没有明确地告诉智能点要做什么。幸运的是,Bellman 发现了一种非常类似的算法来估计最优状态动作值,通常称为 Q 值。状态行动(S, A)对的最优 Q 值,记为Q(s, a),是智能点在选择动作A到达状态S`之后平均期望的衰减的总和,但是是在它看到这个动作的结果之前,假设它在该动作之后起到最佳的作用。 下面是它的工作原理:再次,通过初始化所有的 Q 值估计为零,然后使用 Q 值迭代算法更新它们(参见公式 16-3)。 一旦你有了最佳的 Q 值,定义最优的策略π*(s),它是微不足道的:当智能点处于状态S时,它应该选择具有最高 Q 值的动作,用于该状态。 让我们把这个算法应用到图 16-8 所示的 MDP 中。首先,我们需要定义 MDP: nan=np.nan # 代表不可能的动作 T = np.array([ # shape=[s, a, s'] [[0.7, 0.3, 0.0], [1.0, 0.0, 0.0], [0.8, 0.2, 0.0]], [[0.0, 1.0, 0.0], [nan, nan, nan], [0.0, 0.0, 1.0]], [[nan, nan, nan], [0.8, 0.1, 0.1], [nan, nan, nan]], ]) R = np.array([ # shape=[s, a, s'] [[10., 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], [[10., 0.0, 0.0], [nan, nan, nan], [0.0, 0.0, -50.]], [[nan, nan, nan], [40., 0.0, 0.0], [nan, nan, nan]], ]) possible_actions = [[0, 1, 2], [0, 2], [1]] 让我们运行 Q 值迭代算法 Q = np.full((3, 3), -np.inf) # -inf 对应着不可能的动作 for state, actions in enumerate(possible_actions): Q[state, actions] = 0.0 # 对所有可能的动作初始化为0.0 learning_rate = 0.01 discount_rate = 0.95 n_iterations = 100 for iteration in range(n_iterations): Q_prev = Q.copy() for s in range(3): for a in possible_actions[s]: Q[s, a] = np.sum([T[s, a, sp] * (R[s, a, sp] + discount_rate * np.max(Q_prev[sp])) for sp in range(3)]) 结果的 Q 值类似于如下: >>> Q array([[ 21.89498982, 20.80024033, 16.86353093], [ 1.11669335, -inf, 1.17573546], [ -inf, 53.86946068, -inf]]) >>> np.argmax(Q, axis=1) # 每一状态的最优动作 array([0, 2, 1]) 这给我们这个 MDP 的最佳策略,当使用 0.95 的衰减率时:在状态S0选择动作A0,在状态S1选择动作A2(通过火焰!)在状态S2中选择动作A1(唯一可能的动作)。有趣的是,如果你把衰减率降低到 0.9,最优的策略改变:在状态S1中,最好的动作变成A0(保持不变;不通过火)。这是有道理的,因为如果你认为现在比未来更重要,那么未来奖励的前景是不值得立刻经历痛苦的。 时间差异学习与 Q 学习 具有离散动作的强化学习问题通常可以被建模为马尔可夫决策过程,但是智能点最初不知道转移概率是什么(它不知道T),并且它不知道奖励会是什么(它不知道R)。它必须经历每一个状态和每一次转变并且至少知道一次奖励,并且如果要对转移概率进行合理的估计,就必须经历多次。 时间差分学习(TD 学习)算法与数值迭代算法非常类似,但考虑到智能点仅具有 MDP 的部分知识。一般来说,我们假设智能点最初只知道可能的状态和动作,没有更多了。智能点使用探索策略,例如,纯粹的随机策略来探索 MDP,并且随着它的发展,TD 学习算法基于实际观察到的转换和奖励来更新状态值的估计(见公式 16-4)。 其中: a是学习率(例如 0.01) TD 学习与随机梯度下降有许多相似之处,特别是它一次处理一个样本的行为。就像 SGD 一样,只有当你逐渐降低学习速率时,它才能真正收敛(否则它将在极值点震荡)。 对于每个状态S,该算法只跟踪智能点离开该状态时立即获得的奖励的平均值,再加上它期望稍后得到的奖励(假设它的行为最佳)。 类似地,Q 学习算法是 Q 值迭代算法的自适应版本,其适应过渡概率和回报在初始未知的情况(见公式16-5)。 对于每一个状态动作对(s,a),该算法跟踪智能点在以动作A离开状态S时获得的奖励的运行平均值R,加上它期望稍后得到的奖励。由于目标策略将最优地运行,所以我们取下一状态的 Q 值估计的最大值。 以下是如何实现 Q 学习: import numpy.random as rnd learning_rate0 = 0.05 learning_rate_decay = 0.1 n_iterations = 20000 s = 0 # 在状态 0开始 Q = np.full((3, 3), -np.inf) # -inf 对应着不可能的动作 for state, actions in enumerate(possible_actions): Q[state, actions] = 0.0 # 对于所有可能的动作初始化为 0.0 for iteration in range(n_iterations): a = rnd.choice(possible_actions[s]) # 随机选择动作 sp = rnd.choice(range(3), p=T[s, a]) # 使用 T[s, a] 挑选下一状态 reward = R[s, a, sp] learning_rate = learning_rate0 / (1 + iteration * learning_rate_decay) Q[s, a] = learning_rate * Q[s, a] + (1 - learning_rate) * (reward + discount_rate * np.max(Q[sp])) s = sp # 移动至下一状态 给定足够的迭代,该算法将收敛到最优 Q 值。这被称为关闭策略算法,因为正在训练的策略不是正在执行的策略。令人惊讶的是,该算法能够通过观察智能点行为随机学习(例如学习当你的老师是一个醉猴子时打高尔夫球)最佳策略。我们能做得更好吗? 探索策略 当然,只有在探索策略充分探索 MDP 的情况下,Q 学习才能起作用。尽管一个纯粹的随机策略保证最终访问每一个状态和每个转换多次,但可能需要很长的时间这样做。因此,一个更好的选择是使用 ε 贪婪策略:在每个步骤中,它以概率ε随机地或以概率为1-ε贪婪地(选择具有最高 Q 值的动作)。ε 贪婪策略的优点(与完全随机策略相比)是,它将花费越来越多的时间来探索环境中有趣的部分,因为 Q 值估计越来越好,同时仍花费一些时间访问 MDP 的未知区域。以ε为很高的值(例如,1)开始,然后逐渐减小它(例如,下降到 0.05)是很常见的。 可选择的,相比于依赖于探索的机会,另一种方法是鼓励探索政策来尝试它以前没有尝试过的行动。这可以被实现为附加于 Q 值估计的奖金,如公式 16-6 所示。 其中: N计算了在状态s时选择动作a的次数 f是一个探索函数,例如f=q+K/(1+n),其中K是一个好奇超参数,它测量智能点被吸引到未知者的程度。 近似 Q 学习 Q 学习的主要问题是,它不能很好地扩展到具有许多状态和动作的大(甚至中等)的 MDP。试着用 Q 学习来训练一个智能点去玩 Ms. Pac-Man。Ms. Pac-Man 可以吃超过 250 粒粒子,每一粒都可以存在或不存在(即已经吃过)。因此,可能状态的数目大于 2 的 250 次幂,约等于 10 的 75 次幂(并且这是考虑颗粒的可能状态)。这比在可观测的宇宙中的原子要多得多,所以你绝对无法追踪每一个 Q 值的估计值。 解决方案是找到一个函数,使用可管理数量的参数来近似 Q 值。这被称为近似 Q 学习。多年来,人们都是手工在状态中提取并线性组合特征(例如,最近的鬼的距离,它们的方向等)来估计 Q 值,但是 DeepMind 表明使用深度神经网络可以工作得更好,特别是对于复杂的问题。它不需要任何特征工程。用于估计 Q 值的 DNN 被称为深度 Q 网络(DQN),并且使用近似 Q 学习的 DQN 被称为深度 Q 学习。 在本章的剩余部分,我们将使用深度 Q 学习来训练一个智能点去玩 Ms. Pac-Man,就像 DeepMind 在 2013 所做的那样。代码可以很容易地调整,调整后学习去玩大多数 Atari 游戏的效果都相当好。在大多数动作游戏中,它可以达到超人的技能,但它在长卷游戏中却不太好。 学习去使用深度 Q 学习来玩 Ms.Pac-Man 由于我们将使用 Atari 环境,我们必须首先安装 OpenAI gym 的 Atari 环境依赖项。当需要玩其他的时候,我们也会为你想玩的其他 OpenAI gym 环境安装依赖项。在 macOS 上,假设你已经安装了 Homebrew 程序,你需要运行: $ brew install cmake boost boost-python sdl2 swig wget 在 Ubuntu 上,输入以下命令(如果使用 Python 2,用 Python 替换 Python 3): $ apt-get install -y python3-numpy python3-dev cmake zlib1g-dev libjpeg-dev\ xvfb libav-tools xorg-dev python3-opengl libboost-all-dev libsdl2-dev swig 随后安装额外的 python 包: $ pip3 install --upgrade 'gym[all]' 如果一切顺利,你应该能够创造一个 Ms.Pac-Man 环境: >>> env = gym.make("MsPacman-v0") >>> obs = env.reset() >>> obs.shape # [长,宽,通道] (210, 160, 3) >>> env.action_space Discrete(9) 正如你所看到的,有九个离散动作可用,它对应于操纵杆的九个可能位置(左、右、上、下、中、左上等),观察结果是 Atari 屏幕的截图(见图 16-9,左),表示为 3D Numpy 矩阵。这些图像有点大,所以我们将创建一个小的预处理函数,将图像裁剪并缩小到88×80像素,将其转换成灰度,并提高 Ms.Pac-Man 的对比度。这将减少 DQN 所需的计算量,并加快培训练。 def preprocess_observation(obs): img = obs[1:176:2, ::2] # 裁剪 img = img.mean(axis=2) # 灰度化 img[img==mspacman_color] = 0 # 提升对比度 img = (img - 128) / 128 - 1 # 正则化为-1到1. return img.reshape(88, 80, 1) 过程的结果如图 16-9 所示(右)。 接下来,让我们创建 DQN。它可以只取一个状态动作对(S,A)作为输入,并输出相应的 Q 值Q(s,a)的估计值,但是由于动作是离散的,所以使用只使用状态S作为输入并输出每个动作的一个 Q 值估计的神经网络是更方便的。DQN 将由三个卷积层组成,接着是两个全连接层,其中包括输出层(如图 16-10)。 正如我们将看到的,我们将使用的训练算法需要两个具有相同架构(但不同参数)的 DQN:一个将在训练期间用于驱动 Ms.Pac-Man(演员),另一个将观看演员并从其试验和错误中学习(评论家)。每隔一定时间,我们就把评论家的反馈给演员看。因为我们需要两个相同的 DQN,所以我们将创建一个q_network()函数来构建它们: from tensorflow.contrib.layers import convolution2d, fully_connected input_height = 88 input_width = 80 input_channels = 1 conv_n_maps = [32, 64, 64] conv_kernel_sizes = [(8,8), (4,4), (3,3)] conv_strides = [4, 2, 1] conv_paddings = ["SAME"]*3 conv_activation = [tf.nn.relu]*3 n_hidden_in = 64 * 11 * 10 # conv3 有 64 个 11x10 映射 each n_hidden = 512 hidden_activation = tf.nn.relu n_outputs = env.action_space.n # 9个离散动作 initializer = tf.contrib.layers.variance_scaling_initializer() def q_network(X_state, scope): prev_layer = X_state conv_layers = [] with tf.variable_scope(scope) as scope: for n_maps, kernel_size, stride, padding, activation in zip(conv_n_maps, conv_kernel_sizes, conv_strides,conv_paddings, conv_activation): prev_layer = convolution2d(prev_layer, num_outputs=n_maps, kernel_size=kernel_size,stride=stride, padding=padding, activation_fn=activation,weights_initializer=initializer) conv_layers.append(prev_layer) last_conv_layer_flat = tf.reshape(prev_layer, shape=[-1, n_hidden_in]) hidden = fully_connected(last_conv_layer_flat, n_hidden,activation_fn=hidden_activation, weights_initializer=initializer) outputs = fully_connected(hidden, n_outputs, activation_fn=None,weights_initializer=initializer) trainable_vars = tf.get_collection(tf.GraphKeys.TRAINABLE_VARIABLES,scope=scope.name) trainable_vars_by_name = {var.name[len(scope.name):]: var for var in trainable_vars} return outputs, trainable_vars_by_name 该代码的第一部分定义了DQN体系结构的超参数。然后q_network()函数创建 DQN,将环境的状态X_state作为输入,以及变量范围的名称。请注意,我们将只使用一个观察来表示环境的状态,因为几乎没有隐藏的状态(除了闪烁的物体和鬼魂的方向)。 trainable_vars_by_name字典收集了所有 DQN 的可训练变量。当我们创建操作以将评论家 DQN 复制到演员 DQN 时,这将是有用的。字典的键是变量的名称,去掉与范围名称相对应的前缀的一部分。看起来像这样: >>> trainable_vars_by_name {'/Conv/biases:0': <tensorflow.python.ops.variables.Variable at 0x121cf7b50>, '/Conv/weights:0': <tensorflow.python.ops.variables.Variable...>, '/Conv_1/biases:0': <tensorflow.python.ops.variables.Variable...>, '/Conv_1/weights:0': <tensorflow.python.ops.variables.Variable...>, '/Conv_2/biases:0': <tensorflow.python.ops.variables.Variable...>, '/Conv_2/weights:0': <tensorflow.python.ops.variables.Variable...>, '/fully_connected/biases:0': <tensorflow.python.ops.variables.Variable...>, '/fully_connected/weights:0': <tensorflow.python.ops.variables.Variable...>, '/fully_connected_1/biases:0': <tensorflow.python.ops.variables.Variable...>, '/fully_connected_1/weights:0': <tensorflow.python.ops.variables.Variable...>} 现在让我们为两个 DQN 创建输入占位符,以及复制评论家 DQN 给演员 DQN 的操作: X_state = tf.placeholder(tf.float32, shape=[None, input_height, input_width,input_channels]) actor_q_values, actor_vars = q_network(X_state, scope="q_networks/actor") critic_q_values, critic_vars = q_network(X_state, scope="q_networks/critic") copy_ops = [actor_var.assign(critic_vars[var_name]) for var_name, actor_var in actor_vars.items()] copy_critic_to_actor = tf.group(*copy_ops) 让我们后退一步:我们现在有两个 DQN,它们都能够将环境状态(即预处理观察)作为输入,并输出在该状态下的每一个可能的动作的估计 Q 值。另外,我们有一个名为copy_critic_to_actor的操作,将评论家 DQN 的所有可训练变量复制到演员 DQN。我们使用 TensorFlow 的tf.group()函数将所有赋值操作分组到一个方便的操作中。 演员 DQN 可以用来扮演 Ms.Pac-Man(最初非常糟糕)。正如前面所讨论的,你希望它足够深入地探究游戏,所以通常情况下你想将它用 ε 贪婪策略或另一种探索策略相结合。 但是评论家 DQN 呢?它如何去学习玩游戏?简而言之,它将试图使其预测的 Q 值去匹配演员通过其经验的游戏估计的 Q 值。具体来说,我们将让演员玩一段时间,把所有的经验保存在回放记忆中。每个内存将是一个 5 元组(状态、动作、下一状态、奖励、继续),其中“继续”项在游戏结束时等于 0,否则为 1。接下来,我们定期地从回放存储器中采样一批记忆,并且我们将估计这些存储器中的 Q 值。最后,我们将使用监督学习技术训练评论家 DQN 去预测这些 Q 值。每隔几个训练周期,我们会把评论家 DQN 复制到演员 DQN。就这样!公式 16-7 示出了用于训练评论家 DQN 的损失函数: 其中: S,A,R和S'分别为状态,行为,回报,和在下一状态在存储器中采样来的第i个记忆 m是记忆批处理的长度 Oc和Oa为评论家和演员的参数 Q是评论家 DQN 对第i记忆状态行为 Q 值的预测 Q'是演员 DQN 在选择动作A时的下一状态S'的期望 Q 值的预测 y是第i记忆的目标 Q 值,注意,它等同于演员实际观察到的奖励,再加上演员对如果它能发挥最佳效果(据它所知),未来的回报应该是什么的预测。 J为训练评论家 DQN 的损失函数。正如你所看到的,这只是由演员 DQN 估计的目标 Q 值y和评论家 DQN 对这些 Q 值的预测之间的均方误差。 回放记忆是可选的,但强烈推荐使它存在。没有它,你会训练评论家 DQN 使用连续的经验,这可能是相关的。这将引入大量的偏差并且减慢训练算法的收敛性。通过使用回放记忆,我们确保馈送到训练算法的存储器可以是不相关的。 让我们添加评论家 DQN 的训练操作。首先,我们需要能够计算其在存储器批处理中的每个状态动作的预测 Q 值。由于 DQN 为每一个可能的动作输出一个 Q 值,所以我们只需要保持与在该存储器中实际选择的动作相对应的 Q 值。为此,我们将把动作转换成一个热向量(记住这是一个满是 0 的向量,除了第i个索引中的1),并乘以 Q 值:这将删除所有与记忆动作对应的 Q 值外的 Q 值。然后只对第一轴求和,以获得每个存储器所需的 Q 值预测。 X_action = tf.placeholder(tf.int32, shape=[None]) q_value = tf.reduce_sum(critic_q_values * tf.one_hot(X_action, n_outputs), axis=1, keep_dims=True) 接下来,让我们添加训练操作,假设目标Q值将通过占位符馈入。我们还创建了一个不可训练的变量global_step。优化器的minimize()操作将负责增加它。另外,我们创建了init操作和Saver。 y = tf.placeholder(tf.float32, shape=[None, 1]) cost = tf.reduce_mean(tf.square(y - q_value)) global_step = tf.Variable(0, trainable=False, name='global_step') optimizer = tf.train.AdamOptimizer(learning_rate) training_op = optimizer.minimize(cost, global_step=global_step) init = tf.global_variables_initializer() saver = tf.train.Saver() 这就是训练阶段的情况。在我们查看执行阶段之前,我们需要一些工具。首先,让我们从回放记忆开始。我们将使用一个deque列表,因为在将数据推送到队列中并在达到最大内存大小时从列表的末尾弹出它们使是非常有效的。我们还将编写一个小函数来随机地从回放记忆中采样一批处理: from collections import deque replay_memory_size = 10000 replay_memory = deque([], maxlen=replay_memory_size) def sample_memories(batch_size): indices = rnd.permutation(len(replay_memory))[:batch_size] cols = [[], [], [], [], []] # state, action, reward, next_state, continue for idx in indices: memory = replay_memory[idx] for col, value in zip(cols, memory): col.append(value) cols = [np.array(col) for col in cols] return (cols[0], cols[1], cols[2].reshape(-1, 1), cols[3],cols[4].reshape(-1, 1)) 接下来,我们需要演员来探索游戏。我们使用 ε 贪婪策略,并在 50000 个训练步骤中逐步将ε从 1 降低到 0.05。 eps_min = 0.05 eps_max = 1.0 eps_decay_steps = 50000 def epsilon_greedy(q_values, step): epsilon = max(eps_min, eps_max - (eps_max-eps_min) * step/eps_decay_steps) if rnd.rand() < epsilon: return rnd.randint(n_outputs) # 随机动作 else: return np.argmax(q_values) # 最优动作 就是这样!我们准备好开始训练了。执行阶段不包含太复杂的东西,但它有点长,所以深呼吸。准备好了吗?来次够!首先,让我们初始化几个变量: n_steps = 100000 # 总的训练步长 training_start = 1000 # 在游戏1000次迭代后开始训练 training_interval = 3 # 每3次迭代训练一次 save_steps = 50 # 每50训练步长保存模型 copy_steps = 25 # 每25训练步长后复制评论家Q值到演员 discount_rate = 0.95 skip_start = 90 # 跳过游戏开始(只是等待时间) batch_size = 50 iteration = 0 # 游戏迭代 checkpoint_path = "./my_dqn.ckpt" done = True # env 需要被重置 接下来,让我们打开会话并开始训练: with tf.Session() as sess: if os.path.isfile(checkpoint_path): saver.restore(sess, checkpoint_path) else: init.run() while True: step = global_step.eval() if step >= n_steps: break iteration += 1 if done: # 游戏结束,重来 obs = env.reset() for skip in range(skip_start): # 跳过游戏开头 obs, reward, done, info = env.step(0) state = preprocess_observation(obs) # 演员评估要干什么 q_values = actor_q_values.eval(feed_dict={X_state: [state]}) action = epsilon_greedy(q_values, step) # 演员开始玩游戏 obs, reward, done, info = env.step(action) next_state = preprocess_observation(obs) # 让我们记下来刚才发生了啥 replay_memory.append((state, action, reward, next_state, 1.0 - done)) state = next_state if iteration < training_start or iteration % training_interval != 0: continue # 评论家学习 X_state_val, X_action_val, rewards, X_next_state_val, continues = ( sample_memories(batch_size)) next_q_values = actor_q_values.eval( feed_dict={X_state: X_next_state_val}) max_next_q_values = np.max(next_q_values, axis=1, keepdims=True) y_val = rewards + continues * discount_rate * max_next_q_values training_op.run(feed_dict={X_state: X_state_val,X_action: X_action_val, y: y_val}) # 复制评论家Q值到演员 if step % copy_steps == 0: copy_critic_to_actor.run() # 保存模型 if step % save_steps == 0: saver.save(sess, checkpoint_path) 如果检查点文件存在,我们就开始恢复模型,否则我们只需初始化变量。然后,主循环开始,其中iteration计算从程序开始以来游戏步骤的总数,同时step计算从训练开始的训练步骤的总数(如果恢复了检查点,也恢复全局步骤)。然后代码重置游戏(跳过第一个无聊的等待游戏的步骤,这步骤啥都没有)。接下来,演员评估该做什么,并且玩游戏,并且它的经验被存储在回放记忆中。然后,每隔一段时间(热身期后),评论家开始一个训练步骤。它采样一批回放记忆,并要求演员估计下一状态的所有动作的Q值,并应用公式 16-7 来计算目标 Q 值y_val.这里唯一棘手的部分是,我们必须将下一个状态的 Q 值乘以continues向量,以将对应于游戏结束的记忆 Q 值清零。接下来,我们进行训练操作,以提高评论家预测 Q 值的能力。最后,我们定期将评论家的 Q 值复制给演员,然后保存模型。 不幸的是,训练过程是非常缓慢的:如果你使用你的破笔记本电脑进行训练的话,想让 Ms. Pac-Man 变好一点点你得花好几天,如果你看看学习曲线,计算一下每次的平均奖励,你会发现到它是非常嘈杂的。在某些情况下,很长一段时间内可能没有明显的进展,直到智能点学会在合理的时间内生存。如前所述,一种解决方案是将尽可能多的先验知识注入到模型中(例如,通过预处理、奖励等),也可以尝试通过首先训练它来模仿基本策略来引导模型。在任何情况下,RL仍然需要相当多的耐心和调整,但最终结果是非常令人兴奋的。 练习 你怎样去定义强化学习?它与传统的监督以及非监督学习有什么不同? 你能想到什么本章没有提到过的强化学习应用?智能体是什么?什么是可能的动作,什么是奖励? 什么是衰减率?如果你修改了衰减率那最优策略会变化吗? 你怎么去定义强化学习智能体的表现? 什么是信用评估问题?它怎么出现的?你怎么解决? 使用回放记忆的目的是什么? 什么是闭策略 RL 算法? 使用深度 Q 学习来处理 OpenAI gym 的“BypedalWalker-v2” 。QNET 不需要对这个任务使用非常深的网络。 使用策略梯度训练智能体扮演 Pong,一个著名的 Atari 游戏(PANV0 在 OpenAI gym 的 Pong-v0)。注意:个人的观察不足以说明球的方向和速度。一种解决方案是一次将两次观测传递给神经网络策略。为了减少维度和加速训练,你必须预先处理这些图像(裁剪,调整大小,并将它们转换成黑白),并可能将它们合并成单个图像(例如去叠加它们)。 如果你有大约 100 美元备用,你可以购买 Raspberry Pi 3 再加上一些便宜的机器人组件,在 PI 上安装 TensorFlow,然后让我们嗨起来~!举个例子,看看 Lukas Biewald 的这个有趣的帖子,或者看看 GoPiGo 或 BrickPi。为什么不尝试通过使用策略梯度训练机器人来构建真实的 cartpole ?或者造一个机器人蜘蛛,让它学会走路;当它接近某个目标时,给予奖励(你需要传感器来测量目标的距离)。唯一的限制就是你的想象力。 练习答案均在附录 A。 感谢 在我们结束这本书的最后一章之前,我想感谢你们读到最后一段。我真心希望你能像我写这本书一样愉快地阅读这本书,这对你的项目,或多或少都是有用的。 如果发现错误,请发送反馈。更一般地说,我很想知道你的想法,所以请不要犹豫,通过 O’Reilly 来与我联系,或者通过 ageron/handson-ml GITHUB 项目来练习。 对你来说,我最好的建议是练习和练习:如果你还没有做过这些练习,试着使用 Juyter notebook 参加所有的练习,加入 kaggle 网站或其他 ML 社区,看 ML 课程,阅读论文,参加会议,会见专家。您可能还想研究我们在本书中没有涉及的一些主题,包括推荐系统、聚类算法、异常检测算法和遗传算法。 我最大的希望是,这本书将激励你建立一个美妙的 ML 应用程序,这将有利于我们所有人!那会是什么呢? 2016 年 11 月 26 日,奥列伦·格伦 你的支持,是我们每个开源工作者的骄傲~
第4章 训练模型 来源:ApacheCN《Sklearn 与 TensorFlow 机器学习实用指南》翻译项目 译者:@C-PIG 校对:@PeterHo @飞龙 在之前的描述中,我们通常把机器学习模型和训练算法当作黑箱子来处理。如果你实践过前几章的一些示例,你惊奇的发现你可以优化回归系统,改进数字图像的分类器,你甚至可以零基础搭建一个垃圾邮件的分类器,但是你却对它们内部的工作流程一无所知。事实上,许多场合你都不需要知道这些黑箱子的内部有什么,干了什么。 然而,如果你对其内部的工作流程有一定了解的话,当面对一个机器学习任务时候,这些理论可以帮助你快速的找到恰当的机器学习模型,合适的训练算法,以及一个好的假设集。同时,了解黑箱子内部的构成,有助于你更好地调试参数以及更有效的误差分析。本章讨论的大部分话题对于机器学习模型的理解,构建,以及神经网络(详细参考本书的第二部分)的训练都是非常重要的。 首先我们将以一个简单的线性回归模型为例,讨论两种不同的训练方法来得到模型的最优解: 直接使用封闭方程进行求根运算,得到模型在当前训练集上的最优参数(即在训练集上使损失函数达到最小值的模型参数) 使用迭代优化方法:梯度下降(GD),在训练集上,它可以逐渐调整模型参数以获得最小的损失函数,最终,参数会收敛到和第一种方法相同的的值。同时,我们也会介绍一些梯度下降的变体形式:批量梯度下降(Batch GD)、小批量梯度下降(Mini-batch GD)、随机梯度下降(Stochastic GD),在第二部分的神经网络部分,我们会多次使用它们。 接下来,我们将研究一个更复杂的模型:多项式回归,它可以拟合非线性数据集,由于它比线性模型拥有更多的参数,于是它更容易出现模型的过拟合。因此,我们将介绍如何通过学习曲线去判断模型是否出现了过拟合,并介绍几种正则化方法以减少模型出现过拟合的风险。 最后,我们将介绍两个常用于分类的模型:Logistic回归和Softmax回归 提示 在本章中包含许多数学公式,以及一些线性代数和微积分基本概念。为了理解这些公式,你需要知道什么是向量,什么是矩阵,以及它们直接是如何转化的,以及什么是点积,什么是矩阵的逆,什么是偏导数。如果你对这些不是很熟悉的话,你可以阅读本书提供的 Jupyter 在线笔记,它包括了线性代数和微积分的入门指导。对于那些不喜欢数学的人,你也应该快速简单的浏览这些公式。希望它足以帮助你理解大多数的概念。 线性回归 在第一章,我们介绍了一个简单的的生活满意度回归模型: lifesatisfaction=θ0+θ1∗GDPpercapita 这个模型仅仅是输入量GDP_per_capita的线性函数,θ0 和 θ1 是这个模型的参数,线性模型更一般化的描述指通过计算输入变量的加权和,并加上一个常数偏置项(截距项)来得到一个预测值。如公式 4-1: 公式 4-1:线性回归预测模型 y^=θ0+θ1x1+θ2x2+⋯+θnxn y^ 表示预测结果 n 表示特征的个数 xi 表示第i个特征的值 θj 表示第j个参数(包括偏置项 θ0 和特征权重值 θ1,θ2,…,θn) 上述公式可以写成更为简洁的向量形式,如公式 4-2: 公式 4-2:线性回归预测模型(向量形式) y^=hθ(x)=θT⋅x θ 表示模型的参数向量包括偏置项 θ0 和特征权重值 θ1 到 θn θT 表示向量θ的转置(行向量变为了列向量) x 为每个样本中特征值的向量形式,包括 x1 到 xn,而且 x0 恒为 1 θT⋅x 表示 θT 和x 的点积 hθ 表示参数为 θ 的假设函数 怎么样去训练一个线性回归模型呢?好吧,回想一下,训练一个模型指的是设置模型的参数使得这个模型在训练集的表现较好。为此,我们首先需要找到一个衡量模型好坏的评定方法。在第二章,我们介绍到在回归模型上,最常见的评定标准是均方根误差(RMSE,详见公式 2-1)。因此,为了训练一个线性回归模型,你需要找到一个 θ 值,它使得均方根误差(标准误差)达到最小值。实践过程中,最小化均方误差比最小化均方根误差更加的简单,这两个过程会得到相同的 θ,因为函数在最小值时候的自变量,同样能使函数的方根运算得到最小值。 在训练集 X 上使用公式 4-3 来计算线性回归假设 hθ 的均方差(MSE)。 公式 4-3:线性回归模型的 MSE 损失函数 MSE(X,hθ$)=$1mm∑i=1(θT⋅x(i)−y(i))2 公式中符号的含义大多数都在第二章(详见“符号”)进行了说明,不同的是:为了突出模型的参数向量 θ,使用 hθ 来代替 h。以后的使用中为了公式的简洁,使用 MSE(θ) 来代替 MSE(X,hθ)。 正态方程 为了找到最小化损失函数的 θ 值,可以采用公式解,换句话说,就是可以通过解正态方程直接得到最后的结果。 公式 4-4:正态方程 ˆθ=(XT⋅X)−1⋅XT⋅y ˆθ 指最小化损失 θ 的值 y 是一个向量,其包含了 y(1) 到 y(m) 的值 让我们生成一些近似线性的数据(如图 4-1)来测试一下这个方程。 import numpy as np X = 2 * np.random.rand(100, 1) y = 4 + 3 * X + np.random.randn(100, 1) 图 4-1:随机线性数据集 现在让我们使用正态方程来计算 ˆθ,我们将使用 Numpy 的线性代数模块(np.linalg)中的inv()函数来计算矩阵的逆,以及dot()方法来计算矩阵的乘法。 X_b = np.c_[np.ones((100, 1)), X] theta_best = np.linalg.inv(X_b.T.dot(X_B)).dot(X_b.T).dot(y) 我们生产数据的函数实际上是 y=4+3x0+高斯噪声。让我们看一下最后的计算结果。 >>> theta_best array([[4.21509616],[2.77011339]]) 我们希望最后得到的参数为 θ0=4,θ1=3 而不是 θ0=3.865,θ1=3.139 (译者注:我认为应该是 θ0=4.2150,θ1=2.7701)。这已经足够了,由于存在噪声,参数不可能达到到原始函数的值。 现在我们能够使用 ˆθ 来进行预测: >>> X_new = np.array([[0],[2]]) >>> X_new_b = np.c_[np.ones((2, 1)), X_new] >>> y_predict = X_new_b.dot(theta.best) >>> y_predict array([[4.21509616],[9.75532293]]) 画出这个模型的图像,如图 4-2 plt.plot(X_new,y_predict,"r-") plt.plot(X,y,"b.") plt.axis([0,2,0,15]) plt.show() 图4-2:线性回归预测 使用下面的 Scikit-Learn 代码可以达到相同的效果: >>> form sklearn.linear_model import LinearRegression >>> lin_reg = LinearRegression() >>> lin_reg.fit(X,y) >>> lin_reg.intercept_, lin_reg.coef_ (array([4.21509616]),array([2.77011339])) >>> lin_reg.predict(X_new) array([[4.21509616],[9.75532293]]) 计算复杂度 正态方程需要计算矩阵 XT⋅X 的逆,它是一个 n∗n 的矩阵(n 是特征的个数)。这样一个矩阵求逆的运算复杂度大约在 O(n2.4) 到 O(n3) 之间,具体值取决于计算方式。换句话说,如果你将你的特征个数翻倍的话,其计算时间大概会变为原来的 5.3(22.4)到 8(23)倍。 提示 当特征的个数较大的时候(例如:特征数量为 100000),正态方程求解将会非常慢。 有利的一面是,这个方程在训练集上对于每一个实例来说是线性的,其复杂度为 O(m),因此只要有能放得下它的内存空间,它就可以对大规模数据进行训练。同时,一旦你得到了线性回归模型(通过解正态方程或者其他的算法),进行预测是非常快的。因为模型中计算复杂度对于要进行预测的实例数量和特征个数都是线性的。 换句话说,当实例个数变为原来的两倍多的时候(或特征个数变为原来的两倍多),预测时间也仅仅是原来的两倍多。 接下来,我们将介绍另一种方法去训练模型。这种方法适合在特征个数非常多,训练实例非常多,内存无法满足要求的时候使用。 梯度下降 梯度下降是一种非常通用的优化算法,它能够很好地解决一系列问题。梯度下降的整体思路是通过的迭代来逐渐调整参数使得损失函数达到最小值。 假设浓雾下,你迷失在了大山中,你只能感受到自己脚下的坡度。为了最快到达山底,一个最好的方法就是沿着坡度最陡的地方下山。这其实就是梯度下降所做的:它计算误差函数关于参数向量θ的局部梯度,同时它沿着梯度下降的方向进行下一次迭代。当梯度值为零的时候,就达到了误差函数最小值 。 具体来说,开始时,需要选定一个随机的θ(这个值称为随机初始值),然后逐渐去改进它,每一次变化一小步,每一步都试着降低损失函数(例如:均方差损失函数),直到算法收敛到一个最小值(如图:4-3)。 图 4-3:梯度下降 在梯度下降中一个重要的参数是步长,超参数学习率的值决定了步长的大小。如果学习率太小,必须经过多次迭代,算法才能收敛,这是非常耗时的(如图 4-4)。 图 4-4:学习率过小 另一方面,如果学习率太大,你将跳过最低点,到达山谷的另一面,可能下一次的值比上一次还要大。这可能使的算法是发散的,函数值变得越来越大,永远不可能找到一个好的答案(如图 4-5)。 图 4-5:学习率过大 最后,并不是所有的损失函数看起来都像一个规则的碗。它们可能是洞,山脊,高原和各种不规则的地形,使它们收敛到最小值非常的困难。 图 4-6 显示了梯度下降的两个主要挑战:如果随机初始值选在了图像的左侧,则它将收敛到局部最小值,这个值要比全局最小值要大。 如果它从右侧开始,那么跨越高原将需要很长时间,如果你早早地结束训练,你将永远到不了全局最小值。 图 4-6:梯度下降的陷阱 幸运的是线性回归模型的均方差损失函数是一个凸函数,这意味着如果你选择曲线上的任意两点,它们的连线段不会与曲线发生交叉(译者注:该线段不会与曲线有第三个交点)。这意味着这个损失函数没有局部最小值,仅仅只有一个全局最小值。同时它也是一个斜率不能突变的连续函数。这两个因素导致了一个好的结果:梯度下降可以无限接近全局最小值。(只要你训练时间足够长,同时学习率不是太大 )。 事实上,损失函数的图像呈现碗状,但是不同特征的取值范围相差较大的时,这个碗可能是细长的。图 4-7 展示了梯度下降在不同训练集上的表现。在左图中,特征 1 和特征 2 有着相同的数值尺度。在右图中,特征 1 比特征2的取值要小的多,由于特征 1 较小,因此损失函数改变时,θ1 会有较大的变化,于是这个图像会在θ1轴方向变得细长。 图 4-7:有无特征缩放的梯度下降 正如你看到的,左面的梯度下降可以直接快速地到达最小值,然而在右面的梯度下降第一次前进的方向几乎和全局最小值的方向垂直,并且最后到达一个几乎平坦的山谷,在平坦的山谷走了很长时间。它最终会达到最小值,但它需要很长时间。 提示 当我们使用梯度下降的时候,应该确保所有的特征有着相近的尺度范围(例如:使用 Scikit Learn 的 StandardScaler类),否则它将需要很长的时间才能够收敛。 这幅图也表明了一个事实:训练模型意味着找到一组模型参数,这组参数可以在训练集上使得损失函数最小。这是对于模型参数空间的搜索,模型的参数越多,参数空间的维度越多,找到合适的参数越困难。例如在300维的空间找到一枚针要比在三维空间里找到一枚针复杂的多。幸运的是线性回归模型的损失函数是凸函数,这个最优参数一定在碗的底部。 批量梯度下降 使用梯度下降的过程中,你需要计算每一个 θj 下损失函数的梯度。换句话说,你需要计算当θj变化一点点时,损失函数改变了多少。这称为偏导数,它就像当你面对东方的时候问:”我脚下的坡度是多少?”。然后面向北方的时候问同样的问题(如果你能想象一个超过三维的宇宙,可以对所有的方向都这样做)。公式 4-5 计算关于 θj 的损失函数的偏导数,记为 ∂∂θjMSE(θ)。 公式 4-5: 损失函数的偏导数 ∂∂θjMSE(θ)=2mm∑i=1(θT⋅x(i)−y(i))xj(i) 为了避免单独计算每一个梯度,你也可以使用公式 4-6 来一起计算它们。梯度向量记为 ∇θMSE(θ),其包含了损失函数所有的偏导数(每个模型参数只出现一次)。 公式 4-6:损失函数的梯度向量 ∇θMSE(θ)=(∂∂θ0MSE(θ)∂∂θ1MSE(θ)⋮∂∂θnMSE(θ))=2mXT⋅(X⋅θ−y) 提示 在这个方程中每一步计算时都包含了整个训练集 X,这也是为什么这个算法称为批量梯度下降:每一次训练过程都使用所有的的训练数据。因此,在大数据集上,其会变得相当的慢(但是我们接下来将会介绍更快的梯度下降算法)。然而,梯度下降的运算规模和特征的数量成正比。训练一个数千数量特征的线性回归模型使用*梯度下降要比使用正态方程快的多。 一旦求得了方向是上山的梯度向量,你就可以向着相反的方向去下山。这意味着从 θ 中减去 ∇θMSE(θ)。学习率 η 和梯度向量的积决定了下山时每一步的大小,如公式 4-7。 公式 4-7:梯度下降步长 θ(next step)=θ−η∇θMSE(θ) 让我们看一下这个算法的应用: eta = 0.1 # 学习率 n_iterations = 1000 m = 100 theta = np.random.randn(2,1) # 随机初始值 for iteration in range(n_iterations): gradients = 2/m * X_b.T.dot(X_b.dot(theta) - y) theta = theta - eta * gradiens 这不是太难,让我们看一下最后的结果 θ: >>> theta array([[4.21509616],[2.77011339]]) 看!正态方程的表现非常好。完美地求出了梯度下降的参数。但是当你换一个学习率会发生什么?图 4-8 展示了使用了三个不同的学习率进行梯度下降的前 10 步运算(虚线代表起始位置)。 图 4-8:不同学习率的梯度下降 在左面的那副图中,学习率是最小的,算法几乎不能求出最后的结果,而且还会花费大量的时间。在中间的这幅图中,学习率的表现看起来不错,仅仅几次迭代后,它就收敛到了最后的结果。在右面的那副图中,学习率太大了,算法是发散的,跳过了所有的训练样本,同时每一步都离正确的结果越来越远。 为了找到一个好的学习率,你可以使用网格搜索(详见第二章)。当然,你一般会限制迭代的次数,以便网格搜索可以消除模型需要很长时间才能收敛这一个问题。 你可能想知道如何选取迭代的次数。如果它太小了,当算法停止的时候,你依然没有找到最优解。如果它太大了,算法会非常的耗时同时后来的迭代参数也不会发生改变。一个简单的解决方法是:设置一个非常大的迭代次数,但是当梯度向量变得非常小的时候,结束迭代。非常小指的是:梯度向量小于一个值 ε(称为容差)。这时候可以认为梯度下降几乎已经达到了最小值。 收敛速率: 当损失函数是凸函数,同时它的斜率不能突变(就像均方差损失函数那样),那么它的批量梯度下降算法固定学习率之后,它的收敛速率是 O(1iterations)。换句话说,如果你将容差 ε 缩小 10 倍后(这样可以得到一个更精确的结果),这个算法的迭代次数大约会变成原来的 10 倍。 随机梯度下降 批量梯度下降的最要问题是计算每一步的梯度时都需要使用整个训练集,这导致在规模较大的数据集上,其会变得非常的慢。与其完全相反的随机梯度下降,在每一步的梯度计算上只随机选取训练集中的一个样本。很明显,由于每一次的操作都使用了非常少的数据,这样使得算法变得非常快。由于每一次迭代,只需要在内存中有一个实例,这使随机梯度算法可以在大规模训练集上使用。 另一方面,由于它的随机性,与批量梯度下降相比,其呈现出更多的不规律性:它到达最小值不是平缓的下降,损失函数会忽高忽低,只是在大体上呈下降趋势。随着时间的推移,它会非常的靠近最小值,但是它不会停止在一个值上,它会一直在这个值附近摆动(如图 4-9)。因此,当算法停止的时候,最后的参数还不错,但不是最优值。 图4-9:随机梯度下降 当损失函数很不规则时(如图 4-6),随机梯度下降算法能够跳过局部最小值。因此,随机梯度下降在寻找全局最小值上比批量梯度下降表现要好。 虽然随机性可以很好的跳过局部最优值,但同时它却不能达到最小值。解决这个难题的一个办法是逐渐降低学习率。 开始时,走的每一步较大(这有助于快速前进同时跳过局部最小值),然后变得越来越小,从而使算法到达全局最小值。 这个过程被称为模拟退火,因为它类似于熔融金属慢慢冷却的冶金学退火过程。 决定每次迭代的学习率的函数称为learning schedule。 如果学习速度降低得过快,你可能会陷入局部最小值,甚至在到达最小值的半路就停止了。 如果学习速度降低得太慢,你可能在最小值的附近长时间摆动,同时如果过早停止训练,最终只会出现次优解。 下面的代码使用一个简单的learning schedule来实现随机梯度下降: n_epochs = 50 t0, t1 = 5, 50 #learning_schedule的超参数 def learning_schedule(t): return t0 / (t + t1) theta = np.random.randn(2,1) for epoch in range(n_epochs): for i in range(m): random_index = np.random.randint(m) xi = X_b[random_index:random_index+1] yi = y[random_index:random_index+1] gradients = 2 * xi.T.dot(xi,dot(theta)-yi) eta = learning_schedule(epoch * m + i) theta = theta - eta * gradiens 按习惯来讲,我们进行 m 轮的迭代,每一轮迭代被称为一代。在整个训练集上,随机梯度下降迭代了 1000 次时,一般在第 50 次的时候就可以达到一个比较好的结果。 >>> theta array([[4.21076011],[2.748560791]]) 图 4-10 展示了前 10 次的训练过程(注意每一步的不规则程度)。 图 4-10:随机梯度下降的前10次迭代 由于每个实例的选择是随机的,有的实例可能在每一代中都被选到,这样其他的实例也可能一直不被选到。如果你想保证每一代迭代过程,算法可以遍历所有实例,一种方法是将训练集打乱重排,然后选择一个实例,之后再继续打乱重排,以此类推一直进行下去。但是这样收敛速度会非常的慢。 通过使用 Scikit-Learn 完成线性回归的随机梯度下降,你需要使用SGDRegressor类,这个类默认优化的是均方差损失函数。下面的代码迭代了 50 代,其学习率 η 为0.1(eta0=0.1),使用默认的learning schedule(与前面的不一样),同时也没有添加任何正则项(penalty = None): from sklearn.linear_model import SGDRegressor sgd_reg + SGDRregressor(n_iter=50, penalty=None, eta0=0.1) sgd_reg.fit(X,y.ravel()) 你可以再一次发现,这个结果非常的接近正态方程的解: >>> sgd_reg.intercept_, sgd_reg.coef_ (array([4.18380366]),array([2.74205299])) 小批量梯度下降 最后一个梯度下降算法,我们将介绍小批量梯度下降算法。一旦你理解了批量梯度下降和随机梯度下降,再去理解小批量梯度下降是非常简单的。在迭代的每一步,批量梯度使用整个训练集,随机梯度时候用仅仅一个实例,在小批量梯度下降中,它则使用一个随机的小型实例集。它比随机梯度的主要优点在于你可以通过矩阵运算的硬件优化得到一个较好的训练表现,尤其当你使用 GPU 进行运算的时候。 小批量梯度下降在参数空间上的表现比随机梯度下降要好的多,尤其在有大量的小型实例集时。作为结果,小批量梯度下降会比随机梯度更靠近最小值。但是,另一方面,它有可能陷在局部最小值中(在遇到局部最小值问题的情况下,和我们之前看到的线性回归不一样)。 图4-11显示了训练期间三种梯度下降算法在参数空间中所采用的路径。 他们都接近最小值,但批量梯度的路径最后停在了最小值,而随机梯度和小批量梯度最后都在最小值附近摆动。 但是,不要忘记,批次梯度需要花费大量时间来完成每一步,但是,如果你使用了一个较好的learning schedule,随机梯度和小批量梯度也可以得到最小值。 图 4-11:参数空间的梯度下降路径 让我比较一下目前我们已经探讨过的对线性回归的梯度下降算法。如表 4-1 所示,其中 m 表示训练样本的个数,n 表示特征的个数。 表 4-1:比较线性回归的不同梯度下降算法 提示 上述算法在完成训练后,得到的参数基本没什么不同,它们会得到非常相似的模型,最后会以一样的方式去进行预测。 多项式回归 如果你的数据实际上比简单的直线更复杂呢? 令人惊讶的是,你依然可以使用线性模型来拟合非线性数据。 一个简单的方法是对每个特征进行加权后作为新的特征,然后训练一个线性模型在这个扩展的特征集。 这种方法称为多项式回归。 让我们看一个例子。 首先,我们根据一个简单的二次方程(并加上一些噪声,如图 4-12)来生成一些非线性数据: m = 100 X = 6 * np.random.rand(m, 1) - 3 y = 0.5 * X**2 + X + 2 + np.random.randn(m, 1) 图 4-12:生产加入噪声的非线性数据 很清楚的看出,直线不能恰当的拟合这些数据。于是,我们使用 Scikit-Learning 的PolynomialFeatures类进行训练数据集的转换,让训练集中每个特征的平方(2 次多项式)作为新特征(在这种情况下,仅存在一个特征): >>> from sklearn.preprocessing import PolynomialFeatures >>> poly_features = PolynomialFeatures(degree=2,include_bias=False) >>> X_poly = poly_features.fit_transform(X) >>> X[0] array([-0.75275929]) >>> X_poly[0] array([-0.75275929, 0.56664654]) X_poly现在包含原始特征X并加上了这个特征的平方 X2。现在你可以在这个扩展训练集上使用LinearRegression模型进行拟合,如图 4-13: >>> lin_reg = LinearRegression() >>> lin_reg.fit(X_poly, y) >>> lin_reg.intercept_, lin_reg.coef_ (array([ 1.78134581]), array([[ 0.93366893, 0.56456263]])) 图 4-13:多项式回归模型预测 还是不错的,模型预测函数 ˆy=0.56x21+0.93x1+1.78,事实上原始函数为 y=0.5x21+1.0x1+2.0 再加上一些高斯噪声。 请注意,当存在多个特征时,多项式回归能够找出特征之间的关系(这是普通线性回归模型无法做到的)。 这是因为LinearRegression会自动添加当前阶数下特征的所有组合。例如,如果有两个特征 a,b,使用 3 阶(degree=3)的LinearRegression时,不仅有 a2,a3,b2 以及 b3,同时也会有它们的其他组合项 ab,a2b,ab2。 提示 PolynomialFeatures(degree=d)把一个包含 n 个特征的数组转换为一个包含 (n+d)!d!n! 特征的数组,n! 表示 n 的阶乘,等于 1∗2∗3⋯∗n。小心大量特征的组合爆炸! 学习曲线 如果你使用一个高阶的多项式回归,你可能发现它的拟合程度要比普通的线性回归要好的多。例如,图 4-14 使用一个 300 阶的多项式模型去拟合之前的数据集,并同简单线性回归、2 阶的多项式回归进行比较。注意 300 阶的多项式模型如何摆动以尽可能接近训练实例。 图 4-14:高阶多项式回归 当然,这种高阶多项式回归模型在这个训练集上严重过拟合了,线性模型则欠拟合。在这个训练集上,二次模型有着较好的泛化能力。那是因为在生成数据时使用了二次模型,但是一般我们不知道这个数据生成函数是什么,那我们该如何决定我们模型的复杂度呢?你如何告诉我你的模型是过拟合还是欠拟合? 在第二章,你可以使用交叉验证来估计一个模型的泛化能力。如果一个模型在训练集上表现良好,通过交叉验证指标却得出其泛化能力很差,那么你的模型就是过拟合了。如果在这两方面都表现不好,那么它就是欠拟合了。这种方法可以告诉我们,你的模型是太复杂还是太简单了。 另一种方法是观察学习曲线:画出模型在训练集上的表现,同时画出以训练集规模为自变量的训练集函数。为了得到图像,需要在训练集的不同规模子集上进行多次训练。下面的代码定义了一个函数,用来画出给定训练集后的模型学习曲线: from sklearn.metrics import mean_squared_error from sklearn.model_selection import train_test_split def plot_learning_curves(model, X, y): X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2) train_errors, val_errors = [], [] for m in range(1, len(X_train)): model.fit(X_train[:m], y_train[:m]) y_train_predict = model.predict(X_train[:m]) y_val_predict = model.predict(X_val) train_errors.append(mean_squared_error(y_train_predict, y_train[:m])) val_errors.append(mean_squared_error(y_val_predict, y_val)) plt.plot(np.sqrt(train_errors), "r-+", linewidth=2, label="train") plt.plot(np.sqrt(val_errors), "b-", linewidth=3, label="val") 我们一起看一下简单线性回归模型的学习曲线(图 4-15): lin_reg = LinearRegression() plot_learning_curves(lin_reg, X, y) 图 4-15:学习曲线 这幅图值得我们深究。首先,我们观察训练集的表现:当训练集只有一两个样本的时候,模型能够非常好的拟合它们,这也是为什么曲线是从零开始的原因。但是当加入了一些新的样本的时候,训练集上的拟合程度变得难以接受,出现这种情况有两个原因,一是因为数据中含有噪声,另一个是数据根本不是线性的。因此随着数据规模的增大,误差也会一直增大,直到达到高原地带并趋于稳定,在之后,继续加入新的样本,模型的平均误差不会变得更好或者更差。我们继续来看模型在验证集上的表现,当以非常少的样本去训练时,模型不能恰当的泛化,也就是为什么验证误差一开始是非常大的。当训练样本变多的到时候,模型学习的东西变多,验证误差开始缓慢的下降。但是一条直线不可能很好的拟合这些数据,因此最后误差会到达在一个高原地带并趋于稳定,最后和训练集的曲线非常接近。 上面的曲线表现了一个典型的欠拟合模型,两条曲线都到达高原地带并趋于稳定,并且最后两条曲线非常接近,同时误差值非常大。 提示 如果你的模型在训练集上是欠拟合的,添加更多的样本是没用的。你需要使用一个更复杂的模型或者找到更好的特征。 现在让我们看一个在相同数据上10阶多项式模型拟合的学习曲线(图 4-16): from sklearn.pipeline import Pipeline polynomial_regression = Pipeline(( ("poly_features", PolynomialFeatures(degree=10, include_bias=False)), ("sgd_reg", LinearRegression()), )) plot_learning_curves(polynomial_regression, X, y) 这幅图像和之前的有一点点像,但是其有两个非常重要的不同点: 在训练集上,误差要比线性回归模型低的多。 图中的两条曲线之间有间隔,这意味模型在训练集上的表现要比验证集上好的多,这也是模型过拟合的显著特点。当然,如果你使用了更大的训练数据,这两条曲线最后会非常的接近。 图4-16:多项式模型的学习曲线 提示 改善模型过拟合的一种方法是提供更多的训练数据,直到训练误差和验证误差相等。 偏差和方差的权衡 在统计和机器学习领域有个重要的理论:一个模型的泛化误差由三个不同误差的和决定: 偏差:泛化误差的这部分误差是由于错误的假设决定的。例如实际是一个二次模型,你却假设了一个线性模型。一个高偏差的模型最容易出现欠拟合。 方差:这部分误差是由于模型对训练数据的微小变化较为敏感,一个多自由度的模型更容易有高的方差(例如一个高阶多项式模型),因此会导致模型过拟合。 不可约误差:这部分误差是由于数据本身的噪声决定的。降低这部分误差的唯一方法就是进行数据清洗(例如:修复数据源,修复坏的传感器,识别和剔除异常值)。 线性模型的正则化 正如我们在第一和第二章看到的那样,降低模型的过拟合的好方法是正则化这个模型(即限制它):模型有越少的自由度,就越难以拟合数据。例如,正则化一个多项式模型,一个简单的方法就是减少多项式的阶数。 对于一个线性模型,正则化的典型实现就是约束模型中参数的权重。 接下来我们将介绍三种不同约束权重的方法:Ridge 回归,Lasso 回归和 Elastic Net。 岭(Ridge)回归 岭回归(也称为 Tikhonov 正则化)是线性回归的正则化版:在损失函数上直接加上一个正则项 α∑ni=1θ2i。这使得学习算法不仅能够拟合数据,而且能够使模型的参数权重尽量的小。注意到这个正则项只有在训练过程中才会被加到损失函数。当得到完成训练的模型后,我们应该使用没有正则化的测量方法去评价模型的表现。 提示 一般情况下,训练过程使用的损失函数和测试过程使用的评价函数是不一样的。除了正则化,还有一个不同:训练时的损失函数应该在优化过程中易于求导,而在测试过程中,评价函数更应该接近最后的客观表现。一个好的例子:在分类训练中我们使用对数损失(马上我们会讨论它)作为损失函数,但是我们却使用精确率/召回率来作为它的评价函数。 超参数 α 决定了你想正则化这个模型的强度。如果 α=0 那此时的岭回归便变为了线性回归。如果 α 非常的大,所有的权重最后都接近于零,最后结果将是一条穿过数据平均值的水平直线。公式 4-8 是岭回归的损失函数: 公式 4-8:岭回归损失函数 J(θ)=MSE(θ)+α12n∑i=1θ2i 值得注意的是偏差 θ0 是没有被正则化的(累加运算的开始是 i=1 而不是 i=0)。如我定义 w 作为特征的权重向量(θ1 到 θn),那么正则项可以简写成 12(∥w∥2)2,其中 ∥⋅∥2 表示权重向量的 ℓ2 范数。对于梯度下降来说仅仅在均方差梯度向量(公式 4-6)加上一项 αw。 提示 在使用岭回归前,对数据进行放缩(可以使用StandardScaler)是非常重要的,算法对于输入特征的数值尺度(scale)非常敏感。大多数的正则化模型都是这样的。 图 4-17 展示了在相同线性数据上使用不同 α 值的岭回归模型最后的表现。左图中,使用简单的岭回归模型,最后得到了线性的预测。右图中的数据首先使用 10 阶的PolynomialFearures进行扩展,然后使用StandardScaler进行缩放,最后将岭模型应用在处理过后的特征上。这就是带有岭正则项的多项式回归。注意当α增大的时候,导致预测曲线变得扁平(即少了极端值,多了一般值),这样减少了模型的方差,却增加了模型的偏差。 对线性回归来说,对于岭回归,我们可以使用封闭方程去计算,也可以使用梯度下降去处理。它们的缺点和优点是一样的。公式 4-9 表示封闭方程的解(矩阵 A 是一个除了左上角有一个 0 的 n×n 的单位矩,这个 0 代表偏差项。译者注:偏差 θ0 不被正则化的)。 图 4-17:岭回归 公式 4-9:岭回归的封闭方程的解 ˆθ=(XT⋅X+αA)−1⋅XT⋅y 下面是如何使用 Scikit-Learn 来进行封闭方程的求解(使用 Cholesky 法进行矩阵分解对公式 4-9 进行变形): >>> from sklearn.linear_model import Ridge >>> ridge_reg = Ridge(alpha=1, solver="cholesky") >>> ridge_reg.fit(X, y) >>> ridge_reg.predict([[1.5]]) array([[ 1.55071465]] 使用随机梯度法进行求解: >>> sgd_reg = SGDRegressor(penalty="l2") >>> sgd_reg.fit(X, y.ravel()) >>> sgd_reg.predict([[1.5]]) array([[ 1.13500145]]) penalty参数指的是正则项的惩罚类型。指定“l2”表明你要在损失函数上添加一项:权重向量 ℓ2 范数平方的一半,这就是简单的岭回归。 Lasso 回归 Lasso 回归(也称 Least Absolute Shrinkage,或者 Selection Operator Regression)是另一种正则化版的线性回归:就像岭回归那样,它也在损失函数上添加了一个正则化项,但是它使用权重向量的 ℓ1 范数而不是权重向量 ℓ2 范数平方的一半。(如公式 4-10) 公式 4-10:Lasso 回归的损失函数 J(θ)=MSE(θ)+αn∑i=1|θi| 图 4-18 展示了和图 4-17 相同的事情,仅仅是用 Lasso 模型代替了 Ridge 模型,同时调小了 α 的值。 图 4-18:Lasso回归 Lasso 回归的一个重要特征是它倾向于完全消除最不重要的特征的权重(即将它们设置为零)。例如,右图中的虚线所示(α=10−7),曲线看起来像一条二次曲线,而且几乎是线性的,这是因为所有的高阶多项特征都被设置为零。换句话说,Lasso回归自动的进行特征选择同时输出一个稀疏模型(即,具有很少的非零权重)。 你可以从图 4-19 知道为什么会出现这种情况:在左上角图中,后背景的等高线(椭圆)表示了没有正则化的均方差损失函数(α=0),白色的小圆圈表示在当前损失函数上批量梯度下降的路径。前背景的等高线(菱形)表示ℓ1惩罚,黄色的三角形表示了仅在这个惩罚下批量梯度下降的路径(α→∞)。注意路径第一次是如何到达 θ1=0,然后向下滚动直到它到达 θ2=0。在右上角图中,等高线表示的是相同损失函数再加上一个 α=0.5 的 ℓ1 惩罚。这幅图中,它的全局最小值在 θ2=0 这根轴上。批量梯度下降首先到达 θ2=0,然后向下滚动直到达到全局最小值。 两个底部图显示了相同的情况,只是使用了 ℓ2 惩罚。 规则化的最小值比非规范化的最小值更接近于 θ=0,但权重不能完全消除。 图 4-19:Ridge 回归和 Lasso 回归对比 提示 在 Lasso 损失函数中,批量梯度下降的路径趋向与在低谷有一个反弹。这是因为在 θ2=0 时斜率会有一个突变。为了最后真正收敛到全局最小值,你需要逐渐的降低学习率。 Lasso 损失函数在 θi=0(i=1,2,⋯,n) 处无法进行微分运算,但是梯度下降如果你使用子梯度向量 g 后它可以在任何 θi=0 的情况下进行计算。公式 4-11 是在 Lasso 损失函数上进行梯度下降的子梯度向量公式。 公式 4-11:Lasso 回归子梯度向量 g(θ,J)=∇θMSE(θ)+α(sign(θ1)sign(θ2)⋮sign(θn)) where sign(θi)={−1,θi<00,θi=0+1,θi>0 下面是一个使用 Scikit-Learn 的Lasso类的小例子。你也可以使用SGDRegressor(penalty="l1")来代替它。 >>> from sklearn.linear_model import Lasso >>> lasso_reg = Lasso(alpha=0.1) >>> lasso_reg.fit(X, y) >>> lasso_reg.predict([[1.5]]) array([ 1.53788174] 弹性网络(ElasticNet) 弹性网络介于 Ridge 回归和 Lasso 回归之间。它的正则项是 Ridge 回归和 Lasso 回归正则项的简单混合,同时你可以控制它们的混合率 r,当 r=0 时,弹性网络就是 Ridge 回归,当 r=1 时,其就是 Lasso 回归。具体表示如公式 4-12。 公式 4-12:弹性网络损失函数 J(θ)=MSE(θ)+rαn∑i=1|θi|+1−r2αn∑i=1θ2i 那么我们该如何选择线性回归,岭回归,Lasso 回归,弹性网络呢?一般来说有一点正则项的表现更好,因此通常你应该避免使用简单的线性回归。岭回归是一个很好的首选项,但是如果你的特征仅有少数是真正有用的,你应该选择 Lasso 和弹性网络。就像我们讨论的那样,它两能够将无用特征的权重降为零。一般来说,弹性网络的表现要比 Lasso 好,因为当特征数量比样本的数量大的时候,或者特征之间有很强的相关性时,Lasso 可能会表现的不规律。下面是一个使用 Scikit-Learn ElasticNet(l1_ratio指的就是混合率 r)的简单样本: >>> from sklearn.linear_model import ElasticNet >>> elastic_net = ElasticNet(alpha=0.1, l1_ratio=0.5) >>> elastic_net.fit(X, y) >>> elastic_net.predict([[1.5]]) array([ 1.54333232]) 早期停止法(Early Stopping) 对于迭代学习算法,有一种非常特殊的正则化方法,就像梯度下降在验证错误达到最小值时立即停止训练那样。我们称为早期停止法。图 4-20 表示使用批量梯度下降来训练一个非常复杂的模型(一个高阶多项式回归模型)。随着训练的进行,算法一直学习,它在训练集上的预测误差(RMSE)自然而然的下降。然而一段时间后,验证误差停止下降,并开始上升。这意味着模型在训练集上开始出现过拟合。一旦验证错误达到最小值,便提早停止训练。这种简单有效的正则化方法被 Geoffrey Hinton 称为“完美的免费午餐” 图 4-20:早期停止法 提示 随机梯度和小批量梯度下降不是平滑曲线,你可能很难知道它是否达到最小值。 一种解决方案是,只有在验证误差高于最小值一段时间后(你确信该模型不会变得更好了),才停止,之后将模型参数回滚到验证误差最小值。 下面是一个早期停止法的基础应用: from sklearn.base import clone sgd_reg = SGDRegressor(n_iter=1, warm_start=True, penalty=None,learning_rate="constant", eta0=0.0005) minimum_val_error = float("inf") best_epoch = None best_model = None for epoch in range(1000): sgd_reg.fit(X_train_poly_scaled, y_train) y_val_predict = sgd_reg.predict(X_val_poly_scaled) val_error = mean_squared_error(y_val_predict, y_val) if val_error < minimum_val_error: minimum_val_error = val_error best_epoch = epoch best_model = clone(sgd_reg) 注意:当warm_start=True时,调用fit()方法后,训练会从停下来的地方继续,而不是从头重新开始。 逻辑回归 正如我们在第1章中讨论的那样,一些回归算法也可以用于分类(反之亦然)。 Logistic 回归(也称为 Logit 回归)通常用于估计一个实例属于某个特定类别的概率(例如,这电子邮件是垃圾邮件的概率是多少?)。 如果估计的概率大于 50%,那么模型预测这个实例属于当前类(称为正类,标记为“1”),反之预测它不属于当前类(即它属于负类 ,标记为“0”)。 这样便成为了一个二元分类器。 概率估计 那么它是怎样工作的? 就像线性回归模型一样,Logistic 回归模型计算输入特征的加权和(加上偏差项),但它不像线性回归模型那样直接输出结果,而是把结果输入logistic()函数进行二次加工后进行输出(详见公式 4-13)。 公式 4-13:逻辑回归模型的概率估计(向量形式) ˆp=hθ(x)=σ(θT⋅x) Logistic 函数(也称为 logit),用 σ() 表示,其是一个 sigmoid 函数(图像呈 S 型),它的输出是一个介于 0 和 1 之间的数字。其定义如公式 4-14 和图 4-21 所示。 公式 4-14:逻辑函数 σ(t)=11+exp(−t) 图4-21:逻辑函数 一旦 Logistic 回归模型估计得到了 x 属于正类的概率 ˆp=hθ(x),那它很容易得到预测结果 ˆy(见公式 4-15)。 公式 4-15:逻辑回归预测模型 ˆy={0,ˆp<0.51,ˆp≥0.5 注意当 t<0 时 σ(t)<0.5,当 t≥0时σ(t)≥0.5,因此当θT⋅x 是正数的话,逻辑回归模型输出 1,如果它是负数的话,则输出 0。 训练和损失函数 好,现在你知道了 Logistic 回归模型如何估计概率并进行预测。 但是它是如何训练的? 训练的目的是设置参数向量 θ,使得正例(y=1)概率增大,负例(y=0)的概率减小,其通过在单个训练实例 x 的损失函数来实现(公式 4-16)。 公式 4-16:单个样本的损失函数 c(θ)={−log(ˆp),y=1−log(1−ˆp),y=0 这个损失函数是合理的,因为当 t 接近 0 时,−log(t) 变得非常大,所以如果模型估计一个正例概率接近于 0,那么损失函数将会很大,同时如果模型估计一个负例的概率接近 1,那么损失函数同样会很大。 另一方面,当 t 接近于 1 时,−log(t) 接近 0,所以如果模型估计一个正例概率接近于 0,那么损失函数接近于 0,同时如果模型估计一个负例的概率接近 0,那么损失函数同样会接近于 0, 这正是我们想的。 整个训练集的损失函数只是所有训练实例的平均值。可以用一个表达式(你可以很容易证明)来统一表示,称为对数损失,如公式 4-17 所示。 公式 4-17:逻辑回归的损失函数(对数损失) J(θ)=−1mm∑i=1[y(i)log(ˆp(i))+(1−y(i))log(1−ˆp(i))] 但是这个损失函数对于求解最小化损失函数的 θ 是没有公式解的(没有等价的正态方程)。 但好消息是,这个损失函数是凸的,所以梯度下降(或任何其他优化算法)一定能够找到全局最小值(如果学习速率不是太大,并且你等待足够长的时间)。公式 4-18 给出了损失函数关于第 j 个模型参数 θj 的偏导数。 公式 4-18:逻辑回归损失函数的偏导数 ∂∂θjJ(θj)=1mm∑i=1(σ(θT⋅x(i))−y(i))xj(i) 这个公式看起来非常像公式 4-5:首先计算每个样本的预测误差,然后误差项乘以第 j 项特征值,最后求出所有训练样本的平均值。 一旦你有了包含所有的偏导数的梯度向量,你便可以在梯度向量上使用批量梯度下降算法。 也就是说:你已经知道如何训练 Logistic 回归模型。 对于随机梯度下降,你当然只需要每一次使用一个实例,对于小批量梯度下降,你将每一次使用一个小型实例集。 决策边界 我们使用鸢尾花数据集来分析 Logistic 回归。 这是一个著名的数据集,其中包含 150 朵三种不同的鸢尾花的萼片和花瓣的长度和宽度。这三种鸢尾花为:Setosa,Versicolor,Virginica(如图 4-22)。 图4-22:三种不同的鸢尾花 让我们尝试建立一个分类器,仅仅使用花瓣的宽度特征来识别 Virginica,首先让我们加载数据: >>> from sklearn import datasets >>> iris = datasets.load_iris() >>> list(iris.keys()) ['data', 'target_names', 'feature_names', 'target', 'DESCR'] >>> X = iris["data"][:, 3:] # petal width >>> y = (iris["target"] == 2).astype(np.int) 接下来,我们训练一个逻辑回归模型: from sklearn.linear_model import LogisticRegression log_reg = LogisticRegression() log_reg.fit(X, y) 我们来看看模型估计的花瓣宽度从 0 到 3 厘米的概率估计(如图 4-23): X_new = np.linspace(0, 3, 1000).reshape(-1, 1) y_proba = log_reg.predict_proba(X_new) plt.plot(X_new, y_proba[:, 1], "g-", label="Iris-Virginica") plt.plot(X_new, y_proba[:, 0], "b--", label="Not Iris-Virginica" 图 4-23:概率估计和决策边界 Virginica 花的花瓣宽度(用三角形表示)在 1.4 厘米到 2.5 厘米之间,而其他种类的花(由正方形表示)通常具有较小的花瓣宽度,范围从 0.1 厘米到 1.8 厘米。注意,它们之间会有一些重叠。在大约 2 厘米以上时,分类器非常肯定这朵花是Virginica花(分类器此时输出一个非常高的概率值),而在1厘米以下时,它非常肯定这朵花不是 Virginica 花(不是 Virginica 花有非常高的概率)。在这两个极端之间,分类器是不确定的。但是,如果你使用它进行预测(使用predict()方法而不是predict_proba()方法),它将返回一个最可能的结果。因此,在 1.6 厘米左右存在一个决策边界,这时两类情况出现的概率都等于 50%:如果花瓣宽度大于 1.6 厘米,则分类器将预测该花是 Virginica,否则预测它不是(即使它有可能错了): >>> log_reg.predict([[1.7], [1.5]]) array([1, 0]) 图 4-24 表示相同的数据集,但是这次使用了两个特征进行判断:花瓣的宽度和长度。 一旦训练完毕,Logistic 回归分类器就可以根据这两个特征来估计一朵花是 Virginica 的可能性。 虚线表示这时两类情况出现的概率都等于 50%:这是模型的决策边界。 请注意,它是一个线性边界。每条平行线都代表一个分类标准下的两两个不同类的概率,从 15%(左下角)到 90%(右上角)。越过右上角分界线的点都有超过 90% 的概率是 Virginica 花。 图 4-24:线性决策边界 就像其他线性模型,逻辑回归模型也可以 ℓ1 或者 ℓ2 惩罚使用进行正则化。Scikit-Learn 默认添加了 ℓ2 惩罚。 注意 在 Scikit-Learn 的LogisticRegression模型中控制正则化强度的超参数不是 α(与其他线性模型一样),而是它的逆:C。 C 的值越大,模型正则化强度越低。 Softmax 回归 Logistic 回归模型可以直接推广到支持多类别分类,不必组合和训练多个二分类器(如第 3 章所述), 其称为 Softmax 回归或多类别 Logistic 回归。 这个想法很简单:当给定一个实例 x 时,Softmax 回归模型首先计算 k 类的分数 sk(x),然后将分数应用在Softmax函数(也称为归一化指数)上,估计出每类的概率。 计算 sk(x) 的公式看起来很熟悉,因为它就像线性回归预测的公式一样(见公式 4-19)。 公式 4-19:k类的 Softmax 得分 sk(x)=θT⋅x 注意,每个类都有自己独一无二的参数向量 θk。 所有这些向量通常作为行放在参数矩阵 Θ 中。 一旦你计算了样本 x 的每一类的得分,你便可以通过Softmax函数(公式 4-20)估计出样本属于第 k 类的概率 ˆpk:通过计算 e 的 sk(x) 次方,然后对它们进行归一化(除以所有分子的总和)。 公式 4-20:Softmax 函数 ^pk=σ(s(x))k=exp(sk(x))∑Kj=1exp(sj(x)) K 表示有多少类 s(x) 表示包含样本 x 每一类得分的向量 σ(s(x))k 表示给定每一类分数之后,实例 x 属于第 k 类的概率 和 Logistic 回归分类器一样,Softmax 回归分类器将估计概率最高(它只是得分最高的类)的那类作为预测结果,如公式 4-21 所示。 公式 4-21:Softmax 回归模型分类器预测结果 ˆy=argmax σ(s(x))k=argmax sk(x)=argmax (θTk⋅x) argmax运算返回一个函数取到最大值的变量值。 在这个等式,它返回使 σ(s(x))k 最大时的 k 的值 注意 Softmax 回归分类器一次只能预测一个类(即它是多类的,但不是多输出的),因此它只能用于判断互斥的类别,如不同类型的植物。 你不能用它来识别一张照片中的多个人。 现在我们知道这个模型如何估计概率并进行预测,接下来将介绍如何训练。我们的目标是建立一个模型在目标类别上有着较高的概率(因此其他类别的概率较低),最小化公式 4-22 可以达到这个目标,其表示了当前模型的损失函数,称为交叉熵,当模型对目标类得出了一个较低的概率,其会惩罚这个模型。 交叉熵通常用于衡量待测类别与目标类别的匹配程度(我们将在后面的章节中多次使用它) 公式 4-22:交叉熵 J(Θ)=−1mm∑i=1K∑k=1y(i)klog(ˆp(i)k) 如果对于第 i 个实例的目标类是 k,那么 y(i)k=1,反之 y(i)k=0。 可以看出,当只有两个类(K=2)时,此损失函数等同于 Logistic 回归的损失函数(对数损失;请参阅公式 4-17)。 交叉熵 交叉熵源于信息论。假设你想要高效地传输每天的天气信息。如果有八个选项(晴天,雨天等),则可以使用3位对每个选项进行编码,因为 23=8。但是,如果你认为几乎每天都是晴天,更高效的编码“晴天”的方式是:只用一位(0)。剩下的七项使用四位(从 1 开始)。交叉熵度量每个选项实际发送的平均比特数。 如果你对天气的假设是完美的,交叉熵就等于天气本身的熵(即其内部的不确定性)。 但是,如果你的假设是错误的(例如,如果经常下雨)交叉熵将会更大,称为 Kullback-Leibler 散度(KL 散度)。 两个概率分布 p 和 q 之间的交叉熵定义为:H(p,q)=−∑xp(x)logq(x)(分布至少是离散的) 这个损失函数关于 θk 的梯度向量为公式 4-23: 公式 4-23:k类交叉熵的梯度向量 ∇θkJ(Θ)=1mm∑i=1(ˆp(i)k−y(i)k)x(i) 现在你可以计算每一类的梯度向量,然后使用梯度下降(或者其他的优化算法)找到使得损失函数达到最小值的参数矩阵 Θ。 让我们使用 Softmax 回归对三种鸢尾花进行分类。当你使用LogisticRregression对模型进行训练时,Scikit Learn 默认使用的是一对多模型,但是你可以设置multi_class参数为“multinomial”来把它改变为 Softmax 回归。你还必须指定一个支持 Softmax 回归的求解器,例如“lbfgs”求解器(有关更多详细信息,请参阅 Scikit-Learn 的文档)。其默认使用 ℓ12 正则化,你可以使用超参数 C 控制它。 X = iris["data"][:, (2, 3)] # petal length, petal width y = iris["target"] softmax_reg = LogisticRegression(multi_class="multinomial",solver="lbfgs", C=10) softmax_reg.fit(X, y) 所以下次你发现一个花瓣长为 5 厘米,宽为 2 厘米的鸢尾花时,你可以问你的模型你它是哪一类鸢尾花,它会回答 94.2% 是 Virginica 花(第二类),或者 5.8% 是其他鸢尾花。 >>> softmax_reg.predict([[5, 2]]) array([2]) >>> softmax_reg.predict_proba([[5, 2]]) array([[ 6.33134078e-07, 5.75276067e-02, 9.42471760e-01]])是 图 4-25:Softmax 回归的决策边界 图 4-25 用不同背景色表示了结果的决策边界。注意,任何两个类之间的决策边界是线性的。 该图的曲线表示 Versicolor 类的概率(例如,用 0.450 标记的曲线表示 45% 的概率边界)。注意模型也可以预测一个概率低于 50% 的类。 例如,在所有决策边界相遇的地方,所有类的估计概率相等,分别为 33%。 练习 如果你有一个数百万特征的训练集,你应该选择哪种线性回归训练算法? 假设你训练集中特征的数值尺度(scale)有着非常大的差异,哪种算法会受到影响?有多大的影响?对于这些影响你可以做什么? 训练 Logistic 回归模型时,梯度下降是否会陷入局部最低点? 在有足够的训练时间下,是否所有的梯度下降都会得到相同的模型参数? 假设你使用批量梯度下降法,画出每一代的验证误差。当你发现验证误差一直增大,接下来会发生什么?你怎么解决这个问题? 当验证误差升高时,立即停止小批量梯度下降是否是一个好主意? 哪个梯度下降算法(在我们讨论的那些算法中)可以最快到达解的附近?哪个的确实会收敛?怎么使其他算法也收敛? 假设你使用多项式回归,画出学习曲线,在图上发现学习误差和验证误差之间有着很大的间隙。这表示发生了什么?有哪三种方法可以解决这个问题? 假设你使用岭回归,并发现训练误差和验证误差都很高,并且几乎相等。你的模型表现是高偏差还是高方差?这时你应该增大正则化参数 α,还是降低它? 你为什么要这样做: 使用岭回归代替线性回归? Lasso 回归代替岭回归? 弹性网络代替 Lasso 回归? 假设你想判断一副图片是室内还是室外,白天还是晚上。你应该选择二个逻辑回归分类器,还是一个 Softmax 分类器? 在 Softmax 回归上应用批量梯度下降的早期停止法(不使用 Scikit-Learn)。 附录 A 提供了这些练习的答案。
十八、HTTP 和表单 原文:HTTP and Forms 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 部分参考了《JavaScript 编程精解(第 2 版)》 通信在实质上必须是无状态的,从客户端到服务器的每个请求都必须包含理解请求所需的所有信息,并且不能利用服务器上存储的任何上下文。 Roy Fielding,《Architectural Styles and the Design of Network-based Software Architectures》 我们曾在第 13 章中提到过超文本传输协议(HTTP),万维网中通过该协议进行数据请求和传输。在本章中会对该协议进行详细介绍,并解释浏览器中 JavaScript 访问 HTTP 的方式。 协议 当你在浏览器地址栏中输入eloquentjavascript.net/18_http.html时,浏览器会首先找到和eloquentjavascript.net相关的服务器的地址,然后尝试通过 80 端口建立 TCP 连接,其中 80 端口是 HTTP 的默认通信端口。如果该服务器存在并且接受了该连接,浏览器可能发送如下内容。 GET /18_http.html HTTP/1.1 Host: eloquentjavascript.net User-Agent: Your browser's name 然后服务器会通过同一个链接返回如下内容。 HTTP/1.1 200 OK Content-Length: 65585 Content-Type: text/html Last-Modified: Mon, 08 Jan 2018 10:29:45 GMT <!doctype html> ... the rest of the document 浏览器会选取空行之后的响应部分,也就是正文(不要与 HTML <body>标签混淆),并将其显示为 HTML 文档。 由客户端发出的信息叫作请求。请求的第一行如下。 GET /17_http.html HTTP/1.1 请求中的第一个单词是请求方法。GET表示我们希望得到一个我们指定的资源。其他常用方式还有DELETE,用于删除一个资源;PUT用于替换资源;POST用于发送消息。需要注意的是服务器并不需要处理所有收到的请求。如果你随机访问一个网站并请求删除主页,服务器很有可能会拒绝你的请求。 方法名后的请求部分是所请求的资源的路径。在最简单的情况下,一个资源只是服务器中的一个文件。不过,协议并没有要求资源一定是实际文件。一个资源可以是任何可以像文件一样传输的东西。很多服务器会实时地生成这些资源。例如,如果你打开github.com/marijnh,服务器会在数据库中寻找名为marijnjh的用户,如果找到了则会为该用户的生成介绍页面。 请求的第一行中位于资源路径后面的HTTP/1.1用来表明所使用的 HTTP 协议的版本。 在实践中,许多网站使用 HTTP v2,它支持与版本 1.1 相同的概念,但是要复杂得多,因此速度更快。 浏览器在与给定服务器通信时,会自动切换到适当的协议版本,并且无论使用哪个版本,请求的结果都是相同的。 由于 1.1 版更直接,更易于使用,因此我们将专注于此。 服务器的响应也是以版本号开始的。版本号后面是响应状态,首先是一个三位的状态码,然后是一个可读的字符串。 HTTP/1.1 200 OK 以 2 开头的状态码表示请求成功。以 4 开头的状态码表示请求中有错误。404 是最著名的 HTTP 状态码了,表示找不到资源。以 5 开头的状态码表示服务器端出现了问题,而请求没有问题。 请求或响应的第一行后可能会有任意个协议头,多个形如name: value的行表明了和请求或响应相关的更多信息。这些是示例响应中的头信息。 Content-Length: 65585 Content-Type: text/html Last-Modified: Thu, 04 Jan 2018 14:05:30 GMT 这些信息说明了响应文档的大小和类型。在这个例子中,响应是一个 65585 字节的 HTML 文档,同时也说明了该文档最后的更改时间。 多数大多数协议头,客户端或服务器可以自由决定需要在请求或响应中包含的协议头,不过也有一些协议头是必需的。例如,指明主机名的Host头在请求中是必须的,因为一个服务器可能在一个 IP 地址下有多个主机名服务,如果没有Host头,服务器则无法判断客户端尝试请求哪个主机。 请求和响应可能都会在协议头后包含一个空行,后面则是消息体,包含所发送的数据。GET和DELETE请求不单独发送任何数据,但PUT和POST请求则会。同样地,一些响应类型(如错误响应)不需要有消息体。 浏览器和 HTTP 正如上例所示,当我们在浏览器地址栏输入一个 URL 后浏览器会发送一个请求。当 HTML 页面中包含有其他的文件,例如图片和 JavaScript 文件时,浏览器也会一并获取这些资源。 一个较为复杂的网站通常都会有 10 到 200 个不等的资源。为了可以很快地取得这些资源,浏览器会同时发送多个GET请求,而不是一次等待一个请求。此类文档都是通过GET方法来获取的。 HTML页面可能包含表单,用户可以在表单中填入一些信息然后由浏览器将其发送到服务器。如下是一个表单的例子。 <form method="GET" action="example/message.html"> <p>Name: <input type="text" name="name"></p> <p>Message:<br><textarea name="message"></textarea></p> <p><button type="submit">Send</button></p> </form> 这段代码描述了一个有两个输入字段的表单:较小的输入字段要求用户输入姓名,较大的要求用户输入一条消息。当点击发送按钮时,表单就提交了,这意味着其字段的内容被打包到 HTTP 请求中,并且浏览器跳转到该请求的结果。 当<form>元素的method属性是GET(或省略)时,表单中的信息将作为查询字符串添加到action URL 的末尾。 浏览器可能会向此 URL 发出请求: GET /example/message.html?name=Jean&message=Yes%3F HTTP/1.1 问号表示路径的末尾和查询字符串的起始。后面是多个名称和值,这些名称和值分别对应form输入字段中的name属性和这些元素的内容。&字符用来分隔不同的名称对。 在这个 URL 中,经过编码的消息实际原本是"Yes?",只不过浏览器用奇怪的代码替换了问号。我们必须替换掉请求字符串中的一些字符。使用%3F替换的问号就是其中之一。这样看,似乎有一个不成文的规定,每种格式都会有自己的转义字符。这里的编码格式叫作 URL 编码,使用一个百分号和16进制的数字来对字符进行编码。在这个例子中,3F(十进制为 63)是问号字符的编码。JavaScript 提供了encodeURIComponent和decodeURIComponent函数来按照这种格式进行编码和解码。 console.log(encodeURIComponent("Yes?")); // → Yes%3F console.log(decodeURIComponent("Yes%3F")); // → Yes? 如果我们将本例 HTML 表单中的method属性更改为POST,则浏览器会使用POST方法发送该表单,并将请求字符串放到请求正文中,而不是添加到 URL 中。 POST /example/message.html HTTP/1.1 Content-length: 24 Content-type: application/x-www-form-urlencoded name=Jean&message=Yes%3F GET请求应该用于没有副作用的请求,而仅仅是询问信息。 可以改变服务器上的某些内容的请求,例如创建一个新帐户或发布消息,应该用其他方法表示,例如POST。 诸如浏览器之类的客户端软件,知道它不应该盲目地发出POST请求,但通常会隐式地发出GET请求 - 例如预先获取一个它认为用户很快需要的资源。 我们将在本章后面的回到表单,以及如何与 JavaScript 交互。 Fetch 浏览器 JavaScript 可以通过fetch接口生成 HTTP 请求。 由于它比较新,所以它很方便地使用了Promise(这在浏览器接口中很少见)。 fetch("example/data.txt").then(response => { console.log(response.status); // → 200 console.log(response.headers.get("Content-Type")); // → text/plain }); 调用fetch返回一个Promise,它解析为一个Response对象,该对象包含服务器响应的信息,例如状态码和协议头。 协议头被封装在类Map的对象中,该对象不区分键(协议头名称)的大小写,因为协议头名称不应区分大小写。 这意味着header.get("Content-Type")和headers.get("content-TYPE")将返回相同的值。 请注意,即使服务器使用错误代码进行响应,由fetch返回的Promise也会成功解析。 如果存在网络错误或找不到请求的服务器,它也可能被拒绝。 fetch的第一个参数是请求的 URL。 当该 URL 不以协议名称(例如http:)开头时,它被视为相对路径,这意味着它解释为相对于当前文档的路径。 当它以斜线(/)开始时,它将替换当前路径,即服务器名称后面的部分。 否则,当前路径直到并包括最后一个斜杠的部分,放在相对 URL 前面。 为了获取响应的实际内容,可以使用其text方法。 由于初始Promise在收到响应头文件后立即解析,并且读取响应正文可能需要一段时间,这又会返回一个Promise。 fetch("example/data.txt") .then(resp => resp.text()) .then(text => console.log(text)); // → This is the content of data.txt 有一种类似的方法,名为json,它返回一个Promise,它将解析为,将正文解析为 JSON 时得到的值,或者不是有效的 JSON,则被拒绝。 默认情况下,fetch使用GET方法发出请求,并且不包含请求正文。 你可以通过传递一个带有额外选项的对象作为第二个参数,来进行不同的配置。 例如,这个请求试图删除example/data.txt。 fetch("example/data.txt", {method: "DELETE"}).then(resp => { console.log(resp.status); // → 405 }); 405 状态码意味着“方法不允许”,这是 HTTP 服务器说“我不能这样做”的方式。 为了添加一个请求正文,你可以包含body选项。 为了设置标题,存在headers选项。 例如,这个请求包含Range协议,它指示服务器只返回一部分响应。 fetch("example/data.txt", {headers: {Range: "bytes=8-19"}}) .then(resp => resp.text()) .then(console.log); // → the content 浏览器将自动添加一些请求头,例如Host和服务器需要的协议头,来确定正文的大小。 但是对于包含认证信息或告诉服务器想要接收的文件格式,添加自己的协议头通常很有用。 HTTP 沙箱 在网页脚本中发出 HTTP 请求,再次引发了安全性的担忧。 控制脚本的人的兴趣可能不同于正在运行的计算机的所有者。 更具体地说,如果我访问themafia.org,我不希望其脚本能够使用来自我的浏览器的身份向mybank.com发出请求,并且下令将我所有的钱转移到某个随机帐户。 出于这个原因,浏览器通过禁止脚本向其他域(如themafia.org和mybank.com等名称)发送 HTTP 请求来保护我们。 在构建希望因合法原因访问多个域的系统时,这可能是一个恼人的问题。 幸运的是,服务器可以在响应中包含这样的协议头,来明确地向浏览器表明,请求可以来自另一个域: Access-Control-Allow-Origin: * 运用 HTTP 当构建一个需要让浏览器(客户端)的 JavaScript 程序和服务器端的程序进行通信的系统时,有一些不同的方式可以实现这个功能。 一个常用的方法是远程过程调用,通信遵从正常的方法调用方式,不过调用的方法实际运行在另一台机器中。调用包括向服务器发送包含方法名和参数的请求。响应的结果则包括函数的返回值。 当考虑远程过程调用时,HTTP 只是通信的载体,并且你很可能会写一个抽象层来隐藏细节。 另一个方法是使用一些资源和 HTTP 方法来建立自己的通信。不同于远程调用方法addUser,你需要发送一个PUT请求到users/larry,不同于将用户属性进行编码后作为参数传递,你定义了一个 JSON 文档格式(或使用一种已有的格式)来展示一个用户。PUT请求的正文则只是这样的一个用来建立新资源的文档。由GET方法获取的资源则是自愿的 URL(例如,/users/larry),该 URL 返回代表这个资源的文档。 第二种方法使用了 HTTP 的一些特性,所以使得整体更简洁。例如对于资源缓存的支持(在客户端存一份副本用于快速访问)。HTTP 中使用的概念设计良好,可以提供一组有用的原则来设计服务器接口。 安全和 HTTPS 通过互联网传播的数据,往往走过漫长而危险的道路。 为了到达目的地,它必须跳过任何东西,从咖啡店的 Wi-Fi 到由各个公司和国家管理的网络。 在它的路线上的任何位置,它都可能被探测或者甚至被修改。 如果对某件事保密是重要的,例如你的电子邮件帐户的密码,或者它到达目的地而未经修改是重要的,例如帐户号码,你使用它在银行网站上转账,纯 HTTP 就不够好了。 安全的 HTTP 协议,其 URL 以https://开头,是一种难以阅读和篡改的,HTTP 流量的封装方式。 在交换数据之前,客户端证实该服务器是它所声称的东西,通过要求它证明,它具有由浏览器承认的证书机构所颁发的证书。 接下来,通过连接传输的所有数据,都将以某种方式加密,它应该防止窃听和篡改。 因此,当 HTTPS 正常工作时,它可以阻止某人冒充你想要与之通话的网站,以及某人窥探你的通信。 这并不完美,由于伪造或被盗的证书和损坏的软件,存在各种 HTTPS 失败的事故,但它比纯 HTTP 更安全。 表单字段 表单最初是为 JavaScript 之前的网页设计的,允许网站通过 HTTP 请求发送用户提交的信息。 这种设计假定与服务器的交互,总是通过导航到新页面实现。 但是它们的元素是 DOM 的一部分,就像页面的其他部分一样,并且表示表单字段的 DOM 元素,支持许多其他元素上不存在的属性和事件。 这些使其可以使用 JavaScript 程序检查和控制这些输入字段,以及可以执行一些操作,例如向表单添加新功能,或在 JavaScript 应用程序中使用表单和字段作为积木。 一个网页表单在其<form>标签中包含若干个输入字段。HTML 允许多个的不同风格的输入字段,从简单的开关选择框到下拉菜单和进行输入的字段。本书不会全面的讨论每一个输入字段类型,不过我们会先大概讲述一下。 很多字段类型都使用<input>标签。标签的type属性用来选择字段的种类,下面是一些常用的<input>类型。 text:一个单行的文本输入框。 password:和text相同但隐藏了输入内容。 checkbox:一个复选框。 radio:一个多选择字段中的一个单选框。 file:允许用户从本机选择文件上传。 表单字段并不一定要出现在<form>标签中。你可以把表单字段放置在一个页面的任何地方。但这样不带表单的字段不能被提交(一个完整的表单才可以),当需要和 JavaScript 进行响应时,我们通常也不希望按常规的方式提交表单。 <p><input type="text" value="abc"> (text)</p> <p><input type="password" value="abc"> (password)</p> <p><input type="checkbox" checked> (checkbox)</p> <p><input type="radio" value="A" name="choice"> <input type="radio" value="B" name="choice" checked> <input type="radio" value="C" name="choice"> (radio)</p> <p><input type="file"> (file)</p> 这些元素的 JavaScript 接口和元素类型不同。 多行文本输入框有其自己的标签<textarea>,这样做是因为通过一个属性来声明一个多行初始值会十分奇怪。<textarea>要求有一个相匹配的</textarea>结束标签并使用标签之间的文本作为初始值,而不是使用value属性存储文本。 <textarea> one two three </textarea> <select>标签用来创造一个可以让用户从一些提前设定好的选项中进行选择的字段。 <select> <option>Pancakes</option> <option>Pudding</option> <option>Ice cream</option> </select> 当一个表单字段中的内容更改时会触发change事件。 聚焦 不同于 HTML 文档中的其他元素,表单字段可以获取键盘焦点。当点击或以某种方式激活时,他们会成为激活的元素,并接受键盘的输入。 因此,只有获得焦点时,你才能输入文本字段。 其他字段对键盘事件的响应不同。 例如,<select>菜单尝试移动到包含用户输入文本的选项,并通过向上和向下移动其选项来响应箭头键。 我们可以通过使用 JavaScript 的focus和blur方法来控制聚焦。第一个会聚焦到某一个 DOM 元素,第二个则使其失焦。在document.activeElement中的值会关联到当前聚焦的元素。 <input type="text"> <script> document.querySelector("input").focus(); console.log(document.activeElement.tagName); // → INPUT document.querySelector("input").blur(); console.log(document.activeElement.tagName); // → BODY </script> 对于一些页面,用户希望立刻使用到一个表单字段。JavaScript 可以在页面载入完成时将焦点放到这些字段上,HTML 提供了autofocus属性,可以实现相同的效果,并让浏览器知道我们正在尝试实现的事情。这向浏览器提供了选项,来禁用一些错误的操作,例如用户希望将焦点置于其他地方。 浏览器也允许用户通过 TAB 键来切换焦点。通过tabindex属性可以改变元素接受焦点的顺序。后面的例子会让焦点从文本输入框跳转到 OK 按钮而不是到帮助链接。 <input type="text" tabindex=1> <a href=".">(help)</a> <button onclick="console.log('ok')" tabindex=2>OK</button> 默认情况下,多数的 HTML 元素不能拥有焦点。但是可以通过添加tabindex属性使任何元素可聚焦。tabindex为 -1 使 TAB 键跳过元素,即使它通常是可聚焦的。 禁用字段 所有的表单字段都可以通过其disable属性来禁用。它是一个可以被指定为没有值的属性 - 事实上它出现在所有禁用的元素中。 <button>I'm all right</button> <button disabled>I'm out</button> 禁用的字段不能拥有焦点或更改,浏览器使它们变成灰色。 当一个程序在处理一些由按键或其他控制方式出发的事件,并且这些事件可能要求和服务器的通信时,将元素禁用直到动作完成可能是一个很好的方法。按照这用方式,当用户失去耐心并且再次点击时,不会意外的重复这一动作。 作为整体的表单 当一个字段被包含在<form>元素中时,其 DOM 元素会有一个form属性指向form的 DOM 元素。<form>元素则会有一个叫作elements属性,包含一个类似于数据的集合,其中包含全部的字段。 一个表单字段的name属性会决定在form提交时其内容的辨别方式。同时在获取form的elements属性时也可以作为一种属性名,所以elements属性既可以像数组(由编号来访问)一样使用也可以像映射一样访问(通过名字访问)。 <form action="example/submit.html"> Name: <input type="text" name="name"><br> Password: <input type="password" name="password"><br> <button type="submit">Log in</button> </form> <script> let form = document.querySelector("form"); console.log(form.elements[1].type); // → password console.log(form.elements.password.type); // → password console.log(form.elements.name.form == form); // → true </script> type属性为submit的按钮在点击时,会提交表单。在一个form拥有焦点时,点击enter键也会有同样的效果。 通常在提交一个表单时,浏览器会将页面导航到form的action属性指明的页面,使用GET或POST请求。但是在这些发生之前,"submit"事件会被触发。这个事件可以由 JavaScript 处理,并且处理器可以通过调用事件对象的preventDefault来禁用默认行为。 <form action="example/submit.html"> Value: <input type="text" name="value"> <button type="submit">Save</button> </form> <script> let form = document.querySelector("form"); form.addEventListener("submit", event => { console.log("Saving value", form.elements.value.value); event.preventDefault(); }); </script> 在 JavaScript 中submit事件有多种用途。我们可以编写代码来检测用户输入是否正确并且立刻提示错误信息,而不是提交表单。或者我们可以禁用正常的提交方式,正如这个例子中,让我们的程序处理输入,可能使用fetch将其发送到服务器而不重新加载页面。 文本字段 由type属性为text或password的<input>标签和textarea标签组成的字段有相同的接口。其 DOM 元素都有一个value属性,保存了为字符串格式的当前内容。将这个属性更改为另一个值将改变字段的内容。 文本字段selectionStart和selectEnd属性包含光标和所选文字的信息。当没有选中文字时,这两个属性的值相同,表明当前光标的信息。例如,0 表示文本的开始,10 表示光标在第十个字符之后。当一部分字段被选中时,这两个属性值会不同,表明选中文字开始位置和结束位置。 和正常的值一样,这些属性也可以被更改。 想象你正在编写关于 Knaseknemwy 的文章,但是名字拼写有一些问题,后续代码将<textarea>标签和一个事件处理器关联起来,当点击F2时,插入 Knaseknemwy。 <textarea></textarea> <script> let textarea = document.querySelector("textarea"); textarea.addEventListener("keydown", event => { // The key code for F2 happens to be 113 if (event.keyCode == 113) { replaceSelection(textarea, "Khasekhemwy"); event.preventDefault(); } }); function replaceSelection(field, word) { let from = field.selectionStart, to = field.selectionEnd; field.value = field.value.slice(0, from) + word + field.value.slice(to); // Put the cursor after the word field.selectionStart = from + word.length; field.selectionEnd = from + word.length; } </script> replaceSelection函数用给定的字符串替换当前选中的文本字段内容,并将光标移动到替换内容后让用户可以继续输入。change事件不会在每次有输入时都被调用,而是在内容在改变并失焦后触发。为了及时的响应文本字段的改变,则需要为input事件注册一个处理器,每当用户有输入或更改时就被触发。 下面的例子展示一个文本字段和一个展示字段中的文字的当前长度的计数器。 <input type="text"> length: <span id="length">0</span> <script> let text = document.querySelector("input"); let output = document.querySelector("#length"); text.addEventListener("input", () => { output.textContent = text.value.length; }); </script> 选择框和单选框 一个选择框只是一个双选切换。其值可以通过其包含一个布尔值的checked属性来获取和更改。 <label> <input type="checkbox" id="purple"> Make this page purple </label> <script> let checkbox = document.querySelector("#purple"); checkbox.addEventListener("change", () => { document.body.style.background = checkbox.checked ? "mediumpurple" : ""; }); </script> <label>标签关联部分文本和一个输入字段。点击标签上的任何位置将激活该字段,这样会将其聚焦,并当它为复选框或单选按钮时切换它的值。 单选框和选择框类似,不过单选框可以通过相同的name属性,隐式关联其他几个单选框,保证只能选择其中一个。 Color: <label> <input type="radio" name="color" value="orange"> Orange </label> <label> <input type="radio" name="color" value="lightgreen"> Green </label> <label> <input type="radio" name="color" value="lightblue"> Blue </label> <script> let buttons = document.querySelectorAll("[name=color]"); for (let button of Array.from(buttons)) { button.addEventListener("change", () => { document.body.style.background = button.value; }); } </script> 提供给querySelectorAll的 CSS 查询中的方括号用于匹配属性。 它选择name属性为"color"的元素。 选择字段 选择字段和单选按钮比较相似,允许用户从多个选项中选择。但是,单选框的展示排版是由我们控制的,而<select>标签外观则是由浏览器控制。 选择字段也有一个更类似于复选框列表的变体,而不是单选框。 当赋予multiple属性时,<select>标签将允许用户选择任意数量的选项,而不仅仅是一个选项。 在大多数浏览器中,这会显示与正常的选择字段不同的效果,后者通常显示为下拉控件,仅在你打开它时才显示选项。 每一个<option>选项会有一个值,这个值可以通过value属性来定义。如果没有提供,选项内的文本将作为其值。<select>的value属性反映了当前的选中项。对于一个多选字段,这个属性用处不太大因为该属性只会给出一个选中项。 <select>字段的<option>标签可以通过一个类似于数组对象的options属性访问到。每个选项会有一个叫作selected的属性,来表明这个选项当前是否被选中。这个属性可以用来被设定选中或不选中。 这个例子会从多选字段中取出选中的数值,并使用这些数值构造一个二进制数字。按住CTRL(或 Mac 的COMMAND键)来选择多个选项。 <select multiple> <option value="1">0001</option> <option value="2">0010</option> <option value="4">0100</option> <option value="8">1000</option> </select> = <span id="output">0</span> <script> let select = document.querySelector("select"); let output = document.querySelector("#output"); select.addEventListener("change", () => { let number = 0; for (let option of Array.from(select.options)) { if (option.selected) { number += Number(option.value); } } output.textContent = number; }); </script> 文件字段 文件字段最初是用于通过表单来上传从浏览器机器中获取的文件。在现代浏览器中,也可以从 JavaScript 程序中读取文件。该字段则作为一个看门人角色。脚本不能简单地直接从用户的电脑中读取文件,但是如果用户在这个字段中选择了一个文件,浏览器会将这个行为解释为脚本,便可以访问该文件。 一个文本字段是一个类似于“选择文件”或“浏览”标签的按钮,后面跟着所选文件的信息。 <input type="file"> <script> let input = document.querySelector("input"); input.addEventListener("change", () => { if (input.files.length > 0) { let file = input.files[0]; console.log("You chose", file.name); if (file.type) console.log("It has type", file.type); } }); </script> 文本字段的files属性是一个类数组对象(当然,不是一个真正的数组),包含在字段中所选择的文件。开始时是空的。因此文本字段属性不仅仅是file属性。有时文本字段可以上传多个文件,这使得同时选择多个文件变为可能。 files对象中的对象有name(文件名)、size(文件大小,单位为字节),和type(文件的媒体类型,如text/plain,image/jpeg)等属性。 而files属性中不包含文件内容的属性。获取这个内容会比较复杂。由于从硬盘中读取文件会需要一些时间,接口必须是异步的,来避免文档的无响应问题。 <input type="file" multiple> <script> let input = document.querySelector("input"); input.addEventListener("change", () => { for (let file of Array.from(input.files)) { let reader = new FileReader(); reader.addEventListener("load", () => { console.log("File", file.name, "starts with", reader.result.slice(0, 20)); }); reader.readAsText(file); } }); </script> 读取文件是通过FileReader对象实现的,注册一个load事件处理器,然后调用readAsText方法,传入我们希望读取的文件,一旦载入完成,reader的result属性内容就是文件内容。 FileReader对象还会在读取文件失败时触发error事件。错误对象本身会存在reader的error属性中。这个接口是在Promise成为语言的一部分之前设计的。 你可以把它包装在Promise中,像这样: function readFileText(file) { return new Promise((resolve, reject) => { let reader = new FileReader(); reader.addEventListener( "load", () => resolve(reader.result)); reader.addEventListener( "error", () => reject(reader.error)); }); reader.readAsText(file); }); } 客户端保存数据 采用 JavaScript 代码的简单 HTML 页面可以作为实现一些小应用的很好的途径。可以采用小的帮助程序来自动化一些基本的任务。通过关联一些表单字段和事件处理器,你可以实现华氏度与摄氏度的转换。也可以实现由主密码和网站名来生成密码等各种任务。 当一个应用需要存储一些东西以便于跨对话使用时,则不能使用 JavaScript 绑定因为每当页面关闭时这些值就会丢失。你可以搭建一个服务器,连接到因特网,将一些服务数据存储到其中。在第20章中将会介绍如何实现这些,当然这需要很多的工作,也有一定的复杂度。有时只要将数据存储在浏览器中即可。 localStorage对象可以用于保存数据,它在页面重新加载后还存在。这个对象允许你将字符串存储在某个名字(也是字符串)下,下面是具体示例。 localStorage.setItem("username", "marijn"); console.log(localStorage.getItem("username")); // → marijn localStorage.removeItem("username"); 一个在localStorage中的值会保留到其被重写时,它也可以通过removeItem来清除,或者由用户清除本地数据。 不同字段名的站点的数据会存在不同的地方。这也表明原则上由localStorage存储的数据只可以由相同站点的脚本编辑。 浏览器的确限制一个站点可以存储的localStorage的数据大小。这种限制,以及用垃圾填满人们的硬盘并不是真正有利可图的事实,防止该特性占用太多空间。 下面的代码实现了一个粗糙的笔记应用。程序将用户的笔记保存为一个对象,将笔记的标题和内容字符串相关联。对象被编码为 JSON 格式并存储在localStorage中。用户可以从<select>选择字段中选择笔记并在<textarea>中编辑笔记,并可以通过点击一个按钮来添加笔记。 Notes: <select></select> <button>Add</button><br> <textarea style="width: 100%"></textarea> <script> let list = document.querySelector("select"); let note = document.querySelector("textarea"); let state; function setState(newState) { list.textContent = ""; for (let name of Object.keys(newState.notes)) { let option = document.createElement("option"); option.textContent = name; if (newState.selected == name) option.selected = true; list.appendChild(option); } note.value = newState.notes[newState.selected]; localStorage.setItem("Notes", JSON.stringify(newState)); state = newState; } setState(JSON.parse(localStorage.getItem("Notes")) || { notes: {"shopping list": "Carrots\nRaisins"}, selected: "shopping list" }); } list.addEventListener("change", () => { setState({notes: state.notes, selected: list.value}); }); note.addEventListener("change", () => { setState({ notes: Object.assign({}, state.notes, {[state.selected]: note.value}), selected: state.selected }); }); document.querySelector("button") .addEventListener("click", () => { let name = prompt("Note name"); if (name) setState({ notes: Object.assign({}, state.notes, {[name]: ""}), selected: name }); }); </script> 脚本从存储在localStorage中的"Notes"值来获取它的初始状态,如果其中没有值,它会创建示例状态,仅仅带有一个购物列表。从localStorage中读取不存在的字段会返回null。 setState方法确保 DOM 显示给定的状态,并将新状态存储到localStorage。 事件处理器调用这个函数来移动到一个新状态。 在这个例子中使用Object.assign,是为了创建一个新的对象,它是旧的state.notes的一个克隆,但是添加或覆盖了一个属性。 Object.assign选取第一个参数,向其添加所有更多参数的所有属性。 因此,向它提供一个空对象会使它填充一个新对象。 第三个参数中的方括号表示法,用于创建名称基于某个动态值的属性。 还有另一个和localStorage很相似的对象叫作sessionStorage。这两个对象之间的区别在于sessionStorage的内容会在每次会话结束时丢失,而对于多数浏览器来说,会话会在浏览器关闭时结束。 本章小结 在本章中,我们讨论了 HTTP 协议的工作原理。 客户端发送一个请求,该请求包含一个方法(通常是GET)和一个标识资源的路径。 然后服务器决定如何处理请求,并用状态码和响应正文进行响应。 请求和响应都可能包含提供附加信息的协议头。 浏览器 JavaScript 可以通过fetch接口生成 HTTP 请求。 像这样生成请求: fetch("/18_http.html").then(r => r.text()).then(text => { console.log(`The page starts with ${text.slice(0, 15)}`); }); 浏览器生成GET请求来获取显示网页所需的资源。 页面也可能包含表单,这些表单允许在提交表单时,用户输入的信息发送为新页面的请求。 HTML可以表示多种表单字段,例如文本字段、选择框、多选字段和文件选取。 这些字段可以用 JavaScript 进行控制和读取。内容改变时会触发change事件,文本有输入时会触发input事件,键盘获得焦点时触发键盘事件。 例如"value"(用于文本和选择字段)或"checked"(用于复选框和单选按钮)的属性,用于读取或设置字段的内容。 当一个表单被提交时,会触发其submit事件,JavaScript 处理器可以通过调用preventDefault来禁用默认的提交事件。表单字段的元素不一定需要被包装在<form>标签中。 当用户在一个文件选择字段中选择了本机中的一个文件时,可以用FileReader接口来在 JavaScript 中获取文件内容。 localStorage和sessionStorage对象可以用来保存页面重载后依旧保留的信息。第一个会永久保留数据(直到用户决定清除),第二个则会保存到浏览器关闭时。 习题 内容协商 HTTP 可以做的事情之一就是内容协商。 Accept请求头用于告诉服务器,客户端想要获得什么类型的文档。 许多服务器忽略这个协议头,但是当一个服务器知道各种编码资源的方式时,它可以查看这个协议头,并发送客户端首选的格式。 URL eloquentjavascript.net/author配置为响应明文,HTML 或 JSON,具体取决于客户端要求的内容。 这些格式由标准化的媒体类型"text/plain","text/html"和"application/json"标识。 发送请求来获取此资源的所有三种格式。 使用传递给fetch的options对象中的headers属性,将名为Accept的协议头设置为所需的媒体类型。 最后,请尝试请求媒体类型"application/rainbows+unicorns",并查看产生的状态码。 // Your code here. JavaScript 工作台 构建一个接口,允许用户输入和运行一段 JavaScript 代码。 在<textarea>字段旁边放置一个按钮,当按下该按钮时,使用我们在第 10 章中看到的Function构造器,将文本包装到一个函数中并调用它。 将函数的返回值或其引发的任何错误转换为字符串,并将其显示在文本字段下。 <textarea id="code">return "hi";</textarea> <button id="button">Run</button> <pre id="output"></pre> <script> // Your code here. </script> Conway 的生命游戏 Conway 的生命游戏是一个简单的在网格中模拟生命的游戏,每一个细胞都可以生存或灭亡。对于每一代(回合),都要遵循以下规则: 任何细胞,周围有少于两个或多于三个的活着的邻居,都会死亡。 任意细胞,拥有两个或三个的活着的邻居,可以生存到下一代。 任何死去的细胞,周围有三个活着的邻居,可以再次复活。 任意一个相连的细胞都可以称为邻居,包括对角相连。 注意这些规则要立刻应用于整个网格,而不是一次一个网格。这表明邻居的数目由开始的一代决定,并且邻居在每一代时发生的变化不应该影响给定细胞新的状态。 使用任何一个你认为合适的数据结构来实现这个游戏。使用Math.random来随机的生成开始状态。将其展示为一个选择框组成的网格和一个生成下一代的按钮。当用户选中或取消选中一个选择框时,其变化应该影响下一代的计算。 <div id="grid"></div> <button id="next">Next generation</button> <script> // Your code here. </script>
十七、在画布上绘图 原文:Drawing on Canvas 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 部分参考了《JavaScript 编程精解(第 2 版)》 绘图就是欺骗。 M.C. Escher,由 Bruno Ernst 在《The Magic Mirror of M.C. Escher》中引用 浏览器为我们提供了多种绘图方式。最简单的方式是用样式来规定普通 DOM 对象的位置和颜色。就像在上一章中那个游戏展示的,我们可以使用这种方式实现很多功能。我们可以为节点添加半透明的背景图片,来获得我们希望的节点外观。我们也可以使用transform样式来旋转或倾斜节点。 但是,在一些场景中,使用 DOM 并不符合我们的设计初衷。比如我们很难使用普通的 HTML 元素画出任意两点之间的线段这类图形。 这里有两种解决办法。第一种方法基于 DOM,但使用可缩放矢量图形(SVG,Scalable Vector Graphics)代替 HTML。我们可以将 SVG 看成文档标记方言,专用于描述图形而非文字。你可以在 HTML 文档中嵌入 SVG,还可以在<img>标签中引用它。 我们将第二种方法称为画布(canvas)。画布是一个能够封装图片的 DOM 元素。它提供了在空白的html节点上绘制图形的编程接口。SVG 与画布的最主要区别在于 SVG 保存了对于图像的基本信息的描述,我们可以随时移动或修改图像。 另外,画布在绘制图像的同时会把图像转换成像素(在栅格中的具有颜色的点)并且不会保存这些像素表示的内容。唯一的移动图形的方法就是清空画布(或者围绕着图形的部分画布)并在新的位置重画图形。 SVG 本书不会深入研究 SVG 的细节,但是我会简单地解释其工作原理。在本章的结尾,我会再次来讨论,对于某个具体的应用来说,我们应该如何权衡利弊选择一种绘图方式。 这是一个带有简单的 SVG 图片的 HTML 文档。 <p>Normal HTML here.</p> <svg xmlns="http://www.w3.org/2000/svg"> <circle r="50" cx="50" cy="50" fill="red"/> <rect x="120" y="5" width="90" height="90" stroke="blue" fill="none"/> </svg> xmlns属性把一个元素(以及他的子元素)切换到一个不同的 XML 命名空间。这个由url定义的命名空间,规定了我们当前使用的语言。在 HTML 中不存在<circle>与<rect>标签,但这些标签在 SVG 中是有意义的,你可以通过这些标签的属性来绘制图像并指定样式与位置。 和 HTML 标签一样,这些标签会创建 DOM 元素,脚本可以和它们交互。例如,下面的代码可以把<circle>元素的颜色替换为青色。 let circle = document.querySelector("circle"); circle.setAttribute("fill", "cyan"); canvas元素 我们可以在<canvas>元素中绘制画布图形。你可以通过设置width与height属性来确定画布尺寸(单位为像素)。 新的画布是空的,意味着它是完全透明的,看起来就像文档中的空白区域一样。 <canvas>标签允许多种不同风格的绘图。要获取真正的绘图接口,首先我们要创建一个能够提供绘图接口的方法的上下文(context)。目前有两种得到广泛支持的绘图接口:用于绘制二维图形的"2d"与通过openGL接口绘制三维图形的"webgl"。 本书只讨论二维图形,而不讨论 WebGL。但是如果你对三维图形感兴趣,我强烈建议大家自行深入研究 WebGL。它提供了非常简单的现代图形硬件接口,同时你也可以使用 JavaScript 来高效地渲染非常复杂的场景。 您可以用getContext方法在<canvas> DOM 元素上创建一个上下文。 <p>Before canvas.</p> <canvas width="120" height="60"></canvas> <p>After canvas.</p> <script> let canvas = document.querySelector("canvas"); let context = canvas.getContext("2d"); context.fillStyle = "red"; context.fillRect(10, 10, 100, 50); </script> 在创建完context对象之后,作为示例,我们画出一个红色矩形。该矩形宽 100 像素,高 50 像素,它的左上点坐标为(10,10)。 与 HTML(或者 SVG)相同,画布使用的坐标系统将(0,0)放置在左上角,并且y轴向下增长。所以(10,10)是相对于左上角向下并向右各偏移 10 像素的位置。 直线和平面 我们可以使用画布接口填充图形,也就是赋予某个区域一个固定的填充颜色或填充模式。我们也可以描边,也就是沿着图形的边沿画出线段。SVG 也使用了相同的技术。 fillRect方法可以填充一个矩形。他的输入为矩形框左上角的第一个x和y坐标,然后是它的宽和高。相似地,strokeRect方法可以画出一个矩形的外框。 两个方法都不需要其他任何参数。填充的颜色以及轮廓的粗细等等都不能由方法的参数决定(像你的合理预期一样),而是由上下文对象的属性决定。 设置fillStyle参数控制图形的填充方式。我们可以将其设置为描述颜色的字符串,使用 CSS 所用的颜色表示法。 strokeStyle属性的作用很相似,但是它用于规定轮廓线的颜色。线条的宽度由lineWidth属性决定。lineWidth的值都为正值。 <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.strokeStyle = "blue"; cx.strokeRect(5, 5, 50, 50); cx.lineWidth = 5; cx.strokeRect(135, 5, 50, 50); </script> 当没有设置width或者height参数时,正如示例一样,画布元素的默认宽度为 300 像素,默认高度为 150 像素。 路径 路径是线段的序列。2D canvas接口使用一种奇特的方式来描述这样的路径。路径的绘制都是间接完成的。我们无法将路径保存为可以后续修改并传递的值。如果你想修改路径,必须要调用多个方法来描述他的形状。 <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.beginPath(); for (let y = 10; y < 100; y += 10) { cx.moveTo(10, y); cx.lineTo(90, y); } cx.stroke(); </script> 本例创建了一个包含很多水平线段的路径,然后用stroke方法勾勒轮廓。每个线段都是由lineTo以当前位置为路径起点绘制的。除非调用了moveTo,否则这个位置通常是上一个线段的终点位置。如果调用了moveTo,下一条线段会从moveTo指定的位置开始。 当使用fill方法填充一个路径时,我们需要分别填充这些图形。一个路径可以包含多个图形,每个moveTo都会创建一个新的图形。但是在填充之前我们需要封闭路径(路径的起始节点与终止节点必须是同一个点)。如果一个路径尚未封闭,会出现一条从终点到起点的线段,然后才会填充整个封闭图形。 <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.beginPath(); cx.moveTo(50, 10); cx.lineTo(10, 70); cx.lineTo(90, 70); cx.fill(); </script> 本例画出了一个被填充的三角形。注意只显示地画出了三角形的两条边。第三条从右下角回到上顶点的边是没有显示地画出,因而在勾勒路径的时候也不会存在。 你也可以使用closePath方法显示地通过增加一条回到路径起始节点的线段来封闭一个路径。这条线段在勾勒路径的时候将被显示地画出。 曲线 路径也可能会包含曲线。绘制曲线更加复杂。 quadraticCurveTo方法绘制到某一个点的曲线。为了确定一条线段的曲率,需要设定一个控制点以及一个目标点。设想这个控制点会吸引这条线段,使其成为曲线。线段不会穿过控制点。但是,它起点与终点的方向会与两个点到控制点的方向平行。见下例: <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.beginPath(); cx.moveTo(10, 90); // control=(60,10) goal=(90,90) cx.quadraticCurveTo(60, 10, 90, 90); cx.lineTo(60, 10); cx.closePath(); cx.stroke(); </script> 我们从左到右绘制一个二次曲线,曲线的控制点坐标为(60,10),然后画出两条穿过控制点并且回到线段起点的线段。绘制的结果类似一个星际迷航的图章。你可以观察到控制点的效果:从下端的角落里发出的线段朝向控制点并向他们的目标点弯曲。 bezierCurve(贝塞尔曲线)方法可以绘制一种类似的曲线。不同的是贝塞尔曲线需要两个控制点而不是一个,线段的每一个端点都需要一个控制点。下面是描述贝塞尔曲线的简单示例。 <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.beginPath(); cx.moveTo(10, 90); // control1=(10,10) control2=(90,10) goal=(50,90) cx.bezierCurveTo(10, 10, 90, 10, 50, 90); cx.lineTo(90, 10); cx.lineTo(10, 10); cx.closePath(); cx.stroke(); </script> 两个控制点规定了曲线两个端点的方向。两个控制点相对两个端点的距离越远,曲线就会越向这个方向凸出。 由于我们没有明确的方法,来找出我们希望绘制图形所对应的控制点,所以这种曲线还是很难操控。有时候你可以通过计算得到他们,而有时候你只能通过不断的尝试来找到合适的值。 arc方法是一种沿着圆的边缘绘制曲线的方法。 它需要弧的中心的一对坐标,半径,然后是起始和终止角度。 我们可以使用最后两个参数画出部分圆。角度是通过弧度来测量的,而不是度数。这意味着一个完整的圆拥有2π的弧度,或者2*Math.PI(大约为 6.28)的弧度。弧度从圆心右边的点开始并以顺时针的方向计数。你可以以 0 起始并以一个比2π大的数值(比如 7)作为终止值,画出一个完整的圆。 <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.beginPath(); // center=(50,50) radius=40 angle=0 to 7 cx.arc(50, 50, 40, 0, 7); // center=(150,50) radius=40 angle=0 to ½π cx.arc(150, 50, 40, 0, 0.5 * Math.PI); cx.stroke(); </script> 上面这段代码绘制出的图形包含了一条从完整圆(第一次调用arc)的右侧到四分之一圆(第二次调用arc)的左侧的直线。arc与其他绘制路径的方法一样,会自动连接到上一个路径上。你可以调用moveTo或者开启一个新的路径来避免这种情况。 绘制饼状图 设想你刚刚从 EconomiCorp 获得了一份工作,并且你的第一个任务是画出一个描述其用户满意度调查结果的饼状图。results绑定包含了一个表示调查结果的对象的数组。 const results = [ {name: "Satisfied", count: 1043, color: "lightblue"}, {name: "Neutral", count: 563, color: "lightgreen"}, {name: "Unsatisfied", count: 510, color: "pink"}, {name: "No comment", count: 175, color: "silver"} ]; 要想画出一个饼状图,我们需要画出很多个饼状图的切片,每个切片由一个圆弧与两条到圆心的线段组成。我们可以通过把一个整圆(2π)分割成以调查结果数量为单位的若干份,然后乘以做出相应选择的用户的个数来计算每个圆弧的角度。 <canvas width="200" height="200"></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let total = results .reduce((sum, {count}) => sum + count, 0); // Start at the top let currentAngle = -0.5 * Math.PI; for (let result of results) { let sliceAngle = (result.count / total) * 2 * Math.PI; cx.beginPath(); // center=100,100, radius=100 // from current angle, clockwise by slice's angle cx.arc(100, 100, 100, currentAngle, currentAngle + sliceAngle); currentAngle += sliceAngle; cx.lineTo(100, 100); cx.fillStyle = result.color; cx.fill(); } </script> 但表格并没有告诉我们切片代表的含义,它毫无用处。因此我们需要将文字画在画布上。 文本 2D 画布的context对象提供了fillText方法和strokeText方法。第二个方法可以用于绘制字母轮廓,但通常情况下我们需要的是fillText方法。该方法使用当前的fillColor来填充特定文字的轮廓。 <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.font = "28px Georgia"; cx.fillStyle = "fuchsia"; cx.fillText("I can draw text, too!", 10, 50); </script> 你可以通过font属性来设定文字的大小,样式和字体。本例给出了一个字体的大小和字体族名称。也可以添加italic或者bold来选择样式。 传递给fillText和strokeText的后两个参数用于指定绘制文字的位置。默认情况下,这个位置指定了文字的字符基线(baseline)的起始位置,我们可以将其假想为字符所站立的位置,基线不考虑j或p字母中那些向下突出的部分。你可以设置textAlign属性(end或center)来改变起始点的水平位置,也可以设置textBaseline属性(top、middle或bottom)来设置基线的竖直位置。 在本章末尾的练习中,我们会回顾饼状图,并解决给饼状图分片标注的问题。 图像 计算机图形学领域经常将矢量图形和位图图形分开来讨论。本章一直在讨论第一种图形,即通过对图形的逻辑描述来绘图。而位图则相反,不需要设置实际图形,而是通过处理像素数据来绘制图像(光栅化的着色点)。 我们可以使用drawImage方法在画布上绘制像素值。此处的像素数值可以来自<img>元素,或者来自其他的画布。下例创建了一个独立的<img>元素,并且加载了一张图像文件。但我们无法马上使用该图片进行绘制,因为浏览器可能还没有完成图片的获取操作。为了处理这个问题,我们在图像元素上注册一个"load"事件处理程序并且在图片加载完之后开始绘制。 <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let img = document.createElement("img"); img.src = "https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/hat.png"; img.addEventListener("load", () => { for (let x = 10; x < 200; x += 30) { cx.drawImage(img, x, 10); } }); </script> 默认情况下,drawImage会根据原图的尺寸绘制图像。你也可以增加两个参数来设置不同的宽度和高度。 如果我们向drawImage函数传入 9 个参数,我们可以用其绘制出一张图片的某一部分。第二个到第五个参数表示需要拷贝的源图片中的矩形区域(x,y坐标,宽度和高度),同时第六个到第九个参数给出了需要拷贝到的目标矩形的位置(在画布上)。 该方法可以用于在单个图像文件中放入多个精灵(图像单元)并画出你需要的部分。 我们可以改变绘制的人物造型,来展现一段看似人物在走动的动画。 clearRect方法可以帮助我们在画布上绘制动画。该方法类似于fillRect方法,但是不同的是clearRect方法会将目标矩形透明化,并移除掉之前绘制的像素值,而不是着色。 我们知道每个精灵和每个子画面的宽度都是 24 像素,高度都是 30 像素。下面的代码装载了一幅图片并设置定时器(会重复触发的定时器)来定时绘制下一帧。 <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let img = document.createElement("img"); img.src = "https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/player.png"; let spriteW = 24, spriteH = 30; img.addEventListener("load", () => { let cycle = 0; setInterval(() => { cx.clearRect(0, 0, spriteW, spriteH); cx.drawImage(img, // source rectangle cycle * spriteW, 0, spriteW, spriteH, // destination rectangle 0, 0, spriteW, spriteH); cycle = (cycle + 1) % 8; }, 120); }); </script> cycle绑定用于记录角色在动画图像中的位置。每显示一帧,我们都要将cycle加 1,并通过取余数确保cycle的值在 0~7 这个范围内。我们随后使用该绑定计算精灵当前形象在图片中的x坐标。 变换 但是,如果我们希望角色可以向左走而不是向右走该怎么办?诚然,我们可以绘制另一组精灵,但我们也可以使用另一种方式在画布上绘图。 我们可以调用scale方法来缩放之后绘制的任何元素。该方法接受两个输入参数,第一个参数是水平缩放比例,第二个参数是竖直缩放比例。 <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.scale(3, .5); cx.beginPath(); cx.arc(50, 50, 40, 0, 7); cx.lineWidth = 3; cx.stroke(); </script> 因为调用了scale,因此圆形长度变为原来的 3 倍,高度变为原来的一半。scale可以调整图像所有特征,包括线宽、预定拉伸或压缩。如果将缩放值设置为负值,可以将图像翻转。由于翻转发生在坐标(0,0)处,这意味着也会同时反转坐标系的方向。当水平缩放 –1 时,在x坐标为 100 的位置画出的图形会绘制在缩放之前x坐标为 –100 的位置。 为了翻转一张图片,只是在drawImage之前添加cx.scale(–1,–1)是没用的,因为这样会将我们的图片移出到画布之外,导致图片不可见。为了避免这个问题,我们还需要调整传递给drawImage的坐标,将绘制图形的x坐标改为 –50 而不是 0。另一个解决方案是在缩放时调整坐标轴,这样代码就不需要知道整个画布的缩放的改变。 除了scale方法还有一些其他方法可以影响画布里坐标系统的方法。你可以使用rotate方法旋转绘制完的图形,也可以使用translate方法移动图形。毕竟有趣但也容易引起误解的是这些变换以栈的方式工作,也就是说每个变换都会作用于前一个变换的结果之上。 如果我们沿水平方向将画布平移两次,每次移动 10 像素,那么所有的图形都会在右方 20 像素的位置重新绘制。如果我们先把坐标系的原点移动到(50, 50)的位置,然后旋转 20 度(大约0.1π弧度),此次的旋转会围绕点(50,50)进行。 但是如果我们先旋转 20 度,然后平移原点到(50,50),此次的平移会发生在已经旋转过的坐标系中,因此会有不同的方向。变换发生顺序会影响最后的结果。 我们可以使用下面的代码,在指定的x坐标处竖直反转一张图片。 function flipHorizontally(context, around) { context.translate(around, 0); context.scale(-1, 1); context.translate(-around, 0); } 我们先把y轴移动到我们希望镜像所在的位置,然后进行镜像翻转,最后把y轴移动到被翻转的坐标系当中相应的位置。下面的图片解释了以上代码是如何工作的: 上图显示了通过中线进行镜像翻转前后的坐标系。对三角形编号来说明每一步。如果我们在x坐标为正值的位置绘制一个三角形,默认情况下它会出现在图中三角形 1 的位置。调用filpHorizontally首先做一个向右的平移,得到三角形 2。然后将其翻转到三角形 3 的位置。这不是它的根据给定的中线翻转之后应该在的最终位置。第二次调用translate方法解决了这个问题。它“去除”了最初的平移的效果,并且使三角形 4 变成我们希望的效果。 我们可以沿着特征的竖直中心线翻转整个坐标系,这样就可以画出位置为(100,0)处的镜像特征。 <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let img = document.createElement("img"); img.src = "https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/player.png"; let spriteW = 24, spriteH = 30; img.addEventListener("load", () => { flipHorizontally(cx, 100 + spriteW / 2); cx.drawImage(img, 0, 0, spriteW, spriteH, 100, 0, spriteW, spriteH); }); </script> 存储与清除图像的变换状态 图像变换的效果会保留下来。我们绘制出一次镜像特征后,绘制其他特征时都会产生镜像效果,这可能并不方便。 对于需要临时转换坐标系统的函数来说,我们经常需要保存当前的信息,画一些图,变换图像然后重新加载之前的图像。首先,我们需要将当前函数调用的所有图形变换信息保存起来。接着,函数完成其工作,并添加更多的变换。最后我们恢复之前保存的变换状态。 2D 画布上下文的save与restore方法执行这个变换管理。这两个方法维护变换状态堆栈。save方法将当前状态压到堆栈中,restore方法将堆栈顶部的状态弹出,并将该状态作为当前context对象的状态。 下面示例中的branch函数首先修改变换状态,然后调用其他函数(本例中就是该函数自身)继续在特定变换状态中进行绘图。 这个方法通过画出一条线段,并把坐标系的中心移动到线段的端点,然后调用自身两次,先向左旋转,接着向右旋转,来画出一个类似树一样的图形。每次调用都会减少所画分支的长度,当长度小于 8 的时候递归结束。 <canvas width="600" height="300"></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); function branch(length, angle, scale) { cx.fillRect(0, 0, 1, length); if (length < 8) return; cx.save(); cx.translate(0, length); cx.rotate(-angle); branch(length * scale, angle, scale); cx.rotate(2 * angle); branch(length * scale, angle, scale); cx.restore(); } cx.translate(300, 0); branch(60, 0.5, 0.8); </script> 如果没有调用save与restore方法,第二次递归调用branch将会在第一次调用的位置结束。它不会与当前的分支相连接,而是更加靠近中心偏右第一次调用所画出的分支。结果图像会很有趣,但是它肯定不是一棵树。 回到游戏 我们现在已经了解了足够多的画布绘图知识,我们已经可以使用基于画布的显示系统来改造前面几章中开发的游戏了。新的界面不会再是一个个色块,而使用drawImage来绘制游戏中元素对应的图片。 我们定义了一种对象类型,叫做CanvasDisplay,支持第 14 章中的DOMDisplay的相同接口,也就是setState方法与clear方法。 这个对象需要比DOMDisplay多保存一些信息。该对象不仅需要使用 DOM 元素的滚动位置,还需要追踪自己的视口(viewport)。视口会告诉我们目前处于哪个关卡。最后,该对象会保存一个filpPlayer属性,确保即便玩家站立不动时,它面朝的方向也会与上次移动所面向的方向一致。 class CanvasDisplay { constructor(parent, level) { this.canvas = document.createElement("canvas"); this.canvas.width = Math.min(600, level.width * scale); this.canvas.height = Math.min(450, level.height * scale); parent.appendChild(this.canvas); this.cx = this.canvas.getContext("2d"); this.flipPlayer = false; this.viewport = { left: 0, top: 0, width: this.canvas.width / scale, height: this.canvas.height / scale }; } clear() { this.canvas.remove(); } } setState方法首先计算一个新的视口,然后在适当的位置绘制游戏场景。 CanvasDisplay.prototype.setState = function(state) { this.updateViewport(state); this.clearDisplay(state.status); this.drawBackground(state.level); this.drawActors(state.actors); }; 与DOMDisplay相反,这种显示风格确实必须在每次更新时重新绘制背景。 因为画布上的形状只是像素,所以在我们绘制它们之后,没有什么好方法来移动它们(或将它们移除)。 更新画布显示的唯一方法,是清除它并重新绘制场景。 我们也可能发生了滚动,这要求背景处于不同的位置。 updateViewport方法与DOMDisplay的scrollPlayerintoView方法相似。它检查玩家是否过于接近屏幕的边缘,并且当这种情况发生时移动视口。 CanvasDisplay.prototype.updateViewport = function(state) { let view = this.viewport, margin = view.width / 3; let player = state.player; let center = player.pos.plus(player.size.times(0.5)); if (center.x < view.left + margin) { view.left = Math.max(center.x - margin, 0); } else if (center.x > view.left + view.width - margin) { view.left = Math.min(center.x + margin - view.width, state.level.width - view.width); } if (center.y < view.top + margin) { view.top = Math.max(center.y - margin, 0); } else if (center.y > view.top + view.height - margin) { view.top = Math.min(center.y + margin - view.height, state.level.height - view.height); } }; 对Math.max和Math.min的调用保证了视口不会显示当前这层之外的物体。Math.max(x,0)保证了结果数值不会小于 0。同样地,Math.min`保证了数值保持在给定范围内。 在清空图像时,我们依据游戏是获胜(明亮的颜色)还是失败(灰暗的颜色)来使用不同的颜色。 CanvasDisplay.prototype.clearDisplay = function(status) { if (status == "won") { this.cx.fillStyle = "rgb(68, 191, 255)"; } else if (status == "lost") { this.cx.fillStyle = "rgb(44, 136, 214)"; } else { this.cx.fillStyle = "rgb(52, 166, 251)"; } this.cx.fillRect(0, 0, this.canvas.width, this.canvas.height); }; 要画出一个背景,我们使用来自上一节的touches方法中的相同技巧,遍历在当前视口中可见的所有瓦片。 let otherSprites = document.createElement("img"); otherSprites.src = "https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/sprites.png"; CanvasDisplay.prototype.drawBackground = function(level) { let {left, top, width, height} = this.viewport; let xStart = Math.floor(left); let xEnd = Math.ceil(left + width); let yStart = Math.floor(top); let yEnd = Math.ceil(top + height); for (let y = yStart; y < yEnd; y++) { for (let x = xStart; x < xEnd; x++) { let tile = level.rows[y][x]; if (tile == "empty") continue; let screenX = (x - left) * scale; let screenY = (y - top) * scale; let tileX = tile == "lava" ? scale : 0; this.cx.drawImage(otherSprites, tileX, 0, scale, scale, screenX, screenY, scale, scale); } } }; 非空的瓦片是使用drawImage绘制的。otherSprites包含了描述除了玩家之外需要用到的图片。它包含了从左到右的墙上的瓦片,火山岩瓦片以及精灵硬币。 背景瓦片是20×20像素的,因为我们将要用到DOMDisplay中的相同比例。因此,火山岩瓦片的偏移是 20,墙面的偏移是 0。 我们不需要等待精灵图片加载完成。调用drawImage时使用一幅并未加载完毕的图片不会有任何效果。因为图片仍然在加载当中,我们可能无法正确地画出游戏的前几帧。但是这不是一个严重的问题,因为我们持续更新荧幕,正确的场景会在加载完毕之后立即出现。 前面展示过的走路的特征将会被用来代替玩家。绘制它的代码需要根据玩家的当前动作选择正确的动作和方向。前 8 个子画面包含一个走路的动画。当玩家沿着地板移动时,我们根据当前时间把他围起来。我们希望每 60 毫秒切换一次帧,所以时间先除以 60。当玩家站立不动时,我们画出第九张子画面。当竖直方向的速度不为 0,从而被判断为跳跃时,我们使用第 10 张,也是最右边的子画面。 因为子画面宽度为 24 像素而不是 16 像素,会稍微比玩家的对象宽,这时为了腾出脚和手的空间,该方法需要根据某个给定的值(playerXOverlap)调整x坐标的值以及宽度值。 let playerSprites = document.createElement("img"); playerSprites.src = "https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/player.png"; const playerXOverlap = 4; CanvasDisplay.prototype.drawPlayer = function(player, x, y, width, height){ width += playerXOverlap * 2; x -= playerXOverlap; if (player.speed.x != 0) { this.flipPlayer = player.speed.x < 0; } let tile = 8; if (player.speed.y != 0) { tile = 9; } else if (player.speed.x != 0) { tile = Math.floor(Date.now() / 60) % 8; } this.cx.save(); if (this.flipPlayer) { flipHorizontally(this.cx, x + width / 2); } let tileX = tile * width; this.cx.drawImage(playerSprites, tileX, 0, width, height, x, y, width, height); this.cx.restore(); }; drawPlayer方法由drawActors方法调用,该方法负责画出游戏中的所有角色。 CanvasDisplay.prototype.drawActors = function(actors) { for (let actor of actors) { let width = actor.size.x * scale; let height = actor.size.y * scale; let x = (actor.pos.x - this.viewport.left) * scale; let y = (actor.pos.y - this.viewport.top) * scale; if (actor.type == "player") { this.drawPlayer(actor, x, y, width, height); } else { let tileX = (actor.type == "coin" ? 2 : 1) * scale; this.cx.drawImage(otherSprites, tileX, 0, width, height, x, y, width, height); } } }; 当需要绘制一些非玩家元素时,我们首先检查它的类型,来找到与正确的子画面的偏移值。熔岩瓷砖出现在偏移为 20 的子画面,金币的子画面出现在偏移值为 40 的地方(放大了两倍)。 当计算角色的位置时,我们需要减掉视口的位置,因为(0,0)在我们的画布坐标系中代表着视口层面的左上角,而不是该关卡的左上角。我们也可以使用translate方法,这样可以作用于所有元素。 这个文档将新的显示屏插入runGame中: <body> <script> runGame(GAME_LEVELS, CanvasDisplay); </script> </body> 选择图像接口 所以当你需要在浏览器中绘图时,你都可以选择纯粹的 HTML、SVG 或画布。没有唯一的最适合的且在所有动画中都是最好的方法。每个选择都有它的利与弊。 单纯的 HTML 的优点是简单。它也可以很好地与文字集成使用。SVG 与画布都可以允许你绘制文字,但是它们不会只通过一行代码来帮助你放置text或者包装它,在一个基于 HTML 的图像中,包含文本块更加简单。 SVG 可以被用来制造可以任意缩放而仍然清晰的图像。与 HTML 相反,它实际上是为绘图而设计的,因此更适合于此目的。 SVG 与 HTML 都会构建一个新的数据结构(DOM),它表示你的图片。这使得在绘制元素之后对其进行修改更为可能。如果你需要重复的修改在一张大图片中的一小部分,来对用户的动作进行响应或者作为动画的一部分时,在画布里做这件事情将会极其的昂贵。DOM 也可以允许我们在图片上的每一个元素(甚至在 SVG 画出的图形上)注册鼠标事件的处理器。在画布里则实现不了。 但是画布的基于像素的方法在需要绘制大量的微小元素时会有优势。它不会构建新的数据结构而是仅仅重复的在同一个像素上绘制,这使得画布在每个图形上拥有更低的消耗。 有一些效果,像在逐像素的渲染一个场景(比如,使用光线追踪)或者使用 javaScript 对一张图片进行后加工(虚化或者扭曲),只能通过基于像素的技术来进行真实的处理。在某些情况下,你可能想要将这些技术整合起来使用。比如,你可能用 SVG 或者画布画出一个图形,但是通过将一个 HTML 元素放在图片的顶端来展示像素信息。 对于一些要求低的程序来说,选择哪个接口并没有什么太大的区别。因为不需要绘制文字,处理鼠标交互或者处理大量的元素。我们在本章为游戏构建的显示屏,可以通过使用三种图像技术中的任意一种来实现。 本章小结 在本章中,我们讨论了在浏览器中绘制图形的技术,重点关注了<canvas>元素。 一个canvas节点代表了我们的程序可以绘制在文档中的一片区域。这个绘图动作是通过一个由getContext方法创建的绘图上下文对象完成的。 2D 绘图接口允许我们填充或者拉伸各种各样的图形。这个上下文的fillStyle属性决定了图形的填充方式。strokeStyle和lineWidth属性用来控制线条的绘制方式。 矩形与文字可以通过使用一个简单的方法调用来绘制。采用fillRect和strokeRect方法绘制矩形,同时采用fillText和strokeText方法绘制文字。要创建一个自定义的图形,我们必须首先建立一个路径。 调用beginPath会创建一个新的路径。很多其他的方法可以向当前的路径添加线条和曲线。比如,lineTo方法可以添加一条直线。当一条路径画完时,它可以被fill方法填充或者被stroke方法勾勒轮廓。 从一张图片或者另一个画布上移动像素到我们的画布上可以用drawImage方法实现。默认情况下,这个方法绘制了整个原图像,但是通过给它更多的参数,你可以拷贝一张图片的某一个特定的区域。我们在游戏中使用了这项技术,从包括许多动作的图像中拷贝出游戏角色的单个独立动作。 图形变换允许你向多个方向绘制图片。2D 绘制上下文拥有一个当前的可以通过translate、scale与rotate进行变换。这些会影响所有的后续的绘制操作。一个变换的状态可以通过save方法来保存,通过restore方法来恢复。 在一个画布上展示动画时,clearRect方法可以用来在重绘之前清除画布的某一部分。 习题 形状 编写一个程序,在画布上画出下面的图形。 一个不规则四边形(一个在一边比较长的矩形) 一个红色的钻石(一个矩形旋转45度角) 一个锯齿线 一个由 100 条直线线段构成的螺旋 一个黄色的星星 当绘制最后两个图形时,你可以参考第 14 章中的Math.cos和Math.sin的解释,它描述了如何使用这两个函数获得圆上的坐标。 建议你为每一个图形创建一个方法,传入坐标信息,以及其他的一些参数,比如大小或者点的数量。另一种方法,可以在你的代码中硬编码,会使得你的代码变得难以阅读和修改。 <canvas width="600" height="200"></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); // Your code here. </script> 饼状图 在本章的前部分,我们看到一个绘制饼状图的样例程序。修改这个程序,使得每个部分的名字可以被显示在相应的切片旁边。试着找到一个合适的方法来自动放置这些文字,同时也可以适用于其他数据。你可以假设分类大到足以为标签留出空间。 你可能还会需要Math.sin和Math.cos方法,像第 14 章描述的一样。 <canvas width="600" height="300"></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let total = results .reduce((sum, {count}) => sum + count, 0); let currentAngle = -0.5 * Math.PI; let centerX = 300, centerY = 150; // Add code to draw the slice labels in this loop. results.forEach(function(result) { for (let result of results) { let sliceAngle = (result.count / total) * 2 * Math.PI; cx.arc(centerX, centerY, 100, currentAngle, currentAngle + sliceAngle); currentAngle += sliceAngle; cx.lineTo(centerX, centerY); cx.fillStyle = result.color; cx.fill(); } </script> 弹力球 使用在第 14 章和第 16 章出现的requestAnimationFrame方法画出一个装有弹力球的盒子。这个球匀速运动并且当撞到盒子的边缘的时候反弹。 <canvas width="400" height="400"></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let lastTime = null; function frame(time) { if (lastTime != null) { updateAnimation(Math.min(100, time - lastTime) / 1000); } lastTime = time; requestAnimationFrame(frame); } requestAnimationFrame(frame); function updateAnimation(step) { // Your code here. } </script> 预处理镜像 当进行图形变换时,绘制位图图像会很慢。每个像素的位置和大小都必须进行变换,尽管将来浏览器可能会更加聪明,但这会导致绘制位图所需的时间显着增加。 在一个像我们这样的只绘制一个简单的子画面图像变换的游戏中,这个不是问题。但是如果我们需要绘制成百上千的角色或者爆炸产生的旋转粒子时,这将会成为一个问题。 思考一种方法来允许我们不需要加载更多的图片文件就可以画出一个倒置的角色,并且不需要在每一帧调用drawImage方法。
十五、处理事件 原文:Handling Events 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 部分参考了《JavaScript 编程精解(第 2 版)》 你对你的大脑拥有控制权,而不是外部事件。认识到这一点,你就找到了力量。 马可·奥勒留,《沉思录》 有些程序处理用户的直接输入,比如鼠标和键盘动作。这种输入方式不是组织整齐的数据结构 - 它是一次一个地,实时地出现的,并且期望程序在发生时作出响应。 事件处理器 想象一下,有一个接口,若想知道键盘上是否有一个键是否被按下,唯一的方法是读取那个按键的当前状态。为了能够响应按键动作,你需要不断读取键盘状态,以在按键被释放之前捕捉到按下状态。这种方法在执行时间密集计算时非常危险,因为你可能错过按键事件。 一些原始机器可以像那样处理输入。有一种更进一步的方法,硬件或操作系统发现按键时间并将其放入队列中。程序可以周期性地检查队列,等待新事件并在发现事件时进行响应。 当然,程序必须记得监视队列,并经常做这种事,因为任何时候,按键被按下和程序发现事件之间都会使得软件反应迟钝。该方法被称为轮询。大多数程序员更希望避免这种方法。 一个更好的机制是,系统在发生事件时主动通知我们的代码。浏览器实现了这种特性,支持我们将函数注册为特定事件的处理器。 <p>Click this document to activate the handler.</p> <script> window.addEventListener("click", () => { console.log("You knocked?"); }); </script> window绑定指向浏览器提供的内置对象。 它代表包含文档的浏览器窗口。 调用它的addEventListener方法注册第二个参数,以便在第一个参数描述的事件发生时调用它。 事件与 DOM 节点 每个浏览器事件处理器被注册在上下文中。在为整个窗口注册处理器之前,我们在window对象上调用了addEventListener。 这种方法也可以在 DOM 元素和一些其他类型的对象上找到。 仅当事件发生在其注册对象的上下文中时,才调用事件监听器。 <button>Click me</button> <p>No handler here.</p> <script> let button = document.querySelector("button"); button.addEventListener("click", () => { console.log("Button clicked."); }); </script> 示例代码中将处理器附加到按钮节点上。因此,点击按钮时会触发并执行处理器,而点击文档的其他部分则没有反应。 向节点提供onclick属性也有类似效果。这适用于大多数类型的事件 - 您可以为属性附加处理器,属性名称为前面带有on的事件名称。 但是一个节点只能有一个onclick属性,所以你只能用这种方式为每个节点注册一个处理器。 addEventListener方法允许您添加任意数量的处理器,因此即使元素上已经存在另一个处理器,添加处理器也是安全的。 removeEventListener方法将删除一个处理器,使用类似于addEventListener的参数调用。 <button>Act-once button</button> <script> let button = document.querySelector("button"); function once() { console.log("Done."); button.removeEventListener("click", once); } button.addEventListener("click", once); </script> 赋予removeEventListener的函数必须是赋予addEventListener的完全相同的函数值。 因此,要注销一个处理其,您需要为该函数提供一个名称(在本例中为once),以便能够将相同的函数值传递给这两个方法。 事件对象 虽然目前为止我们忽略了它,事件处理器函数作为对象传递:事件(Event)对象。这个对象持有事件的额外信息。例如,如果我们想知道哪个鼠标按键被按下,我们可以查看事件对象的which属性。 <button>Click me any way you want</button> <script> let button = document.querySelector("button"); button.addEventListener("mousedown", event => { if (event.button == 0) { console.log("Left button"); } else if (event.button == 1) { console.log("Middle button"); } else if (event.button == 2) { console.log("Right button"); } }); </script> 存储在各种类型事件对象中的信息是有差别的。随后本章将会讨论许多类型的事件。对象的type属性一般持有一个字符串,表示事件(例如"click"和"mousedown")。 传播 对于大多数事件类型,在具有子节点的节点上注册的处理器,也将接收发生在子节点中的事件。若点击一个段落中的按钮,段落的事件处理器也会收到点击事件。 但若段落和按钮都有事件处理器,则先执行最特殊的事件处理器(按钮的事件处理器)。也就是说事件向外传播,从触发事件的节点到其父节点,最后直到文档根节点。最后,当某个特定节点上注册的所有事件处理器按其顺序全部执行完毕后,窗口对象的事件处理器才有机会响应事件。 事件处理器任何时候都可以调用事件对象的stopPropagation方法,阻止事件进一步传播。该方法有时很实用,例如,你将一个按钮放在另一个可点击元素中,但你不希望点击该按钮会激活外部元素的点击行为。 下面的示例代码将mousedown处理器注册到按钮和其外部的段落节点上。在按钮上点击鼠标右键,按钮的处理器会调用stopPropagation,调度段落上的事件处理器执行。当点击鼠标其他键时,两个处理器都会执行。 <p>A paragraph with a <button>button</button>.</p> <script> let para = document.querySelector("p"); let button = document.querySelector("button"); para.addEventListener("mousedown", () => { console.log("Handler for paragraph."); }); button.addEventListener("mousedown", event => { console.log("Handler for button."); if (event.button == 2) event.stopPropagation(); }); </script> 大多数事件对象都有target属性,指的是事件来源节点。你可以根据该属性防止无意中处理了传播自其他节点的事件。 我们也可以使用target属性来创建出特定类型事件的处理网络。例如,如果一个节点中包含了很长的按钮列表,比较方便的处理方式是在外部节点上注册一个点击事件处理器,并根据事件的target属性来区分用户按下了哪个按钮,而不是为每个按钮都注册独立的事件处理器。 <button>A</button> <button>B</button> <button>C</button> <script> document.body.addEventListener("click", event => { if (event.target.nodeName == "BUTTON") { console.log("Clicked", event.target.textContent); } }); </script> 默认动作 大多数事件都有与其关联的默认动作。若点击链接,就会跳转到链接目标。若点击向下的箭头,浏览器会向下翻页。若右击鼠标,可以得到一个上下文菜单等。 对于大多数类型的事件,JavaScript 事件处理器会在默认行为发生之前调用。若事件处理器不希望执行默认行为(通常是因为已经处理了该事件),会调用preventDefault事件对象的方法。 你可以实现你自己的键盘快捷键或交互式菜单。你也可以干扰用户期望的行为。例如,这里实现一个无法跳转的链接。 <a href="https://developer.mozilla.org/">MDN</a> <script> let link = document.querySelector("a"); link.addEventListener("click", event => { console.log("Nope."); event.preventDefault(); }); </script> 除非你有非常充足的理由,否则不要这样做。当预期的行为被打破时,使用你的页面的人会感到不快。 在有些浏览器中,你完全无法拦截某些事件。比如在 Chrome 中,关闭键盘快捷键(CTRL-W或COMMAND-W)无法由 JavaScript 处理。 按键事件 当按下键盘上的按键时,浏览器会触发"keydown"事件。当松开按键时,会触发"keyup"事件。 <p>This page turns violet when you hold the V key.</p> <script> window.addEventListener("keydown", event => { if (event.key == "v") { document.body.style.background = "violet"; } }); window.addEventListener("keyup", event => { if (event.key == "v") { document.body.style.background = ""; } }); </script> 尽管从keydown这个事件名上看应该是物理按键按下时触发,但当持续按下某个按键时,会循环触发该事件。有时,你想谨慎对待它。例如,如果您在按下某个按键时向 DOM 添加按钮,并且在释放按键时再次将其删除,则可能会在按住某个按键的时间过长时,意外添加数百个按钮。 该示例查看了事件对象的key属性,来查看事件关于哪个键。 该属性包含一个字符串,对于大多数键,它对应于按下该键时将键入的内容。 对于像Enter这样的特殊键,它包含一个用于命名键的字符串(在本例中为"Enter")。 如果你按住一个键的同时按住Shift键,这也可能影响键的名称 - "v"变为"V","1"可能变成"!",这是按下Shift-1键 在键盘上产生的东西。 诸如shift、ctrl、alt和meta(Mac 上的command)之类的修饰按键会像普通按键一样产生事件。但在查找组合键时,你也可以查看键盘和鼠标事件的shiftKey、ctrlKey、altKey和metaKey属性来判断这些键是否被按下。 <p>Press Ctrl-Space to continue.</p> <script> window.addEventListener("keydown", event => { if (event.key == " " && event.ctrlKey) { console.log("Continuing!"); } }); </script> 按键事件发生的 DOM 节点取决于按下按键时具有焦点的元素。 大多数节点不能拥有焦点,除非你给他们一个tabindex属性,但像链接,按钮和表单字段可以。 我们将在第 18 章中回顾表单字段。 当没有特别的焦点时,document.body充当按键事件的目标节点。 当用户键入文本时,使用按键事件来确定正在键入的内容是有问题的。 某些平台,尤其是 Android 手机上的虚拟键盘,不会触发按键事件。 但即使你有一个老式键盘,某些类型的文本输入也不能直接匹配按键,例如其脚本不适合键盘的人所使用的 IME(“输入法编辑器”)软件 ,其中组合多个热键来创建字符。 要注意什么时候输入了内容,每当用户更改其内容时,可以键入的元素(例如<input>和<textarea>标签)触发"input"事件。为了获得输入的实际内容,最好直接从焦点字段中读取它。 第 18 章将展示如何实现。 指针事件 目前有两种广泛使用的方式,用于指向屏幕上的东西:鼠标(包括类似鼠标的设备,如触摸板和轨迹球)和触摸屏。 它们产生不同类型的事件。 鼠标点击 点击鼠标按键会触发一系列事件。"mousedown"事件和"mouseup"事件类似于"keydown"和"keyup"事件,当鼠标按钮按下或释放时触发。当事件发生时,由鼠标指针下方的 DOM 节点触发事件。 在mouseup事件后,包含鼠标按下与释放的特定节点会触发"click"事件。例如,如果我在一个段落上按下鼠标,移动到另一个段落上释放鼠标,"click"事件会发生在包含这两个段落的元素上。 若两次点击事件触发时机接近,则在第二次点击事件之后,也会触发"dbclick"(双击,double-click)事件。 为了获得鼠标事件触发的精确信息,你可以查看事件中的clientX和clientY属性,包含了事件相对于窗口左上角的坐标(以像素为单位)。或pageX和pageY,它们相对于整个文档的左上角(当窗口被滚动时可能不同)。 下面的代码实现了简单的绘图程序。每次点击文档时,会在鼠标指针下添加一个点。还有一个稍微优化的绘图程序,请参见第 19 章。 <style> body { height: 200px; background: beige; } .dot { height: 8px; width: 8px; border-radius: 4px; /* rounds corners */ background: blue; position: absolute; } </style> <script> window.addEventListener("click", event => { let dot = document.createElement("div"); dot.className = "dot"; dot.style.left = (event.pageX - 4) + "px"; dot.style.top = (event.pageY - 4) + "px"; document.body.appendChild(dot); }); </script> 鼠标移动 每次鼠标移动时都会触发"mousemove"事件。该事件可用于跟踪鼠标位置。当实现某些形式的鼠标拖拽功能时,该事件非常有用。 举一个例子,下面的程序展示一条栏,并设置一个事件处理器,当向左拖动这个栏时,会使其变窄,若向右拖动则变宽。 <p>Drag the bar to change its width:</p> <div style="background: orange; width: 60px; height: 20px"> </div> <script> let lastX; // Tracks the last observed mouse X position let bar = document.querySelector("div"); bar.addEventListener("mousedown", event => { if (event.button == 0) { lastX = event.clientX; window.addEventListener("mousemove", moved); event.preventDefault(); // Prevent selection } }); function moved(event) { if (event.buttons == 0) { window.removeEventListener("mousemove", moved); } else { let dist = event.clientX - lastX; let newWidth = Math.max(10, bar.offsetWidth + dist); bar.style.width = newWidth + "px"; lastX = event.clientX; } } </script> 请注意,mousemove处理器注册在窗口对象上。即使鼠标在改变窗口尺寸时在栏外侧移动,只要按住按钮,我们仍然想要更新其大小。 释放鼠标按键时,我们必须停止调整栏的大小。 为此,我们可以使用buttons属性(注意复数形式),它告诉我们当前按下的按键。 当它为零时,没有按下按键。 当按键被按住时,其值是这些按键的代码总和 - 左键代码为 1,右键为 2,中键为 4。 这样,您可以通过获取buttons的剩余值及其代码,来检查是否按下了给定按键。 请注意,这些代码的顺序与button使用的顺序不同,中键位于右键之前。 如前所述,一致性并不是浏览器编程接口的强项。 触摸事件 我们使用的图形浏览器的风格,是考虑到鼠标界面的情况下而设计的,那个时候触摸屏非常罕见。 为了使网络在早期的触摸屏手机上“工作”,在某种程度上,这些设备的浏览器假装触摸事件是鼠标事件。 如果你点击你的屏幕,你会得到'mousedown','mouseup'和'click'事件。 但是这种错觉不是很健壮。 触摸屏与鼠标的工作方式不同:它没有多个按钮,当手指不在屏幕上时不能跟踪手指(来模拟"mousemove"),并且允许多个手指同时在屏幕上。 鼠标事件只涵盖了简单情况下的触摸交互 - 如果您为按钮添加"click"处理器,触摸用户仍然可以使用它。 但是像上一个示例中的可调整大小的栏在触摸屏上不起作用。 触摸交互触发了特定的事件类型。 当手指开始触摸屏幕时,您会看到'touchstart'事件。 当它在触摸中移动时,触发"touchmove"事件。 最后,当它停止触摸屏幕时,您会看到"touchend"事件。 由于许多触摸屏可以同时检测多个手指,这些事件没有与其关联的一组坐标。 相反,它们的事件对象拥有touches属性,它拥有一个类数组对象,每个对象都有自己的clientX,clientY,pageX和pageY属性。 你可以这样,在每个触摸手指周围显示红色圆圈。 <style> dot { position: absolute; display: block; border: 2px solid red; border-radius: 50px; height: 100px; width: 100px; } </style> <p>Touch this page</p> <script> function update(event) { for (let dot; dot = document.querySelector("dot");) { dot.remove(); } for (let i = 0; i < event.touches.length; i++) { let {pageX, pageY} = event.touches[i]; let dot = document.createElement("dot"); dot.style.left = (pageX - 50) + "px"; dot.style.top = (pageY - 50) + "px"; document.body.appendChild(dot); } } window.addEventListener("touchstart", update); window.addEventListener("touchmove", update); window.addEventListener("touchend", update); </script> 您经常希望在触摸事件处理器中调用preventDefault,来覆盖浏览器的默认行为(可能包括在滑动时滚动页面),并防止触发鼠标事件,您也可能拥有它的处理器。 滚动事件 每当元素滚动时,会触发scroll事件。该事件用处极多,比如知道用户当前查看的元素(禁用用户视线以外的动画,或向邪恶的指挥部发送监视报告),或展示一些滚动的迹象(通过高亮表格的部分内容,或显示页码)。 以下示例在文档上方绘制一个进度条,并在您向下滚动时更新它来填充: <style> #progress { border-bottom: 2px solid blue; width: 0; position: fixed; top: 0; left: 0; } </style> <div id="progress"></div> <script> // Create some content document.body.appendChild(document.createTextNode( "supercalifragilisticexpialidocious ".repeat(1000))); let bar = document.querySelector("#progress"); window.addEventListener("scroll", () => { let max = document.body.scrollHeight - innerHeight; bar.style.width = `${(pageYOffset / max) * 100}%`; }); </script> 将元素的position属性指定为fixed时,其行为和absolute很像,但可以防止在文档滚动时期跟着文档一起滚动。其效果是让我们的进度条呆在最顶上。 改变其宽度来指示当前进度。 在设置宽度时,我们使用%而不是px作为单位,使元素的大小相对于页面宽度。 innerHeight全局绑定是窗口高度,我们必须要减去滚动条的高度。你点击文档底部的时候是无法继续滚动的。对于窗口高度来说,也存在innerWidth。使用pageYOffset(当前滚动位置)除以最大滚动位置,并乘以 100,就可以得到进度条长度。 调用滚动事件的preventDefault无法阻止滚动。实际上,事件处理器是在进行滚动之后才触发的。 焦点事件 当元素获得焦点时,浏览器会触发其上的focus事件。当失去焦点时,元素会获得blur事件。 与前文讨论的事件不同,这两个事件不会传播。子元素获得或失去焦点时,不会激活父元素的处理器。 下面的示例中,文本域在拥有焦点时会显示帮助文本。 <p>Name: <input type="text" data-help="Your full name"></p> <p>Age: <input type="text" data-help="Your age in years"></p> <p id="help"></p> <script> let help = document.querySelector("#help"); let fields = document.querySelectorAll("input"); for (let field of Array.from(fields)) { field.addEventListener("focus", event => { let text = event.target.getAttribute("data-help"); help.textContent = text; }); field.addEventListener("blur", event => { help.textContent = ""; }); } </script> 当用户从浏览器标签或窗口移开时,窗口对象会收到focus事件,当移动到标签或窗口上时,则收到blur事件。 加载事件 当界面结束装载时,会触发窗口对象和文档body对象的"load"事件。该事件通常用于在当整个文档构建完成时,进行初始化。请记住<script>标签的内容是一遇到就执行的。这可能太早了,比如有时脚本需要处理在<script>标签后出现的内容。 诸如image或script这类会装载外部文件的标签都有load事件,指示其引用文件装载完毕。类似于焦点事件,装载事件是不会传播的。 当页面关闭或跳转(比如跳转到一个链接)时,会触发beforeunload事件。该事件用于防止用户突然关闭文档而丢失工作结果。你无法使用preventDefault方法阻止页面卸载。它通过从处理器返回非空值来完成。当你这样做时,浏览器会通过显示一个对话框,询问用户是否关闭页面的对话框中。该机制确保用户可以离开,即使在那些想要留住用户,强制用户看广告的恶意页面上,也是这样。 事件和事件循环 在事件循环的上下文中,如第 11 章中所述,浏览器事件处理器的行为,类似于其他异步通知。 它们是在事件发生时调度的,但在它们有机会运行之前,必须等待其他正在运行的脚本完成。 仅当没有别的事情正在运行时,才能处理事件,这个事实意味着,如果事件循环与其他工作捆绑在一起,任何页面交互(通过事件发生)都将延迟,直到有时间处理它为止。 因此,如果您安排了太多工作,无论是长时间运行的事件处理器还是大量短时间运行的工作,该页面都会变得缓慢且麻烦。 如果您想在背后做一些耗时的事情而不会冻结页面,浏览器会提供一些名为 Web Worker 的东西。 Web Worker 是一个 JavaScript 过程,与主脚本一起在自己的时间线上运行。 想象一下,计算一个数字的平方运算是一个重量级的,长期运行的计算,我们希望在一个单独的线程中执行。 我们可以编写一个名为code/squareworker.js的文件,通过计算平方并发回消息来响应消息: addEventListener("message", event => { postMessage(event.data * event.data); }); 为了避免多线程触及相同数据的问题,Web Worker 不会将其全局作用域或任何其他数据与主脚本的环境共享。 相反,你必须通过来回发送消息与他们沟通。 此代码会生成一个运行该脚本的 Web Worker,向其发送几条消息并输出响应。 let squareWorker = new Worker("code/squareworker.js"); squareWorker.addEventListener("message", event => { console.log("The worker responded:", event.data); }); squareWorker.postMessage(10); squareWorker.postMessage(24); 函数postMessage会发送一条消息,触发接收方的message事件。创建工作单元的脚本通过Worker对象收发消息,而worker则直接向其全局作用域发送消息,或监听其消息。只有可以表示为 JSON 的值可以作为消息发送 - 另一方将接收它们的副本,而不是值本身。 定时器 我们在第 11 章中看到了setTimeout函数。 它会在给定的毫秒数之后,调度另一个函数在稍后调用。 有时读者需要取消调度的函数。可以存储setTimeout的返回值,并将作为参数调用clearTimeout。 let bombTimer = setTimeout(() => { console.log("BOOM!"); }, 500); if (Math.random() < 0.5) { // 50% chance console.log("Defused."); clearTimeout(bombTimer); } 函数cancelAnimationFrame作用与clearTimeout相同,使用requestAnimationFrame的返回值调用该函数,可以取消帧(假定函数还没有被调用)。 还有setInterval和clearInterval这种相似的函数,用于设置计时器,每隔一定毫秒数重复执行一次。 let ticks = 0; let clock = setInterval(() => { console.log("tick", ticks++); if (ticks == 10) { clearInterval(clock); console.log("stop."); } }, 200); 降频 某些类型的事件可能会连续、迅速触发多次(例如mousemove和scroll事件)。处理这类事件时,你必须小心谨慎,防止处理任务耗时过长,否则处理器会占据过多事件,导致用户与文档交互变得非常慢。 若你需要在这类处理器中编写一些重要任务,可以使用setTimeout来确保不会频繁进行这些任务。我们通常称之为“事件降频(Debounce)”。有许多方法可以完成该任务。 在第一个示例中,当用户输入某些字符时,我们想要有所反应,但我们不想在每个按键事件中立即处理该任务。当用户输入过快时,我们希望暂停一下然后进行处理。我们不是立即在事件处理器中执行动作,而是设置一个定时器。我们也会清除上一次的定时器(如果有),因此当两个事件触发间隔过短(比定时器延时短),就会取消上一次事件设置的定时器。 <textarea>Type something here...</textarea> <script> let textarea = document.querySelector("textarea"); let timeout; textarea.addEventListener("input", () => { clearTimeout(timeout); timeout = setTimeout(() => console.log("Typed!"), 500); }); </script> 将undefined传递给clearTimeout或在一个已结束的定时器上调用clearTimeout是没有效果的。因此,我们不需要关心何时调用该方法,只需要每个事件中都这样做即可。 如果我们想要保证每次响应之间至少间隔一段时间,但不希望每次事件发生时都重置定时器,而是在一连串事件连续发生时能够定时触发响应,那么我们可以使用一个略有区别的方法来解决问题。例如,我们想要响应"mousemove"事件来显示当前鼠标坐标,但频率只有 250ms。 <script> let scheduled = null; window.addEventListener("mousemove", event => { if (!scheduled) { setTimeout(() => { document.body.textContent = `Mouse at ${scheduled.pageX}, ${scheduled.pageY}`; scheduled = null; }, 250); } scheduled = event; }); </script> 本章小结 事件处理器可以检测并响应发生在我们的 Web 页面上的事件。addEventListener方法用于注册处理器。 每个事件都有标识事件的类型(keydown、focus等)。大多数方法都会在特定 DOM 元素上调用,接着向其父节点传播,允许每个父元素的处理器都能处理这些事件。 JavaScript 调用事件处理器时,会传递一个包含事件额外信息的事件对象。该对象也有方法支持停止进一步传播(stopPropagation),也支持阻止浏览器执行事件的默认处理器(preventDefault)。 按下键盘按键时会触发keydown和keyup事件。按下鼠标按钮时,会触发mousedown、mouseup和click事件。移动鼠标会触发mousemove事件。触摸屏交互会导致"touchstart","touchmove"和"touchend"事件。 我们可以通过scroll事件监测滚动行为,可以通过focus和blur事件监控焦点改变。当文档完成加载后,会触发窗口的load事件。 习题 气球 编写一个显示气球的页面(使用气球 emoji,\ud83c\udf88)。 当你按下上箭头时,它应该变大(膨胀)10%,而当你按下下箭头时,它应该缩小(放气)10%。 您可以通过在其父元素上设置font-size CSS 属性(style.fontSize)来控制文本大小(emoji 是文本)。 请记住在该值中包含一个单位,例如像素(10px)。 箭头键的键名是"ArrowUp"和"ArrowDown"。确保按键只更改气球,而不滚动页面。 实现了之后,添加一个功能,如果你将气球吹过一定的尺寸,它就会爆炸。 在这种情况下,爆炸意味着将其替换为“爆炸 emoji,\ud83d\udca5”,并且移除事件处理器(以便您不能使爆炸变大变小)。 <p>&#x1f4a5;</p> <script> // Your code here </script> 鼠标轨迹 在 JavaScript 早期,有许多主页都会在页面上使用大量的动画,人们想出了许多该语言的创造性用法。 其中一种是“鼠标踪迹”,也就是一系列的元素,随着你在页面上移动鼠标,它会跟着你的鼠标指针。 在本习题中实现鼠标轨迹的功能。使用绝对定位、固定尺寸的<div>元素,背景为黑色(请参考鼠标点击一节中的示例)。创建一系列此类元素,当鼠标移动时,伴随鼠标指针显示它们。 有许多方案可以实现我们所需的功能。你可以根据你的需要实现简单的或复杂的方法。简单的解决方案是保存固定鼠标的轨迹元素并循环使用它们,每次mousemove事件触发时将下一个元素移动到鼠标当前位置。 <style> .trail { /* className for the trail elements */ position: absolute; height: 6px; width: 6px; border-radius: 3px; background: teal; } body { height: 300px; } </style> <script> // Your code here. </script> 选项卡 选项卡面板广泛用于用户界面。它支持用户通过选择元素上方的很多突出的选项卡来选择一个面板。 本习题中,你必须实现一个简单的选项卡界面。编写asTabs函数,接受一个 DOM 节点并创建选项卡界面来展现该节点的子元素。该函数应该在顶层节点中插入大量<button>元素,与每个子元素一一对应,按钮文本从子节点的data-tabname中获取。除了显示一个初始子节点,其他子节点都应该隐藏(将display样式设置成none),并通过点击按钮来选择当前显示的节点。 当它生效时将其扩展,为当前选中的选项卡,将按钮的样式设为不同的,以便明确选择了哪个选项卡。 <tab-panel> <div data-tabname="one">Tab one</div> <div data-tabname="two">Tab two</div> <div data-tabname="three">Tab three</div> </tab-panel> <script> function asTabs(node) { // Your code here. } asTabs(document.querySelector("tab-panel")); </script>