UCB-STAT187-深度学习笔记-全-
UCB STAT187 深度学习笔记(全)
课程 P1:深度学习课程安排与介绍 📅

在本节课中,我们将学习本深度学习课程的整体安排、目标、评估方式以及核心资源。我们将了解如何通过作业、项目和考试来掌握深度学习,并熟悉课程使用的软件和框架。

课程目标 🎯
上一节我们介绍了课程的基本信息,本节中我们来看看这门课的具体目标。
这门课的目标是介绍深度学习。具体内容包括基础的多层感知器、优化方法、卷积、序列模型(例如LSTM),以及注意力机制等内容。这构成了构建深度网络的基本框架。

同时,课程也确保每个人都理解背后的深度学习理论,至少达到本科级别的深度学习入门课程水平。这意味着我们需要讨论容量控制、权重衰减、丢弃法(Dropout)、批量归一化等内容。此外,课程还会涉及优化、不同类型的模型、过拟合、目标函数等主题。
既然这是一个实践课程,课程还希望确保每个人都能理解如何在实践中执行和应用深度学习。因此,学生将需要在Jupyter Notebooks中用Python编写代码。课程选择的深度学习框架是MXNet。如果学生熟悉PyTorch或TensorFlow等其他框架,这应该不会造成太大困扰。
课程的最终目标是让学生能够解决实际的问题,尽管这些问题不一定是大规模或极其复杂的,但至少是现实且可操作的。项目将帮助学生顺利开始带有辅助功能的研究,并作为进行原创性深度学习研究的起点。

课程结构与资源 📚
了解了课程目标后,我们来看看课程是如何组织的,以及有哪些学习资源。
课程将在周二和周四下午3:30到5:00举行。如果不能现场参加,也可以在YouTube上观看直播。所有的讲义和笔记本都会通过课程网站发布。YouTube频道会更新最新的讲座内容。所有材料也会上传到GitHub仓库:d2l-ai/berkeley-stat-157。
以下是课程的核心资源列表:
- 讲座:由Alex Smola、Mully和讲师本人共同教授。
- 办公时间:星期四下午1点到2点,在Evans Hall 13号。也可以通过电子邮件联系:
berkeley-stat-157@googlegroups.com。 - 助教:Rachel Hu和Ryan Theison。他们的办公时间大概率在星期三下午2点到4点,地点在Evans Hall。
- 讨论平台:课程讨论在
discuss.d2l.ai/c/courses进行,不再使用Piazza。与MXNet或机器学习相关的问题可以在此发布。

作业安排与提交 📝
课程提供了丰富的资源,而作业是确保学习效果的关键环节。

课程共有10次作业,每周发布一次。作业包含一定量的编码任务和一些理论内容,所有内容都是解释型的。作业在发布后一周内提交。
评分时,只计算最好的九次作业成绩。这意味着学生可以因任何原因(如会议、面试或生病)缺席一次作业,而不会影响最终成绩。
所有作业都通过Git提交。操作方式是,在作业截止日期(星期二下午4点)前提交到GitHub仓库。学生应以PDF、LaTeX源文件和Jupyter Notebooks的形式提交作业。助教会通过Git提交注释反馈。
如果学生仍在等待名单上,也应通过Git提交作业,以便有时间戳记录。使用Git是一项非常有用的技能,对未来的职业生涯有帮助。

考试与项目评估 📊
完成作业是日常练习,而考试和项目则是更综合的评估方式。

课程有一场期中考试,日期定在2019年3月19日。考试是开卷形式,但不允许使用计算机、手机、平板等电子设备。学生可以使用任何纸质笔记或打印的幻灯片。考试的目的是确保学生本人参与了课程学习。
课程没有期末考试,因为项目占据了评估的绝大部分比重。
项目的目标是让学生在机器学习领域进行原创性工作。原创性可以体现在应用现有工具解决新问题、处理新数据集,或提出新算法。这是一个“带有辅助轮的研究”项目,旨在帮助学生起步,成为更好的科学家、研究员和工程师。
项目要求以团队形式进行,每个团队由三到五名学生组成。项目将模拟学术研究过程,最终产出包括论文报告和演示文稿。报告需按照NeurIPS会议模板撰写。如果项目完成得出色,学生甚至可以考虑将其提交到学术会议上。
项目时间安排 ⏳

为了确保项目顺利进行,课程设定了明确的时间节点和检查点。
以下是项目的主要时间节点:
- 团队注册截止日期:2月5日。学生需要在此日期前组建团队并提供成员名单和初步标题。
- 项目提案展示:3月5日。团队需要提交一到两页的书面提案,并进行五分钟的全班展示。
- 与助教沟通:在4月22日至25日之间,每个团队必须与助教至少沟通一次,讨论项目进展。
- 最终提交与展示:5月7日和9日。团队需要提交至少六页、最多不超过二十页的最终报告,并准备六到二十张幻灯片进行项目展示。
课程鼓励学生尽早开始规划项目,并与讲师和助教保持沟通,以获得反馈和帮助。
总结 ✨
本节课中,我们一起学习了加州大学伯克利分校STAT 157深度学习课程的整体安排。

我们明确了课程的目标是理论与实践并重,涵盖从多层感知器到注意力机制的核心概念,并使用MXNet框架进行实践。我们介绍了课程的主要资源,包括讲座、办公时间、GitHub仓库和在线讨论区。我们了解了通过每周作业巩固知识、通过开卷期中考试检验理解、以及通过团队研究项目培养原创能力和协作精神的评估体系。最后,我们梳理了项目从组队到最终展示的完整时间线。

总体目标是确保学生能享受学习过程,掌握深度学习的核心技能,并为未来的研究或工程实践打下坚实基础。
课程P10:10. 朴素贝叶斯 🧠
在本节课中,我们将要学习朴素贝叶斯分类器。这是一种基于贝叶斯定理的简单而强大的分类算法,尤其适用于文本分类等任务。我们将了解其核心假设、工作原理、潜在问题以及如何在实际应用中避免数值计算问题。
算法简介与核心假设
上一节我们介绍了本课程的目标。本节中我们来看看朴素贝叶斯算法的基本思想。
朴素贝叶斯的关键假设是:在给定类别(如“垃圾邮件”)的条件下,文档中所有特征(如单词)的出现是相互独立的。这个“朴素”的假设极大地简化了计算。
对于一个包含n个单词的邮件,其属于“垃圾邮件”的概率可以表示为:
P(垃圾邮件 | 单词1, 单词2, ..., 单词n)
根据贝叶斯定理和独立性假设,这个概率正比于:
P(垃圾邮件) * ∏ P(单词i | 垃圾邮件)
其中,∏ 表示连乘。我们通常比较垃圾邮件和正常邮件的这个概率值来做分类,因此可以忽略共同的分母。
训练朴素贝叶斯分类器
理解了核心假设后,本节我们来看看如何训练一个朴素贝叶斯分类器。
训练过程需要计算两个简单的统计量:
- 先验概率 P(垃圾邮件):计算训练数据中垃圾邮件所占的比例。
- 条件概率 P(单词i | 垃圾邮件):对于每个单词,计算它在垃圾邮件中出现的频率。
例如,要计算单词“viagra”在垃圾邮件中的条件概率,只需统计它在所有垃圾邮件中出现的次数,然后除以垃圾邮件中单词的总出现次数(或垃圾邮件的总数,取决于模型定义)。
模型的图形表示与局限性
我们已经了解了如何计算概率。现在,通过图形模型来直观理解这个算法的结构。
朴素贝叶斯可以表示为一个有向图模型:一个父节点代表类别(如“垃圾邮件”),多个子节点代表各个特征(单词),且所有子节点在给定父节点的条件下相互独立。
这种“朴素”的独立性假设也是模型的主要局限。它意味着模型认为“恭喜”和“获奖”这两个词在给定垃圾邮件的条件下出现是无关的,这显然不符合现实。攻击者可以通过在邮件中插入大量无关的随机词汇来故意混淆这种基于独立单词概率的过滤器,历史上这种攻击曾一度成功。

实际问题:零概率与平滑技术

上一节我们讨论了模型的理论局限。本节中我们来看看一个实际应用中会遇到的严重问题。
在训练集中,如果某个特征(单词)在某个类别下从未出现,那么其条件概率 P(单词 | 类别) = 0。这会导致整个概率连乘式为0,模型会做出绝对自信但可能是错误的预测。
以下是解决这个“零概率”问题的常用方法:
- 拉普拉斯平滑 (Laplace Smoothing):在计算每个特征的计数时,为所有可能的特征值都加上一个小的伪计数(例如1)。这确保了没有概率会为零。
数值计算问题与对数技巧
我们解决了概率为零的问题,但在计算许多小概率值的乘积时,又会遇到数值下溢的问题。本节我们来探讨这个挑战及其解决方案。
当计算大量概率(如0.5)的连乘时,结果会迅速变成一个极小的数字(例如2^-100),超出计算机浮点数的精确表示范围,导致数值下溢。
解决方案是使用对数概率。将概率乘法转换为对数概率的加法:
log(P) = log(P(垃圾邮件)) + Σ log(P(单词i | 垃圾邮件))
这样计算更稳定。但在比较不同类别的概率时,我们需要计算 log(e^a + e^b)。直接计算仍可能溢出。技巧是提取最大值:
log(e^a + e^b) = max(a, b) + log(1 + e^(-|a-b|))
这个公式能保证数值稳定性。
低精度计算的意义
最后,我们讨论一下为何要关心这些数值格式。在GPU等硬件上,使用更低精度的浮点数(如16位、8位)可以显著提升计算速度和能效,因为可以在相同的数据带宽内传输和处理更多数据。这使得研究如何在低精度下稳定运行机器学习算法(包括朴素贝叶斯中的概率计算)变得非常重要。
总结

本节课中我们一起学习了朴素贝叶斯分类器。我们了解了其基于特征条件独立性的核心假设,学习了通过计算先验概率和条件概率来训练模型的方法。我们还探讨了模型的主要局限性、零概率问题及其平滑解决方案,并深入研究了在实际实现中避免数值下溢的对数计算技巧。这些知识是理解和使用这一经典分类算法的基础。
课程 P100:RNN 机制详解 🧠
在本节课中,我们将学习循环神经网络(RNN)的核心工作机制,包括输入编码、状态传递、输出解码以及模型评估指标。我们将通过简单的语言和清晰的公式来理解这些概念。
输入编码与嵌入
上一节我们介绍了RNN的基本流程,本节中我们来看看输入是如何被处理的。
首先,我们需要对输入进行编码。这涉及到选择一种粒度,例如单词、字符或子单词。然后,我们将这些单元映射到指示向量,这类似于之前的独热编码。
接着,这个指示向量可能会乘以一个嵌入矩阵,或者直接输入到LSTM、RNN或GRU等模型中。例如,对于输入“时光机器”,每个字符都会进入其对应的标准向量。通过嵌入矩阵,我们会得到一些嵌入向量。
对于单个字符,嵌入可能不是大问题。但对于单词或较长的子字符串序列,嵌入就变得非常重要。例如,“cat”和“cats”是非常相似的单词,我们希望嵌入能确保它们被映射到非常相似的向量表示。
核心公式:嵌入向量 = 独热编码向量 × 嵌入矩阵
RNN的状态与迭代
现在我们已经有了输入向量,接下来看看RNN如何处理序列。
RNN具有状态。我们有一个输入向量序列和一个隐含状态序列。模型会迭代处理序列中的每个元素,并产生一个输出向量序列。
正如我们之前所见,模型会使用当前时间步的输出,并通过最大似然解码将其转换为下一个预测(如下一个字符)。然后,这个预测结果会被再次编码,作为下一个时间步的输入,并继续运行。在这个过程中,会发生几次类型转换。
核心流程:
- 输入
X_t进入RNN单元。 - 结合上一个隐含状态
H_{t-1},计算当前隐含状态H_t。 - 由
H_t产生输出O_t。 - 对
O_t进行解码,得到预测Y_t,并将其作为下一个X_{t+1}。
输出解码的考量
对于输出,我们得到了一个向量。在独热编码的情况下,我们只需选择概率最高的那个维度(argmax)。
但为什么要关心使用解码向量,而不是直接选择概率最高的维度呢?
考虑一个词汇表大小为100万的情况。如果直接解码成一个百万维的向量,会消耗巨大的内存。因此,我们通常不希望这么做。一个更高效的做法是将其解码为一个低维向量(例如1000维或2000维),然后通过与一个参考解码矩阵进行最大化内积运算,来得到最终的预测结果。
核心思想:为了效率和可扩展性,避免处理超高维输出向量。
梯度与训练稳定性
我们已经讨论过梯度问题。在训练RNN时,我们不希望梯度变得太大,否则模型参数会剧烈更新,导致训练过程发散。
为了防止这种情况,我们采用梯度裁剪技术。这基本上是为梯度设置一个上限,确保其范数不会超过某个阈值,从而保持训练的稳定性。
核心代码(概念性描述):
grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)
评估指标:困惑度
你可能还记得之前的作业中提到了困惑度。为什么我们需要定义这个新的评估指标呢?
假设我想构建一个语言模型,并希望同时在推文(约200字符)和整本书(约15-20万字符)上测试它。如果直接看负对数似然,书籍的负对数似然值肯定会大得多,因为它包含的字符数量多得多。即使模型很好,也难以对整本书做出完美预测。
为了使不同长度的序列可比,我们需要对似然进行归一化。具体做法是计算整个序列的负对数似然,然后除以序列长度。这得到了每个字符的平均负对数似然。
核心公式:
平均负对数似然 = (整个序列的负对数似然) / (序列长度 N)
但为什么这仍然可能对长序列有偏呢?因为长序列在开始时获得的信息,可以用来更好地预测后面的内容。例如,如果一本书的主题是“相机”,那么后面再次出现“相机”这个词的概率就会更高。因此,长序列的预测可能本质上更容易一些。
困惑度是这个平均负对数似然的指数形式。

核心公式:
困惑度 = exp(平均负对数似然)

困惑度可以解释为模型在预测下一个词时平均面临的选择数量。如果模型完美(概率为1),困惑度为1(exp(0))。困惑度为3意味着,平均来看,模型在约3个选项中不确定该选哪个。
最佳情况:困惑度越接近1,模型预测越确定、越准确。
总结
本节课中我们一起学习了RNN的核心机制:
- 输入处理:通过嵌入层将离散符号转换为有意义的连续向量。
- 状态迭代:RNN通过隐含状态在时间步之间传递信息。
- 输出解码:高效地将高维输出向量转换为具体的预测结果。
- 训练稳定:使用梯度裁剪防止训练发散。
- 模型评估:使用困惑度作为归一化的指标,公平地比较不同长度序列上的语言模型性能。

理解这些机制是构建和优化循环神经网络模型的基础。

课程 P101:Gluon中的递归神经网络 🧠

在本节课中,我们将学习如何在Gluon框架中实现一个递归神经网络(RNN)。我们将从加载数据开始,逐步构建模型,并最终完成训练和预测的完整流程。整个过程将展示Gluon如何简化RNN的实现。

数据加载与模型定义 📊
上一节我们介绍了课程目标,本节中我们来看看如何准备数据和定义模型。
首先,需要加载数据。

然后,定义模型。这里使用RNN模块,并设置隐藏单元数量为256。

以下是定义RNN层的核心代码:
rnn_layer = rnn.RNN(hidden_size=256)
初始化RNN层的工作仅需几行代码即可完成。
初始化状态与形状检查 🔄
上一节我们定义了模型,本节中我们来看看如何初始化状态并检查输入输出。
假设小批量大小为2,需要初始化一个隐藏状态。状态形状为(batch_size, hidden_size),即(2, 256)。
我们可以执行一步前向传播来验证。输入一个35步的序列,检查输出和状态的形状是否匹配。这与之前的代码逻辑一致,目的是确保数据流正确。
构建RNN块 🧱
上一节我们检查了基础RNN层,本节中我们来看看如何构建一个更完整的RNN块。
RNN层本身只处理输入输出。RNN块则封装了完整的前向传播过程,包括one-hot编码等便捷操作。
以下是构建RNN块的关键步骤:
- 构造函数:创建RNN层和一个用于输出的全连接层(
Dense)。 - 前向传播函数:将输入进行one-hot编码,送入RNN层,然后通过全连接层生成最终输出,并返回输出和状态。
- 初始状态函数:提供一个方法,用于获取RNN的初始隐藏状态。
这个块负责处理数据转换,而调用者仍需准备原始的输入数据。
序列预测 🔮
上一节我们构建了RNN块,本节中我们来看看如何使用它进行序列预测。
预测过程与之前类似,主要区别是现在调用的是封装好的RNN模型。
流程如下:
- 初始化模型和开始状态。
- 对于输入序列中的每个字符,调整其形状,让RNN前进一步。
- 通过
argmax操作将RNN的输出向量转换回字符。
需要注意的是,模型在开始时会将已知的输入字符复制到输出。只有在处理完所有输入字符后,它才会开始生成新的字符。前几个步骤并非浪费,它们的作用是将隐藏状态初始化为一个有意义的数值,这对于后续生成至关重要。
模型训练 🏋️
上一节我们实现了预测,本节中我们来看看如何训练这个RNN模型。
训练过程包含以下步骤:
首先,定义损失函数(如Softmax交叉熵)和优化器。
以下是训练循环中的关键操作:
- 在每个小批量开始时,初始化状态,并对之前的状态调用
.detach()。这可以防止梯度在批次间传播,是处理序列模型的常用技巧。 - 通过
autograd记录计算过程,执行模型的前向传播,得到输出和状态。 - 计算预测输出与真实标签之间的损失。
- 反向传播计算梯度,对梯度进行裁剪以防止爆炸,然后使用优化器更新模型参数。
此外,还需要监控训练过程中的困惑度(Perplexity)以评估模型性能。在作业中,你需要在独立的测试集上测量困惑度。
性能与扩展 ⚡
上一节我们完成了训练,本节中我们来看看一些性能注意事项和扩展话题。
使用Gluon实现的RNN训练速度显著快于手动实现。对于更大的数据集(例如包含500万个字符的莎士比亚全集),这种效率优势更为明显。
关于使用多个GPU进行训练,基本思想与常规模型类似:将批次分配到不同GPU上计算梯度,然后聚合。对于RNN这类序列模型,需要特别注意CPU与GPU之间的负载平衡。最新的深度学习工具包(如用于BERT模型的工具包)提供了复杂的并行训练方案可供参考。
总结 📝
本节课中我们一起学习了在Gluon中实现递归神经网络的全过程。我们从加载数据、定义并初始化RNN模型开始,然后构建了封装好的RNN块以简化操作。接着,我们探讨了如何使用该模型进行序列预测,并详细讲解了包含梯度裁剪和状态分离在内的训练流程。最后,我们讨论了模型训练的性能及其在多GPU环境下的扩展可能性。通过本课,你应能掌握使用Gluon高效构建和训练RNN的基本方法。
P102:L19_5 截断反向传播 🧠

在本节课中,我们将要学习循环神经网络(RNN)训练中的一个关键技术——截断反向传播。我们将了解为什么在RNN中计算梯度会变得复杂和昂贵,以及截断反向传播如何通过一种近似但有效的方法来解决这些问题。


1. RNN的基本结构与梯度问题

上一节我们介绍了RNN的基本概念,本节中我们来看看其梯度计算的具体挑战。
一个基础的RNN单元可以描述如下:
- 隐藏状态更新:
h_t = f(x_{t-1}, h_{t-1}, W) - 观测值输出:
o_t = g(h_t, W)
其中,f 和 g 是参数为 W 的函数。

对于一个序列任务,损失 L 是各个时间步损失 l(o_t, y_t) 的总和:
L = Σ_{t=1}^{T} l(o_t, y_t)


当我们计算损失 L 对参数 W 的梯度时,根据链式法则,会遇到 ∂h_t / ∂W 这一项。问题在于,h_t 依赖于 h_{t-1},而 h_{t-1} 又依赖于 h_{t-2},如此递归下去。这意味着计算 ∂h_t / ∂W 需要回溯整个历史序列:
∂h_t / ∂W = Σ_{i=1}^{t} (Π_{j=i+1}^{t} ∂h_j / ∂h_{j-1}) * ∂f_i / ∂W
这个公式揭示了两个核心问题:
- 计算昂贵:需要计算并存储大量中间状态的梯度。
- 梯度不稳定:公式中包含一连串雅可比矩阵
∂h_j / ∂h_{j-1}的乘积。如果这些矩阵的特征值普遍大于1,乘积会指数级增长,导致梯度爆炸;如果特征值普遍小于1,乘积会指数级衰减至0,导致梯度消失。
2. 截断反向传播的解决方案
面对长序列梯度计算的难题,截断反向传播提供了一种实用的近似方法。

其核心思想是:在反向传播时,人为地切断(或“截断”)超过一定时间步长的依赖关系。具体来说,在计算当前时间步 t 的梯度时,我们只回溯 k 个时间步(例如 k=2),而忽略更早时间步的影响。


以下是截断反向传播带来的好处:
- 显著提升计算效率:减少了需要计算和存储的梯度数量。
- 缓解梯度不稳定问题:避免了长链的矩阵连乘,从而减轻了梯度爆炸或消失的风险。
- 引入隐式正则化:迫使模型更专注于学习短期的、局部的依赖模式,而不是试图拟合可能由噪声或偶然性导致的超长期关联,这有助于提升模型的泛化能力。

注意:截断反向传播只影响梯度的计算方式(即训练过程),模型的前向传播计算保持不变。它通过计算一个近似的(有偏的)梯度来优化参数。



3. 一个线性RNN的示例
为了更具体地理解,让我们看一个没有非线性激活函数的简单线性RNN:
h_t = W_{hx} * x_t + W_{hh} * h_{t-1o_t = W_{oh} * h_t
在这个例子中,∂h_t / ∂h_{t-1} = W_{hh}。因此,∂h_T / ∂h_t 的梯度可以明确地写为 (W_{hh}^T)^{T-t}。这直观地展示了为什么梯度会爆炸(W_{hh} 特征值>1)或消失(W_{hh} 特征值<1)。
截断反向传播在这里的操作就是,在计算 t 时刻的梯度时,我们设定一个截断长度 τ。当需要计算 ∂h_t / ∂h_{t-τ-1} 这类跨越超过 τ 步的梯度时,我们将其视为零,或者更常见的做法是,在计算图中将 h_{t-τ-1} 节点的梯度分离(detach),阻止梯度继续向前传播。


在PyTorch等框架中,这通常通过 .detach() 方法实现。例如,在处理小批量数据时,我们会在每个小批量的起始隐藏状态处调用 .detach(),以切断与前一个小批量的梯度连接。

4. 其他变体与总结

除了固定长度的截断,还有一些研究探索了其他方法,例如随机长度截断。这种方法以一定概率决定是否在某个时间步停止反向传播,并通过调整权重来保证梯度估计的无偏性。虽然理论上更优美,但在实践中其效果通常并不优于简单的固定长度截断,这可能也印证了固定截断所带来的正则化效应是有益的。
本节课中我们一起学习了:
- RNN在长序列上训练时,完整的梯度计算存在计算成本高和梯度不稳定(爆炸/消失)两大问题。
- 截断反向传播是一种高效的近似训练技术,它通过限制梯度回溯的时间步长来解决上述问题。
- 这种方法不仅加快了训练速度、稳定了训练过程,还意外地起到了正则化的作用。
- 在实现上,我们通常在计算图中对较早的隐藏状态使用
.detach()来分离梯度,从而实现截断。


截断反向传播是训练RNN及其变体(如LSTM、GRU)的标准且至关重要的实践,它使得处理长序列数据变得可行。

课程 P103:门控循环单元(GRU)详解 🧠
在本节课中,我们将学习门控循环单元(GRU)。GRU 是一种重要的循环神经网络(RNN)变体,旨在通过引入门控机制,更有效地捕捉序列数据中的长期依赖关系,同时减少计算复杂度。
1. GRU 的设计动机 🎯

上一节我们介绍了基础RNN的局限性。本节中我们来看看GRU的设计初衷。
GRU 出现在长短期记忆网络(LSTM)之后。其核心动机是:在减少计算量的同时,获得与LSTM相近的性能。在序列数据中,并非所有观察值都同等重要。例如,在演讲中,演讲者的动作可能不如演讲内容重要。因此,模型需要两种机制:
- 更新机制:决定何时关注新信息并更新内部状态。
- 重置机制:决定何时忘记过去的无关信息。
通过控制这两个“门”,模型可以更灵活地管理其记忆,例如,通过“忘记门”断开不相关历史信息的梯度传播,从而缓解梯度问题。

2. GRU 的核心结构 ⚙️
理解了动机后,我们来剖析GRU的具体结构。它主要由两个门和一个候选状态构成。
以下是GRU单元在一个时间步 t 的计算流程:

2.1 门控计算
首先,GRU根据当前输入 x_t 和上一个隐藏状态 h_{t-1} 计算两个门:重置门(Reset Gate) r_t 和 更新门(Update Gate) z_t。

# 公式表示
r_t = σ(W_r * [h_{t-1}, x_t] + b_r)
z_t = σ(W_z * [h_{t-1}, x_t] + b_z)
其中,σ 是Sigmoid激活函数,将输出值压缩到 (0, 1) 之间,起到“开关”作用。W_r, W_z, b_r, b_z 是可学习的参数。


2.2 候选隐藏状态
接下来,GRU计算一个候选隐藏状态(Candidate Hidden State) \tilde{h}_t。它融合了当前输入和经过重置门筛选的过去信息。
# 公式表示
\tilde{h}_t = tanh(W * [r_t ⊙ h_{t-1}, x_t] + b)
这里,⊙ 表示逐元素相乘(Hadamard积)。如果重置门 r_t 接近0,则 h_{t-1} 的影响被大幅削弱,候选状态主要依赖于当前输入 x_t,这实现了“重置”或“忘记”过去的效果。
2.3 最终隐藏状态更新
最后,GRU通过更新门 z_t,将上一个隐藏状态 h_{t-1} 和候选状态 \tilde{h}_t 进行组合,得到当前时间步的最终隐藏状态 h_t。
# 公式表示
h_t = (1 - z_t) ⊙ h_{t-1} + z_t ⊙ \tilde{h}_t
更新门 z_t 控制着新旧信息的融合比例:
- 若
z_t接近1,则h_t几乎完全由候选状态\tilde{h}_t更新。 - 若
z_t接近0,则h_t几乎完全保留旧状态h_{t-1}。
3. 门控机制的行为分析 🔍
我们已经了解了GRU的数学公式,现在来分析这些门控如何协作。
以下是不同门控设置下的模型行为:
- 当重置门
r_t ≈ 0,更新门z_t ≈ 1:模型会“重置”或忽略大部分过去状态h_{t-1},主要基于当前输入x_t生成新的隐藏状态。这适用于需要忽略无关历史信息的场景。 - 当重置门
r_t ≈ 1,更新门z_t ≈ 0:模型会“保留”大部分过去状态,几乎不进行更新。这相当于在短时间内维持一个恒定状态,让梯度可以无损地穿越多个时间步,有助于缓解梯度消失。 - 当
r_t ≈ 1,z_t ≈ 1:模型行为接近于一个基础RNN单元,同时考虑当前输入和完整的历史信息进行更新。
关于参数化的讨论:GRU的这种特定设计(使用Sigmoid和凸组合)是经过实践验证的有效方案。理论上可以尝试其他参数化方式(如交换更新公式中的权重),但当前形式在大多数任务中表现稳定。选择tanh作为候选状态的激活函数,是因为它能将值稳定在(-1, 1)之间,防止隐藏状态发散,并保证梯度在原点附近良好流动。



4. GRU 与 LSTM 及代码实现 💻
最后,我们讨论GRU的实践意义并展望其代码实现。
GRU 可以看作是 LSTM 的一个简化版本。它用更少的门控(2个 vs LSTM的3个)和更简单的状态流(只有一个隐藏状态 h_t,而LSTM有细胞状态 C_t 和隐藏状态 h_t),实现了类似的功能。因此,GRU 通常具有更快的训练速度和更少的参数。虽然在许多任务上LSTM的表现略胜一筹,但GRU在计算资源受限或数据量不足时是一个非常有竞争力的选择。
在代码实现层面,现代深度学习框架(如PyTorch, TensorFlow)都提供了封装好的GRU层。其内部计算就是将上述公式串联起来。理解这些底层计算有助于我们更好地调试模型和设计新的结构。
扩展思考:门控机制的思想并不局限于全连接层。在处理图像、图结构等数据时,可以尝试将卷积操作与门控机制结合,这催生了如卷积LSTM/GRU等变体,是值得探索的研究方向。
总结 📝

本节课中我们一起学习了门控循环单元(GRU)。我们从其设计动机出发,理解了引入更新门和重置门的必要性。然后,我们逐步拆解了GRU的三个核心计算步骤:门控计算、候选状态生成和最终状态更新,并用公式和代码描述了其过程。最后,我们分析了GRU的行为模式,并对比了其与LSTM的优劣。GRU通过巧妙的门控设计,在序列建模任务中提供了效率与性能的良好平衡。
课程 P104:门控循环单元在 Python 中的实现 🧠💻

在本节课中,我们将学习如何在 Python 中从零开始实现门控循环单元。我们将逐步讲解数据加载、参数初始化、模型定义以及训练过程,确保你能理解 GRU 的每个核心组件是如何工作的。


1. 数据加载与参数设置 📊
上一节我们介绍了 GRU 的基本概念,本节中我们来看看如何准备数据和设置基础参数。

首先,我们需要加载数据并设置一些基础参数。这个过程与之前循环神经网络的实现类似。

# 设置隐藏单元的数量
num_hiddens = 256
唯一的不同之处在于,我们将隐藏单元的数量设置为 256。这与我们之前实现循环神经网络时的设置完全相同。
2. 参数初始化 🔧
在定义模型之前,我们需要初始化所有可学习的参数。以下是初始化参数的步骤。
我们需要为 GRU 的三个核心门(更新门、重置门、候选隐藏状态)分别初始化权重矩阵和偏置。
def get_params(vocab_size, num_hiddens, device):
# 初始化三种参数:更新门、重置门、候选隐藏状态
num_inputs = num_outputs = vocab_size
def normal(shape):
return torch.randn(size=shape, device=device) * 0.01
def three():
return (normal((num_inputs, num_hiddens)),
normal((num_hiddens, num_hiddens)),
torch.zeros(num_hiddens, device=device))
# 初始化参数
W_xz, W_hz, b_z = three() # 更新门参数
W_xr, W_hr, b_r = three() # 重置门参数
W_xh, W_hh, b_h = three() # 候选隐藏状态参数
# 输出层参数
W_hq = normal((num_hiddens, num_outputs))
b_q = torch.zeros(num_outputs, device=device)
# 附加梯度
params = [W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q]
for param in params:
param.requires_grad_(True)
return params

上述代码初始化了所有必要的权重和偏置,并确保它们可以被优化器更新。接下来,我们将使用这些参数来定义 GRU 的前向传播逻辑。

3. 定义 GRU 模型 🏗️
现在,我们将定义 GRU 单元的前向传播函数。这个函数将输入序列、初始隐藏状态和参数作为输入,并返回最终的隐藏状态和输出。
首先初始化隐藏状态。然后,我们迭代处理输入序列中的每个时间步。

def gru(inputs, state, params):
W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q = params
H, = state
outputs = []
for X in inputs:
# 计算更新门和重置门
Z = torch.sigmoid((X @ W_xz) + (H @ W_hz) + b_z)
R = torch.sigmoid((X @ W_xr) + (H @ W_hr) + b_r)
# 计算候选隐藏状态
H_tilde = torch.tanh((X @ W_xh) + ((R * H) @ W_hh) + b_h)
# 计算新的隐藏状态
H = Z * H + (1 - Z) * H_tilde
# 计算输出
Y = H @ W_hq + b_q
outputs.append(Y)
return torch.cat(outputs, dim=0), (H,)

这个函数逐句实现了 GRU 的数学公式:
- 计算更新门 Z 和重置门 R。
- 结合重置门和当前隐藏状态,计算候选隐藏状态 H̃。
- 使用更新门融合旧隐藏状态和候选状态,得到新隐藏状态 H。
- 根据新隐藏状态计算输出 Y。

4. 训练模型 🚀

模型定义完成后,我们就可以开始训练了。我们需要设置训练参数,并调用训练循环。
以下是训练模型所需的关键参数设置。
num_epochs = 160
num_steps = 35
batch_size = 32
lr = 0.01
grad_clipping = 100


设置好参数后,我们使用与之前相同的训练和预测函数来训练 GRU 模型。训练过程会显示困惑度的下降,表明模型正在学习。
train_ch8(gru, get_params, init_gru_state, num_hiddens,
vocab, device, False, num_epochs, lr, use_random_iter=False)
在训练初期,模型生成的文本可能是无意义的。随着训练的进行,生成文本的质量会逐渐提高。在你的作业中,你将使用莎士比亚的文本进行类似的训练。
5. 扩展讨论与常见问题 ❓
在实现基础 GRU 后,我们来看看一些常见的扩展和问题。
如何构建更深的 GRU 网络?
要增加网络深度,可以将第一层 GRU 的输出作为第二层 GRU 的输入,依此类推。通常,每一层都有自己独立的参数。
词嵌入(Word Embedding)如何选择?
词嵌入是将词语映射为向量的技术。选择好的词嵌入对模型性能至关重要。
- 挑战:一个词可能有多种含义(例如,“bank”可指河岸或银行),仅靠静态向量难以捕捉。
- 解决方案:现代方法(如 Word2Vec, BERT)会根据词语的上下文来学习动态的向量表示,这能更好地处理一词多义。
- 实践:嵌入矩阵通常作为模型的一部分,在大型语料库上进行预训练,然后与下游任务(如 GRU 语言模型)进行联合微调。
使用框架(如 Gluon)实现有何优势?
使用深度学习框架可以大幅减少代码量。例如,在 Gluon 中,你只需定义 rnn.GRU 层并指定隐藏单元数,框架会处理大部分底层细节,使实现更简洁、运行更高效。


总结 📝
本节课中我们一起学习了门控循环单元在 Python 中的完整实现流程。
- 我们首先加载数据并设置了模型参数。
- 接着,我们详细讲解了如何初始化 GRU 的更新门、重置门和候选隐藏状态参数。
- 然后,我们一步步实现了 GRU 的前向传播函数,将数学公式转化为可运行的代码。
- 之后,我们设置了训练循环并观察了模型的训练过程。
- 最后,我们探讨了构建深层 GRU、选择词嵌入以及使用高级框架等扩展话题。

通过本教程,你应该能够理解 GRU 的核心机制,并具备从零开始实现它的能力。
🧠 P105:长短期记忆 (LSTM) 教程

在本节课中,我们将要学习循环神经网络中一个非常重要的变体——长短期记忆网络。我们将了解它的起源、核心设计思想以及其内部的计算机制。
📖 背景故事
长短期记忆网络背后有一个有趣的故事。尤尔根·施密特伯尔是一位值得记住的研究者。故事是这样的,当时有人向尤尔根·施密特伯尔提出了如何让神经网络记住时间序列中信息的问题。最终,这个想法被具体实现出来,成为了长短期记忆网络。实际上,真正完成这项工作的是一位硕士生。这项工作的起点大约在1997到1998年。当时这篇论文看起来有些奇怪,很多人最初并不理解它的意义。
🏗️ LSTM的核心设计
上一节我们介绍了LSTM的背景,本节中我们来看看它的核心设计思想。LSTM的设计是为了解决标准循环神经网络在长序列上难以记住长期依赖关系的问题。
门控机制
LSTM的核心是引入了门控机制。以下是LSTM中关键的三个门:

- 输入门 (I):决定当前输入有多少信息需要存入记忆单元。
- 遗忘门 (F):决定上一时刻的记忆单元有多少信息需要被遗忘。
- 输出门 (O):决定当前记忆单元有多少信息需要输出到隐藏状态。
这些门函数都是sigmoid函数,它们接收当前的输入和上一时刻的隐藏状态,通过线性变换和偏置项来计算。
# 门函数的通用形式(伪代码)
Gate_t = sigmoid(W_g * [h_{t-1}, x_t] + b_g)
记忆单元
接下来,让我们看看候选记忆单元。这是LSTM变得有趣的地方。候选记忆单元 C_tilde 不是隐藏状态,它是通过当前输入和上一时刻隐藏状态的线性变换,再经过tanh激活函数得到的。
# 候选记忆单元
C_tilde_t = tanh(W_c * [h_{t-1}, x_t] + b_c)

有了候选记忆,我们需要决定如何更新旧的记忆。遗忘门帮助我们决定是否保留旧的记忆,输入门决定加入多少新的候选记忆。实际的记忆单元 C_t 的更新公式如下:
# 记忆单元更新
C_t = F_t * C_{t-1} + I_t * C_tilde_t
隐藏状态输出
现在,记忆单元已经更新,但它还没有直接对外产生影响。隐藏状态 h_t 是由输出门 O_t 乘以当前记忆单元 C_t 经过tanh激活后的结果。
# 隐藏状态计算
h_t = O_t * tanh(C_t)
所以,在LSTM中,你需要同时维护两个状态变量:记忆单元 C_t 和隐藏状态 h_t。这比标准RNN更复杂,但也赋予了它更强的记忆能力。
🔍 整体架构回顾
上一节我们分解了LSTM的各个部分,现在我们来整体回顾一下它的完整计算流程。
以下是LSTM在一个时间步内的完整计算展现:
- 计算三个门:输入门
I_t、遗忘门F_t、输出门O_t。 - 计算候选记忆单元
C_tilde_t。 - 更新长期记忆单元:
C_t = F_t * C_{t-1} + I_t * C_tilde_t。 - 计算当前隐藏状态输出:
h_t = O_t * tanh(C_t)。
这个设计看起来比标准RNN和GRU更复杂,像一台精密的机器。但它所做的事情与我们之前讨论的门控循环单元思想相似,只是在如何参数化和控制信息流方面有更大的灵活性。
📝 总结

本节课中我们一起学习了长短期记忆网络。我们了解了它的诞生背景,深入剖析了其核心的门控机制,包括输入门、遗忘门和输出门的作用。我们学习了记忆单元如何通过候选记忆和门控信号进行更新,以及最终如何产生隐藏状态输出。尽管LSTM的结构相对复杂,但它通过精妙的门控设计,有效地解决了长期依赖问题,成为了处理序列数据的强大工具。
课程 P106:LSTM 在 Python 中的实现 🧠💻
在本节课中,我们将学习如何在 Python 中实现长短期记忆网络。我们将从加载数据开始,逐步完成参数初始化、前向传播以及训练过程,并与之前学过的 GRU 进行简单对比。


数据加载
首先,我们需要加载数据。这与之前处理序列数据的方法完全相同。
# 加载数据的代码示例
data = load_your_data()
参数初始化
上一节我们介绍了数据加载,本节中我们来看看如何初始化 LSTM 的参数。
参数初始化方法与之前相同,但 LSTM 拥有更多的权重向量。我们使用正态高斯分布来初始化权重,并将偏置初始化为 0。LSTM 包含三组门参数以及候选单元的参数。

以下是初始化参数的代码框架:
import numpy as np
def init_lstm_params(input_size, hidden_size):
# 初始化输入门、遗忘门、输出门和候选单元的权重与偏置
params = {}
# 示例:输入门权重
params['W_i'] = np.random.randn(hidden_size, input_size + hidden_size) * 0.01
params['b_i'] = np.zeros((hidden_size, 1))
# 类似地初始化其他参数...
return params
初始化完成后,我们将得到附带梯度的参数,以便后续进行反向传播。

状态初始化
接下来我们需要初始化 LSTM 的隐藏状态和记忆单元状态。

在开始时,这两个状态通常被初始化为全零向量。这是因为我们没有任何先前的信息可以依赖。
def init_lstm_state(batch_size, hidden_size):
h = np.zeros((hidden_size, batch_size)) # 隐藏状态
c = np.zeros((hidden_size, batch_size)) # 记忆单元状态
return (h, c)
记忆单元在后台运作,不直接参与输出计算,但需要随网络一起传递。

前向传播

现在,让我们看看 LSTM 前向传播中“魔法”发生的地方。这里我们将参数和状态分解,并应用 LSTM 的数学公式。
以下是 LSTM 单步前向传播的核心计算步骤:
-
计算三个门和候选值:
- 输入门:
i = sigmoid(W_i * [h_prev, x] + b_i) - 遗忘门:
f = sigmoid(W_f * [h_prev, x] + b_f) - 输出门:
o = sigmoid(W_o * [h_prev, x] + b_o) - 候选记忆单元:
c_tilde = tanh(W_c * [h_prev, x] + b_c)
- 输入门:
-
更新记忆单元:
c_next = f * c_prev + i * c_tilde
-
计算当前隐藏状态:
h_next = o * tanh(c_next)

def lstm_step_forward(x, h_prev, c_prev, params):
# 拼接上一时刻隐藏状态和当前输入
concat = np.concatenate((h_prev, x), axis=0)
# 计算各个门和候选值
i = sigmoid(np.dot(params['W_i'], concat) + params['b_i'])
f = sigmoid(np.dot(params['W_f'], concat) + params['b_f'])
o = sigmoid(np.dot(params['W_o'], concat) + params['b_o'])
c_tilde = np.tanh(np.dot(params['W_c'], concat) + params['b_c'])
# 更新记忆单元和隐藏状态
c_next = f * c_prev + i * c_tilde
h_next = o * np.tanh(c_next)
return h_next, c_next
这个过程与我们在理论部分学习的公式完全一致。

训练过程
训练过程与之前循环神经网络的训练完全相同,包括前向传播、损失计算、反向传播和参数更新。步数等超参数设置也保持一致。

由于时间关系,我们在此不运行完整的训练。但理论上,经过足够时间的训练,LSTM 在捕捉长期依赖关系上通常比 GRU 表现稍好。
使用 Gluon 实现 LSTM
最后,我们来看看如何在高级框架(如 MXNet 的 Gluon)中快速实现 LSTM。

在 Gluon 中,实现 LSTM 与实现 GRU 非常相似,主要区别在于调用的模块。
from mxnet.gluon import nn, rnn
# 定义 LSTM 层
lstm_layer = rnn.LSTM(hidden_size=100, num_layers=2)
需要注意的是,LSTM 的计算通常比 GRU 更复杂,因此前向传播速度可能稍慢一些。例如,之前 GRU 的运算时间是 0.3 秒,而 LSTM 可能是 0.48 秒。具体时间取决于硬件和后台任务。

总结
本节课中我们一起学习了长短期记忆网络在 Python 中的实现。我们从数据加载和参数初始化开始,详细讲解了 LSTM 独特的状态(隐藏状态和记忆单元)初始化方法。然后,我们深入探讨了 LSTM 前向传播的核心公式与代码实现,涵盖了输入门、遗忘门、输出门和候选记忆单元的计算。最后,我们简要介绍了训练流程以及如何在 Gluon 框架中便捷地使用 LSTM 层。理解这些基础实现,是后续学习更复杂变体(如双向 LSTM)和词嵌入技术的重要基石。
课程 P11:朴素贝叶斯分类器实践 🧮

在本节课中,我们将学习如何使用朴素贝叶斯算法对经典的MNIST手写数字数据集进行分类。我们将从加载数据开始,计算必要的统计量,然后基于一个“朴素”的独立性假设来构建分类器,并观察其效果。
数据准备与导入

首先,我们需要导入必要的库并加载数据。我们将使用MXNet、DRA以及NumPy。MNIST数据集是一个包含手写数字图像的标准数据集。
import mxnet as mx
import numpy as np
# 假设存在一个标准的图像加载器来加载MNIST数据
# train_images, train_labels, test_images, test_labels = load_mnist_data()
计算统计量
上一节我们导入了必要的工具,本节中我们来看看如何为朴素贝叶斯分类器计算核心的统计量。我们需要计算两类统计量:类别y的先验概率,以及每个像素在给定类别下的条件概率。

以下是计算这些统计量的步骤:
- 初始化统计数组:
y_count用于记录每个数字(0-9)出现的次数,维度为10。x_count用于记录在每个类别下,每个像素点(共28x28=784个)被激活(即显示为黑色)的次数,维度为10 x 784。 - 遍历训练数据:对于每一张训练图像及其对应的标签,我们进行以下操作:
- 在
y_count中,将该标签对应的计数加1。 - 在
x_count中,找到该标签对应的那一行(即一个784维的向量),然后将图像中每个激活的像素位置在该向量中的计数加1。
- 在
- 计算概率:遍历完成后,将
y_count除以总样本数,得到每个类别的先验概率P(y)。将x_count的每一行(对应一个类别)除以该类别的总样本数(即y_count中对应的值),得到在每个类别下,每个像素被激活的条件概率P(x_i | y)。
通过这个过程,我们得到了分类所需的所有概率信息。P(y)代表了每个数字出现的普遍程度,而 P(x_i | y) 则像是一个“平均模板”,展示了数字y通常在每个像素点上的样子。

构建分类器与“朴素”假设

有了统计概率后,我们就可以对新图像进行分类了。分类的核心思想是贝叶斯定理:对于一张新图像X,我们计算它属于每个类别y的后验概率 P(y | X),然后选择概率最大的类别作为预测结果。
根据贝叶斯定理,后验概率计算公式为:
P(y | X) ∝ P(y) * Π P(x_i | y)
请注意公式中的连乘符号Π。这里就引入了“朴素贝叶斯”的核心假设:我们假设图像中每个像素的出现是相互独立的。这个假设显然不符合现实(相邻像素之间高度相关),但它极大地简化了计算,让我们可以将所有像素的条件概率直接相乘来得到联合概率。
实践中的数值问题与解决方案
在代码实践中,直接使用概率相乘会遇到严重的数值问题。因为784个介于0到1之间的概率连续相乘,结果会是一个极其接近0的小数,可能导致计算机浮点数下溢,得到错误的结果。

以下是演示这一问题和解决方案的代码逻辑:
# 错误示范:直接使用概率相乘
# 对于测试图像X(由0和1组成),计算其属于类别y的“得分”
# 对于X中像素值为1的位置,取 P(x_i=1 | y)
# 对于X中像素值为0的位置,取 1 - P(x_i=1 | y)
score_naive = prior_prob[y] * np.prod(conditional_probs)
# 这种方法会导致数值下溢,得到无意义的结果。
# 正确做法:使用对数概率相加
# 利用对数将乘法转换为加法,避免极小数的运算。
log_score = np.log(prior_prob[y]) + np.sum(np.log(conditional_probs))
# 最后,通常比较各个类别的 log_score,最大值对应的类别即为预测结果。

当我们使用对数概率并执行加法运算后,分类器立刻开始正常工作。我们可以观察到,对于测试图像,分类器能为正确的类别分配较高的对数概率得分。

模型评估与总结

运行完整的分类代码后,我们可以在测试集上评估模型的性能。朴素贝叶斯在这个任务上能达到大约85%的准确率,这意味着有大约15%的误差。
这是一个重要的基线模型。它原理简单,实现快速,并且在独立性假设合理或近似满足时效果不错。然而,正如我们将要在后续课程中看到的,更复杂的模型(如深度神经网络)能够显著超越这个基线,因为它们能够建模像素之间复杂的相关关系。

本节课中我们一起学习了:
- 如何为朴素贝叶斯分类器计算先验概率和条件概率。
- 理解并应用了“特征条件独立性”这一核心假设。
- 在实践中使用对数概率来避免数值计算中的下溢问题。
- 在MNIST数据集上实现并评估了一个朴素贝叶斯分类器,建立了性能基线。
感谢你的学习,我们将在接下来的课程中探索更强大的模型。
课程 P12:12. L3_1 抽样 📊

在本节课中,我们将要学习抽样的基本概念与方法。我们将从最简单的均匀分布开始,逐步深入到离散分布和正态分布的抽样,并探讨中心极限定理等核心理论。最后,我们会将理论付诸实践,进行简单的代码实现。
均匀分布抽样 🎲

上一节我们介绍了课程概述,本节中我们来看看最简单的抽样分布——均匀分布。
均匀分布非常简单。在均匀分布中,有一个从下界 L 到上界 U 的区间。在这个区间内,所有值出现的概率相等;超出这个区间,概率为零。典型的均匀随机数生成器就是基于此原理。
如果从中抽取10次、100次,然后重复1000次,结果会开始看起来越来越均匀,但并非完全均匀。如果绘制得足够精细,线条会非常窄,但量化后仍可能看到某些地方略高。这本质上就是从均匀分布中抽样的结果。
离散分布抽样 📝
上一节我们介绍了均匀分布抽样,本节中我们来看看如何从离散分布中抽样。
这是一个非常简单的语言模型,包含六个词:the、and、how、C、is、white、A。每个词都被赋予了一个出现概率,例如某个词的概率是10%,另一个词是5%。我们可以通过线性时间复杂度的暴力方法从这个分布中抽样。
以下是具体步骤:
- 首先假设所有概率之和为1。
- 生成一个介于0和1之间的随机数
r。 - 遍历概率数组,依次从
r中减去每个概率值。 - 当
r减去某个概率后小于零时,就选择对应的那个词。
这个方法的代价很高,因为如果有 n 个结果,平均需要大约 n/2 次减法操作。为了优化,可以将概率从大到小排序,这样能减少平均操作次数。更好的方法是构建一个堆(Heap)数据结构,这实际上实现了二分查找,效率更高。

正态分布与中心极限定理 📈
上一节我们探讨了离散分布的抽样,本节中我们来看看连续分布中最重要的代表——正态分布,以及与之相关的中心极限定理。

正态分布的密度函数为:
p(x) = (1 / (σ * √(2π))) * exp(-(x - μ)² / (2σ²))
其中,μ 是均值,σ² 是方差。
方差的计算公式为:
Var(X) = E[X²] - (E[X])²
这个分解非常有用。例如,在观察一系列样本值 xi 时,我们可以分别计算 x 的滚动平均值和 x² 的滚动平均值,然后利用上述公式计算方差,而无需事先知道均值。
中心极限定理是一个非常有用的性质。假设有一系列独立随机变量 xi,各自具有均值 μi 和方差 σi²。那么,这些随机变量之和的方差等于各个方差之和。如果我们将这些变量重新缩放,使其具有零均值和单位方差,那么在合理的条件下,当变量数量 n 趋于无穷大时,其分布会收敛为标准正态分布(高斯分布)。在实践中,这个收敛速度相当快。
卷积简介 🔄
上一节我们介绍了中心极限定理,本节中我们简要了解一下卷积的概念,因为它与后续的平滑处理等操作相关。
卷积的数学定义如下。对于两个函数 f 和 g,其卷积 (f * g)(z) 定义为:
(f * g)(z) = ∫ f(x) * g(z - x) dx
它计算的是两个函数重叠区域的积分,其中一个函数被翻转并平移。在信号处理或图像处理中,卷积常被用作滤波器。例如,对相邻采样值进行平均就是一种简单的低通滤波卷积操作。

动手实践 💻
上一节我们介绍了一些理论基础,本节中让我们实际尝试编码实现。
以下是使用Python进行简单抽样的示例代码框架:
import random
import numpy as np
import matplotlib.pyplot as plt
# 1. 从均匀分布抽样
uniform_samples = [random.uniform(0, 1) for _ in range(1000)]
plt.hist(uniform_samples, bins=30, edgecolor='black')
plt.title("Uniform Distribution Sampling")
plt.show()
# 2. 从自定义离散分布抽样
words = ['the', 'and', 'how', 'C', 'is', 'white', 'A']
probabilities = [0.1, 0.05, 0.05, 0.1, 0.2, 0.3, 0.2] # 示例概率,总和应为1
def sample_from_discrete(choices, probs):
r = random.random()
cumulative_prob = 0.0
for item, prob in zip(choices, probs):
cumulative_prob += prob
if r < cumulative_prob:
return item
return choices[-1] # 防止浮点数误差
# 抽样多次并统计
samples = [sample_from_discrete(words, probabilities) for _ in range(1000)]
from collections import Counter
print(Counter(samples))
# 3. 从正态分布抽样及中心极限定理演示
normal_samples = np.random.normal(loc=0, scale=1, size=1000)
plt.hist(normal_samples, bins=30, edgecolor='black', density=True)
plt.title("Normal Distribution Sampling")
plt.show()
# 演示中心极限定理:多个均匀分布之和趋近正态分布
num_samples = 10000
num_uniforms = 12 # 使用12个均匀分布变量求和
clt_samples = [sum([random.uniform(0,1) for _ in range(num_uniforms)]) for _ in range(num_samples)]
# 重新缩放,使其均值为0,方差为1
clt_samples = np.array(clt_samples)
clt_samples = (clt_samples - clt_samples.mean()) / clt_samples.std()
plt.hist(clt_samples, bins=30, edgecolor='black', density=True)
plt.title("Central Limit Theorem Demo (Sum of Uniform Variables)")
plt.show()

本节课中我们一起学习了抽样的核心知识。我们从均匀分布的抽样开始,理解了其基本思想。接着,我们探讨了离散分布的抽样方法,包括暴力法和基于堆的优化方法。然后,我们深入研究了正态分布的特性、方差计算以及强大的中心极限定理。最后,我们简要了解了卷积的概念,并通过代码实践巩固了所学内容。掌握这些抽样原理是进行模拟、估计和许多机器学习算法的基础。
课程 P13:13. L3_2 Jupyter中的采样 📊
在本节课中,我们将学习如何在Jupyter Notebook中使用Python进行随机采样。我们将从生成简单的随机数开始,逐步探索均匀分布、分类分布以及正态分布的采样过程,并观察大数定律和中心极限定理的实际效果。

生成随机数
首先,我们来看看如何生成基本的随机数。在Python中,我们可以使用random模块来生成伪随机数。
以下是生成0到1之间随机浮点数的方法:
import random
random_number = random.random()

这个生成器在CPU或GPU上运行高效,虽然不是统计学或密码学上最纯粹的随机源,但速度很快。


生成的结果就是一些0到1之间的数字,没有特别之处。
生成随机整数
上一节我们介绍了生成随机浮点数,本节中我们来看看如何生成随机整数。
以下是生成指定范围内随机整数的方法:
random_integer = random.randint(1, 100)
这段代码会生成一个1到100之间(包含1和100)的随机整数。在实际测试中,你可能会得到重复的数字,例如两次得到1,这是随机过程的正常现象。



可视化均匀分布采样
为了理解随机采样的效果,我们可以通过可视化来观察。我们将生成大量随机整数,并统计每个数字出现的频率。
以下是实现步骤:
- 初始化一个包含100个区间的列表(对应数字0到99),计数初始为0。
- 生成大量(例如100万个)0到99之间的随机整数。
- 每生成一个随机数,就将其对应区间的计数加1。
- 在不同采样次数(如10次、100次、1000次等)时,绘制频率分布图。
由于Python索引从0开始,我们的区间也对应0到99。随着采样次数增加(例如达到百万次),频率分布图会变得相对平滑;而采样次数较少(如10次)时,分布则显得不均匀。




模拟分类分布

接下来,我们模拟一个简单的分类分布,例如模拟一枚有偏硬币的投掷。
我们设定结果为0的概率是0.35,结果为1的概率是0.65。采样逻辑是:生成一个0到1的随机数,如果它小于0.35,则输出0,否则输出1。
为了提高效率,我们避免使用循环,而是利用数组进行批量操作。我们生成一百万个随机数,并一次性判断它们是否大于0.35。

import numpy as np
samples = np.random.rand(1000000)
results = (samples >= 0.35).astype(int)

然后,我们计算结果中0和1的比例。绘制结果后可以看到,比例最初会有波动,但随着样本量增大,最终会稳定在0.35和0.65附近,符合大数定律。


正态分布与中心极限定理
最后,我们探讨正态分布,并观察中心极限定理。
首先,我们可以绘制标准正态分布的概率密度函数图。创建一个从-10到10、间隔为0.01的网格,然后计算每个点对应的函数值并绘图。


现在,我们通过一个例子来观察中心极限定理。我们定义一个随机变量,它有如下分布:
- 以概率0.3取值为0。
- 以概率0.5取值为1。
- 以概率0.2取值为2。
我们进行多次实验,每次实验抽取大量样本(例如10组,每组10000个样本),并计算每组样本的平均值。
以下是计算每组样本均值的核心逻辑:
# 生成随机数数组
temp = np.random.rand(10000, 10)
# 根据阈值分配值:<0.3为0, >=0.3且<0.8为1, >=0.8为2
results = (temp >= 0.3).astype(int) + (temp >= 0.8).astype(int)
# 计算每组(每列)的平均值
means = results.mean(axis=0)

这个随机变量的理论均值可以通过公式计算:
均值 = 0.5 * 1 + 0.2 * 2 = 0.9
我们打印出10次实验得到的样本均值。从图表中可以看到,所有样本均值都汇聚在理论均值0.9附近。同时,图表也显示了均值的波动范围,这有助于理解抽样误差。




总结

本节课中我们一起学习了在Jupyter Notebook中进行随机采样的多种方法。我们从生成基本随机数开始,逐步实现了对均匀分布、分类分布的采样和可视化,并最终通过实例观察了中心极限定理的实际表现,即大量独立随机样本的均值会收敛于理论期望值。
课程 P14:14. 在AWS上设置GPU实例 🚀

在本节课中,我们将学习如何在亚马逊AWS云平台上启动并配置一个带有GPU的实例,用于运行深度学习任务。我们将涵盖从选择实例类型、设置竞价实例、配置存储、安装必要软件到通过SSH连接和端口转发访问Jupyter Notebook的全过程。
概述
我们将逐步完成在AWS上启动一个GPU实例的流程。核心步骤包括:访问AWS控制台、选择正确的深度学习AMI和GPU实例类型、理解并设置竞价实例、配置持久化存储、通过SSH连接到实例、安装CUDA和Conda环境,最后设置端口转发以在本地浏览器中访问云端的Jupyter Notebook。


访问AWS并启动实例
首先,访问AWS官方网站(aws.amazon.com)并登录控制台。

在控制台中,启动一个新实例。为了运行需要GPU的任务,必须选择预装了深度学习框架的基础镜像和包含GPU的实例类型。

- 选择 “深度学习基础Ubuntu版本15.0” 作为亚马逊机器镜像(AMI)。
- 实例类型不要选择T2系列,因为该系列不包含GPU。我们将选择 P2 Xlarge实例,它配备了一块GPU,足以应对大多数计算任务。
理解竞价实例与定价
上一节我们选择了实例类型,本节中我们来看看如何以更经济的价格获取计算资源。
AWS提供“竞价实例”,它们是未被充分利用的闲置计算资源,通过拍卖机制以较低价格提供。其价格远低于“按需实例”。


以下是关于竞价实例的关键点:

- 价格波动:竞价实例的价格会随供需变化。例如,P2 Xlarge实例的按需价格约为每小时0.90美元,而竞价价格可能在0.30至0.37美元之间。
- 可能被中断:如果资源需求增加,出价更高的用户或按需用户可能会占用资源,导致你的实例被终止。但好消息是,被中断前的最后一小时不会计费。
- 出价策略:你可以设置一个愿意支付的最高价格。实际支付的价格通常等于或低于你的出价,类似于“第二价格拍卖”机制。我们出价
$0.50以确保实例稳定。



配置存储与安全

实例的计算能力配置好后,我们需要为其添加存储空间并处理安全设置。
在配置存储步骤中,默认的50GB根卷在实例终止时会丢失。因此,我们需要添加一个独立的持久化存储卷。

以下是存储配置步骤:
- 添加一个新卷,它就像可插拔的USB驱动器,数据会持久保存。
- 可以为此卷创建快照以便备份和重复使用。

在安全组设置中,为了简化教程,我们暂时开放所有端口。在实际生产环境中,应严格限制访问权限。

最后,系统会提示创建或选择密钥对(Key Pair)用于SSH连接。我们创建一个新密钥对(例如 start157.pem)并下载到本地。

提交请求后,竞价实例开始启动。

通过SSH连接到实例
实例启动后,我们需要通过SSH从本地计算机连接到它。
首先,在本地终端中,确保SSH密钥文件权限正确,只有所有者可读。
chmod 400 start157.pem
然后,使用以下命令格式进行连接,将 <PUBLIC_IP> 替换为你的实例公有IP地址。
ssh -i start157.pem ubuntu@<PUBLIC_IP>
首次连接时,系统会询问是否信任该主机,输入 yes 继续。


在实例上安装软件环境
成功连接到实例后,我们需要配置深度学习所需的软件环境,特别是正确版本的CUDA。
预装的AMI可能包含多个CUDA版本。我们需要确保系统使用CUDA 10.0。
以下是安装和配置步骤:
- 检查并切换CUDA版本:根据论坛安装说明,创建符号链接将系统默认CUDA指向所需版本。
sudo ln -s /usr/local/cuda-10.0 /usr/local/cuda - 验证GPU和CUDA:运行
nvidia-smi命令,确认GPU(如K80)被识别,且CUDA版本显示为10.0。 - 创建Conda环境:使用包含CUDA 10.0依赖的
environment.yml文件创建环境。conda env create -f environment.yml source activate gluon
环境安装需要一些时间。在此期间,我们可以进行下一步操作。

设置端口转发以访问Jupyter
为了在本地浏览器中方便地使用实例上运行的Jupyter Notebook,我们需要设置SSH端口转发。
由于本地可能已有服务占用了Jupyter默认的8888端口,我们将其转发到另一个端口(如8890)。
端口转发命令格式如下:
ssh -i start157.pem -L 8890:localhost:8888 ubuntu@<PUBLIC_IP>
此命令将实例上的8888端口映射到本地的8890端口。
连接成功后,在实例上激活Conda环境并启动Jupyter Notebook(需设置无浏览器模式)。
source activate gluon
jupyter notebook --no-browser
启动后,Jupyter会输出一个带有token的URL。将其中的 localhost:8888 改为 localhost:8890,然后在本地浏览器中打开即可访问云端的Notebook。
配置持久化数据盘
考虑到我们使用的是可能被中断的竞价实例,将重要数据存储在独立的持久化卷上至关重要。
之前我们附加了一个卷(如 /dev/xvdb),现在需要对其进行分区、格式化并挂载。
以下是配置持久化数据盘的步骤:
- 查看磁盘:使用
lsblk或sudo fdisk -l确认附加的磁盘(如/dev/xvdb)。 - 创建分区:
sudo fdisk /dev/xvdb # 在交互界面中,依次输入:n (新建), p (主分区), 1 (分区号),其余默认,最后 w (写入) - 格式化分区:
sudo mkfs -t ext4 /dev/xvdb1 - 创建挂载点并挂载:
sudo mkdir /home/ubuntu/payload sudo mount /dev/xvdb1 /home/ubuntu/payload - 设置权限(简化示例,生产环境应更严格):
sudo chmod a+rwx /home/ubuntu/payload
现在,你可以将实验数据和Notebook文件保存在 /home/ubuntu/payload 目录下,即使实例终止,数据也会保留在卷中。

结束使用与成本控制
完成实验后,应妥善关闭资源以避免产生不必要的费用。
以下是收尾步骤:

- 在Jupyter Notebook界面中正常关闭所有笔记本并停止服务器。
- 在SSH终端中,按
Ctrl+C停止Jupyter进程,然后输入exit退出SSH连接。 - 返回AWS控制台,找到正在运行的实例。
- 选择该实例,点击 “操作” -> “实例状态” -> “终止”。

实例终止后,按需实例的计费会停止。竞价实例则根据实际运行小时数和你最终成交的价格计费。记住,你为竞价实例支付的价格不会高于你的出价,且通常是当时的市场成交价。

高级提示:你可以将整个设置过程(包括启动特定类型的竞价实例、附加卷、运行安装脚本等)编写成Shell脚本或使用AWS CloudFormation等工具自动化。这样,每次只需运行一个命令,就能在几分钟内获得一个完全配置好的深度学习环境。

总结

本节课中我们一起学习了在AWS云平台上配置和使用GPU实例的完整流程。我们从登录控制台、选择深度学习AMI和P2 GPU实例开始,理解了竞价实例的经济模型和风险。随后,我们为实例添加了持久化存储,通过SSH密钥安全连接到实例,并在实例上安装了正确的CUDA版本和Conda环境。最后,我们通过SSH端口转发在本地浏览器中访问了云端的Jupyter Notebook,并学会了如何配置独立的数据盘来保存重要工作。记住,使用完毕后及时终止实例是控制成本的关键。通过将这些步骤脚本化,你可以高效地重复这一过程。

课程 P15:15. L3_4 自动微分 🧮

在本节课中,我们将要学习自动微分的基础知识。我们将从标量导数开始,逐步推广到向量和矩阵的导数,并理解梯度、雅可比矩阵等核心概念。这些知识是理解深度学习优化算法(如梯度下降)的基础。
标量导数 📈
上一节我们介绍了课程概述,本节中我们来看看最基础的标量导数。

给定一个标量函数 y = f(x),其中 x 和 y 都是标量,我们计算导数 dy/dx。
以下是基本的导数规则:
- 如果 a 不是 x 的函数,则 da/dx = 0。
- 如果 y = x^m,则 dy/dx = m * x^(m-1)。
- 如果 y = exp(x),则 dy/dx = exp(x)。
- 如果 y = log(x),则 dy/dx = 1/x。
- 如果 y = sin(x),则 dy/dx = cos(x)。
对于复合函数,我们有运算法则:
- 加法法则:如果 y = u + v,则 dy/dx = du/dx + dv/dx。
- 乘法法则:如果 y = u * v,则 dy/dx = du/dx * v + dv/dx * u。
- 链式法则:如果 y 是 u 的函数,而 u 是 x 的函数,则 dy/dx = (dy/du) * (du/dx)。
导数的几何意义是函数在某一点切线的斜率。例如,对于函数 y = x^2,在 x=1 处的导数为 2,这意味着该点切线的斜率为 2。

不可微函数与次导数 ⚠️
上一节我们介绍了可微函数的导数,本节中我们来看看不可微函数的情况。
并非所有函数在所有点都可微。例如,在第一节课中提到的 L1 范数,其简化版本是绝对值函数 y = |x|。在 x=0 这一点,函数不可微。

对于不可微的点,我们可以使用次导数的概念,它是导数概念的推广。次导数是所有可能切线斜率的集合。
以下是两个重要例子:
- 绝对值函数 |x| 的次导数:
- 当 x > 0 时,次导数为 {1}。
- 当 x < 0 时,次导数为 {-1}。
- 当 x = 0 时,次导数为区间 [-1, 1] 内的任意值。实践中,我们常选择 0、1 或 -1。
- 最大值函数 max(x, 0) 的次导数:
- 当 x > 0 时,次导数为 {1}。
- 当 x < 0 时,次导数为 {0}。
- 当 x = 0 时,次导数为区间 [0, 1] 内的任意值。实践中,我们常选择 0 或 1。
从标量到向量:梯度 🧭

上一节我们讨论了标量,本节中我们来看看如何将导数概念推广到向量,这是机器学习和神经网络中的关键。
当输入 x 是向量,输出 y 是标量时,导数推广为梯度。
设 x 是一个 n 维列向量:x = [x1, x2, ..., xn]^T,y 是一个标量。那么 y 关于 x 的梯度是一个行向量:
∇_x y = [∂y/∂x1, ∂y/∂x2, ..., ∂y/∂xn]
例如,若 y = x1^2 + 2*x2^2,则梯度为:
∇_x y = [2x1, 4x2]

梯度指向函数值增加最快的方向。在点 (x1=1, x2=1),梯度为 [2, 4]。如果我们沿着这个方向移动,y 值会增加;反之,沿着反方向移动(梯度下降),y 值会减少。这是优化算法的基础。

以下是向量梯度的一些规则(假设 u 和 v 是 x 的函数,a 是常数):
- ∇_x a = 0 (零行向量)
- ∇_x (a*u) = a * ∇_x u
- ∇_x (sum(x)) = 1^T (全1行向量)
- ∇_x (||x||_2^2) = 2 * x^T
- 加法法则:∇_x (u+v) = ∇_x u + ∇_x v
- 乘法法则:∇_x (u*v) = (∇_x u) * v + u * (∇_x v) (注意维度匹配)

向量对向量:雅可比矩阵 🔢
上一节我们介绍了标量对向量的梯度,本节中我们来看看更一般的情况:向量对向量的导数。

当输入 x 是标量,输出 y 是 m 维向量时,导数是列向量:
∂y/∂x = [∂y1/∂x, ∂y2/∂x, ..., ∂ym/∂x]^T
当输入 x 是 n 维向量,输出 y 是 m 维向量时,导数是一个矩阵,称为雅可比矩阵:
J = ∂y/∂x, 其中第 i 行第 j 列元素为 ∂yi/∂xj。

雅可比矩阵的维度是 m × n。

以下是雅可比矩阵的例子:
- 若 y = a(常向量),则 ∂y/∂x = 0(零矩阵)。
- 若 y = x(恒等映射),则 ∂y/∂x = I(单位矩阵)。
- 若 y = A * x,其中 A 是矩阵,x 是向量,则 ∂y/∂x = A。
- 若 y = A^T * x,则 ∂y/∂x = A^T。
运算法则与之前类似:
- ∂(a*u)/∂x = a * ∂u/∂x (a 为常数标量或矩阵)
- ∂(u+v)/∂x = ∂u/∂x + ∂v/∂x
扩展到矩阵与张量 📊
上一节我们讨论了向量的情况,本节中我们简要看看当输入或输出是矩阵时的形状规律。
当 x 和 y 是更高维的张量(如矩阵)时,导数的计算更复杂,但我们可以关注其形状规律。
设 y 的形状为 S(y),x 的形状为 S(x)。那么导数 ∂y/∂x 的形状通常为:S(y) + reverse(S(x)),其中 + 表示形状的拼接,reverse 表示将形状顺序反转。
例如:
- y 是标量,x 是 n×k 矩阵:导数形状为 k×n(即 x 形状的转置)。
- y 是 m 维向量,x 是 n×k 矩阵:导数形状为 m×k×n(一个三维张量)。
- y 是 m×l 矩阵,x 是 n 维向量:导数形状为 m×l×n。
- y 和 x 都是矩阵:导数是一个四维张量。
理解这些形状规律有助于我们在实现自动微分时管理数据的维度。
总结 🎯
本节课中我们一起学习了自动微分的基础数学概念。
- 我们回顾了标量导数的基本规则和几何意义。
- 我们认识了不可微函数,并学习了次导数这一推广概念。
- 我们将导数推广到向量,引入了梯度的概念,并理解了其指向函数值最快增长方向的重要性。
- 我们学习了雅可比矩阵,用于描述向量对向量的导数。
- 最后,我们了解了当输入输出是矩阵或更高维张量时,导数结果的形状规律。
掌握这些知识是理解后续深度学习模型如何通过梯度下降等算法进行训练的关键。
课程 P16:16. 从零实现线性回归 📈

在本节课中,我们将学习如何从零开始实现线性回归模型。我们将涵盖数据生成、模型定义、损失计算、参数初始化以及使用随机梯度下降(SGD)进行训练的全过程。最后,我们还会介绍如何使用深度学习框架(如Gluon)来简化实现。

概述
线性回归是机器学习中最基础的模型之一。本节课的目标是手动实现一个线性回归模型,以深入理解其核心原理。我们将生成一个合成数据集,定义模型和损失函数,并使用SGD算法优化模型参数。


数据生成

首先,我们需要创建一个用于训练的数据集。我们生成一个包含1000个样本的二维特征矩阵 X,并为其指定真实的权重 w 和偏置 b,最后加入一些高斯噪声来模拟现实数据中的不确定性。

以下是生成数据集的代码:

import numpy as np

# 生成特征矩阵 X (1000个样本,2个特征)
num_samples = 1000
num_features = 2
X = np.random.normal(0, 1, (num_samples, num_features))
# 定义真实的权重 w 和偏置 b
true_w = np.array([2, -3.4])
true_b = 4.2
# 生成标签 y = X * w + b + 噪声
noise = np.random.normal(0, 0.01, num_samples)
y = np.dot(X, true_w) + true_b + noise

生成数据后,我们可以可视化其中一个特征与标签之间的关系,以观察其线性趋势及噪声的影响。
数据读取与批处理


为了使用小批量随机梯度下降,我们需要一个能够按批次读取数据的迭代器。这可以提高训练效率并利用计算资源的并行性。

以下是如何实现一个简单的数据迭代器:
def data_iter(batch_size, features, labels):
num_samples = len(features)
indices = list(range(num_samples))
np.random.shuffle(indices) # 随机打乱索引
for i in range(0, num_samples, batch_size):
batch_indices = np.array(indices[i: min(i + batch_size, num_samples)])
yield features[batch_indices], labels[batch_indices] # 返回一个批次的特征和标签
使用这个迭代器,我们可以方便地在训练循环中获取数据批次。


模型参数初始化
在开始训练之前,我们需要初始化模型参数(权重 w 和偏置 b)。合适的初始化对训练收敛至关重要。
我们使用均值为0、标准差为0.01的正态分布来随机初始化权重,并将偏置初始化为0。
# 初始化权重 w
w = np.random.normal(0, 0.01, (num_features, 1))
# 初始化偏置 b
b = np.zeros(1)
# 为参数附加梯度,以便后续自动求导
w.requires_grad = True
b.requires_grad = True

定义模型

线性回归模型的核心是线性变换:y_hat = X * w + b。我们将这个计算过程封装成一个函数。
def linreg(X, w, b):
"""线性回归模型"""
return np.dot(X, w) + b

定义损失函数

我们使用平方损失函数(L2损失)来衡量模型预测值与真实值之间的差异。损失函数定义为:

公式: loss = (1/2) * (y_hat - y)^2
在代码中,我们需要注意数组形状的匹配,避免因广播机制产生错误。
def squared_loss(y_hat, y):
"""平方损失函数"""
# 确保 y_hat 和 y 形状一致
y_hat = y_hat.reshape(y.shape)
return 0.5 * ((y_hat - y) ** 2)
定义优化算法
我们将使用随机梯度下降(SGD)来更新模型参数。SGD的更新规则如下:

公式: param = param - learning_rate * gradient
以下是SGD优化器的实现:


def sgd(params, lr, batch_size):
"""小批量随机梯度下降"""
for param in params:
param[:] = param - lr * param.grad / batch_size


训练循环
现在,我们将所有部分组合起来,构建完整的训练循环。训练过程包括前向传播、损失计算、反向传播和参数更新。

以下是训练循环的关键步骤:

- 设置超参数(学习率、训练轮数、批次大小)。
- 在每个训练轮次(epoch)中,遍历整个数据集。
- 对每个数据批次,计算预测值和损失。
- 执行反向传播计算梯度。
- 使用SGD更新参数。


# 超参数设置
lr = 0.03 # 学习率
num_epochs = 3 # 训练轮数
batch_size = 10 # 批次大小

# 训练循环
for epoch in range(num_epochs):
for X_batch, y_batch in data_iter(batch_size, X, y):
# 前向传播:计算预测值
y_hat = linreg(X_batch, w, b)
# 计算损失
loss = squared_loss(y_hat, y_batch).sum()
# 反向传播:计算梯度
loss.backward()
# 使用SGD更新参数
sgd([w, b], lr, batch_size)
# 清空梯度,为下一次计算做准备
w.grad.zero_()
b.grad.zero_()
# 每轮结束后,计算并打印整个训练集的平均损失
total_loss = squared_loss(linreg(X, w, b), y)
print(f'Epoch {epoch + 1}, loss: {float(total_loss.mean()):.6f}')

训练结束后,我们可以比较学习到的参数与真实参数的接近程度,以评估模型性能。


使用Gluon框架实现

上一节我们手动实现了线性回归的各个组件。本节中,我们来看看如何使用深度学习框架Gluon来简化这一过程。Gluon提供了高级API,能让我们更高效地构建和训练模型。
Gluon的实现主要涉及三个核心部分:数据加载、模型定义以及训练过程。

数据加载

Gluon提供了便捷的数据加载工具,可以轻松创建数据迭代器。

from mxnet import gluon

# 将数据转换为Gluon数据集
dataset = gluon.data.ArrayDataset(X, y)
# 创建数据加载器,自动进行批处理和打乱
data_iter = gluon.data.DataLoader(dataset, batch_size=batch_size, shuffle=True)
定义模型
在Gluon中,我们可以使用预定义的层来快速构建网络。线性回归对应一个全连接层(Dense Layer)。
from mxnet.gluon import nn

# 定义网络结构:一个输出单元为1的全连接层
net = nn.Sequential()
net.add(nn.Dense(1))

初始化模型参数
Gluon提供了多种参数初始化方法。
from mxnet import init
# 使用正态分布初始化网络参数
net.initialize(init.Normal(sigma=0.01))


定义损失函数和优化器
同样,损失函数和优化器也有预定义的模块。

from mxnet.gluon import loss as gloss
from mxnet import autograd

# 定义平方损失函数
loss = gloss.L2Loss()
# 定义优化器为SGD,并指定学习率
trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr})
训练循环
使用Gluon后,训练循环的代码更加简洁清晰。

for epoch in range(num_epochs):
for X_batch, y_batch in data_iter:
with autograd.record():
l = loss(net(X_batch), y_batch) # 前向传播并计算损失
l.backward() # 反向传播
trainer.step(batch_size) # 更新参数(优化器已处理梯度平均)
# 计算并打印损失
total_loss = loss(net(X), y)
print(f'Epoch {epoch + 1}, loss: {float(total_loss.mean().asscalar()):.6f}')
可以看到,使用框架后,我们无需手动实现梯度计算和参数更新,代码的可靠性和可读性都得到了提升。


总结
本节课中,我们一起学习了线性回归的完整实现流程。
- 我们首先手动实现了数据生成、模型、损失函数和SGD优化器,并完成了训练循环。这个过程帮助我们深入理解了线性回归和梯度下降的核心机制。
- 接着,我们介绍了如何使用Gluon框架来高效地实现相同的功能。通过使用预定义的层、损失函数和优化器,我们能够用更少的代码、更稳定地构建和训练模型。

掌握从零实现和使用框架两种方法,能让我们既理解底层原理,又能熟练运用工具进行高效开发。

课程P17:17. L5_1 作业和项目后勤 📋
在本节课中,我们将要学习关于课程作业提交和项目管理的具体后勤安排。我们将明确项目注册的截止日期、团队组建要求、作业提交的格式规范,并解答一些常见问题。

项目注册与团队组建
上一节我们介绍了课程的整体安排,本节中我们来看看项目注册的具体要求。
首先,项目注册的截止日期是昨天。如果你还没有注册项目,你需要在明天之前完成注册。
如果你还没有找到团队,请按照以下步骤操作:
以下是项目注册的补救措施:
- 给指定邮箱发送邮件。
- 邮件内容需包含你和团队成员的名字、项目标题以及简短的摘要。
- 项目本应在昨天提交,但我们会给予宽限期至明天。
关于团队规模,我们有以下建议:
- 如果你目前是三人团队,可以考虑在课后招募更多队员。
- 如果你已经是四人团队,同样可以接纳希望加入学习的同学。
项目标题目前可以自由拟定,例如 "看油漆干"。但这主要是为了确保你有一个团队。一个月后,将有一次简短的进展展示,届时你需要一个更正式的项目标题。
如果你缺乏项目想法,可以寻求以下帮助:
- 参加助教的办公时间进行讨论。
- 与你的导师或其他同学交流。
- 我们也可以提供帮助,但不会强制要求你采用特定格式。
项目中期要求与时间规划
在接下来的一个月内,你需要完成比确定标题更多的工作。
四周后,你需要提交一份项目计划文档。该文档的要求如下:
- 长度:至少一页,最多两页。
- 内容:描述你计划做什么以及大致的实施思路。
- 目的:这相当于一次项目推介,你需要说明数据来源或项目重要性。
届时还将进行口头展示,具体要求是:
- 时间:每人约四分钟。
- 形式:相当于最多两张幻灯片的内容。

我们强烈建议你尽早开始项目工作。随着学期推进,所有课程的任务量都会增加,提前规划可以减轻期末时的压力。



作业提交格式规范
上一节我们讨论了项目安排,本节中我们来看看作业提交的具体格式要求。
我们注意到大家在提交作业时,对文件的命名方式各不相同。这给助教批改带来了困扰。
请避免出现以下情况:
- 在提交的文件夹中存放多个版本的文件,例如
作业版本1、作业A、作业更新。 - 这迫使助教需要猜测哪一份是你真正打算提交的最终版本。
为了规范提交,第三次作业的指示将更加明确。同时,我们将发布作业1的参考解决方案,该方案综合了提交作业中的优秀部分。
作业转换为PDF的解决方案

关于将Jupyter笔记本转换为PDF提交,我们提供以下方法和注意事项。
一种常见的方法是使用 nbconvert 工具。你可以在命令行中执行类似以下的代码:
jupyter nbconvert --to pdf 你的笔记本.ipynb
你也可以在Stack Overflow等平台搜索“IPython notebook to PDF”来找到更多解决方案。

请注意以下关键点:
- 公式渲染:用于转换的MathJax并非完整的LaTeX,某些复杂的公式环境可能不被支持。
- 内容长度:如果你的作业解答长达16页,说明你可能投入了远超预期的时间。虽然值得称赞,但通常一个问题的解答可以更精炼。
- 提交前检查:务必在提交前打开生成的PDF文件,确保其内容清晰、完整、可读。这是提交任何文件(如会议论文、工作申请)时应养成的好习惯。
- 图表问题:正常情况下,转换工具不应将图表导出为单独的PDF文件。如果遇到此问题,可能是你的转换工具配置有误,需要检查。
如果遇到上述公式不支持的问题,可以考虑:
- 将该特定解答单独以LaTeX格式提交。
- 尝试直接从Jupyter笔记本通过浏览器的“打印”功能生成PDF,有时效果更好。
总结

本节课中我们一起学习了课程项目与作业的后勤管理要点。

我们明确了项目注册的宽限期、团队组建的建议以及项目中期检查的要求。在作业方面,我们强调了规范命名和提交单一最终版本的重要性,并详细介绍了将Jupyter笔记本正确转换为PDF格式的方法与常见问题解决方案。

请务必遵循这些指南,以确保你和助教都能高效地完成课程任务。


课程 P18:最大似然估计与最大后验估计 📊

在本节课中,我们将学习两种重要的参数估计方法:最大似然估计和最大后验估计。我们将从基本原理出发,推导出高斯分布的均值和方差公式,并探讨这两种方法在回归分析中的应用与区别。课程内容力求简单直白,适合初学者理解。
从高斯分布到参数估计
上一节我们提到了逻辑回归,现在让我们回到参数估计的基础概念。为了理解为什么某些常见的估计方法是有效的,我们需要从概率分布的基本原理开始推导。

假设我们有一组数据 X1, X2, ..., Xn,它们是从一个高斯(正态)分布中独立抽取的。这个分布由均值 μ 和方差 σ² 两个参数决定。其概率密度函数为:
公式:
P(X) = 1 / √(2πσ²) * exp( -(X - μ)² / (2σ²) )
我们的目标是,找到最能“解释”当前观测数据的参数 μ 和 σ²。一种直观的思路是:选择能使当前数据出现“可能性”最大的参数。这就是最大似然估计的核心思想。
最大似然估计的推导

在最大似然估计中,我们将参数 μ 和 σ² 视为固定的数值(而非随机变量)。我们定义似然函数为数据在给定参数下的联合概率(由于数据独立同分布,可表示为乘积):
公式:
L(μ, σ²) = ∏_{i=1}^{n} P(Xi; μ, σ²)
直接最大化这个乘积在计算上可能遇到数值过小的问题。一个常见的技巧是取其对数的相反数,即负对数似然,并转而最小化它。这样做不会改变最优解的位置,但能让优化过程更稳定。
公式:
NLL(μ, σ²) = -∑_{i=1}^{n} log P(Xi; μ, σ²)
= (n/2) log(2πσ²) + (1/(2σ²)) ∑_{i=1}^{n} (Xi - μ)²

接下来,我们分别对 μ 和 σ² 求导并令导数为零,即可找到使负对数似然最小的参数值。
-
对
μ求导并优化,我们得到熟悉的样本均值公式:
公式:μ_MLE = (1/n) ∑_{i=1}^{n} Xi -
对
σ²求导并优化,我们得到样本方差公式:
公式:σ²_MLE = (1/n) ∑_{i=1}^{n} (Xi - μ_MLE)²
通过这个推导,我们证明了小学就学到的“求平均”方法,实际上是在高斯分布假设下最优的参数估计方法。
最大似然估计的局限性

最大似然估计虽然直观,但有时会得出反直觉的结论。考虑一个例子:学生没交作业。以下四种原因都能完全“解释”数据(没交作业):
- 狗把作业吃了。
- 被外星人绑架了。
- 祖母生病了。
- 自己太懒了。
从纯最大似然的角度看,这四个原因的似然值可能一样高(都能完美解释观测)。但显然,助教不会首先相信“外星人绑架”这个解释。这是为什么呢?因为最大似然估计忽略了我们对不同原因发生的先验信念。
引入先验:最大后验估计

为了解决上述问题,我们需要将参数 θ(例如,没交作业的原因)本身也视为具有某种概率分布。我们根据经验或常识,为不同的参数赋予一个先验概率 P(θ)。例如:
P(外星人)可能极低,如 0.01%。P(狗吃了)可能为 1%。P(祖母生病)可能为 20%。P(懒惰)可能高达 80%。

然后,我们利用贝叶斯定理,计算在观察到数据 X 后,参数的后验概率:
公式:
P(θ | X) ∝ P(X | θ) * P(θ)
其中,P(X | θ) 是似然,P(θ) 是先验。最大后验估计就是寻找使后验概率 P(θ | X) 最大的参数 θ。
取负对数后,最大后验估计的优化问题变为:
公式:
最小化:-log P(X | θ) - log P(θ)
这相当于在最大似然估计的目标函数上,增加了一个关于参数的惩罚项 -log P(θ)。这个惩罚项会倾向于选择先验概率更高的参数(例如“懒惰”),从而避免选择那些虽然能拟合数据但本身极不合理的参数(例如“外星人”)。

在回归分析中的应用
现在,我们来看最大似然和最大后验估计如何应用于回归问题。一个典型的回归模型假设观测值 yi 由模型预测 f(xi, w) 加上高斯噪声 εi 构成:
公式:
yi = f(xi, w) + εi, 其中 εi ~ N(0, σ²)
- 如果采用最大似然估计,我们的目标是找到参数
w,使所有观测数据y的似然最大。这等价于最小化均方误差:
公式:最小化:(1/n) ∑_{i=1}^{n} (yi - f(xi, w))² - 如果采用最大后验估计,我们通常为参数
w假设一个先验分布,例如高斯先验w ~ N(0, τ²)。此时的目标函数变为:
公式:
这里的最小化:(1/n) ∑_{i=1}^{n} (yi - f(xi, w))² + λ ||w||²λ是一个超参数,控制着正则化的强度。这正是机器学习中常见的L2正则化或岭回归。


通过简单的代数变换(例如对目标函数乘以常数 σ²/n),我们可以发现,最大后验估计中的正则化系数 λ 与噪声方差 σ² 和先验方差 τ² 有关。这揭示了统计视角(贝叶斯先验)与优化视角(正则化惩罚)之间的深刻联系。
总结
本节课我们一起学习了参数估计的两种核心方法:
- 最大似然估计:寻找能使观测数据出现概率最大的参数。它在假设参数为固定值时使用,常等价于最小化误差平方和。
- 最大后验估计:在最大似然的基础上,引入了参数的先验分布。它寻找在观测数据下后验概率最大的参数,其优化目标通常包含一个正则化项,以防止过拟合和选择不合理的参数。

这两种方法为我们理解从简单的平均值计算到复杂的正则化回归模型,提供了统一的理论框架。理解它们有助于我们在面对不同问题时,选择更合适的建模与估计策略。
📘 课程 P19:19. 损失函数详解
在本节课中,我们将学习损失函数的基本概念及其在机器学习中的作用。我们将探讨几种常见的损失函数,包括L2损失、L1损失和Huber损失,并通过直观的图像和公式来理解它们的特点和应用场景。
📊 什么是损失函数?
损失函数用于衡量模型预测值与真实值之间的差异。通过最小化损失函数,我们可以优化模型参数,使其预测更接近真实情况。
以下是一个简单的损失函数示例,它实际上是一个高斯分布。这些图像是直接在蓝色平台上生成的。

蓝色函数表示的是损失函数:
[
L = \frac{1}{2}(y - \hat{y})^2
]
绿色函数是指数化的损失函数:
[
e^{-L}
]
在这种情况下,我们将 (\hat{y}) 设为0。为了简化,我们将绿色函数归一化,使其在区间[-5, 5]内的积分为1。红线是蓝色函数的导数,由自动生成工具绘制。
作为练习,你将使用自动绘图工具来完成这一步。

🔵 L2损失函数
上一节我们介绍了损失函数的基本概念,本节中我们来看看L2损失函数。

L2损失函数的形式为:
[
L = \frac{1}{2}(y - \hat{y})^2
]
如果我们最小化L2损失,得到的结果恰好是均值。图中用红色箭头标出了梯度的大小,如果我们有多个观测值,优化过程会给出它们的均值。
🔶 L1损失函数
接下来,我们探讨L1损失函数。L1损失函数的形式为:
[
L = |y - \hat{y}|
]
我们绘制了蓝色线条表示绝对值函数,然后对其指数化得到绿色线条,同样归一化使其在[-5, 5]区间内积分为1。橙色线条是导数,由自动生成工具绘制。

L1损失函数有一个有趣的特性:它的梯度要么是-1,要么是1。如果我们通过优化找到梯度平衡点,需要左右两边的点数相等,这对应着中位数。如果点的数量是奇数,中位数是中间的点;如果是偶数,中位数是中间两个点之间的任意值。
🟢 Huber损失函数
现在,让我们选择一个稍微复杂一点的损失函数——Huber损失函数。Huber损失函数在分支上看起来像绝对值函数,外侧是直线,内侧是抛物线。它的形式是抛物线在一定范围内被直线延伸。
如果你绘制其导数(橙色曲线),可以清楚地看到交叉点。绿色线条是对应的密度函数。
以下是Huber损失函数的特点:
- Huber损失函数可以确保在存在异常值时,模型仍然保持稳定。
- 它通过修剪最大和最小的项来限制梯度的影响,而不是完全忽略异常值。
- 在深度学习中,这种技术称为梯度裁剪,用于防止优化过程发散。
🛠️ 损失函数的应用
我们刚刚看了不同的损失函数,这是因为在实际问题中,除了最小均方误差损失,我们可能还需要添加其他类型的损失函数来执行不同类型的估计。
我们首先讨论的是回归损失函数,因为它们可以通过图像直观理解。一旦涉及结构化多分类损失等内容,直观理解会变得更加困难。另一个原因是,我们想探讨梯度裁剪与其他方法之间的联系。
梯度裁剪通常通过限制损失向量的大小来防止优化过程发散。在本科生课程中,我们更注重直觉而非严格的数学推导,以便在有限的时间内覆盖更多内容。
📝 总结
本节课中,我们一起学习了损失函数的基本概念及其在机器学习中的应用。我们探讨了L2损失、L1损失和Huber损失函数的特点,并通过图像和公式直观理解了它们的作用。此外,我们还了解了梯度裁剪技术与损失函数之间的联系,以及如何通过损失函数优化模型参数。

希望这节课能帮助你更好地理解损失函数及其在实际问题中的应用!
课程P2:深度学习概览 🧠
在本节课中,我们将对深度学习进行一个非常简要的概述。目的是让你了解深度学习能够完成哪些任务,激发你的学习兴趣,并对这门课程将要探讨的内容有一个初步的认识。
图像分类 📸
深度学习最广为人知的应用之一是图像分类。ImageNet竞赛是展示深度学习在计算机视觉领域能力的关键里程碑。
以下是ImageNet竞赛中分类器错误率的变化趋势:
- 2010年:分类器的准确率参差不齐,错误率从非常糟糕到约20%-25%不等。
- 2012年:首个使用深度学习的团队将错误率降至25%以下,表现显著优于其他团队。
- 2013年及以后:几乎所有团队都突破了25%的错误率门槛,性能提升迅速。到2017年,根据不同的衡量标准,错误率已降至5%到10%之间。
图像分割与标注 🎯
除了识别整张图片的类别,深度学习还能进行更精细的图像分割与标注。
例如,Matterport在GitHub上开源的Mask R-CNN代码,以及在GluonCV等工具包中能找到的类似实现,都使用了卷积神经网络等技术。这些算法不仅能识别物体,还能精确地勾勒出每个物体的轮廓(分割),并为图片中的不同区域生成文字描述(标注)。

风格迁移 🎨
深度学习可以实现图像的风格迁移,即将一张图片的内容与另一张图片的艺术风格相结合。
其核心思想非常直接:我们希望生成一张新图片,使其在深层网络激活值上(内容)与源图片(如维纳斯雕像)相似,同时在浅层统计特征上(纹理、色彩)与目标风格图片相似。
你可以将此功能理解为手机上的高级艺术滤镜。此外,深度学习还能合成以假乱真的人脸,这些完全由算法生成的面孔看起来非常自然。
词向量与类比 🤖
在自然语言处理领域,深度学习可以将单词转换为数学向量(即词嵌入),从而捕捉单词之间的语义关系。
例如,通过词向量空间中的计算,我们可以进行类比推理:
“男人” - “女人” ≈ “国王” - “女王”“西班牙” - “马德里” ≈ “意大利” - “罗马”

基于此,甚至可以尝试进行向量运算,如 “意大利” - “罗马” + “马德里” 的结果会接近 “西班牙”。这表明模型确实学习到了一些有意义的语义知识。如今,几乎所有已部署的机器翻译工具都在某种程度上使用了深度学习技术。

文本生成与视觉问答 📝
深度学习能够根据给定的开头或条件生成连贯的文本。

例如,给定开头“两只狗”,模型可能生成“快乐地玩耍并相爱,还买了一棵树”这样的后续内容。虽然逻辑上可能有些跳跃,但这展示了模型生成创造性文本的能力。
更复杂的任务是视觉问答(VQA),即让AI理解图片并回答相关问题。例如,对于问题“胡须迷宫是什么?”,模型需要:
- 理解这个看似荒谬的问题(通常女性不会长甜菜根状的胡须)。
- 从计算机视觉模块提取图片特征。
- 通过注意力机制关注图片的相关部分(如识别出“香蕉”)。
- 综合信息给出答案。
这是2018年一篇论文中展示的可行技术。
图像描述生成 🖼️➡️📃

深度学习还能为图片自动生成描述性字幕。
例如,给出一张小狗坐在沙滩心形图案里的图片,人类可能会写下“可爱的小狗坐在沙滩上的心形图案里”。深度学习模型,如2016年相关论文所展示的,已经能够自动生成类似“一只狗坐在沙滩上,旁边还有另一只狗”这样准确、自然的描述。
本节课我们一起浏览了深度学习在多个领域的应用概览,包括图像分类、分割、风格迁移、词向量、文本生成、视觉问答和图像描述生成。这只是一个非常简要的介绍,在后续课程中,我们将深入探讨这些激动人心的技术,并学习如何亲手实现它们。希望这个概述能让你对深度学习的力量充满期待。
课程 P20:逻辑回归 🧠
在本节课中,我们将学习逻辑回归。逻辑回归是机器学习中用于解决分类问题的基础模型。我们将探讨它与回归问题的区别,理解其核心的损失函数,并学习如何通过梯度下降进行优化。



回归与分类的区别
上一节我们介绍了回归问题,本节中我们来看看分类问题与回归问题的核心区别。
回归问题旨在预测一个连续值。例如,预测房价、身高体重、每日阳光时长和温度,或是股票市场中多种证券的价值。这些都是典型的回归问题。
分类问题则需要预测一个离散的类别。例如,判断一张图片是猫还是狗,识别手写数字,或是将蛋白质、恶意软件、维基百科编辑命令划分到不同的类别中。
为了让概念更具体,我们来看两者的差异:
- 回归:输出通常有自然的数值尺度,损失函数常基于预测值
Y_hat与真实值Y之间的差异(如平方差)。有时也会关注相对变化,例如使用对数差异。 - 分类:输出是离散的类别。模型需要输出能反映对每个类别置信度的分数。
分类问题的损失函数设计
理解了分类任务的目标后,我们来看看如何为它设计合适的损失函数。以下是几种可能的方法:

1. 使用平方损失(不推荐)
一种简单粗暴的方法是将类别编码为独热向量(例如,[0, 1, 0]),然后使用平方损失进行优化。虽然这种方法理论上可行,并且在某些历史场景中被使用过(如VW工具包),但它通常效果不佳,强烈不建议在实际中使用。
2. 支持向量机(SVM)的思路
支持向量机采用了一种“间隔最大化”的思想。它希望正确类别的输出分数 O_Y 远大于所有错误类别的输出分数 O_i,并且要超过一个设定的间隔 Δ。这个 Δ 可以根据错误的严重程度来调整。例如,在自动驾驶中,偏离到草坪(小损失)和偏离到悬崖(大损失)应有不同的容忍度。这种方法在21世纪初非常流行,涉及复杂的数学优化。
3. 使用校准的损失尺度:Softmax与交叉熵
我们将采用一种更直接且被广泛使用的方法,即使用 Softmax 函数将模型的原始输出转换为概率分布。
假设模型有 n 个输出神经元,对应 n 个类别,原始输出为 O = [o1, o2, ..., on]。Softmax 的计算公式如下:
p_i = exp(o_i) / sum(exp(o_j)) for j=1 to n
这个操作确保所有 p_i 非负,且和为1,形成一个合法的概率分布。

有了预测概率,我们使用 负对数似然损失(Negative Log-Likelihood Loss),对于单个样本,其公式为:
Loss = -log(p_y)
其中 p_y 是模型预测的正确类别 y 的概率。这个公式是机器学习中最重要的公式之一。


更一般地,我们常使用 交叉熵损失(Cross-Entropy Loss)。当真实标签 Y 是独热编码时,交叉熵损失就等价于负对数似然。其一般形式为:
Loss = -sum(Y_i * log(p_i)) for i=1 to n
这里 Y_i 是真实分布(对于独热编码,只有正确类别为1),p_i 是预测概率。

几乎所有深度学习框架都提供了这个损失函数的高效、数值稳定的实现。
损失函数的梯度
现在我们有了损失函数,下一步就是理解如何优化它,这需要计算梯度。
对于使用Softmax和交叉熵损失的逻辑回归,其梯度具有一个优美且直观的形式。经过推导,损失函数 L 关于原始输出 o 的梯度为:
∇_o L = p - Y
其中:
p是模型通过Softmax预测的概率分布向量。Y是真实的标签分布向量(如独热编码)。
这意味着,梯度简单地等于我们“预测的概率”减去“观察到的真实概率”。 这让我们仿佛回到了回归问题的设定:计算估计值与真实值之间的差异。这并非巧合,而是 指数族分布(Exponential Family) 的一个优美数学性质。在指数族中,损失函数的一阶导数给出期望的差异,二阶导数给出方差。
指数族旁注:逻辑回归属于指数族分布。其概率形式可写为
p(x|w) = exp(φ(x)^T w - g(w)),其中g(w)是归一化项(即log-sum-exp)。指数族的性质使得其梯度计算非常简洁。如果这部分显得复杂,可以暂时忽略,它不影响后续核心内容的理解。
总结

本节课中我们一起学习了逻辑回归的核心概念:
- 区分了回归与分类:回归预测连续值,分类预测离散类别。
- 介绍了分类的损失函数:重点讲解了Softmax函数如何将模型输出转化为概率,以及交叉熵损失(或负对数似然损失)如何衡量预测概率与真实分布的差距。
- 推导了梯度公式:得到了一个简洁的梯度表达式
∇L = p - Y,这揭示了优化过程本质是在缩小预测与真实的差距。 - 提到了理论基础:简要说明了逻辑回归属于指数族分布,这解释了其梯度形式优美的数学原因。

逻辑回归是深度学习中分类任务的基础构件,理解其原理对于后续学习更复杂的神经网络模型至关重要。

课程 P21:21. 在 Jupyter 中使用逻辑回归 👨💻

在本节课中,我们将学习如何在 Jupyter Notebook 环境中使用逻辑回归模型。我们将从一个名为“时尚 MNIST”的数据集开始,了解其结构,并演示如何加载和预处理数据,为后续的逻辑回归建模做准备。
数据集介绍:时尚 MNIST 👕
首先,我们将查看一个实际可用的数据集。在本课程中,我们将大量使用“时尚 MNIST”数据集。MNIST 是一个经典的手写数字数据集,包含 60,000 个训练样本和 10 个类别。由于 MNIST 已被研究得非常透彻,其错误率通常可以低于 1%,因此用它进行训练已不再那么具有挑战性。


为了提供一个既易于操作又具有一定难度的替代方案,研究人员创建了“时尚 MNIST”数据集。这个数据集与 MNIST 的规模相同,但内容换成了衣物图片。

下载与加载数据 ⬇️

我们需要先下载这个数据集。代码会从亚马逊 S3 存储服务获取数据。请确保你的网络连接正常。
下载完成后,你会发现训练集包含 60,000 个样本,测试集包含 10,000 个样本,这与原始 MNIST 的规模一致。
熟悉 MXNet 的数据格式很重要。该格式包含特征(features)和标签(labels)。在本数据集中,特征的形状是 Uint8 类型的 NumPy 数组,标签也是一个数组。你可以像操作其他数据集一样对其进行索引。



数据探索与可视化 🔍

数据集中包含的类别有:运动鞋、包、踝靴、凉鞋等。我们可以使用简单的函数来转换标签并可视化图片。
以下是一个示例函数,用于将标签转换为可读文本并显示图片:

def get_fashion_mnist_labels(labels):
text_labels = ['t-shirt', 'trouser', 'pullover', 'dress', 'coat',
'sandal', 'shirt', 'sneaker', 'bag', 'ankle boot']
return [text_labels[int(i)] for i in labels]


def show_images(imgs, num_rows, num_cols, titles=None, scale=1.5):
figsize = (num_cols * scale, num_rows * scale)
_, axes = d2l.plt.subplots(num_rows, num_cols, figsize=figsize)
axes = axes.flatten()
for i, (ax, img) in enumerate(zip(axes, imgs)):
ax.imshow(img.asnumpy())
if titles:
ax.set_title(titles[i])
ax.axes.get_xaxis().set_visible(False)
ax.axes.get_yaxis().set_visible(False)
return axes

运行这些代码,你将看到分辨率较低的衣物图片,例如运动鞋、凉鞋或夹克。


数据转换与加载器 🔄
在加载数据时,我们可以应用预定义的标准转换。例如,将数据转换为 MXNet 的张量格式(NDArray)。这样做的好处是,你可以在数据加载阶段方便地应用各种预处理操作,如裁剪、扭曲、重新标准化、降采样或升采样,而无需在训练循环中额外处理。
数据加载器(DataLoader)会负责这些后台操作。以下是如何使用转换器加载数据的示例:
transformer = gluon.data.vision.transforms.ToTensor()
train_iter = gluon.data.DataLoader(mnist_train.transform_first(transformer),
batch_size, shuffle=True)
test_iter = gluon.data.DataLoader(mnist_test.transform_first(transformer),
batch_size, shuffle=False)

使用这台笔记本电脑,遍历所有数据大约需要三秒半的时间。

本节总结 📝

本节课中,我们一起学习了“时尚 MNIST”数据集的基本情况,包括其背景、规模与结构。我们演示了如何下载、加载该数据集,并介绍了如何使用数据转换器和加载器来预处理数据,为后续应用逻辑回归等机器学习模型做好准备。数据的正确加载与预处理是构建有效模型的第一步。
课程 P22:信息论入门 🧠
在本节课中,我们将学习信息论的基础概念,特别是熵。我们将了解如何量化信息,以及如何用最少的比特数来编码信息。课程将从直观的例子开始,逐步深入到香农熵的公式和编码定理的证明。
概述:什么是信息?

之前我们提到过一些术语,比如交叉熵损失。那么,这些术语到底是什么意思?本节内容是对信息论的一个超轻量级介绍,旨在帮助你理解后续课程中会涉及的其他概念。
香农最初思考的问题是:假设我有一个数据源,它产生从 X1 到 Xn 的观测数据。这个数据源包含了多少信息?我们能否以有意义的方式量化它?
例如,抛一枚公平的硬币,每次结果(正面或反面)带来的“惊讶”程度是固定的。掷一个公平的骰子,有六种可能的结果,显然比抛硬币包含了更多的信息。拍一张白墙的照片与拍一张满是人的讲座厅的照片相比,后者显然包含了更多的信息。

香农提出的一个巧妙方式是:熵是存储这些信息所需的最少比特数。
香农熵的定义 📐
我们如何将上述概念形式化呢?香农给出了一个精妙的定义。

他定义概率分布 P 的熵 H(P) 为:

H(P) = - Σ_j P_j * log₂(P_j)


换句话说,熵是 -log₂(P) 的期望值。有一个著名的编码定理指出,熵是编码所需比特数的下界,并且在某些情况下,这个下界是紧致的。


熵本身是凸函数,因为函数 P * log(P) 是凸的。
熵的计算示例 🧮
为了更好地理解熵,让我们看几个具体的例子。
以下是几个常见概率分布的熵值计算:

-
公平的硬币:概率 P(正面) = 0.5, P(反面) = 0.5。
- 熵 H = -[0.5 * log₂(0.5) + 0.5 * log₂(0.5)] = 1 比特。
- 直观上,编码十次抛硬币的结果正好需要10个比特的序列。
-
有偏的硬币:假设正面朝上的概率 P(正面) = 0.9, 反面 P(反面) = 0.1。
- 熵 H ≈ -[0.9 * log₂(0.9) + 0.1 * log₂(0.1)] ≈ 0.41 比特。
- 这表明,一个结果高度可预测的序列比随机序列更容易压缩存储。
-
20面的骰子:每个面出现的概率均为 1/20。
- 熵 H = -20 * (1/20) * log₂(1/20) = log₂(20) ≈ 4.3 比特。
- 每次掷骰子传递的信息量比抛一次硬币要多。
前缀码与克拉夫特不等式 🔗
为了证明编码定理,我们需要引入前缀码和克拉夫特不等式的概念。
前缀码是一种编码方式,其中任何一个码字都不是另一个码字的前缀。例如,不能将“dog”和“doghouse”同时作为码字,因为“dog”是“doghouse”的前缀。前缀码的好处是易于解码,无需分隔符。
克拉夫特不等式指出:对于一个前缀码,其码字长度集合 {L₁, L₂, ..., L_n} 必须满足以下条件:
Σ_i 2^{-L_i} ≤ 1
反之,如果一组长度满足这个不等式,我们总能构造出一个对应的前缀码。
证明思路(简述):
- 必要性(前缀码 ⇒ 不等式成立):想象随机生成一个二进制字符串。它恰好是某个码字前缀的概率之和必须 ≤ 1(因为事件互斥)。这个概率就是 Σ 2^{-L_i}。
- 充分性(不等式成立 ⇒ 可构造前缀码):我们可以递归地构造码字。从最短的码长开始分配唯一的二进制串,然后将“剩余的概率空间”继续分配给更长的码字。由于不等式保证总“空间”不超过1,这个构造过程总能完成。

编码定理的证明 🛡️
上一节我们介绍了前缀码和克拉夫特不等式,本节我们来看看如何用它们证明核心的编码定理:熵 H(P) 是编码所需平均比特数的下界。
证明分为几个步骤:
-
构造一个可行的前缀码:
- 对于每个事件 x,我们将其码长 L(x) 设为 ⌈-log₂ P(x)⌉,即对 -log₂ P(x) 向上取整。
- 计算所有码字对应的“空间”总和:Σ_x 2^{-L(x)} = Σ_x 2^{-⌈-log₂ P(x)⌉} ≤ Σ_x 2^{log₂ P(x)} = Σ_x P(x) = 1。
- 根据克拉夫特不等式,既然这个总和 ≤ 1,我们总能找到一组具有这些长度的前缀码。这个码的平均长度 E[L] 满足:E[L] = Σ_x P(x) * ⌈-log₂ P(x)⌉ < H(P) + 1。
-
证明下界(最优性):
- 可以证明,任何唯一可解码码的平均长度 E[L] 都满足 E[L] ≥ H(P)。这通常通过引入KL散度(Kullback-Leibler Divergence)来证明,其值非负。
- 因此,熵 H(P) 是最优平均码长的理论下界。
-
逼近下界:
- 我们构造的码长因为向上取整,可能比理论最优值多出最多1比特。为了逼近理论下界,我们可以对符号块(k个符号组成的序列)进行编码。
- 对于k个符号的块,其熵是 k * H(P)。此时,由于取整带来的额外开销平均到每个符号上只有 1/k 比特。当 k 很大时,平均码长可以无限接近熵 H(P)。
总结与展望 🎯
本节课我们一起学习了信息论的核心基础:
- 熵:定义为 H(P) = - Σ P_i log₂(P_i),它量化了信息的不确定性或“惊讶”程度,并代表了存储信息所需的最小平均比特数。
- 前缀码与克拉夫特不等式:前缀码是一种实用的无歧义编码方式,克拉夫特不等式 Σ 2^{-L_i} ≤ 1 是判断一组码长能否构成前缀码的充要条件。
- 香农编码定理:我们证明了熵是最优编码长度的下界,并且可以通过对符号块编码来无限逼近这个下界。
你可能会问,既然理论最优编码存在,为什么现实中不直接使用?原因是,达到理论极限需要编码非常长的符号序列,这会导致编码和解码过程计算复杂度过高。因此,在实际应用中(如通信领域的Turbo码、LDPC码),我们使用更巧妙的、接近最优且可实现的编码方案。这些内容会在更高级的信息论课程中深入探讨。

作业将在今晚发布,并包含两周前的习题解答。祝你好运,我们周四再见!

课程 P23:信息论回顾 📚
在本节课中,我们将回顾信息论中的核心概念,包括熵、前缀码、克拉夫不等式以及KL散度。我们将了解这些概念如何与编码理论及机器学习中的交叉熵损失联系起来。

熵与信息量 🔢

上一节我们介绍了课程背景,本节中我们来看看信息论的基础——熵。

熵是信息量的度量标准,它表示存储或传输数据所需符号的平均数量。熵的公式定义为:
H(p) = - Σ p(x) log₂ p(x)

其中,p(x)是事件x发生的概率。熵是凹函数,因为 -p log p 是凸函数。这意味着两个分布的混合熵总是大于或等于各自熵的混合。换句话说,混合数据源只会增加不确定性。
前缀码与克拉夫不等式 🔗
理解了熵的概念后,我们来看看如何有效地编码信息,这引出了前缀码的概念。
前缀码是一种编码方式,其中没有任何一个码字是另一个码字的前缀。这使得解码过程可以在读取到完整码字时立即停止,而无需等待后续字符。
以下是前缀码的一个例子:
- A 映射到
0 - B 映射到
10 - C 映射到
110 - D 映射到
111

克拉夫不等式
克拉夫不等式为前缀码的存在性提供了充要条件。对于一个码字长度集合 {l₁, l₂, ..., lₙ},存在对应前缀码的充要条件是:
Σ 2^(-lᵢ) ≤ 1
这个不等式有两个方向:
- 正向:任何前缀码都满足此不等式。
- 反向:任何满足此不等式的长度集合,都可以构造出一个前缀码。

反向的构造性证明思路如下:假设我们有一个满足不等式的长度集合(例如 {1, 2, 3, 5})。我们可以通过迭代地分配最短的可用码字(如 0),然后将其前缀(如 1)保留给剩余的长度(这些长度在后续步骤中会减1),从而系统地构建出一个前缀码。
KL散度:衡量分布间的“距离” 📏
上一节我们讨论了如何构造编码,本节中我们来看看如果使用了“错误”的编码会怎样,这引出了KL散度的概念。
KL散度用于衡量两个概率分布 p 和 q 之间的差异。其直观解释是:当我们使用基于分布 q 的最优编码来编码来自真实分布 p 的数据时,所额外付出的平均比特数。
KL散度的公式为:
D_KL(p || q) = Σ p(x) log (p(x) / q(x))
KL散度具有以下重要性质:
- 非负性:
D_KL(p || q) ≥ 0。 - 非对称性:
D_KL(p || q) ≠ D_KL(q || p),因此它不是严格意义上的距离度量。 - 同一性:当且仅当
p = q时,D_KL(p || q) = 0。
非负性的证明利用了 -log 是凸函数这一性质,通过詹森不等式即可推导得出。
从KL散度到交叉熵损失 🎯
理解了KL散度后,我们就能理解它在机器学习,特别是分类任务中的核心应用。
在分类问题中,真实标签 y 可以看作一个概率分布(如one-hot向量),模型预测的 softmax(o) 是另一个分布。我们希望这两个分布尽可能接近,即最小化它们之间的KL散度:

D_KL(y || softmax(o)) = Σ y_i log y_i - Σ y_i log softmax(o_i)
由于第一项 Σ y_i log y_i(真实分布的熵)是常数,最小化KL散度就等价于最小化第二项的负值,即:
Loss = - Σ y_i log softmax(o_i)
这正是我们熟悉的交叉熵损失。因此,最小化交叉熵损失的本质就是最小化模型预测分布与真实分布之间的KL散度。

总结与延伸阅读 📖
本节课中我们一起学习了信息论的核心概念。我们从熵的定义出发,探讨了前缀码和克拉夫不等式,它们保证了高效编码的存在性。接着,我们引入了KL散度来衡量分布间的差异,并揭示了其与交叉熵损失的深刻联系。
信息论是一个博大精深的领域。若希望进一步探索,可以参考以下资源:
- 《信息论基础》(Thomas Cover 著):经典教材,内容全面。
- David MacKay 的《信息论、推理与学习算法》:将信息论与机器学习紧密结合的优秀著作。
- Martin Wainwright 和 Michael Jordan 关于指数族和统计推断的研究工作。

希望本教程能帮助你清晰地理解这些基础但至关重要的概念。
课程 P24:24. Python中的Softmax分类 🧮

在本节课中,我们将学习如何使用Python实现Softmax回归,并将其应用于Fashion-MNIST数据集进行分类任务。我们将从零开始构建模型,理解Softmax函数和交叉熵损失,并训练一个简单的线性分类器。

概述 📋
上一节我们讨论了数据加载和预处理。本节中,我们将重点介绍Softmax回归模型的原理与实现。我们将学习如何将线性模型的原始输出转换为概率分布,并定义一个可优化的损失函数。

数据准备与回顾

Fashion-MNIST数据集包含60,000张训练图像和10,000张测试图像,每张图像是28x28像素的灰度图。与手写数字MNIST相比,它对分类算法提出了稍高的挑战。
以下是加载和查看数据的一些便捷例程:

# 示例代码:加载Fashion-MNIST数据集
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size=256)

这些例程可以获取标签文本并用于绘图,帮助我们直观理解数据内容。

Softmax函数原理

Softmax函数是“最大值函数”的一个“软化”版本。它的目标是将一组实数(模型的原始输出)转换为一个概率分布。
给定一个向量 o,Softmax计算第 i 个类别的概率为:
这样,所有输出值都在0到1之间,且总和为1,可以解释为属于各个类别的概率。
如果我们对正确类别的预测概率取负对数,就得到了交叉熵损失:
由于 y 是独热编码(正确类别为1,其余为0),上式简化为正确类别预测概率的负对数:\(-\log \hat{y}_{y}\)。
从零实现Softmax回归

接下来,我们看看如何从零开始实现一个Softmax回归模型。
1. 初始化模型参数

我们的输入是28x28=784维的向量,输出是10个类别。因此,我们需要一个784x10的权重矩阵 W 和一个10维的偏置向量 b。
import torch
from torch import nn

num_inputs = 784
num_outputs = 10

W = torch.normal(0, 0.01, size=(num_inputs, num_outputs), requires_grad=True)
b = torch.zeros(num_outputs, requires_grad=True)
requires_grad=True 会告知PyTorch需要为这些参数计算梯度,以便后续进行优化。
2. 实现Softmax运算

以下是Softmax函数的一个基础实现(注意:此实现数值上不稳定,仅用于演示):

def softmax(X):
X_exp = torch.exp(X)
partition = X_exp.sum(1, keepdim=True)
return X_exp / partition # 这里应用了广播机制
重要提示:在实际应用中,应使用框架内置的、经过数值稳定性优化的Softmax函数。

3. 定义模型
Softmax回归模型就是一个简单的线性变换后接Softmax函数。
def net(X):
# X的形状应为 (批量大小, 784)
# 首先将图像展平
return softmax(torch.matmul(X.reshape((-1, W.shape[0])), W) + b)
4. 定义损失函数
我们实现交叉熵损失函数。由于我们只关心正确类别的对数概率,实现可以简化。
def cross_entropy(y_hat, y):
# y_hat是预测的概率分布,y是真实标签的索引
# gather函数选取y_hat中对应y索引处的值
return -torch.log(y_hat[range(len(y_hat)), y])

5. 定义评估指标
除了损失,我们更关心分类准确率。
def accuracy(y_hat, y):
"""计算预测正确的数量"""
if len(y_hat.shape) > 1 and y_hat.shape[1] > 1:
y_hat = y_hat.argmax(axis=1) # 获取预测的类别索引
cmp = y_hat.type(y.dtype) == y
return float(cmp.type(y.dtype).sum())
训练模型
我们定义一个通用的训练函数,它将循环遍历数据,计算梯度并更新参数。
以下是训练过程的核心步骤:

- 初始化参数。
- 对于每个迭代周期(epoch):
- 遍历训练数据集的每个小批量(minibatch)。
- 计算模型输出和损失。
- 计算损失关于模型参数的梯度。
- 使用优化算法(如随机梯度下降SGD)更新参数。
- 在测试集上评估模型性能。

def train_epoch_ch3(net, train_iter, loss, updater):
"""训练模型一个迭代周期"""
# 将模型设置为训练模式
if isinstance(net, torch.nn.Module):
net.train()
# 记录损失总和、正确预测数、样本数
metric = d2l.Accumulator(3)
for X, y in train_iter:
# 计算梯度并更新参数
y_hat = net(X)
l = loss(y_hat, y)
if isinstance(updater, torch.optim.Optimizer):
# 使用PyTorch内置的优化器和损失函数
updater.zero_grad()
l.mean().backward()
updater.step()
else:
# 使用定制的优化器和损失函数
l.sum().backward()
updater(X.shape[0])
metric.add(float(l.sum()), accuracy(y_hat, y), y.numel())
# 返回训练损失和训练准确率
return metric[0] / metric[2], metric[1] / metric[2]


现在,我们可以使用这个函数来训练我们的Softmax回归模型。

lr = 0.1
num_epochs = 10

def updater(batch_size):
d2l.sgd([W, b], lr, batch_size)

for epoch in range(num_epochs):
train_metrics = train_epoch_ch3(net, train_iter, cross_entropy, updater)
test_acc = d2l.evaluate_accuracy(net, test_iter)
print(f‘Epoch {epoch + 1}, Train loss {train_metrics[0]:.3f}, ‘
f‘Train acc {train_metrics[1]:.3f}, Test acc {test_acc:.3f}’)

结果分析

运行上述代码后,我们可以观察到:
- 训练损失随着迭代周期增加而稳步下降。
- 训练准确率和测试准确率同步上升。
- 最终,这个简单的线性模型在Fashion-MNIST测试集上能达到约83-85%的准确率。


由于模型非常简单(仅约7850个参数),而训练数据量较大(60000个样本),因此不太容易出现严重的过拟合现象,测试准确率与训练准确率通常很接近。

我们可以可视化一些模型的预测结果,会发现它能正确识别出如包包、凉鞋等特征明显的物品,但对于外形相似的类别(如衬衫、外套、裙子)有时会混淆。

总结 🎯

本节课中我们一起学习了Softmax回归的完整实现流程:
- 原理:理解了Softmax函数如何将线性输出转化为概率分布,以及交叉熵损失作为优化目标。
- 实现:从零开始初始化参数、实现了Softmax运算、定义了网络模型、损失函数和准确率计算。
- 训练:构建了一个训练循环,使用小批量随机梯度下降来优化模型参数。
- 评估:在独立的测试集上评估了模型性能,并分析了结果。

这个例子展示了仅使用线性代数运算和自动微分,就能构建并训练一个有效的多类分类器。这为后续学习更复杂的神经网络模型奠定了坚实的基础。
课程 P25:25. 使用Gluon实现Softmax分类 🧠

在本节课中,我们将学习如何使用深度学习框架Gluon来实现Softmax分类。我们将看到,相比于从零开始实现,使用Gluon可以极大地简化模型定义、参数初始化和训练过程。

概述
我们将构建一个用于图像分类的Softmax回归模型。与手动实现不同,本节将利用Gluon的高级API,它能自动处理许多底层细节,例如参数初始化和维度推断,从而使代码更简洁、更易维护。


数据导入与准备
首先,我们需要导入数据。这部分代码与之前手动实现Softmax分类时完全相同。
# 导入必要的库和数据
from mxnet import gluon, nd, autograd
from mxnet.gluon import nn
# ... 数据加载代码 ...

数据准备步骤保持不变,确保我们拥有正确格式的训练和测试数据集。

定义网络模型
上一节我们介绍了如何手动定义网络层和参数。本节中我们来看看如何使用Gluon简化这一过程。
使用Gluon定义网络非常简单。我们只需声明网络是一个顺序组合的层。在本例中,我们的网络只有一层。
# 使用Sequential容器定义网络
net = nn.Sequential()
# 添加一个全连接层,输出单元数为10(对应10个分类)
net.add(nn.Dense(10))
核心概念:nn.Sequential() 是一个容器,用于按顺序组合网络层。nn.Dense(10) 定义了一个全连接层,其输出维度为10。

参数初始化
在Gluon中,我们可以方便地指定参数的初始化方式。这里,我们将权重初始化为均值为0、方差为0.01的正态分布。

# 初始化网络参数
net.initialize(init=init.Normal(sigma=0.01))

核心概念:initialize() 方法用于初始化网络中的所有参数。init.Normal(sigma=0.01) 指定了初始化分布。

Gluon的自动维度推断
一个显著的优势是,我们不再需要手动指定输入维度。Gluon网络足够智能,能够在第一次前向传播时根据输入数据自动推断参数的数量和大小。
这意味着,如果输入图像尺寸从28x28变为30x30,网络依然能正常工作。然而,这种灵活性在实际应用中需要谨慎使用,因为过大的输入可能导致内存耗尽或计算缓慢。

定义损失函数

接下来,我们需要定义损失函数。Gluon提供了数值稳定的Softmax交叉熵损失函数。
# 定义损失函数
loss = gluon.loss.SoftmaxCrossEntropyLoss()
核心概念:SoftmaxCrossEntropyLoss() 将Softmax运算和交叉熵损失计算结合在一起,并进行了数值优化以提高稳定性。

选择优化算法
我们将使用随机梯度下降(SGD)作为优化算法。Gluon中的Trainer API封装了SGD,并默认包含动量等优化技巧。
# 定义优化器
trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': 0.1})

核心概念:Trainer 类用于更新网络参数。net.collect_params() 收集所有需要训练的参数,’sgd’ 指定优化算法,字典 {‘learning_rate’: 0.1} 设置学习率。

训练模型

训练过程的调用签名与我们手动实现时几乎一模一样。Gluon将前向传播、损失计算、反向传播和参数更新封装了起来。
以下是训练循环的核心结构:

for epoch in range(num_epochs):
for X, y in train_iter:
with autograd.record():
output = net(X)
l = loss(output, y)
l.backward()
trainer.step(batch_size)
# 每个epoch后评估准确率...
由于Gluon内部的高效实现,训练速度可能会比手动实现略快,尤其是在构建更复杂的网络时,优势将更加明显。

总结
本节课中,我们一起学习了如何使用Gluon框架实现Softmax分类。我们看到了Gluon如何通过高级API简化网络定义(nn.Sequential)、参数初始化(initialize)、损失函数(SoftmaxCrossEntropyLoss)和优化器(Trainer)的设置。最重要的是,我们体验了其自动维度推断功能带来的便利,但也提醒了在实际应用中需注意输入尺寸可能带来的问题。使用Gluon,我们可以更专注于模型结构和高层逻辑,而非底层实现细节。
课程 P26:感知机算法 🧠
在本节课中,我们将学习感知机算法。这是20世纪50年代提出的一种早期神经网络模型,用于解决二分类问题。我们将了解它的工作原理、训练方法,并通过一个简单的例子来观察其运行过程。


感知机模型
上一节我们介绍了课程背景,本节中我们来看看感知机的具体模型。
感知机是一个简单的线性分类器。它接收输入 X,通过一个线性函数计算得分,然后根据得分输出分类结果(例如0或1)。
其核心计算过程可以用以下公式描述:
输出 = f(W·X + b)
其中:
W是权重向量。X是输入特征向量。b是偏置项。f是一个非线性激活函数。对于二分类,通常使用阶跃函数:当W·X + b >= 0时输出1,否则输出0。
这个线性函数 W·X + b = 0 在空间中定义了一个决策边界(一个平面或直线),用于分隔不同的类别。
感知机训练算法
了解了模型的基本结构后,本节我们来看看如何训练它。
感知机使用一种迭代算法进行训练,其核心思想是:当模型对某个样本分类错误时,就调整参数以修正这个错误。这可以被视为使用特定损失函数、批大小为1的随机梯度下降。
以下是算法的具体步骤:
初始化权重 W 和偏置 b 为0。
对训练数据进行迭代。
对于每个样本 (xi, yi)(其中 yi 是真实标签,取值为+1或-1):
检查分类是否正确。条件为:yi * (W·xi + b) <= 0。如果成立,则表示分类错误。
如果分类错误,则按以下规则更新参数:
W = W + yi * xib = b + yi
重复此过程,直到所有样本都被正确分类或达到停止条件。
该算法对应的损失函数可以理解为:当预测正确(y * f(x) >= 0)时,损失为0;当预测错误时,损失与 -y * f(x) 成正比。上述更新规则正是该损失函数导出的结果。
算法的收敛性
在开始实践之前,我们先从理论上了解这个算法的表现。

感知机收敛定理保证了算法的可行性。该定理指出:如果存在一个权重向量 W* 和偏置 b* 能够以间隔 ρ > 0 完美分隔所有训练数据,并且所有数据点都在一个半径为 R 的球内,那么感知机算法最多经过 (R² + 1) / ρ² 次参数更新后就会收敛。
这个定理的关键在于,收敛所需的是参数更新的次数,而非遍历数据的次数。如果数据容易分隔(间隔 ρ 大),更新次数就少;如果数据难以分隔或分布复杂,更新次数就多。
实践:用Python实现感知机
理论部分已经介绍完毕,现在让我们通过代码来实际观察感知机是如何工作的。
首先,我们需要生成一些线性可分的数据。

import numpy as np
import matplotlib.pyplot as plt
def generate_linear_separable_data(num_samples=100, margin=0.3):
# 生成一个随机的分隔超平面参数
W_true = np.random.randn(2)
W_true = W_true / np.linalg.norm(W_true) # 归一化
b_true = np.random.randn()
X = []
y = []
while len(X) < num_samples:
x_i = np.random.randn(2) # 随机生成一个点
# 计算该点到决策边界的“距离”
dist = np.dot(W_true, x_i) + b_true
# 如果距离足够大(即远离边界),则赋予标签并保留
if abs(dist) > margin:
label = 1 if dist > 0 else -1
X.append(x_i)
y.append(label)
return np.array(X), np.array(y), W_true, b_true

# 生成数据
X, y, W_true, b_true = generate_linear_separable_data(margin=0.5)

接下来,我们实现感知机训练算法。
def perceptron_train(X, y, max_epochs=100):
num_features = X.shape[1]
W = np.zeros(num_features) # 初始化权重
b = 0.0 # 初始化偏置
mistakes_history = [] # 记录错误历史
for epoch in range(max_epochs):
mistakes = 0
for i in range(len(y)):
# 检查是否分类错误
if y[i] * (np.dot(W, X[i]) + b) <= 0:
# 更新参数
W = W + y[i] * X[i]
b = b + y[i]
mistakes += 1
# 记录当前状态(用于可视化)
mistakes_history.append((W.copy(), b, X[i], y[i]))
# 如果本轮没有错误,则已收敛
if mistakes == 0:
print(f"Converged after {epoch} epochs.")
break
else:
print(f"Stopped after reaching max_epochs ({max_epochs}).")
return W, b, mistakes_history
# 训练感知机
W_trained, b_trained, history = perceptron_train(X, y)
最后,我们可以绘制训练过程,观察决策边界是如何逐步调整的。
def plot_decision_boundary(W, b, X, y, title):
# 绘制数据点
plt.scatter(X[y==1, 0], X[y==1, 1], c='blue', label='Class +1')
plt.scatter(X[y==-1, 0], X[y==-1, 1], c='red', label='Class -1')
# 绘制决策边界 W·x + b = 0
x1_min, x1_max = X[:, 0].min() - 1, X[:, 0].max() + 1
x2_min, x2_max = X[:, 1].min() - 1, X[:, 1].max() + 1
xx1 = np.linspace(x1_min, x1_max, 200)
# 由 W[0]*x1 + W[1]*x2 + b = 0 解出 x2
xx2 = -(W[0] * xx1 + b) / (W[1] + 1e-10) # 防止除零
plt.plot(xx1, xx2, 'k-', label='Decision Boundary')
plt.xlabel('Feature 1')
plt.ylabel('Feature 2')
plt.title(title)
plt.legend()
plt.grid(True)
plt.axis('equal')
plt.show()
# 绘制最终的决策边界
plot_decision_boundary(W_trained, b_trained, X, y, "Perceptron Final Decision Boundary")
运行代码后,你将看到感知机如何从初始状态(决策边界可能很差)开始,随着每次错误更新,逐步调整到能够完美分隔所有数据点的最终状态。
感知机的局限性:XOR问题
感知机虽然简单有效,但它有一个根本性的局限。本节中我们来看看这个著名的“XOR问题”。
异或(XOR)问题要求模型学习以下规则:
- 输入 (0,0) 或 (1,1) 时,输出 0。
- 输入 (0,1) 或 (1,0) 时,输出 1。
在二维平面上,这对应于需要将 (0,0) 和 (1,1) 分为一类(例如红点),将 (0,1) 和 (1,0) 分为另一类(例如绿点)。你无法找到一条直线来完美分隔这两类点。
正是明斯基和帕帕特在20世纪70年代初指出了感知机的这一缺陷,证明了单层感知机无法解决线性不可分问题,这直接导致了第一次“AI寒冬”。
总结
本节课中我们一起学习了感知机算法。
- 我们首先介绍了感知机的基本模型,它是一个通过线性函数加阶跃激活函数进行二分类的简单网络。
- 接着,我们详细讲解了其训练算法,该算法在分类错误时更新权重,并了解了保证其收敛的理论定理。
- 然后,我们通过Python代码实践,生成了线性可分数据并实现了感知机,直观地观察了其学习过程。
- 最后,我们探讨了感知机的主要局限性——无法解决像XOR这样的线性不可分问题,这为后续引入多层网络(多层感知机)埋下了伏笔。

感知机是神经网络发展史上的重要里程碑,理解它为我们学习更复杂的深度学习模型奠定了坚实的基础。
课程 P27:多层感知机 🧠
在本节课中,我们将要学习多层感知机。我们将从经典的XOR问题入手,理解为什么需要引入非线性激活函数和隐藏层,并最终构建一个能够解决复杂分类问题的神经网络模型。

XOR问题与线性不可分性
上一节我们介绍了线性分类器的局限性。本节中我们来看看一个经典例子:XOR(异或)问题。
在XOR问题中,我们遇到的问题是线性分割器无法分割一些非常简单的数据。下图展示了这个问题:

事后看来,显然的解决方案是使用多层网络。值得注意的是,大脑也采用了类似的多层连接结构。
那么这一切与XOR有什么关系呢?让我们看看两个线性分类器。一个是简单的水平分割,另一个是垂直分割。这两者单独都无法正确分类所有点,但我可以轻松地将它们每一个都实现为线性分类器。我有蓝色的和橙色的分类器,它们分别分割其他点(1,2,3,4)。
根据这些加号和减号。但现在真正巧妙的地方在于,如果我取这两个分类器输出的逻辑乘积,我就得到了XOR问题的解。这个结果并不令人惊讶,因为我实际上可以通过这种方式将XOR写成乘法运算。从逻辑上讲,这非常简洁,因为这实际上就是一个带有一个隐藏层的深度网络。我首先取第一个和第二个分类器的输出,将它们相乘,然后输入到下一层就完成了。
单隐藏层网络结构
看起来不错。让我们看看是否可以做得更多。这是一个单隐藏层网络。
如果我有一个分类问题,那么输出就是这个。我要称输出为 O,隐藏层为 H。在这种情况下,我有一些设计自由度,除了选择参数之外。我可以选择有多少个隐藏单元。如果我选择很多隐藏单元,我可能能够建模更复杂的函数类。如果我选择很少,我就做不了太多。
有一个非常好的理论结果,它说,我几乎可以通过添加足够的隐藏单元来逼近任何有趣的函数。问题在于,这是一个美丽的理论结果,但在实践中完全没有用。因为没人会这样做,这样做会带来非常不愉快的后果。与庞大的函数类一起工作并不容易。但至少你知道如果你真的想做的话,是可以做到的。
数学模型与激活函数
那么这里是数学模型。我们有一些输入 x。我们有一些输出。我们有隐藏层。所以我们有一些参数 w1 和偏置 b1。对于输出,我们有一些向量 w2 和偏置 b2。这样输出就是标量。
所以对于隐藏层,我有:
H = σ(w1 * x + b1)
然后对于输出,我有:
O = w2^T * H + b2
这里的 σ 是一个激活函数。
那么为什么我需要激活函数呢?有任何想法吗?否则,无论你加多少层,最终都会得到一个线性函数。因为我可以一个接一个地应用线性变换。一个矩阵乘以下一个矩阵,组合起来仍然是线性的。事实上,我通过增加更多的线性层,可能会把事情搞得更糟。

为什么这是一个可怕的主意?过拟合?其实不完全是,因为最终它只是一个线性函数,尽管你有一个非常奇怪的参数化。但有一些非常实际的问题。第一点是,它可能需要永远才能收敛,因为我有一个非常大的等效网络参数空间,优化算法可能无法很好地收敛。第二个更具数学意义的问题是,我可能会得到一个表达能力更弱的网络。假设我有一个10维的输入和一个10维的输出,在中间我只有5个隐藏单元。那么我自动强制我的网络,在矩阵的基本秩上,将维度限制为5。这大大限制了我能做的事情。所以,不仅仅是我失去了非线性表达能力,我实际上可能会使模型比简单线性模型变得更糟。因此,这就是为什么我们需要激活函数的原因。
常见的激活函数
以下是一些常见的激活函数:

Sigmoid函数:σ(x) = 1 / (1 + e^(-x))
这曾经是一个非常流行的激活函数,但现在似乎没有人再用了。有人知道为什么这实际上是一个非常糟糕的主意吗?所以对于非常负的输入和非常正的输入,梯度会变为零。并且在某个地方有一个非常小的黄金区间(也许是-2到2之间),梯度才比较明显。如果由于某种不幸,你的输入尺度超出了范围,结果落入了那两个平坦的区域之一,那么你的优化算法基本上会卡住。你将再也得不到有意义的梯度。
Tanh函数:tanh(x)
它和Sigmoid的情况类似,只是尺度不同。你可以证明,这两个函数在重新缩放后是等效的。
ReLU函数:ReLU(x) = max(0, x)
现在大家都使用这个叫做ReLU(修正线性单元)的东西。它只是x和0的最大值。它不再有Sigmoid那么严重的梯度消失问题,至少它只剩下一半的问题(输入为负时梯度为0)。对于正半空间,梯度恒为1。
考虑到这一点,在某个时刻,一些人提出了一个想法,让我们固定左边的梯度零点,并使用一种叫做PReLU(参数化修正线性单元)的东西。PReLU本质上就是ReLU,但常数零的位置稍微偏移了一点,这个偏移量 α 是可以学习的。在某些情况下,这会让事情稍微变得更好一些。所以除非你真的知道自己遇到了梯度消失问题,否则不必特意使用它。
扩展到多类分类
现在我们可以进行多类分类。我们需要做的唯一事情就是构建网络:输入层、隐藏层、输出层。然后我在输出上运行一个softmax函数。
数学上讲:
隐藏层是:H = σ(w1 * x + b1)
输出是:O = w2 * H + b2
最终预测是:y = softmax(O)
既然我们这么喜欢增加复杂度,我们可以有更多的层。如果你只用图形表示,那很容易。但在某个时刻,用图形来指定真的很困难。因此,这就是在代码中指定网络会容易得多的地方。
一旦你有了这个框架,你将拥有更多的设计自由度。你可以添加更多的层,让它更深。你可以增加更多的隐藏单元。根据你怎么做,例如,你可以创建一个直线型网络,然后再转到输出,或者在中间变窄,之后又变宽。这实际上是你可能想要为特定数据集微调的地方。
总结与展望
本节课中我们一起学习了多层感知机。我们从XOR问题出发,理解了单层线性模型的局限性,并引入了隐藏层和非线性激活函数(如ReLU)来构建更强大的模型。我们看到了网络的数学模型,并讨论了网络深度和宽度等设计选择。
你现在将有足够的时间来实际尝试构建和训练一个多层感知机。
到目前为止,理论上有任何问题吗?


没有问题?很好,太棒了。


课程 P28:28. L6_3 多层感知器在 Python 中的实现 🧠💻

在本节课中,我们将从零开始,使用 Python 实现一个多层感知器(MLP)。我们将学习如何初始化模型参数、定义网络结构、计算损失并进行训练。通过这个过程,你将理解 MLP 的基本构建模块,并看到它如何比简单的线性模型带来性能提升。

1. 导入库与加载数据 📊
首先,我们需要导入必要的库并加载数据集。这部分代码与之前线性回归模型的准备工作类似。
import numpy as np
# 假设我们有一个加载数据集的函数 load_data()
train_data, test_data = load_data()
我们加载了新的数据集,但代码结构与之前完全相同。

2. 初始化模型参数 🔧

上一节我们准备好了数据,本节中我们来看看如何初始化一个含有一个隐藏层的多层感知器的参数。

假设我们的网络有一个包含 256 个神经元的隐藏层。我们需要初始化两个权重矩阵和两个偏置向量。
以下是参数初始化的步骤:
- W1: 连接输入层和隐藏层的权重矩阵,维度为
(输入特征数, 256)。 - b1: 隐藏层的偏置向量,维度为
(256,)。 - W2: 连接隐藏层和输出层的权重矩阵,维度为
(256, 输出类别数)。 - b2: 输出层的偏置向量,维度为
(输出类别数,)。
我们将所有参数存储在一个列表中,并为每个参数附加一个梯度存储空间,以便后续进行梯度下降学习。
num_inputs = 28 * 28 # 假设是28x28的图像,展平后
num_hiddens = 256
num_outputs = 10 # 假设是10分类问题
def init_params():
W1 = np.random.randn(num_inputs, num_hiddens) * 0.01
b1 = np.zeros(num_hiddens)
W2 = np.random.randn(num_hiddens, num_outputs) * 0.01
b2 = np.zeros(num_outputs)
params = [W1, b1, W2, b2]
# 为每个参数附加梯度
for param in params:
param.grad = np.zeros_like(param)
return params

params = init_params()
W1, b1, W2, b2 = params

3. 定义激活函数与模型结构 🏗️
参数初始化后,我们需要定义网络的前向传播过程。这包括线性变换和非线性激活。
我们使用 ReLU 作为隐藏层的激活函数。ReLU 函数的公式是:
ReLU(x) = max(0, x)

在代码中,我们可以利用广播机制对数组进行逐元素计算。
def relu(x):
return np.maximum(0, x)

现在,我们可以定义整个模型的前向传播函数。模型首先将输入的二维图像展平为一维向量,然后进行两次线性变换,中间通过 ReLU 激活函数引入非线性。

def mlp(X, params):
W1, b1, W2, b2 = params
# 将输入图像展平
X = X.reshape(-1, num_inputs)
# 第一层:线性变换 + ReLU激活
H = relu(np.dot(X, W1) + b1)
# 第二层:线性变换(输出层)
O = np.dot(H, W2) + b2
return O
这个模型结构与之前的 softmax 线性回归模型类似,但增加了一个带有非线性激活的隐藏层。

4. 定义损失函数与训练流程 📉

模型定义好后,我们需要一个损失函数来衡量其预测的好坏,并指导参数更新。
为了代码的简洁和数值稳定性,我们直接使用内置的 softmax 交叉熵损失函数。训练流程的代码(包括梯度计算和参数更新)与之前实现简单模型时基本相同。


def softmax_cross_entropy_loss(O, y):
# 使用稳定的softmax和交叉熵计算
exp_O = np.exp(O - np.max(O, axis=1, keepdims=True))
softmax = exp_O / np.sum(exp_O, axis=1, keepdims=True)
loss = -np.log(softmax[range(len(y)), y]).mean()
return loss
# 训练循环伪代码示意
def train_epoch(data_iter, params, lr):
for X, y in data_iter:
with autograd.record():
O = mlp(X, params)
loss = softmax_cross_entropy_loss(O, y)
loss.backward()
# 使用梯度下降更新每个参数
for param in params:
param[:] = param - lr * param.grad
param.grad[:] = 0 # 梯度清零
唯一的区别是,我们现在将 mlp 函数作为网络架构,而不是简单的线性模型。

5. 模型训练与结果分析 📈

使用上述代码对模型进行训练。经过若干次迭代后,我们可以观察到损失下降,并且模型在测试集上的准确率相较于简单的线性模型有所提升。
例如,线性模型的准确率可能在 85% 左右,而带有一个隐藏层的 MLP 可能将准确率提升到 88% 或更高。这种提升源于更强大的网络架构对数据中复杂模式的捕捉能力。

我们可以进一步检查模型犯的错误类型。例如,新的模型可能会将某些“靴子”图片误分类为“包”,但这可能代表了错误模式的改变,有时也意味着进步。

通过调整隐藏层大小、学习率或训练轮数,我们有可能获得更好的结果。

总结 ✨
本节课中,我们一起学习了如何从零开始实现一个多层感知器(MLP)。我们完成了以下关键步骤:
- 初始化参数:为具有一个隐藏层的网络创建权重和偏置。
- 构建模型:定义了包含展平层、线性变换和 ReLU 激活函数的前向传播过程。
- 计算损失:使用了 softmax 交叉熵损失函数。
- 训练模型:应用梯度下降算法更新参数。

通过这个实践,我们看到了引入隐藏层和非线性激活函数如何让模型获得比简单线性回归更强的表达能力,从而在图像分类任务上取得更好的性能。这为理解更复杂的深度学习模型奠定了坚实的基础。

课程 P29:模型评估、过拟合与欠拟合 🧠
在本节课中,我们将要学习如何评估机器学习模型,并理解模型训练中两个常见的问题:过拟合与欠拟合。我们将探讨为什么不能仅依赖训练数据来评判模型的好坏,以及如何通过验证集、测试集和交叉验证来更准确地评估模型的泛化能力。
作业四介绍:Kaggle房价预测竞赛 🏠
上一节我们介绍了课程安排,本节中我们来看看即将开始的作业四。
作业四将是一个有趣的Kaggle竞赛,任务是预测房屋销售价格。我们已经多次讨论过房价预测问题。你的目标是将作业提交到Kaggle平台。
我们提供了一个基准模型,并说明了如何下载数据集、构建基准模型以及提交结果。这个基准模型非常简单,可能使你在排行榜上排在1000名左右。你的任务是尽最大努力优化模型。

半年前,我们班上有十名学生成功进入了该竞赛的前十名。但现在情况有所不同,因为半年过去,可能已有约2000人参与。因此,在一周内进入前三名比较困难,但你仍然可以尽力而为。
我们建议你与团队合作,特别是你的项目团队。你可以借此机会熟悉你的队友。

我们还将为个人或团队中提交作品排名前三的提供500美元的ADOP积分。这笔积分可用于你的项目,尝试更复杂的模型。
作业四将在周四的课程中回顾相关笔记本。但我们建议你尽早开始,因为Kaggle非专业账户每天最多只能提交五次。你会有很多想法需要尝试,整个过程可能需要五个小时。如果你计划投入这些时间,建议每天花一小时,而不是在截止日期前集中完成。
这就是关于作业四的介绍。
模型评估的重要性:一个激励性示例 💡

上一节我们介绍了作业,本节中我们来看看模型评估的重要性,并通过一个例子来理解。

假设一位雇主雇佣你调查哪些贷款申请者会按时还款。你收到了100名申请者的信息,包括个人资料、银行账单和面试视频。此外,你还有一个月的跟踪数据,显示其中五人在三年内违约了。你的任务是预测未来谁可能违约。
你查看数据后,惊讶地发现所有五个违约者在面试中都表现得非常自信。这是一个强烈的信号,因为样本中其他人普遍表现得不那么自信。这可能意味着特别自信的人更容易违约。
但你无法确定这是巧合还是确有意义的关联。只要你能识别出这个信号,就可以用它来构建模型。模型可能会将这个信号视为最强的预测因子。例如,在面试期间,模型可能会立即标记出某个非常自信的申请者为高风险。
这里存在什么问题?问题在于我们只有五个违约样本。如果我们有10,000名申请者,其中100人违约,那么每个人的违约特征可能就不会如此明显和绝对。
希望这个例子能让你理解,在构建实用模型时,许多因素都至关重要。
训练误差与泛化误差 📊

上一节我们通过例子看到了数据评估的复杂性,本节中我们正式探讨模型评估的核心概念:训练误差与泛化误差。
到目前为止,我们主要讨论如何计算模型在训练数据集上的误差。训练数据集是你用来调整模型参数的数据。
然而在实践中,我们更关心的是泛化误差,即模型在从未见过的新数据上的表现。
举个例子,假设我们要为未来的考试做准备。我们可以根据过去的考试题目进行练习,这相当于在“训练集”上训练。但这并不能保证你在未来的真实考试中取得好成绩。
例如,一个学生可能通过死记硬背所有过往考题的答案,在模拟考试中得到满分。而另一个优秀的学生则努力理解解题背后的原理,他可能在模拟考试中得分稍低,但在未来的真实考试中可能表现更好。
这个类比可以扩展到作业上。我们已经发布了前两次作业的答案。如果你仅仅基于前两次作业的答案来准备第三次和第四次作业,可能会遇到问题。因为每次作业涵盖的主题可能不同。在考试中,情况则不同,因为考试通常覆盖整个课程的内容,且每年考察的知识领域相对稳定。
因此,我们关心的是模型的泛化能力。
验证集与测试集 🧪
上一节我们区分了训练误差和泛化误差,本节中我们来看看如何通过划分数据集来评估泛化能力。
我们通常使用验证数据集来评估模型。这个数据集不用于训练模型,仅用于评估。
例如,我们可以将一半的训练数据留作验证集,在剩余的另一半数据上训练模型,然后在验证集上评估其性能。
机器学习中一个常见的错误就是将训练数据和验证数据混淆。让我举一个真实的例子:几年前,一个团队训练了一个图像分类模型,使用的数据集是ImageNet。为了评估模型,他们让另一个人使用相同的类别标签去Google搜索并抓取新图像,然后用这个新数据集来验证模型。
这里的问题是什么?问题在于,原始的ImageNet数据集本身可能就是通过搜索引擎抓取图像构建的。如果验证时再次使用相同的搜索策略,很可能会抓取到已经存在于训练集中的图像,导致验证集并非真正的“新数据”,从而高估了模型的泛化能力。
因此,你需要非常小心地确保训练集和验证集是独立且不重叠的。

验证数据集可用于模型选择。你可以尝试不同的学习率、不同的模型架构,然后根据在验证集上的误差来选择最佳模型。
此外,还有一个测试数据集。这个数据集也用于最终评估模型,但关键区别在于你只能使用它一次。这类似于期末考试或竞赛的最终私人排行榜——你只有一次机会获得最终分数。
在实际讨论中,人们常说的“测试准确率”通常指的是验证集上的准确率。严格来说,测试误差才是模型在完全独立、仅使用一次的数据集上的最终表现。
K折交叉验证 🔄
上一节我们介绍了验证集和测试集,本节中我们来看看当数据量不足时,如何更有效地利用数据进行评估。
一个常见问题是我们没有足够的有标签数据来单独划分出一个大的验证集。如果我们使用一半数据作为验证集,那么用于训练的数据就会变少。

为了解决这个问题,一个常用的方法是K折交叉验证。这将是你在作业中使用的重要方法。
以下是K折交叉验证的步骤:
- 将整个训练数据集(带有标签的样本)随机分成K个大小相似的子集(或称为“折”)。
- 进行K次实验。对于第i次实验(i从1到K):
- 将第i个子集作为验证数据集。
- 将剩余的K-1个子集合并作为训练数据集。
- 在训练数据集上训练模型。
- 在验证数据集上计算验证误差。
- 计算这K次实验的平均验证误差,作为模型交叉验证误差的估计。
如果你选择一个较大的K值(例如K=100),那么每次训练将使用99%的数据,这对训练模型有利。但问题是,你需要重复进行100次训练,计算成本会非常高。

实际上,我们通常选择K=5或K=10。如果模型较简单,可以选择稍大的K值(如20或50)。如果数据量很大或模型训练非常耗时,则选择较小的K值。
我们将在作业四中实现K折交叉验证,并在周四的课程中详细讨论。
了解了如何评估模型后,我们将讨论训练过程中两种常见的异常情况:过拟合和欠拟合。
过拟合与欠拟合 ⚖️
上一节我们学习了交叉验证,本节中我们深入探讨模型训练中的两个核心问题。
一般来说,模型训练的好坏取决于两个关键因素的匹配程度:数据的复杂性和模型的容量。
以下是四种典型情况:
- 数据简单,模型简单:模型能够很好地学习和概括。
- 数据复杂,模型复杂:模型有能力处理复杂模式,可以正常工作。
- 数据复杂,模型简单:模型能力不足,无法充分学习数据中的复杂模式,导致欠拟合。
- 数据简单,模型复杂:模型能力过强,不仅学习了数据中的真实规律,还“记住”了训练数据中的噪声和随机波动,导致过拟合。
严格来说,模型容量是指模型拟合各种函数或数据分布的能力。低容量模型(如简单的直线)难以拟合复杂曲线。高容量模型(如高阶多项式)可以完美拟合训练数据(误差为零),但这通常意味着过拟合。
下图展示了模型容量对误差的影响:
- 横轴:模型容量/复杂度。
- 纵轴:损失(误差)。
- 训练损失:随着模型容量增加,模型更容易拟合训练数据,因此训练损失持续下降。
- 泛化损失(我们真正关心的):随着模型容量增加,泛化损失先下降后上升,形成一个U形曲线。
- 训练损失与泛化损失之间的差距就是过拟合的程度。模型越复杂,这个差距通常越大。
最优的模型容量位于泛化损失最低的点,通常在中间位置。模型既不能太简单(导致欠拟合),也不能太复杂(导致过拟合)。
如何估计与控制模型容量? 🧮
上一节我们定义了过拟合和欠拟合,本节中我们来看看如何量化并控制模型的容量。
估计模型的容量是一个难题。比较不同算法家族(如决策树 vs. 神经网络)的容量尤其困难。在本课程中,我们主要关注在同一算法家族内进行比较。
影响模型容量的两个主要因素是:
- 参数数量:模型可调整的权重和偏置的数量。
- 参数值范围:训练过程中允许参数取值的范围或约束。
例如,比较两个模型:
- 线性回归:假设输入有D维,模型有D个权重和1个偏置,总参数为
D + 1。 - 两层感知机:输入D维,隐藏层大小为M,输出K类。第一层参数为
(D + 1) * M,第二层参数为(M + 1) * K。总参数远多于线性回归。

因此,两层感知机通常比线性回归具有更高的模型容量。
关于参数值范围,如果我们限制模型参数只能在-1到+1之间取值,那么即使参数数量相同,其容量也低于参数可以取任意实数的模型。
统计学习理论简介:VC维度 📐

上一节我们讨论了参数数量,本节中我们简要介绍一个理论工具——VC维度,它用于更形式化地衡量模型容量。
统计学习理论中,一个核心概念是VC维度(Vapnik-Chervonenkis dimension)。对于分类模型,其VC维度定义为:该模型能够完美分类(即“打散”)的最大数据集的大小。无论你如何给这个数据集中的点分配标签,模型总能找到一个配置来实现完美分类。
来看一个具体例子:2D感知机(即一条直线)。其VC维度是3。这意味着对于平面上的任意三个点(只要不共线),无论你如何给它们分配正负标签,总可以找到一条直线将它们正确分开。但VC维度不是4,因为对于四个点(例如正方形的四个顶点,按对角线分配标签),一条直线就无法完美分类了。
对于一般的D维线性感知机(有D个权重和1个偏置,共D+1个参数),其VC维度大约等于参数数量M。这意味着模型参数越多,其VC维度通常越高,容量也越强。
VC维度理论提供了训练误差与泛化误差之间差距的理论上界,非常有用。然而,将其应用于现代深度学习模型(如深度神经网络、卷积网络)非常困难,因为它们的结构复杂,VC维度难以精确计算。因此,深度学习理论仍在快速发展以跟上实践。

数据复杂性 📈
上一节我们主要关注模型容量,本节中我们来看看问题的另一面:数据复杂性。
数据的复杂性受多种因素影响:
- 样本数量:样本越多,数据集可能越复杂,但也为复杂模型提供了更多学习材料。深度学习的理想数据集通常在10万到100万样本之间。
- 每个样本的特征数量:例如,图像有数百万像素,广告数据可能有数十亿特征。
- 数据结构:数据是否具有时间序列(如股票数据)、空间结构(如图像)或图结构(如社交网络)。具有结构的数据通常更复杂。
- 多样性:数据集中包含多少不同的对象或概念。例如,ImageNet有1000个物体类别,比只有10个类别的数据集复杂得多。
- 类内多样性:同一类别内样本的差异程度。例如,狗的照片如果颜色、品种、姿态差异很大,复杂性就高。

理解数据复杂性有助于你选择合适的模型容量。复杂的数据需要高容量的模型,而简单的数据则用简单模型即可,避免过拟合。
总结 🎯
本节课中我们一起学习了机器学习模型评估的核心概念以及过拟合与欠拟合问题。
我们首先通过一个贷款预测的例子,理解了模型评估的重要性。然后,我们区分了训练误差和泛化误差,指出我们真正追求的是模型在新数据上的表现。
为了评估泛化能力,我们引入了验证集和测试集的概念,并强调了保持它们独立于训练集的必要性。当数据量有限时,我们介绍了强大的K折交叉验证方法。
接着,我们深入探讨了模型训练中的两个关键问题:过拟合(模型过于复杂,记住了噪声)和欠拟合(模型过于简单,无法捕捉数据规律)。它们的发生取决于模型容量与数据复杂性是否匹配。
我们讨论了通过参数数量来粗略估计模型容量,并简要介绍了理论工具VC维度。最后,我们也分析了影响数据复杂性的多种因素。

理解这些概念是构建有效、可靠机器学习模型的基础。在接下来的课程和作业中,你将应用这些知识来优化你的模型。
课程P3:软件环境搭建指南 🛠️
在本节课中,我们将学习如何为深度学习实践搭建必要的软件环境。我们将介绍核心工具Python、包管理器Conda、交互式笔记本Jupyter以及深度学习框架MXNet的安装与配置方法。
1. 核心工具概述
我们将要使用的工具是Python,它在机器学习和数据科学领域应用广泛。为了简化包管理操作,我们将使用Conda。
我们将使用Jupyter笔记本进行代码编写和实验跟踪。对于本课程涉及的代码量(约100到200行),Jupyter是一个非常合适的工具。
最后,我们需要一个深度学习框架。本课程选择的是MXNet-Gluon,因为它具有可扩展性好、易于使用、接口友好、跨硬件兼容性强等优点。
如果你使用其他深度学习框架,你仍然能够理解所有理论和模型。但如果你想动手实践代码,则需要安装MXNet。
2. 基础安装步骤(Linux/Mac)

上一节我们介绍了核心工具,本节中我们来看看在Linux或Mac系统上的具体安装步骤。
如果你使用的是通用的Linux云服务器、Linux笔记本电脑或Mac电脑,安装过程相当简单。
以下是具体步骤:
- 从Conda官网下载并安装最新的mini-conda安装程序。
- 从
d2l-en.zip获取本课程书籍的最新版本代码包。 - 解压缩后,你会找到一个包含所有相关包定义的环境配置文件
environment.yml。 - 通过Conda创建该环境。命令为:
conda env create -f environment.yml。 - 激活Gluon环境。命令为:
source activate gluon(Mac/Linux)。 - 进入解压后的目录,运行Jupyter笔记本即可。
3. GPU支持配置
如果你希望使用GPU来加速计算,则需要额外的配置。这可能会比安装MXNet本身花费更多时间。
首先,你需要安装NVIDIA驱动程序、CUDA和cuDNN。请严格按照NVIDIA官网的说明进行操作。
其次,你需要修改环境配置文件environment.yml,将MXNet的CPU版本替换为对应的GPU版本。例如,将mxnet替换为mxnet-cu92以支持CUDA 9.2。
这只是为了确保你能在设备上实际使用GPU。
4. Windows系统安装
如果你使用的是Windows系统,安装过程与Linux/Mac略有不同。
主要区别有以下两点:
- 你需要下载适用于Windows 64位系统(Windows x86_64)的Conda安装程序(.exe文件)。
- 激活Conda环境的命令不同。在Windows上,你应使用:
activate gluon,而不是Linux/Mac上的source activate gluon。

除此之外,其他步骤与前述基础安装步骤一致。
5. 使用云服务(AWS)
如果你不想在本地处理复杂的NVIDIA驱动安装,可以直接使用亚马逊AWS云服务。
你可以从“深度学习基础AMI”镜像开始创建云服务器实例。访问课程提供的链接,查找最新的基础AMI(例如基于MXNet的版本),然后按照步骤启动实例。
对于P2、P3、G2、G3等GPU实例,你仍需在实例内部配置NVIDIA驱动并更新Conda环境。
6. 安全访问云服务器Jupyter
在云服务器上运行Jupyter会遇到安全问题:直接将Web服务器端口对外开放是危险的。
你需要使用SSH端口转发(隧道)技术。它的作用是将云服务器上Jupyter打开的端口,通过加密的SSH连接转发到你本地计算机的某个端口。
这样,你就可以在本地浏览器中安全地访问运行在远程云服务器上的Jupyter笔记本。
我们将在后续课程中实际演示这一操作。你也可以参考书籍网站中关于AWS的附录获取详细指南。如需AWS学术积分,可以联系我们。

7. 使用Google Colab
你也可以使用Google Colab进行实验。访问 colab.research.google.com,创建一个新笔记本。
以下是使用前的必要设置:
- 在菜单栏选择“运行时” -> “更改运行时类型”,确保激活了“GPU”支持的硬件加速器。
- 在笔记本的第一个单元格中,运行以下两行安装命令:
然后你就可以开始运行代码了。!pip install mxnet-cu92 !pip install d2l
Colab的优点是提供免费的GPU资源。缺点是运行环境不是永久性的,长时间无活动会被回收,本地变量状态会丢失,因此需要注意保存重要数据。

总结
本节课中,我们一起学习了为深度学习课程搭建软件环境的多种途径。我们介绍了核心工具Python、Conda、Jupyter和MXNet,并详细说明了在Linux、Mac、Windows系统上的安装步骤,以及如何配置GPU支持。此外,我们还介绍了通过AWS云服务和Google Colab这两种无需复杂本地配置的替代方案,特别是强调了使用SSH隧道安全访问云服务器的重要性。现在,你的实践环境已经准备就绪。
机器学习基础 P30:30. L7_2 平方 L2 正则化 🧠

在本节课中,我们将要学习一种在机器学习中非常重要的技术——L2正则化,它也被称为权重衰减。我们将了解它的数学原理、直观解释以及在优化过程中的作用。

概述 📖

上一节我们介绍了过拟合的概念,本节中我们来看看如何通过正则化技术来缓解过拟合。具体来说,我们将深入探讨平方L2正则化,这是一种通过约束模型权重的大小来提升模型泛化能力的有效方法。

从硬约束到软约束 🔄
在机器学习中,我们有时希望对模型的权重(参数)施加约束,例如限制其大小不能超过某个值。这被称为硬约束。

然而,在硬约束版本中,优化过程会变得相当困难。所以我们通常使用软约束版本,这意味着对于每一个约束条件,我们实际上可以找到一个对应的数值(拉格朗日乘子)。
根据这个数值,我们可以将硬约束的优化问题重写为以下形式:

目标函数 = 原始损失函数 + λ/2 * ||w||²
这里,||w||² 表示权重向量 w 的 L2范数的平方(即各分量平方和)。λ 是一个超参数,用于控制正则化的强度。

L2正则化的形式与等价性 ⚖️

所以,这实际上是 L2正则化 或者 平方L2正则化。你可以通过拉格朗日乘子法证明,带约束的优化问题(硬约束)与上述添加惩罚项的形式(软约束)是等价的。
这里的 λ 是超参数:
- 如果我们选择
λ = 0,那么正则化项不起作用,对模型能力没有影响。 - 如果我们选择一个更大的值,比如
λ = 0.1或λ = 1,正则化的效果会增强。 - 如果我们选择
λ趋近于无穷大,那么我们就强制所有权重w为 0。

L2正则化如何工作? 🎯

我们可以可视化它是如何工作的。
想象一个二维权重空间(w1 和 w2)。绿色的等高线椭圆代表原始损失函数,其中心点 w* 是未正则化时的最优解。
当我们加上L2正则化项 λ/2 * ||w||² 时,它在图上表现为一系列以原点(0,0)为中心的同心圆。新的优化目标变成了“损失函数值 + 正则化项值”。
因此,最优解的位置会在原始损失函数的最优点 w* 和原点 (0,0) 之间进行权衡。L2正则化项像一根弹簧,将权重向量 w* 向原点方向拉拽。
- 如果我们有一个更大的
λ值,这个拉拽的作用力就更大,最优解会更靠近原点。 - 如果我们有一个小的
λ值,那么这个作用力就较小,最优解会更靠近原始的w*。
为什么叫“权重衰减”? 📉
在神经网络领域,L2正则化常被称为权重衰减。这是因为我们可以从梯度更新的角度来理解它。

让我们计算包含L2正则化的总损失函数的梯度。总梯度是原始损失函数的梯度与正则化项梯度之和。
使用随机梯度下降(SGD),我们知道每次更新权重的公式是:
w_t = w_{t-1} - η * 梯度
其中 η 是学习率。对于包含L2正则化的损失,其关于权重 w 的梯度包含一项 λ * w。因此,更新公式可以重写为:
w_t = w_{t-1} - η * (原始损失梯度 + λ * w_{t-1})
w_t = (1 - ηλ) * w_{t-1} - η * (原始损失梯度)
通常,我们选择一个非常小的 λ 值(例如 1e-3 或 1e-6),而学习率 η 也是一个小于1的数。因此,(1 - ηλ) 是一个略小于1的数字。
这意味着在每次更新时,我们先将前一步的权重 w_{t-1} 乘以一个略小于1的因子进行“收缩”,然后再减去原始损失的梯度。 这种在每一步都使权重绝对值轻微减小的过程,就像权重在“衰减”一样。因此,神经网络领域的人们称之为权重衰减。实际上,它就是L2正则化。
总结 ✨
本节课中我们一起学习了:
- L2正则化(平方L2正则化) 是一种通过向损失函数添加权重范数惩罚项(
λ/2 * ||w||²)来防止过拟合的技术。 - 它等价于对权重大小施加一个软约束,超参数
λ控制着约束的强度。 - 从几何上看,L2正则化将最优解从原始损失的最优点向坐标原点方向推动,从而限制权重的大小。
- 在优化过程中,由于其更新公式会使权重每一步都乘以一个小于1的因子,因此它在神经网络领域常被称为权重衰减。

理解L2正则化是掌握模型正则化与泛化能力的关键一步。
课程 P31:在 Jupyter 中实现平方 L2 正则化 🧮
在本节课中,我们将学习如何在 Jupyter Notebook 中为线性回归模型实现平方 L2 正则化(也称为权重衰减)。我们将通过一个高维数据的例子,演示正则化如何帮助缓解过拟合问题,并比较使用正则化前后的模型表现差异。



数据生成与问题背景
上一节我们讨论了正则化的基本概念。本节中,我们来看看如何在一个具体的高维数据场景下应用它。

我们生成一个用于线性回归的高维数据集。这里,d 代表数据的维度数(即特征数量),w 代表模型的权重向量。
数据生成过程相当简单:权重全部初始化为0,然后减去0和1,并加上一些噪声。通过这个过程,我们可以清楚地知道数据是如何构造的。

获取这些数据后,我们像之前一样,构造线性回归模型并进行权重初始化。

定义平方 L2 正则化

在定义了模型之后,我们需要定义平方 L2 长度惩罚,也就是 L2 正则化项。
其核心概念是:对于给定的权重向量 W,计算其所有元素的平方和,然后除以 2。用公式表示如下:

公式:
L2_penalty = (1/2) * sum(W_i^2)

这就是 L2 正则化项。在训练过程中,我们将把这个项加入到原始的损失函数中。
训练过程与损失函数

训练过程实际上和之前的普通线性回归非常相似。
唯一的区别在于损失函数的计算。新的损失函数是原始损失(如均方误差)加上一个正则化项。具体公式如下:
公式:
total_loss = original_loss(y_pred, y_true) + lambda * L2_penalty(W)
其中,lambda 是一个超参数,用于控制正则化的强度。这是我们引入的唯一不同之处,模型的其他部分都保持不变。

实验:不使用正则化
首先,我们设置 lambda = 0,这意味着不使用任何正则化。
以下是观察结果:
- 蓝色实线代表训练误差,虚线代表验证误差。
- 可以看到训练误差持续下降,但验证误差实际上升了,且变化不大。
- 原因在于我们使用了100个维度,但只用了非常少的样本。因此,即使是线性模型也会在这个数据集上严重过拟合。
- 我们可以计算最终学到的权重
W的 L2 范数,结果约为 13。


实验:使用权重衰减
现在,让我们尝试使用权重衰减,设置 lambda = 3。

以下是观察结果:
- 模型仍然存在一些过拟合,但情况比之前好多了。
- 测试误差被显著降低。
- 鉴于我们仍然只有少数样本对应这个高维数据集,模型仍在过拟合,但正则化大大减少了测试误差。
- 此时,权重
W的 L2 范数相比之前(13)变得非常小。
在深度学习框架中的实现
如果我们要从头实现,我们只需要直接更改损失函数。
然而,如果你要使用一个深度学习库(如 PyTorch),操作通常更简单。我们通常不需要手动修改损失函数,而是告诉优化器(如SGD)应用权重衰减。
例如,在 PyTorch 中,你可以这样定义优化器:
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, weight_decay=3)
这里,weight_decay 参数就对应我们的 lambda 值。

在实际操作中,框架通常只对权重参数应用 weight_decay,而不会对偏置项应用。这与我们公式讨论的内容非常相似。因此,在实践中,你通常只需要指定 weight_decay 参数,而无需手动更改损失函数。

结果对比与总结
通过对比实验,我们可以清晰地看到:
- 当不应用权重衰减时,训练准确度与测试准确度之间存在巨大差距。
- 当应用权重衰减(
lambda = 3)后,测试准确度得到提升,同时权重W的 L2 范数也减小了。

本节课中,我们一起学习了如何在 Jupyter 环境中实现平方 L2 正则化。我们通过代码实践看到了正则化如何通过惩罚大的权重来有效控制模型的复杂度,从而减轻过拟合,提升模型在未见数据上的泛化能力。记住,在实际使用深度学习框架时,直接通过优化器的 weight_decay 参数来应用 L2 正则化是最便捷的方式。
课程 P32:32. L7_4 Dropout 🧠

在本节课中,我们将要学习深度学习中一个重要的正则化技术——Dropout。我们将了解它的核心思想、工作原理、应用方式以及在训练和推理阶段的不同处理方式。
概述 📖
Dropout是一种在神经网络训练过程中,通过随机“丢弃”一部分神经元来防止模型过拟合的技术。它通过向网络内部层添加噪声,而非调整输入数据,来增强模型的鲁棒性。

Dropout的核心思想 💡
一个好的模型应该对输入数据的变化具有鲁棒性。例如,识别图像中的物体时,无论图像的角度、光线或存在多少噪声,模型都应能正确识别。

上一节我们介绍了模型鲁棒性的概念,本节中我们来看看Dropout如何实现这一点。其核心思想是:在训练过程中,随机将神经网络中某些神经元的输出置为零,从而模拟一个“残缺”的网络进行学习。
Dropout的工作原理 ⚙️
Dropout向神经网络的内部层添加噪声,而不是直接调整输入数据。
具体来说,假设某一层的输出为向量 x。应用Dropout后,我们得到新的输出 x',其数学期望 E[x'] 等于原始的 x。这意味着我们加入了噪声,但没有改变输出的期望值。

以下是Dropout的具体操作步骤:
- 设定一个丢弃概率 p(例如0.5)。
- 对于输出向量 x 中的每一个元素 xᵢ,以概率 p 将其设置为0。
- 对于未被设置为0的元素,将其值放大,除以 (1 - p)。
这个过程可以用以下公式描述:
x'ᵢ = (xᵢ / (1 - p)) * Bernoulli(1-p)
其中 Bernoulli(1-p) 是一个以概率 (1-p) 取1,以概率 p 取0的伯努利随机变量。
这样,E[x'ᵢ] = xᵢ 得以保证。
Dropout的应用方式 🎯
Dropout通常应用于全连接层的输出。全连接层是模型中参数最多、最容易过拟合的部分,因此是应用Dropout进行正则化的理想位置。
以下是Dropout在层间应用的具体流程:
- 某一层计算得到输出(例如,经过权重 W、偏置 b 和激活函数处理后的结果)。
- 对该输出应用Dropout操作,得到新的输出 x'。
- 这个新的输出 x' 将作为下一层的输入。
例如,一个内部层有5个神经元输出(边1到边5)。应用Dropout后,可能会随机地将边2和边5的输出置为0。这意味着下一层将接收来自边1、3、4的信号。
关键点:每次进行前向传播训练时,被“丢弃”的神经元都是随机选择的。模型不会永久性地固定丢弃某几个神经元,这使得网络每次学习时都面对略有不同的子网络结构,从而增强了泛化能力。
训练与推理阶段的差异 🔄

Dropout是一种正则化手段,其目的是在训练过程中限制权重的学习,以防止过拟合。
上一节我们介绍了Dropout在训练时的应用,本节中我们来看看在模型部署时的不同。在推理(或预测)阶段,我们不应该再使用Dropout。因为此时我们的目标是获得稳定、确定性的预测结果。
因此,模型在训练和推理时的行为是不同的:
- 训练时:启用Dropout,随机丢弃神经元。
- 推理时:关闭Dropout,使用完整的网络结构进行计算。为了补偿训练时因放大激活值(除以1-p)带来的尺度变化,在推理时,通常需要将该层的输出乘以 (1-p),或者更常见的做法是在训练时进行缩放,而在推理时不做任何处理(即“Inverted Dropout”)。
总结 ✨

本节课中我们一起学习了Dropout技术。我们了解到:
- Dropout是一种通过随机“关闭”神经元来防止神经网络过拟合的正则化技术。
- 它的工作原理是在训练时,以概率 p 将神经元的输出置零,并将剩余输出放大 1/(1-p) 倍,以保持期望值不变。
- Dropout主要应用于全连接层,且每次训练迭代丢弃的神经元是随机的。
- 在模型训练阶段需要启用Dropout,而在推理阶段则应关闭它以获得确定性输出。
掌握Dropout有助于你构建更具鲁棒性和泛化能力的深度学习模型。

课程33:在Jupyter中使用丢弃法 🎲
在本节课中,我们将学习一种名为“丢弃法”的正则化技术。丢弃法通过在训练过程中随机“丢弃”一部分神经元,来防止神经网络过拟合。我们将从原理入手,学习如何手动实现丢弃法,并了解如何在深度学习框架中便捷地使用它。
丢弃法的原理与实现

上一节我们介绍了过拟合问题,本节中我们来看看丢弃法如何作为一种解决方案。丢弃法的核心思想是在训练时,以一定的概率随机将某些神经元的输出置为零,从而减少神经元之间的复杂共适应关系。

丢弃法的数学定义如下:在训练阶段,对于输入 x,我们生成一个掩码 mask,其中每个元素以概率 drop_prob 被置为0,以概率 1 - drop_prob 被置为 1/(1 - drop_prob)。输出是 x 与 mask 的逐元素乘积。
以下是丢弃函数的手动实现代码:

def dropout(x, drop_prob):
# 确保丢弃概率在0到1之间
assert 0 <= drop_prob <= 1
# 如果丢弃概率为1,则所有输出均为0
if drop_prob == 1:
return torch.zeros_like(x)
# 生成一个与x形状相同的随机掩码
mask = (torch.rand(x.shape) > drop_prob).float()
# 应用掩码并缩放输出
return mask * x / (1.0 - drop_prob)
这个函数接收输入张量 x 和丢弃概率 drop_prob。它首先生成一个随机掩码,然后通过缩放来保持输出的期望值不变。我们不对 drop_prob 为1的情况做特殊处理,是因为在实现中直接返回零张量更为直接。

在神经网络中应用丢弃法
理解了丢弃法的基本实现后,我们来看看如何将其整合到一个具体的神经网络模型中。我们将构建一个用于Fashion-MNIST分类的双隐藏层感知机。
以下是模型构建的关键步骤:

- 定义网络结构:我们构建一个具有两个隐藏层的网络。第一隐藏层有256个单元,第二隐藏层有512个单元,输出层对应10个类别。
- 设置丢弃概率:我们为两个隐藏层分别设置丢弃概率。通常,对于更复杂的层(如第二隐藏层),可以使用较大的丢弃概率。这是一个需要调整的超参数。
- 前向传播逻辑:在训练阶段,我们在每个隐藏层的激活函数输出后应用丢弃法。在推理(测试)阶段,则不应用丢弃法。


核心的前向传播代码如下所示:


def net(X):
X = X.reshape((-1, num_inputs))
H1 = relu(X @ W1 + b1)
if autograd.is_training(): # 仅在训练时应用丢弃
H1 = dropout(H1, drop_prob1)
H2 = relu(H1 @ W2 + b2)
if autograd.is_training():
H2 = dropout(H2, drop_prob2)
return (H2 @ W3 + b3)

注意,我们通常不会对最终的输出层应用丢弃法,因为输出层直接用于预测,丢弃部分类别信息会导致预测不稳定。

训练与结果分析
现在,我们可以开始训练这个集成了丢弃法的模型。训练过程与普通模型没有本质区别。

训练完成后,我们观察到一个有趣的现象:测试精度有时会高于训练精度。这是因为在训练过程中,丢弃法引入了噪声,使得模型在训练集上的表现(训练精度)被有意地“压低”了,从而提高了模型的泛化能力,使得在未见过的测试集上表现更好。

为了验证丢弃法的效果,我们可以将其与不使用丢弃法的相同模型进行对比。


以下是可能观察到的结果:

- 使用丢弃法:训练精度增长较慢,最终训练精度可能略低,但训练精度与测试精度之间的差距较小,模型更稳定。
- 不使用丢弃法:训练精度可能很快达到很高的水平,但训练精度与测试精度之间可能存在较大差距,表明模型存在过拟合风险。

在我们的示例中,由于模型和数据集相对简单,丢弃法带来的改善可能不明显。要看到更显著的效果,可以尝试构建更复杂的模型(例如,增加隐藏层数或每层的神经元数量),此时丢弃法作为正则化手段的优势会更加突出。


在深度学习框架中使用丢弃法
手动实现有助于理解原理,但在实际项目中,我们更倾向于使用深度学习框架提供的高效实现。

例如,在PyTorch中,使用丢弃法非常简单:

import torch.nn as nn

# 在定义网络时直接添加Dropout层
net = nn.Sequential(
nn.Flatten(),
nn.Linear(784, 256),
nn.ReLU(),
nn.Dropout(dropout1), # 添加丢弃层
nn.Linear(256, 512),
nn.ReLU(),
nn.Dropout(dropout2), # 添加丢弃层
nn.Linear(512, 10)
)

框架中的 Dropout 层会在训练模式下自动执行丢弃操作,在评估(推理)模式下自动关闭,无需我们手动编写条件判断逻辑,非常方便。


总结与实践建议
本节课中我们一起学习了丢弃法这一重要的正则化技术。我们从原理出发,手动实现了丢弃函数,并将其应用于一个双层感知机中,最后学习了如何在深度学习框架中直接使用它。
总结关键点如下:
- 核心作用:丢弃法通过随机丢弃神经元来减少过拟合,增强模型的泛化能力。
- 应用时机:仅在训练阶段使用,推理阶段应关闭。
- 超参数选择:丢弃概率是一个重要的超参数。对于不同复杂度的层可以使用不同的概率,常见的选择范围在0.2到0.9之间。更复杂的层通常搭配更高的丢弃概率。
- 实践建议:
- 当发现模型在训练集上表现很好,但在验证集上表现较差(即存在较大泛化误差)时,可以考虑使用丢弃法。
- 可以尝试在隐藏层的输出后插入
Dropout层。 - 丢弃法常与权重衰减等其他正则化方法结合使用。

现在,你可以尝试在更复杂的模型上应用丢弃法,观察它对防止过拟合、提升模型泛化性能的具体效果。

课程 P34:34. 梯度爆炸与消失 🧨➡️👻

在本节课中,我们将要学习深度神经网络训练中的两个核心挑战:梯度爆炸与梯度消失。理解这两个问题的成因和影响,是构建稳定、可训练的深度模型的基础。
数值稳定性问题
让我们从数值稳定性开始讨论。

考虑一个简单的深度神经网络。该网络共有 D 层。第 t 层的输入记作 h_{t-1},输出是 h_t。每一层都有一个函数 f_t,负责将输入 h_{t-1} 转换为输出 h_t。给定输入 x,网络的最终输出为 y。计算过程是:首先将 x 输入到第一层 f_1,依次经过各层直到最后一层 f_D,最后通过一个损失函数 l。

我们关注的是计算损失 l 相对于第 t 层参数 w_t 的梯度。根据链式法则,我们有:
公式:
∂l/∂w_t = (∂l/∂h_D) * (∂h_D/∂h_{D-1}) * ... * (∂h_{t+1}/∂h_t) * (∂h_t/∂w_t)
关键点在于,这个计算过程涉及 D - t 个雅可比矩阵的连续乘法。


这种连续的矩阵乘法可能会引发严重问题。第一个问题是梯度爆炸。当这些矩阵中的元素值普遍大于1时,经过多次连乘,梯度值会变得极其巨大。第二个问题是梯度消失。当矩阵中的元素值普遍小于1时,经过多次连乘,梯度值会趋近于零。这两个问题都会导致模型训练失败。
梯度爆炸问题
上一节我们介绍了梯度问题的根源,本节中我们来看看梯度爆炸的具体表现。


让我们考虑一个具体的多层感知机(MLP)例子。为了简化,我们忽略偏置项。对于第 t 层,其计算为:
h_t = σ(W_t * h_{t-1})
其中 σ 是激活函数,W_t 是权重矩阵。
计算该层的梯度时,我们需要计算激活函数的梯度 diag(σ')(一个对角矩阵)与权重矩阵 W_t 的乘积。在反向传播中,从顶层到底层,我们需要连续进行 D-t 次这样的矩阵乘法。

例如,假设我们使用 ReLU 作为激活函数,即 σ(x) = max(0, x)。其导数为:当 x > 0 时为1,否则为0。那么,在反向传播的连乘链中,如果权重矩阵 W_t 中的元素普遍大于1,并且网络很深(D-t 很大),这些大于1的数值经过多次相乘,会导致梯度值呈指数级增长,从而引发梯度爆炸。

以下是梯度爆炸带来的主要问题:
- 数值溢出:梯度值可能超过浮点数所能表示的最大范围(如
inf或NaN),导致计算完全失效。 - 训练不稳定:即使梯度没有溢出,过大的梯度值也会对学习率极其敏感。如果学习率设置不当,权重更新会剧烈波动,可能瞬间破坏模型已经学到的特征,使训练过程震荡甚至发散。
- 学习率调整困难:由于梯度值可能在训练过程中剧烈变化,很难找到一个固定的、合适的学习率,通常需要复杂的自适应学习率算法。
梯度消失问题
了解了梯度爆炸,我们再来看看它的“孪生兄弟”——梯度消失问题。


我们继续使用之前的 MLP 例子,但这次将激活函数改为 Sigmoid 函数。Sigmoid 函数的定义为:
公式:
σ(x) = 1 / (1 + e^{-x})
其导数可以表示为:
公式:
σ'(x) = σ(x) * (1 - σ(x))
Sigmoid 函数及其导数的图像显示,当输入 x 的绝对值较大时(例如大于4),其导数值会变得非常小,趋近于0。

在反向传播中,每一层都会乘上一个由 Sigmoid 导数构成的对角矩阵,其元素值都很小(例如 0.04)。当网络很深时,这些小于1的小数值经过 D-t 次连乘,梯度会以指数速度衰减到接近零。
以下是梯度消失带来的主要问题:

- 训练停滞:梯度值变得极小,以至于在浮点数精度下被舍入为零。权重更新量也为零,模型参数无法得到有效更新,训练进程停滞。
- 深层网络难以训练:梯度消失的影响在网络的底层(靠近输入层)尤为严重。因为反向传播路径更长,连乘次数更多。这导致只有顶层的几层(靠近损失函数)能得到有效的梯度更新,而底层的参数几乎不变。这相当于一个深度网络只有最上面几层在学习,无法发挥深度模型的优势。
- 收敛缓慢:即使梯度没有完全消失,过小的梯度也会导致权重更新极其缓慢,需要非常多的迭代次数才能收敛,大大增加了训练时间和计算成本。

总结
本节课中我们一起学习了深度神经网络训练中的两大难题:梯度爆炸与梯度消失。
- 它们的根源在于反向传播过程中,梯度需要通过链式法则进行多次矩阵连乘。
- 梯度爆炸通常由大于1的数值(如大权重、某些激活函数的导数)连乘导致,会引起训练不稳定和数值溢出。
- 梯度消失通常由小于1的数值(如 Sigmoid/Tanh 激活函数的导数)连乘导致,会使底层网络参数更新停滞,阻碍深层网络的训练。

理解这两个问题是迈向设计更稳定、更高效的深度神经网络架构(如使用 ReLU、残差连接、恰当的权重初始化等)的关键第一步。


课程 P35:激活函数与稳定训练 🧠

在本节课中,我们将学习神经网络中激活函数的作用,并探讨如何选择合适的激活函数以确保训练过程的稳定性。我们将从简单的线性函数开始,逐步分析Sigmoid、Tanh和ReLU等常用激活函数的特性。
线性激活函数
上一节我们介绍了恒等激活函数。本节中,我们来看看一个简单的激活函数:线性函数。
给定输入 x,线性激活函数定义为:
sigma(x) = alpha * x + beta
如果我们定义 h' = W^T * h_{t-1} 作为激活函数的输入,那么第 t 层的输出 h_t 为:
h_t = sigma(h')
现在,我们可以计算 h_t 的期望和方差。我们知道 h' 的均值为零,因此 h_t 的期望为 beta。根据我们之前的假设,我们希望均值为零,这意味着 beta 应该等于零。
类似地,对于方差,我们知道 h_t 是 h' 的线性组合。如果 beta 为零,并且我们选择了适当的权重初始化方法,那么 h_t 的方差为:
Var(h_t) = alpha^2 * Var(h')
为了使方差保持恒定,alpha 应该等于1。这意味着,为了满足我们之前的假设,线性激活函数需要调整为恒等函数。
在反向传播中,根据链式法则,损失函数 L 关于输入 h_{t-1} 的梯度为:
dL/dh_{t-1} = alpha * dL/dh'
同样,其期望为零,方差为:
Var(dL/dh_{t-1}) = alpha^2 * Var(dL/dh')
为了保持梯度稳定,我们再次得到 alpha 应为1。因此,线性激活函数实际上被限制为恒等函数。
常用激活函数分析
了解了线性函数的限制后,本节我们来看看深度学习中实际使用的激活函数,如Sigmoid、Tanh和ReLU。
这些函数在零点附近的行为可以用线性函数来近似。例如:
- Sigmoid函数 在零点附近的泰勒展开近似为:
sigmoid(x) ≈ 1/2 + x/4 + O(x^2) - Tanh函数 在零点附近的展开近似为:
tanh(x) ≈ 0 + x + O(x^2),这接近恒等函数。 - ReLU函数 在
x > 0时直接定义为:
ReLU(x) = x,这也是恒等函数。
由于神经网络权重通常被随机初始化为接近零的值,并且输入/输出具有零均值和小方差,因此在训练初期,激活函数的输入 x 也接近零。此时,Tanh和ReLU函数的行为都接近线性(恒等)函数。
然而,Sigmoid函数在零点附近并不接近恒等函数(其斜率约为1/4,而非1),这可能导致梯度消失问题。
修复Sigmoid函数
针对Sigmoid函数的问题,我们可以通过缩放来修复它。
以下是修复方法:
使用缩放后的Sigmoid函数:4 * sigmoid(x) - 2
经过缩放后,该函数在零点附近的近似变为:
4*(1/2 + x/4) - 2 = x
这使得它在零点附近的行为接近恒等函数。
从图像上可以看到,缩放后的Sigmoid函数(蓝色曲线)在零点附近与Tanh和ReLU函数(均接近恒等函数 y=x)的行为相似,而与原始Sigmoid函数(绿色曲线)有显著不同。

总结
本节课中,我们一起学习了激活函数对神经网络训练稳定性的影响。

我们首先分析了线性激活函数,发现为了保持前向传播和反向传播中信号的均值和方差稳定,它必须退化为恒等函数。接着,我们探讨了Sigmoid、Tanh和ReLU等非线性激活函数,指出在权重初始化合理的情况下,Tanh和ReLU在训练初期其行为接近恒等函数,有利于稳定训练。最后,我们介绍了通过缩放(4*sigmoid(x)-2)来修正Sigmoid函数,使其在零点附近也接近恒等函数,从而缓解梯度消失问题。
理解激活函数的这些特性,对于设计和初始化稳定的深度神经网络至关重要。
课程 P36:数值稳定性实验验证 📊

在本节课中,我们将通过一系列Python实验,验证之前讨论过的确保神经网络数值稳定性的方法。我们将观察不同初始化策略和激活函数如何影响深层网络中的梯度行为。

实验一:随机矩阵连乘的敏感性

上一节我们讨论了初始化的重要性,本节中我们来看看一个简单的实验:连续相乘多个随机矩阵。这个实验能直观展示数值对初始化方差的敏感性。
以下是实验步骤:
- 定义一个函数
prod_random_matrices,它接受两个参数:scale(控制随机矩阵的方差)和K(矩阵的形状)。 - 函数首先生成一个
K x K的单位矩阵Y。 - 然后进行100次循环,每次生成一个随机矩阵
W,其元素服从均值为0、方差为scale的正态分布,形状为K x K。 - 每次循环执行矩阵乘法
Y = W @ Y。 - 函数返回最终的
Y矩阵。
import numpy as np

def prod_random_matrices(scale, K):
Y = np.eye(K) # 初始化为单位矩阵
for _ in range(100):
W = np.random.randn(K, K) * scale # 生成随机矩阵
Y = W @ Y # 矩阵连乘
return Y
现在,我们使用不同的 scale 值来观察结果:

- 当
scale=0.5,K=4时,结果矩阵中的值都非常小(约1e-6)。 - 当
scale=0.7,K=4时,结果矩阵中的值变得极大(约1e+10)。
这个实验表明,即使初始化方差仅有微小变化,在经过多次矩阵乘法后,输出值也会产生数量级上的巨大差异。因此,我们必须谨慎选择初始化参数。


实验二:多层感知机(MLP)的梯度分析
接下来,我们将分析在一个深层MLP中,梯度如何随层数传播。这有助于我们理解梯度消失或爆炸问题。
以下是核心实验函数 synthetic_grad 的步骤:
- 函数接受参数:
K(层大小)、sigma(激活函数)、sigma_prime(激活函数的梯度函数)和get_weight(权重初始化函数句柄)。 - 重复实验10次并取平均,以减少随机性。
- 在每次重复中,随机生成一个输入向量
x。 - 模拟一个50层的网络。对于每一层:
- 使用
get_weight函数初始化权重矩阵w。 - 计算该层的激活前值:
a = w @ h(h初始为x)。 - 计算该层的梯度:
grad = sigma_prime(a) * (w.T @ y)(y初始为输出层的单位矩阵,通过链式法则反向传播)。 - 更新
y为当前层的梯度,更新h为当前层的激活值sigma(a),用于下一层计算。
- 使用
- 记录并返回这10次重复中,最终梯度绝对值的平均值。
def synthetic_grad(K, sigma, sigma_prime, get_weight, repeats=10):
grad_vals = []
for _ in range(repeats):
x = np.random.randn(K, 1)
h = x
y = np.eye(K) # 假设输出梯度为单位矩阵
for _ in range(50): # 50层网络
w = get_weight(K)
a = w @ h
grad = sigma_prime(a) * (w.T @ y) # 链式法则
y = grad
h = sigma(a)
grad_vals.append(np.abs(y).min())
return np.mean(grad_vals)
使用ReLU激活函数
我们首先使用ReLU作为激活函数,其梯度函数为:
relu_prime(x) = 1 if x > 0 else 0
我们尝试不同的权重初始化方差(scale):

# 权重初始化函数:正态分布
def get_weight_normal(scale, K):
return np.random.randn(K, K) * scale
scales = [0.1, 0.2, 0.4, 0.8]
for scale in scales:
avg_grad = synthetic_grad(100, np.maximum(0, x), relu_prime, lambda K: get_weight_normal(scale, K))
print(f"方差 {scale}: 平均梯度最小值 = {avg_grad:.2e}")
结果示例:
- 方差 0.1: 梯度极小 (
~1e-9),可能梯度消失。 - 方差 0.2: 梯度值合理 (
~0.01)。 - 方差 0.4/0.8: 梯度极大 (
~1e+20),发生梯度爆炸。
结论是,对于ReLU网络,存在一个狭窄的“合理”初始化方差范围(如0.2),过大或过小都会导致梯度不稳定。
使用Xavier初始化

为了解决上述问题,我们采用Xavier初始化,它根据输入和输出的维度调整方差。
对于均匀分布,权重范围的计算公式为:
limit = sqrt(6 / (fan_in + fan_out))
weight ~ Uniform(-limit, limit)

# Xavier均匀分布初始化
def get_weight_xavier(K):
limit = np.sqrt(6 / (K + K)) # 假设fan_in = fan_out = K
return np.random.uniform(-limit, limit, (K, K))
avg_grad = synthetic_grad(100, np.maximum(0, x), relu_prime, get_weight_xavier)
print(f"Xavier初始化下,平均梯度最小值 = {avg_grad:.2e}")
使用Xavier初始化后,梯度值 (~1e-9) 虽然仍偏小,但相比不稳定的正态分布初始化,它提供了一个更可靠、更不易爆炸的起点。

使用Sigmoid激活函数
现在,我们观察经典的Sigmoid激活函数,其梯度公式为:
sigmoid_prime(x) = sigmoid(x) * (1 - sigmoid(x))
def sigmoid(x):
return 1 / (1 + np.exp(-x))
def sigmoid_prime(x):
s = sigmoid(x)
return s * (1 - s)

scales = [0.1, 0.2, 0.4, 0.8]
for scale in scales:
avg_grad = synthetic_grad(100, sigmoid, sigmoid_prime, lambda K: get_weight_normal(scale, K))
print(f"方差 {scale} (Sigmoid): 平均梯度最小值 = {avg_grad:.2e}")
结果:无论方差取0.1到0.8中的哪个值,梯度都极小 (~1e-33 到 ~5e-5)。这清晰地展示了Sigmoid函数在深层网络中容易导致梯度消失的问题。
改进:缩放Sigmoid函数
如前所述,我们可以通过缩放Sigmoid函数来缓解梯度消失。例如,使用 4 * sigmoid(x) - 2。
def scaled_sigmoid(x):
return 4 * sigmoid(x) - 2

def scaled_sigmoid_prime(x):
return 4 * sigmoid_prime(x) # 梯度也相应放大4倍
for scale in scales:
avg_grad = synthetic_grad(100, scaled_sigmoid, scaled_sigmoid_prime, lambda K: get_weight_normal(scale, K))
print(f"方差 {scale} (缩放Sigmoid): 平均梯度最小值 = {avg_grad:.2e}")
结果:使用缩放后的Sigmoid函数,梯度值变得合理得多(例如,方差0.1时梯度约为 0.01)。即使方差增大到0.8,梯度也保持在可控范围内。这表明对激活函数进行适当的缩放,能显著改善梯度流,增强数值稳定性。
总结 🎯
本节课中我们一起学习了如何通过实验验证神经网络的数值稳定性:
- 矩阵连乘实验表明,初始化方差微小的变化会被深层计算放大,强调初始化的重要性。
- MLP梯度分析实验揭示:
- 使用ReLU时,初始化方差需精心选择,Xavier初始化是一个更稳健的方案。
- 使用标准Sigmoid函数会导致严重的梯度消失。
- 通过对Sigmoid函数进行缩放(如
4*sigmoid(x)-2),可以有效地缓解梯度消失问题,获得更稳定的梯度。

这些实验巩固了我们的理解:通过结合恰当的权重初始化方法(如Xavier)和激活函数选择/调整,我们可以有效地控制深层神经网络中的梯度,避免其消失或爆炸,从而确保模型能够成功训练。

课程P37:深度学习硬件指南 🖥️💡

在本节课中,我们将学习深度学习任务中硬件(特别是CPU和GPU)的基础知识、性能差异以及如何高效利用它们。我们将从个人可购买的硬件讲起,深入探讨CPU和GPU的架构特点,并比较它们的性能与适用场景。

CPU架构与性能优化 🧠
上一节我们介绍了课程概述,本节中我们来看看中央处理器(CPU)的架构以及如何优化其性能。
CPU是计算机的核心处理单元。以英特尔i7 CPU为例,其芯片区域包含集成的图形处理器(GPU)、物理核心、多级缓存以及与内存的接口。集成GPU通常较弱,不用于深度学习训练,但可用于推理任务。
CPU通常有多个物理核心(例如4个),每个核心拥有自己的L1和L2缓存,所有核心共享一个L3缓存。内存带宽约为每秒30GB。访问不同层级存储的速度差异巨大:访问L1缓存的速度比访问主内存快约200倍。
为了充分利用CPU性能,关键在于减少数据移动。这可以通过优化内存局部性来实现。
以下是两种主要的内存局部性类型:

- 时间局部性:如果数据在不久的将来会被再次使用,就应将其保留在缓存中。
- 空间局部性:如果使用了某个内存地址的数据,那么其邻近地址的数据也很有可能被使用,CPU可以据此预取数据。
让我们通过一个案例来理解空间局部性的应用。假设一个矩阵按行主序存储,这意味着内存中连续存储的是每一行的元素。在这种情况下,按行顺序访问元素(具有良好的空间局部性)会比按列顺序访问快得多。因为CPU以缓存行(通常为64字节)为单位读取数据。按行访问时,一次读取就能获取多个后续需要的元素;而按列访问时,每次可能都需要发起新的内存请求,导致效率低下。
除了内存局部性,另一种提升CPU性能的关键技巧是并行化。现代CPU拥有多个核心,可以同时执行多个线程。例如,服务器CPU可能拥有数十个物理核心。为了充分利用所有核心,我们需要使用至少与物理核心数相等的线程数。
注意:英特尔CPU的超线程技术会将一个物理核心虚拟为两个逻辑核心。但对于计算密集型任务,使用的线程数最好等于物理核心数,以避免寄存器资源的争抢。
让我们看一个向量加法的案例。在Python中,直接使用NumPy进行向量运算 c = a + b 远比使用 for 循环逐个元素相加要快。这是因为前者将整个操作作为一个整体交给底层优化过的C/C++代码执行,减少了Python解释器的开销,并且更易于实现并行化。在C++中,可以简单地使用OpenMP指令来并行化这样的循环。
// 使用OpenMP并行化的向量加法示例
#pragma omp parallel for
for (int i = 0; i < n; ++i) {
c[i] = a[i] + b[i];
}
GPU架构与性能优化 ⚡
上一节我们探讨了CPU的优化技巧,本节中我们来看看图形处理器(GPU)的架构及其性能优化策略。
GPU的设计目标与CPU不同。以NVIDIA Titan X GPU为例,它拥有超过2000个小型、高效的计算核心,专门用于执行大量并行的简单算术运算。它拥有一个较小的共享L2缓存(如3MB),但配备了极高的内存带宽(如480GB/s)。

鉴于这种架构,优化GPU性能的策略与CPU类似但也有所不同:
- 大规模并行:GPU需要成千上万个线程才能充分发挥性能。这意味着提交给GPU的任务(例如处理的向量或矩阵)必须足够大。处理一个仅有100个元素的向量对GPU来说是大材小用。
- 极重视内存局部性:由于GPU缓存较小,高效的内存访问模式至关重要,以减少对高延迟全局内存的访问。
- 控制流简单:GPU核心设计简单,不擅长处理复杂的条件分支和控制系统(如运行操作系统或Web服务器)。深度学习中的矩阵运算等规则计算非常适合GPU。

对于个人购买GPU,有一个简单的指南:在同一代产品系列内,GPU的计算能力(以GFLOPS衡量)与价格大致呈线性关系。然而,新一代产品的性能通常远优于旧一代。因此,在预算允许的情况下,应优先购买新一代的型号。
CPU与GPU对比 🆚
上一节我们分别了解了CPU和GPU,现在我们来系统地比较它们。
以下是典型配置与高端配置的对比:
| 特性 | 典型CPU | 高端CPU | 典型GPU | 高端GPU |
|---|---|---|---|---|
| 核心数量 | 6-16核 | 可达64核 | 2000+核心 | 4000+核心 |
| 计算能力 | 0.2-1 TFLOPS | 1+ TFLOPS | 10+ TFLOPS | 30+ TFLOPS |
| 内存容量 | 16-64 GB | 可达1 TB | 8-16 GB | 32 GB |
| 内存带宽 | 30-50 GB/s | 可达100 GB/s | 300-500 GB/s | 1 TB/s |
| 控制流能力 | 强,适合复杂逻辑 | 强 | 弱,适合规则计算 | 弱 |
关键差异总结:
- 计算能力:GPU的并行计算能力远超CPU,可达百倍之差。
- 内存:CPU可支持更大的内存容量,而GPU内存有限,需要精打细算。
- 带宽:GPU拥有极高的内存带宽以喂饱其海量计算核心。
- 适用性:CPU擅长复杂控制流和通用任务;GPU擅长大规模数据并行计算。
CPU和GPU通过PCIe总线连接,其带宽(例如PCIe 3.0 x16约为16GB/s)远低于GPU自身的内存带宽。因此,应尽量避免在GPU和CPU之间频繁复制数据,否则PCIe带宽将成为性能瓶颈。在深度学习训练中,应尽可能将整个模型和数据保持在GPU内存中。

其他硬件与编程生态 🌐
除了主流的英特尔CPU和英伟达GPU,市场上还有其他选择。
CPU方面:还有AMD的CPU和ARM架构的处理器(广泛应用于移动设备)。
GPU方面:除了英伟达,还有AMD的GPU、英特尔的集成GPU,以及移动设备上的ARM Mali、高通Adreno等GPU。

不同的硬件对应不同的编程生态:
- CPU编程:通常使用C++等语言,编译器成熟,在不同CPU上都能获得稳定性能。
- GPU编程:
- 英伟达GPU:使用CUDA平台,生态成熟,工具链完善,是深度学习的首选。
- 其他GPU:可使用OpenCL,它是一种开放的并行计算标准,但不同厂商的驱动和工具链质量参差不齐,可能影响性能和使用体验。

总结 📚
本节课中我们一起学习了深度学习硬件的核心知识。
我们首先从个人硬件选型入手,然后深入分析了CPU和GPU的架构差异:CPU核心少而强,擅长复杂控制流;GPU核心多而专,擅长大规模并行计算。我们探讨了优化两者性能的关键,即内存局部性和并行化,并指出GPU对任务规模和内存访问模式有更高要求。接着,我们系统比较了CPU和GPU在计算能力、内存、带宽等方面的优劣,并强调了避免CPU与GPU间频繁数据拷贝的重要性。最后,我们简要介绍了其他硬件选项(如AMD、ARM)及其编程生态(CUDA vs. OpenCL)。

理解这些硬件特性,能帮助你在实践中更好地配置资源、编写高效代码,并选择适合的硬件进行深度学习开发。

课程P38:高级深度学习硬件 🧠💻
在本节课中,我们将学习除了CPU和GPU之外,其他用于深度学习的专用硬件。我们将探讨DSP、FPGA和AI加速器(如TPU)的工作原理、优势、劣势以及它们的应用场景。
更多硬件选择
上一节我们介绍了CPU和GPU,本节中我们来看看其他可用于深度学习的硬件。

例如,高通的骁龙845芯片集成了多个组件。右上角是GPU,右下角是CPU。在GPU和CPU之间,有一个ISP(图像信号处理器)。在ISP左侧,有一个DSP(数字信号处理器)。DSP和ISP占据了芯片的很大面积,它们功能强大,可以用于深度学习任务。

数字信号处理器 (DSP) 🎛️
DSP是专门为数字信号处理算法设计的处理器。它能高效执行矩阵点乘、卷积和快速傅里叶变换等操作,前两项与深度学习密切相关。
DSP的设计使其在某些场景下精度更高,性能可能达到GPU的五倍甚至更多。其主要优点是功耗较低。在手机等移动设备上运行任务时,使用DSP而非GPU或CPU可以节省电力,避免设备过热,从而在电池模式下执行更多任务。

DSP采用了一种名为超长指令字(VLIW)的架构。在CPU上,每条指令可能只执行几十次浮点乘法。而在DSP上,每条指令可以执行大量的浮点乘法。因此,即使频率较低,DSP的性能也能超越CPU。
以下是DSP的主要优缺点:
- 优点:通常比GPU更快,更节能。
- 缺点:编程相对复杂,调试困难,且不同芯片厂商提供的编译工具链质量参差不齐。

现场可编程门阵列 (FPGA) 🔧
FPGA与我们常用的CPU和GPU有本质不同。它包含大量可编程逻辑块,我们可以重新配置这些逻辑块之间的连接。

在CPU和GPU上,我们编写并执行程序。但对于FPGA,你需要编写一个程序来描述硬件本身。编译过程实际上是在改变FPGA的硬件结构,这可能需要数小时甚至数天。编译完成后,你就得到了一个为特定任务定制的硬件,然后在其上运行程序。这项工作通常使用VHDL或Verilog等硬件描述语言完成。
FPGA的优势在于,它可以为特定应用定制硬件,去除不必要的部分,从而获得比通用硬件更高的效率。然而,它并不常见,因为编程和调试非常困难,且编译时间可能很长。

AI加速器与TPU 🚀

在深度学习领域,许多公司都在设计自己的AI芯片。其中,谷歌的TPU是最早且最成功的案例之一。

谷歌TPU的性能可以媲美高端GPU,能提供约100 TFLOPS的算力。但关键优势在于成本:一个高端GPU可能售价约10,000美元,而一个TPU的制造成本可能只有500美元,存在20倍的差距。这也是谷歌部署TPU的主要原因。
TPU的核心是一个名为脉动阵列的结构。它由一个处理单元(PE)组成的二维网格构成,配有输入和输出缓冲区。这个设计专为矩阵乘法优化。
我们来通过一个例子看看脉动阵列如何工作。假设我们要计算 y = Wx,其中 W 是一个3x3的矩阵,x 是一个3x2的矩阵。

- 数据加载:首先,将权重矩阵 W 的每个元素预先放入对应的PE中。同时,将输入矩阵 x 以特定形式对齐到输入缓冲区。
- 计算与数据流动:计算开始后,输入数据从缓冲区向左一步步移动。同时,每个PE将接收到的输入与存储的权重相乘,并将部分结果向下移动。
- 结果输出:经过多个时钟周期后,完整的计算结果 y 会从阵列底部输出。
脉动阵列通常具有很高的效率。对于大规模矩阵乘法,可以根据阵列大小对矩阵进行分块处理。虽然初始延迟可能较高,但在处理大批量输入时,吞吐量会非常可观。
需要注意的是,脉动阵列主要优化矩阵乘法。对于Sigmoid等其他操作,可能需要额外的专用芯片。此外,目前的硬件对稀疏计算(即矩阵中包含大量零)的优化有限。专门的稀疏计算芯片虽然性能可能与密集计算芯片相似,但功耗可以降低约10倍,这对移动设备极具吸引力。

硬件对比与总结 📊
我们可以根据性能和灵活性对硬件进行分类:
- X轴(性能):左侧功耗高、速度快,右侧功耗低、速度慢。
- Y轴(灵活性):表示编程、调试和部署的难易程度。
以下是各类硬件的定位:
- CPU:最容易使用,通用性强,支持广泛。
- GPU:比CPU更快,但编程更难(如CUDA, OpenCL)。
- DSP:主要用于手机等小型设备,能效比可能高于手机GPU,但编程复杂。
- FPGA:可定制硬件以实现高效率,但需要深厚的硬件知识,编程和调试极难。
- AI加速器 (如TPU):专为特定算法(如矩阵乘法)设计,效率极高,但灵活性和通用性较差,目前正处于快速发展阶段。

本节课中我们一起学习了DSP、FPGA和AI加速器等高级深度学习硬件。我们了解了它们的基本原理、各自的优势(如高性能、低功耗)和劣势(如编程复杂性)。理解这些硬件的特点,对于从事深度学习架构研究或将模型部署到实际硬件中至关重要。未来一年,预计AI加速器领域将会有更多进展。
课程 P39:经验风险与期望风险 📊

在本节课中,我们将学习机器学习中一个核心概念:经验风险与期望风险。我们将探讨当训练数据与测试数据分布不一致时会发生什么,并介绍几种常见的分布偏移问题及其应对思路。
泛化性能概述 🎯
上一节我们介绍了机器学习的基本目标。本节中,我们来看看模型在训练集和测试集上表现不一致的问题,即泛化性能问题。


仅仅因为在训练集上表现良好,并不能保证在测试集上也会表现良好。
以下是泛化问题的常见表现:
- 训练误差通常低于测试误差。
- 对于更复杂的模型(如深层神经网络),这种差距可能更明显。
- 问题不仅存在于图像识别,也存在于自然语言处理等任务中。

经验风险与期望风险 📈
上一节我们了解了泛化问题的现象,本节中我们来深入其背后的数学概念:经验风险与期望风险。

我们有一个真实的数据分布 p(x, y)。在训练时,我们只能从一个数据集中采样,并最小化经验风险(Empirical Risk):
R_emp(f) = (1/m) * Σ_{i=1}^{m} L(f(x_i), y_i)
然而,我们真正关心的是模型在未知数据上的表现,即期望风险(Expected Risk):
R_exp(f) = E_{(x,y) ~ p}[L(f(x), y)]
我们的目标是使期望风险最小化,但我们只能通过优化经验风险来间接实现。


分布偏移问题 🔄
上一节我们定义了风险,本节中我们来看看导致期望风险与经验风险不一致的主要原因:数据分布偏移。
当训练数据分布 p_train(x, y) 与测试数据分布 p_test(x, y) 不同时,就会发生分布偏移。主要有以下几种类型:
以下是三种常见的分布偏移:
- 协变量偏移(Covariate Shift):输入特征
x的分布发生变化(p_train(x) ≠ p_test(x)),但条件分布p(y|x)保持不变。例如,用白天照片训练的模型去识别夜间照片。 - 标签偏移(Label Shift):标签
y的分布发生变化(p_train(y) ≠ p_test(y)),但条件分布p(x|y)保持不变。这是协变量偏移的一个特例。 - 非平稳环境(Nonstationary Environments):数据分布随时间或环境持续变化。例如,金融市场的预测模型或推荐系统。
应对策略与修正方法 🛠️
上一节我们识别了问题,本节中我们探讨一些应对分布偏移的思路和策略。
解决泛化问题的核心思路之一是提高模型的鲁棒性,使其对输入的小变化不敏感。
以下是两种常用的技术思路:
- 添加噪声:在训练时向输入数据或网络激活中添加随机噪声,这相当于一种正则化,可以迫使模型学习更稳健的特征。
- 正则化:通过显式的约束(如权重衰减)或隐式的方法(如Dropout)来限制模型复杂度,防止其过度拟合训练数据中的特定噪声。
对于已知的协变量偏移,存在一种数学修正方法。其核心思想是重要性加权:在计算经验风险时,为每个训练样本赋予一个权重 β_i,该权重正比于测试分布与训练分布在输入 x_i 处的密度比。
β_i = p_test(x_i) / p_train(x_i)
修正后的风险为:R_weighted(f) = (1/m) * Σ_{i=1}^{m} β_i * L(f(x_i), y_i)
实践中,难点在于估计密度比 β_i。


总结 📝
本节课中,我们一起学习了机器学习中的核心挑战——泛化问题。
- 我们明确了经验风险(在训练集上的平均损失)与期望风险(在真实数据分布上的预期损失)的区别。
- 我们探讨了导致两者差异的主要原因:数据分布偏移,包括协变量偏移、标签偏移和非平稳环境。
- 我们介绍了一些提高模型泛化能力的思路,如添加噪声和正则化。
- 最后,我们简要了解了针对协变量偏移的重要性加权修正方法。

理解这些概念对于在实践中构建稳健的机器学习系统至关重要。
课程 P4:4. 线性代数基础 🧮
在本节课中,我们将要学习线性代数的核心概念。线性代数是深度学习的基石,因为它擅长表示和计算深度学习所需的大量函数与数据。我们将从最基础的概念开始,逐步构建理解。
概述
线性代数提供了处理多维数据的数学框架。我们将从标量和向量开始,然后介绍矩阵及其运算,最后讨论一些特殊的矩阵及其在计算中的意义。
标量 🔢
标量是我们熟悉的单个数字。我们可以对它们进行基本的数学运算。
以下是标量的基本操作:
- 加法与乘法:
c = a + b或c = a * b。 - 函数应用:计算如
sin(a)或cos(a)。 - 绝对值:
|a|表示 a 的长度,如果a > 0则为a,否则为-a。
标量的绝对值满足两个重要性质:
- 三角不等式:
|a + b| ≤ |a| + |b|。 - 乘积的绝对值:
|a * b| = |a| * |b|。
向量 ➡️

上一节我们介绍了标量,本节中我们来看看向量。向量是一组有序排列的数字。我们可以对向量进行逐元素的运算。
以下是向量的基本操作:
- 向量加法:
c = a + b,其中c_i = a_i + b_i。 - 标量乘法:
c = α * b,其中c_i = α * b_i。 - 逐元素函数:例如,对向量
a应用符号函数sign(a)。

向量的长度(范数)
衡量向量长度有多种方式,最常用的是欧几里得范数(L2范数):
||a||₂ = sqrt(∑_i a_i²)
在二维空间中,这对应勾股定理。范数满足以下性质:
||a|| ≥ 0。||a + b|| ≤ ||a|| + ||b||(三角不等式)。||α * a|| = |α| * ||a||。
另一种常见的范数是曼哈顿距离(L1范数):
||a||₁ = ∑_i |a_i|
它表示在网格状路径(如城市街区)上移动的总距离。
向量运算的几何意义
向量加法可以几何地理解为将两个向量首尾相接。标量乘法则是将向量按系数进行拉伸或收缩。这种表示方式天然适合并行计算,因为对每个元素的操作是独立的,这正是GPU(图形处理器)所擅长的。
点积与正交性 ✖️

一个非常重要的向量运算是点积。两个向量 a 和 b 的点积定义为:
aᵀb = ∑_i a_i * b_i
如果两个向量的点积为零,即 aᵀb = 0,我们称它们为正交向量。
正交性有一个有用的性质:如果向量 a 和 b 都与另一个向量 c 正交,那么它们的任意线性组合 (αa + βb) 也与 c 正交。证明如下:
(αa + βb)ᵀc = α(aᵀc) + β(bᵀc) = α*0 + β*0 = 0
这种线性性质在未来非常有用。


矩阵 🧱

现在,让我们从向量扩展到矩阵。矩阵是排列成矩形阵列的数字集合。它们也构成一个向量空间,支持加法和标量乘法。

以下是矩阵的基本操作:
- 矩阵加法:
C = A + B,按元素相加。 - 标量乘法:
C = α * B,每个元素乘以α。 - 逐元素函数:如
sin(A)或cos(A)作用于每个元素。
矩阵乘法
矩阵最强大的操作是矩阵乘法,这是线性代数中“魔法”发生的地方。
- 矩阵乘以向量:将一个
m×n的矩阵A乘以一个n维向量x,得到一个m维向量b。
b_i = ∑_j A_ij * x_j
这可以理解为矩阵的每一行与向量做点积。矩阵乘法会对向量空间进行线性变换(如旋转、缩放、剪切)。 - 矩阵乘以矩阵:将一个
m×n的矩阵A乘以一个n×p的矩阵B,得到一个m×p的矩阵C。
C_ik = ∑_j A_ij * B_jk
这相当于用矩阵A分别乘以矩阵B的每一列,然后将结果并排堆叠起来。

矩阵的范数
矩阵的范数需要定义,以确保乘法不等式 ||A * B|| ≤ ||A|| * ||B|| 成立。常见的矩阵范数有:
- 谱范数:与矩阵的最大特征值相关(当向量使用L2范数时)。
- 弗罗贝尼乌斯范数:将矩阵视为一个长向量,计算其L2范数。
||A||_F = sqrt(∑_i ∑_j A_ij²)

特征值与特征向量 🔍
我简要提到了特征值。特征向量是那些经过矩阵变换后方向不变(仅被拉伸或压缩)的向量。满足:
A * x = λ * x
其中 λ 称为特征值。对于对称矩阵,总可以找到一组完整的特征向量来分解矩阵。
特殊矩阵 🎭

除了通用矩阵,还有一些具有特殊性质的矩阵在深度学习中很重要。
以下是几种重要的特殊矩阵:
- 对称矩阵:满足
A_ij = A_ji。其元素关于主对角线对称。 - 反对称矩阵:满足
A_ij = -A_ji。其主对角线元素必须全为零。 - 正定矩阵:推广了向量长度的概念。对于所有非零向量
x,满足xᵀ A x > 0。若等号可以取零,则为半正定矩阵。 - 正交矩阵:其所有行(或列)彼此正交且长度为1。满足
U * Uᵀ = I(单位矩阵)。正交矩阵的逆等于其转置。 - 置换矩阵:每行和每列有且仅有一个元素为1,其余为0。它用于对向量或矩阵的行/列进行重新排列。可以证明,置换矩阵是正交矩阵。
为什么关心这些?💻
我们关心这些线性代数概念,一个核心原因是计算效率。现代GPU(如图形处理器)拥有数千个小型处理核心,能够同时并行执行大量相同的操作(如向量和矩阵的逐元素运算)。线性代数的运算结构天然适合这种并行计算模式,使得大规模矩阵乘法等操作能够高效执行,这正是训练深度学习模型所必需的。

总结
本节课中我们一起学习了线性代数的基础知识。我们从标量和向量出发,学习了它们的运算和范数。然后引入了点积和正交性的概念。接着,我们扩展到矩阵,掌握了矩阵加法、乘法和范数。我们还探讨了特征值与特征向量的意义。最后,我们认识了几种特殊矩阵(对称、正交、置换等),并理解了线性代数与GPU并行计算之间的紧密联系,这为后续的深度学习学习奠定了坚实的数学基础。
课程 P40:协变量漂移 🎯

在本节课中,我们将学习机器学习中一个常见且重要的问题——协变量漂移。我们将了解它是什么,它如何在实际场景中导致模型失效,并学习一个核心的数学概念来应对它。
什么是协变量漂移?🤔
上一节我们介绍了课程主题,本节中我们来看看协变量漂移的具体定义。简单来说,协变量漂移是指模型训练时使用的数据分布(特征 X 的分布)与模型部署或测试时遇到的数据分布不一致的情况。一个关键的假设是,特征 X 与标签 Y 之间的关系 P(Y|X) 并没有改变。
一个直观的例子
假设我们用一个包含特定品种猫狗的数据集训练了一个图像分类器。如果我们将这个分类器部署到一个用户主要上传另一种外观迥异的猫的图片的环境中,分类器很可能会失败。这并不是因为猫狗的本质关系变了,而是因为我们遇到的“猫”这个特征(图片像素)的分布变了。
现实世界中的案例 📊
以下是协变量漂移在现实中导致问题的几个具体案例,每个案例都说明了忽视分布差异的后果。
案例一:搜索引擎与市场差异
假设你基于某个地区(如美国)的数据训练了一个搜索引擎相关性排名模型。当这个搜索引擎在加拿大、英国、澳大利亚等其他英语国家变得流行时,用户的搜索习惯和关注点(例如,更关注“皇家骑警”或“英女王”)会导致查询词的分布发生变化,从而使原有的排名模型效果下降。
案例二:语音识别与口音差异
一个用美国西海岸口音训练的语音识别系统,如果被部署到德克萨斯州,或者非英语母语者众多的地区,其识别准确率可能会显著降低。这是因为输入的语言信号(特征)分布发生了漂移。
案例三:医疗诊断与样本偏差
一个真实的案例是,一家创业公司试图开发一个通过血液检测前列腺癌的模型。他们很容易从患病的老年男性群体中获得血样,但难以获取健康男性的血样。于是,他们用大学校园里健康的年轻男性血样作为“健康”对照组。最终,训练出的分类器可能只是学会了区分“年轻人”和“老年人”,而不是“健康人”和“病人”,因为训练集(年轻健康男性 vs 老年患病男性)的特征分布与真实世界(各年龄段健康男性 vs 患病男性)的分布严重不匹配。
案例四:强化学习与策略变化
在强化学习中,智能体根据当前策略与环境交互收集数据,并更新策略。当策略变得更好后,环境状态或动作的分布也随之改变。如果用在旧分布下学到的策略来评估或应用于新分布,性能可能不再最优。
案例五:自学习数据库
一个在2017年用户访问模式上自我调优的数据库,如果被部署到2018年或2019年,由于用户行为模式可能已经改变(分布漂移),其自动优化的配置可能不再是最优的。
协变量漂移的数学形式 🧮
上一节我们看了一些案例,本节中我们来形式化地描述这个问题,并探讨解决方案的核心思想。
我们的目标是最小化模型在真实测试分布 Q 上的期望风险。设损失函数为 l,模型为 f,我们关心的是:
E_(x~Q)[l(f(x), y)]
然而,我们只有从训练分布 P 中采样的带标签数据。一个关键的假设是条件分布 P(Y|X) 和 Q(Y|X) 相同,即数据背后的因果关系未变。那么,我们可以对期望风险进行如下改写:
E_(x~Q)[l(f(x), y)] = ∫ l(f(x), y) dQ(x) = ∫ (dQ(x)/dP(x)) * l(f(x), y) dP(x) = E_(x~P)[β(x) * l(f(x), y)]
其中,β(x) = dQ(x)/dP(x) 被称为密度比率或重要性权重。
这个公式告诉我们,可以通过对训练集(来自P)中的每个样本根据其重要性权重 β(x) 进行重新加权,来模拟在测试分布(Q)上的损失。
如何估计密度比率?⚖️
直接分别估计分布 P 和 Q 然后计算比率通常非常困难且不稳定。更巧妙的方法是直接估计密度比 β(x)。
以下是核心思路:
- 我们将从
P中采样的数据标记为标签z = 0。 - 我们将从
Q中采样的数据(通常是无标签的测试数据)标记为标签z = 1。 - 训练一个二元分类器来区分数据是来自
P还是Q。 - 对于一个样本
x,如果这个分类器预测它来自Q的概率为D(x),那么根据贝叶斯定理,可以推导出密度比的估计为:
β(x) = dQ(x)/dP(x) ≈ (D(x) / (1 - D(x))) * (N_P / N_Q)
其中N_P和N_Q是来自两个分布的样本数量。当样本量足够大时,比率(N_P / N_Q)可以忽略或通过归一化处理。
这种方法避免了困难的密度估计问题,转而利用相对容易的分类任务来获得我们需要的权重。
总结 📝
本节课中我们一起学习了:
- 协变量漂移:训练与测试阶段输入特征分布
P(X)与Q(X)不同,而条件分布P(Y|X)保持不变的问题。 - 严重后果:它在搜索引擎、语音识别、医疗诊断、强化学习等多个领域都会导致模型在实际部署中失效。
- 核心数学工具:通过重要性加权来纠正漂移,即给训练损失加上权重
β(x) = dQ(x)/dP(x)。 - 实用估计方法:通过训练一个区分训练集和测试集数据的二元分类器,来直接估计重要性权重
β(x),这是一个更稳健的方法。

理解并处理协变量漂移,是构建鲁棒、实用机器学习系统的重要一步。
课程P41:协变量偏移与偏差 🧠
在本节课中,我们将探讨机器学习模型在现实部署中可能遇到的“协变量偏移”问题,以及由此引发的性能偏差。我们将通过简单的例子和数学原理,理解为何模型在某些数据子集上表现会变差,并认识到这种偏差在数学上是难以完全避免的。

问题引入:训练集与测试集的差异

假设我们有一个用于区分猫和狗的图像分类器。我们的训练集数据分布如下:


然而,在真实部署(测试集)时,我们遇到的数据可能大不相同。测试集中可能包含更多灰色动物、更多猫,并且这些猫的外观也可能与训练时不同。

在这种情况下,我们不能期望系统表现良好。训练数据与测试数据分布的不匹配,是导致模型性能下降的一个核心原因。
上一节我们介绍了训练与测试数据分布不同这一现象,本节中我们来看看导致性能下降的具体数学原因。

性能下降的简单示例
性能变差至少有两个直观原因。我们通过一个简单的线性回归问题来说明。
第一种情况:数据点位置变化
假设我们有两种观测值。在训练时,数据点(用黑色十字表示)集中在某个区域,我们拟合出一条线性函数(实线),模型表现良好。

但在测试时,数据点的分布发生了偏移(例如,黑色十字出现在上方区域)。如果我们仍使用原模型(虚线),性能就会变差。理想情况下,我们应该根据测试分布重新训练,得到另一条实线。
第二种情况:数据特征分布变化
另一种常见情况是特征本身分布的变化。例如,训练数据集可能只包含在理想光照、角度下的“美丽”面孔(如来自IMDB数据库的明星头像)。

而在测试时,我们可能遇到光线较暗、角度不佳的普通面孔。这种协变量的偏移同样会导致模型性能显著下降。
数学原理:无偏差保护定理
现在,我们用一个简单的数学定理来量化这个问题,它被称为“无偏差保护定理”。

定理的核心思想是:如果一个估计器在平均性能上达到某个水平,但其性能在不同数据子集上存在波动(即方差),那么我们总能找到一个数据分布,使得模型在该分布上的性能比“方差除以均值”所度量的还要差。

以下是该关系的公式描述:

设模型在分布 p 上的期望损失(即平均性能)为 μ,其性能的方差为 σ²。
那么,总存在另一个数据分布 q,使得模型在 q 上的期望损失 L_q 满足:

L_q ≥ μ + σ² / μ
换句话说,性能的方差(σ²)除以平均性能(μ),给出了模型性能可能变差程度的一个下界。只要模型性能存在波动,我们就无法保证它在所有可能的数据分布上都有好表现。
以下是可能使性能变差的数据分布示例:
- 包含更多黑猫或灰猫的图片。
- 包含更多高个子或矮个子的狗的图片。
- 包含特定人口统计特征的面孔。

现实意义与总结
本节课我们一起学习了协变量偏移如何导致模型性能偏差。
这个定理对机器学习实践有重要警示:当你部署一个机器学习产品时,如果发现它在某些用户群体或数据子集上表现远差于平均水平,这并不一定是编程错误或道德偏见,而可能是数据分布差异和模型性能方差的直接数学结果。
理解这一点至关重要,它意味着:
- 收集具有广泛代表性的训练数据非常重要。
- 监控模型在不同子群体上的性能是必要的。
- 完全消除这种性能差异在数学上非常困难。

因此,作为工程师或研究者,我们需要管理好各方预期,并持续致力于改进模型的鲁棒性。
课程 P42:逻辑回归 🧠
在本节课中,我们将学习逻辑回归。逻辑回归是处理二分类问题的一种核心方法,它实际上是我们在多类分类中已接触过的 softmax 函数的一个特例。我们将从回顾多类分类开始,逐步推导出逻辑回归的公式,并理解其损失函数和概率输出的含义。
从多类分类到二分类 🔄
上一节我们介绍了多类分类。在多类分类中,我们使用 softmax 函数来获得经过校准的概率。对于一个输出向量 O,类别 Y 的概率为:

P(Y | O) = e^{O_Y} / Σ_j e^{O_j}
其对应的负对数似然损失函数形式为:
L = -log(e^{O_Y} / Σ_j e^{O_j}) = log(Σ_j e^{O_j}) - O_Y
本节中我们来看看,当类别减少到只有两个时,情况会如何简化。
逻辑回归的推导 📝
如果我们只有两个类别,例如类别 1 和类别 -1,softmax 函数会呈现出一种“加法不变性”。这意味着我们可以给所有输出加上一个常数 C,而概率保持不变。
P(Y=1 | O) = e^{O_1} / (e^{O_1} + e^{O_{-1}})
利用加法不变性,我们可以将其中一个输出设为 0 以简化计算。这里,我们选择将 O_{-1} 设为 0。
P(Y=1 | O) = e^{O_1} / (1 + e^{O_1})
对上式分子分母同时除以 e^{O_1},我们得到:
P(Y=1 | O) = 1 / (1 + e^{-O_1})
这个函数就是逻辑函数(Logistic Function),也称为 Sigmoid 函数。现在,我们可以统一地表示两个类别的概率。对于任意类别标签 Y(取值为 1 或 -1),其概率可以写为:
P(Y | O) = 1 / (1 + e^{-Y * O})
这个公式非常方便,因为它避免了使用条件判断语句,可以通过简单的乘法统一处理两个类别。
逻辑回归的损失函数与概率 📉
基于上述概率公式,我们可以定义逻辑回归的损失函数,即负对数似然。
损失函数 L = -log(P(Y | O)) = log(1 + e^{-Y * O})
以下是关于损失函数和概率的几个关键点:
- 损失函数形状:该损失函数是一个平滑的凸函数,当预测正确且置信度高时(即 Y * O 值很大),损失趋近于 0;当预测错误时(即 Y * O 值为负),损失会线性增长。
- 概率输出:模型对于输入 X 预测为正类(Y=1)的概率,恰好就是 Sigmoid 函数的输出:
P(Y=1 | X) = 1 / (1 + e^{-O})
其中 O 是模型对样本 X 的原始输出(也称为 logit)。 - 导数的巧合:有趣的是,损失函数关于 O 的导数,其形式恰好等于 -Y * P(Y=-1 | O)。这与概率模型属于指数族有关,背后有优美的数学原理。如果你对此感兴趣,可以选修相关的统计学课程。
损失函数的渐近线分析 📈

最后,我们来分析一下逻辑回归损失函数在极端情况下的行为,即计算其渐近线。
当 X(此处 X 对应 Y * O)趋向于正无穷时,e^{-X} 趋近于 0。因此,损失函数 log(1 + e^{-X}) 趋近于 log(1) = 0。

当 X 趋向于负无穷时,情况稍复杂一些。我们重写损失函数:
L = log(1 + e^{-X}) = log(e^{-X} * (e^{X} + 1)) = -X + log(1 + e^{X})
当 X → -∞ 时,e^{X} → 0,所以 log(1 + e^{X}) → 0。因此,损失函数渐近于 -X,即一条斜率为 -1 的直线。
这意味着,对于严重错误的预测,损失会线性增加,这为优化过程提供了明确的方向。

总结 🎯
本节课中我们一起学习了逻辑回归的核心内容。
- 我们首先回顾了多类分类的 softmax 函数,并利用其加法不变性,推导出了二分类场景下的逻辑回归公式。
- 我们得到了统一的概率表达式 P(Y | O) = 1 / (1 + e^{-Y * O}) 和对应的损失函数 L = log(1 + e^{-Y * O})。
- 我们分析了损失函数的性质,包括其与预测概率的关系,以及在预测完全正确或完全错误时的渐近行为。

逻辑回归是机器学习中一个基础且强大的模型,理解其数学本质对于后续学习更复杂的模型至关重要。

课程 P43:协变量偏移校正 🎯
在本节课中,我们将学习一种名为“协变量偏移校正”的技术。当训练数据和测试数据来自不同的分布时,这种技术可以帮助我们调整模型,使其在测试集上表现更好。我们将通过一个巧妙的分类器技巧来实现这一点,并讨论其潜在问题及解决方案。
回顾与引入
上一节我们介绍了倾向得分和密度比率的概念。如果已知两个分布密度之比 α(x),我们就能利用复杂的统计工具轻松处理协变量偏移问题。
实际上,我们可以利用已构建的工具,例如逻辑回归,它既能用于协变量校正,也能解决实际的分类问题。接下来,我们将具体看看如何操作。
核心思想:使用分类器进行双样本检验
我们的核心思路是:将训练集和测试集的数据混合,并尝试训练一个分类器来区分它们。
以下是具体步骤:
- 假设训练集和测试集大小相同。
- 将训练集数据标记为类别
1,测试集数据标记为类别-1。 - 将两组数据合并为一个数据集。
现在,我们的目标是训练一个分类器,看它能否区分某个数据点来自训练集还是测试集。
其直觉在于:如果分类器无法区分,说明两组数据可能来自相同分布。如果能区分,则分类器给出的概率可以告诉我们某个观测值更可能来自哪个分布,我们可以利用这个信息来重新加权数据。
这个技巧在生成对抗网络(GAN)中也常被用作“双样本检验”。不过,我们这里的目的不是生成数据,而是重新加权数据,使一个数据集看起来像另一个数据集。
从分类概率到权重校正
接下来是精妙的部分。我们关注条件类别概率 P(y=1|x)。在等量混合了分布 P(训练)和 Q(测试)后,这个概率与密度比率 α(x) = Q(x)/P(x) 存在数学关系。
通过代数推导,我们可以得到以下关键公式:
α(x) = exp(f(x))
其中,f(x) 是我们训练的分类器输出的某个函数(例如,逻辑回归中 logit 函数的一部分)。
这意味着,我们无需走复杂的密度估计路线,只需调用任何能输出条件类别概率的现成分类器,就能直接得到一个协变量校正器。
总结一下流程:
- 合并训练和测试数据,并赋予类别标签。
- 训练一个二分类器。
- 对于训练数据中的每个样本
xi,计算权重exp(f(xi))。 - 使用这些权重重新加权训练数据,然后再去解决我们原本的预测问题(如分类或回归)。
潜在问题与解决方案
上述方法有一个明显的问题:对函数 f(x) 取指数可能导致权重值过大或过小。
- 如果
f(x)非常大,exp(f(x))会给该样本赋予极高的权重。 - 如果
f(x)非常小,exp(f(x))会接近零,导致该样本几乎被丢弃。
这两种情况都可能使估计变得不稳定。
一种解决方案是对函数 f(x) 进行裁剪(Clipping)。例如,设定一个阈值 c,并执行以下操作:
clipped_f = max(min(f(x), c), -c)
weight = exp(clipped_f)
这样可以将单个观测值的影响限制在一个可接受的范围内,确保不会完全丢弃数据,也不会让某个数据点主导整个过程。当然,这会引入一些偏差,但能显著降低方差。
此外,还有更复杂的方法,如 W-Robos估计法,它通过组合多种统计技术来稳健地纠正偏差。
总结

本节课我们一起学习了协变量偏移校正的实用方法。我们了解到,可以通过训练一个区分训练集和测试集的分类器,并利用其输出的概率来推导重新加权的权重 exp(f(x))。同时,我们也认识到直接取指数可能带来的数值不稳定问题,并介绍了裁剪等解决方案来保证估计的稳健性。这种方法巧妙地将分布对齐问题转化为了一个标准的分类任务,易于实现和应用。

课程 P44:标签偏移 🏷️➡️📊
在本节课中,我们将要学习一种称为“标签偏移”的分布偏移问题。我们将了解它与之前讨论的协变量偏移有何不同,探索其常见的应用场景,并简要介绍其背后的数学原理和一种可能的解决方法。

概述
上一节我们介绍了协变量偏移,本节中我们来看看另一种分布偏移问题——标签偏移。标签偏移是协变量偏移的一种简化但常见的情况,其核心在于标签 y 的先验分布 P(y) 在训练集和测试集之间发生了变化,而条件分布 P(x|y) 保持不变。

什么是标签偏移?
假设这是我们的训练集,其中包含一定比例的病例(例如,生病的病人)和健康个体。


而我们的测试集看起来是这样的。结果发现,测试集中有更多的病例,而训练集中较少。这与简单的协变量偏移略有不同,而且实际上这种情况更为常见。

在数学上,标签偏移的假设是:
- 条件分布
P_train(x|y) = P_test(x|y)保持不变。 - 标签的边缘分布发生了变化,即
P_train(y) ≠ P_test(y)。
为什么会出现标签偏移?
那么,为什么你会遇到这种情况呢?以下是几个常见的例子:

- 医学诊断:假设我使用一个医疗诊断工具,在训练时使用了50%生病和50%健康的病人数据。但在实际应用中,目标人群的疾病发病率可能远低于50%(例如,乳腺癌筛查)。模型需要根据实际发病率进行调整。
- 季节性变化:在流感季节,流感病例的比例会显著升高。一个在非流感季节训练的模型,其预测结果需要根据季节进行调整。
- 语音识别:假设我构建了一个语音识别器。训练时,人们讨论的话题和提及的人名是特定的。之后,如果出现了新的话题(如新的政治议题)或新流行的人物(名字奇怪的电影明星),虽然语言本身没变,但词汇的分布发生了变化,这本质上也是一种标签(输出内容)分布的变化。
标签偏移的数学表现与挑战

从数学上看,标签偏移的设置略有不同。

我们不再假设 P(y|x) 相同,而是假设 P(x|y) 相同,但 P(y) 从训练集的 p(y) 变成了测试集的 q(y)。这通常发生在 y 是 x 的因果因素时(例如,流感引起症状,而不是症状引起流感)。
与协变量偏移修正相比,处理标签偏移的一个主要挑战是:我们可能没有测试集的真实标签。因此,无法直接计算重要性权重 β_i = P_test(x_i) / P_train(x_i)。
一种解决方法:利用混淆矩阵
你可以做的是,你想要估算在测试集上的表现。事实证明,有一种方法可以处理。


其核心思想是假设模型的错误混淆矩阵在训练集和测试集上保持不变。虽然这是一个稍微高级的话题,但其背后的代数相当简单。
以下是基本的思路:
- 在训练集上计算模型的混淆矩阵
C(例如,对于流感预测,矩阵包含真正例、假正例等)。 - 在测试集上,运行模型得到预测标签的分布,记为向量
μ(例如,模型预测40%为流感,60%为健康)。 - 假设测试集上真实的标签分布向量为
ν(未知),那么存在关系:μ ≈ C * ν。 - 如果混淆矩阵
C是可逆的,我们就可以估算出真实的测试集标签分布:ν ≈ C^{-1} * μ。
通过这种方式,我们可以推测出测试集上标签分布的变化,从而对模型进行修正。
总结

本节课中我们一起学习了标签偏移的概念。它描述了当标签 P(y) 的分布发生变化,而特征给定标签的条件分布 P(x|y) 保持不变时出现的问题。这与协变量偏移既相似又不同。

我们看到了它在医疗诊断、季节性预测和语音识别等领域的实际应用。最后,我们简要介绍了一种通过假设混淆矩阵不变来估计和修正标签偏移的方法。这提醒我们,在实际机器学习应用中,需要仔细考虑数据分布可能发生的各种偏移。


是的,当然你尝试了一下,它有效,反正就是这样。


没关系。


课程 P45:45. 敌对数据 👁️🗨️

在本节课中,我们将要学习机器学习中的一个重要概念——敌对数据。我们将探讨它的定义、产生原因、实际案例以及如何通过数学和工程方法来应对它。
概述
敌对数据是指经过精心设计的、旨在欺骗机器学习模型的输入数据。这些数据对人眼来说可能看起来正常,却能导致模型做出错误的预测。理解敌对数据对于构建鲁棒的AI系统至关重要。
上一节我们介绍了模型评估,本节中我们来看看一种特殊的、可能影响模型性能的数据形式。
什么是敌对数据?🤔
假设我们构建了一个名人识别系统。系统可以顺利识别出Jeff(亚马逊创始人)和他右边的人——AWS的CEO Andy Jassy。这看起来工作良好。

但问题在于,如何让系统无法识别出特定人物(例如Jeff)?这就引出了对抗性攻击的概念。
一篇2017年的论文展示了一个实验:研究人员在一张人像照片上进行了数字处理,给人物戴上了一副造型夸张的眼镜(类似电影《王牌大贱谍》的风格)。他们甚至3D打印了这副眼镜。
结果是,这副眼镜成功地欺骗了人脸识别系统,使其无法正确识别佩戴者。尽管对人眼而言,这仍然是同一个人,但模型却被“愚弄”了。这种现象既令人担忧,也引人深思。

敌对数据的其他案例 🎤

敌对数据不仅限于图像领域。去年(指课程录制时的去年)伯克利的一篇论文在音频上做了类似实验。
研究人员选取了一段朗读狄更斯小说《双城记》开篇“这是最好的时代,也是最坏的时代……”的音频。他们向这段音频中添加了微小的、人耳难以察觉的噪声。

结果,语音识别系统将这段音频完全错误地转译成了其他文本(例如“来自言语识别器的宪法”)。而人类听众甚至无法察觉到音频被改动过。
这个实验的核心方法是:尝试对输入数据做一个微小的改动,以最大化模型的损失。


为什么敌对数据会生效?🔍
以下是敌对数据能够生效的几个关键原因:
- 训练数据分布有限:模型在训练时只接触过相对较小、较“自然”的输入数据子集。敌对数据则轻微偏离了这个数据分布的支持集。
- 模型未见过“怪异”数据:像那副夸张的眼镜,现实中几乎不会有人佩戴,因此人脸识别系统从未针对此类数据进行训练。
- 人类与模型感知差异:对于不自然的数据,人类和模型的判断可能截然不同。例如,看到一个圆圈左边的生物,人类可能会联想到“猫医生”这样的虚构角色,而不一定直接判断为“猫”。模型则可能因此做出意外反应。
几年前,关于“对抗性数据生成将摧毁机器学习”的讨论甚嚣尘上。但这并非全新问题。

历史渊源:垃圾邮件过滤 📧
对抗性数据几乎从机器学习应用之初就已存在,一个典型的例子是垃圾邮件过滤。
垃圾邮件发送者为了绕过过滤器,会不断修改邮件内容。例如,一封关于“尼日利亚王子”的诈骗邮件,发送者会在其中添加或替换一些词语,使邮件对人类读者而言仍然可读(内容核心不变),但却能逃过基于关键词或模式的垃圾邮件过滤器。
他们可能会插入一些高频但无关的词汇,或者使用特殊的句式。例如,将称呼改为“亲爱的Alex”,如果收件人正好是Alex,这封邮件就更可能通过基于社交关系的过滤器。
因此,对抗性攻击在文本领域早已存在。当它被应用于图像、音频等更直观的领域时,才因其视觉冲击力而受到广泛关注。
应对之道:利用不变性增强鲁棒性 🛡️
既然敌对数据利用了模型对某些变换的敏感性,那么一个直接的应对思路就是让模型学会不变性——即对某些不影响本质的变换不敏感。
一个早期的经典例子是1995年AT&T实验室关于手写数字识别的研究(切平面距离法)。他们系统地研究了数字图像的各种变换:

以下是他们考虑的不变性类型:
- 缩放:将数字变大或变小。
- 旋转:旋转数字的角度。
- 形变:沿轴线扭曲或挤压数字形状。
- 笔画粗细变化:改变数字笔画的粗细。
他们通过计算数字在这些变换构成的“切平面”内的距离来判断相似性。如果两个数字在这个平面内距离很近,就可以被认为是同一个数字(例如,都是“5”)。这实质上是通过数据增强来提升模型鲁棒性。

如今,数据增强已成为标准实践:
- 图像分类:在训练时随机裁剪、旋转、调整亮度对比度。
- 语音识别:添加背景噪音、改变语速、音调。
- 文本分析:随机删除或替换单词,使用同义词。
甚至早期的“虚拟支持向量机”概念,部分也是因为当时计算机内存有限,无法存储所有数据,而采用的一种独特的数据增强方式。
背后的数学模型 🧮
对抗性训练可以从一个极小极大化的游戏角度来形式化理解。

我们不仅希望在原始数据 (x, y) 上损失小,还希望在其扰动版本 (x + δ, y) 上损失也小。其中 δ 是一个小的扰动。
对抗性训练的目标可以表述为以下公式:
min_θ [ max_δ∈Δ L(f_θ(x + δ), y) + penalty(δ) ]
公式解读:
L是损失函数。f_θ是我们的模型,参数为θ。δ是添加到输入x上的扰动,通常被限制在一个小范围Δ内(例如,像素值变化很小)。penalty(δ)是对扰动大小的惩罚项,确保扰动不会太大。- 内层
max:对手(攻击者)试图找到一个扰动δ,使得模型在当前参数下的损失 最大。 - 外层
min:我们(防御者)则调整模型参数θ,以最小化这个 最坏情况下的损失。

这个过程本质上是在训练一个能够抵抗最坏情况扰动的鲁棒模型。这套框架在1995年就被明确提出,构成了对抗性数据生成和防御的理论基础。

总结
本节课中我们一起学习了敌对数据的核心内容:
- 定义:敌对数据是人为设计的、能导致机器学习模型出错的微小扰动数据。
- 普遍性:它并非新问题,在垃圾邮件过滤等历史应用中早已存在。
- 成因:主要源于模型训练数据分布的有限性,以及其对人类不易察觉的变换的敏感性。
- 防御策略:核心思想是提升模型鲁棒性,主要方法包括数据增强(让模型见识更多变换)和对抗性训练(通过数学框架主动训练模型抵抗攻击)。

理解敌对数据有助于我们认识到机器学习模型的局限性,并指导我们设计出更安全、更可靠的AI系统。




机器学习实战课程 P46:非平稳环境 🚀
在本节课中,我们将要学习机器学习系统在现实世界中部署时,所面临的各种“非平稳环境”。我们将探讨不同的交互模式,理解每种模式的特点和挑战,为将来构建更健壮的机器学习应用打下基础。
机器学习系统的交互模式
上一节我们讨论了模型训练与测试的差异,本节中我们来看看模型部署后与环境的几种主要交互方式。

以下是几种常见的交互模式:

- 批处理:我们目前最熟悉的方式。使用一批固定数据进行训练,生成一个分类器,然后直接部署。
- 在线学习:模型在收到新数据、做出预测并观察到真实标签后,立即进行更新。这个过程持续进行。
- 主动学习:模型可以主动提出查询(例如,请求特定数据的标签),以加速学习过程。
- 多臂老丨虎丨机问题:模型需要在“探索”(尝试新选项)和“利用”(选择当前最佳选项)之间做出权衡。
- 强化学习:最复杂的模式,模型的行为会直接影响后续的环境状态和反馈。

批处理与在线学习
在批处理设置中,流程是线性的:训练数据 -> 构建模型 -> 在测试数据上评估 -> 部署。

然而,在线学习是更常见于现实世界部署的场景。例如,一个广告系统需要不断根据用户点击反馈来更新模型,以最大化收入。其核心流程是一个循环:观察输入 -> 模型预测 -> 获得真实标签 -> 更新模型。
从老丨虎丨机问题到强化学习
多臂老丨虎丨机问题是一个经典的探索与利用问题。模型选择不同的“臂”(选项),获得奖励,并据此更新对每个臂价值的估计。其特点是环境本身没有“记忆”,你的选择不会改变老丨虎丨机内部的概率分布。
当环境具有状态,并且你的行为会改变后续状态时,问题就演变为强化学习。例如,下棋、控制机器人或管理数据中心。强化学习的环境可以是合作的、敌对的或中立的。

强化学习系统需要考虑:
- 状态空间:简单或复杂。
- 记忆:短期或长期。
- 数据可用性:是否有大量历史数据(离线策略学习),还是主要依赖实时交互(在线策略学习)。
- 动作空间:离散的(如移动棋子)或连续的(如控制机器人关节角度)。
注意:在模拟环境(如游戏)中失败可以轻松重启,但在现实世界系统(如控制服务器冷却)中失败可能导致严重后果。这是理论与应用的关键区别。
总结与展望

本节课中我们一起学习了机器学习在非平稳环境下面临的挑战。我们介绍了从简单的批处理、在线学习,到更复杂的主动学习、老丨虎丨机问题,直至最具综合性的强化学习等多种交互模式。
我希望大家能认识到,模型的训练环境和真实的测试/部署环境是截然不同的。虽然我们有一系列工具(如正则化、验证集)来缓解这个问题,但无法完全根除。理解这些环境差异及其数学基础,能帮助我们在未来更好地设计、部署和维护机器学习系统,并理解其可能存在的局限性。

课程总结:我们探讨了机器学习系统与动态环境交互的五种主要模式,理解了从静态批处理到具有状态和记忆的强化学习的演进,强调了理论模型与真实世界部署之间的重要差异。

深度学习框架入门教程(P47)🚀
在本节课中,我们将学习深度学习框架的发展历程、主要框架的特点以及如何开始使用Gluon框架。我们将从历史背景入手,逐步了解不同框架的设计哲学,并最终聚焦于课程所使用的MXNet及其Gluon接口。

框架发展历程 📈
上一节我们介绍了硬件基础,本节中我们来看看深度学习的前端框架是如何演变的。
下图展示了深度学习框架随时间发展的概况。横轴代表年份,可以看到不同框架的兴起与更迭。


大约三年前,有四个主要的框架并存。我们可以简要回顾每个框架的核心设计决策。
主要框架介绍 🔍

以下是几个具有代表性的深度学习框架及其特点。
Caffe ☕
Caffe源自伯克利,大约四年前是计算机视觉领域最流行的框架。
它的程序接口基于协议缓冲区(Protocol Buffers)。用户通过编写文本文件来描述网络结构。例如,以下是ResNet-101网络的一部分定义:
layer {
name: "conv1"
type: "Convolution"
bottom: "data"
top: "conv1"
convolution_param {
num_output: 64
kernel_size: 7
stride: 2
pad: 3
}
}

Caffe拥有出色的计算机视觉算子覆盖,并且易于移植,通常打包为单一二进制文件。然而,它在Python级别的交互式编程上不够灵活,且网络定义文件可能非常冗长(例如,一个四层网络的定义可能长达四千行)。
TensorFlow 🔧
TensorFlow是目前最流行的深度学习框架之一。它提供了一种用于Python的领域特定语言(DSL)。

TensorFlow功能强大,包含成千上万的运算符,涵盖训练、部署等各个方面。但其代码风格对Python用户而言可能较难理解。例如,以下代码中的 state_ops 赋值操作可能让初学者困惑:
import tensorflow as tf
x = tf.placeholder(tf.float32, shape=(None, 784))
W = tf.Variable(tf.zeros([784, 10]))
b = tf.Variable(tf.zeros([10]))
y = tf.nn.softmax(tf.matmul(x, W) + b)
Keras 🧠

Keras是一个高层神经网络API,旨在简化开发过程。它的设计理念与我们将要学习的Gluon非常相似。
以下是如何使用Keras定义一个多层感知机(MLP):
from keras.models import Sequential
from keras.layers import Dense
model = Sequential()
model.add(Dense(units=64, activation='relu', input_dim=100))
model.add(Dense(units=10, activation='softmax'))

Keras可以使用不同的后端(如TensorFlow、Theano、CNTK),这使得它比直接使用TensorFlow更简单,但有时会因前端开销而稍慢。它采用符号式编程,在获取中间结果进行交互式调试时可能不太方便。
PyTorch 🔥
PyTorch融合了Torch的Tensor接口和Chainer的自动求导接口。它完全基于Python,因此代码非常易于阅读和理解。
对于Python用户来说,PyTorch代码直观明了。然而,这种与Python的紧密集成在工业部署(尤其是需要与Java等语言集成或在移动设备上运行)时可能带来挑战。尽管如此,其易用性使其在研究领域越来越流行。
MXNet与Gluon框架 🎯
本课程基于MXNet框架。原始的MXNet包含一个强大的张量计算库NDArray,以及一个符号式编程的神经网络模块。
MXNet最初的设计优先考虑性能,因此在一定程度上牺牲了易用性。当时社区规模较小,用户多为专家,对易用性要求不高。
随着社区发展,像PyTorch这样易用性极高的框架出现后,MXNet团队推出了Gluon接口。Gluon的设计理念与PyTorch类似,旨在使神经网络的开发和调试变得更加容易。

以下是一个使用Gluon定义网络的简单示例:
from mxnet.gluon import nn
net = nn.Sequential()
net.add(nn.Dense(256, activation='relu'),
nn.Dense(10))

Gluon的交互式体验很好,虽然相比纯符号式接口可能损失一点性能,但在大多数应用场景下(除非处理海量数据,如自动驾驶的4K视频流),这并不是问题。对于课程作业而言,完全无需担心。
框架即工具:关注应用 🛠️

过去两年的经验表明,框架本身只是一个工具。研究人员更关注是否有现成的基准模型可供修改和实验;工程师则更关心数据流水线、模型训练和部署流程。
因此,社区的焦点逐渐转向应用。例如:
- GluonCV:一个计算机视觉工具包,提供了大量预训练模型(如物体检测、语义分割、人脸识别)和训练脚本,帮助用户复现论文结果。实践中,许多论文的性能提升依赖于特定的训练技巧(如标签平滑),而这类工具包集成了这些技巧。
- 自动机器学习(AutoML):例如,利用课程第三章的代码,可以自动为你的数据集寻找合适的模型结构。
- 自然语言处理(NLP):针对文本任务,出现了基于Transformer架构的模型(如BERT、GPT-2),取得了显著成果。
- 图神经网络(GNN):用于处理社交网络、推荐系统等图结构数据,是一个快速发展的新兴领域。
未来展望与Gluon教程 🚀
框架的发展日新月异。基于课程反馈,MXNet生态也在持续进化:
- API改进:例如,针对NDArray不支持布尔索引等反馈,未来可能引入一个名为
NP的新包,在保持NumPy兼容性的同时,增加GPU支持和自动图优化。 - 性能优化:利用编译器技术对计算图进行整体优化,可以在CPU/GPU上获得显著的性能提升。
- 硬件扩展:深度学习正扩展到更多硬件平台(如手机、专用芯片ASIC)。预计未来即使在集成显卡上也能高效运行GPU代码。

接下来,我们将开始Gluon的具体教程。我们已经了解了NDArray接口,接下来将重点学习三方面内容:

- 如何创建和定义新的网络层。
- 如何初始化和操作模型参数。
- 如何利用GPU加速计算(对于下周将学习的计算量更大的卷积神经网络,GPU几乎是必需的)。

本节课中,我们一起回顾了深度学习框架的发展简史,了解了Caffe、TensorFlow、Keras、PyTorch等框架的特点,并深入认识了本课程的核心——MXNet及其Gluon接口。我们认识到框架是服务于应用的工具,并展望了其未来发展方向。从下一节开始,我们将动手实践,学习如何使用Gluon构建和训练神经网络。

深度学习框架入门:Gluon 🧠
课程概述

在本节课中,我们将学习深度学习框架的基本概念,并重点介绍MXNet框架下的Gluon接口。我们将了解不同框架的设计哲学、优缺点,以及Gluon如何平衡易用性与性能,最后学习如何使用Gluon定义网络和操作参数。
主流深度学习框架简介
在硬件知识之后,我们来了解一下软件前端——深度学习框架。下图展示了近年来主流框架的发展时间线。


横轴代表年份,可以看到Caffe、TensorFlow、Keras、PyTorch等框架相继出现。每个框架都有其核心的设计决策和目标。
Caffe:计算机视觉的早期流行框架
第一个是Caffe。Caffe由伯克利大学开发,大约四年前,它是计算机视觉领域最流行的框架之一。
它的程序接口特点是:用户提供一个协议缓冲区文件(prototxt)来描述网络层次结构。例如,以下是ResNet-101网络的一部分定义方式:

layer {
name: "conv1"
type: "Convolution"
bottom: "data"
top: "conv1"
convolution_param {
num_output: 64
kernel_size: 7
stride: 2
pad: 3
}
}
Caffe具有非常好的计算机视觉模型覆盖度,并且是可移植的单一二进制文件,可以在任何地方运行。然而,它不够灵活,难以进行像Python那样的交互式编程。一个完整的网络定义可能长达数千行代码。
TensorFlow:当前最流行的框架之一
之后,TensorFlow成为了目前最流行的深度学习框架之一。它提供了一种针对Python的领域特定语言(DSL)。
TensorFlow代码示例:
import tensorflow as tf
x = tf.placeholder(tf.float32, shape=(None, 784))
W = tf.Variable(tf.zeros([784, 10]))
b = tf.Variable(tf.zeros([10]))
y = tf.nn.softmax(tf.matmul(x, W) + b)

TensorFlow拥有成千上万个操作符,功能全面,能满足训练、部署等各种需求。但它的代码对于Python用户来说可能有些难以理解,例如其中的“状态操作”(stateful ops)概念。
Keras:以简化开发为目标
另一方面,Keras是一个高层API,旨在简化开发过程。例如,使用Keras定义一个多层感知机(MLP)非常简单:
from keras.models import Sequential
from keras.layers import Dense

model = Sequential()
model.add(Dense(units=64, activation='relu', input_dim=100))
model.add(Dense(units=10, activation='softmax'))
Keras可以使用不同的后端,如TensorFlow、Theano或CNTK。它相对于TensorFlow更易用,但可能会因为前端抽象而带来轻微的性能开销。

PyTorch:研究界的宠儿
另一个非常流行的是PyTorch。它继承了Torch的Tensor接口和Chainer的自动求导接口,完全基于Python。
PyTorch代码示例:
import torch
import torch.nn as nn
model = nn.Sequential(
nn.Linear(784, 256),
nn.ReLU(),
nn.Linear(256, 10),
nn.LogSoftmax(dim=1)
)
这使得PyTorch代码非常易于阅读和理解。由于其与Python的紧密集成,它在研究界变得极其流行。然而,这种紧密集成有时也使得模型在工业环境(如移动端或Java环境)中的部署变得更具挑战性。

MXNet与Gluon的设计哲学
本课程基于MXNet框架。原始的MXNet包含一个类似NumPy的张量库NDArray,其设计初衷是追求极致的性能,有时会牺牲一些易用性。
随着社区发展,人们意识到易用性的重要性。PyTorch因其易用性而广受欢迎。因此,MXNet推出了Gluon接口。
Gluon的设计理念与PyTorch非常相似,旨在使代码开发和调试更容易。以下是如何使用Gluon定义一个简单的网络:
from mxnet.gluon import nn
net = nn.Sequential()
net.add(nn.Dense(256, activation='relu'))
net.add(nn.Dense(10))
Gluon采用了命令式编程(imperative programming)风格,这使得获取中间结果和进行交互操作变得简单。虽然相比纯粹的符号式接口(symbolic programming)可能在性能上略有损失,但对于大多数研究和教学场景来说,这种损失是可以接受的。
超越框架:工具包与生态系统
随着深度学习的发展,大家越来越将框架视为完成任务的工具。社区的需求主要集中在两个方面:

- 对于研究人员:希望有高质量的基准模型和实现,以便在其基础上进行修改和创新。
- 对于工程师:关心如何快速获取数据、训练模型并部署到生产环境。
为了满足这些需求,MXNet生态系统提供了一系列工具包:

- GluonCV:计算机视觉工具包,提供了图像分类、目标检测、语义分割等任务的众多预训练模型和训练脚本。
- GluonNLP:自然语言处理工具包,涵盖了如BERT、GPT-2等基于Transformer的流行模型。
- DGL:深度图神经网络库,用于处理社交网络、推荐系统等图结构数据。
这些工具包不仅提供了模型,还集成了许多提升模型性能的实用技巧(例如使用标签平滑技术),帮助用户更容易地复现论文结果。

框架的持续演进
深度学习框架领域发展迅速。以MXNet为例,它也在不断学习和改进:
- API改进:根据用户反馈,MXNet引入了
mx.np模块,旨在提供与NumPy 100%兼容的接口,并支持GPU和自动求导。 - 性能优化:利用编译器技术对计算图进行整体优化,在CPU和GPU上能获得显著的性能提升。
- 硬件扩展:积极支持更多硬件后端,如Intel和AMD的GPU,使得深度学习应用能更广泛地部署在边缘设备上。
动手使用Gluon
接下来,我们将提供一些关于Gluon的实际操作指南。我们已经讨论过NDArray接口,现在我们将更多地讲解如何编写代码来构建新的网络。
主要有三件事需要掌握:
- 如何创建和定义新的网络层。
- 如何初始化和操作模型参数。
- 如何利用GPU进行加速计算(这对于接下来要学习的计算量更大的卷积神经网络尤为重要)。

我们将在后续的实践环节中详细展开这些内容。

课程总结

本节课我们一起学习了深度学习框架的演变历程,了解了Caffe、TensorFlow、Keras、PyTorch等主流框架的特点。我们重点介绍了MXNet的Gluon接口,它如何平衡易用性与性能,并了解了围绕MXNet构建的丰富工具包生态系统。最后,我们认识到框架是快速实现想法的工具,而整个领域仍在高速演进中。在接下来的课程中,我们将开始使用Gluon进行实际编程。
课程 P49:49. 块与层 🧱
在本节课中,我们将学习如何使用自定义的“块”来构建神经网络。我们将从回顾使用顺序容器构建多层感知器开始,然后逐步深入到如何通过继承“块”类来创建更灵活、更复杂的网络结构。

回顾:使用顺序容器构建网络

在前面的课程中,我们已经学习了如何构建多层感知器。具体方法是定义一个顺序网络容器,并向其中添加层。

以下是一个示例,我们定义了一个顺序网络,它包含两个密集层。第一个密集层有256个输出并使用ReLU激活函数,第二个密集层输出维度为10。
# 示例:使用顺序容器
net = nn.Sequential()
net.add(nn.Dense(256, activation='relu'))
net.add(nn.Dense(10))
在前向传播时,我们生成一个随机输入x(例如,形状为(2, 20),代表2个样本,每个样本20个特征)。将x输入网络后,我们得到输出y,其形状为(2, 10),即批处理大小和输出特征数。

使用自定义块构建网络
现在,让我们使用用户自定义的“块”来重新实现相同的多层感知器。这种方法与PyTorch中的做法非常相似。

我们创建一个名为MLP的类,它继承自nn.Block类。这个类需要定义两个核心函数:__init__和forward。

class MLP(nn.Block):
def __init__(self, **kwargs):
# 调用父类构造函数
super(MLP, self).__init__(**kwargs)
# 定义并创建层
self.hidden = nn.Dense(256, activation='relu')
self.output = nn.Dense(10)
def forward(self, x):
# 定义前向传播逻辑
return self.output(self.hidden(x))
__init__函数:主要目的是定义并创建网络中的所有层。我们首先调用父类的构造函数来初始化必要的内部数据结构。forward函数:定义了给定输入x时,如何计算网络的输出。
使用这个自定义块的方式与使用顺序模型非常相似:
net = MLP()
net.initialize()
y = net(x)

实现自定义顺序容器

利用Block基类,我们甚至可以自己实现一个类似于Sequential的容器。我们将其称为MySequential。

class MySequential(nn.Block):
def __init__(self, **kwargs):
super(MySequential, self).__init__(**kwargs)
# 使用一个有序字典来存储层
self._layers = {}
def add(self, name, block):
# 添加层或块到容器中
self._layers[name] = block
def forward(self, x):
# 按添加顺序依次通过每一层
for name, block in self._layers.items():
x = block(x)
return x

add函数:允许我们向容器中添加层或块。forward函数:遍历存储的所有层,依次将输入x传递下去。

它的使用方式与内置的Sequential类似:
net = MySequential()
net.add(‘hidden‘, nn.Dense(256, activation=‘relu‘))
net.add(‘output‘, nn.Dense(10))
net.initialize()
y = net(x)
块的灵活性:复杂网络示例

使用Block类的主要优势在于其极大的灵活性,允许我们实现顺序接口难以表达的复杂网络结构。
以下是一个“复杂MLP”的示例,它展示了几个高级功能:
class FancyMLP(nn.Block):
def __init__(self, **kwargs):
super(FancyMLP, self).__init__(**kwargs)
# 1. 一个普通的可训练密集层
self.dense = nn.Dense(256, activation=‘relu‘)
# 2. 一个随机权重层,在训练中固定(不更新)
self.rand_weight = self.params.get_constant(‘rand_weight‘, np.random.uniform(size=(20, 20)))
# 3. 一个密集层,它将与第一层共享参数
self.shared_dense = self.dense # 指向同一个层对象
def forward(self, x):
x = self.dense(x) # 通过第一层
# 使用固定的随机权重进行运算
x = nd.relu(nd.dot(x, self.rand_weight.data()) + 1)
# 再次通过第一层(参数共享)
x = self.shared_dense(x)
# 添加一些自定义逻辑,例如根据值调整输出
while x.norm().asscalar() > 1:
x /= 2
if x.norm().asscalar() < 0.8:
x *= 10
return x
这个例子展示了:
- 固定参数:
self.rand_weight被声明为常量参数,在训练过程中不会更新梯度。这在微调预训练模型时很有用,可以固定底层参数防止小数据集过拟合。 - 参数共享:
self.shared_dense直接指向self.dense,意味着它们使用完全相同的参数。计算梯度时,梯度会在这两层间累积。 - 自定义前向逻辑:在
forward函数中,我们可以编写任意的Python控制流(如循环、条件判断),这是顺序容器难以做到的。
混合搭配各种组件
无论是内置层(如Dense)、自定义块(如MLP)、自定义容器(如MySequential),还是自定义操作,只要它们是Block的子类,就可以自由地混合在一起使用。
class MixedMLP(nn.Block):
def __init__(self, **kwargs):
super(MixedMLP, self).__init__(**kwargs)
# 包含一个顺序网络
self.net = nn.Sequential()
self.net.add(nn.Dense(64, activation=‘relu‘), nn.Dense(32, activation=‘relu‘))
# 和一个独立的层
self.dense = nn.Dense(16)
def forward(self, x):
return self.dense(self.net(x))
# 甚至可以进一步嵌套
final_net = nn.Sequential()
final_net.add(MixedMLP(), nn.Dense(10))

这种设计使得构建网络像搭积木一样简单而强大。

调试优势

使用自定义块还有一个额外的好处:易于调试。你可以在forward函数中的任何位置插入代码来检查中间结果。
def forward(self, x):
x = self.hidden(x)
print(‘隐藏层输出:‘, x) # 调试语句
x = self.output(x)
return x
这种灵活性让你能够深入理解数据在网络中的流动和变换过程。

总结


本节课中,我们一起学习了神经网络“块”的核心概念。
- 我们回顾了使用
Sequential容器快速构建顺序网络的方法。 - 我们深入学习了如何通过继承
nn.Block类来创建自定义网络块,这需要定义__init__(初始化层)和forward(定义计算逻辑)两个方法。 - 我们利用
Block基类自己实现了一个简单的顺序容器MySequential,理解了其内部工作机制。 - 我们探索了
Block提供的强大灵活性,包括创建固定参数层、实现参数共享以及在forward函数中编写复杂的自定义逻辑。 - 我们了解到,所有层和网络结构都是
Block的子类,因此可以无缝地混合搭配使用。 - 最后,我们看到了自定义块在调试方面的优势,允许我们方便地检查中间变量。

通过掌握“块”的概念,你便获得了构建任意复杂神经网络架构的能力,而不仅仅是堆叠简单的层。
课程 P5:5. 线性代数与 Jupyter 🧮
在本节课中,我们将学习如何在 Jupyter 笔记本中使用 MXNet 框架进行基础的线性代数操作。我们将从导入库开始,逐步学习如何创建和操作标量、向量、矩阵和张量,并执行加法、乘法、点积、矩阵乘法以及范数计算等核心运算。

导入库与创建数组
首先,我们需要导入 MXNet 的数组模块。这是进行所有后续操作的基础。
from mxnet import nd

导入完成后,我们可以创建最基本的数组,即标量。
x = nd.array([3])
y = nd.array([2])
我们可以对这些标量执行基本的算术运算。
print(x - y) # 输出: [1.]
运算结果符合预期。我们可以将数组转换回 Python 的标量类型,以便在纯 Python 环境中使用。

x_asscalar = x.asscalar()
注意:频繁地在深度学习框架和 Python 原生环境之间转换数据可能导致性能下降,尤其是在循环中,应尽量避免。

向量操作
向量是一维的标量序列。我们可以使用 arange 函数方便地创建向量。
x = nd.arange(5) # 创建向量 [0., 1., 2., 3., 4.]

我们可以通过索引访问向量的特定元素。
print(x[3]) # 输出: 3.
查看向量的形状和维度是常见的操作。


print(x.shape) # 输出: (5,)
向量之间可以进行逐元素运算。

a = nd.array([1, 2, 3])
b = nd.array([10, 20, 30])
print(a + b) # 输出: [11., 22., 33.]
print(a * b) # 输出: [10., 40., 90.]

矩阵与张量
矩阵是二维数组。我们可以通过重塑(reshape)一个向量来创建矩阵。
x = nd.arange(20)
X = x.reshape((2, 10)) # 创建一个 2行10列 的矩阵
print(X.shape) # 输出: (2, 10)

我们可以获取矩阵的转置。
print(X.T) # 输出转置后的矩阵,形状为 (10, 2)
张量是更高维度的数组。例如,一个三维张量可以表示图像数据(高度、宽度、颜色通道)。

Y = nd.arange(24).reshape((2, 3, 4)) # 创建一个 2x3x4 的三维张量
print(Y.shape) # 输出: (2, 3, 4)
我们可以用相同的方式对矩阵和张量进行运算。

A = nd.ones(shape=(2, 3))
print(A * 2) # 每个元素乘以2

求和与均值

我们可以计算数组中所有元素的总和或均值。
x = nd.ones(shape=(3,))
print(nd.sum(x)) # 输出: 3.

对于多维数组,我们可以指定沿某个轴(维度)进行求和。
X = nd.ones(shape=(2, 3))
print(nd.sum(X, axis=0)) # 沿行(维度0)求和,输出形状 (3,)
print(nd.sum(X, axis=1)) # 沿列(维度1)求和,输出形状 (2,)

计算均值是类似的,它本质上是总和除以元素数量。

print(nd.mean(X)) # 计算整个矩阵的均值


点积与矩阵乘法

点积(内积)是两个向量对应元素相乘后求和。
x = nd.arange(4)
y = nd.ones(4)
dot_product = nd.dot(x, y)
print(dot_product) # 输出: 6.
# 等价于 print(nd.sum(x * y))
矩阵与向量的乘法是线性代数中的核心操作。
A = nd.ones(shape=(3, 4))
x = nd.arange(4)
# 正确的矩阵-向量乘法
matrix_vector_product = nd.dot(A, x)
print(matrix_vector_product)
重要提示:在 MXNet/NumPy 中,A * x 执行的是广播机制下的逐元素乘法,而非矩阵乘法。这与 MATLAB 的约定不同,是常见的错误来源。
矩阵乘法遵循标准的数学定义。

A = nd.ones(shape=(3, 4))
B = nd.ones(shape=(4, 3))
matrix_product = nd.dot(A, B) # 结果是一个 3x3 的矩阵
print(matrix_product.shape) # 输出: (3, 3)


范数计算
范数用于衡量向量或矩阵的“大小”。L2范数(欧几里得范数)是最常用的。

x = nd.arange(4)
l2_norm = nd.norm(x)
print(l2_norm) # 计算 x 的 L2 范数

我们也可以计算 L1 范数(所有元素绝对值之和)。
l1_norm = nd.sum(nd.abs(x))
print(l1_norm)
# 或者使用 norm 函数并指定阶数
l1_norm_alt = nd.norm(x, ord=1)
总结

本节课中,我们一起学习了在 MXNet 中执行线性代数操作的基础知识。我们从导入库和创建数组开始,逐步探索了向量、矩阵和张量的创建与基本运算,包括索引、形状变换、转置、求和、均值计算。接着,我们深入讲解了核心的线性代数操作:点积、矩阵-向量乘法以及矩阵乘法,并特别指出了广播机制与 MATLAB 的区别。最后,我们介绍了如何计算向量的 L1 和 L2 范数。掌握这些操作是进行更复杂机器学习和深度学习模型构建的重要基石。
课程 P50:50. L10_3 管理参数 🧠

在本节课中,我们将学习如何访问和修改神经网络中的参数。参数管理是深度学习中的重要环节,它允许我们查看、初始化和调整模型内部的权重与偏置。

访问网络参数 🔍
一旦定义了网络,访问其参数是至关重要的。在下面的例子中,我们定义了一个简单的多层感知机(MLP),它包含两个全连接层(密集层)。
import mxnet as mx
from mxnet import nd, init
from mxnet.gluon import nn

# 定义网络
net = nn.Sequential()
net.add(nn.Dense(256, activation='relu'))
net.add(nn.Dense(10))
net.initialize()

# 随机输入
X = nd.random.uniform(shape=(2, 20))
Y = net(X)
因为我们有两层,所以可以通过索引(0和1)来访问每一层。使用 params 属性可以获取该层所有的参数。
# 访问第一层的参数
print(net[0].params)

输出是一个参数字典,其中包含如 dense0_weight 和 dense0_bias 这样的键。我们可以通过键名来访问具体的参数。
# 通过名称访问权重参数
weight = net[0].params.get('dense0_weight')
print(weight.data()) # 获取权重数据的数组

访问特定参数:权重与偏置 ⚖️
有时,直接通过属性访问权重和偏置更为方便。

# 访问第一层的偏置
bias = net[0].bias
print(bias.data()) # 默认初始化为0
# 访问第一层的权重及其梯度
weight = net[0].weight
print(weight.data())
print(weight.grad()) # 尚未进行反向传播,梯度为0
我们还可以通过 collect_params 方法一次性获取网络中所有层的参数。


# 获取所有参数
all_params = net.collect_params()
print(all_params)
这是一个包含所有层权重和偏置的字典,例如 dense0_weight, dense0_bias, dense1_weight, dense1_bias。

使用模式匹配访问参数 🎯

为了更灵活地访问特定类型的参数(例如所有权重),我们可以使用模式匹配。
# 获取所有权重参数
weights = net.collect_params('.*weight')
print(weights)
这将返回一个只包含权重参数的字典,方便进行批量操作。

参数初始化 🚀

参数初始化对模型训练至关重要。好的初始化能带来更稳定和高效的训练结果。Gluon 提供了多种初始化方法。

首先,我们来看如何使用正态分布初始化权重。
# 使用正态分布初始化(标准差为0.01)
net.initialize(init=init.Normal(sigma=0.01), force_reinit=True)
print(net[0].weight.data()[0]) # 查看第一行权重值
我们还可以为不同的层或参数指定不同的初始化器。

# 为不同层设置不同的初始化器
net.initialize(init=init.Constant(1), force_reinit=True) # 默认常量初始化
net[0].weight.initialize(init=init.Xavier(), force_reinit=True) # 第一层权重使用Xavier初始化

自定义初始化函数 🛠️

如果内置的初始化方法不满足需求,我们可以定义自己的初始化函数。
# 自定义初始化器类
class MyInit(init.Initializer):
def _init_weight(self, name, data):
print('Init', name, data.shape)
# 例如:将数据初始化为均匀分布
data[:] = nd.random.uniform(low=-10, high=10, shape=data.shape)
# 使用自定义初始化器
net.initialize(init=MyInit(), force_reinit=True)
此外,我们也可以在初始化后直接修改参数数据。
# 初始化后直接修改参数
net[0].weight.set_data(net[0].weight.data() + 1) # 所有权重加1
net[0].weight.data()[0, 0] = 42 # 修改特定元素
print(net[0].weight.data()[0])

参数共享 🤝

在某些网络架构中,我们可能希望不同层共享相同的参数。这可以通过在定义层时传入共享的参数对象来实现。
# 创建一个共享的密集层
shared_dense = nn.Dense(8, activation='relu')
shared_dense.initialize()
# 构建一个共享参数的网络
net = nn.Sequential()
net.add(nn.Dense(8, activation='relu'))
net.add(shared_dense) # 与第一层共享参数
net.add(nn.Dense(10))
net.add(shared_dense) # 再次共享参数

# 修改共享参数会影响所有使用它的层
shared_dense.weight.data()[:] += 1
这意味着 net[1] 和 net[3] 层将共享完全相同的权重和偏置,对其中一层的修改会立即反映在另一层上。
总结 📚
本节课中,我们一起学习了如何管理神经网络中的参数。我们涵盖了以下核心内容:
- 访问参数:通过层索引、
params属性或collect_params方法查看网络参数。 - 初始化参数:使用内置初始化器(如
Normal,Constant,Xavier)或自定义函数来设置参数的初始值。 - 修改参数:在初始化后直接调整参数数据。
- 参数共享:在不同层之间共享参数,以构建更复杂的网络结构。

掌握参数管理是灵活构建和调试深度学习模型的基础技能。
课程 P51:使用GPU 🚀

在本节课中,我们将学习如何在编程中利用GPU进行计算。主要内容包括如何检查GPU设备、如何将数据和计算任务分配到GPU上,以及在不同GPU设备间管理数据的注意事项。
检查GPU设备

上一节我们介绍了课程概述,本节中我们来看看如何确认你的计算机是否配备了GPU。

你可以通过运行 nvidia-smi 命令来检查是否拥有GPU。例如,下图显示系统拥有两个GPU。

理解核心设备
在开始使用GPU之前,需要引入“核心设备”的概念。设备可以是CPU或GPU。
以下是关于设备编号的关键点:
- CPU通常被标识为
cpu(0),这里的数字0没有特殊含义,无论有多少个GPU,通常只有一个cpu(0)。 - 对于GPU,编号用于区分不同的物理设备。默认的第一个GPU是
gpu(0),第二个是gpu(1)。


在GPU上创建和存储数据
理解了设备概念后,我们来看看如何将数据放到GPU上。

默认情况下,创建的数组会存储在CPU内存中。我们可以通过检查数组的 .context 属性来确认。
x = nd.array([1, 2, 3])
print(x.context) # 输出:cpu(0)
要将数据放到指定的GPU上,可以使用 context 参数(常缩写为 ctx)。
x_gpu = nd.array([1, 2, 3], ctx=gpu(0))
print(x_gpu.context) # 输出:gpu(0)
同样,也可以在另一个GPU(例如 gpu(1))上创建随机数组。


设备间的计算规则
将数据放到GPU后,就可以在其上进行计算了。计算遵循一个核心原则:计算发生在数据所在的设备上。
这意味着,如果两个数组不在同一个设备上,它们之间无法直接进行计算。例如,x 在 gpu(0) 上而 y 在 gpu(1) 上,那么 x + y 的操作会失败。

在设备间复制数据
既然不同设备上的数据不能直接计算,我们就需要学习如何移动数据。
你可以使用 copyto 方法将数据显式复制到另一个设备。
x_gpu0 = nd.array([1, 2, 3], ctx=gpu(0))
x_gpu1 = x_gpu0.copyto(gpu(1)) # 将数据复制到 GPU 1
必须显式复制数据的原因:
- 保证程序逻辑清晰,避免因隐式复制导致的性能问题。
- GPU与CPU之间的数据传输速度很慢,随意复制会严重影响程序效率。
- 计算结果会自动保存在输入数据所在的设备上。


as_in_context 与 copyto 的区别

除了 copyto,还可以使用 as_in_context 方法来处理设备上下文。
两者的主要区别在于:
as_in_context:如果数据已经在目标设备上,则直接返回数据本身,不进行复制。copyto:无论数据是否已在目标设备上,都会创建一个新的副本。
因此,在代码中频繁使用 as_in_context 可以避免不必要的复制操作,提升性能。
y = nd.array([4, 5, 6], ctx=gpu(1))
z = y.as_in_context(gpu(1)) # z 就是 y 本身,没有复制

初始化网络模型到GPU

最后,我们学习如何将整个神经网络模型初始化到GPU上。
在初始化模型参数时,可以直接指定 ctx 参数,将其创建在特定的GPU上。
# 假设 net 是一个神经网络,initialize 用于初始化参数
net.initialize(ctx=gpu(0))
然后,需要确保输入数据 x 也在同一个GPU上,计算才能正常进行。
x = nd.array([...], ctx=gpu(0))
y = net(x) # 计算在 GPU 0 上执行
如果你需要切换到另一个GPU运行,必须将模型参数和数据都复制到那个设备上。


本节课中我们一起学习了使用GPU进行加速计算的基础知识。我们掌握了如何检查GPU设备、将数据分配至GPU、理解设备间的计算规则,以及使用 copyto 和 as_in_context 管理设备间数据。同时,我们也了解了将神经网络模型初始化到GPU的方法。这些是有效利用GPU资源进行高效计算的关键步骤。
课程 P52:52. 定制层 🧱

在本节课中,我们将学习如何在深度学习框架中自定义神经网络层。我们将从创建一个简单的无参数层开始,逐步深入到创建带有可学习参数(如权重和偏置)的层,并最终使用自定义层构建一个多层感知机。
概述
自定义层是深度学习研究中的重要工具,它允许我们实现新的算法思想或对现有层进行修改。通过继承基础层类并实现关键方法,我们可以创建符合特定需求的层。

创建无参数的自定义层
上一节我们介绍了自定义层的概念,本节中我们来看看如何创建一个简单的、无参数的自定义层。这个层的作用是将输入数据进行零中心化处理。

我们创建一个名为 CenterLayer 的类,它继承自基础块类 nn.Block。在初始化函数 __init__ 中,我们调用父类的初始化器。在前向传播函数 forward 中,我们实现具体的计算逻辑:将输入 x 减去其均值,从而实现零中心化。
class CenterLayer(nn.Block):
def __init__(self, **kwargs):
super().__init__(**kwargs)
def forward(self, x):
return x - x.mean()
使用这个层的方法很简单。以下是创建层实例并进行计算的步骤:
- 实例化
CenterLayer。 - 准备输入数据,例如一个张量
[1, 2, 3, 4, 5]。 - 调用层实例,得到零中心化的输出
[-2, -1, 0, 1, 2]。

这个层本身可以视为一个简单的网络。同时,它也可以作为更复杂网络中的一个组件。例如,我们可以创建一个顺序网络,其中包含一个标准的全连接层和我们自定义的中心化层。
net = nn.Sequential()
net.add(nn.Dense(128), CenterLayer())
在深度学习框架中,网络和层本质上都是基础块类的子类,这为灵活构建复杂模型提供了基础。
创建带参数的自定义层

上一节我们创建了一个无参数层,本节中我们来看看如何创建一个带有可学习参数(如权重和偏置)的自定义层。
首先,需要了解框架如何管理参数:所有参数都存储在一个参数字典中。我们可以通过指定层名、参数名和参数形状来向这个字典中注册一个新的参数。
以下是创建一个自定义全连接层 MyDense 的关键步骤:
- 在
__init__函数中,我们定义层的输入和输出大小。 - 使用
self.params.get方法创建权重参数weight,其形状为(in_units, out_units)。 - 同样地,创建偏置参数
bias,其形状为(out_units,)。 - 在
forward函数中,实现Y = X @ W + b的计算。
class MyDense(nn.Block):
def __init__(self, units, in_units, **kwargs):
super().__init__(**kwargs)
self.weight = self.params.get('weight', shape=(in_units, units))
self.bias = self.params.get('bias', shape=(units,))
def forward(self, x):
linear = np.dot(x, self.weight.data()) + self.bias.data()
return linear
与从零开始实现的不同之处在于,我们无需手动指定参数的初始化方法。框架允许我们在后续选择在何种设备上、使用何种方式(如 Xavier 初始化)来初始化这些参数。
使用这个自定义层的方法如下:

- 实例化
MyDense层,指定输出大小为 3,输入大小为 5。 - 初始化该层的参数。此时,参数字典中会包含名为
mydense0_weight和mydense0_bias的参数。 - 给定一个随机输入
X,调用层实例即可得到计算结果Y。
使用自定义层构建网络
现在,我们可以像使用标准层一样,使用自定义层来构建更复杂的神经网络,例如一个多层感知机。

以下是构建一个两层 MLP 的示例,其中第一层使用我们自定义的 MyDense 层:

net = nn.Sequential()
net.add(MyDense(8, in_units=64),
MyDense(1, in_units=8))
net.initialize()
这里有两个关键点需要注意:
- 我们需要为每个
MyDense层显式指定in_units(输入维度)。 - 在初始化网络时,框架会根据第一层的
in_units=64来推断整个网络期望的输入形状。
假设我们的输入 X 的形状是 (2, 64),经过这个网络计算后,我们将得到一个形状为 (2, 1) 的输出 Y。网络会自动确保第一层的输出维度(8)与第二层的输入维度(8)相匹配。
总结

本节课中我们一起学习了如何定制神经网络层。我们从创建一个无参数的中心化层开始,理解了层的基本结构。接着,我们创建了带有可学习权重和偏置的自定义全连接层,掌握了参数注册和管理的方法。最后,我们成功使用自定义层构建了一个功能完整的双层感知机。掌握自定义层的创建,是进行深度学习模型研究和创新的重要基础。
课程 P53:延迟初始化 🧠
在本节课中,我们将要学习深度学习框架中的一个重要概念——延迟初始化。我们将通过一个简单的两层全连接网络示例,来理解为什么以及如何延迟参数的初始化,直到我们获得输入数据的形状信息。

概述

延迟初始化是指神经网络参数的形状和内存分配,直到模型第一次接收到输入数据时才被确定和初始化。这样做的好处是,在定义网络结构时,我们无需预先指定输入维度,使得模型定义更加灵活。
上一节我们介绍了网络层的定义,本节中我们来看看参数初始化是如何被延迟的。
创建网络与参数检查
首先,我们创建一个包含两个全连接层的简单网络。
net = nn.Sequential(nn.Dense(256), nn.Dense(10))
创建网络后,我们可以打印其参数。

print(net.collect_params())
以下是返回的参数信息示例:
dense0_weight的形状为(256, 0)。权重矩阵的列数(输入维度)为0,因为我们尚未指定输入大小。dense0_bias的形状为(256,)。偏置向量的形状是确定的,因为它只与输出维度(256)相关。- 第二层
dense1的参数情况类似,其权重形状也未知。
此刻,网络仅拥有部分参数信息,无法推断出所有权重矩阵的完整形状。
调用初始化函数
接下来,我们尝试调用初始化函数。
net.initialize()
这个 initialize() 函数本身并不会立刻初始化所有权重。它只是设置了初始化方法(例如高斯分布、常数等)和目标设备。如果我们再次检查参数,会发现形状依然是一堆零,数据也无法访问,因为有效的内存尚未分配。
真正的初始化时刻
真正的初始化发生在网络第一次处理输入数据时。

X = nd.random.uniform(shape=(2, 20))
Y = net(X)
当我们把形状为 (2, 20) 的输入 X 传入网络后,框架会进行前向传播。在这个过程中:
- 第一层
dense0知道了输入特征数是20,因此其权重形状被推断为(256, 20),并立即按预设方法进行初始化。 - 接着,第一层的输出(256维)作为第二层的输入,因此第二层
dense1的权重形状被推断为(10, 256),并完成初始化。
关键点在于:如果没有指定输入形状,在首次传入实际数据之前,我们无法访问或使用网络的参数。

验证初始化过程
为了更直观地看到初始化发生的时机,我们可以定义一个自定义初始化函数,在其中加入打印语句。
def my_init(weight):
print('Init', weight.name)
weight[:] = nd.random.uniform(low=-10, high=10, shape=weight.shape)
net.initialize(init=my_init)
此时不会有任何输出,因为初始化函数未被调用。

当我们首次传入数据时:
Y = net(X)
在 net(X) 执行前向计算之前,框架会调用我们的 my_init 函数来初始化每一层的参数。这时,我们会在控制台看到“Init dense0_weight”等打印信息。
需要注意的是,初始化通常只在第一次前向传播前发生。如果之后再次调用 net.initialize(),由于网络已经知晓所有参数形状,初始化函数会被立刻调用,这可能会覆盖已训练好的参数。

如何避免延迟初始化
如果你希望定义网络时就立即初始化参数,可以在创建层时直接指定输入大小。
net = nn.Sequential()
net.add(nn.Dense(256, in_units=20))
net.add(nn.Dense(10, in_units=256))
net.initialize()
通过指定 in_units,每一层在创建时就知道其输入维度。此时调用 initialize(),所有参数会立刻被初始化。
总结

本节课中我们一起学习了延迟初始化机制。
- 它的核心优势是灵活性,允许我们在不知道输入数据具体形状的情况下定义网络结构。
- 参数的实际形状确定和内存分配,被推迟到网络第一次执行前向传播时。
- 这对于层数很多的复杂网络(如卷积神经网络)尤为重要,因为手动计算每一层的输入输出维度非常繁琐。
- 如果需要在定义网络后立即初始化,可以通过在层定义中指定
in_units参数来实现。
理解延迟初始化,有助于我们更好地把握深度学习框架的工作流程,并在调试时明白参数何时才真正变得可用。
卷积神经网络课程 P54:卷积基础 🧠
在本节课中,我们将要学习卷积神经网络(CNN)的核心概念——卷积操作。我们将从为什么需要卷积开始,逐步推导其数学形式,并通过直观的例子理解其工作原理。

为什么需要卷积?🤔
上一节我们介绍了多层感知机在处理图像时面临的参数爆炸问题。本节中我们来看看卷积如何巧妙地解决这个问题。
假设我们有一张1200万像素的彩色图像。每个像素有红、绿、蓝三个通道,总共是3600万个数字。如果构建一个仅含100个隐藏单元的单隐藏层多层感知机,参数数量将达到约36亿个(3600万输入 × 100个隐藏单元)。这需要海量的数据来训练,现实中难以实现。

然而,现代图像识别系统效果很好,说明它们使用了不同的方法。卷积神经网络就是关键。
从全连接到卷积 🔄
卷积的思想源于图像的两个关键特性:平移不变性和局部性。
以“寻找瓦尔多”游戏为例。无论瓦尔多出现在图像的哪个位置,他的特征都是相同的。识别他主要依赖局部信息,而非整张图像的全局上下文。
对于一个全连接层,其计算可以表示为:
h_ij = Σ_k Σ_l W_ijkl * x_kl
其中 h_ij 是隐藏单元,x_kl 是输入像素。


通过数学上的重新索引,我们可以将这个公式改写为:
h_ij = Σ_a Σ_b v_ab * x_(i+a)(j+b)
这本质上表达了一个操作:对于输出位置 (i, j),其值由输入图像中以 (i, j) 为中心的局部区域加权求和得到。

卷积的核心思想 🎯
基于平移不变性和局部性的假设,我们可以对上述公式进行两项关键简化:

- 平移不变性:滤波器权重
v不应依赖于其在图像中的绝对位置(i, j),而只应依赖于相对偏移(a, b)。因此,v_ijab简化为v_ab。这极大地减少了参数量。 - 局部性:对于图像中某个点,我们只关心其邻近像素的影响。因此,求和范围
a, b可以从-Δ到+Δ,其中Δ是一个较小的数(如3或5)。

最终,我们得到卷积(更准确说是互相关)的标准公式:
h_ij = Σ_(a=-Δ)^Δ Σ_(b=-Δ)^Δ v_ab * x_(i+a)(j+b)

卷积操作实例 🧮
以下是卷积操作的一个具体例子:

假设我们有一个3x3的输入图像 X:
[[0, 1, 2],
[3, 4, 5],
[6, 7, 8]]
以及一个2x2的卷积核 K:
[[0, 1],
[2, 3]]
计算输出 H 的左上角元素 H[0,0]:
H[0,0] = 0*0 + 1*1 + 2*3 + 3*4 = 0 + 1 + 6 + 12 = 19
然后将卷积核向右滑动,计算 H[0,1]:
H[0,1] = 0*1 + 1*2 + 2*4 + 3*5 = 0 + 2 + 8 + 15 = 25
依此类推,得到最终的2x2输出:
[[19, 25],
[37, 43]]
卷积层的公式与维度 📐

一个基础的卷积层可以形式化定义如下:

- 输入:一个形状为
(n_h, n_w)的二维张量(图像)。 - 卷积核:一个形状为
(k_h, k_w)的二维张量(滤波器)。 - 输出:一个形状为
(n_h - k_h + 1, n_w - k_w + 1)的二维张量。 - 计算:
H[i, j] = Σ_(a=0)^(k_h-1) Σ_(b=0)^(k_w-1) K[a, b] * X[i+a, j+b] + b
其中b是一个可学习的偏置参数。
卷积的应用与扩展 🌐
卷积操作非常强大,可以用于实现各种图像处理功能,例如边缘检测、图像锐化或模糊。更重要的是,在深度学习中,卷积核的权重 K 是通过数据学习得到的,而非手工设计。

卷积的概念可以扩展到其他维度:
- 一维卷积:常用于处理时间序列数据或文本(尽管文本的平移不变性不完全成立)。
- 三维卷积:常用于处理视频(空间+时间)或医学体积图像(如CT扫描)。
- 通道维度:实际中,彩色图像有多个通道(如RGB),卷积核也会对应有多个通道,在空间和通道维度同时进行卷积。

互相关 vs. 卷积 ⚙️
在深度学习的语境下,我们通常执行的是互相关操作,而非严格的数学卷积。两者的区别在于卷积核是否在计算前进行了翻转(旋转180度)。互相关不进行翻转,这更符合计算机内存的顺序访问模式,有利于缓存优化,且不影响模型的学习能力,因为学习算法会自动适应这种形式。

本节课中我们一起学习了卷积神经网络的基础——卷积操作。我们了解了其产生的动机(解决参数过多问题),推导了其数学形式(基于平移不变性和局部性),并通过实例直观理解了其计算过程。卷积是构建现代计算机视觉系统的基石,它通过共享权重和局部连接,极大地提升了网络处理图像的效率和能力。
课程 P55:填充与步幅 🧩
在本节课中,我们将学习卷积神经网络中的两个重要概念:填充和步幅。它们分别用于控制卷积操作后输出特征图的大小,是构建有效网络结构的关键。
概述
当我们对图像应用卷积核时,输出尺寸通常会缩小。经过多层卷积后,图像可能变得非常小,甚至无法继续卷积。此外,有时我们也希望主动降低特征图的尺寸。填充和步幅就是用来解决这些问题的技术。
填充:保持输出尺寸

上一节我们提到了卷积会缩小图像尺寸。本节中我们来看看如何通过填充来保持输出尺寸与输入尺寸一致。
卷积操作的输出尺寸公式为:
输出高度 = 输入高度 - 卷积核高度 + 1
输出宽度 = 输入宽度 - 卷积核宽度 + 1
例如,一个32×32的输入图像,经过一个5×5的卷积核后,会得到一个28×28的输出。经过多层卷积后,图像会迅速变小。
为了解决这个问题,我们可以在输入图像的边缘周围添加额外的像素(通常是值为0的像素),这个过程就是填充。


通过填充,我们可以使卷积后的输出尺寸与原始输入尺寸相同。例如,一个3×3的图像与2×2的卷积核卷积,如果不填充,会得到2×2的输出。如果在周围填充一圈0,再进行卷积,就可以得到更大的输出。
以下是关于填充的一些常见做法:
- 通常使用方形卷积核(高度等于宽度)。
- 通常使用奇数尺寸的卷积核(如3×3,5×5)。
- 填充量通常为
(卷积核尺寸 - 1) / 2。例如,对于3×3卷积核,在上下左右各填充1个像素;对于5×5卷积核,则各填充2个像素。
这种对称填充的方式在构建复杂的网络架构(如Inception)时至关重要,它能确保不同路径输出的特征图在尺寸上保持一致,以便后续进行合并操作。
步幅:主动降低维度

上一节我们学习了如何使用填充来维持尺寸。本节中我们来看看如何使用步幅来主动、快速地降低特征图的维度。
步幅指的是卷积核在输入图像上每次移动的间隔。默认步幅为1,即卷积核每次移动一个像素。如果我们将步幅设置为2,卷积核就会每次跳过1个像素,这样输出的特征图尺寸就会减半。

以下是步幅操作的核心思想:
- 通过跳过一些位置,实现对输入特征的子采样。
- 可以非常高效地减少数据尺寸和计算量。
- 常与池化(如最大池化、平均池化)结合使用,共同实现下采样。

当引入步幅后,输出尺寸的计算公式变为:
输出高度 = floor((输入高度 + 2*填充高度 - 卷积核高度) / 步幅高度 + 1)
输出宽度 = floor((输入宽度 + 2*填充宽度 - 卷积核宽度) / 步幅宽度 + 1)
其中 floor() 表示向下取整,这是因为当剩余空间不足以进行一次完整的卷积核滑动时,操作就会停止。


(图示展示了步幅为2和3时的卷积核移动方式)
幸运的是,在现代深度学习框架(如PyTorch、TensorFlow)中,这些尺寸计算都是自动完成的。框架具备自动的形状推断功能,开发者无需手动计算每一层的输出尺寸,这极大地简化了构建深层网络的复杂度。
总结
本节课中我们一起学习了卷积神经网络中的两个基础概念:
- 填充:通过在输入边缘添加像素(通常是0),可以控制卷积后输出特征图的尺寸,常用方式是保持输入输出尺寸相同。
- 步幅:通过增大卷积核的移动间隔,可以主动降低输出特征图的尺寸,实现下采样并减少计算量。
理解并合理运用填充与步幅,是设计高效、有效卷积神经网络结构的重要一步。


卷积神经网络基础课程 - P56:填充与步长在Python中的实现 🐍
在本节课中,我们将学习如何在Python中实现卷积操作中的填充(Padding)和步长(Stride)参数。我们将通过具体的代码示例,直观地理解这些参数如何影响卷积输出的尺寸。
概述
卷积神经网络(CNN)是深度学习的核心组件。在卷积操作中,除了卷积核本身,填充和步长是两个至关重要的超参数。它们直接决定了输出特征图的大小。本节我们将通过Python代码,手动实现一个简单的卷积过程,以验证不同填充和步长设置下的输出尺寸变化规律。
填充与步长的作用

上一节我们介绍了填充和步长的基本概念。本节中我们来看看如何在代码中应用它们。
填充是指在输入数据周围添加额外的像素(通常为0),以控制输出尺寸的缩小程度。步长则是指卷积核在输入数据上每次滑动的距离,它决定了采样的密度。

在Python中实现卷积
我们将实现一个简单的函数来模拟卷积操作,重点关注输入尺寸、卷积核尺寸、填充和步长如何共同决定输出尺寸。
以下是核心的计算输出尺寸的公式:
输出高度 = floor((输入高度 + 2*填充高度 - 卷积核高度) / 步长高度) + 1
输出宽度 = floor((输入宽度 + 2*填充宽度 - 卷积核宽度) / 步长宽度) + 1
让我们通过代码来验证这个公式。
import numpy as np

def conv2d_output_size(input_size, kernel_size, padding=0, stride=1):
"""
计算二维卷积后的输出尺寸。
参数:
input_size: 输入数据的尺寸 (高度, 宽度)
kernel_size: 卷积核尺寸 (高度, 宽度)
padding: 填充,可以是整数(等高宽)或元组 (高度填充, 宽度填充)
stride: 步长,可以是整数(等高宽)或元组 (高度步长, 宽度步长)
返回:
输出数据的尺寸 (高度, 宽度)
"""
# 处理参数,使其统一为 (高度, 宽度) 格式的元组
if isinstance(padding, int):
padding = (padding, padding)
if isinstance(stride, int):
stride = (stride, stride)
# 应用公式计算
output_height = (input_size[0] + 2*padding[0] - kernel_size[0]) // stride[0] + 1
output_width = (input_size[1] + 2*padding[1] - kernel_size[1]) // stride[1] + 1
return (output_height, output_width)
验证示例
现在,我们使用上面的函数来验证几个具体的例子,观察输出尺寸的变化。
示例1:对称卷积核与填充
假设输入是 8x8 的矩阵,使用 3x3 的卷积核,填充为 1,步长为 1。
# 示例1
input_size = (8, 8)
kernel_size = (3, 3)
padding = 1
stride = 1
output_size = conv2d_output_size(input_size, kernel_size, padding, stride)
print(f"输入尺寸: {input_size}, 卷积核: {kernel_size}, 填充: {padding}, 步长: {stride}")
print(f"输出尺寸: {output_size}")
运行结果将是 (8, 8)。填充为1抵消了3x3卷积核带来的尺寸缩减,因此输入和输出尺寸保持一致。
示例2:引入步长
现在,保持其他参数不变,将步长改为 2。
# 示例2
stride = 2
output_size = conv2d_output_size(input_size, kernel_size, padding, stride)
print(f"\n输入尺寸: {input_size}, 卷积核: {kernel_size}, 填充: {padding}, 步长: {stride}")
print(f"输出尺寸: {output_size}")
运行结果将是 (4, 4)。因为步长为2,卷积核每次跳过两个像素,所以输出尺寸减半。
示例3:非对称参数
以下是一个更复杂的例子,使用非对称的卷积核、填充和步长。
假设输入是 8x8,卷积核为 3x5(高3宽5)。我们在高度上不填充(padding_height=0),在宽度上填充1(padding_width=1)。步长在高度方向为3,宽度方向为4。
# 示例3
input_size = (8, 8)
kernel_size = (3, 5) # 高度3,宽度5
padding = (0, 1) # 高度填充0,宽度填充1
stride = (3, 4) # 高度步长3,宽度步长4
output_size = conv2d_output_size(input_size, kernel_size, padding, stride)
print(f"\n输入尺寸: {input_size}, 卷积核: {kernel_size}, 填充: {padding}, 步长: {stride}")
print(f"输出尺寸: {output_size}")
我们来手动验证一下宽度方向的计算:
- 输入宽度:8
- 宽度填充:1(两边),所以有效宽度为 8 + 1*2 = 10
- 卷积核宽度:5
- 宽度步长:4
- 输出宽度 = floor((10 - 5) / 4) + 1 = floor(5/4) + 1 = 1 + 1 = 2
高度方向的计算:
- 输入高度:8
- 高度填充:0,所以有效高度为 8
- 卷积核高度:3
- 高度步长:3
- 输出高度 = floor((8 - 3) / 3) + 1 = floor(5/3) + 1 = 1 + 1 = 2
因此,最终输出尺寸为 (2, 2),与代码计算结果一致。这个例子清晰地展示了非对称参数如何影响最终的输出形状。
总结

本节课中我们一起学习了填充和步长在卷积操作中的具体实现。我们通过Python代码定义了一个计算卷积输出尺寸的函数,并用三个例子进行了验证:
- 对称的填充可以保持输出尺寸不变。
- 步长大于1会减小输出尺寸,实现下采样。
- 卷积核尺寸、填充和步长都可以是非对称的,需要分别计算高度和宽度方向的结果。
理解一次这个计算过程非常重要,之后在实际应用中,我们就可以依赖框架(如PyTorch、TensorFlow)或写好的函数来自动处理这些计算,而将重点放在网络结构的设计上。
卷积神经网络教程 P57:57. L11_6 通道 🧠
在本节课中,我们将要学习卷积神经网络中的一个核心概念:通道。我们将从灰度图像开始,逐步理解如何处理彩色图像中的多个通道,以及如何通过卷积操作生成多个输出通道。最后,我们会探讨计算成本,并理解为何GPU对深度学习如此重要。
从灰度图像到彩色图像 🖼️
到目前为止,我们主要处理的是灰度图像。这类图像只有亮度信息,像素值通常在0到255之间,用于表示从黑到白的亮度级别。例如,在Fashion MNIST数据集中,我们使用的就是这种单通道图像。
然而,灰度图像无法完全展现真实世界的丰富细节。请看下面这张著名的“Lena”测试图。它之所以在图像处理领域广为人知,是因为它包含了光滑的皮肤、纹理丰富的羽毛、模糊的背景和锐利的边缘等多种复杂细节,是测试算法的绝佳样本。

如果仅将其视为灰度图像,我们会丢失大量信息。为了处理彩色图像,我们需要理解通道的概念。

理解输入通道 🔴🟢🔵
一张彩色图像通常由红、绿、蓝三个颜色通道叠加而成。下图展示了“Lena”图被拆分为三个独立通道后的样子,每个通道都承载了不同类型的信息。

在卷积神经网络中,处理多通道输入的方法非常直观。我们不再使用单一的卷积核,而是为每个输入通道配备一个卷积核。网络会分别对每个通道进行卷积运算,然后将所有通道的卷积结果相加,最终得到一个二维的输出特征图。
这个过程可以用以下公式描述:
输出[y, x] = 求和(输入通道c * 卷积核c[y, x]) + 偏置

其中,输入数据的维度是 [通道数, 高度, 宽度],卷积核的维度也相应地变为 [输入通道数, 高度, 宽度]。
引入输出通道 🎯

上一节我们介绍了如何处理多个输入通道,本节中我们来看看为什么以及如何生成多个输出通道。
单一的输出通道可能不足以捕捉图像中所有有用的特征。例如,我们可能希望同时检测垂直边缘、对角线、圆形区域或特定颜色区域。因此,我们需要多个输出通道,每个通道可能负责识别一种特定的模式。
实现方法很简单:我们不再只使用一组卷积核,而是使用多组卷积核。每组核都会与所有输入通道进行卷积,并生成一个独立的输出通道。最后,我们将这些输出通道在深度方向上堆叠起来,形成一个三维的输出张量。
此时,卷积核的维度变为 [输出通道数, 输入通道数, 高度, 宽度]。


特殊的1x1卷积层 ⚙️
在众多卷积操作中,1x1卷积是一个特殊且重要的存在。它听起来有些奇怪,因为1x1的核似乎只与单个像素进行运算。

实际上,1x1卷积的作用并非进行空间上的特征提取,而是跨通道的信息整合。它对所有输入通道的像素值进行线性组合(相当于一个全连接层),然后可能加上非线性激活函数。这等同于在每个像素位置应用了一个小型的前馈神经网络。
因此,1x1卷积常被用来升维、降维(减少计算量)或增加非线性,是构建高效网络(如GoogLeNet中的Inception模块)的关键组件。

计算成本与硬件选择 💻

一个标准的二维卷积层,其计算成本主要取决于以下几个因素:
以下是影响计算量的主要参数:
- 输入通道数 (Ci):需要处理的通道数量。
- 输出通道数 (Co):需要生成的通道数量。
- 卷积核尺寸 (Kh, Kw):决定了每次卷积操作的浮点运算次数。
- 输出特征图尺寸 (Ho, Wo):需要计算的位置数量。
总计算量可以粗略估算为:Ci * Co * Kh * Kw * Ho * Wo 次浮点运算。
让我们看一个例子:假设输入输出通道均为100,使用5x5卷积核,处理64x64的图像。单次卷积的计算量约为1 GFLOP(十亿次浮点运算)。对于一个10层的网络,在100万张图像上训练,总计算量将达到10 PFLOP(千万亿次)。
- 在每秒150 GFLOP的CPU上,这可能需要约18小时。
- 在每秒12 TFLOP(万亿次)的现代GPU上,相同任务可能只需14分钟。
这就是为什么在深度学习,尤其是处理图像等大数据时,使用GPU进行加速变得至关重要。

总结 📝
本节课中我们一起学习了卷积神经网络中“通道”的核心概念。
- 输入通道:彩色图像包含多个通道(如RGB),卷积核需要与之匹配,分别卷积后相加。
- 输出通道:使用多组卷积核可以生成多个输出通道,以捕捉不同类型的特征。
- 1x1卷积:这是一种特殊的卷积,用于跨通道的信息融合与维度变换。
- 计算考量:卷积网络的计算量巨大,其成本与通道数、核尺寸等线性相关,这解释了GPU在深度学习训练中的必要性。

理解通道是如何在卷积层中流动和变换的,是构建和理解复杂卷积神经网络的基础。
课程 P58:Python中的通道 🐍
在本节课中,我们将学习如何在Python中处理卷积神经网络中的多输入通道与多输出通道。我们将通过具体的代码示例,理解如何对多通道输入应用卷积核,以及如何生成多通道输出。

概述
卷积神经网络在处理图像等数据时,常常需要处理多个输入通道(例如RGB图像的三个颜色通道)并生成多个输出通道(即多个特征图)。本节将介绍如何用Python实现这些操作,并解释其背后的核心概念。
多输入通道
上一节我们介绍了单通道的卷积操作。本节中我们来看看如何处理具有多个输入通道的情况。
当输入数据有多个通道时,我们需要为每个输入通道配备一个独立的卷积核。计算时,分别对每个通道进行卷积操作,然后将所有通道的结果相加,得到最终的输出特征图。
以下是实现多输入通道卷积的关键步骤:

- 为每个输入通道定义对应的卷积核。
- 对每个通道的输入应用其对应的卷积核进行卷积运算。
- 将所有通道的卷积结果相加,得到单通道的输出。
让我们通过代码来具体实现。首先,我们导入必要的库并定义一个基础的二维互相关运算函数。
import torch
from torch import nn
def corr2d(X, K):
"""计算二维互相关运算。"""
h, w = K.shape
Y = torch.zeros((X.shape[0] - h + 1, X.shape[1] - w + 1))
for i in range(Y.shape[0]):
for j in range(Y.shape[1]):
Y[i, j] = (X[i:i + h, j:j + w] * K).sum()
return Y
接下来,我们实现处理多输入通道的函数。该函数接受一个多通道输入张量 X 和一个多通道卷积核张量 K,并返回卷积结果。
def corr2d_multi_in(X, K):
# 对每个输入通道进行互相关计算,然后求和
return sum(corr2d(x, k) for x, k in zip(X, K))

现在,我们构造一个具有两个通道的输入张量 X 和一个对应的两通道卷积核 K,并应用上述函数。
# 构造一个2通道的3x3输入
X = torch.tensor([[[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]],
[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]])
# 构造一个2通道的2x2卷积核
K = torch.tensor([[[0.0, 1.0], [2.0, 3.0]],
[[1.0, 2.0], [3.0, 4.0]]])
output = corr2d_multi_in(X, K)
print(output)
运行上述代码,我们将得到一个2x2的输出矩阵,这正是分别对两个通道卷积后相加的结果。
多输出通道

理解了多输入通道后,我们进一步探讨如何生成多个输出通道。每个输出通道可以捕捉输入数据中不同类型或不同抽象层次的特征。
为了实现多输出通道,我们只需为每个输出通道准备一组独立的卷积核。每组卷积核的通道数需要与输入数据的通道数相匹配。计算时,每组核独立地与输入进行卷积,生成一个输出通道。
以下是实现多输出通道卷积的步骤:
- 定义卷积核张量,其形状为
(输出通道数, 输入通道数, 核高度, 核宽度)。 - 对每个输出通道,使用其对应的那组卷积核与输入进行多通道卷积。
- 将所有输出通道的结果堆叠起来,形成最终的多通道输出。
我们扩展之前的函数,使其支持多输出通道。
def corr2d_multi_in_out(X, K):
# K的形状: (输出通道数, 输入通道数, 核高度, 核宽度)
# 对每个输出通道,计算其与输入X的互相关结果
return torch.stack([corr2d_multi_in(X, k) for k in K], 0)
现在,我们构造一个具有3个输出通道的卷积核 K。我们可以通过简单地在现有核 K 的基础上加一个常数来生成新的核,以模拟不同的滤波器。
# 在原始卷积核K的基础上,通过加常数构造3个输出通道的核
K = torch.stack((K, K + 1, K + 2), 0)
# 此时K的形状为 torch.Size([3, 2, 2, 2])
# 含义:3个输出通道,2个输入通道,2x2的卷积核
output_multi = corr2d_multi_in_out(X, K)
print(output_multi.shape) # 输出 torch.Size([3, 2, 2])
print(output_multi)
运行代码后,我们得到一个形状为 (3, 2, 2) 的张量,表示有3个输出通道,每个通道是一个2x2的特征图。
1x1卷积层
最后,我们来看一个特殊的卷积操作:1x1卷积。它不进行空间维度(高和宽)上的特征提取,其核心作用是在通道维度上进行信息整合与变换。
1x1卷积可以理解为在每个像素位置上,对所有的输入通道值进行的一次全连接层计算。假设输入有 c_i 个通道,输出需要 c_o 个通道,那么1x1卷积核实际上是一个 c_o x c_i 的权重矩阵。
以下是1x1卷积的实现方式。我们首先将输入和核调整形状,然后利用矩阵乘法高效计算。
def corr2d_1x1(X, K):
"""1x1卷积,通过矩阵乘法实现。"""
c_i, h, w = X.shape
c_o = K.shape[0]
# 将X的形状转换为 (c_i, h*w),将K的形状转换为 (c_o, c_i)
X_reshaped = X.reshape((c_i, h * w))
K_reshaped = K.reshape((c_o, c_i))
# 全连接层中的矩阵乘法
Y = torch.matmul(K_reshaped, X_reshaped)
# 将输出形状转换回 (c_o, h, w)
return Y.reshape((c_o, h, w))
为了验证其正确性,我们将其与使用标准卷积函数 corr2d_multi_in_out 计算1x1卷积的结果进行比较。
# 构造一个3通道的3x3输入
X_1x1 = torch.normal(0, 1, (3, 3, 3))
# 构造一个2输出通道的1x1卷积核
K_1x1 = torch.normal(0, 1, (2, 3, 1, 1))
# 方法1:使用我们实现的1x1专用函数
Y1 = corr2d_1x1(X_1x1, K_1x1)
# 方法2:使用通用的多通道卷积函数
Y2 = corr2d_multi_in_out(X_1x1, K_1x1)
print(torch.abs(Y1 - Y2).sum() < 1e-6) # 输出应为 True,表明结果相同
两种方法得到的结果在数值上是相等的,这证实了1x1卷积的本质是在每个像素位置应用一个微型全连接网络,对通道信息进行混合与变换。
总结
本节课中我们一起学习了Python中处理卷积通道的关键技术。

我们首先介绍了多输入通道的卷积,其方法是为每个输入通道配备卷积核,分别计算后求和。接着,我们探讨了多输出通道的生成,即为每个输出通道准备独立的卷积核组。最后,我们深入分析了1x1卷积层,它不感知空间信息,专门用于融合和变换通道特征,其计算等价于像素位置上的全连接操作。
理解这些通道操作是构建复杂卷积神经网络的基础,它们使得网络能够从输入中提取丰富且层次化的特征。
课程 P59:59. L11_8 池化 🧊
在本节课中,我们将要学习卷积神经网络中的池化层。池化是一种简单但非常有效的操作,它可以帮助网络获得对平移、尺度等变化的不变性,从而提升模型的鲁棒性。
池化的作用与动机
上一节我们介绍了卷积层,本节中我们来看看池化层。池化操作理解起来可能更为直观。让我们从一个简单的例子开始。
假设我们有一张非常原始的5x4像素图像,其左侧两列像素值为1,其余部分全为0。
如果对此图像应用一个边缘检测器,我们会在第二列检测到一个边缘。此时,如果我将图像向左或向右移动1个像素,这个边缘的位置也会随之移动1个像素。
虽然1个像素的位移听起来不多,但如果我们希望网络提取的特征具有平移不变性,即特征不依赖于其在图像中的精确位置,那么这种对位置的敏感性就会成为问题。特征完全依赖于其发生的具体位置。
因此,我们需要一种机制来为特征提供对平移、光照、物体位置、尺度、外观等变化的不变性。


这正是池化操作发挥作用的地方。
最大池化操作
接下来,我们详细看看最常用的池化操作之一:2D最大池化。
最大池化的操作流程如下:它接受一个输入(例如一个特征图),并定义一个滑动窗口(例如2x2大小)。对于窗口覆盖的每个区域,我们只取该区域内所有值的最大值作为输出。然后,窗口按照设定的步长(例如向右移动一个单元)滑动,重复此操作。
如果我们对一个3x3的输入进行2x2的最大池化(步长为1),我们会得到一个2x2的输出。在尺寸变化的语义上,这与卷积操作完全相同。不同之处在于,我们不再进行乘法和加法运算,而是执行取最大值的操作。
公式表示:对于一个2x2的池化窗口,输出值 output[i, j] 的计算方式为:
output[i, j] = max(input[i:i+2, j:j+2])
或者,如果我有一张4x4的图像,并在3x3的窗口上执行最大池化(步长为1),那么我将得到一个2x2的输出,正如我们在右侧图中看到的那样。


池化如何提供平移不变性
现在,让我们回到最初的例子,看看池化如何帮助实现平移不变性。

假设我们有一个垂直边缘检测器对图像进行卷积,得到了一个特征图。现在,对这个特征图应用2x2的最大池化。你会发现,即使原始图像中的边缘发生了1个像素的位移,经过池化后的输出特征可能保持不变或变化很小。

这正是我们想要的效果:池化使网络对微小的位置变化变得具有容忍性。
池化的参数:填充与步幅
与卷积层类似,池化层也可以使用填充和步幅来控制输出尺寸。
以下是关于池化参数的关键点:
- 输入/输出通道:池化是按通道独立进行的,因此输入和输出的通道数相同。
- 步幅:决定了滑动窗口每次移动的像素数。
- 可学习参数:池化层没有需要训练的参数(如权重)。
- 填充:如果你想保持输出特征图的空间尺寸(高度和宽度)不变,可以使用填充。例如,对一个输入应用3x3的最大池化,并在四周各填充1个像素,同时设置步长为1,就能得到尺寸不变的输出。
如果我想降低特征图的分辨率(这是一种常见的做法,可以逐步减少计算量并扩大感受野),我可以使用更大的步幅。例如,一个3x3的池化窗口,配合填充为1、步幅为2的设置,就会使输出尺寸减半。


平均池化与其他形式
除了最大池化,另一种常见的操作是平均池化。
平均池化的做法是:对于滑动窗口覆盖的每个区域,计算该区域内所有像素值的平均值,并将这个平均值作为输出。
公式表示:对于一个2x2的池化窗口,输出值 output[i, j] 的计算方式为:
output[i, j] = mean(input[i:i+2, j:j+2])
这是一个非常直观的操作。例如,当你用相机拍摄一张高分辨率照片,然后在软件中缩小图像尺寸时,图像处理软件执行的操作本质上就是一种平均池化,它在更大范围的像素上进行平均来生成新的像素值。
在卷积神经网络的发展早期,平均池化被广泛使用。但随着研究的深入,人们发现从平均池化转向最大池化通常能带来更高的识别准确率。因此,最大池化成为了当前的主流选择。
一个重要的例外是在网络的最后一层,有时会使用全局平均池化。这种操作会对整个特征图的每个通道计算平均值,得到一个固定长度的向量,常用于分类任务。我们将在后续课程中详细讨论这一点。


总结
本节课中我们一起学习了卷积神经网络中的池化层。我们了解了池化的核心目的是为网络特征提供平移不变性等鲁棒性。我们重点讲解了最大池化的操作方式,即取局部区域的最大值,并介绍了如何通过填充和步幅来控制输出尺寸。此外,我们还简要对比了平均池化,并指出最大池化是目前更常用的选择。池化层没有可学习参数,结构简单,但却是构建高效、鲁棒卷积神经网络的关键组件之一。
课程 P6:6. NDArrays 入门教程 📊
在本节课中,我们将学习深度学习框架 MXNet 中的核心数据结构——NDArray。我们将了解它的基本概念、创建方法、常用操作以及与 NumPy 的交互。掌握 NDArray 是进行高效数值计算和深度学习的基础。
1. 数组与张量:多维数据的表示
上一节我们介绍了线性代数的基本概念,本节中我们来看看这些向量和矩阵在计算机中是如何存储和表示的。
一个标量就是一个零维数组。
一个向量是一个一维数组。
一个二维数组可以表示一个矩阵,例如数据集中的特征行(每个观测对应一行属性)。
对于更高维度:
- 三维数组:可以表示一张黑白图像(高度 x 宽度 x 1),或一张彩色图像(高度 x 宽度 x 3个颜色通道 RGB)。
- 四维数组:可以表示一个图像批次(样本数 x 高度 x 宽度 x 通道数)。
- 五维数组:可以表示视频数据(时间帧 x 样本数 x 高度 x 宽度 x 通道数)。
超过五维在视觉上展示会变得困难,但原理相同。张量(Tensor) 是这些多维数组的统称,其概念并不复杂。
2. 创建与访问 NDArray


我们可以像使用 NumPy 一样创建和访问 NDArray。


首先需要导入 MXNet 的 NDArray 模块:
import mxnet.ndarray as nd
以下是创建数组的几种基本方法:
- 创建连续数字的数组:使用
arange函数。x = nd.arange(12) # 创建一个包含0到11的一维数组 - 查询数组形状与大小:
x.shape返回数组的形状,例如(12,)。x.size返回数组中元素的总数,例如12。
- 改变数组形状:使用
reshape函数。
可以使用x = x.reshape((3, 4)) # 将一维数组重塑为3行4列的二维数组-1自动推断某一维度的大小:x = x.reshape((3, -1)) # 自动推断第二维为4 - 创建未初始化数组:使用
empty。注意:其内容为内存中的任意值,并非全零。a = nd.empty((3, 4)) # 类似 malloc,分配内存但不初始化 - 创建全零或全一阵列:
z = nd.zeros((3, 4)) o = nd.ones((3, 4)) - 从列表创建指定数组:
nd.array([[1, 2, 3], [4, 5, 6]]) - 创建随机数组:例如,从标准正态分布中采样。
nd.random.normal(0, 1, shape=(3, 4))
访问元素的方式与 NumPy 完全一致,可以通过索引访问单个元素、一行、一列或一个子块。
3. NDArray 的基本运算
NDArray 支持丰富的数学运算。
- 按元素运算:使用
+,-,*,/,**等运算符直接在相同形状的数组间进行运算。x + y, x - y, x * y, x / y, x ** y # 均为按元素操作 - 指数、三角函数等:
nd.exp(x), nd.sin(x), nd.cos(x) - 矩阵乘法(点积):使用
dot函数。重要:*是按元素乘,dot()才是矩阵乘。nd.dot(x, y.T) # 计算 x 和 y 转置的矩阵乘积 - 连接数组:使用
concat函数,可以沿指定轴连接多个数组。nd.concat(x, y, dim=1) # 沿第1轴(列)连接 - 逻辑比较:返回布尔值数组。
x == y - 求和:
sum()函数可以对整个数组或沿特定轴求和。x.sum(), x.sum(axis=0) # 全局求和,沿第0轴(行)求和 - 转换为 Python 标量:
x.asscalar() # 当数组只有一个元素时,将其转换为 Python 标量
4. 广播机制
广播机制是处理不同形状数组间运算的强大工具。其核心思想是:通过适当复制数据,使两个数组的形状兼容,从而进行按元素运算。
规则:从尾部维度开始,逐一比较。维度大小要么相等,要么其中一个为1,要么其中一个不存在(维度大小为1被自动扩展)。
例如,一个 (3, 1) 的数组 A 与一个 (1, 2) 的数组 B 相加:
A (3x1) + B (1x2) -> 结果 (3x2)
运算时,A 的列被复制到2列,B 的行被复制到3行,然后进行按元素加法。这类似于线性代数中的外积运算。
广播机制可以避免使用显式的 for 循环,极大提升代码效率和可读性。
5. 索引、切片与内存管理
索引和切片用于访问或修改数组的特定部分。
x[0:2, :] = 12 # 将前两行的所有列元素设置为12
关于内存,需要注意赋值操作可能产生新对象:
y = x + y # 创建新数组,y 指向新的内存地址
为了节省内存,特别是处理大数组时,应使用原地操作:
# 方法一:使用全切片赋值
z[:] = x + y # 结果写入z的原始内存
# 方法二:使用原地操作符
y += x # 等价于 y[:] = y + x,但更高效
# 方法三:调用明确的原地函数
nd.elemwise_add(x, y, out=y)
此外,zeros_like 或 ones_like 可以方便地创建与目标数组形状相同的全零或全一阵列。
nd.zeros_like(y)
6. 与 NumPy 的互操作
在实际项目中,常需要将 NumPy 数据转换为 NDArray 以供 MXNet 计算,或将结果转换回 NumPy 进行后续处理。
- 从 NumPy 转到 NDArray:
np_array = np.ones((2, 3)) nd_array = nd.array(np_array) # MXNet 知道如何转换 NumPy 对象 - 从 NDArray 转到 NumPy:
np_array = nd_array.asnumpy()
重要提示:这种转换会触发数据在 CPU 内存中的实际拷贝,并可能引起 Python 全局解释器锁(GIL)的争用。如果频繁进行,会显著降低程序性能。因此,应尽量减少这种转换,尤其是在循环内部。
7. 总结与资源
本节课中我们一起学习了 MXNet 中 NDArray 的核心知识:
- 概念:理解了标量、向量、矩阵到高阶张量的关系。
- 创建:掌握了使用
arange,zeros,ones,random,array等方法创建数组。 - 运算:学会了按元素运算、矩阵乘法、广播机制等关键操作。
- 操作:熟悉了索引、切片以及节省内存的原地操作。
- 交互:了解了与 NumPy 互操作的方法及其性能注意事项。
为了深入学习,你可以参考以下资源:
- 本书 NDArray 章节:提供更系统的讲解。
- MXNet 官网 (beta.mxnet.io):查阅 NDArray API 文档和教程。
- 线性代数复习:如果对基础概念不熟,建议复习线性代数中的点积、外积等概念。
对于想了解硬件加速(如 GPU)原理的同学,可以搜索相关的高性能计算(HPC)课程资料,它们通常用直观的方式解释了 GPU 如何通过大量并行处理单元高效执行线性代数运算,而像 MXNet 这样的框架则帮助我们避免了直接编写复杂的 CUDA 代码。

掌握 NDArray 是开启深度学习实践的第一步,请务必动手练习以巩固理解。
课程 P60:Python中的池化操作 🧮
在本节课中,我们将学习如何在Python中实现池化操作。池化是卷积神经网络中的一种重要技术,用于降低特征图的空间维度,同时保留最重要的信息。我们将从定义池化函数开始,逐步探讨最大池化和平均池化,并了解如何通过填充和步幅来控制输出尺寸。
概述
池化操作的核心目的是对输入数据的局部区域进行下采样,以减少计算量并增强特征的鲁棒性。本节将手动实现一个基础的池化函数,并使用MXNet的Gluon API进行更复杂的操作演示。
定义池化函数
首先,我们需要导入必要的库并定义一个基础的池化函数。这个函数将接收输入张量 X 和池化窗口大小作为参数。
import mxnet as mx
from mxnet import nd
def pool2d(X, pool_size, mode='max'):
"""
基础的2D池化函数实现。
参数:
X: 输入张量,形状为 (height, width)
pool_size: 池化窗口大小,例如 (2, 2)
mode: 池化模式,'max' 或 'avg'
"""
p_h, p_w = pool_size
# 计算输出形状(无填充,步幅为1)
Y_h = X.shape[0] - p_h + 1
Y_w = X.shape[1] - p_w + 1
# 初始化输出张量
Y = nd.zeros((Y_h, Y_w))
# 遍历输出张量的每个位置
for i in range(Y_h):
for j in range(Y_w):
# 提取当前池化窗口区域
region = X[i: i + p_h, j: j + p_w]
if mode == 'max':
Y[i, j] = region.max()
elif mode == 'avg':
Y[i, j] = region.mean()
return Y
这个实现非常简单,它忽略了通道维度、填充和步幅,仅用于说明池化的基本原理。接下来,我们将通过一个具体例子来观察其效果。
池化操作示例

让我们对一个简单的矩阵应用池化操作,以直观理解其工作原理。

假设我们有一个包含数字0到8的3x3矩阵:
X = nd.array([[0, 1, 2],
[3, 4, 5],
[6, 7, 8]])
最大池化

应用2x2的最大池化:
Y_max = pool2d(X, pool_size=(2, 2), mode='max')
# 输出: [[4., 5.],
# [7., 8.]]
对于第一个2x2窗口 [[0,1],[3,4]],最大值是4。依此类推,得到最终结果。
平均池化

应用2x2的平均池化:

Y_avg = pool2d(X, pool_size=(2, 2), mode='avg')
# 输出: [[2., 3.],
# [5., 6.]]
对于第一个窗口,平均值是 (0+1+3+4)/4 = 2。第二个窗口 [[1,2],[4,5]] 的平均值是 (1+2+4+5)/4 = 3。
通过以上示例,我们看到了池化如何压缩数据。然而,实际应用中我们还需要考虑填充和步幅。
使用Gluon进行池化

上一节我们手动实现了基础的池化。在实际深度学习中,我们使用框架提供的高效API。本节中我们来看看如何使用MXNet的Gluon模块进行更灵活的池化操作。
Gluon的 nn 模块提供了 MaxPool2D 和 AvgPool2D 层,它们支持填充、步幅和多通道输入。
首先,我们创建一个4x4的输入张量:

X = nd.arange(16).reshape((1, 1, 4, 4)) # 形状:(批量大小, 通道数, 高, 宽)

默认池化
创建一个3x3的最大池化层,默认步幅与池化窗口大小相同(即3):
from mxnet.gluon import nn
pool = nn.MaxPool2D(pool_size=3)
Y = pool(X)
# 输出形状: (1, 1, 1, 1)
# 输出值: [[[[10.]]]]
由于4x4的输入在应用3x3池化且步幅为3后,只有一个完整的窗口,其最大值为10。

自定义填充和步幅
我们可以通过指定 padding 和 strides 参数来改变输出尺寸。
以下是使用填充为1、步幅为2的3x3最大池化示例:

pool = nn.MaxPool2D(pool_size=3, padding=1, strides=2)
Y = pool(X)
# 输出形状: (1, 1, 2, 2)
填充将输入“扩大”到6x6,然后步幅为2的3x3池化产生了2x2的输出。

我们也可以使用非对称的窗口、填充和步幅:
pool = nn.MaxPool2D(pool_size=(2, 3), padding=(1, 2), strides=(2, 3))
Y = pool(X)
# 输出形状: (1, 1, 3, 2) # 3行,2列
多通道输入

池化操作的一个重要特性是它独立应用于每个输入通道,因此输出通道数与输入通道数保持一致。
让我们创建一个具有两个通道的输入:

X_multi = nd.stack(nd.arange(16).reshape(4,4),
nd.arange(1,17).reshape(4,4))
X_multi = X_multi.reshape((1, 2, 4, 4)) # 形状: (1, 2, 4, 4)
应用相同的池化层:
pool = nn.MaxPool2D(pool_size=3, padding=1, strides=2)
Y_multi = pool(X_multi)
# 输出形状: (1, 2, 2, 2)
第一个输出通道的结果与之前单通道示例一致。第二个通道独立进行池化,结果基于其自身的数值。

总结
本节课中我们一起学习了Python中的池化操作。我们从手动实现一个基础的池化函数开始,理解了最大池化和平均池化的核心计算过程,其公式可以概括为对局部区域取最大值或平均值。随后,我们探讨了如何使用MXNet Gluon API进行更实际、更灵活的池化操作,包括如何通过填充和步幅来控制输出特征图的空间尺寸。重要的是,池化操作独立作用于每个输入通道,因此不改变通道数量。池化是一种简单而强大的下采样工具,在卷积神经网络中用于逐步减少数据的空间维度,同时保留关键特征。
课程 P61:61. L12_1 LeNet 👨🏫
在本节课中,我们将学习卷积神经网络(CNN)的经典架构之一——LeNet。我们将回顾其历史背景、网络结构,并理解它是如何应用于手写数字识别任务的。通过本节课,你将掌握LeNet的基本构成及其在现代深度学习中的意义。

历史背景与动机 📜
上一节我们介绍了卷积神经网络的基本组件。本节中,我们来看看这些组件如何被整合成一个具体的网络——LeNet。
LeNet是上世纪90年代提出的网络,其最著名的版本LeNet-5大约在1995年由Yann LeCun及其团队提出。该网络最初是为低分辨率的黑白物体识别任务设计的,特别是手写数字识别。
当时,AT&T有一个实际项目,需要识别信件上的邮政编码以及支票上的金额。因此,开发能够自动、准确识别手写数字的算法具有重要的商业价值。虽然这对人类来说相对简单,但对机器而言却是一个不小的挑战。

为了服务于这一目的,MNIST数据集被创建出来。该数据集包含了居中和缩放后的手写数字图像,共有60,000个训练样本和10,000个测试样本,每张图像的分辨率为28x28像素,对应10个数字类别(0-9)。
LeNet网络架构 🏗️
了解了历史背景后,我们深入探讨LeNet的具体网络结构。LeNet-5是一个相对简单的卷积神经网络,但其设计思想影响深远。
以下是LeNet-5的典型结构流程:
- 输入层:接收32x32像素的灰度图像。
- 第一卷积层:使用6个5x5的卷积核,生成6个28x28的特征图。
- 公式:
输出尺寸 = (输入尺寸 - 卷积核尺寸 + 2*填充) / 步幅 + 1。此处为(32 - 5 + 0) / 1 + 1 = 28。
- 公式:
- 第一池化层:进行2x2的平均池化,将特征图下采样至14x14。
- 第二卷积层:使用16个5x5的卷积核,生成16个10x10的特征图。
- 第二池化层:再次进行2x2的平均池化,将特征图下采样至5x5。
- 第一个全连接层:将上一层的输出展平,连接120个神经元。
- 第二个全连接层:连接84个神经元。
- 输出层:最初使用高斯RBF单元,现代实现中通常替换为包含10个神经元的全连接层(对应10个数字类别)和Softmax激活函数。

值得注意的是,池化操作不改变通道数,而全连接层参数量巨大,这在处理更多类别(如ImageNet的1000类)时会成为瓶颈,后来的网络设计都致力于解决这个问题。
LeNet的实现与效果 ⚙️
理解了理论架构后,我们来看看如何用现代深度学习框架简洁地实现LeNet,并观察其效果。
使用如PyTorch或TensorFlow等框架,实现LeNet变得非常直观。以下是核心思路:
# 伪代码示例,展示层序结构
model = Sequential([
Conv2D(filters=6, kernel_size=5, activation='sigmoid'),
AvgPool2D(pool_size=2, strides=2),
Conv2D(filters=16, kernel_size=5, activation='sigmoid'),
AvgPool2D(pool_size=2, strides=2),
Flatten(),
Dense(units=120, activation='sigmoid'),
Dense(units=84, activation='sigmoid'),
Dense(units=10, activation='softmax')
])
在实际运行中,网络能够有效识别手写数字。通过可视化,我们可以观察到:
- 第一层卷积后,激活图主要对应边缘、角点等基础特征检测器。
- 更深层的特征图则对应更复杂、更抽象的组合特征。
- 网络对数字的平移具有一定的不变性,识别效果稳定。

LeCun等人1998年发表的论文是里程碑式的著作,其中详细介绍了网络设计、训练技巧以及图形转换器等概念,为后续研究奠定了坚实基础。
总结 📝
本节课中,我们一起学习了卷积神经网络的经典开山之作——LeNet。

我们首先回顾了其诞生的历史背景和要解决的实际问题(支票手写数字识别)。接着,我们详细剖析了LeNet-5的网络架构,从输入到输出,逐步理解了卷积层、池化层和全连接层的组合方式。最后,我们了解了其现代实现方式以及展现出的有效识别能力。

LeNet虽然结构相对简单,但它成功验证了卷积神经网络在视觉任务上的潜力,其设计思想至今仍在影响着深度学习模型的发展。在下一节课中,我们将继续学习更复杂、更强大的网络,如AlexNet和VGG。
课程 P62:62. 使用 Python 实现 LeNet 🐍

在本节课中,我们将学习如何使用 Python 和 MXNet 深度学习框架来实现经典的 LeNet-5 卷积神经网络。我们将从网络架构的定义开始,逐步完成数据加载、模型训练和评估的完整流程。
1. 网络架构回顾与定义 🏗️
首先,我们来回顾一下 LeNet-5 的网络架构。它由以下层顺序组成:输入图像、卷积层、池化层、第二个卷积层、第二个池化层,以及几个全连接层。
以下是使用 MXNet 的 gluon 模块定义该网络的具体代码:

from mxnet.gluon import nn
net = nn.Sequential()
net.add(
nn.Conv2D(channels=6, kernel_size=5, padding=2, activation='sigmoid'),
nn.AvgPool2D(pool_size=2, strides=2),
nn.Conv2D(channels=16, kernel_size=5, activation='sigmoid'),
nn.AvgPool2D(pool_size=2, strides=2),
nn.Dense(120, activation='sigmoid'),
nn.Dense(84, activation='sigmoid'),
nn.Dense(10)
)
在上述代码中,我们定义了一个顺序模型。第一个卷积层使用 6 个 5x5 的卷积核,并添加了填充(padding)以保持特征图尺寸。接着是一个平均池化层。第二个卷积层使用 16 个 5x5 的卷积核。最后是三个全连接层,其中最后一个输出 10 个类别。全连接层会自动处理输入数据的展平(flatten)操作。
2. 数据流验证 🔍

上一节我们定义了网络结构,本节中我们来看看数据在网络中的流动过程,以验证架构是否正确。
当我们输入一个 28x28 的单通道图像时,数据流如下:
- 经过第一个卷积层(含填充)后,得到 6 个 28x28 的特征图。
- 经过第一个池化层(步幅为 2)后,特征图尺寸减半,变为 6 个 14x14。
- 经过第二个卷积层(无填充)后,根据公式
输出尺寸 = (输入尺寸 - 卷积核尺寸 + 1),得到 16 个 10x10 的特征图。 - 经过第二个池化层后,尺寸再次减半,变为 16 个 5x5 的特征图。
- 数据被自动展平为 16 * 5 * 5 = 400 个特征,并输入到后续的全连接层。
这个过程与我们的预期完全一致。需要注意的是,在 MXNet 中,网络参数的实际初始化是在第一次将数据传入网络时完成的。
3. 准备训练工具 🛠️
定义好网络后,我们需要准备训练所需的工具,包括数据加载器和设备上下文。
以下是准备工作的代码:

from mxnet import autograd, gluon, init, nd
from mxnet.gluon import loss as gloss, nn
import d2lzh as d2l
import time
# 1. 加载数据
batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size=batch_size)
# 2. 初始化模型参数(延迟初始化,此处可指定初始化方式)
net.initialize(init=init.Xavier())
# 3. 定义损失函数
loss = gloss.SoftmaxCrossEntropyLoss()
# 4. 定义优化器
trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': 0.9})

4. 评估精度函数 📊

在训练过程中,我们需要评估模型在测试集上的精度。以下是评估精度的函数实现:
def evaluate_accuracy(data_iter, net, ctx):
acc_sum, n = 0.0, 0
for X, y in data_iter:
# 如果 ctx 是 GPU 上下文,则将数据复制到 GPU
X, y = X.as_in_context(ctx), y.as_in_context(ctx)
y = y.astype('float32')
acc_sum += (net(X).argmax(axis=1) == y).sum().asscalar()
n += y.size
return acc_sum / n
该函数遍历数据迭代器,将数据和标签移动到指定的计算设备(CPU 或 GPU),计算模型预测正确的样本数,最后返回平均精度。
5. 训练循环 🔄
核心的训练循环负责在多个周期(epoch)内迭代数据、计算损失、反向传播和更新参数。
以下是训练循环的代码:

def train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx, num_epochs):
print('training on', ctx)
for epoch in range(num_epochs):
train_l_sum, train_acc_sum, n, start = 0.0, 0.0, 0, time.time()
for X, y in train_iter:
# 将数据移动到指定设备
X, y = X.as_in_context(ctx), y.as_in_context(ctx)
with autograd.record():
y_hat = net(X)
l = loss(y_hat, y).sum()
l.backward()
trainer.step(batch_size)
y = y.astype('float32')
train_l_sum += l.asscalar()
train_acc_sum += (y_hat.argmax(axis=1) == y).sum().asscalar()
n += y.size
# 计算并打印每个周期后的指标
test_acc = evaluate_accuracy(test_iter, net, ctx)
print('epoch %d, loss %.4f, train acc %.3f, test acc %.3f, '
'time %.1f sec'
% (epoch + 1, train_l_sum / n, train_acc_sum / n, test_acc,
time.time() - start))
该函数在每个训练周期内,计算训练损失和训练精度,并在周期结束后评估模型在测试集上的精度,同时记录耗时。
6. 选择计算设备 ⚙️

深度学习训练对计算资源要求较高。以下代码帮助我们自动检测并使用可用的 GPU,如果不可用则回退到 CPU。
def try_gpu():
try:
ctx = nd.gpu()
_ = nd.zeros((1,), ctx=ctx)
return ctx
except:
return nd.cpu()

ctx = try_gpu() # 获取设备上下文


使用 GPU 可以极大加速训练过程。例如,在本例的 Fashion-MNIST 数据集上,GPU 训练一个周期仅需约 2.3 秒,而 CPU 则需要约 23 秒,有近 10 倍的差距。对于更复杂的网络(如后续将学的 AlexNet),GPU 的优势将更加明显。
7. 开始训练 🚀


现在,我们将所有部分组合起来,开始训练 LeNet 模型。

num_epochs = 5
train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx, num_epochs)

运行上述代码,模型在 Fashion-MNIST 测试集上的精度大约可以达到 82%-83%。

本节课总结
在本节课中,我们一起学习了如何使用 MXNet 实现 LeNet-5 卷积神经网络。我们完成了以下步骤:
- 使用
nn.Sequential()定义了网络架构。 - 理解了数据在网络中的流动过程。
- 准备了数据迭代器、损失函数和优化器。
- 编写了评估精度和训练循环的函数。
- 学会了自动检测并使用 GPU 设备来加速训练。
- 成功训练模型并达到了预期的精度。

通过这个完整的流程,你不仅实现了经典的 LeNet,也掌握了使用现代深度学习框架进行模型开发、训练和评估的基本模式。
课程 P63:AlexNet 详解 🧠
在本节课中,我们将学习深度学习发展史上的一个里程碑模型——AlexNet。我们将回顾其诞生的背景、核心创新点、网络架构细节,并理解它如何引领了计算机视觉乃至整个深度学习领域的范式转变。

历史背景与范式转变 🔄
上一节我们介绍了LeNet等早期卷积神经网络。本节中,我们来看看AlexNet出现时的技术环境。
在2001年左右,核方法是机器学习的主流。人们通过精心设计特征提取器和核函数来解决非线性问题,然后求解凸优化问题。计算机视觉领域同样如此,其核心是特征工程。例如,设计出SIFT或SURF特征提取器被视为重大成就。之后,只需提取图像特征点,进行聚类等处理,再应用支持向量机(SVM)即可解决问题。
这种方法的局限性在于,每当遇到新问题,都需要重新进行特征工程,可扩展性不强。这种状况的根源在于当时可用的数据、内存和计算能力。
以下是不同年代关键资源的大致规模:
- 数据量:2000年前后数据集规模很小。随着互联网和云计算的普及,数据量在2000-2010年间增长了约100倍,在2010-2020年间又增长了约1000倍。
- 内存:改进相对平缓。
- 计算能力:2010年左右出现重大突破,从单核/少核CPU转向大规模多核架构(如GPU),计算性能得到飞跃。
在这种背景下:
- 1990年代:深度网络曾流行,但受限于小内存和小数据集。
- 2000-2010年:核方法因能较好处理当时规模的数据而成为合适选择。
- 2010年后:计算资源的飞跃使得训练大规模、非凸、计算密集的深度网络成为可能。同时,ImageNet数据集(2010年发布)的出现提供了必要的“燃料”。它包含120万张图片、1000个类别,分辨率远高于之前的基准数据集(如MNIST)。
因此,当AlexNet在2012年ImageNet竞赛中取得压倒性胜利时,它标志着一个转折点:计算机视觉的默认策略从“手动设计特征 + SVM”转变为“自动学习特征 + Softmax分类”。
AlexNet的核心创新 ✨

AlexNet并非仅仅是更大、更深的LeNet。它引入了多项关键创新,解决了训练深层网络的核心难题。

以下是AlexNet相比前代模型的三个主要改进:
-
ReLU激活函数:用修正线性单元(ReLU)替换了Sigmoid等饱和激活函数。
- 公式:
ReLU(x) = max(0, x) - 作用:有效缓解了深层网络中的梯度消失问题,因为至少在正半轴,梯度恒为1,使得训练更深网络成为可能。
- 公式:
-
Dropout正则化:在训练过程中,随机“丢弃”(即暂时屏蔽)网络中的一部分神经元。
- 作用:这是一种强大的正则化技术,防止网络对特定神经元过度依赖,增强了模型的泛化能力,使得设计更深的网络而不过拟合成为可能。
-
最大池化:使用最大池化替代了平均池化。
- 作用:最大池化对特征的位置微小变化(平移)具有更好的不变性,能更鲁棒地提取关键特征。
此外,数据增强也被广泛且系统地应用。通过对训练图像进行随机裁剪、水平翻转、颜色扰动等操作,可以显著增加训练数据的多样性,提升模型的鲁棒性和泛化能力。

网络架构剖析 🏗️

了解了核心创新后,我们具体看看AlexNet的网络结构设计。

AlexNet的输入是224x224像素的三通道(RGB)图像。整个网络包含8个学习层:5个卷积层和3个全连接层。其架构比LeNet复杂得多。
以下是架构的一些关键特点:

- 更大的卷积层:第一卷积层使用96个大小为11x11的滤波器,通道数远超LeNet。
- 更深的网络:通过堆叠多个卷积层来增强非线性表达能力。
- 更大的全连接层:最后两个全连接层各有4096个神经元,为最终的1000类分类提供足够的信息容量。
- 双GPU训练:由于当时单个GPU内存有限,原始AlexNet的设计被拆分到两个GPU上并行训练,这在工程上是一大挑战。
从计算复杂度和参数量来看,AlexNet的计算量约为LeNet的250倍,而参数量约为10倍。这体现了从“参数效率”到“计算效率”的权衡转变,充分利用了当时新出现的强大计算资源。

总结与展望 📈
本节课中,我们一起学习了深度学习历史上的标志性模型AlexNet。
我们回顾了其出现前以特征工程和核方法为主导的技术背景,理解了计算能力(GPU)和大规模数据集(ImageNet)的突破是其成功的关键前提。我们详细分析了AlexNet的核心创新:ReLU激活函数、Dropout正则化和最大池化,这些技术至今仍是深度学习的基础组件。最后,我们剖析了其更复杂、更深的网络架构。

AlexNet的胜利不仅证明了深度卷积神经网络在视觉任务上的强大能力,更彻底改变了计算机视觉的研究范式,并推动了深度学习在语音识别、自然语言处理等领域的广泛应用。在接下来的课程中,我们将看到研究者们如何在此基础上,进一步解决网络加深带来的挑战,构建更强大、更高效的模型。
课程 P64:64. 使用 Python 实现 AlexNet 🚀
在本节课中,我们将学习如何使用 Python 和 MXNet 框架来实现经典的 AlexNet 网络,并将其应用于 Fashion-MNIST 数据集。我们将从网络结构定义开始,逐步讲解数据加载、模型训练和评估的完整流程。

导入必要的库
首先,我们需要导入构建和训练网络所需的所有工具。这包括 MXNet 的核心组件、神经网络层、损失函数以及数据迭代器。

import mxnet as mx
from mxnet import gluon, autograd, nd
from mxnet.gluon import nn
from mxnet.gluon.data.vision import transforms

定义 AlexNet 网络结构 🏗️

上一节我们介绍了必要的库,本节中我们来看看如何定义 AlexNet 的网络结构。AlexNet 是一个顺序网络,包含多个卷积层、池化层和全连接层。

以下是网络的具体构建步骤:

- 第一层:卷积层,使用 96 个 11x11 的卷积核,步幅为 4。
- 第二层:最大池化层,窗口大小为 3x3,步幅为 2。
- 第三层:卷积层,使用 256 个 5x5 的卷积核,并填充 2。
- 第四层:最大池化层,窗口大小为 3x3,步幅为 2。
- 第五至七层:三个连续的卷积层,分别使用 384、384 和 256 个 3x3 的卷积核,并填充 1。
- 第八层:最大池化层,窗口大小为 3x3,步幅为 2。
- 第九至十层:两个全连接层,分别有 4096 个神经元。
- 第十一层:输出层,将特征映射到 10 个类别(对应 Fashion-MNIST)。

net = nn.Sequential()
# 第一层卷积和池化
net.add(nn.Conv2D(96, kernel_size=11, strides=4, activation='relu'),
nn.MaxPool2D(pool_size=3, strides=2))
# 第二层卷积和池化
net.add(nn.Conv2D(256, kernel_size=5, padding=2, activation='relu'),
nn.MaxPool2D(pool_size=3, strides=2))
# 连续三个卷积层
net.add(nn.Conv2D(384, kernel_size=3, padding=1, activation='relu'),
nn.Conv2D(384, kernel_size=3, padding=1, activation='relu'),
nn.Conv2D(256, kernel_size=3, padding=1, activation='relu'),
nn.MaxPool2D(pool_size=3, strides=2))
# 展平操作,为全连接层准备
net.add(nn.Flatten())
# 全连接层
net.add(nn.Dense(4096, activation='relu'),
nn.Dropout(0.5),
nn.Dense(4096, activation='relu'),
nn.Dropout(0.5),
# 输出层,10个类别
nn.Dense(10))


输入一个 224x224 的图像,网络会依次输出各层的尺寸,最终得到 10 个类别的预测值。
准备数据迭代器 📊
定义好网络后,我们需要准备数据。由于 AlexNet 的输入尺寸是 224x224,而 Fashion-MNIST 原始图像是 28x28,因此需要进行调整。
以下是创建数据迭代器的步骤:
- 定义数据转换流程:将图像调整为
224x224,然后转换为张量。 - 加载 Fashion-MNIST 数据集。
- 创建数据加载器,指定批量大小并进行数据打乱。
def get_data_iterators(batch_size=128):
# 定义图像变换:调整大小并转为张量
transformer = transforms.Compose([
transforms.Resize(224),
transforms.ToTensor()
])
# 加载训练和测试数据集,并应用变换
train_data = gluon.data.vision.FashionMNIST(train=True).transform_first(transformer)
test_data = gluon.data.vision.FashionMNIST(train=False).transform_first(transformer)
# 根据操作系统设置工作线程数
num_workers = 0 if sys.platform.startswith('win32') else 4
# 创建数据迭代器
train_iter = gluon.data.DataLoader(train_data, batch_size, shuffle=True, num_workers=num_workers)
test_iter = gluon.data.DataLoader(test_data, batch_size, shuffle=False, num_workers=num_workers)
return train_iter, test_iter

模型训练与评估 ⚙️

有了网络和数据,接下来我们进入训练环节。训练脚本的逻辑与之前介绍过的简单网络类似,但需要注意学习率的设置。
以下是训练和评估模型的核心步骤:
- 初始化:初始化网络参数,定义损失函数(交叉熵)和优化器(SGD),并将模型和数据移动到 GPU(如果可用)。
- 准确度计算:编写一个函数,用于计算模型在给定数据迭代器上的分类准确度。
- 训练循环:
- 遍历数据集多个周期(epoch)。
- 在每个批次(batch)中,执行前向传播计算损失。
- 执行反向传播计算梯度。
- 使用优化器更新模型参数。
- 定期计算并输出训练和测试准确度。
def train(net, train_iter, test_iter, num_epochs, lr=0.01, ctx=mx.gpu()):
# 初始化网络参数
net.initialize(force_reinit=True, ctx=ctx, init=init.Xavier())
# 定义损失函数和优化器
loss_fn = gluon.loss.SoftmaxCrossEntropyLoss()
trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr})
for epoch in range(num_epochs):
train_l_sum, train_acc_sum, n = 0.0, 0.0, 0
# 遍历训练数据
for X, y in train_iter:
X, y = X.as_in_context(ctx), y.as_in_context(ctx)
with autograd.record():
y_hat = net(X)
l = loss_fn(y_hat, y)
l.backward()
trainer.step(X.shape[0])
# 计算训练准确度
train_l_sum += l.sum().asscalar()
train_acc_sum += (y_hat.argmax(axis=1) == y.astype('float32')).sum().asscalar()
n += y.size
# 计算测试准确度
test_acc = evaluate_accuracy(test_iter, net, ctx)
print(f'Epoch {epoch + 1}, Train loss {train_l_sum / n:.4f}, '
f'Train acc {train_acc_sum / n:.3f}, Test acc {test_acc:.3f}')
def evaluate_accuracy(data_iter, net, ctx):
acc_sum, n = 0.0, 0
for X, y in data_iter:
X, y = X.as_in_context(ctx), y.as_in_context(ctx)
y_hat = net(X)
acc_sum += (y_hat.argmax(axis=1) == y.astype('float32')).sum().asscalar()
n += y.size
return acc_sum / n

关键细节:
- 学习率:设置为
0.01,比浅层网络更小。这是因为网络越复杂,需要更小的学习率来稳定训练。 - 训练 vs 测试准确度:在训练过程中计算的训练准确度可能低于周期结束后的测试准确度。这是因为训练准确度是在每个批次后即时计算的移动平均值,而测试准确度是在一个完整周期后计算的,更能反映模型当前的真实性能。


运行与结果 📈
现在,让我们运行完整的训练流程。由于 AlexNet 参数较多,在 CPU 上训练会非常慢,因此建议在 GPU 环境下运行。
# 获取数据迭代器
train_iter, test_iter = get_data_iterators(batch_size=128)
# 开始训练(假设使用GPU)
ctx = mx.gpu() if mx.context.num_gpus() > 0 else mx.cpu()
train(net, train_iter, test_iter, num_epochs=5, lr=0.01, ctx=ctx)
经过 5 个周期的训练,模型在测试集上的准确率大约可以达到 70% 左右。要获得更好的性能,需要更先进的网络架构、数据增强技术和更精细的超参数调优。
总结 🎯
本节课中我们一起学习了如何使用 Python 实现 AlexNet。我们从网络结构的逐层构建开始,接着准备了符合网络输入要求的数据迭代器,然后详细讲解了包含前向传播、反向传播和参数更新的训练循环,最后对模型进行了评估。

通过本教程,你掌握了实现一个经典卷积神经网络的关键步骤,并理解了在训练更深网络时需要注意的细节,如学习率调整和性能评估方式。这为学习更复杂的现代网络架构奠定了坚实的基础。
课程 P65:65. L12_6 VGG 在 Python 中 🧠

在本节课中,我们将学习如何使用“块”的概念来构建VGG网络。我们将了解VGG网络的设计思想,并通过Python代码实现一个简化的VGG模型,最后进行训练和评估。
概述:网络设计的“块”思想
上一节我们介绍了基础的网络层。本节中我们来看看VGG网络的核心创新:使用块进行网络设计。
VGG网络在设计上的一个显著区别是,它不再仅仅思考单个网络层,而是开始思考如何设计块,然后通过组合这些块来构建整个网络。这极大地简化了网络架构的设计和实现。

构建VGG块
首先,我们需要定义一个VGG块。这个块本身并不复杂,它接受一系列卷积层和通道数量作为参数。

以下是VGG块的核心实现思路:
def vgg_block(num_convs, out_channels):
# 创建一个顺序容器
layers = []
for _ in range(num_convs):
# 添加卷积层
layers.append(nn.Conv2d(...))
# 添加激活函数,如ReLU
layers.append(nn.ReLU())
# 在块的最后添加一个最大池化层
layers.append(nn.MaxPool2d(kernel_size=2, stride=2))
# 返回一个顺序块
return nn.Sequential(*layers)

为了让代码运行得更快,我们使用了混合顺序模式(例如PyTorch的torch.jit.script),这可以激活即时编译,优化执行速度。最终,这个函数返回一个完整的网络块。

回顾VGG架构
在定义了基础块之后,我们来回顾完整的VGG架构。一个典型的VGG网络由多个卷积块和最后的几个全连接层组成。
以下是VGG网络的经典结构:
- 两个卷积层,64通道。
- 两个卷积层,128通道。
- 三个卷积层,256通道。
- 三个卷积层,512通道。
- 三个全连接层。


用块组装VGG网络
有了VGG块的定义,组装整个网络就变得非常简单明了,就像使用一个for循环一样。

以下是组装网络的核心代码逻辑:
def make_vgg(conv_arch):
conv_blks = []
# 遍历架构定义,添加每个VGG块
for (num_convs, out_channels) in conv_arch:
conv_blks.append(vgg_block(num_convs, out_channels))
# 添加全连接层
net = nn.Sequential(
*conv_blks,
nn.Flatten(),
nn.Linear(...), # 第一个全连接层
nn.ReLU(),
nn.Dropout(0.5),
nn.Linear(...), # 第二个全连接层
nn.ReLU(),
nn.Dropout(0.5),
nn.Linear(...) # 输出层
)
return net
我们使用的具体架构conv_arch定义了每个块中的卷积层数量和通道数。这种设计使得修改网络深度(例如添加或删除一个块)变得非常容易。




网络参数与内存使用

让我们查看一下网络的具体参数。根据conv_arch的定义,我们可以看到各层的通道数依次为64、128、256、512,这与VGG的经典设计一致。
网络的第一行输出就清晰地展示了conv_arch所定义的架构。




最终,网络末端是参数量庞大的全连接层。


训练简化版VGG网络
为了能在合理的时间内完成训练,我们通常会对原始VGG网络进行简化。一个常见的方法是将所有通道数按比例缩小(例如除以4)。
以下是训练准备步骤:
- 使用简化后的架构生成网络。
- 对网络进行混合(编译),这样在第一次执行后,框架可以直接使用优化后的代码进行计算,无需再经过Python解释器,从而提升效率。

# 简化架构:将通道数除以4
small_conv_arch = [(pair[0], pair[1] // 4) for pair in conv_arch]
# 生成网络
net = make_vgg(small_conv_arch)
# 进行混合/编译(以PyTorch JIT为例)
net = torch.jit.script(net)


执行训练
训练过程与之前学习过的标准流程完全相同。
以下是训练循环的关键参数和步骤:
- 优化器:使用SGD或Adam。
- 学习率:设置一个较小的值。
- 批量大小:例如128。
- 迭代周期:根据需求设置,例如5个周期。

代码会遍历训练数据,计算损失,并反向传播更新权重。在Fashion-MNIST数据集上,经过几个周期的训练后,误差率大约可以降至8.5%到9%。


总结

本节课中我们一起学习了VGG网络的核心思想与实现。
我们首先介绍了VGG采用块进行网络设计的概念,这比设计单个层更高效。接着,我们定义了VGG块的结构,并用它像搭积木一样组装出完整的VGG网络。为了实际训练,我们学会了如何简化网络架构以加快训练速度,并回顾了标准的训练流程。通过本课,你掌握了使用模块化思想构建和训练经典卷积神经网络的基本方法。
课程 P66:网络中的网络 (NiN) 🧠
在本节课中,我们将学习一种名为“网络中的网络”(NiN)的神经网络设计策略。这种策略旨在解决传统卷积神经网络(CNN)中全连接层参数过多、计算量庞大的问题。我们将了解其核心思想、具体实现方式以及它在深度学习发展中的重要意义。

传统CNN的瓶颈:庞大的全连接层
上一节我们回顾了LeNet、AlexNet和VGG等经典CNN架构。本节中我们来看看这些网络面临的一个共同挑战。

在LeNet等早期网络中,最后的全连接层参数规模相对较小。然而,在AlexNet或VGG等更深的网络中,问题变得突出。
以下是VGG网络中一个全连接层的参数规模示例:
# 从最后一个卷积层(512通道,7x7分辨率)到第一个全连接层(4096维)
参数数量 = 512 * 7 * 7 * 4096 ≈ 102,760,448
这个数量级的参数占据了模型总参数的绝大部分,使得模型变得臃肿,难以训练和部署。卷积层本身通过共享权重(卷积核)已经相当高效,但最后的全连接层成为了主要瓶颈。
问题的根源在于,网络最终需要将二维的、多通道的特征图转换成一个与类别数量匹配的一维向量。挑战在于如何高效、优雅地完成这个转换。
NiN的解决方案:用“网络块”替代全连接层
为了解决上述问题,NiN提出了一种创新的思路:用一系列特殊的“网络块”来逐步压缩特征,最终避免使用庞大的全连接层。

核心组件:1x1卷积(逐点卷积)
NiN策略的核心是1x1卷积,也称为逐点卷积。它的作用相当于在每个像素位置上应用一个小型的多层感知机(MLP)。
其操作可以表示为以下公式:
输出[x, y, :] = MLP(输入[x, y, :])
其中,输入[x, y, :] 是位置 (x, y) 上所有通道的值构成的向量。

1x1卷积可以在不改变空间分辨率(宽和高)的情况下,灵活地混合和压缩不同通道的信息。这为实现通道间的复杂交互提供了一种轻量级的方法。
构建NiN块
一个标准的NiN块由以下层顺序堆叠而成:
- 一个常规卷积层(例如使用3x3或5x5的卷积核)。
- 两个连续的1x1卷积层,充当微型多层感知机。
通过堆叠多个这样的NiN块,并在块之间使用最大池化层来降低空间分辨率,网络可以逐步提取和压缩特征。
NiN网络架构与工作流程

现在,让我们看看如何将这些NiN块组合成一个完整的网络。

NiN网络的典型架构是重复堆叠“NiN块 + 最大池化”的组合。例如,一个网络可能包含三个这样的阶段,每个阶段逐步增加通道数并降低空间分辨率。
以下是NiN处理图像的一个简化流程描述:
- 输入图像经过第一个NiN块和池化层。
- 特征图经过第二个NiN块和池化层,通道数增加,尺寸减小。
- 特征图经过第三个NiN块和池化层,得到最终的高维特征图(例如尺寸为5x5)。
- 在最后一个NiN块后,网络不再使用全连接层,而是直接使用全局平均池化。
最终输出:全局平均池化

全局平均池化是NiN的另一个关键创新。它对最后一个特征图的每个通道计算其所有空间位置(整个宽和高)的平均值。
对于一个形状为 (C, H, W) 的特征图,全局平均池化的输出是一个长度为 C 的向量:
输出[c] = 平均值(特征图[c, :, :]) for c in 1...C
如果我们将最后一个NiN块的输出通道数 C 直接设置为目标类别数(例如10),那么经过全局平均池化后,我们就能直接得到一个10维的向量,无需任何全连接层。这个向量可以直接送入Softmax函数进行分类。


NiN的意义与影响
尽管NiN模型在当时的图像分类竞赛中表现不如VGG等网络突出,这导致其最初被部分研究者忽视,但它的设计思想极具前瞻性,为后续更强大的网络架构铺平了道路。
NiN的主要贡献在于:
- 参数效率:通过用1x1卷积和全局平均池化替代全连接层,大幅减少了模型参数。
- 概念创新:引入了“在网络中嵌入微型网络(MLP)”的思想,以及全局平均池化这一简洁的最终层设计。

这些思想直接影响了GoogleNet(Inception)系列网络,其中1x1卷积被广泛用于降维和增加非线性。ResNet等更现代的架构也从中受益。可以说,NiN是连接经典CNN与当代高效网络设计的重要桥梁。
总结
本节课中我们一起学习了“网络中的网络”(NiN)。我们首先分析了传统卷积神经网络中全连接层参数庞大的问题。接着,我们深入探讨了NiN的解决方案:利用1x1卷积构成的NiN块作为基本构建单元,并在网络末端使用全局平均池化来替代全连接层。这种设计显著提升了模型的参数效率。最后,我们认识到NiN虽然在其诞生时未达性能巅峰,但其核心思想对后续的Inception、ResNet等里程碑式网络产生了深远影响。

下节课,我们将看到如何将这些思想进一步结合与发展,引出强大的Inception网络架构。
课程 P67:网络中的网络(NiN)在Python中的实现 🧠
在本节课中,我们将学习网络中的网络(NiN)架构,并了解如何在Python中实现它。NiN的核心思想是使用1×1卷积层来替代传统的全连接层,从而减少模型参数并提升效率。
回顾:NiN与VGG的区别
上一节我们介绍了经典的卷积网络结构。为了回顾,左边是VGG网络,它主要由带有ReLU激活函数的卷积块构成。而右边的NiN块则不同,它基本上是一个卷积层,后接两个1×1的卷积层,然后是最大池化层,最后是全局平均池化层。

开始实现NiN
让我们从导入必要的库开始。这里我们使用MXNet和gluon。
import mxnet as mx
from mxnet import gluon, nd
from mxnet.gluon import nn
接下来,我们需要定义一个NiN块。这个块的结构相当简单。
以下是NiN块的定义,其参数包括通道数、卷积核大小、步幅和填充:
class NiNBlock(nn.Block):
def __init__(self, channels, kernel_size, strides, padding, **kwargs):
super(NiNBlock, self).__init__(**kwargs)
self.net = nn.Sequential()
self.net.add(
nn.Conv2D(channels, kernel_size, strides, padding, activation='relu'),
nn.Conv2D(channels, kernel_size=1, activation='relu'),
nn.Conv2D(channels, kernel_size=1, activation='relu')
)
def forward(self, x):
return self.net(x)
在NiN块中,我们首先进行一个常规卷积,然后是两个1×1的卷积。1×1卷积不需要定义填充,因为其尺寸不会发生变化。
构建完整的NiN模型
现在,我们来构建完整的NiN模型。这个模型并不复杂。
以下是模型的结构,它使用了三个NiN块,每个块后接最大池化层,最后应用Dropout和全局平均池化:
class NiN(nn.Block):
def __init__(self, num_classes, **kwargs):
super(NiN, self).__init__(**kwargs)
self.net = nn.Sequential()
# 第一个NiN块序列
self.net.add(
NiNBlock(96, kernel_size=11, strides=4, padding=0),
nn.MaxPool2D(pool_size=3, strides=2)
)
# 第二个NiN块序列
self.net.add(
NiNBlock(256, kernel_size=5, strides=1, padding=2),
nn.MaxPool2D(pool_size=3, strides=2)
)
# 第三个NiN块序列
self.net.add(
NiNBlock(384, kernel_size=3, strides=1, padding=1),
nn.MaxPool2D(pool_size=3, strides=2),
nn.Dropout(0.5)
)
# 最后一个NiN块,后接全局平均池化
self.net.add(
NiNBlock(num_classes, kernel_size=3, strides=1, padding=1),
nn.GlobalAvgPool2D(),
nn.Flatten()
)
def forward(self, x):
return self.net(x)

模型的关键区别在于,这里我们不再需要任何密集层(全连接层)。因为在NiN块中,1×1的卷积层起到了类似多层感知机的作用,并且是应用在每个通道上的。
网络结构与数据流
让我们看看这个网络在实际应用中的数据流是什么样的。
假设我们将一个224×224的图像输入网络:
- 经过第一个NiN块和池化后,特征图尺寸变为54×54。
- 经过第二个块和池化后,尺寸变为26×26。
- 经过第三个块和池化后,尺寸变为12×12。
- 在应用Dropout后,我们得到一个5×5的输出,其通道数等于最终需要的类别数量(例如10类)。
- 最后,经过全局平均池化层,我们直接得到每个类别的预测分数。
这样,我们就完全避免了使用任何密集层。

训练与观察
在实际训练时,我们可以使用稍大的学习率,因为NiN架构相对简单,并且我们使用了大小为128的小批量数据。
以下是训练循环的简化示例:
# 假设已定义好数据迭代器(train_data)和损失函数(loss)
net = NiN(num_classes=10)
net.initialize()
trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': 0.1})
for epoch in range(num_epochs):
for X, y in train_data:
with autograd.record():
output = net(X)
l = loss(output, y)
l.backward()
trainer.step(batch_size)
有一点值得注意:NiN网络在最初提出时表现并不突出,这也是它当时未受重视的原因之一。它当时被视为另一个“奇怪”的网络架构,并且人们普遍认为无法摆脱密集层。
然而,NiN中“用卷积替代全连接”的思想非常重要,它为后来的Inception和ResNet等先进架构铺平了道路。这些架构充分利用了去除密集层的优势,显著减少了参数量。
总结
本节课中,我们一起学习了网络中的网络(NiN)架构。我们了解了NiN与VGG的核心区别,即使用1×1卷积块替代全连接层。我们逐步实现了NiN块和完整的NiN模型,并分析了其数据流。虽然NiN本身在当时并非突破性模型,但其设计理念对后续深度学习架构的发展产生了深远影响。

下周,我们将讲解更为先进的、代表技术前沿的网络架构。
感谢您的关注。

课程 P68:Inception (GoogLeNet) 详解 🧠

在本节课中,我们将深入学习卷积神经网络发展史上的一个重要里程碑——Inception网络(也称GoogLeNet)。我们将从它要解决的问题出发,逐步解析其核心的Inception模块设计思想、网络整体架构,以及后续的改进版本。通过本教程,你将理解如何通过巧妙的模块设计来平衡网络的表达能力和计算效率。


回顾与问题引入 🤔
上一节我们介绍了多种卷积核尺寸(如1x1, 3x3, 5x5)在不同网络(如AlexNet, VGG, NIN)中的应用。面对如此多的选择,设计者面临一个难题:到底应该使用哪种卷积操作?

- 大卷积核(如5x5):参数多,计算量大,但可能捕捉更长距离的特征。
- 小卷积核(如1x1或3x3):参数少,计算高效,但表达能力可能受限。
那么,有没有一种方法可以兼得鱼与熊掌呢?

Inception模块:小孩子才做选择,我全都要! 🎯
Inception网络的核心思想非常简单:既然无法决定哪种卷积最好,那就把所有可能都用上。这个“用上所有可能”的构建块,就叫做Inception模块。
以下是Inception模块的典型结构,它并行执行四种操作:

- 1x1卷积:用于跨通道的信息整合与降维。
- 1x1卷积 + 3x3卷积:先降维,再进行3x3的空间特征提取。
- 1x1卷积 + 5x5卷积:先降维,再进行5x5的空间特征提取。
- 3x3最大池化 + 1x1卷积:先进行下采样,再通过1x1卷积调整通道数。
# Inception模块的简化伪代码描述
def inception_block(x):
path1 = Conv2D(filters=64, kernel_size=1, padding='same')(x)
path2 = Conv2D(filters=96, kernel_size=1, padding='same')(x)
path2 = Conv2D(filters=128, kernel_size=3, padding='same')(path2)
path3 = Conv2D(filters=16, kernel_size=1, padding='same')(x)
path3 = Conv2D(filters=32, kernel_size=5, padding='same')(path3)
path4 = MaxPool2D(pool_size=3, strides=1, padding='same')(x)
path4 = Conv2D(filters=32, kernel_size=1, padding='same')(path4)
# 在通道维度上拼接所有路径的输出
output = concatenate([path1, path2, path3, path4], axis=-1)
return output
关键设计点:
- 所有并行路径都使用合适的填充(
padding='same'),确保输出特征图的空间尺寸(高和宽)与输入相同。 - 各路径的输出通道数经过精心设计(例如第一条路64通道,第二条路128通道等),最后在通道维度上进行拼接。
- 在5x5卷积前使用1x1卷积进行降维,是控制参数量的关键技巧。其参数量计算公式为:
参数 ≈ (输入通道数 * 1*1 * 降维后通道数) + (降维后通道数 * 5*5 * 输出通道数)
这通常远小于直接进行5x5卷积的参数量:输入通道数 * 5*5 * 输出通道数。

这种设计的优势在于,网络可以自动学习在哪些通道上使用哪种特征提取方式更有效,用相对较少的参数和计算量,获得了强大的特征表示能力。

GoogLeNet 整体架构 🏗️
理解了核心模块后,我们来看看完整的GoogLeNet网络是如何组织的。整个网络可以分为五个主要阶段。

第一阶段与第二阶段:与传统CNN类似,以卷积和池化操作进行初步特征提取和下采样。
- 使用7x7大卷积核起步。
- 穿插1x1卷积和3x3卷积。
- 通过最大池化降低分辨率。

第三、四、五阶段:这些是网络的主体,由多个Inception模块堆叠而成。
- 在进入第三阶段前,分辨率已从224x224降至28x28。
- 每个阶段内部,先堆叠多个Inception模块增加网络深度和表达能力,然后通过一个3x3最大池化操作将分辨率减半(例如从28x28到14x14),同时通道数显著增加。
- 这种“模块堆叠 -> 下采样”的模式重复进行。
分类头:网络最后采用“全局平均池化层”接“全连接层”的方式输出分类结果。全局平均池化将每个通道的所有像素值取平均,大大减少了参数,并有一定抗过拟合作用。

过渡:原始的Inception V1(GoogLeNet)取得了巨大成功,但研究者们并未止步。接下来,我们看看后续版本如何对这个基础设计进行优化。

Inception 家族的进化 🚀
Inception V2/V3:主要引入了两大改进。
- 用连续小卷积核替代大卷积核:将5x5卷积替换为两个连续的3x3卷积。这在不增加感受野的情况下,增加了非线性,并且参数量更少(2个3x3: 9+9=18 < 1个5x5: 25)。
- 引入非对称卷积:将nxn卷积拆分为1xn和nx1卷积的串联(例如将7x7拆为1x7和7x1)。这进一步减少了参数量,并可能捕捉到某些方向性更强的特征。
Inception V4:借鉴了ResNet(残差网络)的思想,将跳跃连接(Shortcut Connection)引入Inception模块,形成了Inception-ResNet结构,使得训练更深的网络成为可能。

模型效率与部署实践 ⚙️
在实际应用中,我们不仅关心模型的准确率,也关心其计算速度(吞吐量)和内存占用。研究显示,通过以下技术可以在精度损失很小的情况下大幅提升效率:
- 模型压缩:如知识蒸馏。
- 降低数值精度:
- 从32位浮点数(FP32)降至16位浮点数(FP16),在支持Tensor Core的GPU上可获得数倍加速。
- 进一步降至8位整数(INT8),能再次显著提升速度,这对移动端和嵌入式部署至关重要。
在选择模型时,需要在准确率与推理速度之间进行权衡,找到最适合应用场景的平衡点。
总结 📚
本节课我们一起学习了Inception网络的核心思想与架构。
- 核心问题:通过Inception模块,创造性地解决了卷积核尺寸选择困难的问题,采用“并行多路径”结构融合不同尺度的特征。
- 关键技巧:大量使用1x1卷积进行降维和升维,有效控制了模块的参数量和计算复杂度。
- 网络结构:GoogLeNet整体由传统的浅层特征提取器与深层的Inception模块堆叠构成,并通过阶段性池化降低分辨率。
- 持续进化:后续的V2、V3、V4版本通过分解卷积、引入非对称卷积和融合残差连接等方式,持续提升性能与效率。
- 实践考量:模型最终需要服务于实际应用,因此平衡精度、速度与资源消耗至关重要,数值精度降低是常用的部署优化手段。


Inception网络的设计哲学对后续的神经网络架构产生了深远影响,其“网络内部结构多样化”和“高效计算”的理念至今仍被广泛借鉴。

课程 P69:69. L13_2 Python中的Inception 🧠
在本节课中,我们将学习如何在Python中实现一个Inception网络模块。我们将从理解Inception块的基本结构开始,逐步构建一个完整的网络,并观察其在实际数据集上的运行效果。
概述
Inception网络的核心思想是并行处理。与传统的顺序堆叠卷积层不同,Inception块在同一层内并行地应用多种尺寸的卷积核和池化操作,然后将结果拼接起来。这允许网络在同一层级捕获不同尺度的特征。
实现基础Inception块

上一节我们介绍了Inception网络的概念,本节中我们来看看如何用代码实现一个基础的Inception块。
首先,我们需要初始化Inception块中的四条并行路径。每条路径负责不同尺寸的特征提取。
以下是四条路径的组件定义:
- 路径一:一个1×1的卷积层。
- 路径二:一个1×1的卷积层,后接一个3×3的卷积层。
- 路径三:一个1×1的卷积层,后接一个5×5的卷积层(需配合适当的填充)。
- 路径四:一个最大池化层,后接一个1×1的卷积层。


这些路径的输出通道数由参数 c1、c2、c3、c4 控制。

接下来,我们分别对输入数据应用这四条路径的操作。

# 伪代码示意
p1 = conv1x1(x) # 路径一
p2 = conv3x3(conv1x1(x)) # 路径二
p3 = conv5x5(conv1x1(x)) # 路径三
p4 = conv1x1(maxpool(x)) # 路径四

最后,我们将四条路径的输出在通道维度(dimension 1)上进行拼接。
output = torch.cat([p1, p2, p3, p4], dim=1)

这样,我们就得到了一个基础的Inception块。修改其架构(如调整卷积核大小或通道数)也非常容易。
构建完整的Inception网络

现在我们已经有了Inception块,接下来需要将它们组合成一个完整的网络。网络通常由多个阶段组成,每个阶段可能包含卷积层、Inception块和池化层。
以下是构建网络各阶段的步骤:
- 第一阶段:标准的卷积层和最大池化层,用于初步提取特征并降低空间维度。
- 第二阶段:更多的卷积层和另一个最大池化层,进一步减少维度。
- 第三阶段:堆叠两个Inception块,然后进行最大池化。
- 第四阶段:堆叠更多的Inception块,通道数会显著增加。
- 第五阶段:最后两个Inception块,后接全局平均池化层和一个输出维度为10(对应10个类别)的全连接层。

整个网络从输入图像(例如96x96像素)开始,经过层层处理,最终空间维度降至1x1,输出分类结果。

训练网络与注意事项
网络架构定义完成后,我们就可以开始训练了。训练代码与我们之前使用过的标准流程相同。
在开始训练前,必须对网络参数进行初始化。这里有一个重要的注意事项:使用 reset_parameters() 等方法重新初始化时,只会重置参数的值,而不会改变网络结构所绑定的输入维度。
这意味着,如果你的网络最初是为64x64图像设计的,重新初始化后也无法直接处理128x128的图像。要改变输入尺寸,需要重新设计并实例化整个网络架构。这是因为网络无法自动推断和重新实例化由用户定义的维度。
在本例中,我们使用Fashion-MNIST数据集(黑白图像)在GPU上进行训练。经过训练,模型可以达到约88%的准确率(约12%的错误率),这是一个不错的结果。
总结

本节课中我们一起学习了如何在Python中实现Inception网络。我们从构建基础的Inception块开始,它通过并行组合不同尺寸的卷积和池化操作来丰富特征表达。接着,我们将多个Inception块与其他层结合,构建了一个完整的网络架构。最后,我们讨论了网络训练流程和一个关键的实践注意事项:参数重新初始化不会改变网络预设的输入维度。通过实际运行,我们验证了该模型在图像分类任务上的有效性。
课程 P7:AWS EC2 实例启动教程 🚀
在本节课中,我们将学习如何在亚马逊云服务(AWS)上启动一个EC2实例。我们将从登录控制台开始,逐步完成选择地区、实例类型、配置存储和安全设置等关键步骤,最终启动一个可用于深度学习的GPU实例。


登录AWS控制台
首先,你需要访问AWS官方网站并登录你的账户。

点击“登录到控制台”按钮。

如果你尚未注册,需要先创建一个AWS账户。
选择AWS区域 🌍
AWS在全球运营着多个区域。不同区域提供的设备、服务和价格可能存在差异。
选择区域时,你可以考虑以下因素:
- 延迟:选择离你的用户或客户地理位置近的区域,例如为印度尼西亚的用户选择新加坡区域。
- 服务可用性:并非所有服务在所有区域都可用。
- 法规遵从:某些数据法规要求数据存储在特定区域,例如欧盟。
对于本教程,建议选择“美国东部(弗吉尼亚北部)”或“美国西部(俄勒冈)”区域。
启动EC2实例
在AWS控制台的服务列表中,找到并点击 EC2 服务。EC2提供了可租用的虚拟计算机(实例)。
点击“启动实例”按钮以开始创建新实例。
选择亚马逊机器镜像(AMI)
AMI是预装了操作系统和软件的模板。你可以选择公共AMI,也可以创建自己的。
以下是选择AMI的步骤:
- 在搜索框中输入“深度学习”。
- 从结果中选择一个预装了NVIDIA驱动和深度学习框架的AMI,例如“Deep Learning AMI (Ubuntu)”。
- 选择基础版本即可。
选择实例类型 💻
AWS提供了上百种实例类型,针对不同计算需求(如CPU、内存、GPU、存储)进行了优化。
对于需要GPU进行深度学习的任务,请按以下步骤操作:
- 筛选出GPU实例系列(如
p3,g4等)。 p3.2xlarge实例搭载了NVIDIA Volta架构的GPU,性能强大但成本较高。- 为节省成本,可以考虑使用抢占式实例,其价格远低于按需实例,但可能被AWS随时中断。
实例类型的命名规则通常反映了其配置,例如 p3.2xlarge 表示P3系列,2倍于大型实例的配置。
配置实例存储 💾
你可以为实例配置附加的存储卷(EBS卷)。
配置存储时,请注意:
- 默认根卷大小可能较小,你可以根据需求增加,例如设置为50GB。
- 可以选择卷类型,如通用型SSD (
gp2) 或预配置IOPS的SSD (io1),后者能提供稳定的高性能。
增加存储大小的操作类似于为电脑挂载一块新硬盘。
配置安全组 🔒
安全组充当虚拟防火墙,控制进出实例的流量。
创建一个新的安全组时,建议采取以下安全措施:
- 限制SSH访问:仅允许从你的可信IP地址(如学校或公司的网络范围)通过22端口进行SSH连接。
- 避免开放所有端口:不要设置过于宽松的规则,以防被恶意扫描或攻击。

选择密钥对 🔑
你需要一个密钥对来安全地通过SSH连接到你的Linux实例。
以下是相关操作:
- 在下拉菜单中选择一个已有的密钥对。
- 或者点击“创建新密钥对”,为其命名(例如
my-ec2-key)并下载私钥文件(.pem)。 - 务必妥善保管私钥文件,它是连接实例的唯一凭证。

处理配额与启动实例
点击“启动实例”后,你可能会遇到启动失败的情况,提示“实例配额不足”。
这是因为AWS对新账户的某些资源类型(如GPU实例)设置了默认限制。你需要申请提高配额。
申请提高配额的步骤如下:
- 在AWS控制台顶部,点击“支持” -> “支持中心”。
- 选择“创建案例” -> “服务限额增加”。
- 在表单中,选择服务为“EC2”,限额类型为“实例”。
- 指定区域和所需的实例类型(如
p3.2xlarge),并请求将限额提高到所需数量(例如4个)。 - 提交请求,通常AWS支持团队会很快通过邮件或电话与你联系并处理。
成功提高配额后,再次尝试启动实例即可。
使用命令行与SDK 🛠️
除了Web控制台,你还可以通过命令行工具或编程语言来管理AWS资源。
- AWS CLI:通过命令行执行所有AWS操作。
- AWS SDK:使用Python、JavaScript等语言编写脚本,以编程方式启动和管理大量实例,适用于自动化任务。
例如,使用Python的boto3库启动一个实例的核心代码结构如下:
import boto3
ec2 = boto3.resource(‘ec2’)
instance = ec2.create_instances(
ImageId=‘ami-xxxxxxxx’, # 你的AMI ID
InstanceType=‘p3.2xlarge’,
KeyName=‘my-ec2-key’,
MinCount=1,
MaxCount=1
)
总结 📝

本节课中,我们一起学习了在AWS上启动一个EC2实例的完整流程。我们从登录控制台和选择区域开始,逐步完成了选择AMI、实例类型、配置存储和安全组、管理密钥对等关键步骤。我们还了解了如何处理实例配额限制,并简要介绍了通过命令行和SDK进行自动化管理的方法。掌握这些步骤后,你就能在云端快速获取所需的计算资源了。

课程 P70:70. L13_3 批量归一化 🧠

在本节课中,我们将要学习深度学习中一个非常重要的技术——批量归一化。我们将了解它的原始动机、工作原理、实际效果以及实现时的关键细节。
概述 📋
批量归一化最初是为了解决深度神经网络训练中的“内部协变量偏移”问题而提出的。它通过对每一层的输入进行归一化处理,旨在加速网络的训练过程并提升模型的稳定性。
批量归一化的原始动机 🎯
上一节我们介绍了批量归一化的目标。本节中我们来看看它最初是如何被构思出来的。
在训练一个非常庞大且深的网络(如Inception网络)时,梯度需要从网络的顶层向底层反向传播。顶层会首先开始适应并拟合标签,然后其下一层也会开始适应,以此类推,形成一个级联的适应过程。
这里的问题是,当上层参数更新后,其输出的特征分布会发生变化。这意味着,对于已经适应得不错的最后一层来说,它现在必须重新适应来自上一层的新输入分布。这个过程会使得整个网络的训练变得非常缓慢。

当时的推理是,如果能稳定每一层输入的分布,让训练信号更顺畅地传播,就能帮助网络更好地收敛。这听起来与我们之前了解的某些预处理技术(如对输入数据进行归一化)有相似之处。
批量归一化的基本思想 💡
基于上述动机,批量归一化的核心思想是:在训练时,对每一层的输入进行修正,将其归一化到一个固定的均值和方差。但为了避免限制网络的表达能力,我们并不完全固定它,而是引入一个可学习的仿射变换。
仿射变换包含两个可学习的参数:缩放系数 γ 和偏移系数 β。具体操作如下:对于一个批次中的数据,我们计算其均值 μ 和方差 σ²,然后对每个样本 xᵢ 进行归一化,最后应用缩放和偏移。

以下是其核心公式:

BN(xᵢ) = γ * ( (xᵢ - μ) / σ ) + β
其中,μ 和 σ 是在当前训练批次上计算得到的。
批量归一化的实际效果与理解 🔍
上一节我们介绍了批量归一化的基本操作。本节中我们来看看它实际是如何起作用的,这与最初的设想可能有所不同。
后续的研究(如Lipnidal的论文)发现,批量归一化实际上并没有显著减少所谓的“内部协变量偏移”,甚至有时会使问题变得更糟。然而,它确实能极大地提升训练速度和模型性能。
那么它为什么有效呢?一个关键的理解在于:批量归一化实际上是一种通过噪声注入实现的正则化。
- 我们在一个小批量(例如,包含64或128个样本)上计算均值和方差。
- 这个小批量统计量(μ̂_B 和 σ̂_B)是对整体数据统计的有噪声估计。
- 因此,每个批次的归一化过程,都相当于为激活值添加了一个随机的偏移和缩放噪声。
这种噪声注入起到了正则化的作用,有助于防止模型过拟合。这也解释了为什么在使用批量归一化后,通常可以减少或省略Dropout层,因为两者在控制模型容量方面有相似的效果。
实现的关键细节 ⚙️
理解了原理后,我们来看看在实现批量归一化时需要注意的几个关键点。
小批量大小的影响

批量归一化的效果对小批量大小非常敏感。
以下是不同批量大小的影响:
- 批量过大:均值和方差的估计过于稳定,噪声注入不足,正则化效果减弱。
- 批量过小:均值和方差的估计噪声过大,可能导致训练不稳定,难以收敛。
这一点在单GPU训练中需要注意,在多GPU分布式训练中则更为关键,因为需要合理地将大批次分割到各个GPU上。

测试阶段的处理
在训练时,我们使用当前批次的统计量。但在测试或推理时,我们无法获取批次统计量。
以下是测试时的做法:
- 可学习参数 γ 和 β 直接使用训练好的固定值。
- 对于均值 μ 和方差 σ²,我们使用在整个训练集上计算得到的运行平均值(Moving Average)来替代。我们将在后续的代码实现中详细看到这一点。
应用于不同网络层

批量归一化可以应用于不同类型的网络层,但具体操作稍有不同:
- 全连接层:对每个神经元的所有激活值进行归一化。
- 卷积层:对每个输出通道,在其对应的所有空间位置(高和宽)和批次样本上计算均值和方差,即每个通道有自己独立的 γ 和 β。
总结 🏁

本节课中我们一起学习了批量归一化技术。我们首先了解了它为解决深度网络训练困难而提出的原始动机。然后,我们深入探讨了其基本思想,即通过可学习的仿射变换对层输入进行归一化。更重要的是,我们认识到其实际效果源于噪声注入带来的正则化作用。最后,我们讨论了实现中的关键细节,包括小批量大小的影响、测试阶段的处理方式以及在不同网络层中的应用方法。掌握批量归一化对于理解和构建现代深度神经网络至关重要。
课程 P71:Python中的批量归一化 🧠

在本节课中,我们将学习如何在Python中实现批量归一化(Batch Normalization)。这是一种用于深度神经网络的技术,可以加速训练过程并提高模型性能。我们将从理解其核心概念开始,然后逐步实现一个批量归一化层,并将其集成到一个简单的神经网络中。
批量归一化的定义与原理
批量归一化的核心思想是对神经网络每一层的输入进行归一化处理,使其均值为0,方差为1。这有助于缓解训练过程中的内部协变量偏移问题,使网络更稳定、更快地收敛。
其基本公式如下:
对于给定的小批量数据 x,我们计算其均值 μ 和方差 σ²:
μ = mean(x)
σ² = variance(x)
然后对 x 进行归一化:
x_hat = (x - μ) / sqrt(σ² + ε)
其中 ε 是一个很小的常数,用于防止除以零。
最后,引入可学习的缩放参数 γ 和平移参数 β,得到输出:
y = γ * x_hat + β
在训练阶段,我们使用当前小批量的统计量(均值和方差)。同时,我们会计算并更新一个“移动平均”的均值和方差,这个移动平均值将在推理(测试)阶段使用。

判断训练模式

在实现批量归一化时,一个关键步骤是判断当前是处于训练模式还是推理模式。一个简单的方法是检查是否启用了自动求导(autograd),因为我们通常只在训练阶段使用它。
# 判断是否在训练模式
training = torch.is_grad_enabled()
如果 training 为 True,则使用当前批次的统计量;如果为 False,则使用之前计算并存储的移动平均统计量。
处理不同网络结构
批量归一化需要适应不同的网络层,例如全连接层(MLP)和卷积层(CNN)。这两种层输入数据的维度不同。

- 对于全连接层(MLP):输入
x的形状通常是[batch_size, num_features]。我们需要在batch_size维度(即第0维)上计算均值和方差。 - 对于卷积层(CNN):输入
x的形状通常是[batch_size, channels, height, width]。我们需要在batch_size、height和width维度(即第0、2、3维)上计算均值和方差,以保持通道间的独立性。
我们可以通过检查输入 x 的维度数量来区分这两种情况。

if len(x.shape) == 2: # MLP: [batch, features]
# 在第0维计算均值和方差
axes = 0
elif len(x.shape) == 4: # CNN: [batch, channels, height, width]
# 在第0, 2, 3维计算均值和方差
axes = (0, 2, 3)
else:
raise ValueError(f'期望输入维度为2或4,但得到了 {len(x.shape)}')
实现批量归一化层

现在,我们将上述逻辑整合起来,定义一个完整的批量归一化层。

以下是实现一个批量归一化层的关键步骤:
- 初始化参数:在
__init__方法中,根据输入维度初始化可学习的参数γ(缩放)和β(平移),以及用于推理的移动平均统计量moving_mean和moving_var。 - 前向传播:在
forward方法中,根据当前模式(训练/推理)执行不同的计算。- 训练模式:计算当前批次的均值和方差,更新移动平均,然后进行归一化和仿射变换。
- 推理模式:直接使用存储的移动平均统计量进行归一化和仿射变换。

import torch
from torch import nn

class BatchNorm(nn.Module):
def __init__(self, num_features, num_dims):
super().__init__()
# 根据维度初始化参数形状
if num_dims == 2:
shape = (1, num_features) # MLP: [1, features]
else:
shape = (1, num_features, 1, 1) # CNN: [1, channels, 1, 1]
# 可学习参数
self.gamma = nn.Parameter(torch.ones(shape))
self.beta = nn.Parameter(torch.zeros(shape))
# 移动平均统计量(非可学习参数)
self.moving_mean = torch.zeros(shape)
self.moving_var = torch.ones(shape)
def forward(self, x):
# 确保参数与输入数据在同一设备上
if self.moving_mean.device != x.device:
self.moving_mean = self.moving_mean.to(x.device)
self.moving_var = self.moving_var.to(x.device)
# 判断模式
if self.training:
# 训练模式:计算当前批次统计量
if x.dim() == 2: # MLP
mean = x.mean(dim=0)
var = ((x - mean) ** 2).mean(dim=0)
else: # CNN
mean = x.mean(dim=(0, 2, 3), keepdim=True)
var = ((x - mean) ** 2).mean(dim=(0, 2, 3), keepdim=True)
# 更新移动平均(简化版,通常使用动量)
self.moving_mean = 0.9 * self.moving_mean + 0.1 * mean.detach()
self.moving_var = 0.9 * self.moving_var + 0.1 * var.detach()
else:
# 推理模式:使用移动平均统计量
mean = self.moving_mean
var = self.moving_var
# 归一化并应用仿射变换
x_hat = (x - mean) / torch.sqrt(var + 1e-5)
y = self.gamma * x_hat + self.beta
return y

在神经网络中应用批量归一化
理解了批量归一化层的实现后,我们来看看如何将其应用到实际的神经网络中。通常,批量归一化层被放置在仿射变换(如线性层或卷积层)之后、激活函数之前。
我们将构建一个简单的多层感知机(MLP),并在每个隐藏层的激活函数前加入批量归一化。
# 定义一个带有批量归一化的MLP
net = nn.Sequential(
nn.Flatten(),
nn.Linear(784, 256),
BatchNorm(256, num_dims=2), # 添加批量归一化
nn.ReLU(),
nn.Linear(256, 10)
)
然后,我们可以像训练普通网络一样训练这个网络。实验表明,加入批量归一化后,网络通常能更快地达到更高的准确率。
与框架内置实现对比
虽然我们从零实现了批量归一化以加深理解,但在实际项目中,我们应优先使用深度学习框架(如PyTorch)提供的高效、稳定的内置实现。
PyTorch中的 nn.BatchNorm1d(用于MLP)和 nn.BatchNorm2d(用于CNN)就是为此设计的。它们会自动推断输入维度,并且经过高度优化,计算开销远小于我们的Python实现。

# 使用PyTorch内置的批量归一化
net_builtin = nn.Sequential(
nn.Flatten(),
nn.Linear(784, 256),
nn.BatchNorm1d(256), # 使用内置BatchNorm1d
nn.ReLU(),
nn.Linear(256, 10)
)
使用内置实现不仅能获得更好的性能,还能减少代码错误,是更推荐的做法。
总结

在本节课中,我们一起学习了批量归一化的原理与实现。我们首先了解了其通过归一化层输入来稳定训练的核心思想。接着,我们探讨了如何区分训练与推理模式,以及如何处理全连接层和卷积层等不同网络结构。然后,我们动手从零实现了一个批量归一化层,并将其集成到一个简单的神经网络中,观察到了其对训练速度和模型性能的积极影响。最后,我们对比了手动实现与PyTorch框架内置实现的差异,强调了在实际开发中使用高效、稳定内置模块的重要性。批量归一化是深度学习中一项基础且强大的技术,掌握它对于构建高效的神经网络至关重要。
课程 P72:72. L13_5 ResNet(残差网络)🚀

在本节课中,我们将要学习深度学习中一个里程碑式的架构——残差网络(ResNet)。我们将探讨它被提出的原因、其核心设计思想、具体实现方式以及后续的改进版本。
概述
ResNet 是一种非常成功的深度神经网络架构。如果你需要一个无需担忧、性能出色的现成网络,ResNet 通常是推荐选择之一。它之所以表现显著更好,源于一个巧妙的设计,解决了深度网络训练中的关键难题。
为什么需要 ResNet?🤔
假设我们有一个深度网络。为了让其能力更强,一个自然的想法是增加网络层数,使其更深。每增加一层,网络的函数表示能力(函数类)就会变得更大、更强大,但同时也与前一个函数类有所不同。
想象一下,我们的目标是逼近一个代表“真实情况”的蓝色星星。随着网络层数增加(函数类变大),我们可能先逐渐接近这个目标。但继续增加层数后,我们可能会再次远离目标,甚至无法保证最终能回到最佳逼近点。这使得我们难以通过工程化的方式决定网络应该有多深、多复杂。
理想的情况是拥有嵌套的函数类,即更大的函数类完全包含较小的函数类。这样,随着网络加深(函数类变大),我们至少能保证不丢失之前学到的较好解,从而稳定地逼近目标。然而,标准的深度网络通常不具备这种嵌套特性。ResNet 的核心目标,就是让深度网络的函数类更接近这种理想的嵌套结构。
ResNet 的核心思想💡


ResNet 在2015年提出了一个巧妙的构思。传统网络层学习的是从输入 x 到输出 f(x) 的映射。ResNet 则改变了学习目标:它让网络层学习残差。
具体来说,ResNet 块不再直接学习 H(x),而是学习残差函数 F(x) = H(x) - x。这样,原始映射就变成了 H(x) = F(x) + x。
公式表示:
输出 = F(x, {W_i}) + x

这里,F(x, {W_i}) 代表需要学习的残差映射,x 是恒等映射(输入)。如果残差 F(x) 为0,那么输出就等于输入 x,即实现了恒等函数。


这种设计的精妙之处在于:
- 将恒等函数作为基线:网络无需从零开始学习恒等映射,这降低了学习难度。
- 促进嵌套函数类:即使添加新的层,前一层的有效输出(通过恒等连接)也能无损地传递到后面,这在一定程度上模拟了嵌套函数类的行为。
- 缓解梯度消失:梯度可以通过恒等连接(捷径)直接反向传播,有助于训练极深的网络。
ResNet 块的结构🔧
一个基础的 ResNet 块(或称残差块)结构如下:
以下是 ResNet 块的前向传播代码逻辑描述:

def forward(x):
# 主路径
y = BatchNorm(Conv2d(x)) # 卷积层1 + 批量归一化
y = ReLU(y)
y = BatchNorm(Conv2d(y)) # 卷积层2 + 批量归一化
# 捷径连接:如果输入x的维度与y匹配,直接相加
# 如果不匹配(例如下采样时),需要对x进行1x1卷积调整维度
if x.shape != y.shape:
shortcut = Conv2d_1x1(x) # 1x1卷积调整通道或尺寸
else:
shortcut = x
# 残差连接:主路径输出 + 捷径
output = y + shortcut
output = ReLU(output) # 通常在相加后应用激活函数
return output
结构说明:
- 主路径:通常包含两个
卷积层->批量归一化层->ReLU激活函数的组合。 - 残差连接(捷径):将块的输入
x直接加到主路径的输出上。 - 维度匹配:当输入和输出的维度(通道数、高、宽)不一致时(例如进行下采样),捷径部分需要使用一个
1x1卷积层来调整x的维度,使其能与主路径输出相加。
研究人员尝试过批量归一化、加法和ReLU的不同排列顺序,某些顺序在特定任务上可能表现更好。这体现了深度学习实践中“炼丹”的一面,但当前图示的结构是经过验证且广泛使用的标准形式。

从模块到完整网络🌉
上一节我们介绍了ResNet的核心构建块,本节中我们来看看如何用这些块搭建完整的ResNet网络。

完整的ResNet网络架构通常如下:
- 起始部分:一个普通的卷积层,接着是批量归一化、ReLU和最大池化层,对输入进行初步的特征提取和下采样。
- 主体部分:由多个“阶段”组成,每个阶段包含若干个堆叠的ResNet块。通常,每个新阶段的第一个ResNet块会进行下采样(步幅为2),以降低特征图的空间尺寸并增加通道数。
- 结尾部分:全局平均池化层,将特征图转换为特征向量,最后接一个全连接层进行分类。
根据堆叠的ResNet块数量不同,产生了不同深度的变体,如 ResNet-18, ResNet-34, ResNet-50, ResNet-101, ResNet-152 等。数字代表网络的层数。更深的网络(如ResNet-152)通常能获得更高的精度,但计算成本也更高。

ResNet的演进:ResNeXt🚀

ResNet的成功启发了许多改进工作,ResNeXt是其中重要的一种。它基于一个简单的观察:在标准的ResNet块中,所有通道都在一个大的变换空间中交互。
ResNeXt引入了“分组卷积”和“基数”的概念。其核心思想是:在单个ResNet块内部,采用并行且结构相同的多条路径(称为“基数”),每条路径的变换在更小的分组通道内进行,最后将所有路径的结果合并。
公式/概念:
输出 = Σ_{i=1 to C} T_i(x)
其中 C 是基数(路径数量),T_i 是第 i 条路径上的相同变换(一个小的全连接或卷积网络)。

代码实现关键:
在PyTorch的二维卷积 (nn.Conv2d) 中,可以通过设置 groups 参数来实现分组卷积,从而轻松构建ResNeXt块。
# 例如,将输入通道分为32组进行卷积
conv = nn.Conv2d(in_channels=256, out_channels=256, kernel_size=3, groups=32)
与原始ResNet相比,在相近的计算预算和参数量下,ResNeXt通过这种“分裂-变换-合并”的策略,通常能获得更好的性能,因为它增加了网络宽度方向的容量,同时保持了计算效率。


总结📚
本节课中我们一起学习了残差网络(ResNet)。
- 动机:为了解决深度网络难以训练和性能退化的问题,使网络函数类更接近理想的嵌套结构。
- 核心:引入了残差学习和恒等捷径连接,让网络层学习输入与输出之间的残差
F(x) = H(x) - x,极大缓解了梯度消失问题,使得训练成百上千层的网络成为可能。 - 结构:基础构建块是ResNet块,包含主路径和残差连接。完整的网络由多个这样的块堆叠而成,形成不同深度的变体(如ResNet-50)。
- 发展:ResNeXt作为重要改进,在块内引入了分组卷积和更高的基数,以更高效的方式增加网络容量,在相似计算成本下提升了性能。

ResNet的设计思想深刻影响了后续的神经网络架构,其残差连接已成为构建深度模型的常用技术。
课程 P73:73. L13_6 使用 Python 实现 ResNet 🧠

在本节课中,我们将学习如何使用 Python 和 PyTorch 框架来实现残差网络(ResNet)。我们将从核心的残差块构建开始,逐步组合成完整的网络,并理解其数据流和设计原理。
概述
ResNet 的核心思想是通过引入“残差连接”或“跳跃连接”来解决深度神经网络中的梯度消失和网络退化问题。本节课我们将动手实现这一结构。
1. 导入必要的库


第一步是导入所有构建和运行网络所需的库。

import torch
import torch.nn as nn
import torch.nn.functional as F

2. 实现残差块



上一节我们介绍了 ResNet 的核心思想,本节中我们来看看如何用代码实现其基本单元——残差块。

残差块的结构遵循我们之前看到的图示。它包含两个主要的卷积层,中间可能插入批量归一化(BatchNorm)和激活函数。此外,它还有一个可选的旁路卷积(1x1卷积)用于调整维度。


以下是残差块 Residual 类的实现代码:


class Residual(nn.Module):
def __init__(self, input_channels, num_channels, use_1x1conv=False, strides=1):
super().__init__()
# 主路径上的两个卷积层
self.conv1 = nn.Conv2d(input_channels, num_channels, kernel_size=3, padding=1, stride=strides)
self.conv2 = nn.Conv2d(num_channels, num_channels, kernel_size=3, padding=1)
# 对应的批量归一化层
self.bn1 = nn.BatchNorm2d(num_channels)
self.bn2 = nn.BatchNorm2d(num_channels)
# 可选的旁路1x1卷积,用于调整通道数和空间尺寸
if use_1x1conv:
self.conv3 = nn.Conv2d(input_channels, num_channels, kernel_size=1, stride=strides)
else:
self.conv3 = None
def forward(self, X):
# 主路径前向传播
Y = F.relu(self.bn1(self.conv1(X)))
Y = self.bn2(self.conv2(Y))
# 处理旁路
if self.conv3:
X = self.conv3(X)
# 残差连接:主路径输出 + 旁路输入
Y += X
return F.relu(Y)

核心概念解析:
- 残差连接:公式为
输出 = F(输入) + 输入。这里的F(输入)是主路径上两个卷积等操作的结果。 - 1x1卷积 (
conv3):当主路径改变了输入的通道数或空间尺寸(通过strides>1实现下采样)时,需要使用1x1卷积将旁路的输入X变换到与主路径输出Y相同的形状,以便进行加法运算。
3. 构建 ResNet 网络

有了基础的残差块,我们现在可以将它们组合起来,构建完整的 ResNet 网络。ResNet 通常由多个阶段(Stage)组成,每个阶段包含若干个残差块,并且通道数会逐阶段翻倍。
以下是构建 ResNet-18 的代码。它首先是一个基础的卷积和池化层,然后是四个由残差块堆叠而成的阶段。
def resnet_block(input_channels, num_channels, num_residuals, first_block=False):
blk = []
for i in range(num_residuals):
# 每个阶段(Stage)的第一个块可能需要下采样和调整通道
if i == 0 and not first_block:
blk.append(Residual(input_channels, num_channels, use_1x1conv=True, strides=2))
else:
# 后续块以及整个网络的第一个阶段不需要下采样
blk.append(Residual(num_channels, num_channels))
return blk

class ResNet18(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
# 初始层:7x7卷积 + 批量归一化 + ReLU + 最大池化
self.b1 = nn.Sequential(
nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3),
nn.BatchNorm2d(64),
nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
)
# 四个阶段
self.b2 = nn.Sequential(*resnet_block(64, 64, 2, first_block=True))
self.b3 = nn.Sequential(*resnet_block(64, 128, 2))
self.b4 = nn.Sequential(*resnet_block(128, 256, 2))
self.b5 = nn.Sequential(*resnet_block(256, 512, 2))
# 结尾层:全局平均池化 + 全连接层
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(512, num_classes)
def forward(self, X):
X = self.b1(X)
X = self.b2(X)
X = self.b3(X)
X = self.b4(X)
X = self.b5(X)
X = self.avgpool(X)
X = torch.flatten(X, 1)
X = self.fc(X)
return X
网络结构说明:
b1:处理输入图像,进行初步的特征提取和下采样。b2到b5:四个阶段,每个阶段由多个残差块组成。除了b2,每个阶段的第一个残差块都会将特征图尺寸减半(strides=2),并将通道数翻倍(通过use_1x1conv=True实现)。- 结尾:使用全局平均池化将每个通道的特征图降为1x1,然后通过一个全连接层输出分类结果。

4. 测试与运行


现在,我们可以实例化网络并进行简单的测试,观察数据维度的变化。

# 实例化网络
net = ResNet18()
print(net)


# 创建一个模拟输入(批量大小=1, 通道=1, 图像尺寸=224x224)
X = torch.rand(size=(1, 1, 224, 224))

# 逐阶段查看输出形状
for layer in [net.b1, net.b2, net.b3, net.b4, net.b5]:
X = layer(X)
print(layer.__class__.__name__, 'output shape:\t', X.shape)

# 经过全局池化和全连接层
X = net.avgpool(X)
X = torch.flatten(X, 1)
print('After avgpool shape:\t', X.shape)
X = net.fc(X)
print('Final output shape:\t', X.shape)
运行上述代码,你将看到数据从 [1, 1, 224, 224] 开始,经过每个阶段后,空间尺寸逐渐减小,通道数逐渐增加,最终变为 [1, 10] 的分类得分向量。
5. 关于正则化的讨论
在构建网络时,你可能会注意到我们没有显式地使用 Dropout 等正则化方法。这里有一个重要的原因:
- 批量归一化作为隐式正则化:批量归一化在训练时会对每个批次的数据进行归一化(减去均值,除以标准差)。由于每个批次的统计量是随机的,这相当于向网络的激活值中注入了噪声。这种噪声具有尺度不变性,是一种非常有效的正则化手段,在卷积网络中常常可以替代 Dropout。
- 其他正则化选项:你仍然可以在最后的全连接层使用权重衰减(Weight Decay)或 Dropout。对于更深的网络或特定任务,组合使用多种正则化策略可能带来微小的性能提升。
6. ResNet 的应用范围

ResNet 不仅限于图像分类。其残差连接的思想具有普适性:
- 图像回归:如图像超分辨率、深度估计等。
- 非视觉数据:在自然语言处理、语音识别等领域的序列模型中也广泛采用了残差连接,因为它能有效缓解深层网络中的优化困难。
总结
本节课中我们一起学习了如何使用 Python 实现 ResNet。
- 我们首先实现了残差块,理解了其包含主路径和跳跃连接的核心结构,以及如何使用1x1卷积处理维度不匹配。
- 接着,我们通过堆叠残差块构建了 ResNet-18 网络,明确了其多阶段设计、通道数翻倍和下采样的规律。
- 我们测试了网络的数据流,并讨论了批量归一化所起的正则化作用。
- 最后,我们了解了残差思想的广泛应用性,它已成为构建深层神经网络的重要模块。

通过本教程,你应该能够掌握 ResNet 的基本实现原理,并可以将其应用到自己的项目中。
课程 P74:超越ResNet 🚀
在本节课中,我们将学习ResNet之后的一些重要神经网络架构变体。我们将探讨ResNeXt、DenseNet、SENet和ShuffleNet等模型的核心思想,了解它们如何通过改进网络结构来提升性能或效率。



ResNeXt:分组卷积的威力 🔄
上一节我们介绍了ResNet的基本思想。本节中我们来看看它的一个重要变体——ResNeXt。ResNeXt的核心思想是将标准的卷积操作分解为多个并行的“组”或“路径”。
在ResNet中,一个卷积层通常将输入通道 C_i 转换为输出通道 C_o,这涉及一个大小为 C_i × C_o 的密集矩阵运算,计算成本较高。ResNeXt通过引入“分组卷积”来分离参数数量和输出维度之间的依赖关系。
具体来说,ResNeXt不是使用一个大的密集矩阵,而是使用一个块对角矩阵来近似它。每个块内部是稠密的,但块与块之间是独立的。这带来了两个好处:
- 可以增加输出通道数(通过增加块的数量),而不显著增加参数总量。
- 参数数量减少了,因为每个块只处理输入通道的一个子集。

其核心操作可以视为一种特殊的稀疏卷积。与通用稀疏矩阵不同,这种预定义的块稀疏结构在GPU上能更高效地执行,避免了指针查找带来的开销。

DenseNet:密集连接的网络 🌳
既然ResNet通过残差连接取得了成功,一个自然的想法是:为什么不进行更密集的连接呢?这就是DenseNet的出发点。

DenseNet将每一层的输出与所有后续层的输入进行连接。如果用公式表示,第 l 层的输出 x_l 是前面所有层输出的拼接:
x_l = H_l([x_0, x_1, ..., x_{l-1}])
其中 [·] 表示拼接操作,H_l 是一个复合函数(如BN-ReLU-Conv)。
这类似于构建了一个高阶的泰勒展开式,使得网络能够重用所有先前层的特征。理论上,这应该能增强特征传播、鼓励特征重用并减少参数数量。然而,在实践中,一个训练良好的标准ResNet的性能往往能与DenseNet媲美甚至超越,这说明训练技巧和实现细节有时比网络结构本身更为关键。
SENet:通道注意力机制 🎯
DenseNet试图通过增加连接来改善信息流。但信息在卷积网络中传递的速度可能仍然较慢,因为一个像素的信息需要经过多层卷积才能影响到远处的像素。SENet(Squeeze-and-Excitation Networks)引入了一种轻量级的“注意力”机制来快速传递全局信息。
SENet的核心思想是:让网络学会动态地调整各通道的重要性。它通过一个额外的、计算量很小的分支来实现:
- 压缩(Squeeze):对每个通道的空间维度(高和宽)进行全局平均池化,将一个通道的所有像素信息压缩成一个标量。这得到了一个长度为
C(通道数)的向量。 - 激励(Excitation):将这个向量输入一个小型的两层全连接网络(通常带有一个瓶颈层),学习出每个通道的权重(一个0到1之间的值)。这个过程可以表示为:
s = σ(W_2 δ(W_1 z))
其中z是压缩后的向量,W_1和W_2是可学习的权重矩阵,δ是ReLU激活函数,σ是Sigmoid函数。 - 重加权(Reweight):将学习到的通道权重
s与原始的输入特征图进行逐通道的乘法,从而增强重要通道,抑制不重要通道。
这个机制允许网络利用图像的全局上下文信息,快速调整各通道的响应,从而在不显著增加计算成本的前提下提升模型性能。
ShuffleNet:分组卷积后的通道混合 🃏

ResNeXt使用了分组卷积来提升效率,但这可能导致不同组(或“路径”)之间的信息无法交流。ShuffleNet在ResNeXt的基础上增加了一个巧妙的操作来解决这个问题:通道洗牌(Channel Shuffle)。
以下是ShuffleNet单元的基本步骤:
- 首先,对输入特征图进行分组卷积(类似ResNeXt)。
- 然后,在进入下一层之前,对分组卷积输出的通道进行重新排列。例如,将不同组的输出通道交错混合。
- 这种洗牌操作可以通过一个高效的、无需学习的固定索引重排来实现。

通过引入通道洗牌,ShuffleNet确保了不同组之间的信息能够有效混合,从而在保持高效计算的同时,获得了比普通分组卷积更好的性能。它特别适合移动端等计算资源受限的场景。

其他技巧与总结 📚
除了上述主要架构,还有一些相关的有效技巧:
- 深度可分离卷积:这是MobileNet的核心,也是分组卷积的一种极端形式(每组只有一个通道)。它将标准卷积分解为深度卷积(逐通道的空间卷积)和点卷积(1x1卷积,用于组合通道),极大地减少了计算量和参数量。

本节课中我们一起学习了ResNet之后几种重要的网络架构演进。我们来总结一下关键点:

- ResNeXt:通过分组卷积和块对角稀疏化,在控制参数量的同时增加了网络的宽度(基数)。
- DenseNet:通过密集连接所有层,促进特征重用,但其优势高度依赖于训练实现。
- SENet:引入了轻量的通道注意力机制,让网络能自适应地校准通道特征响应,有效提升精度。
- ShuffleNet:在分组卷积的基础上加入通道洗牌操作,促进了组间信息交流,兼顾了效率与性能。
- 深度可分离卷积:作为高效的卷积分解方式,是许多轻量级网络(如MobileNet)的基石。

这些工作展示了神经网络设计中的核心思路:通过更高效的结构化稀疏、更灵活的特征重用以及引入轻量的自适应机制,来不断突破模型性能与效率的边界。理解这些思想,有助于我们设计或选择适合特定任务的网络架构。
课程 P75:DenseNet在Python中的实现 🧱
在本节课中,我们将学习如何在Python中实现DenseNet网络。我们将从理解其核心概念开始,逐步构建卷积块、密集块和过渡块,最终组装成一个完整的DenseNet模型。本教程旨在让初学者能够清晰地理解每一步的实现细节。

概述

DenseNet的核心思想是密集连接,即每一层的输入都来自前面所有层的输出。这种设计促进了特征重用,并缓解了梯度消失问题。我们将通过代码来具体实现这一思想。

1. 卷积块
首先,我们实现一个基础的卷积块。这个块是网络的基本构建单元,它依次执行批量归一化、激活函数(如ReLU)和2D卷积操作。

以下是卷积块的代码实现:

class ConvBlock(nn.Module):
def __init__(self, in_channels, out_channels):
super(ConvBlock, self).__init__()
self.conv = nn.Sequential(
nn.BatchNorm2d(in_channels),
nn.ReLU(inplace=True),
nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1)
)
def forward(self, x):
return self.conv(x)
这个简单的对象封装了卷积操作的标准流程。
2. 密集块
上一节我们介绍了基础的卷积块,本节中我们来看看如何构建密集块。密集块是DenseNet的核心,它通过连接(concatenate)前面所有层的输出特征图来构建更丰富的特征表示。

密集块会堆叠多个卷积块,每个卷积块输出的通道数可以不同。关键步骤是将当前卷积块的输入与之前所有层的输出在通道维度上进行连接。
以下是密集块的代码实现:
class DenseBlock(nn.Module):
def __init__(self, num_layers, in_channels, growth_rate):
super(DenseBlock, self).__init__()
self.layers = nn.ModuleList()
for i in range(num_layers):
# 每个卷积块的输入通道数是:初始通道 + 已生成通道数
layer_in_channels = in_channels + i * growth_rate
self.layers.append(ConvBlock(layer_in_channels, growth_rate))
def forward(self, x):
features = [x]
for layer in self.layers:
# 将当前输入与之前所有特征图连接
new_feature = layer(torch.cat(features, dim=1))
features.append(new_feature)
# 将块内所有层的输出连接起来,作为整个密集块的输出
return torch.cat(features, dim=1)

随着网络加深,输入到每个卷积块的通道数会越来越大,这意味着计算的高阶项会涉及更多参数。
示例:假设输入有3个通道,密集块包含2个卷积层,每层输出10个通道(growth_rate=10)。那么:
- 第一层输入:3通道,输出:10通道。
- 第二层输入:3 + 10 = 13通道,输出:10通道。
- 最终输出通道数:3 + 10 + 10 = 23通道。


3. 过渡块
如果持续使用密集块,特征图的通道数会无限增长,导致计算量剧增。因此,我们需要一种机制来压缩特征图。这就是过渡块的作用。
过渡块通常位于密集块之后,用于降低特征图的分辨率和通道数,从而控制模型的复杂度。其设计原则是:先构建丰富的特征,再进行压缩,以避免过早丢失信息。
以下是过渡块的代码实现,它包含批量归一化、ReLU激活、1x1卷积(用于降维)和平均池化:
class TransitionBlock(nn.Module):
def __init__(self, in_channels, out_channels):
super(TransitionBlock, self).__init__()
self.transition = nn.Sequential(
nn.BatchNorm2d(in_channels),
nn.ReLU(inplace=True),
nn.Conv2d(in_channels, out_channels, kernel_size=1), # 1x1卷积压缩通道
nn.AvgPool2d(kernel_size=2, stride=2) # 降低空间分辨率
)
def forward(self, x):
return self.transition(x)
示例:如果过渡块输入为23个通道,通过1x1卷积将其压缩到10个通道,并使用2x2平均池化将特征图尺寸减半。
4. 构建完整的DenseNet模型
现在,我们将前面定义的各个模块组合起来,构建一个完整的DenseNet模型。一个典型的DenseNet结构遵循“初始卷积 -> 密集块 -> 过渡块 -> ... -> 全局平均池化 -> 全连接层”的模式。
以下是DenseNet的一个简化实现示例:
class DenseNet(nn.Module):
def __init__(self, num_blocks, growth_rate=12, compression_factor=0.5, num_classes=10):
super(DenseNet, self).__init__()
# 初始卷积层
self.initial_conv = nn.Conv2d(3, 2*growth_rate, kernel_size=3, padding=1)
self.dense_blocks = nn.ModuleList()
self.transition_blocks = nn.ModuleList()
in_channels = 2 * growth_rate
for i in range(len(num_blocks)):
# 添加密集块
self.dense_blocks.append(DenseBlock(num_blocks[i], in_channels, growth_rate))
in_channels += num_blocks[i] * growth_rate
# 如果不是最后一个块,添加过渡块
if i != len(num_blocks) - 1:
out_channels = int(in_channels * compression_factor)
self.transition_blocks.append(TransitionBlock(in_channels, out_channels))
in_channels = out_channels
# 最终分类层
self.final_bn = nn.BatchNorm2d(in_channels)
self.global_pool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(in_channels, num_classes)
def forward(self, x):
x = self.initial_conv(x)
for i in range(len(self.dense_blocks)):
x = self.dense_blocks[i](x)
if i < len(self.transition_blocks):
x = self.transition_blocks[i](x)
x = self.final_bn(x)
x = self.global_pool(x)
x = x.view(x.size(0), -1)
x = self.fc(x)
return x
参数说明:
num_blocks: 一个列表,指定每个密集块中包含的卷积层数量(例如[4, 4, 4])。growth_rate: 每个卷积层输出的通道数(k)。compression_factor: 过渡块中通道压缩的比例(θ,通常为0.5)。
这种设计模式使得深度网络的结构变得相对可预测和统一。
5. 模型特点与讨论
DenseNet通过密集连接实现了强大的特征复用能力,但其代价是可能产生非常大的中间特征表示,导致前向和反向传播的计算量都很大。与ResNet等网络相比,DenseNet在参数量控制方面可能不那么直接。
关于效率:虽然DenseNet理论上有其优势,但在固定的计算预算下,像ResNet或ShuffleNet这样结构更简洁的网络可能更具实践效率。DenseNet的密集连接使得特征图通道数快速增长,计算成本较高。
关于迁移学习:更大的中间表示是否对迁移学习有益?这并不绝对。迁移学习的成功很大程度上取决于源领域和目标领域的相似性。例如,在ImageNet(自然图像)上预训练的模型,直接应用于卫星图像可能效果不佳。
后续发展:DenseNet的思想启发了后续研究,例如尝试连接特定层而非所有层,或设计动态推理网络(根据输入难度调整计算量)。然而,这些理论改进往往伴随着额外的实现开销,并不总是能在实践中带来净收益。
总结

本节课中我们一起学习了DenseNet在Python中的完整实现。我们从基础的卷积块开始,构建了实现密集连接的密集块,然后引入了控制复杂度的过渡块,最后将这些组件组装成完整的DenseNet模型。我们了解到,尽管DenseNet的设计促进了特征重用,但其计算开销也相对较大。在实际应用中,需要根据具体任务和计算资源在模型性能和效率之间进行权衡。
📝 课程 P76:期中考试后勤安排与备考指南
在本节课中,我们将详细介绍期中考试的后勤安排、考试范围、题型以及备考建议。请仔细阅读,为即将到来的考试做好准备。
🗓️ 考试时间与地点安排
下周二是期中考试日,因此当天没有家庭作业,以便大家专心准备。
考试将从下午3:30持续到5:00。我们可能会在3:00开始准备,因为需要组织大家进入考场。
由于考生人数较多,我们将使用两个教室:LeCant 1 和 LeCant 3。我们会优先安排考生进入 LeCant 1。如果 LeCant 1 坐满,后续的考生将前往 LeCant 3 参加考试。
📵 考试规则与物品要求
考试期间不允许使用任何电子设备,包括电脑和手机。这是为了确保考试的公平性,防止通过通讯工具获取外部帮助。

考试纸张将由考场统一提供,考生不应携带自己的纸张进入考场。你可以打印复习资料带入考场,但需要自己管理好这些材料。携带过多纸张不一定有助于考试,关键在于你能否快速找到所需信息。
📚 考试范围与内容
考试将涵盖截至本周四课程结束前所讲授的全部内容。
以下是考试可能涉及的主要方面:
1. 核心概念理解
考试会考察你对课程核心概念的理解。例如:
- 学习率 (Learning Rate)
- 正则化 (Regularization)
- 过拟合 (Overfitting)
- 协变量漂移 (Covariate Shift)
你需要能够解释、理解并识别这些概念。
2. 代码阅读理解
由于不能运行代码,考试会侧重考察阅读和理解代码的能力。你可能会看到一些网络定义或其他代码片段,并被要求理解其逻辑。
3. 基础数学知识
作为一门统计学课程,考试会包含必要的数学问题。例如:
- 卷积操作
- 求导
- 链式法则
4. 实验分析与诊断
本课程实践性较强,因此考试可能会展示一些实验场景或结果,要求你分析其中可能出现的问题。认真完成并复盘作业中的实验将对此有很大帮助。
📝 考试题型与策略
答案通常简洁明了,无需长篇大论。
考试题目数量会多于大多数学生能完成的数量,这是故意设计的。其目的是:如果你在某道题上卡住,可以果断跳过,继续解答下一道题,从而更合理地分配时间。
每个问题都会附有预估的完成时间,帮助你判断问题的难易程度,并调整答题节奏。
❓ 常见问题与备考建议
问:应该如何备考?
- 回顾课程内容:复习至今为止的所有课程幻灯片。
- 阅读教材章节:教材的对应章节可能比幻灯片更易于系统理解。
- 重温课程视频:可以重新观看教学视频以加深理解。
- 研究作业解答:作业的参考解答能提供重要提示,尤其是关于实验操作和问题诊断的部分。如果你作业完成得很好,这一步可能不是必须的。
问:考试问题会和作业一样吗?
考试问题会尽量与作业相关,但由于考试形式(纸上作答)与作业形式(上机编程)不同,问题不会完全一致。
问:有多少道题?
目前计划大约有10道题,但各题长度和耗时不同,请以现场标注的预估时间为准。
📊 评分与公平性
考试结束后,我们会根据分数分布情况评定成绩,并尽力保证评分过程的公平性。

🎯 总结
本节课我们一起学习了期中考试的后勤安排与备考指南。关键点包括:考试禁止使用电子设备、涵盖所有已学概念、题型包含概念、代码、数学和实验分析,以及采用“题目总量多于可完成量”的策略来帮助大家管理时间。请根据建议认真复习,祝大家考试顺利!
课程 P77:混合化(即时编译)🚀
在本节课中,我们将学习如何通过“混合化”技术,将命令式编程的灵活性与符号式编程的高性能结合起来,以加速深度学习模型的训练过程。我们将探讨这两种编程模式的核心概念、优缺点,并学习如何在实践中应用混合化。
两种编程模式:命令式 vs. 符号式
上一节我们介绍了课程的目标。本节中,我们来看看深度学习编程的两种主要模式:命令式编程和符号式编程。
命令式编程(Imperative Programming)

到目前为止,我们主要使用的是命令式编程。这是最直观的编程方式,代码按顺序一步步执行。
以下是命令式编程的一个简单示例:
a = 1
b = 2
c = a + b
优点:
- 易于调试:可以随时打印中间变量(如
print(b))来检查状态。 - 直观灵活:代码执行流程与书写顺序一致。
缺点:
- 性能开销:Python解释器需要将代码编译为字节码并在虚拟机上运行,每次执行操作都有调用开销。
- 可移植性差:程序依赖Python环境,难以在手机等没有Python的设备上直接运行。
符号式编程(Symbolic Programming)
另一方面,符号式编程要求我们先定义完整的计算流程(即“符号”或计算图),然后再将数据输入执行。
以下是符号式编程的一个概念性示例:
# 1. 定义计算(符号)
program = “c = a + b”
# 2. 编译为可执行对象
compiled_prog = compile(program)
# 3. 提供输入数据并执行
result = compiled_prog.run(a=1, b=2)

优点:
- 高性能:编译器在编译时能看到整个程序,可以进行深度优化(如内存复用、死代码消除)。
- 单次调用:无论程序多长,对编译后的程序只需一次函数调用。
- 可移植性好:编译后的程序可以脱离Python环境(例如用C++)运行。
缺点:
- 难以调试:由于执行与定义分离,难以插入
print语句进行调试。 - 不够灵活:程序结构需要预先静态定义。
混合化:结合两者优势
我们已经了解了两种编程模式各自的优缺点。本节中,我们来看看如何通过“混合化”将两者结合起来,实现既灵活又高效的编程。
混合化的核心思想是:使用命令式的方式定义和开发模型,然后将其转换为符号式程序进行高性能训练和部署。
在MXNet/Gluon中,这通过 HybridBlock 和 hybridize() 方法实现。
以下是如何实现混合化的具体步骤:
首先,我们像往常一样使用 nn.HybridBlock 或 nn.HybridSequential 来定义网络。
from mxnet.gluon import nn
# 定义一个混合序贯模型
net = nn.HybridSequential()
net.add(nn.Dense(256, activation=‘relu’))
net.add(nn.Dense(128, activation=‘relu’))
net.add(nn.Dense(10))
定义完成后,我们只需调用 hybridize() 方法,即可将网络从命令式执行模式切换到符号式执行模式。
net.hybridize() # 开启混合化(即时编译)

调用 hybridize() 后,网络的前向传播将被编译和优化,从而获得显著的性能提升,同时保留了命令式编程的易用性。
总结
本节课中,我们一起学习了深度学习中的混合化技术。
- 我们首先回顾了命令式编程的直观与灵活,以及它在性能和可移植性上的局限。
- 接着,我们探讨了符号式编程的高效与可优化性,并了解了它调试困难的缺点。
- 最后,我们学习了混合化如何取两者之长:用命令式的方式轻松构建和调试模型,再通过
hybridize()一键转换为符号式程序,以获得更快的执行速度和更好的部署能力。

掌握混合化技术,能帮助你在保持开发效率的同时,充分挖掘硬件性能,是训练复杂深度学习模型时的一项重要技能。

课程 P78:Python 中的混合化 🚀
在本节课中,我们将学习深度学习框架中的“混合化”概念。我们将了解命令式编程与符号式编程的区别,并学习如何在 Python 中利用混合化技术来提升模型执行效率,同时保持代码的易用性。

命令式编程与符号式编程 🔄
上一节我们介绍了混合化的概念,本节中我们来看看这两种编程范式在 Python 中的具体表现。
命令式编程

命令式编程是我们最熟悉的编程方式。代码按顺序执行,并立即计算结果。

以下是命令式编程的一个简单例子:

def add(a, b):
return a + b
def fancy(a, b, c, d):
e = add(a, b)
f = add(c, d)
g = add(e, f)
return g

result = fancy(1, 2, 3, 4)
print(result) # 输出:10
这段代码定义了函数并立即执行计算,得到结果 10。
符号式编程
符号式编程则不同。它首先定义计算流程(一个“符号图”),而不立即执行。只有在最后调用“编译”和“运行”时,才根据输入数据执行整个计算图。

以下是符号式编程的例子:
# 定义一个符号化的加法“函数”,它不计算,只描述操作
def add_sym(a, b):
# 这里返回一个描述“加法”的符号对象
return f"add({a}, {b})"

def fancy_sym(a, b, c, d):
e = add_sym(a, b)
f = add_sym(c, d)
g = add_sym(e, f)
return g

# 此时只是构建了计算图,没有实际计算
program = fancy_sym('data_a', 'data_b', 'data_c', 'data_d')
print(program) # 输出类似:add(add(data_a, data_b), add(data_c, data_d))

# 后续需要“编译器”将符号程序与真实数据结合,才能得到结果
# result = compile_and_run(program, data)
符号编程更高效,但编写和调试更复杂。
在神经网络中应用混合化 🧠
理解了基础概念后,我们来看看如何在深度学习框架中应用混合化。
命令式网络定义
首先,我们以命令式方式定义一个简单的多层感知机。
from mxnet.gluon import nn
# 定义一个顺序网络
net = nn.Sequential()
net.add(nn.Dense(256, activation='relu'),
nn.Dense(128, activation='relu'),
nn.Dense(10))
# 初始化网络参数
net.initialize()
# 创建随机输入数据
import mxnet as mx
x = mx.nd.random.normal(shape=(1, 20))

# 执行前向传播(命令式)
output = net(x)
print(output)
这段代码按层顺序执行计算,易于理解和调试。
启用混合化
现在,我们启用混合化。只需将网络类改为 HybridSequential 并调用 .hybridize() 方法。
from mxnet.gluon import nn

# 使用 HybridSequential
net = nn.HybridSequential()
net.add(nn.Dense(256, activation='relu'),
nn.Dense(128, activation='relu'),
nn.Dense(10))
net.initialize()
# 启用混合化
net.hybridize()
# 同样的输入,得到同样的结果
x = mx.nd.random.normal(shape=(1, 20))
output = net(x)
print(output)
表面上看,代码和结果没有变化。但内部执行机制已从命令式转为符号式。

性能对比与原理 ⚡
上一节我们启用了混合化,本节中我们通过基准测试来看看它的性能优势,并探讨其工作原理。
性能基准测试

以下是测试网络前向传播 1000 次所需时间的函数:

import time
from mxnet import autograd

def benchmark(net, x):
# 等待所有计算完成,确保计时准确
mx.nd.waitall()
start = time.time()
for i in range(1000):
y = net(x)
y.wait_to_read() # 确保计算完成
mx.nd.waitall()
end = time.time()
return end - start
# 测试未混合化的网络
net_imperative = nn.Sequential() # ... 添加层
print(f"命令式时间: {benchmark(net_imperative, x):.4f} 秒")
# 测试混合化的网络
net_hybrid = nn.HybridSequential() # ... 添加层
net_hybrid.hybridize()
print(f"混合式时间: {benchmark(net_hybrid, x):.4f} 秒")
典型结果:混合化后的网络执行速度通常快 2 倍或更多。对于计算量小但调用频繁的操作,节省的 Python 解释器开销尤为明显。

混合化如何工作
混合化的核心是 “首次执行时构建,后续直接调用”。
- 当你第一次调用
net(x)时(在hybridize()之后),系统会:- 跟踪执行流程。
- 构建一个符号计算图。
- 将该图编译为高效的低级代码(如 C++)并缓存。
- 后续再次调用
net(x)时,系统将直接运行缓存的编译后代码,绕过 Python 层的解释和执行。

这意味着,网络前向传播中的 Python 代码(如循环、条件判断)只在第一次构建图时运行一次。这消除了 Python 的运行时开销,提升了性能。



导出与部署 📦
混合化的另一个关键优势是便于模型部署。
导出符号图
你可以将混合化后的网络导出为与语言无关的中间表示(IR)。
# 假设 net 是一个已混合化并运行过的 HybridSequential 网络
net.export('my_model')

# 这会生成两个文件:
# - my_model-symbol.json (计算图定义)
# - my_model-0000.params (模型参数)
my_model-symbol.json 文件以 JSON 格式描述了整个计算图。它不依赖于 Python,可以被 C++、Java、JavaScript 等后端加载和执行。

调试注意事项
在混合化模式下,调试方式有所不同:

class DebugNet(nn.HybridBlock):
def __init__(self):
super().__init__()
self.hidden = nn.Dense(256)
self.output = nn.Dense(10)
def hybrid_forward(self, F, x):
# F 是命名空间,在命令式下是 nd,在符号式下是 sym
print(f"F 是: {F}")
print(f"x 是: {x}")
h = self.hidden(x)
print(f"隐藏层输出是: {h}")
return self.output(h)


net = DebugNet()
net.initialize()
x = mx.nd.random.normal(shape=(1, 20))

print("--- 第一次运行(构建图阶段)---")
output = net(x) # 此时会打印信息
print("\n--- 启用混合化后运行 ---")
net.hybridize()
output = net(x) # 第一次调用会打印
output = net(x) # 第二次调用不会打印,因为直接运行编译后的代码
关键点:hybrid_forward 中的 print 语句只在第一次构建计算图时执行。一旦图被编译缓存,后续调用将不再执行这些 Python 代码。因此,生产环境中的调试需要使用框架提供的专用符号式调试工具。

总结 🎯
本节课中我们一起学习了深度学习中混合化编程的核心内容:
- 概念对比:理解了命令式编程(即时执行,易调试)与符号式编程(先构图后执行,高效便携)的根本区别。
- 混合化实践:学会了使用
HybridSequential和.hybridize()方法,轻松将命令式代码转换为混合模式,兼顾开发效率与运行性能。 - 性能提升:通过基准测试验证了混合化能显著减少 Python 解释器开销,尤其适合小型、频繁调用的运算。
- 工作原理:掌握了混合化“首次构建、后续缓存”的执行机制,它只在第一次运行时构建和编译符号图。
- 部署与调试:学会了如何通过
export导出模型为通用格式以便跨平台部署,并了解了在混合化模式下调试代码的特殊性(如print语句只在首次生效)。

核心建议:在模型开发、调试阶段使用命令式编程,利用其交互性好、易调试的优点。在模型训练和部署阶段启用混合化,以获得最佳性能并实现轻松部署。
课程 P79:Python 中的多 GPU 训练 🚀

在本节课中,我们将学习如何在 Python 中利用多个 GPU 来加速深度学习模型的训练过程。我们将从基础概念开始,逐步讲解如何将数据和模型参数分配到不同的 GPU 上,以及如何同步梯度以实现并行计算。

1. 准备工作与环境检查 🔍

进行多 GPU 训练前,首先需要确保你的系统拥有多个 GPU。例如,本教程示例中使用了两个 GPU,其中一块是较旧的 M60 GPU。

确认硬件环境后,我们就可以开始构建训练流程了。

2. 参数初始化与设备分配 ⚙️
我们首先需要定义模型参数。默认情况下,参数初始化在 CPU 上。为了进行 GPU 训练,我们需要将这些参数复制到各个 GPU 设备上。
我们定义一个函数来完成这个操作:它接收 CPU 上的参数和一个目标 GPU 上下文,然后将每个参数复制到该 GPU 上,并为其分配梯度计算所需的空间。
def copy_params_to_gpu(params, ctx):
# 将参数复制到指定 GPU 上下文
new_params = [param.copyto(ctx) for param in params]
# 为梯度计算分配空间
for param in new_params:
param.attach_grad()
return new_params

例如,将参数复制到 GPU 0 后,第一个权重和偏置参数都将位于 GPU 0 上。

3. 梯度同步与归约操作 🔄

上一节我们介绍了如何将参数分配到不同 GPU。本节中我们来看看如何在不同 GPU 之间同步计算得到的梯度,这是多 GPU 训练的核心。
梯度同步通常采用“归约”(Reduce)操作,将分散在各个 GPU 上的梯度汇总。我们采用一种简单的方法:将所有 GPU 上的梯度相加,然后将结果复制回每个 GPU。

以下是实现梯度归约的关键步骤:

- 收集所有 GPU 上的梯度数据。
- 将所有梯度相加(通常在主 GPU,如 GPU 0 上进行)。
- 将求和后的梯度广播回所有 GPU。
def sync_gradients(grad_list):
# grad_list 是一个列表,包含各个 GPU 上的梯度
# 假设我们在 GPU 0 上进行归约
for i in range(1, len(grad_list)):
grad_list[0][:] += grad_list[i].copyto(grad_list[0].context)
# 将归约后的梯度复制回其他 GPU
for i in range(1, len(grad_list)):
grad_list[i][:] = grad_list[0].copyto(grad_list[i].context)
例如,假设 GPU 0 和 GPU 1 上各有一个值为 2 的梯度张量,归约后,每个 GPU 上的梯度都会变为 4(2+2)。

4. 数据划分与分发 📊

现在,我们需要将训练数据的一个批次(Batch)划分成多个部分,并分发到不同的 GPU 上。
假设我们有 K 个 GPU,一个包含 N 个样本的数据批次。理想情况下,N 能被 K 整除,这样每个 GPU 将获得 N/K 个样本。
以下是数据划分与分发的步骤:

- 计算每个 GPU 应获得的样本数
m = N // K。 - 将数据批次在样本维度上均匀切分成 K 份。
- 将每一份数据复制到对应的 GPU 上。
def split_and_load_batch(data, ctx_list):
# data: 一个批次的训练数据
# ctx_list: GPU 上下文列表
k = len(ctx_list)
# 假设数据样本维度为0,且可被k整除
split_size = data.shape[0] // k
splits = []
for i, ctx in enumerate(ctx_list):
# 切分数据
part = data[i * split_size: (i+1) * split_size]
# 复制到指定 GPU
splits.append(part.copyto(ctx))
return splits
例如,一个在 CPU 上包含 6 个样本的批次,分发给 2 个 GPU 后,前 3 个样本会分配到 GPU 0,后 3 个样本会分配到 GPU 1。

5. 单批次多 GPU 训练流程 🏃♂️
理解了数据分发和梯度同步后,我们可以将它们组合起来,构建一个完整的单批次多 GPU 训练流程。
这个流程的核心思想是:数据并行。每个 GPU 持有相同的模型参数副本,但处理不同的数据子集,最后同步梯度来更新模型。
以下是单批次训练的关键步骤:
- 数据分发:将输入
X和标签Y划分并加载到各个 GPU。 - 并行前向与反向传播:在每个 GPU 上,使用其分配到的数据和参数副本,独立计算损失并进行反向传播以获取梯度。
- 梯度同步:将所有 GPU 计算出的梯度进行归约(如求和平均)。
- 参数更新:使用同步后的梯度,在每个 GPU 上更新其参数副本(由于梯度相同,更新后的参数也保持一致)。

def train_batch_multi_gpu(X, Y, param_list, ctx_list):
# 1. 拆分数据
X_splits = split_and_load_batch(X, ctx_list)
Y_splits = split_and_load_batch(Y, ctx_list)
losses = []
# 2. 在每个GPU上并行计算损失和梯度
with autograd.record():
for i, (x, y, params) in enumerate(zip(X_splits, Y_splits, param_list)):
# 在每个GPU上进行前向传播
loss = model_forward(params, x, y)
losses.append(loss)
# 反向传播在每个GPU上自动并行执行
for loss in losses:
loss.backward()
# 3. 同步所有GPU上的梯度
grad_list = [params.grad for params in param_list]
sync_gradients(grad_list)
# 4. 更新每个GPU上的参数
for params in param_list:
update_parameters(params) # 例如: params[:] -= lr * params.grad

通过自动并行化机制,系统会尽可能地并行执行数据复制、前向计算和反向传播等操作。




6. 完整训练循环与性能分析 📈
将单批次训练流程嵌入完整的训练循环中,就构成了多 GPU 训练程序。训练函数的结构与单 GPU 训练相似,主要区别在于需要管理多个 GPU 上下文和数据划分。

def train_multi_gpu(num_gpus, batch_size, lr, num_epochs):
# 创建GPU上下文列表
ctx_list = [gpu(i) for i in range(num_gpus)]
# 初始化参数并复制到各个GPU
initial_params = init_params()
param_list = [copy_params_to_gpu(initial_params, ctx) for ctx in ctx_list]
for epoch in range(num_epochs):
for X_batch, Y_batch in data_iter(batch_size):
# 在多个GPU上训练一个批次
train_batch_multi_gpu(X_batch, Y_batch, param_list, ctx_list)
# ... 打印日志,评估模型等 ...

性能分析:
单纯增加 GPU 数量不一定能线性提升训练速度。例如,将 GPU 从 1 个增加到 2 个,但保持总批次大小(Batch Size)不变,那么每个 GPU 处理的子批次大小会减半,这可能无法充分利用 GPU 的计算能力,同时通信开销会显得相对更大。

为了获得更好的加速比,常见的做法是:
- 增大总批次大小:例如,使用 2 个 GPU 时,将总批次大小翻倍,这样每个 GPU 处理的子批次大小与单 GPU 时相同。
- 调整学习率:增大批次大小后,通常需要适当增大学习率或调整学习率调度策略。

需要注意的是,批次大小受 GPU 内存限制,并且过大的批次大小可能影响模型的收敛速度和最终精度。



7. 使用高级框架简化操作 🛠️

在实际应用中,我们可以使用深度学习框架(如 PyTorch, TensorFlow, MXNet 等)内置的多 GPU 支持来极大简化上述过程。
这些框架通常提供高级 API,只需指定设备列表,它们会自动处理参数复制、数据分发和梯度同步。
例如,在 PyTorch 中,可以使用 nn.DataParallel 包装模型:

import torch.nn as nn
model = MyModel()
if torch.cuda.device_count() > 1:
model = nn.DataParallel(model) # 包装模型,实现数据并行
model.to('cuda')
在训练时,框架会自动将每个批次的数据划分并送到不同 GPU,收集梯度并更新参数。你只需要像单 GPU 训练一样编写前向和反向传播代码即可。

总结 🎯
本节课中我们一起学习了 Python 多 GPU 训练的核心概念与实现步骤:

- 环境准备:确认拥有多个 GPU 设备。
- 参数分配:将模型参数从 CPU 复制到各个 GPU。
- 数据并行:将每个训练批次的数据划分并分发到不同 GPU。
- 梯度同步:通过归约操作(如求和)同步所有 GPU 计算出的梯度,这是保证模型一致性的关键。
- 训练流程:在每个 GPU 上并行执行前向和反向传播,然后同步梯度并更新参数。
- 性能调优:通过增加总批次大小等方式来更好地利用多 GPU 的计算能力,同时注意通信开销和收敛性。
- 框架应用:利用现代深度学习框架的高级 API 可以大幅简化多 GPU 编程的复杂性。

多 GPU 训练是扩展深度学习模型训练规模、缩短实验周期的重要手段。理解其底层原理有助于你更有效地使用高级框架并调试相关问题。
课程P8:基本概率 🎲


在本节课中,我们将学习概率论的基本概念。这些概念是理解后续机器学习与深度学习模型(如朴素贝叶斯分类器)的基础。我们将从事件和概率的定义开始,逐步介绍联合概率、条件概率以及贝叶斯定理,并通过实际例子来理解这些概念的应用。
概述:概率的基本规则
概率论为我们提供了一套量化不确定性的数学框架。它建立在几个基本公理之上,这些公理定义了概率的性质。

以下是概率论的核心公理:
- 非负性:任何事件
A的概率P(A)满足0 ≤ P(A) ≤ 1。 - 规范性:整个样本空间(即所有可能结果的集合)的概率为
1。 - 可加性:对于互斥(即不能同时发生)的事件
A和B,有P(A ∪ B) = P(A) + P(B)。

这些公理是构建更复杂概率概念(如条件概率和独立性)的基石。
联合概率与事件关系
上一节我们介绍了概率的基本公理,本节中我们来看看多个事件之间的关系。联合概率描述了两个或多个事件同时发生的可能性。
以下是描述事件关系的关键概念:
- 联合概率:事件
A和事件B同时发生的概率,记为P(A ∩ B)或P(A, B)。 - 加法法则:对于任意两个事件
A和B,它们至少有一个发生的概率为P(A ∪ B) = P(A) + P(B) - P(A ∩ B)。这考虑了事件交集被重复计算的部分。
理解这些关系对于处理复杂场景至关重要。
独立性与条件概率
在现实世界中,事件往往不是孤立的。一个事件的发生可能会影响另一个事件发生的可能性。这就引出了条件概率和独立性的概念。
以下是相关的核心定义:
- 条件概率:在事件
B已经发生的条件下,事件A发生的概率,记为P(A | B)。其计算公式为P(A | B) = P(A ∩ B) / P(B),前提是P(B) > 0。 - 独立性:如果事件
A的发生不影响事件B发生的概率(反之亦然),则称A和B相互独立。数学上表示为P(A ∩ B) = P(A) * P(B),这等价于P(A | B) = P(A)。

一个经典的例子是俄罗斯轮盘赌:在第一次扣动扳机后存活下来,如果不重新转动转轮(即不重置条件),第二次扣动扳机时死亡的概率会发生变化,说明这两个事件不是条件独立的。

贝叶斯定理 🧠
条件概率的一个极其重要的应用是贝叶斯定理。它允许我们根据新的证据(数据)来更新我们对某个假设的信念(概率)。
贝叶斯定理的公式如下:
P(A | B) = [P(B | A) * P(A)] / P(B)
其中:
P(A | B)是后验概率:观察到证据B后,假设A为真的概率。P(B | A)是似然:在假设A为真的条件下,观察到证据B的概率。P(A)是先验概率:在观察到任何证据之前,假设A为真的初始信念。P(B)是证据概率:观察到证据B的总概率,通常通过全概率公式计算:P(B) = P(B|A)P(A) + P(B|¬A)P(¬A)。
实例分析:艾滋病检测
让我们通过一个艾滋病检测的例子来应用贝叶斯定理。
- 已知条件:
- 人群感染率(先验)
P(病) = 0.001。 - 检测灵敏度
P(阳 | 病) = 1(感染者100%检出)。 - 检测假阳性率
P(阳 | 健康) = 0.01(健康者有1%误检)。
- 人群感染率(先验)
- 问题:如果一个人检测结果为阳性,他实际患病的概率
P(病 | 阳)是多少?
计算过程:
- 根据贝叶斯定理:
P(病 | 阳) = [P(阳 | 病) * P(病)] / P(阳)。 - 计算
P(阳)(全概率):
P(阳) = P(阳 | 病)*P(病) + P(阳 | 健康)*P(健康) = 1*0.001 + 0.01*0.999 = 0.01099。 - 代入计算:
P(病 | 阳) = (1 * 0.001) / 0.01099 ≈ 0.091。
结论:即使检测看起来很准确(假阳性率仅1%),但一个阳性结果的人实际患病的概率也只有约9.1%。这凸显了在罕见病筛查中考虑先验概率的重要性。为了更可靠,需要进行第二次条件独立的测试来更新概率。
总结
本节课中我们一起学习了概率论的核心基础。我们从概率的公理化定义出发,理解了概率必须满足非负性、规范性和可加性。接着,我们探讨了联合概率以及事件之间的加法法则。
然后,我们引入了条件概率和独立性的概念,这是理解事件间依赖关系的关键。最后,我们深入学习了贝叶斯定理,这是一个强大的工具,能够让我们利用新的证据来更新对某个假设的概率估计,并通过艾滋病检测的实例演示了其应用。

掌握这些基本概念,是后续学习朴素贝叶斯分类器、概率图模型等更高级机器学习算法的重要前提。
课程 P80:多机训练 🚀
在本节课中,我们将学习如何从单机多GPU训练扩展到多机多GPU训练。我们将探讨其架构、通信开销、性能考量以及实际应用中的注意事项。

从单机到多机:架构概述
上一节我们介绍了单机多GPU训练,本节中我们来看看多机多GPU的架构。
假设数据位于分布式文件系统中,可供所有机器访问。我们有多台机器,每台机器上配备多个GPU。模型参数不再集中于单机,而是分布在多台机器上。例如,一个50层的ResNet模型可以分配到4台机器上,每台机器处理约12层。数据通过网络读取,梯度通过网络发送和更新。
这种架构与单机多GPU训练类似,但存在通信层次。GPU与同机CPU的通信(通过PCIe)速度较快,而机器间的网络通信速度较慢。我们需要关注这种层次结构带来的性能影响。

硬件成本与可靠性 💸
构建一个多机GPU集群可以控制成本。例如,四年前可以通过购买二手数据中心CPU和廉价主板搭建一个包含16台机器的集群,总成本约2万美元。
然而,硬件可靠性是一个重要问题。GPU功耗高,在密集计算下容易过热或故障,尤其是消费级GPU。系统后端需要处理各种故障。一个有效的策略是频繁保存检查点(例如每两分钟),以便在故障发生时能从最新状态恢复训练。

多机训练流程 🔄
以下是多机训练中一个批次的处理流程。
假设有两个工作节点,一个批次有100个样本。流程如下:
- 每台机器获得50个样本。
- 每台机器将其数据进一步分配到本机的各个GPU上(例如,2个GPU各得25个样本)。
- 参数服务器将参数复制到每台机器,每台机器再复制到其每个GPU。
- 每个GPU计算其分配数据的梯度。
- 单台机器内,汇总所有本地GPU的梯度。
- 每台机器将汇总后的梯度推送到远程参数服务器进行全局更新。
- 参数服务器更新参数,并开始下一轮循环。
这个流程的关键在于两级聚合:先在单机内聚合GPU梯度,再在机器间进行全局聚合。这样做是为了减少网络通信量。
同步随机梯度下降与性能 ⚡
在多机多GPU环境下,我们通常使用同步随机梯度下降。这意味着每个GPU处理等量的工作负载,并且所有GPU必须同步完成梯度计算后才能进行参数更新。
在理想情况下,使用 n 个GPU应能达到近 n 倍的训练加速。性能主要由两个时间因素决定:
- T1:单个GPU计算其分配数据梯度的时间。
- T2:在网络中发送和接收梯度数据的时间。
实际每批次的耗时是 max(T1, T2)。为了隐藏通信开销(T2),我们可以增加每个GPU的批次大小(B)或使用更多GPU(N),从而使T1增大。当T1 > T2时,通信开销就被计算时间“掩盖”了,从而获得更好的加速比。
然而,批次大小并非越大越好。过大的批次大小可能会影响模型收敛的最终精度,需要在系统性能和统计效率之间做出权衡。
多机训练实用建议 📋
成功进行多机训练需要考虑多个方面,以下是关键建议:
1. 数据集与任务
确保数据集足够大,任务计算量足够重。如果单GPU训练一个批次只需几秒钟,那么多机通信开销将占主导,难以获得有效加速。
2. 硬件选择
选择GPU间和机器间通信带宽高的硬件。如果自建集群,需要注意主板对多GPU的支持以及交换机网络的性能。云服务通常已优化此类配置。
3. 避免CPU瓶颈
当一台机器上挂载多个GPU时,负责数据加载和预处理的CPU可能成为瓶颈。需要监控数据吞吐量,确保CPU能及时为GPU供给数据。
4. 模型选择
选择计算开销与通信开销比值高的模型。通信开销大致等于模型参数量。例如,SqueezeNet参数量小(10MB),计算量大,比值高;而AlexNet因包含大量全连接层,参数量大(700MB),该比值较低,在多机训练中效率不佳。
5. 优化策略
使用更大的批次大小来提升系统效率,并配合学习率预热等技巧来保证模型在超大批次下的收敛性能。
性能示例 📊
以ResNet在CIFAR-10数据集上的训练为例:
- 使用单GPU,调整至合适的批次大小和线性学习率,每个epoch耗时约60秒,达到一定准确率。
- 切换到两个GPU,批次大小加倍,学习率相应调整。每个epoch时间几乎减半,实现了近线性加速,且最终测试准确率基本保持一致。
这表明对于计算量适中的任务和数据集,多GPU训练可以带来显著的效率提升。
总结

本节课中我们一起学习了多机多GPU训练。
我们了解了其分布式架构、两级通信流程以及同步梯度下降的原理。
我们认识到性能受计算时间T1和通信时间T2的制约,并通过增大批次大小来优化。
最后,我们探讨了从数据集、硬件、模型到优化策略的一系列实用建议,以帮助在实际应用中有效实施和调试多机训练。
🖼️ 课程P81:图像增强技术详解

在本节课中,我们将学习图像增强技术。这项技术通过人为地改变图像的外观,来增加训练数据的多样性,从而帮助机器学习模型更好地适应现实世界中复杂多变的环境。
📖 概述
图像增强是解决模型在实验室表现良好,但在实际部署中因环境变化(如光线、角度、背景)而性能下降问题的关键技术。我们将通过一个真实案例引入,并详细介绍几种核心的图像增强方法及其实现。
🎯 一个真实案例:自动售货机的识别难题
大约两个月前,一家初创公司在消费电子展上展示了一款小型自动售货机。该机器通过摄像头识别用户拿取的饮料瓶。
在实验室环境中,识别准确率高达99%。然而,在展会现场,系统却无法正常工作。
经过调试,他们发现了两个主要原因:
- 展厅的夜间色温与实验室不同,导致图像整体偏黄。
- 展台上放置的盒子造成了桌面光线反射,而实验室没有这种情况。
为了解决这个问题,工程师们通宵工作:
- 调整光线设置,使其与展厅环境匹配。
- 采集并标注了大量新环境下的数据,重新训练模型。
- 用桌布覆盖桌面,以消除光线反射。
这个案例非常典型。无论是面部识别、语音识别还是其他感知任务,环境变化(如室内外光线、背景噪音、设备差异)都会严重影响模型性能。图像增强技术正是为了应对这一挑战而生。
🔧 图像增强的核心原理
图像增强的工作原理是:我们拥有一个原始数据集(例如一张猫的图片),然后通过一系列变换生成一个增强数据集。这个增强数据集包含了原始图像的多种变体,从而形成了一个规模更大、内容更多样化的数据集用于模型训练。
核心公式:
增强数据集 = 原始数据集 + 变换操作(翻转、裁剪、调色等)

在实际操作中,我们通常不会预先生成并存储所有增强图像,而是在训练过程中实时生成。每次读取一批图像时,都会随机应用增强操作,然后送入模型训练。
🛠️ 常用图像增强技术
接下来,我们来看看几种常用且有效的图像增强技术。
1. 翻转

翻转操作包括水平翻转和垂直翻转。水平翻转(左右翻转)通常很有效,因为它符合许多物体的自然对称性。垂直翻转(上下翻转)则需谨慎使用,因为很多场景(如天空在上、地面在下)不符合物理规律。
代码示例(伪代码):
# 随机水平翻转
if random() > 0.5:
image = flip_left_right(image)
# 随机垂直翻转(谨慎使用)
if random() > 0.5:
image = flip_up_down(image)
2. 裁剪

裁剪操作首先在图像中随机选择一个矩形区域,然后将该区域调整到统一的尺寸。这有助于处理不同尺寸的输入图像,并让模型关注物体的局部特征。
以下是裁剪步骤:
- 随机选择宽高比(例如在3/4到4/3之间)。
- 随机选择区域大小(例如占原图的8%到100%)。
- 随机确定区域位置。
- 将裁剪出的区域调整为模型所需的固定尺寸。
3. 颜色变换
颜色变换通过调整图像的色调、饱和度和亮度来模拟不同的光照和拍摄条件。例如,可以将亮度降低或提高一定比例。
代码示例(伪代码):
# 随机调整亮度
brightness_factor = random.uniform(0.5, 1.5) # 在50%到150%之间随机
image = adjust_brightness(image, brightness_factor)
# 类似地可以调整饱和度和对比度

4. 其他高级增强技术
除了上述基础方法,还存在约50种不同的增强技术,例如:
- 添加噪声
- 调整锐度
- 应用模糊
- 模拟透视变换(不同角度、距离拍摄的效果)

这就像使用程序对图像进行自动化的“Photoshop”处理,以创造出尽可能多样的训练样本。
💻 实践与应用
在Python中,我们可以利用诸如torchvision.transforms、albumentations或imgaug等库轻松实现上述图像增强操作。通过将这些增强流程集成到数据加载器中,我们就能在训练过程中实时地丰富数据集。
📝 总结
本节课我们一起学习了图像增强技术。我们从实际部署问题出发,理解了增强技术的重要性。随后,我们深入探讨了其核心原理,即通过对原始图像施加随机变换来生成多样化的训练数据。最后,我们详细介绍了翻转、裁剪、颜色变换等几种核心的增强方法及其实现逻辑。

掌握图像增强,能有效提升模型的鲁棒性和泛化能力,是构建实用计算机视觉系统不可或缺的一环。

课程P82:微调(Fine-Tuning)详解 🎯
在本节课中,我们将学习深度学习中的一个核心技巧——微调。微调是计算机视觉乃至许多深度学习应用领域最重要的技术之一。它允许我们利用在大规模数据集(如ImageNet)上预训练好的强大模型,来高效地解决我们自己的、数据量可能较小的特定任务。
微调的必要性 🤔
上一节我们介绍了模型训练的基本概念,本节中我们来看看为什么需要微调。在计算机视觉中,我们常遇到两类数据集:小型数据集(如MNIST)和大型数据集(如ImageNet)。
- MNIST数据集包含10个类别,每个类别约6000张图片。在30年前它是较大的数据集,但如今已非常小。
- ImageNet数据集包含1000个类别,约120万张图片,至今仍是一个大型基准数据集。
然而,在实际产品开发中,我们通常需要构建自己的数据集。例如,要构建一个识别100种车型的系统,若每种车型收集500张图片,总数据量为5万张。这个数据量是ImageNet的十分之一,但仍远大于MNIST。构建这样的数据集是现实可行的。
以下是构建自定义数据集可能面临的挑战:
- 数据标注耗时:即使高效标注,处理数万张图片也可能需要数十小时。
- 模型能力受限:如果仅使用自己的数据从头训练,模型性能上限受限于该数据集的规模和质量。
- 资源与时间成本高:要构建ImageNet级别的数据集需要巨大的时间和金钱投入,这在产品快速迭代中不现实。
同时,现有的顶尖模型(如各种ResNet、Inception网络)都是针对ImageNet数据集设计和优化的。如果直接将如此复杂的模型应用于我们较小的自定义数据集,很可能因为模型过于复杂而导致过拟合。
因此,我们需要一种方法,既能利用在大数据集上训练好的强大模型的知识,又能使其适应我们特定的、数据量较小的任务。这就是微调要解决的问题。

微调的核心思想 💡
上一节我们了解了面临的挑战,本节我们来探讨解决方案的核心思想。一个深度卷积神经网络通常可以分为两部分:

- 特征提取器(Feature Extractor):由网络的大部分层(尤其是卷积层)组成。其作用是将原始像素输入,逐层转换和抽象,最终输出一个高度可分离的特征表示。公式可以简化为:
特征 = 特征提取器(原始图像) - 分类器(Classifier):通常是网络的最后几层(如全连接层)。其作用是基于提取出的特征,通过一个线性或非线性函数,映射到最终的类别标签。公式可以简化为:
预测标签 = 分类器(特征)
微调的基本假设是:在ImageNet等大型通用数据集上预训练得到的特征提取器,其学到的底层视觉特征(如边缘、纹理、形状)具有通用性,可以迁移到其他图像任务中。然而,最后的分类器是高度任务特定的,与ImageNet的1000个类别紧密绑定,无法直接用于我们的新任务。
因此,微调的思路是:
- 重用:获取一个在ImageNet上预训练好的模型,将其特征提取器部分的权重复制过来,作为我们新模型的初始化。
- 替换与初始化:移除或替换原模型的分类器部分(因为类别数不同),并为其随机初始化新的权重。
通过这种方式,我们相当于站在了“巨人的肩膀”上,从一个非常优秀的起点开始训练,而非从零开始。

微调的实施步骤 🔧
理解了核心思想后,本节我们来看看如何具体实施微调。整个过程可以概括为以下几个步骤:
以下是微调的关键步骤:
- 获取预训练模型:选择一个在大型源数据集(如ImageNet)上训练好的模型。
- 模型架构调整:修改网络输出层,使其输出节点数与我们目标数据集的类别数一致。
- 参数初始化:
- 将预训练模型中特征提取器部分的权重参数复制到新模型的对应部分。
- 对新模型的分类器部分(特别是新增或修改的层)进行随机初始化。
- 训练策略调整:由于起点较好且为避免在小数据集上过拟合,训练时需要调整超参数:
- 使用更小的学习率:因为参数已经接近一个较优解,大步幅更新可能导致震荡或跳出最优区域。
- 减少训练轮数(Epochs):可能只需要训练10个轮次,而不是从头训练所需的100个轮次。
- 考虑更强的正则化:如Dropout、权重衰减等,以控制模型复杂度。
在实践中,如果源数据集(如ImageNet)比目标数据集更复杂、更通用,那么微调几乎总是比从头训练效果更好,且收敛速度更快。

微调的高级技巧与考量 🧠
上一节我们介绍了标准的微调流程,本节中我们进一步探讨一些可以提升微调效果的技巧和需要注意的方面。

1. 标签空间的关联性
理想情况下,如果源数据集的标签集(如ImageNet的“赛车”类别)与目标数据集(如“汽车”识别)的标签有重叠或语义关联,那么迁移效果会更好。我们可以利用这种关联性,例如,将源模型中对“赛车”类别敏感的权重,有选择地初始化到目标模型对应的“汽车”类别中。但通常这种完美对应并不常见,不过源模型学到的通用物体表征依然极具价值。
2. 分层冻结训练
深度神经网络的不同层学习到的特征层次不同:
- 底层(靠近输入):学习通用、底层的视觉特征,如边缘、角点、纹理、颜色。这些特征对于大多数图像任务都是通用的。
- 高层(靠近输出):学习与特定任务高度相关的抽象特征和语义概念,如“狗头”、“车轮”。
基于此,一个常见的技巧是冻结(固定)底层网络的参数,只训练高层网络。例如,在一个50层的ResNet中,可以冻结底部30层的权重,只微调顶部20层。这样做有两大好处:
- 防止过拟合:大幅减少可训练参数量,降低了模型在小数据集上的过拟合风险。
- 保留通用知识:确保模型保留那些宝贵的、通用的底层视觉特征。
在代码中,这通常通过设置参数的 requires_grad 属性来实现:
# 假设 model 是预训练模型,我们想冻结前30层(示例)
for param in list(model.parameters())[:30]:
param.requires_grad = False
# 只对剩余层的参数计算梯度并更新

3. 关于特征可视化的说明
在讨论网络层次时,有时会提到通过可视化来理解不同层激活的模式。这通常是指对某个卷积层的单个输出通道进行可视化,以观察该通道对输入图像的哪类模式(如特定纹理或形状)敏感。这有助于直观理解网络不同层所扮演的角色,但并非微调的必要步骤。
总结 📝
本节课中,我们一起深入学习了微调(Fine-Tuning) 这一强大的迁移学习技术。我们首先分析了在自定义数据集上从头训练模型面临的挑战,从而引出了微调的必要性。接着,我们剖析了网络结构,将其分为特征提取器和分类器两部分,并理解了微调重用特征提取器,重置分类器的核心思想。
我们详细介绍了微调的实施步骤:获取预训练模型、调整输出层、初始化参数以及采用更谨慎的训练策略(如更小的学习率)。最后,我们还探讨了标签关联性和分层冻结训练等高级技巧,这些都能帮助我们在特定任务上获得更好的微调效果。

记住,当你有一个中等规模或小规模的目标数据集时,从一个在大规模通用数据集上预训练好的模型开始进行微调,是提升性能、加速收敛的首选策略。
课程P83:Python中的微调 🐕🔧

在本节课中,我们将学习如何在Python中实际进行深度学习模型的微调。微调是一种利用预训练模型,通过少量新数据快速适应新任务的关键技术。我们将通过一个“热狗识别”的具体例子,一步步展示从数据准备、模型加载到训练微调的完整流程。

数据准备 📂

上一节我们介绍了微调的概念,本节中我们来看看如何准备用于微调的数据集。

我们准备了一个名为“Harddog识别”的小型数据集。这个数据集可以通过网络搜索“Harddog”及相关图片轻松获得,大约花费一个下午的时间即可收集完成。

以下是数据集的目录结构:
- 数据集包含一个
train(训练)文件夹和一个test(测试)文件夹。 train文件夹下有两个子文件夹:harddog和not harddog,分别存放对应类别的图片。test文件夹下也存放了用于测试的JPEG图像。
我们可以加载并可视化这些图像。结果显示,第一行是“热狗”图片,第二行是“非热狗”图片,它们在外观上可能十分相似。
图像增强与预处理 🖼️➡️🔢
准备好数据后,我们需要对图像进行增强和预处理,以提高模型的泛化能力并匹配预训练模型的输入要求。
我们主要进行以下操作:
- 标准化RGB通道:使用来自ImageNet数据集的均值和标准差对图像进行归一化。公式如下:
normalized_image = (image - mean) / std
这是因为预训练模型是基于这些统计值训练的,保持一致性很重要。 - 训练集变换:包括随机调整大小、随机裁剪(例如到224x224像素)、随机水平翻转,最后转换为张量并进行上述标准化。
- 测试集变换:为了获得可靠的评估结果,我们仅进行中心裁剪和相同的标准化操作,避免引入随机性。
一个常见的问题是:如果目标物体(如热狗)不在图片中心怎么办?在图像分类任务中,我们通常假设主要物体位于图像中心区域。对于目标不在中心的情况,这属于目标检测任务的范畴。在训练中,随机裁剪可能会产生不含目标的“噪声”图像,但神经网络通常足够鲁棒,能够处理一定程度的噪声数据。
加载与修改预训练模型 🤖
预处理完成后,接下来我们加载并修改预训练模型以适应新任务。

我们从模型库中下载ResNet-18的预训练模型。这个模型包含特征提取器(主干网络)和最后的分类输出层。
由于我们的新任务只有“热狗”和“非热狗”两个类别,我们需要修改模型:
- 我们实例化一个全新的ResNet-18模型,但将其输出类别数设置为2。
- 然后,我们将预训练模型中除最后一层外的所有权重复制到这个新模型中。
- 对于新的分类层(最后一层),我们保持其随机初始化的状态。

一个关键技巧是,在训练时,我们为最后一层设置更大的学习率(例如10倍)。这是因为特征提取器的权重已经训练得很好,我们只希望微调它们;而新分类层的权重是随机初始化的,需要更快地更新以快速收敛。在代码中,这通常通过为不同层设置不同的学习率来实现。
执行微调训练 ⚙️
现在,我们可以开始微调训练了。


微调的训练过程与常规训练没有本质区别。我们设置数据加载器、定义损失函数(如交叉熵损失)、选择优化器(如SGD或Adam),并开始迭代训练。
在训练中,我们使用较小的学习率(例如0.01)开始微调,以防止破坏预训练好的特征。作为对比,如果是从头开始训练同一个模型,我们可能需要使用更大的初始学习率(例如0.1)。

结果通常会显示,微调模型比从零开始训练的模型收敛更快,并且最终在测试集上能达到更高的准确率。这是因为微调从一个更好的初始点开始优化。

总结 📝
本节课中我们一起学习了深度学习模型微调的完整流程:
- 准备数据:收集并组织特定任务的小型数据集。
- 预处理:对图像进行标准化和增强,以匹配预训练模型并提升鲁棒性。
- 修改模型:加载预训练模型,替换其输出层以适应新任务的类别数,并复制主干网络权重。
- 设置训练:为新分类层设置更高的学习率,以加速其收敛。
- 开始训练:使用较小的学习率微调整个网络,观察其快速收敛并达到良好性能。

微调是一种强大而高效的技术,能够让我们利用在大规模数据集上训练好的模型,快速解决新的、数据量有限的实际问题。
课程P84:风格迁移 🎨
在本节课中,我们将学习风格迁移的基本概念、数学原理和实现方法。风格迁移是一种将一张图片的内容与另一张图片的风格相结合,生成全新合成图像的技术。
概述
风格迁移的核心目标是:给定一张内容图像和一张风格图像,生成一张合成图像。这张合成图像需要保留内容图像的主体结构和内容,同时具备风格图像的艺术风格与纹理特征。
核心思想与数学公式
风格迁移通过优化一个损失函数来实现。该损失函数由三部分组成:内容损失、风格损失和噪声(总变差)损失。我们需要学习的合成图像 i 通过最小化以下总损失函数得到:
总损失公式:
总损失 = w1 * 内容损失(i, 内容图像) + w2 * 风格损失(i, 风格图像) + w3 * 总变差损失(i)
其中,w1, w2, w3 是用于平衡各项损失的权重参数。

内容损失
上一节我们介绍了总损失函数的构成,本节中我们来看看内容损失的具体计算方式。
我们并不直接比较合成图像与内容图像的原始像素。相反,我们利用一个预训练的卷积神经网络(例如VGG),提取图像的深层特征。内容损失通过比较合成图像与内容图像在神经网络某一中间层的特征表示来计算。
内容损失公式(L2损失):
内容损失 = || F_content(内容图像) - F_content(合成图像) ||^2
这里的 F_content(·) 代表从选定中间卷积层提取的特征图。
- 选择底层卷积层:特征更关注局部细节和纹理,可能导致合成图像过于拘泥于原图的像素级信息。
- 选择顶层卷积层:特征更关注全局形状和物体,可能丢失过多细节。
- 选择中间层:这是一个折中方案,能在保留主要内容结构的同时,为风格融合留出空间。
下图展示了内容匹配的过程:

风格损失

理解了如何保留内容后,我们来看看如何为图像注入新的艺术风格,这通过风格损失来实现。
风格的定义比内容更抽象。一个广泛使用的有效方法是计算特征的 Gram矩阵,它捕获了不同特征通道之间的相关性,可以理解为图像纹理风格的统计表示。
Gram矩阵计算:
给定一个特征图(形状为 [C, H, W]),我们首先将其重塑为 [C, H*W] 的矩阵 F。Gram矩阵 G 通过下式计算:
G = F · F^T
即,G[i, j] 表示第 i 个特征通道与第 j 个特征通道的内积,反映了它们之间的协方差。
风格损失公式:
风格损失是合成图像与风格图像在多个选定层的Gram矩阵之间的L2损失之和。
风格损失 = Σ_l || Gram(F_style^l(风格图像)) - Gram(F_style^l(合成图像)) ||^2
- 使用底层:匹配局部、小范围的纹理风格。
- 使用顶层:匹配全局、大范围的构图与色彩风格。
- 组合使用多层:可以更全面地捕捉和迁移风格图像从局部到全局的风格特征。
下图说明了风格匹配的过程:

总变差损失(噪声平滑)
为了确保生成的合成图像视觉上平滑、自然,避免出现高频噪声或过于突兀的像素点,我们引入了总变差损失。
总变差损失鼓励图像在空间上保持平滑,其计算方式是惩罚相邻像素(水平和垂直方向)之间的强度差异。
总变差损失公式:
总变差损失 = Σ_i, j |像素(i, j) - 像素(i+1, j)| + |像素(i, j) - 像素(i, j+1)|
这个损失函数的作用类似于一个平滑滤波器,能够有效减少图像中的不规则噪点,使最终结果更加悦目。

下图展示了总变差损失的作用:

优化过程
我们将上述三个损失函数按照公式组合起来,形成最终的目标函数。
优化过程通常从一个随机噪声图像或内容图像的副本开始。我们不是训练神经网络的权重,而是将合成图像本身作为可优化变量,通过梯度下降法(如Adam优化器)迭代更新图像的像素值,以最小化总损失。

以下是迭代优化过程的简要步骤:
- 初始化合成图像(例如,填充随机噪声)。
- 将内容图像、风格图像和当前合成图像输入预训练网络,提取指定层的特征。
- 计算内容损失、风格损失和总变差损失。
- 计算总损失相对于合成图像像素的梯度。
- 使用梯度更新合成图像的像素值。
- 重复步骤2-5,直到损失收敛或达到预定迭代次数。
随着迭代进行,合成图像会逐渐呈现出内容图像的结构与风格图像的纹理。下图展示了从随机噪声开始,图像逐渐演变的示例:


总结
本节课中我们一起学习了风格迁移技术:
- 目标:将内容图像的结构与风格图像的纹理融合,生成新图像。
- 方法:通过优化一个组合损失函数来实现,该函数包含内容损失、风格损失和总变差损失。
- 内容损失:在预训练网络的中间层比较特征,以保留主要内容。
- 风格损失:通过比较多层Gram矩阵来匹配纹理风格。
- 总变差损失:用于平滑图像,减少噪声。
- 过程:将合成图像作为可优化变量,通过梯度下降迭代更新其像素值,最小化总损失。
这项技术的优势在于,你无需预先定义任何滤镜,只需提供你喜欢的内容和风格图片,算法就能自动完成创作。
课程P85:在Python中实现风格迁移 🎨

在本节课中,我们将学习如何使用Python实现神经风格迁移。我们将通过代码,一步步理解如何将一张图片的内容与另一张图片的艺术风格相结合,生成全新的图像。



1. 图像读取与预处理 📸

首先,我们需要读取内容图像和风格图像。

# 示例:读取图像
content_image = read_image('corner_image.jpg')
style_image = read_image('star_image.jpg')
图像预处理是一个标准步骤,通常包括将像素值归一化到0到1之间,或者进行减均值、除以标准差的操作。
# 常见的预处理:减去最小值并除以标准差
preprocessed_image = (image - image.min()) / image.std()
2. 使用预训练VGG网络作为特征提取器 🧠

上一节我们介绍了图像预处理,本节中我们来看看如何提取图像特征。我们将使用预训练的VGG19网络作为基础特征提取器。
VGG网络包含五个卷积块。在风格迁移中,我们通常选择每个块的第一层作为风格特征层,并选择靠后的某一层(如第四个块的最后一层)作为内容特征层。
# 选择VGG19中特定的层用于特征提取
style_layers = ['block1_conv1', 'block2_conv1', 'block3_conv1', 'block4_conv1', 'block5_conv1']
content_layer = 'block4_conv2'

我们加载预训练的VGG权重。由于我们只进行特征提取而不训练网络,因此可以冻结这些权重。
3. 提取内容与风格特征 🔍

以下是提取特征的关键步骤:

给定内容图像和风格图像,我们将其输入VGG网络,并获取在指定层上的输出。
def extract_features(model, image, layers):
"""提取指定层的特征"""
outputs = []
for layer in layers:
output = model.get_layer(layer).output
outputs.append(output)
feature_extractor = Model(inputs=model.input, outputs=outputs)
return feature_extractor(image)
对于内容图像,我们提取内容层的特征。对于风格图像,我们提取所有风格层的特征。这些特征将分别用于计算内容损失和风格损失。
4. 定义损失函数 ⚖️
现在我们已经获得了特征,接下来需要定义损失函数来指导图像的生成。

内容损失 衡量生成图像与内容图像在内容特征上的差异。我们通常使用均方误差(MSE)。
def content_loss(content_features, generated_features):
return tf.reduce_mean(tf.square(content_features - generated_features))

风格损失 则更为复杂。它通过计算特征图的Gram矩阵(即协方差矩阵的一种近似)来捕捉纹理信息,然后比较生成图像与风格图像Gram矩阵的差异。
def gram_matrix(x):
# x的形状为 (batch, height, width, channels)
batch, height, width, channels = x.shape
features = tf.reshape(x, (batch, height * width, channels))
gram = tf.matmul(features, features, transpose_a=True)
return gram / tf.cast(height * width * channels, tf.float32)

def style_loss(style_features, generated_features):
loss = 0
for style_feat, gen_feat in zip(style_features, generated_features):
style_gram = gram_matrix(style_feat)
gen_gram = gram_matrix(gen_feat)
loss += tf.reduce_mean(tf.square(style_gram - gen_gram))
return loss
此外,我们还会加入总变差(TV)损失,它有助于使生成的图像更加平滑,减少噪声。
def total_variation_loss(image):
# 计算相邻像素差的绝对值之和
dx = image[:, 1:, :, :] - image[:, :-1, :, :]
dy = image[:, :, 1:, :] - image[:, :, :-1, :]
return tf.reduce_sum(tf.abs(dx)) + tf.reduce_sum(tf.abs(dy))

5. 组合损失与优化目标 🎯

我们将三个损失函数按不同的权重组合起来,形成总损失。
# 设置权重
content_weight = 1e4
style_weight = 1e-2
tv_weight = 30

total_loss = content_weight * content_loss + style_weight * style_loss + tv_weight * tv_loss
与训练神经网络不同,在风格迁移中,我们不更新网络权重,而是更新输入图像本身的像素值。因此,我们的优化变量就是这张待生成的图像。

# 初始化一张随机图像或内容图像的副本作为起点
generated_image = tf.Variable(content_image)

# 定义优化器(如Adam)
optimizer = tf.optimizers.Adam(learning_rate=0.02)

6. 训练过程:从粗到精 🚀
训练过程的核心是迭代地计算损失,并通过反向传播更新生成图像的像素。
以下是训练循环的关键步骤:

- 使用特征提取器获取当前生成图像的特征。
- 分别计算内容损失、风格损失和TV损失。
- 计算总损失关于生成图像的梯度。
- 使用优化器更新生成图像。
一个实用的技巧是从低分辨率图像开始训练,这样可以快速捕捉整体风格和布局。然后,将训练好的低分辨率图像上采样,作为更高分辨率图像的初始值,继续进行训练。这种方法可以加速训练并提升最终效果。

# 伪代码:从低分辨率到高分辨率的训练策略
low_res_image = train_style_transfer(content_image_small, style_image_small, iterations=500)
high_res_init = upsample(low_res_image)
final_image = train_style_transfer(content_image_large, style_image_large, init_image=high_res_init, iterations=1000)
通过这种方式,我们可以生成既保留内容图像结构,又具备风格图像艺术纹理的高质量合成图像。
总结 📝


本节课中我们一起学习了神经风格迁移在Python中的完整实现流程。我们回顾一下关键步骤:
- 预处理:读取并归一化内容图像与风格图像。
- 特征提取:利用预训练的VGG网络,从特定层提取内容特征和风格特征。
- 定义损失:构建了内容损失、风格损失(基于Gram矩阵)和总变差损失。
- 优化图像:将待生成的图像本身作为可训练变量,通过梯度下降法优化其像素值,以最小化组合后的总损失。
- 训练策略:采用了从低分辨率到高分辨率的“从粗到精”训练策略,以提高效率和质量。
通过组合这些技术,我们能够创造出融合两种图像特点的全新艺术作品。
课程 P86:物体检测基础概念 🎯

在本节课中,我们将学习物体检测的基本概念。与图像分类不同,物体检测旨在识别图像中的多个物体,并确定它们的位置。我们将介绍边界框、锚框、交并比(IoU)以及非极大值抑制(NMS)等核心概念。
物体检测与图像分类的区别
上一节我们介绍了图像分类,它通常假设图像中只有一个主要物体。本节中我们来看看物体检测的不同之处。
在图像分类中,我们试图识别图像中的主要物体。通常我们假设这张图像只包含一个物体,一个大的物体。但对于物体检测,我们尝试在一张图像中抓取所有这些有趣的物体。我们不仅知道有什么物体,还想知道这些物体的位置。
这里我们使用矩形框来定义物体的位置,它被称为边界框。

物体检测对自动驾驶等领域非常有用。例如,我们想拍摄街道的照片并尝试识别所有的汽车、行人、自行车和交通灯。这相当困难,因为有些物体(如交通灯或卡车后面的汽车)并不容易识别。
数据标注的挑战

标注物体检测数据集比图像分类更困难。以下是标注过程中的主要挑战:
- 你需要为图像中的每个物体标注其类别和边界框。
- 图像中可能包含许多小物体,标注起来非常耗时。
- 在视频流(如自动驾驶场景)中进行实时标注和检测更具挑战性。

此外,在现实应用(如车载系统)中,还需要考虑计算效率。强大的GPU会产生大量热量和能耗,这在实际部署中是一个重要约束。因此,物体检测算法需要非常关注推理速度。
边界框与锚框
接下来,我们从图像分类中得到的新概念开始:边界框和锚框。
边界框的定义
边界框可以通过矩形定义。通常用四个数字来定义:
- 方法一:左上角x坐标、左上角y坐标、右下角x坐标、右下角y坐标
(x1, y1, x2, y2)。 - 方法二:左上角x坐标、左上角y坐标、框的宽度、框的高度
(x, y, w, h)。


例如,在原点位于左上角的图片中,一个框可以用 (60, 45, 220, 150) 来定义。
锚框的概念
锚框是检测算法提出的多个预定义区域。以下是锚框的工作原理:

- 算法首先生成多个不同大小和宽高比的锚框。
- 对于每个锚框,预测它是否包含物体(还是背景)。
- 如果包含物体,则预测一个偏移量,将这个锚框调整到真实物体的边界框。
因此,我们预测四个数字的偏移量,将锚框映射到边界框。不同的检测算法(如SSD或R-CNN系列)提出锚框的方式不同,但核心思想一致。


数据集与标注格式
在物体检测中,数据标注格式与分类不同。以下是典型的数据格式:
每一行对应图像中的一个物体。例如,你可以指定图片文件名、对象类别以及对象的边界框坐标。这意味着如果你的数据集中图片数量较少,但每张图片包含多个物体,那么你的训练样本(物体实例)数量可能远多于图片数量。
例如,著名的COCO数据集包含80个日常物体类别,约有33万张图片,但平均每张图片有4个物体,总共约有150万个物体实例。一百万大小的数据集通常足以训练一个合理的深度学习模型。

交并比(IoU)
如何衡量两个边界框之间的相似度?在分类中,我们判断对错。但对于框,我们使用交并比。
IoU的原理是计算两个边界框的交集面积,除以它们的并集面积。公式如下:

IoU = Area of Intersection / Area of Union

IoU的值介于0到1之间:
0表示两个框完全不重叠。1表示两个框完全重合。

IoU越高,两个框越相似。它是Jaccard指数在边界框上的一个特例。
锚框与真实框的匹配
在训练过程中,算法提出一堆锚框,我们需要为它们分配标签(与哪个真实物体关联,或是背景)。以下是典型的匹配方法:

- 计算每个锚框与每个真实边界框之间的IoU,形成一个IoU矩阵。
- 找到矩阵中最大的IoU值,将该真实框分配给对应的锚框。
- 移除已分配的真实框和锚框所在的行和列。
- 重复步骤2和3,直到所有真实框都被分配。
- 剩余未被分配的锚框则标记为“背景”。
这是一个不平衡的分类问题,因为背景锚框的数量远多于包含物体的锚框,这使得训练颇具挑战性。
非极大值抑制(NMS)

在预测时,每个锚框都会生成一个边界框预测。这可能导致对同一个物体产生多个非常相似的预测框。为了减少重复,我们使用非极大值抑制。
以下是NMS的算法步骤:

- 选择所有预测框中置信度得分最高的一个。
- 计算该框与所有其他框的IoU。
- 移除那些IoU超过某个阈值(例如0.5)的其他框(即抑制它们)。
- 在剩下的框中,重复步骤1-3,直到没有更多的框被处理。
最终,每个物体通常只保留一个最可靠的预测框。
总结


本节课中我们一起学习了物体检测的核心基础。我们了解了物体检测与图像分类的目标差异,即同时识别物体类别和位置。我们学习了用边界框定义位置,以及算法如何通过锚框机制来提出候选区域。关键的评价指标交并比(IoU)用于衡量框的相似度。在训练中,我们需要将锚框与真实标注框进行匹配。最后,在预测阶段,非极大值抑制(NMS)被用来消除冗余的检测框,得到清晰的结果。这些概念是理解现代物体检测模型的基石。
课程 P87:边界框与锚框详解 🧠
在本节课中,我们将学习目标检测中的两个核心概念:边界框和锚框。我们将了解它们如何定义、如何生成,以及如何用于训练和预测。


边界框的定义与可视化 📐

边界框是用于在图像中定位物体的矩形框。它通常由四个数字定义:左上角的x坐标、y坐标,以及框的宽度和高度。
以下是绘制单个边界框的示例代码:


def draw_bbox(img, bbox, label=None, color='black'):
# 绘制边界框的代码逻辑
pass
上一节我们介绍了边界框的基本概念,本节中我们来看看如何同时绘制多个边界框。
以下是绘制多个边界框的注意事项:
- 边界框以列表形式传入。
- 可以为每个框指定标签。
- 可以自定义每个框的颜色。


锚框的生成 🎯
锚框是一组预先定义好的、具有不同大小和宽高比的边界框。它们以图像中的每个像素为中心生成,用于作为目标检测模型的初始预测候选框。

以下是生成锚框的核心函数逻辑:
def multibox_prior(data, sizes, ratios):
# 输入:图像数据、尺寸比例列表、宽高比列表
# 输出:生成的锚框集合
pass

给定一张图像,我们可以为每个像素生成多个锚框。sizes参数控制锚框相对于原图的大小(例如0.5表示原图尺寸的50%),ratios参数控制锚框的宽高比(例如1表示正方形,2表示宽是高的两倍)。
通过组合不同尺寸和比例,可以为每个像素生成大量锚框,覆盖各种可能的物体形状和大小。


为锚框分配标签 🏷️

在训练时,我们需要为每个生成的锚框分配标签,标明它对应的是背景还是某个特定物体(如猫、狗)。
以下是锚框标注的核心步骤:

- 输入:一组真实边界框及其类别标签,以及生成的锚框。
- 计算交并比:计算每个锚框与所有真实框的重叠程度。
- 分配规则:
- 为每个真实框分配与之重叠度最高的锚框。
- 为每个锚框分配与之重叠度超过阈值(如0.5)的真实框。
- 未分配任何真实框的锚框被标记为“背景”。
- 输出:
- 标签:每个锚框的类别(背景为0,物体类别从1开始)。
- 掩码:指示哪些锚框被分配给了真实物体(用于计算损失时忽略背景框)。
- 偏移量:锚框需要调整多少才能与匹配的真实框对齐。

非极大值抑制 🚫
模型预测时,会对每个锚框输出一个类别概率和边界框偏移量。调整后的锚框就是预测框。这会导致对同一个物体产生大量重叠的预测框。
非极大值抑制用于去除冗余的预测框,其步骤如下:

- 根据预测置信度对所有框进行排序。
- 选择置信度最高的框,将其添加到最终输出列表中。
- 计算该框与剩余所有框的交并比。
- 移除所有交并比超过设定阈值(如0.5)的框(即抑制掉与当前框高度重叠的框)。
- 对剩余的框重复步骤2-4,直到没有框剩下。

以下是NMS的简化代码逻辑:

def nms(boxes, scores, iou_threshold):
# boxes: 预测框坐标
# scores: 预测置信度
# iou_threshold: 重叠度阈值
# 返回保留框的索引
pass


多尺度检测 🔍
为了检测不同大小的物体,我们可以在不同尺度的特征图上生成锚框。
- 在深层特征图(分辨率低)上生成锚框,其感受野大,适合检测大物体。
- 在浅层特征图(分辨率高)上生成锚框,其感受野小,适合检测小物体。

这种方法的核心思想是:不改变锚框的绝对尺寸和比例,而是改变生成锚框时所基于的“输入”的尺度。通过在不同层级的特征图上应用锚框生成算法,模型能够自然地适应多尺度物体检测。


总结 📝
本节课中我们一起学习了目标检测中边界框与锚框的关键知识:

- 边界框是定位物体的基础,用四个坐标值定义。
- 锚框是预先定义的一组候选框,以每个像素为中心,具有不同尺寸和宽高比,为模型提供初始猜测。
- 训练时,需要将锚框与真实框进行匹配,并为每个锚框分配类别标签和偏移量目标。
- 预测时,使用非极大值抑制来消除对同一物体的重复预测。
- 多尺度检测通过在神经网络不同层级的特征图上生成锚框,来有效检测大小各异的物体。

理解边界框和锚框的工作机制,是掌握现代目标检测模型(如SSD、Faster R-CNN、YOLO系列)的重要基础。
课程P88:物体检测数据集 🖼️

在本节课中,我们将学习如何为物体检测任务准备和处理数据集。与图像分类不同,物体检测需要更复杂的数据格式和预处理步骤。我们将通过一个合成数据集的例子,了解数据迭代器、数据标注格式以及如何可视化数据。
概述

与图像分类不同,目前没有小规模的物体检测数据集可供直接使用。这意味着无法像某些课程那样获得现成的“C文件”来尝试。因此,本节课程将展示如何制作一个用于物体检测的合成数据集。
数据集的获取与处理
我们实际上是从网上下载了数据集。这里,我们使用了与图像分类不同的数据迭代器。
用户数据集通常是一个文件记录,它将所有图像信息整合在一起。然后,所有图像会被切割成单独的二进制文件。这种做法通常能使训练更高效,读取速度更快,因为你不需要读取大量小文件,而是读取整合后的大数据块。
数据预处理:随机裁剪
我们告知数据迭代器数据的形状,并进行随机洗牌和随机裁剪。这里有一个关键的不同点:在物体检测中,随机裁剪可能会裁剪到只剩下背景的部分。

因此,这里的策略是:确保随机裁剪出的图像块至少包含目标物体的一部分。具体来说,我们要求裁剪区域覆盖原始物体边界框的至少90%,并且物体在裁剪后图像中的面积至少达到原面积的95%。如果不满足条件,就丢弃这次裁剪并重复尝试,最多重复200次。这与图像分类中的随机裁剪有所不同,在图像分类中,我们通常假设裁剪不会对标签产生太大影响。
数据批次的格式
接下来,我们跳过数据集加载的具体代码,直接看一个数据批次。数据的零形状与图像相同,批次大小(batch size)为32。数据的维度依次是:RGB通道数、图像高度、图像宽度。
对于标注(label),其形状为 [32, 1, 10]。这里的32是批次大小,1可能代表一个维度(在某些框架中用于统一格式),10代表每张图像中最多包含的物体数量。在这个简单的例子中,每张图像只包含一个物体,但数据格式预留了最多10个物体的空间。
标注数据的最后四个值是边界框(bounding box)的坐标。与仅进行图像分类相比,如果你只做分类,就不会使用 32×1×10 这样的标注格式。但是对于物体检测,你需要使用这种包含物体类别ID和边界框信息的标注。

合成数据集示例
我们展示的效果如何呢?效果很好。我们制作数据集的方法是:下载一个3D皮卡丘模型,对其进行旋转并渲染,生成一系列图像。然后,我们将这些皮卡丘图像合成到自然背景图像中。
因为我们知道皮卡丘被放置的位置,所以我们始终知道其边界框的坐标。这就是一个我们可以轻松获取标注的合成数据集。
你可以看到效果很好。一个简单的方法是查看RGB颜色直方图。由于皮卡丘是黄色的,我们可以通过分析颜色直方图来进行简单的物体检测,而不需要复杂的模型。我们计算了许多图像块的颜色直方图来演示这一点,这展示了如何尝试进行物体检测。
当然,我们可以做更高级的事情。实际上,使用更接近真实场景的图像会更好。
总结
本节课中,我们一起学习了物体检测数据集的特点。我们了解到物体检测需要包含边界框信息的标注格式,并看到了一个通过合成3D模型来创建数据集的实例。我们还比较了物体检测与图像分类在数据预处理(如随机裁剪)和数据批次格式上的不同之处。
🧠 课程 P89:基于区域的卷积神经网络(R-CNN)家族详解

在本节课中,我们将学习目标检测领域的一个重要算法家族——基于区域的卷积神经网络(R-CNN)。我们将从最早的R-CNN开始,逐步了解其演进过程,包括Fast R-CNN、Faster R-CNN以及Mask R-CNN,并分析它们的设计思想、改进之处以及性能对比。
📜 概述:R-CNN家族
R-CNN是基于深度学习的物体检测算法中最早获得成功的系列之一。其核心思想是:首先从图像中生成一系列可能包含物体的候选区域(锚框),然后对每个区域进行特征提取和分类,最终预测物体的类别和精确边界框。
🔍 R-CNN:最初的尝试
R-CNN是这一系列的开创性工作。它的流程非常直观。

给定一张包含猫和狗的图片,算法首先使用一种启发式方法(如选择性搜索)生成大量候选锚框。这个过程可以看作一个黑箱:输入图片,输出许多可能包含物体的区域。
对于每个锚框,算法执行以下步骤:
- 将锚框对应的图像区域裁剪出来。
- 使用一个预训练的网络(如VGG)提取该区域的特征,得到一个固定长度的特征向量(例如1000维)。
- 使用支持向量机(SVM)根据特征对区域内的物体进行分类。
- 同时,训练一个线性回归模型来预测边界框的偏移量,以微调锚框的位置。
核心问题:这种方法计算成本极高。如果每张图片生成100个锚框,一个有100万张图片的数据集就会产生1亿个区域。对每个区域独立进行CNN前向传播来提取特征,需要巨大的计算资源。
解决方案:在实际训练中,通常会预先计算所有区域的特征并保存到磁盘,然后在后续阶段使用这些特征进行SVM和回归器的训练,这比实时计算要高效得多。

⚡ Fast R-CNN:效率的飞跃
上一节我们介绍了R-CNN,但其计算瓶颈明显。Fast R-CNN的核心改进在于大幅提升了特征提取的效率。

关键概念:感兴趣区域池化(RoI Pooling)
在介绍Fast R-CNN之前,需要理解一个关键操作:感兴趣区域池化(RoI Pooling)。
给定一个特征图和一个锚框(即感兴趣区域),RoI Pooling的目标是为任意形状的锚框生成一个固定尺寸的特征表示。

例如,进行一个2x2的RoI Pooling:
- 将锚框在特征图上对应的区域划分为2x2的子区域。
- 对每个子区域执行最大池化操作。
- 最终,无论原始锚框是何种尺寸,我们都能得到一个2x2的固定输出。

代码描述:
# 伪代码示意 RoI Pooling 的核心思想
output = roi_pooling(feature_map, rois, output_size=(2, 2))
# 对于每个roi,output的形状都是固定的 (2, 2, channels)

这个操作的好处是,我们可以用统一的方式处理不同大小和长宽比的锚框。
Fast R-CNN 的流程

Fast R-CNN的流程优化如下:
- 共享特征提取:不再将每个锚框裁剪后的图像单独输入CNN。而是将整张图片一次性输入CNN,得到一个共享的特征图。
- 应用锚框:在共享的特征图上,根据原始图像中的锚框位置,映射到对应的特征图区域。
- RoI Pooling:对每个映射后的区域执行RoI Pooling,得到固定尺寸的特征。
- 预测:将这些固定特征输入全连接层,同时完成分类(用Softmax替代SVM)和边界框回归。
核心改进:通过共享计算,整个网络只需对整图做一次前向传播,而不是对成千上万个区域重复计算。这使速度提升了约10倍,并且将分类和回归任务整合进一个统一的网络中进行端到端训练。

🚀 Faster R-CNN:引入区域提议网络

Fast R-CNN虽然更快,但其候选区域(锚框)的生成仍然依赖外部算法(如选择性搜索),这本身较慢且是性能瓶颈。Faster R-CNN的关键创新是将区域生成步骤也整合进神经网络。


区域提议网络(RPN)
Faster R-CNN在共享的特征图后,接入了一个小型神经网络——区域提议网络(Region Proposal Network, RPN)。

RPN的工作流程如下:
- 在特征图上滑动一个小的窗口(如3x3卷积)。
- 在每个窗口中心,预设多种尺度和长宽比的“锚点”(anchor)。
- 对每个锚点,RPN同时输出两个预测:
- 物体性得分:判断该锚点是否包含物体(二分类:是物体/背景)。
- 边界框偏移量:预测如何调整锚框以更好地匹配物体。
- 根据物体性得分筛选出高质量的候选区域,并利用预测的偏移量进行初步调整。

这些由RPN生成的、质量更高的候选区域,随后被送入Fast R-CNN模块(RoI Pooling + 分类/回归)进行最终检测。

核心优势:
- 速度:RPN是一个轻量的卷积网络,与主网络共享特征,计算代价很小,替代了耗时的外部区域提议算法。
- 质量:RPN通过训练学习如何生成更好的提议,提高了候选区域的质量,从而提升了整体检测精度。
- 端到端:整个系统(特征提取、区域提议、分类回归)可以联合训练。
🎭 Mask R-CNN:扩展到实例分割
Faster R-CNN专注于边界框检测。但当数据集中包含更精细的像素级标注(例如,每个像素属于哪个物体实例)时,我们可以做得更多。Mask R-CNN在Faster R-CNN的基础上,增加了一个像素级掩码预测的分支。

主要改进

- RoI Align:为了解决RoI Pooling在量化操作中导致的特征图与原始区域不对齐问题(这对像素级任务至关重要),Mask R-CNN引入了 RoI Align。它使用双线性插值来更精确地计算每个区域的特征,保持了空间位置的准确性。
- 掩码预测分支:在原有的分类和边界框回归分支之外,新增一个全卷积网络(FCN)分支。对于每个候选区域,这个分支会输出一个小的二进制掩码(例如28x28),预测该区域内物体的精确像素级形状。
公式描述:
总损失函数在Faster R-CNN的基础上增加了掩码损失:
L = L_cls + L_box + L_mask
其中 L_mask 通常使用逐像素的二元交叉熵损失。
核心贡献:Mask R-CNN实现了实例分割——不仅能框出物体,还能精确勾勒出物体的轮廓。额外的掩码监督信息也有助于提升边界框预测的准确性。这项工作获得了CVPR 2017的最佳论文奖。
📊 性能对比与总结
最后,我们来对比一下R-CNN家族与其他主流检测模型的性能。评估指标通常是准确度(mAP)和速度(FPS,每秒处理帧数)。

上图展示了三种模型的对比(注意横轴速度为对数刻度):
- R-CNN系列(蓝色):准确度最高,但速度最慢。即使是Faster R-CNN,相比其他现代模型(如YOLO v3)也可能慢一个数量级。
- SSD(黄色)与 YOLO v3(绿色):属于单阶段检测器,速度更快,但早期版本在精度上可能有所妥协。
选择建议:
- 如果对精度要求极高且不计较计算成本(例如某些自动驾驶场景),Faster R-CNN或Mask R-CNN仍是重要选择。
- 如果需要在精度和速度间取得平衡,更现代的单阶段检测器(如YOLO系列及其变体)是更实用的选择。


目标检测领域发展迅速,每年都有新模型涌现。R-CNN家族因其开创性的两阶段(提议+检测)思想和模块化设计,奠定了深厚的基础,其核心概念(如锚框、边界框回归、RoI操作)至今仍深刻影响着后续的研究。

🎯 本节课总结
在本节课中,我们一起学习了基于区域的卷积神经网络(R-CNN)家族:
- R-CNN:开创了使用CNN特征进行目标检测的先河,但计算效率低下。
- Fast R-CNN:通过引入RoI Pooling和共享特征提取,大幅提升了训练和测试效率。
- Faster R-CNN:创新性地提出区域提议网络(RPN),将区域生成过程也纳入网络,实现了端到端训练,在速度和精度上取得更好平衡。
- Mask R-CNN:在Faster R-CNN基础上增加掩码预测分支和RoI Align,实现了实例分割,并进一步提升了检测性能。

这个系列模型清晰地展示了目标检测算法如何通过结构创新,逐步解决计算瓶颈、提升精度并扩展任务能力。理解R-CNN家族的演进,是掌握现代目标检测技术的重要基石。
课程 P9:9. L2_4 基本概率 🎲
在本节课中,我们将要学习如何使用 Python 和 MXNet 库进行基本的概率实验。我们将通过模拟掷骰子的过程,来观察随机抽样、频率统计以及概率收敛的现象。

导入必要的库
首先,我们需要导入 MXNet 库,它提供了生成随机数的功能。同时,我们也会导入一些绘图相关的库,以确保我们的图表能够清晰地展示结果。
import mxnet as mx
from mxnet import nd
# 此处省略了绘图库的导入,仅用于美化图表
定义概率与抽样

上一节我们介绍了如何导入库,本节中我们来看看如何定义一组概率并进行随机抽样。
假设我们有一个公平的六面骰子,每个面朝上的概率相等,都是 1/6。我们可以用一个包含六个概率值的列表来表示。
probabilities = [1/6, 1/6, 1/6, 1/6, 1/6, 1/6]
然后,我们可以从这个概率分布中进行抽样。以下是从中抽取一个样本的代码:
sample = nd.sample_multinomial(nd.array(probabilities), shape=1)
print(sample)
输出可能类似于:
[3]
<NDArray 1 @cpu(0)>

进行多次抽样
单次抽样的结果具有随机性。为了观察统计规律,我们需要进行多次抽样。
以下是进行多次抽样的方法,例如从一个形状为 (5, 10) 的矩阵中抽取样本:
samples = nd.sample_multinomial(nd.array(probabilities), shape=(5, 10))
print(samples)
这段代码会生成一个 5 行 10 列的矩阵,每个元素都是从给定概率分布中抽取的一个结果(0到5之间的整数)。

模拟大量实验并观察频率
现在,让我们模拟掷骰子一千次,并统计每个数字出现的次数。

以下是模拟和统计的步骤:
- 进行 1000 次抽样。
- 统计每个面出现的次数。
- 计算每个面出现的频率(次数/总次数)。

# 进行1000次抽样
num_trials = 1000
results = nd.sample_multinomial(nd.array(probabilities), shape=num_trials)
# 统计每个数字(0-5)出现的次数
counts = nd.sum(nd.one_hot(results, depth=6), axis=0)

# 计算频率
frequencies = counts / num_trials
print(frequencies)
运行后,你会发现每个数字的频率并不精确等于 1/6,有些会稍大,有些会稍小。这是随机抽样的正常现象。
观察概率的收敛性

一个有趣的问题是:随着实验次数的增加,观察到的频率会以多快的速度收敛到真实的概率值?
为了观察这个过程,我们可以计算累积频率。以下是计算累积频率的步骤:

- 记录每次实验后,每个数字出现的累计次数。
- 将累计次数除以当前的总实验次数,得到截至该时刻的频率。
import numpy as np
cumulative_counts = np.zeros((num_trials, 6))
current_counts = np.zeros(6)

for i in range(num_trials):
outcome = int(results[i].asscalar())
current_counts[outcome] += 1
cumulative_counts[i] = current_counts / (i + 1)
# 现在 cumulative_counts[i, j] 表示前 i+1 次实验中,数字 j 出现的频率
在实验初期(例如前100次),频率可能与真实概率 1/6 相差较大。但随着实验次数(例如达到1000次)的增加,所有数字的频率都会逐渐稳定并收敛到真实概率附近。
这个现象体现了大数定律:当随机实验的次数足够多时,随机事件的经验平均值(频率) 会收敛于其理论期望值(概率)。
在更高级的统计学课程中,你会学到如切比雪夫不等式、切尔诺夫界等定理,它们从数学上描述了这种收敛的速度和可靠性。
总结
本节课中我们一起学习了如何使用代码进行基本的概率实验。

我们首先定义了均匀的概率分布,然后进行了单次和多次随机抽样。接着,我们通过模拟大量掷骰子实验,统计了各结果的频率,并观察到随着实验次数增加,频率会逐渐收敛到真实的概率值。这个过程直观地演示了概率论中的核心概念——大数定律。
通过动手实验,你可以更深刻地理解随机性、统计规律以及概率收敛的含义。
目标检测入门:SSD与YOLO 🎯
在本节课中,我们将学习两种经典的单阶段目标检测算法:SSD(Single Shot MultiBox Detector)和YOLO(You Only Look Once)。我们将重点理解它们的基本思想、核心区别以及各自的优缺点。
SSD:单次多框检测器 🧩
上一节我们介绍了目标检测的背景,本节中我们来看看SSD算法。SSD被称为“单次多框检测器”,其核心概念相对简单。SSD与RCNN系列算法的主要区别在于边界框(Bounding Box)的生成方式。
在RCNN系列中,通常使用复杂的算法(如Selective Search)来生成候选区域(Region Proposals),以期尽可能覆盖图像中的物体。而SSD的做法则要简单得多。
SSD的核心思想
在SSD中,对于特征图上的每一个像素点,我们都会以其为中心,生成多个不同大小和形状的边界框,这些预定义的框被称为锚框(Anchor Boxes)。
以下是生成锚框的关键参数:
- 大小(Size):相对于原始图像的比例,例如
S1, S2, ..., SN。 - 比例(Aspect Ratio):框的宽高比,例如
R1, R2, ..., RM。
生成锚框的数量并非简单的 N * M,而是 N + M - 1。具体生成方式如下:
- 首先,固定第一个比例
R1,然后遍历所有大小S1到SN,生成前N个锚框。 - 接着,固定第一个大小
S1,然后遍历所有比例R2到RM(R1已生成过),生成剩余的M-1个锚框。
公式:锚框总数 = N + (M - 1)
之所以不生成 N * M 个锚框,是为了避免数量过多导致计算和内存负担过重。例如,若 N=10, M=10,每个像素点将产生100个锚框。对于一张100x100的图像,将产生多达100万个锚框,这是不现实的。因此,SSD通过调整大小和比例的线性组合来取得平衡。
SSD的网络结构与多尺度检测
SSD的整个模型流程如下:
- 输入图像:将图片输入网络。
- 基础网络(Backbone):使用一个卷积神经网络(如VGG、ResNet)进行特征提取,输出一个较小的特征图(例如32x32)。
- 生成锚框与预测:在该特征图的每个位置上,为每个预定义的锚框进行两项预测:
- 类别预测:判断框内物体的类别。
- 边界框回归:微调锚框的位置和大小,使其更贴合真实物体。
- 多尺度特征图:SSD的一个重要特点是多尺度检测。基础网络输出的深层特征图尺寸较小,感受野大,适合检测大物体。网络还会从中间层引出一些较浅的特征图,这些特征图尺寸较大,感受野小,适合检测小物体。在每一层特征图上,都会生成一组特定尺度的锚框并进行预测。
这样,SSD就不再需要像RCNN那样显式地学习如何生成候选区域。它只需预先定义好不同尺度和比例的锚框,就能覆盖图像中各种可能的物体形状和大小。这些尺度和比例就是需要手动设置的超参数。
SSD的性能表现
我们可以通过准确率与速度的权衡来评估检测算法。在性能图表中:
- X轴:吞吐量(FPS,每秒检测帧数),代表速度。
- Y轴:准确度(如mAP,平均精度均值),代表精度。
理想的目标是位于图表的右上角(高精度、高速度)。SSD作为两三年前的算法,在当时实现了速度上的显著突破,比Fast R-CNN等两阶段方法快很多,虽然牺牲了一些精度,但在需要快速推理的场景中非常实用。
YOLO:你只看一次 🚀
上一节我们介绍了SSD,本节中我们来看看另一种著名的单阶段检测器——YOLO。YOLO的基本思想与SSD有相似之处,但在锚框的处理上有所不同。

YOLO的核心思想
SSD为每个像素点生成大量高度重叠的锚框。而YOLO(以v1版本为例)的思路更直接:
- 将输入图像(或特征图)均匀划分为
S x S个网格(Grid Cell)。 - 每个网格负责预测固定数量(例如B个)的边界框。
- 每个边界框预测包含:框的位置、大小以及该框包含物体的置信度。同时,每个网格还会预测一组类别概率。

关键区别:在YOLO v1中,每个网格预测的边界框在初始阶段不重叠(由不同网格负责不同区域),这大大减少了冗余的锚框数量,使得检测速度极快。“You Only Look Once”正体现了其一次性完成分类和定位的简洁特性。
YOLO的演进
YOLO算法本身也在不断进化:
- YOLO v2:吸收了SSD等算法的思想,引入了锚框(Anchor Box)机制,性能与SSD等算法更为接近。
- YOLO v3及以后:在v2的基础上,引入了更优的基础网络、多尺度预测等改进,在速度和精度上取得了更好的平衡。
从性能图表上看,YOLO系列(如图中的绿色点)通常能实现比小型SSD更快的速度(例如快1.5倍),同时保持良好的精度,因此在实时检测领域应用非常广泛。
总结 📝
本节课中我们一起学习了两种主流的单阶段目标检测算法:
- SSD:通过在不同尺度的特征图上,为每个位置预定义多种大小和比例的锚框,实现多尺度目标检测。它在速度和精度间取得了良好平衡。
- YOLO:最初通过将图像划分为网格,每个网格直接预测边界框和类别,实现了极高的速度。后续版本借鉴了锚框等思想,持续优化性能。

它们的共同点是将“区域提议”和“分类回归”合并在一个网络中进行,因此速度远快于RCNN系列的两阶段方法。理解锚框、多尺度预测以及网格划分这些核心概念,是掌握现代目标检测模型的基础。
🧠 课程 P91:CNN训练技巧集锦


在本节课中,我们将学习一系列能够显著提升卷积神经网络(CNN)训练效果和最终性能的实用技巧。这些技巧虽然看似微小,但在复现论文结果或提升模型性能时至关重要。


📊 概述:为什么需要训练技巧?
在前面的课程中,我们讨论了构建和训练网络的基本思路。然而,如果仅仅按照常规步骤操作,通常难以复现学术论文中报告的高性能结果。阅读论文时,你会发现其中包含了许多实验细节和“小技巧”。实际上,这些细节对于达到最佳性能至关重要。
例如,在模型库中,一个典型的模型在应用了一系列技巧后,其准确率可以从约75%提升到77%甚至更高。这种2%的提升在学术研究中(例如CVPR会议)可能就是决定性的改进。这些技巧通常计算开销极小,却能带来显著的性能增益。
接下来,我们将逐一介绍几个核心的训练技巧。
🎨 技巧一:MixUp 数据增强

MixUp是一种非常规但有效的数据增强方法。它的核心思想是:在每次训练时,我们不直接使用原始样本,而是将两个随机样本及其标签进行线性组合,生成一个新的“混合”样本来进行训练。

以下是其工作原理:
- 选择样本:从数据集中随机选择两个样本,记作
(X_i, Y_i)和(X_j, Y_j)。 - 生成混合系数:从Beta分布或简单地从区间
[0, 1]中随机采样一个系数λ。 - 混合输入与标签:
- 新的输入
X_new是两个原始输入的加权和:X_new = λ * X_i + (1 - λ) * X_j - 新的标签
Y_new也是两个原始独热编码标签的加权和:Y_new = λ * Y_i + (1 - λ) * Y_j
- 新的输入
代码描述:
# 假设 lambda 是混合系数
lambda = np.random.beta(alpha, alpha) # 或 np.random.uniform(0, 1)
x_mixed = lambda * x_i + (1 - lambda) * x_j
y_mixed = lambda * y_i + (1 - lambda) * y_j
# 使用 x_mixed 和 y_mixed 进行训练
这种方法生成的图像可能看起来很奇怪(例如,一个时钟图像中混合了微弱的眼镜特征),但它能鼓励模型学习更平滑的决策边界,从而提升泛化能力。

在目标检测中的应用:MixUp也可用于目标检测。此时,需要将两张图片拼接,并将它们的边界框(bounding box)列表合并。关键是要保持输入图像的几何形状不变,以避免比例失真。

🏷️ 技巧二:标签平滑
在分类任务中,我们通常使用独热编码来表示标签:对于类别 i,其标签向量在第 i 位为1,其余位为0。然而,模型的输出层通常使用Softmax函数,它很难完美地逼近0或1这样的极值。
标签平滑通过软化独热编码的“硬”目标来解决这个问题,使模型训练更稳定,并有助于模型校准。
具体做法是:对于真实类别 i,我们不将其标签设为1,而是设为 1 - ε;对于其他所有类别,我们不将其标签设为0,而是设为 ε / (K - 1),其中 K 是总类别数。
公式描述:
对于真实类别 i 的平滑后标签 y_smooth:
y_smooth[i] = 1 - ε- 对于所有
j ≠ i,y_smooth[j] = ε / (K - 1)

例如,当 ε = 0.1,K=10时,真实类别的标签变为0.9,其他每个类别的标签变为0.1/9 ≈ 0.011。这使得目标分布更加平滑,更容易让Softmax去拟合。
📈 技巧三:学习率预热与余弦衰减
学习率的设置对训练收敛速度和最终效果影响巨大。这里介绍两种策略:预热和余弦衰减。

学习率预热
当我们使用大批量训练或复杂的模型初始化时,如果一开始就使用较大的学习率,可能导致优化过程不稳定。学习率预热策略在训练初期使用一个很小的学习率,然后线性地增加到预设的初始学习率。
操作方式:假设我们计划使用0.1作为基础学习率。在前 N 个epoch(例如5个)内,学习率从0线性增长到0.1。这给了模型一个“热身”阶段,使其参数先稳定到一个相对平滑的区域。
余弦衰减
传统的学习率调度(如在30、60、90个epoch时将学习率降低10倍)需要手动设置多个超参数。余弦衰减提供了一种更平滑、更自动化的衰减方式。
公式描述:
假设总训练步数为 T,当前步数为 t,初始学习率为 η,则当前学习率 η_t 为:
η_t = 0.5 * η * (1 + cos(π * t / T))
这种调度方式在开始时衰减较慢,中期近似线性下降,末期衰减又逐渐变缓,形成一个平滑的曲线。它的主要优点是几乎无需调参,并且通常能带来更好的最终精度。

🔗 技巧四:同步批量归一化
标准的批量归一化在一个批次的数据上计算均值和方差。这在图像分类任务中通常没问题,因为我们可以使用较大的批次大小(如32、64)。
然而,在目标检测或语义分割任务中,由于需要处理大量候选区域(如锚框),内存消耗巨大,经常导致每个GPU只能处理一张或很少几张图片(即批次大小很小)。在小批次上计算的BN统计量非常不可靠,会导致训练不稳定。
同步批量归一化的解决方案是:在多GPU训练时,跨所有GPU同步计算均值和方差。如果使用8个GPU,每个GPU有1张图片,那么SyncBN就相当于在一个批次大小为8的数据上计算BN统计量。虽然这引入了GPU间的通信开销,但换来了稳定且有效的归一化,对于检测和分割任务至关重要。

🖼️ 技巧五:随机训练尺寸
在图像分类中,我们通常将所有输入图像缩放到固定尺寸(如224x224)。但在目标检测中,物体尺寸变化很大,固定尺寸可能不是最优的。
随机训练尺寸策略在每个训练批次中,随机选择一个目标尺寸(通常是32的倍数,如224、256、288等),然后将该批次内的所有图片都缩放到这个随机尺寸。
为什么选择32的倍数? 因为许多CNN(如ResNet)包含多个下采样阶段(通常为5次,每次缩小2倍),最终特征图尺寸会缩小32倍。使用32的倍数作为输入尺寸,可以确保最终特征图的尺寸是整数,避免不必要的对齐问题。
这种方法让模型在训练过程中接触到不同尺度的图像,提升了模型对不同尺寸目标的鲁棒性。

📊 技巧效果总结
让我们通过一些实验结果,直观感受这些技巧带来的提升:
-
图像分类(如ResNet):
- 基线准确率:约77%
-
- 余弦衰减:提升 ~0.75%
-
- 标签平滑:提升 ~0.4%
-
- MixUp:提升 ~0.8%
- 累计提升后准确率:接近79%
-
目标检测(如YOLOv3):
- 基线mAP:约80%
-
- 同步批量归一化:提升 ~0.56%
-
- 随机训练尺寸:提升 ~0.4%
-
- 余弦衰减:提升 ~0.4%
-
- 标签平滑:提升 ~0.4%
-
- MixUp:提升 ~1.5%
- 累计提升后mAP:提升约3.4%

可以看到,将这些技巧组合使用,能带来非常可观的性能增益。

🎯 课程总结

本节课我们一起学习了五个关键的CNN训练技巧:
- MixUp:通过线性混合样本和标签进行数据增强。
- 标签平滑:软化独热编码标签,使训练更稳定。
- 学习率预热与余弦衰减:更智能地调度学习率,促进收敛。
- 同步批量归一化:解决小批次训练时的归一化问题,对检测/分割任务关键。
- 随机训练尺寸:提升模型对不同输入尺度的适应性。

这些技巧是许多高水平论文和代码库中的“隐藏细节”。掌握它们,不仅能帮助你更好地复现前沿成果,也能在你自己的项目中有效提升模型性能。未来阅读论文或源码时,请务必留意这些看似微小却至关重要的实践。

课程 P92:在 Python 中实现 SSD 🚀
在本节课中,我们将学习如何用 Python 实现单次多框检测器。我们将从核心概念入手,逐步构建一个简化的“Tiny SSD”模型,并完成训练和预测的全过程。
概述 📋
SSD 是一种高效的目标检测算法。与之前的方法相比,它速度更快,因为它能在一次前向传播中同时预测多个边界框及其类别。本节课,我们将深入探讨其实现细节,包括网络结构设计、多尺度预测、损失函数以及最终的预测流程。


1. 类别预测

上一节我们介绍了 SSD 的基本思想,本节中我们来看看如何为每个锚框预测类别。

假设我们有一个输入特征图,并为它生成了多个锚框。设锚框数量为 a,类别数量为 c。对于每个锚框,我们需要预测 c + 1 个类别的分数,其中加上的“1”代表“背景”类别,表示该锚框不包含任何目标物体。

为了实现这一点,我们使用一个卷积层。其输出通道数应为 a * (c + 1)。这是因为对于特征图上的每个像素位置(对应一个锚框中心),我们需要为 a 个锚框中的每一个生成 c+1 个预测值。

我们选择卷积核大小为 3,步长为 1,填充为 1。这样可以保证输出特征图的空间尺寸(高度和宽度)与输入相同。这意味着特征图上的每个像素,在输出中都有一个对应的像素,而该像素拥有 a * (c+1) 个通道,分别代表该位置所有锚框的类别预测。
这与传统的图像分类网络不同。在分类网络中,我们通常通过全局平均池化将特征图压缩成一个向量,其长度等于类别数 c。但在 SSD 中,我们不进行池化,因为需要为空间中的每个位置(即每个锚框)进行独立预测。
以下是类别预测层的代码示意:
# num_anchors: 锚框数量 a
# num_classes: 类别数量 c
cls_predictor = nn.Conv2d(in_channels, num_anchors * (num_classes + 1), kernel_size=3, padding=1)

2. 边界框预测

与类别预测类似,我们也需要为每个锚框预测其边界框的偏移量。


一个边界框可以用 4 个值表示(例如中心点坐标、高度和宽度)。因此,对于 a 个锚框,边界框预测层的输出通道数应为 a * 4。


以下是边界框预测层的代码示意:
# num_anchors: 锚框数量 a
bbox_predictor = nn.Conv2d(in_channels, num_anchors * 4, kernel_size=3, padding=1)


3. 拼接多尺度预测
SSD 的核心优势在于利用不同层级的特征图进行多尺度预测。每个尺度会输出自己的预测结果,我们需要将它们拼接起来,输入到损失函数中进行统一计算。
具体做法如下:
- 对于每个尺度的输出张量,其形状为
(batch_size, channels, height, width)。 - 我们将其转置为
(batch_size, height, width, channels)。 - 然后将其展平为一个二维矩阵,形状为
(batch_size * height * width, channels)。这里的channels对于类别预测是a*(c+1),对于边界框预测是a*4。 - 最后,将所有尺度展平后的预测矩阵在第0维(非批次维度)进行拼接。
假设有两个尺度的类别预测 y1 和 y2,处理后的形状分别为 (N1, C) 和 (N2, C),拼接后得到 (N1+N2, C)。这样,我们就将所有锚框的预测统一到了一个二维张量中。
4. 下采样块

为了生成多尺度特征图,我们需要一个能降低空间分辨率(下采样)并增加通道数的基本模块。
我们定义一个“下采样块”。它通常包含以下操作:
- 一个卷积层(
Conv2d),用于将通道数映射到目标输出通道数,并保持空间尺寸不变(通过填充实现)。 - 批量归一化层(
BatchNorm2d)。 - ReLU 激活函数。
- 一个最大池化层(
MaxPool2d),步长为 2,用于将高度和宽度减半。


例如,输入一个 20x20 的特征图,经过下采样块后,输出通道数变为指定值(如 10),而空间尺寸变为 10x10。

以下是下采样块的代码示意:
def down_sample_blk(in_channels, out_channels):
blk = []
# 卷积层,改变通道数,保持尺寸
blk.append(nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1))
blk.append(nn.BatchNorm2d(out_channels))
blk.append(nn.ReLU())
# 池化层,尺寸减半
blk.append(nn.MaxPool2d(2))
return nn.Sequential(*blk)


5. 基础网络与完整模型


现在,我们可以组装完整的 Tiny SSD 模型。
基础网络:通常可以使用 ResNet 等预训练网络进行微调。为了简化,这里我们将多个下采样块堆叠起来作为基础网络。例如,连续使用三个下采样块,每次将空间尺寸减半,并将通道数翻倍。对于 256x256 的输入图像,基础网络可将其转换为 32x32 的特征图(尺寸减少 8 倍)。


完整模型流程:完整的 Tiny SSD 包含 5 个“阶段”。
- 阶段 0:基础网络。
- 阶段 1-3:三个额外的下采样块,用于生成更小尺度的特征图。
- 阶段 4:一个全局平均池化层,生成
1x1的特征图,用于检测非常大的物体。
每个阶段都会输出特征图,并为该特征图上的每个像素生成锚框、类别预测和边界框预测。

前向传播:对于每个阶段,输入通过该阶段的网络得到特征图 y。然后,根据为该阶段预设的锚框尺寸和宽高比,为特征图 y 上的每个像素生成锚框。接着,将特征图 y 分别输入该阶段对应的类别预测器和边界框预测器,得到预测结果。最后,将所有阶段的锚框、类别预测、边界框预测分别拼接起来。
锚框设置:不同阶段负责检测不同大小的物体。例如:
- 浅层特征图(尺寸大)使用较小的锚框尺寸(如原图的 20%),用于检测小物体。
- 深层特征图(尺寸小)使用较大的锚框尺寸(如原图的 80%),用于检测大物体。
- 每个位置通常设置多个宽高比(如 1:1, 2:1, 1:2)的锚框。若设置 2 种尺寸和 2 种宽高比,则每个位置有 4 个锚框。

6. 损失函数
SSD 的损失函数由两部分组成:类别损失和边界框损失。
类别损失:使用交叉熵损失(Cross-Entropy Loss)。对于每个锚框,我们计算其预测的 c+1 个类别分数与真实标签之间的损失。
cls_loss = F.cross_entropy(cls_preds, cls_labels)

边界框损失:使用 L1 损失(平滑 L1 损失更佳)。我们只对那些与真实边界框匹配(即非背景)的锚框的边界框预测进行惩罚。这通过一个掩码来实现。
# bbox_preds: 边界框预测
# bbox_labels: 边界框真实标签
# bbox_masks: 掩码,匹配的锚框位置为1,背景为0
bbox_loss = F.l1_loss(bbox_preds * bbox_masks, bbox_labels * bbox_masks)
总损失:总损失是类别损失和边界框损失的加权和。为简化,常将两者直接相加。
total_loss = cls_loss + bbox_loss

7. 训练流程


训练循环与分类网络类似,但数据准备和损失计算更复杂。


以下是核心训练步骤:
- 前向传播:将图像输入网络,得到所有锚框、类别预测和边界框预测。
- 匹配锚框:将预测的锚框与图像的真实标注边界框进行匹配,为每个锚框分配类别标签和边界框偏移量标签,并生成掩码。
- 计算损失:使用匹配得到的标签和掩码,计算类别损失和边界框损失,并求和。
- 反向传播与优化:计算梯度,并更新模型参数。
- 评估:计算分类准确率和边界框预测的平均 L1 误差,用于监控训练过程。

由于需要为每张图像处理成千上万个锚框,目标检测的训练通常比图像分类更耗时。


8. 预测与后处理
训练好模型后,我们可以用它来预测新图像。


预测步骤:
- 网络前向传播:输入图像,得到锚框、类别预测分数和边界框偏移量预测。
- 生成最终边界框:利用预测的偏移量调整对应的锚框,得到初步的预测框。
- 应用 Softmax:对类别预测分数应用 Softmax,得到每个框属于各个类别的置信度。
- 非极大值抑制:由于会产生大量重叠的预测框,需要使用非极大值抑制来移除冗余框。NMS 会保留置信度最高的框,并抑制与其重叠度(IoU)过高的其他框。
- 阈值过滤:根据设定的置信度阈值(如 0.5),过滤掉置信度低的预测结果,只保留可靠的检测框。
最终,我们将保留下来的边界框及其类别标签、置信度绘制在原始图像上,完成目标检测。

总结 🎯
本节课中,我们一起学习了 SSD 目标检测算法的 Python 实现。我们从类别与边界框的预测原理出发,构建了多尺度预测的网络结构,定义了包含分类和回归的损失函数,并完成了模型的训练和预测流程。虽然这是一个简化版的“Tiny SSD”,但它涵盖了 SSD 的核心思想:利用单次前向传播,在不同特征层上密集预测目标类别和位置,从而实现快速且有效的检测。理解这个流程是掌握现代目标检测模型的重要基础。

课程 P93:依赖随机变量 🧠

在本节课中,我们将学习序列模型以及为什么需要它们。我们将探讨数据通常不是独立同分布(IID)的现实情况,并理解处理依赖随机变量的重要性。

为什么需要序列模型?🤔
到目前为止,我们讨论的模型大多基于一个假设:训练数据点是独立且同分布的。我们收集观察对 (xi, yi),它们来自某个联合分布 p(x, y)。然后我们估计条件分布 p(y | x),以对新的 x' 进行预测。例如,这适用于图像分类、回归或房价预测问题。
在这种设定下,数据点的顺序并不重要。我们可以随意遍历训练集,一切都很顺利。然而,现实世界的数据往往不满足这种理想化的假设。

回顾:与环境的互动模式 🔄

上一节我们介绍了独立同分布的理想情况。本节中,我们来看看当数据存在依赖关系时,我们与环境互动的几种不同模式。
以下是几种主要的互动模式:
- 批量训练:下载所有数据,在这些数据上训练模型,然后部署。
- 在线学习:每次获取一个观察值,更新模型,然后部署。这在计算广告等领域很重要,因为模型越新鲜,效果可能越好。
- 主动学习:主动与环境互动并进行实验。例如,在广告中尝试展示一个不确定效果的新广告以收集数据。
- 强化学习:智能体采取一个行动,环境做出反应并给出奖励,然后智能体采取新的行动。例如,自动驾驶汽车需要根据环境状态(如是否快撞到树)做出连续决策。

系统的状态与记忆 💾

关键的区别在于系统是否有状态。一个有状态的系统拥有记忆,其当前行为会受到过去经历的影响。例如,一只未被惊扰的犀牛和一只曾被麻丨醉丨枪射击过的犀牛,对同一刺激的反应会截然不同。同样的系统,因为记忆不同,状态也不同。
因此,训练环境和测试环境可能并不相同。特别是,我们可能会遇到非平稳环境,即数据分布随时间或其他因素而变化。接下来,我们将探讨如何应对非平稳、有状态以及依赖性的情况。

非平稳数据的实例 📈
为了理解非平稳性,让我们看几个现实世界的例子。

以下是几个展示数据依赖性和非平稳性的具体案例:
- Netflix评分突变:Netflix曾更改其评分体系的标签(例如将“讨厌”改为“不喜欢”),导致用户评分行为发生整体性偏移,平均评分出现跳跃式变化。这并非推荐系统改进,而是度量标准本身发生了变化。
- 电影评分随时间“改善”:老电影的平均评分似乎随时间推移而升高。这可能是一种选择性偏差:人们倾向于回顾并评价历史上公认的经典好电影,而非随机的老旧电影。
- 获奖效应:某部电影在获奖(如奥斯卡)后,其评分会短期冲高,但大约一年后会回落到原本的水平。这表明外部事件会短暂影响用户评价。
- 幸福感变化:数据显示,已婚女性的幸福感在婚后初期上升,但随后会逐渐回落并稳定。类似地,尽管收入增长,某些地区的整体生活满意度却可能停滞甚至下降。这反映了“享乐适应”现象。
- 地震发生率:地震不仅发生在传统断层带(如加州)。由于人类活动(如俄克拉荷马州的水力压裂采油),会扰动地质结构,导致地震在非传统区域发生。依赖性基于地理位置和人类活动。
- 股票价格:像FTSE100这样的股票指数,其变化明显受到科技股泡沫、次贷危机等重大事件的影响,是典型的非平稳过程。

核心结论 🎯
本节课中我们一起学习了依赖随机变量的概念。核心结论是:数据通常不是独立同分布(IID)的。


IID 只是一个为了使问题初始简化而做的假设。在现实中,数据点之间常常存在基于时间、序列顺序、空间位置或其他变量的依赖关系,并且数据分布可能是非平稳的。认识到这一点,并发展能够处理数据依赖性和非平稳性的模型,通常能带来更好的性能表现。
课程 P94:序列模型 📊

在本节课中,我们将学习序列模型的基本概念。序列模型用于处理具有时间或顺序依赖关系的数据,例如时间序列、语言或DNA序列。我们将探讨如何对这类数据进行建模,并比较两种主要的建模思路。
序列建模的基本思路
到目前为止,我们处理的数据通常被视为独立同分布的。然而,许多数据具有序列依赖关系。


那么,如何为这种数据建模呢?假设我们有一系列相互依赖的随机变量 x1 到 xt,它们从某个分布 p(x) 中抽取。我们可以通过条件概率来写出联合概率分布:
p(x) = p(x1) * p(x2|x1) * p(x3|x1, x2) * ... * p(xt|x1, ..., xt-1)
这个分解方法无论数据是否是时间序列都适用。我们也可以写出反向的条件概率分解。单纯从数学上看,我们可以构建出许多模型,但其中一些可能在现实中是荒谬的。
了解这一点有什么意义呢?关键在于,现实世界中的因果关系通常具有方向性。

因果关系的方向性

让我们再次审视两种分解方式。

在一种情况下,所有的依赖箭头指向右侧(未来依赖于过去);在另一种情况下,箭头指向左侧(过去依赖于未来)。现实中的因果关系通常防止反向影响,即未来不会影响现在或过去。因此,我们通常采用前向的因果模型。
确定时间方向的一种方法是尝试用两种模型拟合数据,然后看哪一种更合适、解释更简单。数学上可以证明,错误的因果关系方向通常会导致更复杂的模型。Bernard Cholkopf 和 Dominic Yansing 在这方面做了很多出色的工作,并著有相关书籍。
回归模型方法(计划 A)
既然这是一门深度学习课程,我们自然会想到使用模型。一种直接的方法是使用回归模型。我们的目标是建模条件概率 p(xt|x1, ..., xt-1)。我们可以简单地将其视为给定过去所有数据的某个函数 f 的输出。

p(xt|x1, ..., xt-1) ≈ p(xt) given f(x1, ..., xt-1)

这里存在一个问题:如果函数 f 需要捕捉所有历史信息,计算会变得非常复杂。我们将探讨几种解决这个问题的方法。

马尔可夫假设
第一种解决方案是使用马尔可夫假设。该假设认为,当前状态只依赖于过去有限步(τ步)的历史,而不是全部历史。
p(xt|x1, ..., xt-1) ≈ f(xt-τ, ..., xt-1)
这是一个完全合理的模型。如果 τ 足够长且数据充足,它可以很好地工作。但在许多情况下,这种方法可能会失败。



基于此,我们可以将其简化为一个回归问题:
x̂_t = f(xt-τ, ..., xt-1)
然后通过最小化预测误差来训练模型。这是一个非常直接的方法。如果问题都这么简单,那么时间序列建模就完成了。但显然,还有更多内容需要探讨。
隐变量模型方法(计划 B)
第二种长期流行的方法是使用隐变量(或潜状态)模型。我们不直接截断历史,而是假设存在一个隐藏状态 h_t,它承载了历史信息。
这个隐藏状态根据过去的观察 x_{t-1} 和过去的隐藏状态 h_{t-1} 进行更新。新的观察值 x_t 则依赖于当前的隐藏状态 h_t。


在图中,绿点代表隐藏状态,蓝点代表观察值。可以看到,如果隐藏状态 h_t 是 h_{t-1} 和 x_{t-1} 的函数,并且足够强大以存储信息,那么它就等价于拥有一个依赖于全部历史的函数,只是表达形式不同。

两种方法的对比
总结一下两种核心思路:

- 计划A(显式回归):在马尔可夫假设下,我们直接用一个依赖于近期过去观察值的函数进行建模。一切都是可观测的,我们可以直接构造训练数据。
- 计划B(隐变量模型):我们假设存在一个隐藏状态,它根据
(h_{t-1}, x_{t-1})更新为h_t,而观察值x_t由h_t生成。

这两者之间有微妙的区别。隐变量模型的一个现实例子是语音理解:说话者的大脑产生思想(隐藏状态),思想生成语音(观察值);听者的大脑则解决逆向问题,从语音(观察值)推断思想(隐藏状态)。
虽然区别微妙,但它会引向非常不同的算法。在接下来的课程中,我们将遇到的大多数模型都属于计划B(隐变量模型)。
经典序列模型实例
在深度学习兴起之前,人们已经发展了许多基于隐变量思想的序列模型。了解这些背景有助于理解当前技术与传统的联系。

以下是一些经典模型的例子:

- 隐马尔可夫模型(HMM):用于语音识别、生物信息学等。它假设系统是一个马尔可夫过程,但状态不可见(隐藏),只能观察到由状态生成的输出。
- 卡尔曼滤波器:用于控制系统(如雷达跟踪飞机)。它假设存在一个潜在状态(如飞机位置),你观察到的是带有噪声的测量值(雷达回波)。
![]()
- 主题模型(如动态主题模型):用于文本分析。假设文档背后存在随时间演变的主题(隐藏状态),观察到的词由这些主题生成。
- 线性动力系统(时间主成分分析):一种潜在因子模型,假设潜在变量服从正态分布,并通过线性变换生成观察序列。
![]()

为什么需要深度学习?
既然经典方法已经存在了几十年,为什么我们还需要深度学习来处理序列问题呢?


核心问题在于:我们是否应该完全相信这些具有强参数假设的经典模型?经典模型通常是科学家为了处理少量数据或强行引入可解释结构而做的简化假设(例如,语音由离散单词组成)。

然而,现实世界的数据往往更复杂、更丰富。深度学习提供了更灵活的表示能力,能够自动从数据中学习复杂的依赖关系和模式,而无需过多的人为假设。

例如,深度学习模型可以更好地处理事件发生在随机时间点(而非固定间隔)的数据,或者学习更丰富、更连续的潜在状态表示。


总结 🎯

本节课我们一起学习了序列模型的基础。我们首先了解了如何通过条件概率分解为序列数据建模,并讨论了因果关系的方向性。接着,我们重点对比了两种建模思路:基于马尔可夫假设的直接回归方法(计划A)和基于隐变量的状态空间方法(计划B)。我们还回顾了隐变量思想在经典模型(如HMM、卡尔曼滤波器)中的应用,并探讨了深度学习为序列建模带来更强表示能力和灵活性的原因。在接下来的课程中,我们将深入探讨基于深度学习的序列模型。

课程 P95:95. L18_3 马尔科夫假设与自回归模型 📈
在本节课中,我们将要学习马尔科夫假设的基本概念,并动手实现一个基于该假设的自回归模型来预测时间序列数据。我们将通过Python代码演示其工作原理,并探讨其在短期和长期预测中的表现差异。

马尔科夫假设简介
上一节我们介绍了时间序列预测的背景,本节中我们来看看马尔科夫假设。马尔科夫假设的核心思想是:未来的状态仅依赖于最近的过去,而与更早的历史无关。

在时间序列预测中,这意味着我们可以用最近 τ 个时间点的观测值来预测下一个时间点的值。其核心公式可以表示为:

x̂_t = f(x_{t-τ}, ..., x_{t-1})
其中,x̂_t 是时间点 t 的预测值,f 是我们需要训练的回归模型。

生成模拟数据
为了理解模型如何工作,我们首先需要一些数据。以下是生成模拟时间序列数据的步骤。
以下是生成数据的代码:
import numpy as np
# 设置参数
tau = 4 # 嵌入维度,即使用过去4个点
n = 1000 # 总数据点数
# 生成时间序列:正弦波加噪声
time = np.arange(n)
data = np.sin(0.01 * time) + np.random.normal(0, 0.1, n)
这段代码生成了一个包含1000个数据点的序列,它由一个基础的正弦波和叠加的高斯噪声组成。我们可以将其可视化以便更好地理解。
构建数据集与模型

有了数据之后,我们需要根据马尔科夫假设构建用于训练的数据集。具体来说,我们需要将连续的 τ 个观测值作为特征(X),将其下一个观测值作为标签(y)。
以下是构建数据集的步骤:

- 初始化特征矩阵
X和标签向量y。 - 遍历数据,将每个时间点
t之前的τ个值作为一组特征。 - 将时间点
t的值作为对应的标签。

为了提高效率,我们可以使用向量化操作而非循环来构建数据集。
接下来,我们定义一个简单的多层感知机(MLP)作为回归模型 f。
以下是定义模型的代码:
import torch
import torch.nn as nn

class RegressionModel(nn.Module):
def __init__(self, input_dim):
super().__init__()
self.net = nn.Sequential(
nn.Linear(input_dim, 10),
nn.ReLU(),
nn.Linear(10, 10),
nn.ReLU(),
nn.Linear(10, 1)
)
# 使用Xavier初始化权重
for layer in self.net:
if isinstance(layer, nn.Linear):
nn.init.xavier_uniform_(layer.weight)
def forward(self, x):
return self.net(x)
这个模型结构简单,包含两个隐藏层,使用ReLU激活函数,适用于我们的回归任务。


训练模型
数据集和模型准备就绪后,我们就可以开始训练了。我们将使用前600个数据点作为训练集,剩余部分作为测试集。
以下是训练循环的核心步骤:
- 定义损失函数(均方误差损失)和优化器(Adam)。
- 在多个训练周期(epoch)中,遍历训练数据的小批量(mini-batch)。
- 对于每个小批量,计算模型预测值、损失、梯度,并更新模型参数。
训练过程会打印出每个周期的平均损失,以便我们监控训练进度。

模型评估与问题揭示
训练完成后,我们首先在测试集上进行“一步预测”评估。即,在预测每个未来点时,我们仍然使用真实的过去观测值作为输入。

结果看起来非常好,预测曲线(橙色)与真实数据曲线(蓝色)几乎重合。这表明模型在拥有真实历史数据的情况下,可以准确预测下一步。
然而,这引出了一个关键问题:在真实的长期预测场景中,我们并没有未来的真实数据。当我们预测第 t+1 步时,我们必须使用第 t 步的预测值作为输入,而不是真实值。这个过程称为自回归预测或多步预测。
当我们进行这种真正的多步预测时(例如,从第600步之后开始完全依赖模型自身的预测),结果迅速恶化。预测值很快收敛到一个常数,不再捕捉数据的任何波动。
以下是进行多步预测的简化逻辑:
predictions = []
# 初始输入是最后tau个真实观测值
current_input = last_tau_true_values

for i in range(steps_to_predict):
# 用模型预测下一步
next_pred = model(current_input)
predictions.append(next_pred)
# 更新输入:去掉最旧的值,加入最新的预测值
current_input = torch.cat([current_input[:, 1:], next_pred.unsqueeze(1)], dim=1)

我们进一步分析了预测步长(如4步、8步、16步、32步)对误差的影响。结果显示,模型在短期(4-8步)预测中表现尚可,但随着预测步长增加,误差急剧上升,长期预测能力非常有限。
总结

本节课中我们一起学习了马尔科夫假设和自回归模型。我们了解到,基于马尔科夫假设的模型在单步预测(使用真实历史数据)时表现优异。然而,在进行真正的多步自回归预测时,由于误差会随着预测步长的增加而累积和放大,模型的长期预测效果会显著下降。这是一个在实际应用时间序列模型时需要特别注意的关键局限性。
语言建模基础 - P96 🧠
在本节课中,我们将学习如何处理和分析文本序列数据。我们将从基础概念开始,介绍如何将文本转换为可处理的标记,并探讨经典的 n-gram 语言建模方法。通过一个具体的例子,我们将看到如何加载数据、进行基本的统计分析,并观察文本数据中普遍存在的统计规律。
从数字序列到字符序列 🔄
到目前为止,我们主要关注的是数字序列数据。现在,我们将转向字符序列数据,因为这是一个非常常见的应用场景,并且需要一些特殊的处理方式。因此,我们必须了解如何实际处理这类数据。
这包括如何加载数据,以及如何将其映射为具体的处理对象。这是我们需要完成的一些基础工作。
标记与词汇表 📝
在文本处理中,标记(tokens)并不是实际的值,并且其所属的领域(词汇表)是可数的、有限的。至少在假设下,我们有一个有限的词汇宇宙。但这并不总是成立。例如,英语语言会不断吸收新的单词。
因为人们会创造新词,或者从其他语言中引入词汇。所以总是会有新的内容出现。即便如此,我们仍然可以尝试对这些内容进行建模。
经典的 n-gram 语言建模 🧮
例如,如果我有一个标记序列 “statistics is fun”。我可以通过以下公式来建模它:
p(statistics) * p(is | statistics) * p(fun | statistics is)
如果我想获得一些有意义的概率估计,我可以建模 p_hat(is | statistics)。我只需计算我看到 “statistics is” 的次数,然后除以我看到 “statistics” 的次数。这给了我一个粗略的估计。
从统计学的标准来看,这种方法非常粗糙。同样地,对于长的 n-gram,我基本上需要先进行平滑处理。我们在做朴素贝叶斯分类器时已经看到了这一点。
当我们构建朴素贝叶斯分类器时,我们添加了伪计数。你也需要为单词概率做这件事。因此,你可能会预留一些额外的概率质量 ε,然后将其均匀分配到所有类别上。
所以,p_hat(w) 可能是该单词出现的次数加上某个 ε,除以总标记数 n 加上 ε 乘以词汇表大小 m。


然后,我可以处理 p(w‘ | w),并使用回退平滑器来处理像单例这样的情况。对于像三元组这样的长序列,我可以回退到二元组。这是一个变通方法。
我写的几处细节并不完全精确,但核心思想是这样的。如果你想了解如何正确地进行,你应该去上 Tom Griffiths、Mike Jordan 或 Jim Pitman 教授的基础非参数统计课程。
我们不会深入讨论细节,但这就是经典统计学中建模 n-gram 的精神:先获取计数,然后使用某种回退平滑技术。你可以看到,一旦我有了庞大的词汇表或更长的序列,这种方法就不太奏效了,因为大多数计数都会是零。
实践:分析《时间机器》文本 📚
为了让事情更具体,我们将使用一个小型数据集进行实践。我们将使用《时间机器》的文本。
我们要做的第一件事是加载它。我打开一个文件,从中读取一些行。这是我从古腾堡计划下载的《时间机器》原始数据集。然后,我将文本拆分成单独的标记,并将所有内容转换为小写字母。我丢弃了所有的标点符号和其他非字母字符。
以下是文本的开头几行:
“the time traveler for so it will be convenient to speak of him was expounding a recondite matter to us his grey eyes shone and twinkled”
这就是时间旅行的开始。这就像是 Python 101 的基础操作。
计算单词频率 📊
现在,我要计算单词的数量。我将使用一个简单的集合,即计数器(Counter)。我将遍历数据集中的每个标记。
在 Python 中,你可以优雅地写出双重循环:for string in raw_dataset for token in string。这很酷。然后,计算所有内容。这就是人们喜欢 Python 的原因,因为很多复杂的代码已经被封装好了,你只需要调用它。
我只需查找 counter[‘traveler’],它会给我计数结果。计数器甚至有一个叫做 most_common 的函数。所以,前 10 个最常见的单词是:首先是 “the”,然后是 “and”, “i”, “of”, “a”, “to”, “was”, “in”, “that”, “it”。
这些单词都非常频繁地出现,这是完全可以预期的,因为它们是英语中常见的单词。它们是最常用的词汇之一。这些单词通常被称为停用词。
过去在自然语言处理中,人们通常会丢弃这些停用词,因为它们出现得太频繁了。但现在情况不同了,人们会保留它们,因为现代模型(如 Transformer 或 LSTM)可以直接处理它们。所以,我们不再丢弃停用词,但要意识到它们的存在。
观察齐夫定律(Zipf‘s Law)📉
接下来,我要绘制单词的出现次数与其排名顺序的关系图。我绘制的是对数-对数图。这应该是一个巨大的提示,告诉你预期会看到什么样子。
结果是幂律分布。这几乎是一条直线。出现次数的对数与排名的对数呈线性关系。即使是那些只出现一次的最不常见的单词也是如此。
这就是齐夫定律。它的意思是,计数与 (rank + C)^(-α) 成正比。基本上,我有一个偏移量 C 和一个负指数 α。当然,如果我对两边取对数,我就得到 log(count) = -α * log(rank + C) + constant。
几乎总是,当你有大的离散集合时,就会出现某种幂律。充分利用这一点。如果在你的领域里还没有人做过这件事,你可以就此写一篇论文。
例如,在推荐系统中,你会发现实际上有少数几部电影非常受欢迎,而大多数电影都非常不受欢迎;有些人会给很多电影评分,而大多数人则不会。如果你利用这一点,你可以构建一个比没有利用这一点的实现要快得多的版本。
分析二元组与三元组 🔗
让我们看看这是否同样适用于单词对(二元组)。同样,这非常 Pythonic。我生成了一个单词序列,然后通过将序列中的每个单词与其后一个单词配对,生成了这些二元组。
我使用了 zip 函数来取两个数组(单词序列和其偏移一位的序列)并生成配对。
如果我们查看文本的开头,常见的二元组包括 “time traveler”, “time machine”。然后,“machine by”, “by h”, “h g” 等等。最常见的二元组主要是停用词的组合。唯一的例外是 “time traveler”,它出现了 102 次。
基于此,可以得出几点结论。正如你所见,用几行代码就能得到相当有意义的文本统计。如果我能做到,你们肯定也能做到。而且,在开始其他工作之前先浏览一下数据是个好主意。
现在,我们还可以查看三元组,并绘制单元词、二元组和三元组的频率分布。结果是,齐夫定律同样适用于二元组和三元组。你可能想在模型中以某种方式利用这一点。
总结 🎯

本节课中,我们一起学习了语言建模的基础知识。我们从处理字符序列与数字序列的区别开始,介绍了标记和词汇表的概念。我们探讨了经典的 n-gram 建模方法及其平滑技术。通过《时间机器》文本的实践,我们学习了如何加载和预处理文本数据,计算单词频率,并观察到了文本数据中普遍存在的齐夫定律。我们还简单分析了二元组和三元组的统计特性。这些基础操作为后续更复杂的语言模型打下了坚实的基础。
课程 P97:文本预处理 📝
在本节课中,我们将学习如何为语言模型准备文本数据。我们将探讨分词的不同方法,并学习如何将文本序列有效地组织成用于训练的小批量数据。
1. 分词:将文本转换为数字序列 🔢
上一节我们介绍了基本的文本统计信息。本节中我们来看看我们实际上该如何处理文本。
我们首先要做的就是对文本进行分词处理。所谓分词,指的是把每个词或者每个简单的单位转换成一个概念,然后文本就变成了一系列整数。
有很多方法可以实现这个目标。
字符编码
一种方法是使用字符编码。这种方法很好,因为如果只有30到35个有意义的字符,词汇表会非常小,每次预测一个新字符会非常容易。这样做永远不会遇到一个没见过的特定单词,因为所有单词都由这些字符组成。
问题是,如果这么做,模型往往并不能很好地工作。网络需要学习如何正确拼写。对于某些语言来说,这实际上是件好事。
以下是字符编码的优缺点:
- 优点:词汇量小,不会遇到未知字符。
- 缺点:模型需要学习拼写,对于小字母表的语言(如英语)效果不佳。
词级分词
另一种方法是把每个词当作一个标记。这对于中文来说非常有效,因为中文有成千上万个字符,这提供了一个“恰到好处”的颗粒度表示。

然而,对于英语或其他语言,名词和动词的形式变化非常剧烈。这会导致词汇量非常庞大,模型可能无法很好地泛化到没见过的单词形式上。
字节对编码
显然,在字符和词之间有一个折中方案,那就是字节对编码。

人们寻找常见的频繁子字符串,对它们排序、聚合,最频繁的常见子字符串会被进一步聚合。这样得到的东西看起来像音节。如果你有一些非常常见的其他子字符串,它们可能会稍微长一些。
这种字节对编码几乎可以让你进入一个“恰到好处”的区间,而处理日语或中文时,你基本上一开始就已经进入了这个区间。
2. 生成训练小批量 📦
接下来我们需要生成小批量数据,因为我们需要生成训练数据。记住,我们有嵌入,然后我们想预测下一个字符。


你可以将文本分割开,例如,分成五个字符的子序列。这样你就有不同的偏移量,你只需要继续,稍微计算一下,确保不会越界。
随机分区
一种方法是随机分区,例如选择一个随机偏移量,将序列随机分布到小批量中。这会给你一种相对独立的样本,虽然它们不完全独立。
问题是,如果你有一个潜变量模型,你需要重置隐藏状态。因为每当你在该字符串的不同部分生成一个不同的序列时,你并不知道它所来自的上下文,所以你需要重置你的隐藏状态。
顺序分区
另一种方法是使用顺序分区。你只需取一个随机偏移量,然后将序列分布到小批量中,但你需要保持序列在小批量中不变。
你为这一部分生成一个小批量,接下来的小批量用于这里,下一个小批量用于这里,然后继续下去。这样你可以在小批量之间携带隐藏状态。这样做效果要好得多。
3. 代码实现 💻
现在是代码时间。我们将读取文本数据,并实现上述的分区方法。
首先,我们读取数据并建立字符到索引的映射。
# 假设 text 是读取的文本字符串
chars = sorted(list(set(text)))
char_to_idx = {ch: i for i, ch in enumerate(chars)}
idx_to_char = {i: ch for i, ch in enumerate(chars)}
vocab_size = len(chars)
这段代码获取了数据集中所有唯一的字符,并建立了字符与整数索引之间的双向映射。
随机小批量生成器
接下来,我们实现一个函数来生成随机的小批量数据。
import torch
import random
def data_iter_random(corpus_indices, batch_size, num_steps, device=None):
# 计算可用的子序列数量
num_examples = (len(corpus_indices) - 1) // num_steps
# 随机偏移量
offset = random.randint(0, num_steps)
# 丢弃开头的一些字符
corpus_indices = corpus_indices[offset:]
num_examples = (len(corpus_indices) - 1) // num_steps
example_indices = list(range(num_examples))
random.shuffle(example_indices)
# 返回数据的迭代器
def _data(pos):
return corpus_indices[pos: pos + num_steps]
if device is None:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
for i in range(0, num_examples, batch_size):
batch_indices = example_indices[i: i + batch_size]
X = [_data(j * num_steps) for j in batch_indices]
Y = [_data(j * num_steps + 1) for j in batch_indices]
yield torch.tensor(X, dtype=torch.long, device=device), torch.tensor(Y, dtype=torch.long, device=device)
这个函数会生成 (X, Y) 对,其中 X 是一个子序列,Y 是 X 向右移动一个位置后的子序列。
顺序小批量生成器
然后,我们实现顺序分区的小批量生成器。
def data_iter_consecutive(corpus_indices, batch_size, num_steps, device=None):
if device is None:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
corpus_indices = torch.tensor(corpus_indices, dtype=torch.long, device=device)
data_len = len(corpus_indices)
batch_len = data_len // batch_size
indices = corpus_indices[0: batch_size * batch_len].view(batch_size, batch_len)
epoch_size = (batch_len - 1) // num_steps
for i in range(epoch_size):
i = i * num_steps
X = indices[:, i: i + num_steps]
Y = indices[:, i + 1: i + num_steps + 1]
yield X, Y
这个函数将长序列分成 batch_size 段,然后每次从每段中取出长度为 num_steps 的连续子序列作为一个小批量。
总结 🎯
本节课中我们一起学习了文本预处理的几个关键步骤:
- 分词:我们探讨了字符编码、词级分词和字节对编码等不同方法,理解了它们各自的适用场景。
- 生成小批量:我们学习了两种生成训练数据小批量的方法:随机分区和顺序分区。随机分区样本相对独立但需要重置隐藏状态;顺序分区允许在小批量间传递隐藏状态,通常效果更好。
- 代码实践:我们使用 Python 和 PyTorch 实现了字符映射、随机小批量生成器和顺序小批量生成器,为后续构建和训练语言模型打下了基础。

掌握这些预处理技术是构建高效语言模型的第一步。
课程P98:递归神经网络 (RNN) 入门 🧠
在本节课中,我们将学习递归神经网络(RNN)的基本概念、工作原理及其在序列数据处理中的应用。我们将从回顾潜在变量模型开始,逐步深入到RNN的具体实现,并通过简单的代码示例来理解其核心机制。
回顾:潜在变量模型与序列建模
上一节我们介绍了为什么需要递归模型来处理序列数据。关键区别在于,你可以选择仅使用过去k个观测作为输入,也可以引入一个潜在变量来聚合所有历史信息。

- 仅使用过去k个观测:训练简单,但可能精度有限。
- 使用潜在变量模型:在建模上更灵活,理论上能存储所有过去观测的充分统计量,可能表现更好。
理想情况下,潜在变量能有效压缩从 X1 到 Xt-1 的所有信息,形成一个状态 ht。为了避免为每个时间步显式计算整个历史(计算量巨大),我们设计一个函数 F,使其能递归地更新状态。
其核心递归公式为:
ht = F(ht-1, xt-1)
这意味着当前状态 ht 由前一个状态 ht-1 和前一个观测 xt-1 共同决定。同时,观测 xt 由当前状态 ht 生成。
这种设计与深度学习结合,绕过了传统图模型中复杂且可能低效的精确推断问题,使我们能专注于设计可训练且高效的模型。
从理论到实践:基础RNN模型
理解了潜在变量的概念后,本节我们来看看如何将其工程化为一个具体的、可训练的神经网络模型,即基础的递归神经网络(RNN)。
在最简单的情况下,我们可以如下定义RNN:
-
隐藏状态更新:当前时刻的隐藏状态
ht是前一刻状态ht-1和当前输入xt的函数。
ht = φ(Whh * ht-1 + Wxh * xt + bh)
其中,φ是非线性激活函数(如tanh),Whh和Wxh是权重矩阵,bh是偏置项。 -
输出生成:当前时刻的输出
ot由当前隐藏状态ht经过一个变换得到。
ot = ψ(Who * ht + bo)
其中,ψ是输出激活函数(取决于任务,如Softmax用于分类),Who是权重矩阵,bo是偏置项。
需要区分观测(输入,如单词)和输出(预测目标,如词性标签)。在序列标注等任务中,我们并不总是用输出来预测下一个输入。
代码视角:实现一个简单的RNN
理论模型看起来可能有些抽象,现在让我们从代码的角度来实际理解它,因为其实现真的非常简单。
以下是一个高度简化的RNN前向传播步骤的伪代码,展示了如何按时间步处理序列:
# 初始化隐藏状态
hidden_state = zeros(hidden_size)
# 遍历输入序列中的每个元素
for input_t in input_sequence:
# 结合当前输入和上一个隐藏状态,计算新的隐藏状态
combined = np.dot(Whh, hidden_state) + np.dot(Wxh, input_t) + bh
hidden_state = np.tanh(combined) # 应用非线性激活
# 根据当前隐藏状态计算输出
output_t = np.dot(Who, hidden_state) + bo
# 存储或处理 output_t

这个循环结构清晰地体现了RNN“递归”的本质:状态在时间步之间传递并不断更新。
总结
本节课中,我们一起学习了递归神经网络(RNN)的核心思想。

- 我们从潜在变量模型的动机出发,理解了使用一个持续更新的状态来汇总历史信息的重要性。
- 接着,我们将该思想具体化为基础的RNN模型,定义了隐藏状态更新和输出生成的数学公式。
- 最后,我们通过简单的伪代码揭示了RNN前向传播的过程,看到它本质上是一个在时间维度上展开的循环,结构并不比多层感知机复杂太多。

RNN为处理文本、语音、时间序列等数据提供了强大的基础框架。然而,基础RNN在实践中可能面临梯度消失或爆炸等挑战,这引出了后续更复杂的变体,如LSTM和GRU,我们将在未来的课程中探讨。



课程 P99:Python中的递归神经网络 (RNN) 🧠

在本节课中,我们将学习如何从零开始构建一个简单的递归神经网络 (RNN)。我们将使用字符级语言建模作为示例任务,通过“时间机器”数据集来训练一个能够生成文本的RNN模型。我们将涵盖数据预处理、模型定义、前向传播、训练循环以及文本生成等核心步骤。


数据加载与预处理 📊

首先,我们需要加载数据集并进行预处理。我们使用“时间机器”数据集,并按字符级别进行划分。
以下是加载数据集和导入必要库的代码:



import torch
import torch.nn.functional as F
from torch import nn, optim
# 假设 load_data_time_machine 是一个加载数据集的函数
from utils import load_data_time_machine


接下来,我们使用独热编码 (One-Hot Encoding) 来处理字符。对于字符数量有限且独特的语言(如中文),这种编码方式非常有效。在本例中,词汇表大小约为43。
def one_hot(x, vocab_size):
# 将整数索引转换为独热编码向量
return F.one_hot(x, vocab_size).float()
假设我们有一个包含三个字符的数组 [2, 2, 30],经过独热编码后,每个字符会变成一个长度为43的向量,其中对应索引位置为1,其余为0。

为了处理整个小批量数据,我们定义一个映射函数,将小批量中的所有输入转换为独热编码格式。
def to_one_hot_batch(batch, vocab_size):
# batch 形状: (batch_size, seq_length)
# 返回形状: (batch_size, seq_length, vocab_size)
return one_hot(batch, vocab_size)
例如,如果小批量形状为 (2, 5),那么编码后的数据形状将是 (2, 5, 43)。


模型参数初始化 ⚙️
上一节我们介绍了数据预处理,本节中我们来看看如何初始化RNN模型的参数。
首先,我们设置一些基本参数:
- 输入数量:等于词汇表大小,因为我们使用独热编码。
- 隐藏单元数量:例如512。
- 输出数量:同样等于词汇表大小,因为我们要预测下一个字符。


我们还需要检查是否有可用的GPU。



vocab_size = 43
num_hiddens = 512
num_outputs = vocab_size
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

接下来,我们定义模型参数。一个简单的RNN单元包含以下参数:
W_xh: 输入到隐藏层的权重。W_hh: 隐藏层到隐藏层的权重。b_h: 隐藏层的偏置。W_hq: 隐藏层到输出层的权重。b_q: 输出层的偏置。
我们使用正态分布初始化权重,偏置初始化为零,并为所有参数附加梯度。

def get_params(vocab_size, num_hiddens, device):
num_inputs = vocab_size
# 初始化权重
def normal(shape):
return torch.randn(size=shape, device=device) * 0.01
# 隐藏层参数
W_xh = normal((num_inputs, num_hiddens))
W_hh = normal((num_hiddens, num_hiddens))
b_h = torch.zeros(num_hiddens, device=device)
# 输出层参数
W_hq = normal((num_hiddens, num_outputs))
b_q = torch.zeros(num_outputs, device=device)
# 附加梯度
params = [W_xh, W_hh, b_h, W_hq, b_q]
for param in params:
param.requires_grad_(True)
return params

定义RNN模型 🧱
现在我们已经初始化了参数,接下来定义RNN模型本身。这包括初始化隐藏状态和定义RNN单元的前向传播逻辑。
首先,我们定义一个函数来初始化隐藏状态。这里我们简单地将初始隐藏状态设置为全零。返回一个列表是为了保持接口的一致性,以便将来处理具有多个隐藏状态的模型(如LSTM)。

def init_rnn_state(batch_size, num_hiddens, device):
# 返回一个包含初始隐藏状态的列表
return (torch.zeros((batch_size, num_hiddens), device=device), )
接下来是核心部分:定义RNN单元。该函数接受当前输入、隐藏状态和参数,计算新的隐藏状态和输出。
以下是RNN单元的前向传播公式:
- 新隐藏状态:
H_t = tanh(X_t @ W_xh + H_{t-1} @ W_hh + b_h) - 输出:
O_t = H_t @ W_hq + b_q

def rnn(inputs, state, params):
# inputs 形状: (seq_len, batch_size, vocab_size)
# state 是一个包含隐藏状态的元组
W_xh, W_hh, b_h, W_hq, b_q = params
H, = state
outputs = []
for X in inputs:
# 计算新的隐藏状态
H = torch.tanh(torch.mm(X, W_xh) + torch.mm(H, W_hh) + b_h)
# 计算输出
Y = torch.mm(H, W_hq) + b_q
outputs.append(Y)
# 返回新的隐藏状态和所有时间步的输出
return (H, ), torch.stack(outputs)



这个函数遍历输入序列的每个时间步,依次更新隐藏状态并生成输出。最后返回最终的隐藏状态和整个输出序列。




文本生成预测 🔮
定义好RNN的前向传播后,我们需要一个函数来使用训练好的模型生成文本。这个预测函数接受一个字符串前缀,并生成指定数量的后续字符。

以下是预测函数的步骤概述:
- 初始化隐藏状态。
- 将前缀中的字符依次输入RNN,更新状态(但不输出生成的字符,只是为了让模型获得上下文)。
- 开始生成新字符:将当前预测的字符作为下一步的输入,重复此过程。
def predict_rnn(prefix, num_chars, rnn, params, init_rnn_state,
num_hiddens, vocab_size, device, idx_to_char, char_to_idx):
state = init_rnn_state(1, num_hiddens, device) # 批量大小为1用于生成
output = [char_to_idx[prefix[0]]]
# 用前缀更新隐藏状态
for t in range(len(prefix) + num_chars - 1):
X = to_one_hot_batch(torch.tensor([[output[-1]]], device=device), vocab_size)
X = X.permute(1, 0, 2) # 调整形状以适应rnn函数
(state, ), Y = rnn(X, state, params)
if t < len(prefix) - 1:
# 仍在前缀中,读取下一个字符
output.append(char_to_idx[prefix[t + 1]])
else:
# 开始生成,选择概率最高的字符
output.append(int(Y.argmax(dim=2).reshape(1)))
return ''.join([idx_to_char[i] for i in output])
在模型参数未经训练时,这个函数会生成看似随机的字符序列。






梯度裁剪与训练循环 🏋️


为了稳定训练过程,我们需要实现梯度裁剪。当梯度向量的范数过大时,梯度裁剪可以防止优化步骤过大,导致模型发散。



梯度裁剪的公式如下:如果梯度 g 的L2范数超过阈值 θ,则将其缩放为 g * θ / ||g||。


def grad_clipping(params, theta):
norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params if p.grad is not None))
if norm > theta:
for param in params:
if param.grad is not None:
param.grad[:] *= theta / norm
现在,我们可以构建完整的训练循环。训练RNN与训练其他神经网络类似,但我们需要按顺序处理序列数据,并妥善管理隐藏状态在不同批次间的传递(对于顺序采样)。

以下是训练循环的关键步骤:
- 迭代周期:遍历数据集多次。
- 状态初始化:如果是随机采样,每个小批量开始时重置隐藏状态;如果是顺序采样,则在整个周期内保持状态的连续性(但需在反向传播时断开梯度连接)。
- 前向与反向传播:
- 对小批量数据进行独热编码。
- 运行RNN,得到输出和最终状态。
- 计算损失(通常使用交叉熵损失,用于预测下一个字符)。
- 执行反向传播。
- 应用梯度裁剪。
- 使用优化器(如SGD)更新参数。
- 评估:定期计算困惑度(Perplexity),它是衡量语言模型好坏的标准指标,是交叉熵损失的指数。
def train_epoch_ch8(rnn, train_iter, loss, updater, device, use_random_iter):
state, timer = None, d2l.Timer()
metric = d2l.Accumulator(2) # 训练损失之和, 词元数量
for X, Y in train_iter:
if state is None or use_random_iter:
# 第一次迭代或使用随机采样时,初始化state
state = init_rnn_state(X.shape[0], num_hiddens, device)
else:
# 顺序采样时,从计算图分离状态,避免梯度跨批次传播
if isinstance(state, tuple):
state = (s.detach() for s in state)
else:
state.detach_()
# 前向传播、损失计算、反向传播、梯度裁剪、参数更新...
# ... (具体代码较长,此处省略细节)
return math.exp(metric[0] / metric[1]), metric[1] / timer.stop() # 返回困惑度和速度


在训练过程中,我们可以观察到困惑度逐渐下降,生成的文本从毫无意义变得逐渐连贯。顺序采样通常比随机采样表现更好,因为它能让模型在更长的序列上下文中进行学习。

总结 📝

本节课中我们一起学习了如何从零开始实现一个简单的递归神经网络 (RNN)。
我们首先学习了如何为字符级语言建模任务准备数据,特别是使用独热编码。接着,我们详细讲解了RNN模型参数的初始化方法。然后,我们定义了RNN的核心前向传播逻辑,包括隐藏状态的更新和输出的计算。在此基础上,我们构建了一个文本生成函数,用于模型预测。
为了确保训练稳定性,我们引入了梯度裁剪技术。最后,我们构建了一个完整的训练循环,它能够处理序列数据、管理隐藏状态、计算损失并优化模型参数。

通过本课程,你应该对RNN的基本工作原理和实现细节有了扎实的理解,这是学习更复杂序列模型(如LSTM和GRU)的重要基础。


浙公网安备 33010602011771号