fast-ai-v3-深度学习笔记-全-

fast.ai v3 深度学习笔记(全)

深度学习实践课程:1:深度学习入门 🚀

在本节课中,我们将要学习深度学习的基本概念,并通过一个实际案例——构建一个“识别鸟类”的图像分类器——来展示深度学习的强大与易用性。我们将看到,即使没有复杂的数学知识、海量数据或昂贵的硬件,也能在几分钟内创建一个功能强大的模型。


深度学习:从“不可能”到“两分钟”的飞跃

2015年底,有一幅XKCD漫画将“判断一张照片是否为鸟类”视为“几乎不可能”的任务,并以此作为一个笑话。然而,自那时起,深度学习领域发生了翻天覆地的变化。如今,我们可以在大约两分钟内,免费构建一个完全相同的系统。

构建“是鸟吗?”系统

我们将使用Python和FastAI库来快速构建这个系统。以下是核心步骤的概述:

  1. 获取数据:从互联网下载鸟类和森林的图片作为训练数据。
  2. 准备数据:使用FastAI的DataBlock API来组织数据,指定输入类型(图像)、输出类型(类别)、数据来源和验证集划分方式。
  3. 创建模型:使用预训练的ResNet模型,并通过微调使其适应我们的特定任务。
  4. 训练与预测:在笔记本电脑上训练模型,并用它来预测新图片是否为鸟类。

整个过程代码简洁,无需复杂数学,训练时间不到30秒,最终模型能以极高的准确率识别鸟类。

这个例子表明,创建有趣且实用的深度学习程序并不需要大量代码、数学知识或昂贵的计算资源。在接下来的七周里,我们将深入学习如何做到这一点。


深度学习的现状与潜力 🌟

深度学习的发展日新月异。近期,社区在图像生成和自然语言处理等领域取得了突破性进展。

  • 图像生成:如DALL-E 2、Midjourney等模型,能够根据文本描述生成极具创意和复杂度的图像。许多艺术家和FastAI校友正在将深度学习与艺术创作深度融合。
  • 语言模型:如Google的Pathways语言模型,不仅能回答复杂的文本问题,还能解释其“推理”过程,例如解释一个关于TPU的笑话。

这些进展意味着深度学习正在处理一些我们曾认为计算机在有生之年都无法完成的任务。随之而来的是许多实际和伦理考量,我们将在课程中有所涉及,并推荐大家深入学习Rachel Thomas博士的数据伦理课程。


课程教学方法与理念 🎓

本课程的教学方法与传统技术课程不同,它深受教育研究启发。

  • 从实践开始:我们没有从线性代数和微积分开始,而是直接训练了一个模型。这就像学习体育时,先体验整个游戏的乐趣,再逐步学习具体技巧。
  • 情境化学习:研究表明,在有具体情境的情况下学习效果更好。因此,我们先让你能够构建和部署模型,再随着需求深入理解其背后的原理。
  • 持续反馈:我们使用一个在线的“红黄绿杯”系统来收集学习者的理解情况,以便调整教学节奏。

对于习惯传统技术教育路径的学习者,这种方法起初可能令人不适,但请尽力适应。你会发现,这是掌握实用深度学习技能的高效途径。


为什么选择本课程?👨‍🏫

以下是关于讲师Jeremy Howard的一些背景,以说明本课程内容的基础:

  • 《程序员深度学习》:与Sylvain Gugger合著了这本广受好评的书籍,本课程内容与之紧密相关但呈现方式不同,以符合“多角度学习效果更佳”的教育原则。
  • 行业经验:拥有约30年的机器学习行业经验,曾是Kaggle机器学习竞赛全球排名第一的选手。
  • 创业与贡献:创立了首家专注于医学深度学习的公司Enlitic。与Rachel Thomas共同创立了fast.ai。
  • 研究影响:其工作具有全球影响力,例如在DAWNBench竞赛中展示了如何更快、更便宜地训练大型神经网络。他是ULMFi算法的发明者,该算法被认为是现代NLP革命的两大关键基础之一。
  • 教学成果:自课程第一版起便开始教学,广受欢迎,并被特斯拉、OpenAI等顶尖公司的AI团队用作入职培训材料。

深度学习为何现在可行?🤔

关键在于神经网络可以自动学习特征,而无需人工设计和编码。

  • 传统方法(2012年):例如斯坦福的“计算病理学家”项目,需要跨学科专家团队花费数年时间,手工设计成千上万的特征(如图像中细胞核的关系),然后输入逻辑回归等模型。过程冗长、复杂且依赖专业知识。
  • 深度学习方法:我们只需向神经网络输入原始图像(即像素RGB数值矩阵)和标签。网络通过其层次结构(多层权重矩阵乘加和非线性激活)自动学习从简单到复杂的特征:
    • 第一层可能学会识别边缘、颜色梯度。
    • 更深层则能组合出更复杂的特征,如纹理、图案、物体部件乃至整个物体。

这种自动特征学习的能力,使得解决以往难以想象的问题成为可能。


核心概念:机器学习的基本框架 🧠

Arthur Samuel在20世纪50年代末提出的机器学习基本框架至今仍是核心。我们可以将其可视化如下:

传统程序输入 -> [程序:条件、循环等] -> 输出

机器学习程序
输入 + 参数(权重) -> [模型:数学函数,如神经网络] -> 输出 -> [损失函数:评估输出好坏] -> 更新参数

具体步骤如下:

  1. 初始化:从一个随机权重的模型开始(此时模型无用)。
  2. 前向传播:将输入数据和当前权重输入模型,得到预测输出。
  3. 计算损失:通过损失函数计算预测输出与真实标签之间的差异(即模型有多“糟糕”)。
  4. 参数更新:关键步骤。利用损失值,通过一种算法(如梯度下降)计算如何小幅调整权重,使得损失降低。
  5. 迭代:重复步骤2-4多次。每次迭代,模型都会变得“好一点点”。
  6. 部署:训练完成后,固定权重。此时,训练好的模型就像一个普通函数:输入 -> 训练好的模型 -> 输出,可以轻松集成到任何应用程序中。

神经网络(由多层矩阵乘加和ReLU等激活函数构成)被证明是一种通用函数逼近器。只要网络足够大、数据足够多、训练时间足够长,理论上它可以逼近任何可计算函数。这正是深度学习强大能力的理论基础。


实践工具与环境 🛠️

我们将使用以下工具进行实践:

  • PyTorch:当前学术界和工业界主流的深度学习框架,以其灵活性和动态计算图著称。
  • FastAI库:构建于PyTorch之上,它封装了最佳实践,让我们能用极少的代码实现强大功能。例如,实现AdamW优化器,在纯PyTorch中需要大量代码,而在FastAI中只需一行。
  • Jupyter Notebook:交互式计算环境,非常适合实验、探索和展示。本课程的所有代码、书籍甚至FastAI库本身的源码都使用Notebook编写。我们还会使用RISE扩展将Notebook转换为幻灯片。

对于计算资源,你可以在免费的云平台(如Kaggle、Colab)上运行所有代码,无需强大的本地硬件。


不止于图像分类:广阔的应用领域 🌈

我们在第一课聚焦图像分类,但深度学习模型的应用远不止于此。FastAI提供了统一的API来处理多种数据类型:

  • 图像分割:对图像中的每个像素进行分类(如区分道路、汽车、行人)。代码结构与图像分类惊人地相似。
  • 表格数据分析:处理像电子表格或数据库表这样的结构化数据,用于预测、分类等。同样使用DataLoadersLearner模式。
  • 协同过滤:构建推荐系统的基础。根据用户-物品交互历史(如评分),预测用户可能喜欢的其他物品。

更重要的是,图像分类的技术可以创造性地应用于其他领域。例如:

  • 声音波形转换为频谱图,然后用图像分类器进行声音分类。
  • 时间序列数据转换为图像,再进行模式识别。
  • 将用户的鼠标移动轨迹(点击为点,移动为线,速度为颜色)转化为图像,用于行为分析。

这展示了深度学习的通用性和灵活性。


破除迷思:深度学习其实很亲民 💡

  • 不需要大量数学:本课程将按需介绍必要的数学概念,大部分实践操作不涉及复杂公式。
  • 不需要海量数据:我们的鸟类分类器只用了200张图片。迁移学习等技术使得在小数据集上也能取得优异效果。
  • 不需要昂贵硬件:我们可以在笔记本电脑或免费云GPU上完成训练。
  • 代码量很少:借助像FastAI这样的高级库,可以用非常简洁的代码实现复杂功能,减少错误并遵循最佳实践。

第一课总结与作业 📚

本节课我们一起学习了:

  1. 深度学习如何使“识别鸟类”这类任务从不可能变得简单快捷。
  2. 机器学习的基本框架:通过迭代更新模型参数来最小化损失函数。
  3. FastAI和PyTorch的基本使用方法,以及Jupyter Notebook环境。
  4. 深度学习在图像、表格、推荐系统等多个领域的应用潜力。
  5. 深度学习的亲民特性,它不需要你预先掌握大量数学或拥有海量数据。

你的作业是:

  1. 动手实验:运行课程中的鸟类分类Notebook。尝试修改它,例如:
    • 创建自己的分类器(如“猫 vs 狗”、“披萨 vs 汉堡”)。
    • 尝试使用三个或更多类别。
    • 关键在于动手尝试并完成一个项目
  2. 阅读:阅读《程序员深度学习》第1章。它以不同的方式呈现了相似的内容,能帮助你巩固和理解。
  3. 分享与交流:将你的实验成果发布到课程论坛的“分享你的作品”主题中。过往学员的分享催生了许多创业项目、科研论文和工作机会。
  4. 自测:完成书中第1章末尾的测验题,检验自己的理解。

无论你是Python新手还是经验丰富的开发者,都请勇敢尝试,从实践中学习。我们下节课见!

深度学习实践课程:2:模型部署与数据清洗 🚀

在本节课中,我们将学习如何将训练好的深度学习模型部署到生产环境,并探索一个强大的数据清洗技巧:先训练模型,再清洗数据。我们将使用Hugging Face Spaces和Gradio来快速创建交互式Web应用。


概述 📋

上一节我们学习了如何快速构建一个图像分类器。本节中,我们将看看如何将这个模型变成一个可供他人使用的实际应用。核心步骤包括:数据清洗、模型导出、创建Web界面以及部署。我们将从一个反直觉但极其有效的技巧开始:在清洗数据之前先训练一个模型


数据清洗前的模型训练 🧠

传统流程通常是先清洗数据,再训练模型。但我们将采用一个更高效的方法:先快速训练一个初始模型,然后利用这个模型来帮助我们发现数据中的问题

训练初始模型

我们使用与上节课类似的代码,用DataBlock创建数据加载器并训练一个简单的分类器(例如,区分泰迪熊、灰熊和黑熊)。

from fastai.vision.all import *
path = Path('bears')
bears = DataBlock(
    blocks=(ImageBlock, CategoryBlock),
    get_items=get_image_files,
    splitter=RandomSplitter(valid_pct=0.2, seed=42),
    get_y=parent_label,
    item_tfms=Resize(224))
dls = bears.dataloaders(path)
learn = vision_learner(dls, resnet34, metrics=error_rate)
learn.fine_tune(4)

利用模型识别问题数据

训练完成后,我们可以使用FastAI的ClassificationInterpretation工具来查看模型预测最不准的样本。

interp = ClassificationInterpretation.from_learner(learn)
interp.plot_top_losses(9, figsize=(15,10))

plot_top_losses会展示损失值最高的图片。高损失可能源于两种情况:

  1. 预测错误且置信度高:模型很自信地给出了错误答案。
  2. 预测正确但置信度低:模型猜对了,但非常不确定。

这些样本正是我们需要重点检查的数据点。


使用ImageClassifierCleaner进行交互式清洗 🧹

FastAI提供了一个名为ImageClassifierCleaner的图形化工具,它利用我们刚刚训练的模型来辅助数据清洗。

cleaner = ImageClassifierCleaner(learn)
cleaner

运行上述代码会弹出一个交互界面。你可以:

  • 选择类别(如“teddy”)。
  • 界面会按损失值从高到低显示该类别下的图片。
  • 对于错误标记的图片,你可以将其移动到正确的类别
  • 对于不属于任何类别的无关图片,你可以点击删除

这个工具的核心优势在于,它让我们能够有针对性地检查最可能出问题的数据,而不是盲目地浏览整个数据集。

清洗完成后,运行以下代码应用更改:

for idx, cat in cleaner.change(): shutil.move(str(cleaner.files[idx]), path/cat)
for idx in cleaner.delete(): cleaner.files[idx].unlink()

总结一下这个强大的工作流:我们通过一个快速训练的模型,智能地定位了数据中的噪声和错误,从而实现了高效、精准的数据清洗。


模型部署到生产环境 🌐

数据清洗完毕后,下一步就是将模型部署出去,让其他人也能使用。我们将使用Hugging Face SpacesGradio,它们提供了免费、简单的模型托管和界面创建服务。

第一步:创建Hugging Face Space

  1. 访问 Hugging Face Spaces
  2. 点击“Create new Space”。
  3. 填写空间名称,选择“Gradio”作为SDK,设置许可证为“Apache 2.0”,并创建空间。

第二步:准备模型文件

在Kaggle、Google Colab等免费GPU平台上训练并导出你的模型。

# 在训练完成后,导出模型
learn.export('model.pkl')

下载这个model.pkl文件。

第三步:编写Gradio应用脚本

我们需要创建一个app.py文件,它包含加载模型和处理预测的逻辑。

from fastai.vision.all import *
import gradio as gr

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/ee7b9a2faf685413bc8869fb66d9d516_36.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/ee7b9a2faf685413bc8869fb66d9d516_38.png)

# 1. 加载导出的模型
learn = load_learner('model.pkl')

# 2. 定义预测函数
def classify_image(img):
    # 模型预测
    pred, pred_idx, probs = learn.predict(img)
    # 将结果格式化为Gradio需要的字典形式
    return {learn.dls.vocab[i]: float(probs[i]) for i in range(len(learn.dls.vocab))}

# 3. 创建Gradio界面
image = gr.Image(type="pil", label="上传图片")
label = gr.Label(num_top_classes=3, label="预测结果")
examples = ['dog.jpg', 'cat.jpg', 'dunno.jpg']

# 4. 启动界面
iface = gr.Interface(fn=classify_image, inputs=image, outputs=label, examples=examples)
iface.launch()

关键点:如果模型在训练时使用了自定义的标签函数(如is_cat),你必须在app.py中重新定义这个函数,因为导出的模型不包含源代码。

第四步:推送文件到Space

model.pklapp.py文件上传到你的Hugging Face Space仓库中。你可以使用Git命令、GitHub Desktop或VS Code的Git功能来完成。

git add .
git commit -m "Add model and app"
git push

推送完成后,Hugging Face会自动构建并发布你的应用。几分钟后,任何人都可以通过生成的URL访问你的图像分类器了!


进阶:使用JavaScript API构建自定义前端 🛠️

Gradio界面适合快速原型开发。如果你需要更定制化的用户体验,可以利用Hugging Face Spaces提供的API端点,用JavaScript构建自己的网页。

调用API

每个Gradio Space都自带一个API。你可以在Space页面点击“View API”查看调用方式。通常,你可以通过向一个URL发送POST请求(包含图片数据)来获得模型的JSON格式预测结果。

简单HTML/JavaScript示例

以下是一个极简的HTML文件,它调用部署好的模型API:

<!DOCTYPE html>
<html>
<body>
  <input type="file" id="file-input" accept="image/*">
  <div id="result"></div>

  <script>
    const API_URL = "https://your-username.hf.space/run/predict";

    document.getElementById('file-input').addEventListener('change', function(e) {
      const file = e.target.files[0];
      const reader = new FileReader();
      reader.onload = function(e) {
        const data = e.target.result;
        // 调用API
        fetch(API_URL, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ "data": [data] })
        })
        .then(response => response.json())
        .then(data => {
          // 显示结果
          document.getElementById('result').innerHTML = `预测结果: ${JSON.stringify(data.data)}`;
        });
      };
      reader.readAsDataURL(file);
    });
  </script>
</body>
</html>

部署静态网站

你可以将HTML/JS/CSS文件放在GitHub Pages上免费托管。使用FastPages可以更轻松地创建和部署基于Jekyll的网站。

  1. 访问 FastPages模板 生成你自己的仓库。
  2. 克隆仓库到本地,将你的网页文件(如index.html)放入。
  3. 推送更改到GitHub,你的网站就会自动发布在 https://你的用户名.github.io/仓库名


本地开发环境设置 💻

为了在本地运行Jupyter Notebook和进行开发,我们推荐使用Mamba(一个更快的Conda替代品)来管理Python环境。

简易安装步骤

  1. 安装Mambaforge:使用fastsetup脚本可以一键安装。
  2. 创建环境并安装库
    mamba create -n fastai python=3.9
    mamba activate fastai
    mamba install -c fastchan fastai
    
  3. 安装Jupyter Notebook扩展(可选但推荐):
    pip install jupyter_contrib_nbextensions
    
    这可以提供目录导航、可折叠标题等实用功能。


总结 🎯

本节课中我们一起学习了:

  1. 创新的数据清洗流程:先训练一个简单模型,再利用ClassificationInterpretationImageClassifierCleaner智能定位并清洗问题数据,这比传统手动浏览高效得多。
  2. 完整的模型部署流水线:从导出模型(learn.export)到使用Hugging Face Spaces和Gradio创建免费的、可交互的Web应用。
  3. 自定义前端开发:了解了如何通过Gradio Space的API,用简单的HTML和JavaScript构建更个性化的用户界面,并利用GitHub Pages进行免费部署。
  4. 本地环境搭建:介绍了使用Mamba配置稳定Python开发环境的最佳实践。

现在,你不仅能够构建深度学习模型,还能将它变成任何人都可以使用的实际产品。在下一课中,我们将转向自然语言处理领域,并深入探索模型是如何通过“随机梯度下降”等机制进行学习的。

深度学习实践课程:3:神经网络基础与优化

在本节课中,我们将学习神经网络背后的核心数学原理,包括梯度下降、矩阵乘法以及如何从零开始构建一个简单的模型。我们将通过直观的例子和实际应用来理解这些概念。


概述

本节课我们将深入探讨神经网络的工作原理。我们将从拟合一个简单的二次函数开始,逐步引入损失函数、梯度下降和矩阵乘法等核心概念。最后,我们将在Microsoft Excel中构建一个完整的神经网络模型,以直观地理解整个过程。


课程反馈与学习建议

我们进行了一次快速调查,以了解大家对课程进度的感受。超过一半的参与者认为课程节奏适中。对于其余参与者,有些人认为课程进度稍慢,有些人则认为稍快。总体而言,这是我们能做到的最佳平衡。

前两节课的节奏对于已经熟悉基础技术的学员来说较为轻松。后续课程将更多地深入基础知识。今天我们将讨论矩阵乘法、梯度和链式法则等内容。对于数学背景较强的学员,这节课可能会更舒适,反之亦然。

请记住,官方课程更新帖子和课程网站上包含所有最新信息。观看课程视频时,如果遇到问题,很可能已经有人提出过类似问题。因此,请务必先搜索论坛并查看常见问题。如果找不到答案,欢迎在论坛上提问。

课程网站上还有一个“第0课”,它大量借鉴了Raex的书籍《Meta Learn》,其中包含了许多关于学习科学的建议。我强烈推荐观看第0课,除非你对从零开始设置Linux系统不感兴趣。

学习FastAI课程的基本方法是:首先完整观看讲座视频,然后带着暂停回看,同时运行笔记本代码。这样可以更好地理解代码的目标。建议运行书中的每个代码单元格,尝试修改输入和输出以理解其工作原理,然后尝试复现结果。最后,尝试使用不同的数据集重复整个过程。这是一个具有挑战性的目标,但能证明你已经掌握了知识。

在FastAI书籍仓库中,有一个名为“clean”的文件夹,其中包含所有章节的代码,但移除了除标题外的所有文本和输出。这是测试你对章节理解的好方法。在运行每个单元格之前,尝试思考它的作用和可能的输出。如果不确定,可以跳回带有文本的版本来提醒自己。

虽然这是自学,但研究表明,与他人一起学习更容易坚持下去。论坛是寻找和创建学习小组的好地方。论坛上还有我们Discord服务器的链接,那里也有一些学习小组。如果没有适合你水平、地区或时区的学习小组,可以创建一个。

本周论坛上有很多精彩的活动。我使用论坛的摘要功能抓取了投票最高的内容。例如,有人创建了一个漫威角色检测器、一个石头剪刀布游戏(电脑总是输)、一个埃隆·马斯克检测器,以及一个根据航拍照片预测平均温度的项目(在布里斯班预测误差在1.5摄氏度以内)。还有恐龙识别、通过面部表情选择路径的冒险游戏、音乐流派分类等有趣项目。Brian Smith创建了一个在手机上运行的Microsoft Power App应用程序。艺术运动分类器项目引发了关于不同艺术运动之间相似性的有趣讨论。还有一个涂黑检测器项目,附有完整的推文线程和博客文章。


模型优化与平台介绍

在深入神经网络机制之前,我将快速展示一些提高神经网络准确性的技巧。我创建了一个宠物品种检测器(不仅仅是区分猫狗,而是识别具体品种)。这个项目发布在Hugging Face Spaces上,你可以下载并查看我的代码。

今天我将使用一个不同的平台——Paper Space。它类似于Kaggle和Google Colab,但有一个名为“Gradient Notebooks”的产品。在我看来,这是目前运行本课程和进行实验的最佳平台。它是一个真实的计算机,不像Colab或Kaggle那样是虚拟环境。你可以打开完整的Jupyter Lab或Jupyter Notebooks界面。对于不熟悉终端的初学者来说,Jupyter Lab是一个很好的环境。你可以通过图形界面完成所有操作,例如文件浏览、Git仓库管理等。Paper Space提供免费GPU,你也可以每月支付约8-9美元获得更好的GPU和几乎无限的运行时间。最重要的是,它有持久存储,不像Colab需要将数据保存到Google Drive。

我将添加所有这些功能的详细教程。如果你有兴趣充分利用这个平台,请查看这些教程。


训练与部署的核心概念

我希望大家从第2课中学到的关键不是如何使用特定平台训练模型并将其部署到JavaScript或在线应用程序中,而是理解核心概念。主要有两部分:训练部分和部署部分。

训练结束后,你会得到一个名为model.pkl的文件。这个文件接受输入并基于训练好的模型输出结果。一旦训练完成,你通常不需要GPU,因为推理过程很快。然后就是单独的部署步骤。

我将展示如何训练我的宠物分类器。我有两个IPython笔记本:一个是用于推理和生产的app,另一个是用于训练模型的笔记本。首先,我创建图像数据加载器,检查数据,训练一个ResNet34模型,得到了7%的准确率,这相当不错。

然后,我尝试通过寻找更好的架构来改进模型。PyTorch Image Models库中有超过500种架构。我们关心的是三件事:速度、内存使用和准确性。我和Ross Whiteman抓取了所有模型,并绘制了图表。X轴是每个样本的秒数(速度),Y轴是准确性。一般来说,我们希望模型位于图表的左上角(又快又准)。

我们主要使用ResNet,但ResNet34现在已经不是最先进的了。我们可以看看图表上方的模型,例如ConvNeXt模型。其中一些模型在准确性和速度上表现优异。我尝试了ConvNeXt模型,训练时间从20秒增加到27秒,但准确率从7.2%提高到5.5%,相对提升了约30%。这是一个巨大的进步。如果你不确定使用什么架构,可以尝试这些ConvNeXt模型。模型名称中的“tiny”、“small”、“large”等表示模型大小,影响内存使用和速度。有些模型在包含22,000个类别的ImageNet数据集上训练,通常在自然物体照片上更准确。

训练完成后,我导出了模型。要将其转换为应用程序,只需像上周一样加载学习器并调用预测。学习器输出37个数字的概率列表,对应37个猫狗品种。顺序由词汇对象决定,我们可以获取类别列表并将其与概率压缩成字典。

这个神奇的模型文件实际上是一个学习器对象,包含两个主要部分:预处理步骤和训练好的模型。我们可以通过.model属性获取模型。模型包含许多层,是一个深度模型。我们可以使用PyTorch的get_submodule方法查看特定层。每个层都有代码(数学函数)和参数(大量数字)。这些数字是通过优化过程得到的。


神经网络基础数学

为了理解这些数字如何识别图像内容,我们将通过一个Kaggle笔记本探讨神经网络的实际工作原理。机器学习模型是拟合数据到函数的工具。我们从一个非常灵活的函数(神经网络)开始,并让它识别数据中的模式。

让我们从一个更简单的例子开始:二次函数。我们创建一个函数f(x) = 3x² + 2x + 1,并绘制它。假设我们不知道真实的数学函数,我们试图从一些数据中重建它。为了测试不同的二次函数,我们定义一个通用形式quadratic(a, b, c, x) = ax² + bx + c。使用Python的partial函数,我们可以固定系数并创建特定的二次函数。

我们生成一些带有噪声的数据,并尝试重建原始二次函数。我们可以创建一个交互式绘图函数,通过滑块调整系数,观察函数如何拟合数据。为了更严谨地评估拟合效果,我们需要一个损失函数,例如均方误差(MSE)。损失函数告诉我们模型的预测与实际值之间的差异。

我们可以手动调整系数以最小化损失,但更好的方法是计算导数。导数告诉我们当输入增加时,输出是增加还是减少,以及变化多少。PyTorch可以自动计算导数。我们创建一个函数,将二次函数的系数作为输入,并返回损失。然后,我们将系数包装在一个张量中,并设置requires_grad=True以收集梯度。调用.backward()后,我们可以访问梯度,并按照负梯度方向更新系数,乘以一个小的学习率。这个过程称为梯度下降。

通过多次迭代,我们可以自动优化系数。这就是优化器的基础。所有深度学习优化器都基于这个原理。


构建无限灵活的函数

我们无法仅使用二次函数来建模复杂关系,但我们可以构建一个无限灵活的函数。关键组件是修正线性单元(ReLU)。ReLU是一个线性函数,后跟一个将负数裁剪为零的操作。通过组合多个ReLU函数,我们可以创建任意复杂的函数。如果有足够多的ReLU单元,我们可以近似任何函数。同样的思想可以扩展到多个输入维度。通过梯度下降优化这些参数,我们可以构建强大的模型。

深度学习本质上就是使用梯度下降优化参数,使由许多ReLU单元(或类似组件)组成的函数拟合数据。后续的所有改进都是为了使其更快、需要更少的数据。


矩阵乘法:核心计算技巧

在神经网络中,我们需要进行大量的线性运算。矩阵乘法是一种高效的数学运算,可以一次性处理所有这些计算。矩阵乘法涉及将行与列相乘并求和。GPU具有专门的核心(张量核心)来加速矩阵乘法。

我们将通过一个实际例子在电子表格中构建完整的机器学习模型。FastAI以使用电子表格进行深度学习而闻名。我将使用Kaggle上的泰坦尼克号数据集。数据集包含乘客信息,我们想预测他们是否幸存。

首先,我删除了一些不重要的列(如乘客姓名和ID),并将分类变量(如性别、登船港口)转换为二进制数值变量。对于年龄和票价等连续变量,我进行了归一化处理,使它们处于相似的范围。对于票价,我使用了对数变换以处理极端分布。

然后,我添加了一个全为1的列作为常数项。接下来,我使用随机初始化的系数计算线性模型的预测值,并计算均方误差损失。在Excel中,我可以使用“规划求解”工具(一个梯度下降优化器)来最小化损失。优化后,我们得到了一个回归模型。

为了将其变成神经网络,我添加了第二组系数和ReLU激活函数。通过优化所有系数,我们得到了一个深度神经网络模型,其损失低于简单的回归模型。

最后,我使用矩阵乘法重新实现了相同的模型,得到了相同的结果。矩阵乘法是深度学习中关键的基础数学运算。


自然语言处理预览

在下一节课中,我们将探讨自然语言处理(NLP)。NLP涉及处理文本数据,例如文档分类、情感分析、作者识别等任务。我们将使用Hugging Face Transformers库,因为它提供了高质量的模型和技术。虽然它的高级API不如FastAI友好,但通过使用它,我们可以更深入地理解底层细节。

在下一节课之前,你可以查看“NLP绝对初学者入门”笔记本,并探索我们将要使用的数据。数据来自一个Kaggle竞赛,目标是判断两个文本概念是否指向同一事物。这是一个分类任务,我们将借此机会讨论验证集和评估指标这两个机器学习中的重要主题。


总结

在本节课中,我们一起学习了神经网络的基础数学原理,包括梯度下降、矩阵乘法和ReLU函数。我们通过简单的二次函数拟合和电子表格中的实际模型构建,直观地理解了这些概念。我们还预览了下一节课的自然语言处理内容。记住,深度学习的核心是使用梯度下降优化参数,使灵活的函数拟合数据。

深度学习实践课程:4:自然语言处理入门

在本节课中,我们将学习自然语言处理的基础知识,特别是如何使用预训练模型进行微调。我们将使用Hugging Face Transformers库,而不是FastAI库,以便大家能够熟悉不同的深度学习工具。通过本教程,你将学会如何将文本数据转换为模型可以理解的格式,并训练一个模型来解决实际问题。


概述

自然语言处理是深度学习中的一个重要领域,涉及文本数据的分析和处理。本节课我们将学习如何使用预训练模型进行微调,以解决文本分类问题。我们将通过一个Kaggle竞赛实例,详细讲解数据预处理、模型训练和评估的完整流程。


什么是预训练模型和微调

上一节我们介绍了深度学习的基本概念,本节中我们来看看预训练模型和微调的具体含义。

预训练模型是一组已经训练好的参数,这些参数在某些任务上已经表现良好。微调是在这些参数的基础上,针对特定任务进行进一步训练的过程。这类似于调整一组滑块,其中一些滑块的位置已经接近最优,而另一些则需要进一步调整。

微调的过程可以总结为以下步骤:

  1. 使用大量无标签数据训练一个语言模型。
  2. 使用特定领域的数据进一步训练语言模型。
  3. 针对具体任务(如分类)微调模型。

数据预处理

在开始训练模型之前,我们需要对数据进行预处理。以下是数据预处理的主要步骤:

1. 加载数据

我们使用Pandas库加载CSV格式的数据文件。Pandas是Python中处理表格数据的核心库之一。

import pandas as pd
df = pd.read_csv('data.csv')

2. 数据探索

使用describe方法查看数据的基本信息,包括唯一值数量、最常见值等。

df.describe(include='object')

3. 数据格式化

将多个文本字段合并为一个输入字段,以便模型处理。例如,将“锚点”、“目标”和“上下文”字段合并为一个字符串。

df['input'] = 'text1: ' + df['anchor'] + '; text2: ' + df['target'] + '; text3: ' + df['context']

文本转换为数字

神经网络只能处理数字数据,因此我们需要将文本转换为数字。这一过程分为两个步骤:分词和数值化。

1. 分词

分词是将文本拆分为更小的单元(如单词或子词)的过程。我们使用Hugging Face的自动分词器,确保与预训练模型的分词方式一致。

from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained('model_name')
tokens = tokenizer("good day, folks")

2. 数值化

数值化是将分词后的单元转换为数字ID的过程。每个唯一的词或子词在词汇表中都有一个对应的ID。

tokenized_data = tokenizer(df['input'].tolist(), padding=True, truncation=True)

训练集、验证集和测试集

在机器学习中,将数据分为训练集、验证集和测试集是非常重要的。训练集用于训练模型,验证集用于调整超参数和评估模型性能,测试集用于最终评估模型的泛化能力。

以下是划分数据集的示例代码:

from datasets import Dataset
dataset = Dataset.from_pandas(df)
split_dataset = dataset.train_test_split(test_size=0.25)

模型训练

我们使用Hugging Face Transformers库中的Trainer类来训练模型。以下是训练模型的主要步骤:

1. 定义模型

使用自动模型类创建一个适合序列分类的模型。

from transformers import AutoModelForSequenceClassification
model = AutoModelForSequenceClassification.from_pretrained('model_name', num_labels=1)

2. 配置训练参数

设置训练的超参数,如学习率、批次大小和训练轮数。

from transformers import TrainingArguments
training_args = TrainingArguments(
    output_dir='./results',
    learning_rate=2e-5,
    per_device_train_batch_size=128,
    num_train_epochs=3
)

3. 创建训练器

将模型、数据和训练参数组合到训练器中。

from transformers import Trainer
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=split_dataset['train'],
    eval_dataset=split_dataset['test'],
    compute_metrics=compute_metrics
)

4. 开始训练

调用train方法开始训练模型。

trainer.train()

模型评估

在训练过程中,我们需要评估模型的性能。对于分类任务,常用的评估指标包括准确率和皮尔逊相关系数。

以下是计算皮尔逊相关系数的示例代码:

import numpy as np
from scipy.stats import pearsonr

def compute_metrics(eval_pred):
    predictions, labels = eval_pred
    predictions = predictions.flatten()
    labels = labels.flatten()
    return {'pearson': pearsonr(predictions, labels)[0]}

生成预测结果

训练完成后,我们可以使用模型生成预测结果。以下是生成预测结果的示例代码:

predictions = trainer.predict(test_dataset)
pred_values = predictions.predictions.flatten()

处理异常值

在数据中,异常值可能会影响模型的性能。处理异常值时,不应直接删除,而应深入分析其来源和影响。例如,在加州房价数据集中,异常值可能代表不同类型的住房,需要单独处理。


自然语言处理的应用与挑战

自然语言处理技术在许多领域都有广泛应用,如情感分析、文本分类和自动生成文本。然而,这些技术也可能被滥用,例如生成虚假信息或操纵舆论。因此,我们需要在利用这些技术的同时,警惕其潜在风险。


总结

本节课我们一起学习了自然语言处理的基础知识,包括预训练模型的微调、数据预处理、模型训练和评估。通过一个Kaggle竞赛实例,我们详细讲解了如何使用Hugging Face Transformers库解决实际问题。希望这些知识能够帮助你在自然语言处理领域取得进一步进展。

深度学习实践课程:5:从零构建线性模型与神经网络

概述

在本节课中,我们将学习如何从零开始,使用Python和PyTorch构建并训练一个线性模型和一个神经网络。我们将以泰坦尼克号数据集为例,涵盖数据预处理、模型构建、训练和评估的全过程。通过本课,你将理解深度学习模型背后的基本原理,并掌握使用PyTorch进行基础建模的技能。


数据准备与探索

首先,我们需要获取并探索泰坦尼克号数据集。这个数据集包含了每位乘客的信息以及他们是否幸存。

import pandas as pd
import numpy as np
import torch

# 读取数据
df = pd.read_csv('titanic.csv')
print(df.head())
print(df.tail())
print(df.shape)

接下来,我们检查数据中是否存在缺失值。

# 检查缺失值
print(df.isna().sum())

以下是处理缺失值的步骤:

  1. 识别缺失值:使用 df.isna().sum() 可以统计每列的缺失值数量。
  2. 填充缺失值:一个简单有效的方法是使用每列的众数(出现频率最高的值)来填充缺失值。
# 获取每列的众数
mode = df.mode().iloc[0]
# 用众数填充缺失值
df.fillna(mode, inplace=True)
# 再次检查缺失值
print(df.isna().sum())

对于数值型变量,我们可以使用 describe 方法快速了解其分布。

# 描述数值型变量
print(df.describe())

我们发现 Fare(票价)列呈现长尾分布。对于这类数据,通常取对数可以使其分布更集中。

# 对票价取对数
df['LogFare'] = np.log(df['Fare'] + 1)

对于分类变量(如 SexEmbarked),我们需要将其转换为模型可以处理的数值形式。常用的方法是创建虚拟变量(哑变量)。

# 为分类变量创建虚拟变量
df = pd.get_dummies(df, columns=['Sex', 'Embarked', 'Pclass'])

现在,我们已经将数据预处理完毕,可以将其转换为PyTorch张量,以便进行后续计算。

# 定义自变量(特征)和因变量(标签)
indep_vars = ['Age', 'SibSp', 'Parch', 'LogFare'] + [col for col in df.columns if col.startswith(('Sex_', 'Embarked_', 'Pclass_'))]
dep_var = 'Survived'

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/eec0150d9060fac6cc17d75a9fe5d165_23.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/eec0150d9060fac6cc17d75a9fe5d165_25.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/eec0150d9060fac6cc17d75a9fe5d165_27.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/eec0150d9060fac6cc17d75a9fe5d165_29.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/eec0150d9060fac6cc17d75a9fe5d165_31.png)

# 转换为PyTorch张量
indep = torch.tensor(df[indep_vars].values, dtype=torch.float)
dep = torch.tensor(df[dep_var].values, dtype=torch.float)


构建线性模型

上一节我们准备好了数据,本节中我们来看看如何构建一个基础的线性模型。线性模型的核心是矩阵乘法。

首先,我们需要初始化模型的系数(权重)。

# 初始化随机系数
torch.manual_seed(42) # 设置随机种子以保证结果可复现
coefs = torch.rand(indep.shape[1]) - 0.5
coefs.requires_grad_() # 告诉PyTorch我们需要计算这些系数的梯度

为了优化过程更稳定,我们通常需要对特征进行归一化处理。

# 特征归一化(除以最大值)
indep_max = indep.max(dim=0).values
indep_norm = indep / indep_max

现在,我们可以定义模型的前向传播过程,即计算预测值。

def calc_preds(coefs, indep):
    '''计算线性模型的预测值'''
    return indep @ coefs

def calc_loss(coefs, indep, dep):
    '''计算损失(平均绝对误差)'''
    preds = calc_preds(coefs, indep)
    return torch.abs(preds - dep).mean()

为了使用梯度下降优化系数,我们需要计算损失函数关于系数的梯度,并更新系数。

def update_coefs(coefs, lr):
    '''根据梯度更新系数'''
    coefs.sub_(coefs.grad * lr)
    coefs.grad.zero_() # 清空梯度,为下一次计算做准备

def one_epoch(coefs, lr):
    '''执行一个训练周期'''
    loss = calc_loss(coefs, indep_norm, dep)
    loss.backward() # 反向传播,计算梯度
    update_coefs(coefs, lr)
    print(f'Loss: {loss:.3f}')
    return loss

接下来,我们将数据划分为训练集和验证集,并开始训练模型。

from fastai.data.transforms import RandomSplitter
# 划分训练集和验证集
trn_split, val_split = RandomSplitter(seed=42)(df)
trn_indep, val_indep = indep_norm[trn_split], indep_norm[val_split]
trn_dep, val_dep = dep[trn_split], dep[val_split]

# 训练模型
def train_model(epochs=30, lr=0.01):
    torch.manual_seed(42)
    coefs = (torch.rand(indep_norm.shape[1]) - 0.5).requires_grad_()
    for i in range(epochs):
        one_epoch(coefs, lr)
    return coefs

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/eec0150d9060fac6cc17d75a9fe5d165_58.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/eec0150d9060fac6cc17d75a9fe5d165_60.png)

coefs = train_model()

训练完成后,我们可以查看每个特征对应的系数,并评估模型在验证集上的准确率。

# 查看系数
for var, coef in zip(indep_vars, coefs.detach()):
    print(f'{var}: {coef:.3f}')

# 计算准确率
def calc_accuracy(coefs, indep, dep):
    preds = calc_preds(coefs, indep) > 0.5
    return (preds == dep).float().mean()

acc = calc_accuracy(coefs, val_indep, val_dep)
print(f'Validation Accuracy: {acc:.3f}')

改进:使用Sigmoid函数

上一节我们构建了基础的线性模型,但它的输出范围没有限制。对于二分类问题(幸存/未幸存),我们希望模型的输出是一个介于0和1之间的概率。本节中我们引入Sigmoid函数来实现这一点。

Sigmoid函数的公式为:
σ(x) = 1 / (1 + e^(-x))

它将任何实数映射到(0, 1)区间。我们只需在原始线性模型的输出上应用Sigmoid函数。

def calc_preds_sigmoid(coefs, indep):
    '''计算应用Sigmoid后的预测值'''
    return torch.sigmoid(indep @ coefs)

def calc_loss_sigmoid(coefs, indep, dep):
    preds = calc_preds_sigmoid(coefs, indep)
    return torch.abs(preds - dep).mean()

更新训练函数以使用新的预测和损失计算方式。通常,使用Sigmoid后,我们可以使用更大的学习率,模型也更容易优化。

# 使用Sigmoid重新训练
coefs_sigmoid = train_model(lr=0.5) # 学习率可以增大
acc_sigmoid = calc_accuracy(coefs_sigmoid, val_indep, val_dep)
print(f'Validation Accuracy with Sigmoid: {acc_sigmoid:.3f}')

构建神经网络

线性模型是神经网络的基础。本节中,我们将扩展思路,构建一个包含隐藏层的简单神经网络。神经网络通过在层与层之间引入非线性激活函数(如ReLU),能够学习更复杂的模式。

首先,我们需要初始化更多层的系数。

def init_coefs(n_hidden=20):
    # 第一层系数:从输入层到隐藏层
    l1 = (torch.rand(indep.shape[1], n_hidden) - 0.5) / n_hidden
    # 第二层系数:从隐藏层到输出层
    l2 = (torch.rand(n_hidden, 1) - 0.5) * 0.1
    # 输出层常数项
    const = torch.rand(1) - 0.5
    # 为所有参数设置需要梯度
    l1.requires_grad_()
    l2.requires_grad_()
    const.requires_grad_()
    return l1, l2, const

接下来,定义神经网络的前向传播过程。

def calc_preds_nn(coefs, indep):
    l1, l2, const = coefs
    # 第一层:线性变换 + ReLU激活
    res = indep @ l1
    res = res.clamp_min(0) # ReLU: 将负数置为0
    # 第二层:线性变换 + Sigmoid激活
    res = res @ l2 + const
    return torch.sigmoid(res).squeeze() # 压缩维度以匹配标签形状

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/eec0150d9060fac6cc17d75a9fe5d165_86.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/eec0150d9060fac6cc17d75a9fe5d165_88.png)

def calc_loss_nn(coefs, indep, dep):
    preds = calc_preds_nn(coefs, indep)
    return torch.abs(preds - dep).mean()

更新系数时,我们需要遍历所有层。

def update_coefs_nn(coefs, lr):
    for coef in coefs:
        coef.sub_(coef.grad * lr)
        coef.grad.zero_()

现在,我们可以训练这个神经网络了。

def train_nn(epochs=30, lr=0.01, n_hidden=20):
    torch.manual_seed(42)
    coefs = init_coefs(n_hidden)
    for i in range(epochs):
        loss = calc_loss_nn(coefs, trn_indep, trn_dep)
        loss.backward()
        update_coefs_nn(coefs, lr)
        if i % 5 == 0:
            print(f'Epoch {i}: Loss = {loss:.3f}')
    return coefs

coefs_nn = train_nn()
acc_nn = calc_accuracy(lambda c, i: calc_preds_nn(c, i) > 0.5, coefs_nn, val_indep, val_dep)
print(f'Neural Network Validation Accuracy: {acc_nn:.3f}')

使用FastAI简化流程

从零构建模型有助于理解原理,但在实际项目中,使用高级框架可以极大提升效率。本节中,我们使用FastAI库来快速构建并训练一个表格数据模型。

FastAI自动处理了许多繁琐的步骤,如缺失值填充、分类变量编码、数据分割等。

from fastai.tabular.all import *
# 定义预处理步骤
procs = [Categorify, FillMissing, Normalize]
# 定义分类变量和连续变量
cat_vars = ['Pclass', 'Sex', 'Embarked']
cont_vars = ['Age', 'SibSp', 'Parch', 'Fare']
# 创建数据加载器
dls = TabularDataLoaders.from_df(df, path='.', procs=procs,
                                 cat_names=cat_vars, cont_names=cont_vars,
                                 y_names='Survived', splits=RandomSplitter(seed=42)(df))
# 创建学习器并寻找合适的学习率
learn = tabular_learner(dls, layers=[10,10], metrics=accuracy)
learn.lr_find()
# 训练模型
learn.fit_one_cycle(5, lr_max=0.03)

使用FastAI,我们可以轻松进行预测并提交结果。

# 对测试集进行预测
test_df = pd.read_csv('test.csv')
# 应用相同的预处理
test_dl = learn.dls.test_dl(test_df)
preds, _ = learn.get_preds(dl=test_dl)
# 准备提交文件
submission = pd.DataFrame({'PassengerId': test_df['PassengerId'],
                           'Survived': (preds[:,1] > 0.5).int()})
submission.to_csv('submission.csv', index=False)

模型集成

单个模型的性能可能有限。一种提升性能的有效方法是模型集成,即结合多个模型的预测结果。最简单的方法是创建多个相同的模型,使用不同的随机初始化进行训练,然后对它们的预测取平均。

def ensemble(n_models=5):
    all_preds = []
    for i in range(n_models):
        learn = tabular_learner(dls, layers=[10,10], metrics=accuracy)
        learn.fit_one_cycle(5, lr_max=0.03)
        preds, _ = learn.get_preds(dl=test_dl)
        all_preds.append(preds[:,1]) # 取幸存类别的概率
    # 对多个模型的预测取平均
    avg_preds = torch.stack(all_preds).mean(dim=0)
    return avg_preds

ensemble_preds = ensemble()


总结

本节课中我们一起学习了深度学习模型构建的核心流程。我们从泰坦尼克号数据集出发,逐步完成了以下内容:

  1. 数据预处理:包括处理缺失值、对长尾分布取对数、将分类变量转换为虚拟变量。
  2. 构建线性模型:从零开始使用PyTorch张量和矩阵乘法实现线性模型,并应用梯度下降进行训练。
  3. 引入Sigmoid函数:改进模型,使其输出符合二分类问题的概率形式。
  4. 构建神经网络:增加了隐藏层和ReLU激活函数,构建了更复杂的模型。
  5. 使用FastAI库:利用高级框架简化了整个建模流程,包括自动预处理和便捷的训练接口。
  6. 模型集成:通过组合多个模型的预测来提升最终性能。

通过本课,你不仅理解了线性模型和神经网络的基本原理,也掌握了从零实现和使用高级工具链的两种实践路径。这些技能是构建更复杂深度学习项目的坚实基础。

深度学习实践课程:6:表格数据与决策树 🌳

在本节课中,我们将学习如何处理表格数据,并深入探讨决策树和随机森林这两种强大的机器学习算法。我们将从最简单的规则开始,逐步构建更复杂的模型,并了解如何评估和解释它们。

从单一规则到决策树

上一节我们介绍了泰坦尼克号数据集,并尝试通过单一规则(如性别)来预测乘客的生存情况。我们通过计算每个分组内生存率的标准差来衡量规则的好坏,并创建了一个简单的界面来手动寻找最佳分割点。

我们成功找到了一个非常有效的单一规则:根据性别进行分割。然而,单一规则通常不足以解决复杂问题。我们可以进一步思考:能否在每个分组内再次进行分割?

例如,在泰坦尼克号数据集中,我们首先根据性别将乘客分为男性和女性两组。然后,我们可以分别对男性和女性乘客再次应用相同的规则寻找最佳分割点。

以下是实现这一过程的关键步骤:

# 将数据按性别分割
males = df[df['Sex'] == 'male']
females = df[df['Sex'] == 'female']

# 对男性乘客寻找最佳分割点
find_best_split(males, 'Survived')
# 对女性乘客寻找最佳分割点
find_best_split(females, 'Survived')

通过这种方法,我们为男性乘客找到了年龄(是否大于6岁)作为最佳预测因子,为女性乘客找到了船舱等级(是否为一等舱)作为最佳预测因子。这样就形成了一个包含四个“叶节点”的决策树。

使用Scikit-Learn构建决策树

手动构建多层决策树非常繁琐。我们可以使用Scikit-Learn库中的DecisionTreeClassifier类来自动完成这个过程。

以下是创建和可视化决策树的代码:

from sklearn.tree import DecisionTreeClassifier

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/31da51d8b2f299b859df5d61d2cb319e_99.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/31da51d8b2f299b859df5d61d2cb319e_101.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/31da51d8b2f299b859df5d61d2cb319e_103.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/31da51d8b2f299b859df5d61d2cb319e_105.png)

# 创建一个最多有4个叶节点的决策树
m = DecisionTreeClassifier(max_leaf_nodes=4)
m.fit(X, y)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/31da51d8b2f299b859df5d61d2cb319e_107.png)

# 绘制决策树
draw_tree(m, X, y)

决策树的可视化结果清晰地展示了分割逻辑:首先根据性别分割,然后对女性乘客根据船舱等级分割,对男性乘客根据年龄分割。在每个叶节点中,我们可以看到生存和死亡的人数统计,这有助于快速理解数据中的关键驱动因素。

基尼不纯度:衡量分割质量的指标

在决策树的可视化中,我们看到了一个名为“Gini”的指标。基尼不纯度是衡量一个节点“纯净度”的另一种方法。

基尼不纯度的概念是:从该节点中随机抽取两个样本,它们属于不同类别的概率是多少?如果一个节点中所有样本都属于同一类别,那么基尼不纯度就是0(因为抽到相同类别的概率是1)。如果类别完全均匀混合(例如各占50%),那么基尼不纯度就是0.5。

在二分类情况下,基尼不纯度的计算公式为:
Gini = 1 - (p_survived² + p_died²)

决策树算法会寻找能最大程度降低加权平均基尼不纯度的分割点。

从决策树到随机森林 🌲🌲🌲

单个决策树的能力是有限的,特别是当叶节点中样本数量很少时,继续分割会导致过拟合。那么,如何获得更强大、更稳定的模型呢?

答案是:集成学习。其核心思想是,如果我们能创建许多不同的、无偏的模型,然后对它们的预测结果取平均,那么平均后的误差将趋近于零,从而得到比任何单个模型都更好的预测结果。

Bagging(自举汇聚法) 就是这样一种集成技术。对于决策树,我们通过以下步骤创建多个不同的模型:

  1. 从原始数据中随机抽取一个子集(例如,每次抽取50%或75%的行)。
  2. 用这个子集训练一棵决策树。
  3. 重复上述过程多次,得到多棵决策树。

由于每棵树都是在不同的数据子集上训练的,它们之间相关性较低,且都是无偏的(平均预测值接近真实平均值)。将这些树的预测结果取平均,就构成了一个随机森林

以下是手动实现随机森林核心逻辑的简化代码:

def get_tree(train_data):
    # 随机抽取部分数据
    idxs = np.random.choice(len(train_data), int(len(train_data)*0.75))
    # 训练一棵决策树
    return DecisionTreeClassifier().fit(X.iloc[idxs], y.iloc[idxs])

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/31da51d8b2f299b859df5d61d2cb319e_151.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/31da51d8b2f299b859df5d61d2cb319e_153.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/31da51d8b2f299b859df5d61d2cb319e_154.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/31da51d8b2f299b859df5d61d2cb319e_156.png)

# 创建100棵树
trees = [get_tree(df) for _ in range(100)]

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/31da51d8b2f299b859df5d61d2cb319e_158.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/31da51d8b2f299b859df5d61d2cb319e_159.png)

# 对预测结果取平均
preds = np.stack([t.predict(X_val) for t in trees])
mean_preds = preds.mean(axis=0)

在实际应用中,我们直接使用RandomForestClassifier。随机森林还有一个关键技巧:在构建每棵树的每个分割点时,不仅对行进行随机抽样,还会随机选择一部分特征列进行评估。这进一步增加了树之间的差异性,使集成效果更稳健。

随机森林的强大洞察力

随机森林不仅是一个强大的预测工具,还能为我们提供关于数据的深刻洞察。以下是几个关键功能:

1. 特征重要性图
随机森林可以告诉我们哪些特征对预测结果最重要。它通过计算每个特征在所有树中带来的基尼不纯度降低的总和来实现这一点。特征重要性图能帮助我们快速识别出数据集中最具预测力的变量,这对于初步探索拥有数百个特征的数据集尤其有用。

2. 预测置信度
对于单个样本的预测,我们可以查看森林中所有树对该样本预测结果的标准差。如果标准差很高,说明不同树的预测差异很大,我们对这个预测的置信度就较低。这在风险评估等场景中非常有用。

3. 部分依赖图
部分依赖图用于展示单个特征与预测目标之间的关系,同时保持其他所有特征不变。它的计算方法是:将数据集中某个特征的值统一设置为某个特定值(如1950年),用模型进行预测并取平均;然后重复这个过程(设置为1951年、1952年等),最后绘制出平均预测值随该特征变化的曲线。这种方法可以排除其他特征混杂因素的影响,揭示出纯粹的关联关系。

4. 单样本预测解释
我们可以分析对于一个特定的预测(例如,拒绝某人的贷款申请),随机森林是如何做出决定的。通过追踪该样本在每棵树中经过的路径,我们可以汇总出哪些特征对该预测的影响最大,以及影响的方向(是提高还是降低了预测值)。这类似于针对单个样本的特征重要性分析。

实践演练:Kaggle竞赛迭代流程 🏆

理论学习之后,让我们通过一个实际的Kaggle竞赛(水稻病害分类)来实践完整的迭代流程。快速迭代和有效验证是竞赛和实际项目成功的关键。

核心原则:

  • 建立可靠的验证集:这是评估模型改进的基石。
  • 快速实验循环:选择能够快速训练的模型和设置,以便在短时间内尝试大量想法。

迭代步骤示例:

  1. 基线模型:使用fastkaggle库快速设置竞赛数据,选择一个训练速度快的架构(如ResNet26d),进行少量周期的训练,并立即提交结果。即使排名靠后,这也建立了起点。
  2. 优化数据管道:分析训练瓶颈。例如,发现Kaggle CPU资源不足导致数据加载慢,可以预先将图像调整到统一尺寸,加速后续训练。
  3. 改进模型架构:根据最新的模型基准研究(如“哪些视觉模型最擅长微调”),切换到更优的架构(如ConvNeXt),这可能会带来显著的精度提升。
  4. 调整图像预处理:尝试不同的图像调整策略,如裁剪、填充或保持原始宽高比的矩形缩放,并评估其对验证集精度的影响。
  5. 使用测试时增强:在预测时,对输入图像进行多种数据增强(如翻转、旋转、亮度调整),将所有增强版本的预测结果进行平均,这通常能提升模型鲁棒性和精度。
  6. 增大图像尺寸:在计算资源允许的情况下,使用更大的图像尺寸进行训练,这通常能捕获更多细节,提升模型性能。

在整个过程中,保持代码的模块化和可重复性,为每个重要的实验步骤创建独立的笔记本,并记录结果。每天提交到排行榜,用客观分数驱动迭代。

总结

本节课我们一起深入探索了表格数据建模的世界。我们从最简单的决策规则出发,构建了决策树,并进一步学习了通过Bagging集成多棵决策树而形成的强大模型——随机森林。我们不仅了解了它们的预测能力,还重点掌握了随机森林提供的丰富诊断工具,如特征重要性、部分依赖图和预测解释,这些工具对于理解数据和模型至关重要。最后,我们通过一个Kaggle竞赛实例,实践了从基线模型开始,通过快速迭代、优化数据管道、改进模型和预处理策略,逐步提升性能的完整工作流程。记住,对于表格数据,随机森林是一个强大且鲁棒的起点;而在任何数据科学项目中,建立可靠的验证集和保持快速的实验循环是成功的关键。

深度学习实践课程:7:神经网络内部机制与多目标模型

概述

在本节课中,我们将深入探讨神经网络的内部机制,特别是如何通过梯度累积技术训练更大的模型,以及如何构建能够同时预测多个目标的多目标模型。我们还将学习协同过滤的基本原理,并了解如何从零开始构建一个推荐系统模型。


梯度累积:突破GPU内存限制 🚀

上一节我们介绍了如何通过数据增强和模型集成来提升竞赛成绩。本节中,我们来看看一个简单但强大的技巧——梯度累积,它允许我们在有限的GPU内存下训练更大的模型。

梯度累积原理

梯度累积的核心思想是:我们不需要在每个小批量数据后都更新模型权重,而是可以累积多个小批量的梯度,然后一次性更新。

以下是梯度累积的关键代码实现:

count = 0
for x, y in data_loader:
    loss = calculate_loss(x, y)
    loss.backward()
    count += len(x)
    if count >= effective_batch_size:
        coefficients -= gradients * learning_rate
        coefficients.grad.zero_()
        count = 0

梯度累积的优势

以下是梯度累积的主要优势:

  • 突破内存限制:通过使用更小的实际批量大小,减少单次前向传播和反向传播的内存占用。
  • 保持训练动态:累积梯度后更新权重,模拟了使用更大批量大小的训练效果,避免了因批量大小变化而需要重新调整学习率等问题。
  • 成本效益:无需购买昂贵的大内存GPU,通过软件技巧即可训练大模型。

实践应用

在FastAI中,使用梯度累积非常简单,只需在创建Learner时添加GradientAccumulation回调,并指定累积步数即可。


多目标模型:同时预测疾病和品种 🌾

现在,让我们将注意力转向模型的输出层。我们将构建一个能够同时预测水稻疾病类型和品种的多目标模型。

数据准备

首先,我们需要创建一个包含两个因变量的数据加载器。使用FastAI的DataBlock可以轻松实现:

dblock = DataBlock(
    blocks=(ImageBlock, CategoryBlock, CategoryBlock),
    get_items=get_image_files,
    get_y=[parent_label, get_variety],
    splitter=RandomSplitter(valid_pct=0.2),
    item_tfms=Resize(192),
    batch_tfms=aug_transforms(size=128)
)

模型架构与损失函数

多目标模型的关键在于其输出层和损失函数的设计。

输出层:模型需要输出20个激活值,其中前10个对应10种疾病的概率,后10个对应10个品种的概率。

损失函数:我们需要为每个目标分别计算损失,然后求和作为总损失。

以下是自定义损失函数和评估指标的代码:

def disease_loss(preds, targs):
    disease_preds = preds[:, :10]  # 前10列为疾病预测
    disease_targs = targs[0]       # 第一个目标是疾病标签
    return F.cross_entropy(disease_preds, disease_targs)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/9790e5fe5cae4197004209600cae6f7e_41.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/9790e5fe5cae4197004209600cae6f7e_43.png)

def variety_loss(preds, targs):
    variety_preds = preds[:, 10:]  # 后10列为品种预测
    variety_targs = targs[1]       # 第二个目标是品种标签
    return F.cross_entropy(variety_preds, variety_targs)

def combined_loss(preds, targs):
    return disease_loss(preds, targs) + variety_loss(preds, targs)

交叉熵损失详解

对于分类任务,我们使用交叉熵损失。它衡量的是模型预测的概率分布与真实标签的“距离”。

Softmax函数:首先将模型的原始输出转换为概率分布。
公式:softmax(z_i) = exp(z_i) / Σ_j exp(z_j)

交叉熵计算:对于真实类别k,交叉熵损失为 -log(p_k),其中p_k是模型预测该类别属于k的概率。

多目标训练的优势:有时,让模型同时学习相关的辅助任务(如预测品种),反而能提升其主要任务(如预测疾病)的性能,因为模型可以学习到更有泛化能力的特征表示。


协同过滤:从零构建推荐系统 🎬

最后,我们探索深度学习的第四个主要应用领域——协同过滤,它是推荐系统的核心。

问题定义

我们使用MovieLens数据集,其中包含用户对电影的评分。我们的目标是预测用户对未观看电影的评分,从而进行推荐。

潜在因子模型

协同过滤的核心思想是“矩阵补全”。我们假设存在一些“潜在因子”(如电影风格、用户偏好),用户对电影的评分可以通过用户因子向量和电影因子向量的点积来近似。

嵌入层(Embedding):在深度学习中,我们使用“嵌入层”来学习这些因子。嵌入层本质上是一个查找表,将用户ID或电影ID映射到一个低维向量。

# 用户嵌入矩阵:n_users x n_factors
# 电影嵌入矩阵:n_movies x n_factors
user_factors = torch.randn(n_users, n_factors)
movie_factors = torch.randn(n_movies, n_factors)

从零构建模型

我们可以从零开始构建一个点积协同过滤模型:

class DotProduct(Module):
    def __init__(self, n_users, n_movies, n_factors):
        self.user_factors = Embedding(n_users, n_factors)
        self.movie_factors = Embedding(n_movies, n_factors)
        
    def forward(self, x):
        users = self.user_factors(x[:,0])  # 查找用户因子
        movies = self.movie_factors(x[:,1]) # 查找电影因子
        return (users * movies).sum(dim=1)  # 点积作为预测评分

模型改进

基础点积模型可以进一步改进:

  1. 偏置项:添加用户偏置(反映用户打分严苛程度)和电影偏置(反映电影普遍受欢迎程度)。
  2. 激活函数:使用Sigmoid函数将输出限制在评分范围内(如0-5分)。
  3. 权重衰减:在损失函数中添加L2正则化项(权重衰减),防止过拟合。这通过惩罚过大的权重,鼓励模型学习更简单、泛化更好的模式。


总结

本节课中我们一起学习了三个核心内容:

  1. 梯度累积:一种通过累积多个小批量的梯度来模拟大批量训练的技术,有效解决了GPU内存不足的问题,是训练大模型的实用技巧。
  2. 多目标模型:学习了如何构建能够同时预测多个目标的神经网络,深入理解了输出层设计、交叉熵损失函数,并认识到多任务学习有时能提升主任务性能。
  3. 协同过滤:从零开始探索了推荐系统的基本原理,理解了潜在因子模型和嵌入层的概念,并实践了如何构建、训练以及改进一个协同过滤模型。

这些知识将帮助你更灵活地设计模型架构,优化训练过程,并解决更复杂的实际问题。

深度学习实践课程:8:协作过滤、嵌入与卷积神经网络 🧠

在本节课中,我们将学习协作过滤的底层实现、嵌入(Embedding)的概念及其在多种模型中的应用,并深入探讨卷积神经网络(CNN)的基本原理。我们将看到,这些看似复杂的模型背后,其实都基于一些简单而统一的核心思想。


协作过滤与自定义嵌入模块

上一节我们介绍了协作过滤的基本概念。本节中,我们来看看如何从零开始构建一个嵌入模块,以深入理解其工作原理。

在PyTorch中,我们无需手动跟踪和更新模型的所有系数(参数)。PyTorch的nn.Module会自动识别并管理这些参数。关键在于,任何我们希望被优化器更新的张量,都需要包装在nn.Parameter类中。

以下是一个自定义嵌入模块的示例,它模拟了协作过滤中的用户和物品嵌入:

class DotProduct(Module):
    def __init__(self, n_users, n_movies, n_factors):
        self.user_factors = nn.Parameter(torch.randn(n_users, n_factors) * 0.01)
        self.user_bias = nn.Parameter(torch.zeros(n_users))
        self.movie_factors = nn.Parameter(torch.randn(n_movies, n_factors) * 0.01)
        self.movie_bias = nn.Parameter(torch.zeros(n_movies))

    def forward(self, x):
        users = self.user_factors[x[:,0]]
        movies = self.movie_factors[x[:,1]]
        res = (users * movies).sum(dim=1)
        res += self.user_bias[x[:,0]] + self.movie_bias[x[:,1]]
        return sigmoid_range(res, 0.5, 5.5)

在这个模块中:

  • user_factorsmovie_factors 是嵌入矩阵。
  • user_biasmovie_bias 是偏置项。
  • forward 方法执行点积运算并加上偏置,最后通过sigmoid函数将输出限制在一定范围内。

训练这个模型后,我们可以分析其学到的参数。例如,检查movie_bias,数值最低的电影通常被认为是“糟糕”的电影,而数值最高的电影则是普遍受欢迎或超出预期的好电影。

我们还可以对movie_factors这个高维嵌入矩阵进行主成分分析(PCA),将其降维并可视化。结果常常显示,模型自动学会了根据电影风格(如主流流行片、批判性剧情片、动作科幻片、对话驱动片等)对电影进行有意义的聚类,尽管我们从未提供过任何类型标签。

FastAI库提供了CollabLearner来简化这个过程,其底层实现与我们自定义的模块非常相似。


从点积到深度神经网络

除了简单的点积模型,我们还可以使用深度神经网络进行协作过滤。其核心思想是将用户嵌入和物品嵌入拼接起来,然后输入到一个多层神经网络中。

以下是构建此类模型的一种方式:

class CollabNN(Module):
    def __init__(self, user_sz, item_sz, n_act=50):
        self.user_factors = Embedding(*user_sz)
        self.item_factors = Embedding(*item_sz)
        self.layers = Sequential(
            nn.Linear(user_sz[1]+item_sz[1], n_act),
            nn.ReLU(),
            nn.Linear(n_act, 1)
        )

    def forward(self, x):
        users = self.user_factors(x[:,0])
        items = self.item_factors(x[:,1])
        x = torch.cat([users, items], dim=1)
        return self.layers(x)

在实际应用中,可以结合点积模型和神经网络模型,或者加入用户和物品的元数据(如年龄、性别、电影类型等),以提升推荐效果。


嵌入的通用性:超越协作过滤

嵌入的概念不仅限于协作过滤,它在自然语言处理(NLP)和表格数据建模中同样至关重要。

在NLP中,我们将词汇表中的每个单词映射为一个整数ID,然后通过一个嵌入矩阵将其转换为密集向量表示。这个嵌入矩阵是可学习的参数,其每一行对应一个单词的向量。

在表格数据建模中,对于分类变量,我们同样为其创建嵌入。FastAI的TabularLearner会自动为所有分类列创建嵌入层,并将它们与连续变量拼接后,输入到一个标准的多层神经网络中。

一个有趣的发现是,模型学到的嵌入常常具有可解释的结构。例如,在一个预测商店销售额的模型中,德国各地区学到的嵌入向量在空间中的接近程度,与实际地理位置的接近程度高度相关。同样,星期几和月份学到的嵌入也符合我们的直觉认知(如工作日聚集在一起,周末聚集在一起)。这表明模型从数据中自动发现了有意义的潜在模式。


卷积神经网络(CNN)揭秘

现在,让我们转向计算机视觉的核心——卷积神经网络。我们将看到,卷积本质上也是一种特殊的矩阵运算。

卷积操作使用一个称为“滤波器”或“核”的小矩阵(例如3x3),在输入图像上滑动。在每个位置,计算滤波器与对应图像区域元素的点积,从而生成一个新的特征图。

例如,一个特定的3x3滤波器 [[1,1,1],[0,0,0],[-1,-1,-1]] 可以用于检测水平边缘。当它滑过图像时,在水平边缘处会产生高激活值。

在深度CNN中,我们会堆叠多个卷积层。每一层的输入可能具有多个“通道”(如第一层是RGB三个通道,后续层是多个特征图通道)。因此,滤波器也变成三维的(例如3x3xC_in),其输出是多个新的特征图通道(C_out)。

传统CNN架构中,卷积层后常接“池化层”(如2x2最大池化),以逐步降低特征图的空间尺寸。现代架构则更常使用“步幅为2的卷积”来替代池化层,实现下采样。

网络的末端,通常会通过全局平均池化(或最大池化)将空间特征图转换为一个向量,再通过全连接层输出最终预测。FastAI采用了“连接池化”,即同时使用平均池化和最大池化并将结果拼接,以获取更丰富的特征。

重要的是,卷积运算可以被重新表述为一种特殊的矩阵乘法,其中权重矩阵具有大量固定的零值和重复的权重。这帮助我们理解,CNN的核心仍然是矩阵乘法和激活函数的组合。


正则化技术:Dropout

为了防止神经网络过拟合,我们使用一种名为“Dropout”的正则化技术。它在训练过程中,随机将一部分神经元的激活值置为零。

这可以看作是对“激活值”进行的数据增强。它迫使网络不能依赖于任何单个神经元或特征,必须学习更鲁棒、更通用的表示。Dropout的比例是一个超参数,需要在模型泛化能力和训练性能之间取得平衡。


总结与后续步骤

本节课中我们一起学习了:

  1. 协作过滤的底层实现:如何从零创建嵌入模块,并解释学到的偏置和潜在因子。
  2. 嵌入的通用性:在NLP和表格数据中,嵌入是如何将分类变量转换为连续向量的,并且这些向量常包含可解释的语义。
  3. 卷积神经网络的核心:卷积是滑动窗口的点积操作,是处理图像等网格数据的有力工具,其本质仍是矩阵运算。
  4. 关键组件:了解了Dropout等正则化技术的工作原理。

现在你已经对多种神经网络的内部机制有了扎实的理解。它们都建立在输入表示、矩阵乘法/卷积、激活函数、输出调整和损失函数这些核心组件之上。

接下来该做什么?

  • 实践与巩固:重新观看课程视频并动手编写代码,尝试完成所有练习。
  • 参与社区:在FastAI论坛上帮助他人,阅读他人的项目和成功故事。
  • 组建学习小组:与他人一起学习,讨论问题。
  • 开展个人项目:将所学知识应用到感兴趣的问题上。
  • 阅读:推荐阅读《元学习》等相关书籍,学习如何更高效地学习。
  • 保持动力:专注于一个细分领域,深入钻研。请记住,这个领域的基础变化并不快,强大的理解和扎实的基础比追逐每一个新潮流更重要。你完全可以使用单个GPU解决大量有实际价值的问题。

感谢你的学习,期待在第二部分课程中与你再见!

深度学习基础到稳定扩散模型:1:课程介绍与稳定扩散初探 🚀

在本节课中,我们将学习稳定扩散模型的基本概念,并通过实际操作快速体验其图像生成能力。课程分为两部分:首先,我们将动手使用稳定扩散模型生成图像;其次,我们将深入探讨其背后的工作原理,为后续从零构建打下基础。

课程背景与定位

本课程是“程序员实用深度学习”系列的第二部分,名为“深度学习基础到稳定扩散模型”。虽然这里标注为第9课,但这是因为第一部分有8节课,所以这是第二部分的第1课。您无需担心错过任何内容。

本课程的重点不在于教授如何用深度学习完成“重要”任务,而是专注于生成模型等有趣的应用,并深入理解许多细节。这些细节对于日常使用可能不是必需的,但如果您想成为一名研究者,或者需要部署具有复杂定制需求的生产系统,学习这些细节将非常有帮助。

课程结构与学习方法

本节课将分为两个部分:

  1. 快速上手使用稳定扩散模型,因为大家都迫不及待地想尝试它。
  2. 详细描述其工作原理。由于需要几节课才能从头讲清楚,本次会进行一些概括性的介绍,但希望您能对本课结束时对整个过程有一个合理的、直观的理解。

本课程会尝试解释所有内容。如果您之前没有深度学习经验,学习起来会非常困难,但我会尽量解释大致发生了什么以及在哪里可以找到更多信息。我强烈建议在学习本课程之前先完成第一部分,除非您想挑战自己。如果您没有完成第一部分,但对深度学习基础有合理的了解(例如,能用Python编写基本的SGD循环,会使用PyTorch或TensorFlow,了解嵌入等基本概念),那么您可能也能跟上。

一般来说,对于这些课程,大多数人会观看视频几遍,第二遍时通常会暂停并查阅不熟悉的内容。我们预计每节课大约需要10小时的学习时间,但有些人会投入更多时间深入研究。

快速上手:玩转稳定扩散

现在,让我们开始动手实践。第一部分我们将玩转稳定扩散模型。

快速发展的领域

我尽可能晚地准备材料,以免过时,但不幸的是,就在12小时前,它又过时了。这就是我要描述的部分(即如何使用稳定扩散以及具体细节如何工作)面临的一大问题:它发展得太快了。我今天要描述的所有细节和要展示的所有软件,到您观看时可能已经改变。

例如,就在昨晚,有两篇新论文发布。我原本要告诉您的是,进行稳定扩散生成所需的步骤数已从1000步减少到约40-50步。但昨晚的论文说现在已降至4步,速度提高了256倍。另一篇论文则提出了另一种正交的方法,使其速度再提高10到20倍。事情非常令人兴奋,发展非常迅速。

不过别担心,在本课之后,我们将从基础开始学习,这意味着我们将学习所有这些模型是如何构建起来的。这些基础内容几乎不会改变。事实上,我们将看到的内容与我们在2019年做的另一门课程非常相似,因为基础不会改变。一旦您掌握了基础,您就能理解这些论文中的细节,并跟上研究,甚至进行自己的研究。

社区资源与工具

本课程与以往课程的一个不同之处在于,由于领域发展太快,我需要大量帮助才能勉强跟上进度。我今天展示的所有内容都深受这些杰出人士的高度影响,他们都是Fast.ai的校友。

为了充分利用本课程,请务必访问 course.fast.ai 获取所有材料(包括每节课的Notebook链接和详细信息)。如果您想深入探索,请访问论坛 forum.fast.ai,在“Part 2 2022”类别下,点击“关于课程”按钮。您会找到每节课的聊天区,里面有更多资料。请仔细查看这些内容,以帮助您理解视频。同时,查看下面的问题和答案,了解大家的讨论。当讨论变得庞大时,您可以点击“总结”按钮,只查看最受欢迎的帖子。这些都是获取课程最大价值的重要资源。

计算资源

完成第二部分需要比第一部分更多的计算资源。计算选项正在迅速变化。目前,由于稳定扩散的巨大流行,许多人开始使用Colab,而Colab的回应是对大多数使用情况开始按小时收费。如果您是Colab用户,可能会发现他们不再提供像样的GPU,或者升级后限制了使用时长。目前,仍然可以尝试Colab,免费版本能提供一些不错的资源。

我强烈建议也尝试一下Paperspace Gradient,目前每月支付约9美元就能获得相当不错的GPU,或者支付更多以获得更好的GPU。但这一切都可能发生很大变化,请访问 course.fast.ai 查看我们当前的最新推荐。

Lambda Labs和Jarvis Labs也是不错的选择。Jarvis由课程校友创建,提供价格非常合理的优质选项,许多Fast.ai学生使用并喜爱它。Lambda Labs是本页面上最新的提供商,他们正在快速添加新功能。我特别提到他们,是因为至少在我录制时(2022年10月初),他们是提供您可能想用来运行大型模型的GPU的最便宜供应商。但这一切都可能改变,所以请查看最新的推荐。

另外,在2022年底,GPU价格已经下降了很多,您可能考虑购买自己的机器。

开始实践:使用扩散模型Notebook

现在,我们将跳转到Notebook。我们链接了一个名为“diffusion-nbs”的仓库。这不是主要的课程Notebook,而是一些您可以尝试的有趣内容的Notebook。

Jonathan Whitaker(我常称他为Jonno)创建了一个名为“suggested-tools.md”的有趣文件,希望他能保持更新,这样即使您以后访问,它仍然是最新的。他非常了解这个领域,能够提炼出一些入门玩耍的最佳资源。我认为玩耍很重要,因为这样您才能真正理解其能力和限制,从而思考可以用它做什么,以及可能存在哪些研究机会。

社区总体上倾向于将内容作为Colab Notebook提供。例如,如果您点击其中一个,通常会看到许多功能,您基本上只需填写内容即可尝试。它们通常有一些示例。您可以点击“运行时”->“更改运行时类型”,确保选择GPU,然后开始运行。

许多使用这些工具的人实际上并不知道这些参数是什么意思。但到课程结束时,您将基本了解所有这些参数的含义,这将帮助您从这类工具中创造出出色的输出。当然,您也可以通过更“手工”的方法尝试,网上有很多关于可以尝试什么的信息。

提示词工程

您会发现,目前大多数工具都期望您输入一些文本来描述您想创建的图片。事实证明,选择合适的文本并不容易,这会产生有趣的结果。目前,理解该写什么还相当“手工”。学习提示词的最佳方法是查看他人的提示词和输出。

目前,最好的方式可能是访问 Lexica,那里有大量有趣的AI艺术作品。您可以点击一个作品,查看使用了什么提示词。通常,您会以“想要制作什么图片?”和“什么风格?”开始。技巧是添加一堆艺术家名字或他们放置艺术的地方,这样算法就会倾向于创建与那些在标题中倾向于包含这些词汇的艺术品相匹配的作品。

这是一个非常有用的技巧。您甚至可以搜索特定内容,例如“teddy bears”,看看人们是如何创建漂亮的泰迪熊图像的。到本课程结束时,您将理解为什么会发生这种情况,为什么这类提示词会产生这类输出,以及如何超越仅仅创建提示词,真正利用新数据类型构建创新的新事物。

探索 diffusion-nbs 仓库

让我们看看 diffusion-nbs 仓库。首先,我们将查看稳定扩散部分。您可以选择克隆这个仓库(链接在 course.fast.ai 和论坛上都有),然后在Paperspace Gradient或您自己的机器上运行;或者,您可以前往Colab,点击“GitHub”并粘贴链接直接从GitHub运行。

我正在自己的机器上运行。这个Notebook的构建主要得益于Hugging Face的优秀团队。Hugging Face有一个名为diffusers的库。如果您完成了课程的第一部分,您会对Hugging Face非常熟悉,我们在第一部分使用了很多他们的库。diffusers是他们的库,用于进行稳定扩散及类似稳定扩散的任务。目前,这是我们推荐用于此类任务的库,也是我们将在本课程中使用的。也许到您观看时,会有很多其他选择,所以请继续关注 course.fast.ai。

一般来说,Hugging Face在深度学习模型方面做得非常出色,处于领先地位。因此,他们在相当长一段时间内继续成为最佳选择并不奇怪。任何库的基本思想看起来都会非常相似。

使用 Diffusers 库

要开始使用,您需要登录Hugging Face。如果您有Hugging Face账户,可以在那里创建用户名和密码,然后登录。登录一次后,它会保存在您的计算机上,以后就不需要再次登录了。

我们将使用pipelines,特别是StableDiffusionPipeline。到您观看时,您可能在使用不同的pipeline,但pipeline的基本思想与我们在Fast.ai中称为Learner的东西非常相似:它包含一大堆东西,比如处理、模型和推理,所有这些都是自动进行的。就像您可以在Fast.ai中保存一个Learner一样,您也可以在diffusers中保存一个pipeline。

Hugging Face库几乎都可以做而Fast.ai不能做的一件事是,您可以将pipeline或其他东西保存回云端到Hugging Face(他们称之为Hub)。因此,当我们说from_pretrained时,这很像我们在Fast.ai中创建预训练Learner的方式,但您在这里输入的内容实际上如果不是本地路径,就是一个Hugging Face仓库。

如果您搜索Hugging Face的这个模型,您可以看到它将下载什么。您实际上可以将自己的pipeline保存到Hub供他人使用,我认为这是一个非常棒的功能,有助于社区构建东西。

第一次运行时,它将从互联网下载数GB的数据。在Colab上使用的一个小挑战是,每次使用Colab时,所有东西都会被丢弃并从头开始,因此每次使用Colab时都必须重新下载所有内容。如果您使用像Paperspace或特别是Lambda Labs这样的服务,所有内容都会为您保存。

一旦下载了所有内容,它会在您的缓存和主目录中保存一大堆东西,这是Hugging Face存放内容的地方。

生成第一张图像

现在我们有了一个名为pipe的pipeline。我们可以将其视为一个函数。这对于PyTorch和Fast.ai的东西来说非常常见,希望您已经非常熟悉了。您可以传递一个提示词(只是一些文本)给它,它将返回一些图像。由于我们只传递一个提示词,它将返回一张图像,所以我们只需索引到.images

当我们运行它时,大约需要30秒左右,并返回一张“宇航员骑马”的照片。每次使用相同的随机种子调用pipeline,您都会得到相同的图像。您可以手动设置随机种子,这样您就可以发送给别人说“哦,我找到了一个很酷的宇航员骑马图像,试试手动种子1024”,然后您就会得到这个特定的宇航员骑马图像。

这就是在Colab或您自己的机器上开始运行并创建图像的最基本方法。如我所说,它需要大约30秒,在这个例子中用了51步。

理解扩散过程

这与我们在Fast.ai中习惯的推理方式非常不同,例如分类只需一步。在这51步中,它做的是:从随机噪声开始(实际上,这是我们将在课程中自己创建手写数字的一个例子),每一步都试图让噪声稍微减少一点,让它更接近我们想要的东西。向下滚动会显示创建第一个数字的所有步骤。如果您仔细观察,可以在这噪声中看到一些看起来有点像“1”的东西,然后算法决定聚焦于它。

这就是扩散模型的基本工作原理。

调整生成步骤

一个问题可能是,为什么我们不一步完成?我们可以一步完成,但如果尝试一步完成,效果并不好。就我发言时(2022年10月),这些模型还不够智能,无法一步完成。正如开头提到的,我在这里用51步完成已经过时了,因为从昨天起,我们显然可以在3到4步内完成。我不确定代码是否已经可用,所以到您看到这时,这一切可能会快得多。但我相信,理解这个基本概念将永远非常重要。

如果我们用16步而不是51步,它看起来更像一点,但仍然不完美。

探索引导尺度

这就是您入门的方法。我将向您展示一些可以调整的东西。我应该提醒您,我今天展示的大部分内容都是由Pedro Cuenca和Hugging Face的其他人员构建的,非常感谢他们。没有他们的帮助,我不可能如此深入地了解所有这些细节。他们构建了这个diffusers库,并在展示其功能方面做得非常出色。

让我们看一个例子。我们只是快速定义一个小函数来创建图像网格。细节不重要,但我们想在这里展示的是,您可以获取您的提示词(“宇航员骑马”),并创建它的四个副本。当应用于列表时,*运算符只是将列表复制多次。所以我们有一个包含完全相同提示词四次的列表。

然后,我们将把提示词列表传递给pipeline,并使用一个名为guidance_scale的新参数。我们将在课程后面详细学习引导尺度,但基本上,它表示我们应该在多大程度上专注于特定标题,而不是仅仅创建一张图像。

我们将尝试几个不同的引导尺度:大约1、3、7、14。一般来说,7.5目前是默认值(到您观看时可能已更改)。所以这里的每一行都是不同的引导尺度。

您可以看到,在第一行(引导尺度为1),它并没有真正听从我们,这些看起来非常奇怪,没有一个真的像宇航员骑马。在引导尺度为3时,它们看起来更像骑马的东西,可能有点像宇航员。在7.5时,它们总体上肯定像宇航员骑马。在14或15时,它们肯定像,但有时变得有点太抽象了。我强烈感觉这里实际上在编码或算法工作原理上存在一些小问题,我们将在本课程中研究这些问题。

基本上,这里发生的是,在高引导尺度下,它实际上有点“跳过头”了。无论如何,它的基本思想是:对于每个提示词,它实际上创建两个版本的图像:一个带有提示词“宇航员骑马”的图像,和一个没有提示词(只是随机内容)的图像。然后它基本上取这两个东西的平均值,引导尺度就像一个用于加权该平均值的数字。

使用负向提示词

有一个非常相似的事情可以做,同样是让模型创建两个图像,但不是取平均值,而是要求它有效地将一个从另一个中减去。

这是Pedro做的一个例子,使用提示词“维米尔风格的拉布拉多犬”。然后他说,如果我们减去一些东西,比如只是模型对标题“蓝色”的响应,会怎样?您可以向diffusers传递这个negative_prompt参数,它会做的是:获取提示词(本例中是“维米尔风格的拉布拉多犬”),并有效地创建第二个图像来响应提示词“蓝色”,然后有效地将一个从另一个中减去。细节略有不同,但这是基本思想。这样我们就得到了一个非蓝色的、维米尔风格的拉布拉多犬。您可以尝试这个,很有趣。

图像到图像生成

您还可以玩的是,您不仅可以传入文本,还可以传入图像。为此,您需要一个不同的pipeline:图像到图像pipeline。

使用图像到图像pipeline,您可以抓取一张相当粗略的草图,然后将其传递给这个“img2img”(图像到图像)pipeline。基本上,这将做的是:不是从随机噪声开始扩散过程,而是基本上从这张绘图的噪声版本开始。然后它会尝试创建一些既匹配这个标题,又遵循这种引导起点构图的东西。

因此,您得到的东西看起来比原图好得多,但您可以看到构图是相同的。使用这种方法,您可以构建符合您正在寻找的特定构图的东西。

我认为这是一个非常巧妙的方法。这里strength参数表示您希望在多大程度上真正创建看起来像这个草图的东西,或者您希望模型在多大程度上能够尝试一些不同的东西。

迭代优化与微调

现在事情变得有趣了,这是您目前仅靠基本指南无法做到的。但如果您真的知道自己在做什么,您现在可以做的是:获取这些输出图像,然后说“哦,这张不错,让我们把它作为初始图像”。然后我们可以说“让我们做一幅梵高的油画”,传递相同的东西和强度为1。实际上,这几乎成功了。我认为这绝对令人着迷,因为这是我以前从未见过的东西,Pedro本周将其整合在一起,它将简单的Python代码结合在一起。

您还可以做其他事情,这个例子实际上来自Lambda Labs的团队。我们现在不会详细讨论这个,因为这基本上就像我们在Fast.ai中做过无数次的事情:您可以获取pipeline中的模型,并传递您自己的图像和您自己的标题。

这些家伙所做的是,他们创建了一个非常酷的数据集:抓取了一个包含近千张宝可梦图像的数据集,然后使用图像描述模型自动为每张图像生成描述。接着,他们使用这些图像-描述对微调了稳定扩散模型。

这是一个描述和图像的示例。然后,他们使用微调后的模型,并传递诸如“戴珍珠耳环的女孩”和“可爱的奥巴马生物”等提示词,得到了这些超级棒的输出,现在既反映了他们使用的微调数据集,又响应了这些提示词。

文本反转与 DreamBooth

这是您可以做的另一个例子。微调可能需要相当多的数据和时间,但您可以做一些特殊类型的微调。一种您可以做的是称为“文本反转”的方法,即我们实际上只微调一个嵌入。

例如,我们可以创建一个新的嵌入,试图让东西看起来像这样。我们可以给这个概念起个名字。这里我们称之为“水彩肖像”。这就是我们将使用的嵌入名称。然后,我们基本上可以将该标记添加到文本模型中,然后训练其嵌入以匹配我们看到的示例图片。这会快得多,因为我们只训练一个标记,在这个例子中只针对四张图片。

当我们这样做时,我们可以说“以...风格阅读的女人”,然后传入我们刚刚训练的那个标记。正如您将看到的,我们会得到一种新颖的图像,我认为这非常有趣。

另一个与文本反转非常相似的例子是“DreamBooth”。如前所述,它所做的是获取一个现有但不常用的标记(比如“sks”),并微调模型使该标记接近我们提供的图像。

Pedro在这里做的是,他抓取了我的一些照片,并说“sks的绘画”。在这个例子中,他微调了这个标记,使其成为“保罗·塞尚风格的杰里米·霍华德照片”。这就是它们。我之前展示的“杰里米·霍华德矮人”图像的例子,那个Dreamer服务实际上就是使用这个DreamBooth。

这就是您可以自己尝试的方法。

总结第一部分

好了,这就是本节课的第一部分:如何开始玩转稳定扩散模型。第二部分,我们将从机器学习的角度讨论这里实际发生了什么。我们大约七分钟后回来讨论。


深入原理:从机器学习视角理解稳定扩散

欢迎回来。在深入之前,我想再分享一个文本反转训练的例子。这是我女儿的泰迪熊“Tiny”。Pedro和我尝试创建一个“Tiny”的文本反转。我试图得到“Tiny骑马”的图像。有趣的是,当我尝试这样做时,顶行实际上是Pedro运行时的示例,显示了他训练时尝试使用标题“Tiny骑马”的步骤。正如您所见,它最终从未生成Tiny骑马。相反,它最终生成了一匹看起来有点像Tiny的马。

然后我们尝试得到“Tiny坐在粉色地毯上”的图像。实际上,过了一段时间,我确实取得了一些进展,但它并不完全像Tiny。Pedro做的一件与我不问的事是,他从“person”的嵌入开始,而我的实际上是从“teddy”的嵌入开始,效果稍好一些。但正如您所见,存在一些问题。随着我们在本课剩余部分更多地讨论它是如何训练的,我们将理解这些问题的来源。

基础概念回顾

接下来,我将依赖于

深度学习基础到稳定扩散模型:2:稳定扩散模型深度解析 🧠

在本节课中,我们将深入探索稳定扩散模型的代码实现。我们将拆解流行的API和库背后的生成过程,了解每个独立组件的工作原理,并学习如何修改它们。

环境准备与基础生成

首先,我们通过复制Hugging Face默认稳定扩散流水线的核心代码,来重现图像生成过程。以下是基础设置和生成循环的代码框架:

# 基础设置代码
import torch
from diffusers import StableDiffusionPipeline

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/0629c20054834ad43432bab705e8969b_19.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/0629c20054834ad43432bab705e8969b_20.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/0629c20054834ad43432bab705e8969b_21.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/0629c20054834ad43432bab705e8969b_22.png)

# 加载预训练模型
pipe = StableDiffusionPipeline.from_pretrained("CompVis/stable-diffusion-v1-4")
pipe = pipe.to("cuda")

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/0629c20054834ad43432bab705e8969b_23.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/0629c20054834ad43432bab705e8969b_24.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/0629c20054834ad43432bab705e8969b_25.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/0629c20054834ad43432bab705e8969b_27.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/0629c20054834ad43432bab705e8969b_28.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/0629c20054834ad43432bab705e8969b_30.png)

# 生成图像
prompt = "A watercolor picture of an otter"
image = pipe(prompt).images[0]
image.save("otter.png")

这段代码会生成一张水獭的水彩画。但我们的目标是理解这背后的机制。

组件一:自动编码器 (VAE) 🎨

稳定扩散是一个潜在扩散模型。这意味着它不在像素空间操作,而是在一个经过训练的变分自动编码器的潜在空间中操作。

上一节我们介绍了基础生成流程,本节中我们来看看第一个核心组件:自动编码器。它的作用是将高分辨率图像压缩成一个信息丰富的低维潜在表示。

以下是编码和解码过程的代码示例:

def encode_image(vae, image_tensor):
    # 编码图像到潜在分布
    with torch.no_grad():
        latent_dist = vae.encode(image_tensor).latent_dist
        latent_sample = latent_dist.sample()
    # 按原始论文缩放因子进行缩放
    latent_sample = latent_sample * 0.18215
    return latent_sample

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/0629c20054834ad43432bab705e8969b_55.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/0629c20054834ad43432bab705e8969b_56.png)

def decode_latent(vae, latent_sample):
    # 缩放回原始范围
    latent_sample = latent_sample / 0.18215
    # 解码回图像空间
    with torch.no_grad():
        image = vae.decode(latent_sample).sample
    return image

这个过程实现了8倍的尺寸缩减(例如从512x512到64x64),数据量减少了约64倍,但依然能保留图像的大部分视觉信息。输入图像的尺寸必须是8的倍数。

组件二:调度器 (Scheduler) ⏱️

调度器负责控制扩散过程中噪声的添加与移除。在训练时,我们向图像添加噪声,然后让模型尝试预测所添加的噪声。

以下是使用DDPM调度器添加噪声的示例:

from diffusers import DDPMScheduler

# 初始化调度器
scheduler = DDPMScheduler(
    beta_start=0.00085,
    beta_end=0.012,
    beta_schedule="scaled_linear",
    num_train_timesteps=1000
)

# 设置推理步数(通常远小于训练步数)
scheduler.set_timesteps(50)

# 查看对应的时间步和噪声水平(sigma)
timesteps = scheduler.timesteps  # 例如 [999, 957, ... , 0]
sigmas = scheduler.sigmas        # 对应每个时间步的噪声水平

噪声添加的数学原理很简单,核心公式是:
noisy_latents = original_latents + noise * sigma

我们可以从任意时间步开始生成,这构成了“图生图”功能的基础。例如,从一张已有图像的加噪版本开始去噪,可以保留原图的部分结构和色彩,同时根据新提示词生成新内容。

组件三:文本编码器 (Text Encoder) 🔤

文本编码器负责将文本提示转换为模型可以理解的数值表示(嵌入向量)。这个过程分为几个步骤。

上一节我们介绍了噪声调度,本节中我们来看看文本是如何被转换成模型条件的。以下是文本编码的完整流程:

  1. 分词:将提示词转换为离散的令牌ID序列(例如77个令牌,不足则用填充令牌补足)。
  2. 令牌嵌入:每个令牌ID通过一个查找表映射为一个768维的向量。
  3. 位置嵌入:为序列中的每个位置(1到77)添加一个额外的768维位置编码向量,以提供顺序信息。令牌嵌入和位置嵌入直接相加。
  4. Transformer编码:将组合后的嵌入向量输入一个Transformer编码器(由多个块堆叠而成),经过自注意力等机制处理,得到最终的输出嵌入(即编码器隐藏状态)。

以下是手动模拟这一过程的代码:

# 1. 分词
tokenizer = pipe.tokenizer
text_input = tokenizer(
    prompt,
    padding="max_length",
    max_length=tokenizer.model_max_length,
    truncation=True,
    return_tensors="pt"
)
input_ids = text_input.input_ids.to(device)  # 形状: [1, 77]

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/0629c20054834ad43432bab705e8969b_108.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/0629c20054834ad43432bab705e8969b_109.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/0629c20054834ad43432bab705e8969b_111.png)

# 2. 获取令牌嵌入
token_embeddings = text_encoder.text_model.embeddings.token_embedding(input_ids)

# 3. 获取位置嵌入并相加
position_ids = torch.arange(tokenizer.model_max_length).unsqueeze(0).to(device)
position_embeddings = text_encoder.text_model.embeddings.position_embedding(position_ids)
input_embeddings = token_embeddings + position_embeddings

# 4. 通过Transformer编码器
encoder_outputs = text_encoder.text_model.encoder(
    inputs_embeds=input_embeddings,
    output_hidden_states=True
)
# 获取最终的输出嵌入(通常是最后一层的隐藏状态)
prompt_embeddings = encoder_outputs.hidden_states[-1]  # 形状: [1, 77, 768]

理解这个流程的妙处在于,我们可以在不同阶段干预嵌入向量,实现有趣的控制。

嵌入操控技巧

以下是几种通过修改嵌入向量来控制生成的方法:

  • 令牌替换:直接替换特定单词(如“puppy”)的令牌嵌入为另一个单词(如“cat”)的嵌入,生成的图像内容会随之改变。
  • 令牌混合:将两个令牌的嵌入进行线性插值(如embedding = α * embed_puppy + (1-α) * embed_skunk),可以生成混合概念的图像。
  • 文本反转:这是最强大的应用之一。我们可以为模型训练一个全新的“概念”(如一种特定绘画风格)对应的嵌入向量,并将其作为一个特殊令牌使用。社区已经贡献了成千上万个这样的概念嵌入。
  • 提示词嵌入混合:在文本编码器的最终输出阶段,将两个不同提示词(如“a mouse”和“a leopard”)的输出嵌入进行混合,可以生成融合两者特征的图像。

组件四:UNet扩散模型 🧬

UNet是扩散模型的核心,它负责预测添加到潜在表示中的噪声。

上一节我们探讨了文本编码,本节中我们聚焦于执行去噪任务的UNet模型。UNet的调用签名如下,它接收噪声潜在、时间步和文本嵌入作为输入:

# 模型预测噪声
noise_pred = unet(
    noisy_latents,          # 当前噪声潜在表示
    timestep,               # 当前时间步
    encoder_hidden_states=prompt_embeddings  # 文本条件
).sample

# 预测的“去噪”潜在可以通过以下公式计算:
pred_original_sample = noisy_latents - sigma * noise_pred

在采样循环中,我们不会一步就减去所有预测的噪声,而是逐步进行。可视化每一步的noisy_latentspred_original_sample,可以清晰地看到图像从模糊噪声逐渐变得清晰的过程。

无分类器引导

为了生成更贴合文本提示的图像,稳定扩散使用了无分类器引导技术。其核心思想是同时计算有条件(带提示词)和无条件(空提示)的噪声预测,然后按以下公式进行引导:

final_noise_pred = noise_pred_uncond + guidance_scale * (noise_pred_cond - noise_pred_uncond)

guidance_scale(引导尺度)控制着向有条件预测方向推进的强度。较高的引导尺度通常能生成更符合提示词但多样性可能降低的图像。

采样算法原理 📈

为什么我们需要迭代采样而不是一步到位?我们可以从两个角度理解:

  1. ODE求解视角:扩散过程可以表述为一个随机微分方程(SDE),而去噪则是求解对应的逆向常微分方程(ODE)。由于这个ODE无法一步精确求解,我们需要使用数值方法(如欧拉法)进行多步近似。更高级的采样器(如二阶求解器、线性多步法)通过估计梯度变化或利用历史信息,可以用更少的步数获得更好的结果。
  2. 优化视角:将生成过程视为一个优化问题。我们的目标是找到一个潜在点,使得模型预测的噪声尽可能小(即看起来像一张真实图像)。我们从随机噪声点开始,将模型预测的噪声视为“梯度”,使用优化器(带有学习率、动量等)逐步更新潜在表示,直至收敛到一个低“噪声损失”的点。

两种视角都解释了迭代采样的必要性:单步预测会得到模糊、平均的结果,而多步迭代能逐步细化,得到清晰、高质量的图像。

高级控制:引导生成 🎯

除了文本控制,我们还可以通过额外损失函数来引导生成过程,例如控制颜色、风格或形状。

以下是实现颜色引导(使图像偏蓝)的示例步骤:

  1. 在采样循环中,定期(如每5步)启用潜在张量的梯度计算。
  2. 计算当前步预测的去噪潜在,并通过VAE解码为图像。
  3. 在图像空间计算自定义损失(例如,蓝色通道值与目标值的均方误差)。
  4. 计算该损失对噪声潜在张量的梯度。
  5. 按照梯度方向更新潜在张量,以减小损失。
# 简单蓝色损失示例
def blue_loss(images):
    # 计算图像蓝色通道与目标值0.9的差异
    return (images[:,2] - 0.9).mean().abs() # images shape: [B, C, H, W]

# 在采样循环中
if i % 5 == 0:
    latents.requires_grad_(True)
    # 获取预测的原始样本并解码
    pred_original_sample = latents - sigma * noise_pred
    decoded_image = decode_latent(vae, pred_original_sample)
    loss = blue_loss(decoded_image) * guidance_scale_extra
    # 计算梯度并更新latents
    grad = torch.autograd.grad(loss, latents)[0]
    latents = latents - grad * (sigma**2)
    latents.requires_grad_(False)

这种方法非常强大,可以结合CLIP模型、分类器或任何可微分的损失函数,为生成过程注入丰富的控制信号。

总结 🎉

本节课中我们一起深入学习了稳定扩散模型的内部机制:

  1. VAE:在潜在空间进行高效操作,实现图像压缩与重建。
  2. 调度器:管理噪声添加与移除的节奏,是“图生图”等功能的基础。
  3. 文本编码器:将文本转换为条件嵌入,并可通过嵌入操控实现文本反转、概念混合等高级控制。
  4. UNet:核心去噪模型,预测噪声以逐步净化潜在表示。
  5. 无分类器引导:通过结合有条件与无条件预测,增强文本跟随能力。
  6. 采样原理:从ODE求解或优化视角理解迭代采样的必要性。
  7. 引导生成:通过外部损失函数对生成过程进行细粒度控制。

希望本教程能帮助你不仅学会使用稳定扩散,更能理解其原理,并激发你创造更多有趣的应用。

深度学习基础到稳定扩散模型:3:扩散模型的数学原理 🧮

在本节课中,我们将一起学习扩散模型背后的数学原理。我们将从概率分布的基本概念出发,逐步理解扩散过程、反向过程以及如何训练模型来生成图像。内容会尽可能简单直白,让初学者能够跟上。

概述:从数据分布到噪声预测

扩散模型的核心思想是:通过一个逐步添加噪声的过程(前向过程)将数据(如图像)转化为纯噪声,然后学习一个反向过程,从噪声中逐步恢复出原始数据。我们将看到,这个看似复杂的过程最终可以简化为一个神经网络学习预测所添加的噪声。

1. 理解符号:数据分布 Q(x⁰)

我们首先遇到的符号是 Q(x⁰),它被称为数据分布

  • x⁰: 这里的 x 通常代表输入变量,上标 0 表示这是序列的起点,意味着后面可能还有 , 等。在图像生成的上下文中,x⁰ 就代表一张原始图像(例如一个手写数字“9”的图片)。
  • Q: 这是一个概率密度函数。我们可以把它想象成一个“魔法API”:你输入一张图片,它返回一个数字,告诉你这张图片看起来像真实数据的可能性有多大。例如,输入一张清晰的手写数字“9”的图片,Q(x⁰) 会返回一个很高的概率值;如果输入一张完全随机的噪声图片,则会返回一个很低的概率值。

公式表示Q(x⁰) 是一个函数,它映射图像 x⁰ 到一个概率值,表示 x⁰ 属于真实数据分布的可能性。

2. 前向扩散过程:逐步添加噪声

上一节我们介绍了数据分布,本节中我们来看看如何定义从数据到噪声的变换过程。

扩散模型定义了一个前向过程,它通过一系列步骤 t=1, 2, ..., T,逐步向数据 x⁰ 添加少量高斯噪声,最终得到纯噪声 x^T。这个过程由条件概率分布 Q(xᵗ | xᵗ⁻¹) 描述。

  • Q(xᵗ | xᵗ⁻¹): 这是一个条件概率密度函数。给定前一步的图像 xᵗ⁻¹,它定义了下一步带噪声的图像 xᵗ 的概率分布。
  • 具体形式: 在2015年的奠基性论文中,这个分布被定义为一个高斯分布(正态分布)

公式表示
Q(xᵗ | xᵗ⁻¹) = N(xᵗ; √(1-βₜ) * xᵗ⁻¹, βₜ I)

让我们来拆解这个公式:

  • N(...): 表示高斯分布。
  • 均值 μ√(1-βₜ) * xᵗ⁻¹。这意味着下一步图像的“中心”是上一步图像按系数 √(1-βₜ) 缩放后的结果。
  • 协方差 Σβₜ Iβₜ 是一个介于0和1之间的小数,称为噪声调度参数。I 是单位矩阵,这意味着我们为每个像素独立地添加方差为 βₜ 的高斯噪声,像素之间没有相关性。

代码描述(前向单步采样)

# 假设 x_prev 是上一步的图像,beta_t 是当前步的噪声方差
mean = sqrt(1 - beta_t) * x_prev
noise = torch.randn_like(x_prev) # 从标准正态分布采样噪声
x_t = mean + sqrt(beta_t) * noise # 得到当前步的带噪声图像

理解极端情况

  • βₜ = 0 时,均值 μ = xᵗ⁻¹,方差为0。这意味着 xᵗ 完全等于 xᵗ⁻¹,没有添加噪声。
  • βₜ = 1 时,均值 μ = 0,方差为 I。这意味着 xᵗ 是完全独立的标准正态分布噪声,原始图像信息完全丢失。
  • 通过设置一系列很小的 β₁, β₂, ..., β_T,我们可以构造一个从清晰图像 x⁰ 逐步、平缓地过渡到纯噪声 x^T 的马尔可夫链。这个过程被称为前向扩散过程加噪过程

3. 反向生成过程:从噪声中重建

既然我们可以将数据变成噪声,一个自然的问题是:我们能否逆转这个过程?幸运的是,数学告诉我们,当噪声步长 βₜ 足够小时,反向过程 Q(xᵗ⁻¹ | xᵗ) 也具有高斯分布的形式。

我们定义反向过程P(xᵗ⁻¹ | xᵗ),这是我们希望模型学会的分布。

公式表示
P(xᵗ⁻¹ | xᵗ) = N(xᵗ⁻¹; μ_θ(xᵗ, t), Σ_θ(xᵗ, t))

  • μ_θΣ_θ 是未知的参数,它们依赖于当前噪声图像 xᵗ 和时间步 t
  • 我们的目标就是训练一个神经网络(参数为 θ)来预测这些均值和方差。

4. 训练目标:证据下界

直接最大化原始数据 x⁰ 在整个反向过程下的似然概率 P(x⁰) 是非常困难的。因此,我们转而优化一个更容易计算且与之密切相关的替代目标——证据下界

ELBO (Evidence Lower Bound) 是似然函数 log P(x⁰) 的一个下界。最大化 ELBO 近似等价于最大化数据的似然概率。经过推导,ELBO 可以转化为一个更具体的损失函数。

以下是优化 ELBO 的关键步骤:

  1. 比较分布: ELBO 的核心涉及比较前向过程分布 Q 和反向过程分布 P
  2. KL散度: 这种比较通过 KL 散度 实现,它是一种衡量两个概率分布差异的方法。
  3. 高斯分布的优势: 由于 QP 都被假设为高斯分布,它们之间的 KL 散度有一个简单、可解析计算的公式,这使得损失函数的计算变得高效。

最终,通过优化这个基于 KL 散度的损失函数,我们可以训练神经网络来学习反向过程的参数 μ_θΣ_θ

5. 关键简化:DDPM 与噪声预测

2020年的论文《Denoising Diffusion Probabilistic Models (DDPM)》引入了一个关键的简化,使得扩散模型更易于实现和训练。

他们做出了两个重要假设:

  1. 将反向过程的方差 Σ_θ(xᵗ, t) 固定为常数,不再由神经网络学习。
  2. 对前向过程的噪声调度 βₜ 进行精心设计。

在这些假设下,一个惊人的结论出现了:学习反向过程的均值 μ_θ,等价于学习预测添加到图像 xᵗ 中的噪声 ε

公式推导结果
神经网络 ε_θ(xᵗ, t) 的目标是预测在前向过程中第 t 步所添加的噪声。损失函数简化为预测噪声和真实噪声之间的均方误差。

损失函数公式
L = E[|| ε - ε_θ(xᵗ, t) ||²]
其中,ε 是前向过程中实际采样得到的噪声。

代码描述(训练循环核心)

# 1. 从数据集中采样一张干净图像 x0
x0 = sample_from_dataset()
# 2. 随机选择一个时间步 t
t = torch.randint(0, T, (1,))
# 3. 采样噪声,并根据时间步 t 计算加噪后的图像 xt
noise = torch.randn_like(x0)
xt = forward_diffusion(x0, t, noise) # 根据调度参数添加噪声
# 4. 神经网络预测噪声
predicted_noise = model(xt, t)
# 5. 计算噪声预测的均方误差损失
loss = F.mse_loss(noise, predicted_noise)
loss.backward()
optimizer.step()

这个框架极其简洁:模型只是一个接收噪声图像 xᵗ 和时间步 t,并输出预测噪声 ε_θ 的神经网络。

6. 另一种视角:分数匹配

Denish 提到了一个深刻且等价的视角:分数匹配

  • 分数函数 定义为对数概率密度函数 log p(x) 的梯度:∇ₓ log p(x)
  • 可以证明,在一定的条件下(特别是使用高斯噪声时),去噪得分匹配——即训练一个模型从带噪声数据中预测原始干净数据——等价于学习这个分数函数。
  • 更重要的是,学习预测噪声的扩散模型(DDPM)与学习分数函数是等价的。预测噪声 ε_θ 与分数函数 ∇ₓ log p(x) 只差一个缩放因子。

这连接了基于似然最大化的概率视角和基于分数匹配的视角,显示了扩散模型深厚的数学根基。

总结:从数学到代码的旅程

本节课中我们一起学习了扩散模型背后的数学原理,我们经历了以下关键步骤:

  1. 定义起点: 从我们想要建模的数据分布 Q(x⁰) 开始。
  2. 构造前向过程: 定义了一个逐步添加高斯噪声的前向扩散过程 Q(xᵗ | xᵗ⁻¹),将数据变为纯噪声。
  3. 设定学习目标: 利用数学性质,我们知道反向过程 P(xᵗ⁻¹ | xᵗ) 形式相似,但参数未知。我们通过最大化证据下界来训练神经网络学习这些参数。
  4. 实现关键简化: 借助 DDPM 的框架,训练目标被简化为让神经网络 ε_θ(xᵗ, t) 直接预测所添加的噪声,损失函数是简单的均方误差。
  5. 理解深层联系: 认识到噪声预测与学习数据分布的分数函数是等价的,这为扩散模型提供了更丰富的理论解释。

最终,所有这些数学推导都凝结成了一个清晰、可训练的算法:用一个神经网络去预测噪声,并通过迭代去噪的过程,从随机噪声中生成新的数据样本。这就是现代扩散模型,如 Stable Diffusion,能够创造出惊人图像的数学基础。

深度学习基础到稳定扩散模型:4:从基础到前沿

概述

在本节课中,我们将回顾上周关于稳定扩散模型的核心概念,并探讨几篇最新的研究论文,这些论文显著提升了模型的采样速度和可控性。我们还将深入代码,从最基础的矩阵操作开始,逐步构建我们自己的深度学习框架。


学生作品展示 📸

上一周,课程论坛上涌现了许多精彩的学生作品。以下是部分示例:

  • Purro 展示了在两个不同的潜在噪声起点之间进行线性插值(具体是球面线性插值)的过程,并呈现了所有中间结果。
  • Namrada 在此基础上更进一步,实现了从恐龙图像到鸟类图像的渐变转换,其中一张“恐龙鸟”的中间图像非常酷。
  • John Richmond 将他女儿的狗的照片逐渐变成了一只独角兽,这张沿途生成的图像非常可爱,堪称“本周最佳爸爸奖”。
  • Maureen 尝试将Jonno课程中的鹦鹉图像转换成不同画家的风格,并让大家猜猜提示词中使用了哪些艺术家。

这些作品提醒我们,一定要去查看Jonno关于稳定扩散的课程视频,以及Wassim和Tenniish关于扩散模型数学原理的视频。即使你觉得自己不擅长数学,后者也能提供有用的背景知识。

此外,Jason Antic(曾参与创建Delphi、NoGAN等)本周取得了惊人进展。他尝试使用经典深度学习优化器(而非微分方程求解器)来训练模型,仅用单GPU几小时就从零开始生成了高质量的人脸图像,这一研究方向极具前景。


核心概念回顾 🔄

上一节我们欣赏了同学们的创意,现在让我们回顾一下稳定扩散模型的核心工作原理。

训练过程:预测噪声

基本思想是,我们从一个图像(例如手写数字7)开始,向其添加一些噪声,得到一个带噪图像。我们将这个带噪图像输入一个神经网络(称为U-Net),并尝试预测所添加的噪声本身。网络将其预测的噪声与实际添加的噪声进行比较,计算损失,并据此更新权重。这就是U-Net训练的基本方式。

为了使训练更容易,我们还可以传入目标数字(例如数字7)的嵌入向量(如one-hot编码)。这样,在后续生成时,我们就可以通过指定“我想要数字7”来生成特定图像。

公式表示带噪图像 = 原始图像 + 噪声。U-Net的目标是学习一个函数 f(带噪图像, 条件) ≈ 噪声

处理复杂概念:CLIP模型

为了处理更复杂的文本描述(如“优雅的天鹅”),我们需要将文本也转换为嵌入向量。这通常通过CLIP模型实现。CLIP通过对比学习进行训练:它同时训练一个图像编码器和一个文本编码器,目标是让匹配的图像-文本对(例如“优雅的天鹅”文本和对应的天鹅图片)的嵌入向量尽可能接近(点积大),而不匹配的则尽可能远离(点积小)。

核心思想损失 = Σ(匹配对的相似度) - Σ(不匹配对的相似度)

训练完成后,我们就可以使用CLIP的文本编码器将任何提示词(如“优雅的天鹅”)转换为嵌入向量,并将其输入U-Net进行条件生成。

推理过程:逐步去噪

在推理(生成图像)时,我们从一个随机噪声开始,将其输入U-Net。U-Net会预测需要移除多少噪声才能得到目标图像(例如数字3)。最初预测可能不准确,我们只移除一小部分预测的噪声,得到一张稍微清晰的图像。然后,我们重复这个过程多次(例如60步),逐步去除噪声,最终得到清晰的图像。

代码逻辑

latent = 随机噪声
for i in range(去噪步数):
    预测噪声 = U-Net(latent, 条件, 时间步)
    latent = latent - 预测噪声 * 调度系数
最终图像 = VAE解码器(latent)

之前这个过程需要上千步,现在通过改进的调度器可以缩短到60步。我们看到的“噪声”图像实际上是经过VAE编码的潜在表示,所以看起来不像普通高斯噪声。


前沿论文速览 📄

上周有几篇重要论文发布,极大地推动了该领域的发展。本节我们将简要介绍它们。

论文一:渐进式蒸馏加速采样

这篇论文的目标是将生成所需的步数从60步大幅减少到4步。其核心思想是知识蒸馏

传统方法:教师模型(原始的、慢速的稳定扩散模型)逐步去噪,需要很多步。
蒸馏方法:我们训练一个学生模型,让它学习直接“跳步”。具体来说,我们利用教师模型生成大量“输入-输出”对(例如,第36步的噪声潜在变量对应第54步更清晰的潜在变量)。然后,我们训练学生模型,输入是第36步的状态,目标是直接输出第54步的状态。

迭代过程

  1. 训练学生模型A,学习从噪声直接跳到教师模型2步后的结果。
  2. 将学生模型A作为新的教师,训练学生模型B,学习从噪声直接跳到(学生模型A)2步后的结果(相当于原教师4步)。
  3. 重复此过程,每次迭代学生模型都能跳过更多的步数。同时,一个学生模型可以学习在不同噪声水平(时间步)上进行多步跳跃。

这样,我们就得到了一个速度极快、步数很少的学生模型。

论文二:无分类器引导扩散模型的蒸馏

上一节我们介绍了如何加速普通扩散模型。然而,我们通常希望使用无分类器引导来更好地控制生成内容(例如,“一只可爱的小狗”)。传统的CFG需要同时计算有条件和无条件的预测,并进行加权平均,这增加了计算负担。

这篇论文将蒸馏思想应用于CFG。学生模型不仅学习去噪,还将引导尺度作为额外输入进行学习。这样,训练好的学生模型就能内部处理不同引导尺度的影响,在推理时无需分别计算有条件和无条件路径,从而在保持控制力的同时进一步加速。

论文三:Imagic——基于文本的图像编辑

这篇几个小时前刚发布的论文展示了令人惊叹的图像编辑能力。给定一张输入图像和一个目标文本描述(例如,一张鸟的照片和提示词“展翅的鸟”),模型能够对图像进行最小程度的修改,使其符合文本描述,同时最大程度保持原图的其他内容。

工作原理简述

  1. 使用预训练的扩散模型(如Imagen或稳定扩散)。
  2. 优化文本嵌入:固定模型权重,微调目标文本的嵌入向量,使得模型用这个嵌入生成的图像尽可能接近输入图像。这类似于“文本反转”。
  3. 微调扩散模型:固定上一步优化好的嵌入向量,微调整个扩散模型(包括VAE等),使其能根据这个特定嵌入更精确地重建输入图像。
  4. 插值生成:最后,将原始目标文本的嵌入和优化后的嵌入进行加权平均,将这个混合嵌入输入微调后的模型,生成既符合新描述又保持原图特征的最终图像。

这种方法可以在普通硬件上相对快速地实现,为图像编辑打开了新的大门。


代码实战:拆解稳定扩散流程 🛠️

了解了理论前沿后,让我们回到代码,深入理解稳定扩散管道内部的每一步。Hugging Face diffusers 库的团队已经将流程分解,我们可以一步步查看。

主要组件

首先,我们加载预训练好的核心模型,这些都是现成的:

  • 文本编码器:CLIP模型,将提示词转换为嵌入向量。
  • VAE:变分自编码器,用于图像和潜在空间之间的编码/解码。
  • U-Net:执行去噪任务的核心神经网络。
  • 调度器:管理噪声添加/移除的节奏(时间步)。

生成步骤详解

以下是以提示词“宇航员骑马”为例的生成流程:

  1. 分词与编码

    # 将文本转换为token ID
    input_ids = tokenizer("photograph of an astronaut riding a horse")
    # 通过CLIP文本编码器获取文本嵌入
    text_embeddings = text_encoder(input_ids)
    # 同时获取空字符串(无条件)的嵌入,用于分类器自由引导
    uncond_embeddings = text_encoder(tokenizer(""))
    # 将两者拼接,批量处理
    embeddings = torch.cat([uncond_embeddings, text_embeddings])
    
  2. 准备初始潜在噪声

    # 生成随机噪声,尺寸因VAE下采样而缩小
    latent = torch.randn((1, 4, 64, 64))  # (批次, 通道, 高/8, 宽/8)
    
  3. 设置去噪循环

    num_inference_steps = 70
    guidance_scale = 7.5
    scheduler.set_timesteps(num_inference_steps)
    
  4. 迭代去噪

    for t in scheduler.timesteps:
        # 扩展潜在变量以同时处理有条件和无条件输入
        latent_model_input = torch.cat([latent] * 2)
        # 使用调度器缩放输入
        latent_model_input = scheduler.scale_model_input(latent_model_input, t)
        # U-Net预测噪声
        noise_pred = unet(latent_model_input, t, encoder_hidden_states=embeddings).sample
        # 分离有条件和无条件预测
        noise_pred_uncond, noise_pred_text = noise_pred.chunk(2)
        # 应用引导尺度进行加权
        noise_pred = noise_pred_uncond + guidance_scale * (noise_pred_text - noise_pred_uncond)
        # 根据预测,使用调度器计算更新后的潜在变量
        latent = scheduler.step(noise_pred, t, latent).prev_sample
    
  5. 解码为图像

    # 使用VAE解码器将潜在变量转换回图像空间
    image = vae.decode(latent / 0.18215).sample
    # 后处理:缩放、裁剪到[0,1],转换为PIL图像
    image = (image / 2 + 0.5).clamp(0, 1)
    image = image.cpu().permute(0, 2, 3, 1).float().numpy()
    pil_image = Image.fromarray((image[0] * 255).astype(np.uint8))
    

通过将上述步骤整合成简洁的函数,我们可以拥有一个完全透明、易于理解和修改的生成流程。我建议的本周作业是:尝试在此基础上实现负向提示词图像到图像生成功能。


回归基础:从零构建的旅程 🧱

前面的章节我们站在了研究前沿。现在,让我们回到最根本的起点,开始从零构建我们自己的深度学习框架,并最终重新实现稳定扩散。

我们的“地基”

我们允许使用:

  • Python 及标准库
  • Matplotlib(用于绘图)
  • Jupyter Notebook 和 nbdev(用于从笔记本创建模块)
    我们将逐步重新实现 NumPy/PyTorch 中的关键功能,并构建我们自己的小型框架,称之为 miniAI

第一步:处理数据与理解张量

我们从经典数据集 MNIST(手写数字)开始。首先,我们学习如何在不依赖 NumPy 的情况下,用纯 Python 处理和查看数据。

将扁平列表转换为图像矩阵

# 假设 image_flat 是一个784长度的列表(28*28)
def chunks(lst, n):
    """将列表lst分割成长度为n的块。"""
    for i in range(0, len(lst), n):
        yield lst[i:i + n]

# 转换为28x28的列表的列表
image_matrix = list(chunks(image_flat, 28))

创建自定义矩阵类
为了能像 matrix[20, 15] 这样索引,我们创建一个简单的类:

class Matrix:
    def __init__(self, xs): self.xs = xs
    def __getitem__(self, idxs):
        i, j = idxs
        return self.xs[i][j]

当然,在实际中我们会使用 PyTorch 张量,它提供了强大的多维数组操作。理解张量的本质很重要:它不过是多维数组的数学/编程抽象,源于 APL 语言和数学中的张量分析。

理解随机数生成器

在深度学习中,可重复性至关重要,这依赖于随机数生成器(RNG)的状态管理。

伪随机数生成器原理
真正的随机源(如量子涨落)获取慢。我们使用伪随机数生成器(PRNG),它是一个确定的数学函数,给定一个初始种子,能产生一长串看似随机的数字序列。序列中的下一个数由当前内部状态计算得出,并更新该状态。

关键陷阱:并行处理中的RNG状态
在深度学习中,我们常用多进程进行数据增强。如果使用 fork() 创建子进程,子进程会复制父进程的整个内存空间,包括RNG的内部状态。这会导致所有子进程产生完全相同的“随机”序列,破坏了随机性。

示例

import os, time
def get_random():
    # 一个简单的PRNG示例
    global state
    state = (state * 1103515245 + 12345) & 0x7fffffff
    return state

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/4b27de97c639517bbc05138a8df1e36a_13.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/4b27de97c639517bbc05138a8df1e36a_15.png)

state = int(time.time() * 1000) # 初始化种子
if os.fork() == 0:
    # 子进程
    print(get_random()) # 可能和父进程打印出相同的数!
else:
    # 父进程
    print(get_random())

因此,在编写涉及并行化的代码时,必须确保每个进程正确初始化其独立的RNG状态。Python的 random 模块会自动处理 fork 后的重播种,但 NumPy 和 PyTorch 的默认行为可能需要手动处理。

我们实现了一个简单的 PRNG(如 Wichmann-Hill 算法)来演示这一原理,但在实际中我们会使用更高效的 PyTorch RNG。


总结

本节课内容非常丰富。我们一起:

  1. 回顾了稳定扩散的核心训练(预测噪声)和推理(逐步去噪)过程,以及CLIP模型如何实现文本控制。
  2. 探讨了三篇前沿论文
    • 通过渐进式蒸馏将生成步数减少到个位数。
    • 将蒸馏技术应用于无分类器引导模型,进一步提升可控生成的效率。
    • 了解了 Imagic 如何实现基于文本的精准图像编辑。
  3. 深入代码,拆解了Hugging Face稳定扩散管道的每一步,从文本编码、噪声生成、迭代去噪到最终解码,并鼓励大家在此基础上进行扩展。
  4. 开启了从零构建的旅程,从最基础的Python列表操作、自定义矩阵类、理解张量概念,到深入探究了伪随机数生成器的原理及其在并行计算中的关键陷阱

从下节课开始,我们将真正从矩阵乘法起步,一步步构建我们的深度学习框架,直至重新实现稳定扩散的所有组件。这将是一段需要耐心和坚持但收获巨大的旅程。

深度学习基础到稳定扩散模型:5:Lesson 11 课程内容

概述

在本节课中,我们将学习如何阅读和理解深度学习领域的学术论文,并深入探讨矩阵运算的核心概念——广播机制。我们将通过一个具体的论文案例和代码实践,帮助初学者掌握这些关键技能。


社区精彩分享与论文阅读入门

上一节我们探讨了扩散模型的基础,本节中我们来看看社区成员们的一些创新实践,并学习如何阅读一篇新发表的学术论文。

社区创新实践展示

以下是本周论坛上一些令人兴奋的探索:

  • 提示词插值与循环生成:John Robinson 展示了一段通过在不同季节描述的提示词之间进行插值,并以前一个插值序列的最终图像作为下一个序列的起始图像,从而生成稳定、优美的季节过渡视频。这种方法创造了流畅的动态效果。
  • 引导尺度缩放改进:Sebastian 发现并改进了无条件嵌入的更新公式。原始公式 无条件嵌入 + 引导尺度 * (文本嵌入 - 无条件嵌入) 在文本嵌入与无条件嵌入差异较大时,会导致更新步长过大,图像“跳跃”过度。他的解决方案是根据原始无条件更新向量的范数来缩放最终更新,从而稳定生成过程,带来了更丰富的纹理和细节(例如,为马匹补上了缺失的腿)。
  • 动态引导尺度:Rqueo Prachhan 提出了一种新思路:在去噪过程中逐步降低引导尺度,直至为零。这样,模型在初期被强烈引导至目标方向后,后期可以更自由地发挥,从而生成了细节更丰富、更生动的图像(例如,更逼真的眼睛和毛发纹理)。

这些探索表明,即使是代码中的“错误”也可能意外地产生优秀结果(如John的视频),这反向启发了新的研究方向。社区的集体智慧正在快速推动技术进步。

如何阅读一篇深度学习论文

John O 推荐了一篇新论文《DiffEdit》,我们将以此为例,学习阅读论文的方法。

第一步:获取与整理论文

  1. 大多数深度学习论文会先发布在 arXiv 预印本服务器上。
  2. 使用文献管理工具(如 Zotero)保存论文,便于标注、整理和与团队共享。

第二步:高效阅读论文结构
阅读论文的目标是理解核心思想,而非每个细节。可以按以下顺序进行:

  1. 摘要:快速了解论文要解决什么问题(语义图像编辑),核心贡献是什么(自动生成掩码,无需用户提供)。
  2. 引言与图示:通过文字和图示(如 Figure 1)确认你是否理解并感兴趣。本文目标是:输入一张图像和一个文本查询(如“一碗梨”),模型能自动编辑图像中相关部分以匹配查询,同时保持其他部分不变。
  3. 跳过或速读相关工作和背景:这部分通常是为评审设立,或作为领域内读者的复习。初次阅读时可快速浏览,了解大致脉络即可。背景中的数学公式(如 DDPM 目标函数)通常是对已有知识的重述,不必深究,但需留意其中定义的符号(如期望算子 E、范数 ||·||),以便后续理解。
  4. 核心方法:这是阅读重点。仔细阅读描述算法核心步骤的部分。对于《DiffEdit》,其三步流程是:
    • 步骤1(生成掩码):对输入图像加噪,然后分别用参考文本(真实描述)和查询文本(编辑目标)去噪。两次去噪预测的噪声之差,揭示了图像中哪些部分需要改变以符合新描述,据此可自动生成二进制掩码。
    • 步骤2(准备潜在变量):对输入图像进行确定性的编码(加噪),得到一个中间潜在表示。
    • 步骤3(条件去噪与修复):以上述潜在表示为起点,以查询文本为条件进行去噪生成。关键技巧是:在每一步去噪后,将掩码外(不需改变)区域的像素值用原始图像的对应噪声版本替换,从而保留背景。
  5. 实验与结果:直接查看生成图片的效果,判断方法是否有效、是否满足你的需求。对于定量评估指标(如 FID),初期可不必过于关注。
  6. 结论与附录:结论通常总结摘要内容。附录可能包含更多实验细节、失败案例或扩展结果,值得一看。

核心技巧

  • 利用工具理解符号:遇到不认识的数学符号(如希腊字母、特殊算子),可使用 Detexify(手绘识别)或 Mathpix/Pix2Tex(截图转 LaTeX)等工具识别,然后搜索其含义。
  • 明确局限性:通过理解方法原理,可以推断其适用边界。例如,《DiffEdit》适用于需要局部修改且修改前后语义类别相似的场景(如“马变斑马”),但不适用于需要全局风格变化或完全不同物体替换的情况。

课后实践:尝试基于课程中已实现的扩散模型代码,复现《DiffEdit》的核心步骤1(自动掩码生成),这将是一个极好的练习项目。


矩阵运算核心:广播机制详解

上一节我们介绍了如何用循环实现基础的矩阵乘法,本节中我们来看看如何利用广播机制将其效率提升数千倍。

从元素级运算到广播

在 PyTorch 或 NumPy 中,张量间的标准算术运算是元素级的,这要求两个张量形状完全相同。

import torch
a = torch.tensor([10, 6, -4])
b = torch.tensor([2, 8, 7])
# 元素级加法
c = a + b  # tensor([12, 14, 3])
# 元素级比较(布尔值用0/1表示)
d = a > 0  # tensor([1, 1, 0])

广播机制允许我们对形状不同的张量进行元素级运算。其核心规则是:从最右边的维度开始向左比较,两个维度兼容的条件是:1) 相等;或 2) 其中一个是 1。系统会自动将大小为1的维度扩展(复制)以匹配另一个张量对应的维度。

广播基础示例

以下是广播的几个关键示例:

  • 标量与任意形状张量:标量被视为在所有维度上大小为1。
    a = torch.tensor([10, 6, -4])
    # 标量0被广播为 [0, 0, 0]
    e = a > 0
    
  • 向量与矩阵:这是广播中最常见且强大的应用之一。
    # 向量 c: shape (3,)
    c = torch.tensor([10, 20, 30])
    # 矩阵 m: shape (3, 3)
    m = torch.tensor([[1,2,3],[4,5,6],[7,8,9]])
    # 情况1:向量广播到每一行 (c 被视为行向量)
    # c 形状变为 (1,3) -> 广播为 (3,3)
    r1 = m + c[None, :]  # 或 m + c.unsqueeze(0)
    # 结果:每一行加上 [10,20,30]
    # tensor([[11, 22, 33],
    #         [14, 25, 36],
    #         [17, 28, 39]])
    # 情况2:向量广播到每一列 (c 被视为列向量)
    # c 形状变为 (3,1) -> 广播为 (3,3)
    r2 = m + c[:, None]  # 或 m + c.unsqueeze(1)
    # 结果:每一列加上 [[10],[20],[30]]
    # tensor([[11, 12, 13],
    #         [24, 25, 26],
    #         [37, 38, 39]])
    
  • 高效的外积计算:利用广播,可以无需显式循环即可计算向量的外积。
    # 外积: (3,1) * (1,3) -> 广播为 (3,3) 进行元素乘
    outer_product = c[:, None] * c[None, :]
    
  • 高维张量应用:例如,对一幅RGB图像(形状 [256, 256, 3])进行逐通道归一化。
    image = torch.randn(256, 256, 3)
    channel_means = torch.tensor([0.485, 0.456, 0.406])
    channel_stds = torch.tensor([0.229, 0.224, 0.225])
    # channel_stds 形状 (3,) 广播为 (1,1,3) -> (256,256,3)
    normalized_image = (image - channel_means) / channel_stds
    

利用广播加速矩阵乘法

回顾我们之前用循环实现的矩阵乘法。假设我们有一个输入数据矩阵 m1(形状 [5, 784],5张MNIST图像)和权重矩阵 m2(形状 [784, 10])。

原始的朴素三重循环非常缓慢(约450毫秒)。我们可以利用广播消除最内层的循环:

def matmul_broadcast(a, b):
    ar, ac = a.shape
    br, bc = b.shape
    # 结果矩阵
    res = torch.zeros(ar, bc)
    for i in range(ar):
        for j in range(bc):
            # 利用广播进行元素乘后求和,替代内层循环
            # a[i, :] 形状 (ac,) -> (ac,)
            # b[:, j] 形状 (br,) -> (br,)
            # 元素乘后求和,即点积
            res[i, j] = (a[i, :] * b[:, j]).sum()
    return res

这已将速度提升至约600微秒。然而,我们可以更进一步,利用广播同时计算一整行与所有列的点积,从而消除对列索引 j 的循环:

def matmul_broadcast_v2(a, b):
    ar, ac = a.shape
    br, bc = b.shape
    # 关键步骤:将 a 的每一行变为形状 (ac, 1),b 为 (1, br, bc)?不,更简单:
    # 我们想要对于每一行 i,计算它与 b 的所有列的点积。
    # 可以将 a 的每一行 a[i] 视为形状 (1, ac)
    # 然后与 b (形状 (ac, bc)) 相乘并求和。
    # 实际上,利用广播:a[:, :, None] (ar, ac, 1) * b[None, :, :] (1, ac, bc) -> (ar, ac, bc)
    # 再对维度1(ac)求和 -> (ar, bc)
    # 更直观的实现(针对单样本或批处理):
    res = torch.zeros(ar, bc)
    for i in range(ar):
        # a[i, :, None] 形状 (ac, 1)
        # b 形状 (ac, bc)
        # 广播相乘: (ac, 1) * (ac, bc) -> (ac, bc),然后对列求和?不对。
        # 正确点积计算:对对应元素乘后沿ac轴求和。
        # 更有效的方式是直接使用 torch.dot 或 einsum,但为演示广播思想:
        # 我们可以将 a[i] 扩展为 (1, ac),然后与 b.T (bc, ac) 乘?标准做法是:
        res[i] = (a[i].unsqueeze(0) @ b).squeeze(0) # 但这已调用了优化过的 @
    return res

实际上,最彻底且高效的方式是直接利用 PyTorch 的广播机制和 sum 函数,完全消除所有 Python 循环,让运算在C++层并行完成。最终,我们可以实现与 PyTorch 内置 torch.matmul 接近的性能,将 5 张图像的矩阵乘法时间从 450 毫秒 降至 约 100 微秒,加速超过 4000 倍。对于整个数据集(50000张图像),也仅需约600毫秒。

# 完全向量化的矩阵乘法思想(针对单个样本或小批量)
# 对于单个样本向量 x (形状 [784]) 和权重矩阵 w (形状 [784, 10])
x = digit # 形状 [784]
w = weights # 形状 [784, 10]
# 将 x 变为列向量 [784, 1],利用广播与 w 相乘
# x_col = x[:, None] # [784, 1]
# 广播相乘: [784, 1] * [784, 10] -> [784, 10] (每列是 x * w[:,j])
# 然后沿第0维(784维)求和,得到 [1, 10] -> [10]
output_vector = (x[:, None] * w).sum(dim=0)
# 对于批量数据 m1 [batch, 784],可以扩展为:
# m1_ = m1[:, :, None] # [batch, 784, 1]
# w_ = w[None, :, :]   # [1, 784, 10]
# element_wise = m1_ * w_ # [batch, 784, 10]
# output = element_wise.sum(dim=1) # [batch, 10]

总结

本节课中我们一起学习了两个重要主题:

  1. 学术论文阅读方法:我们以《DiffEdit》为例,拆解了阅读一篇深度学习论文的实用流程——从摘要、图示把握核心思想,跳过艰深背景,聚焦方法细节,并通过实验结果判断其价值。我们还介绍了使用工具辅助理解数学符号的技巧。
  2. 广播机制:我们深入探讨了张量运算中广播机制的原理与规则,并通过多个示例展示了其强大的表达能力。最重要的是,我们利用广播将矩阵乘法的效率提升了数千倍,这是实现高效深度学习代码的基石技能。

掌握论文阅读能力将使你能紧跟领域前沿,而精通广播机制则能让你写出简洁、高效的数值计算代码。请务必花时间实践这两项技能。

深度学习基础到稳定扩散模型:6:矩阵乘法、聚类与微积分入门

概述

在本节课中,我们将要学习三个核心主题:深入理解稳定扩散模型背后的“逆问题”概念、通过实现均值漂移聚类算法来练习矩阵运算与GPU加速,以及为后续的反向传播学习微积分基础知识。


逆问题与稳定扩散 🔄

上一节我们介绍了扩散模型的基本原理,本节中我们来看看一个常见的误解:CLIP Interrogator这类工具能否“逆转”图像生成过程。

最近,一个名为CLIP Interrogator的工具受到了很多关注。用户上传一张图片,它会输出一段文本提示。许多人误以为这段文本提示就是能生成原图的“完美提示词”。但事实并非如此,这揭示了一些人对稳定扩散工作原理的误解。

让我们通过一个思想实验来理解为什么无法做到这一点:

  1. 假设你的朋友想通过电子邮件发送一张照片给你。
  2. 为了压缩,他使用CLIP图像编码器将图片转换为一个嵌入向量。这个向量比原图小得多。
  3. 他发送了这个嵌入向量,并期望你能将其“解码”回原图。

这个过程在数学上意味着寻找一个编码函数 F 的逆函数 F⁻¹,使得 F⁻¹(F(x)) = x。然而,并非所有函数都有逆函数。例如,一个将所有输入都映射为0的函数就无法逆转。更重要的是,CLIP编码器将高维图像(如512x512x3)压缩到低维向量,信息已经丢失,因此不存在精确的逆函数。

那么,稳定扩散在做什么?它实际上是在尝试近似解决一个逆问题。扩散过程学习从噪声和条件(如图像或文本嵌入)出发,逐步去噪以生成图像。它并不是在精确反转编码器,而是在生成一个可能产生相似嵌入的图像。

为什么CLIP Interrogator的输出不是“完美提示词”?
试图从图像嵌入反推回原始图片,与试图反推回精确的文本提示,是同样不可行的。两者都需要逆转一个编码器,而这并不存在。目前最好的方法是通过扩散过程来近似。因此,CLIP Interrogator生成的文本是有趣的尝试,但并非能精确复现原图的“咒语”。其代码显示,它实际上是混合了一个预定义的艺术家、风格等列表,并结合了BLIP图像描述模型的输出,而非真正的逆运算。


矩阵乘法优化实践 ⚡

在掌握了逆问题的概念后,我们将回到代码实践,优化矩阵乘法这一核心操作。

上一节我们通过广播将双循环优化为单循环,速度提升了5000倍。本节中我们来看看更强大的工具:爱因斯坦求和约定。

爱因斯坦求和是一种紧凑表示乘积和求和的标记法。整个矩阵乘法操作可以压缩为一行字符。例如,对于两个矩阵 M1 (I x K)M2 (K x J),其矩阵乘法可以用爱因斯坦求和表示为:

torch.einsum('ik,kj->ij', M1, M2)
  • 箭头左侧 ik,kj 是输入矩阵及其维度标记。
  • 箭头右侧 ij 是输出矩阵的维度。
  • 规则是:在输入中重复的维度标记(如 k)意味着沿该轴的元素相乘后求和。如果该标记未出现在输出中,则自动执行求和。

爱因斯坦求和非常方便,能极大简化涉及乘积和求和的代码,并且其执行速度与我们之前最快的广播方法相当。

当然,PyTorch本身提供了更直接的矩阵乘法:

  • X_train @ weights
  • torch.matmul(X_train, weights)
    它们的速度与einsum版本相近。

GPU加速计算 🚀

目前我们的计算都在CPU上进行。为了更快,我们可以利用GPU的并行计算能力。

GPU(如NVIDIA GPU)通过同时执行大量并行操作来工作。为了在GPU上运行,我们需要编写一个“内核”函数,该函数能独立计算输出矩阵中的单个元素,这样成千上万个这样的计算就可以同时进行。

以下是使用Numba库编译CUDA内核的简化步骤:

  1. 使用 @cuda.jit 装饰器定义一个内核函数,计算单个输出元素。
  2. 将输入和输出张量复制到GPU设备。
  3. 配置网格和块的大小(CUDA编程模型细节),然后启动内核。
  4. 将结果从GPU复制回CPU(主机)。

性能对比

  • 原始Python双循环版本:约663毫秒
  • PyTorch CPU矩阵乘法:约15毫秒
  • 我们的CUDA内核版本:约3.61毫秒
  • 直接使用PyTorch GPU张量(.cuda())进行矩阵乘法:约458微秒

最终,使用PyTorch在GPU上进行矩阵乘法,比我们最初的纯Python实现快了约500万倍。这充分说明了为什么在深度学习中必须使用GPU。


均值漂移聚类算法实践 📊

现在,让我们运用所学的张量操作技能来实现一个完整的算法:均值漂移聚类。

聚类分析与我们目前学过的任务不同,它没有要预测的因变量,而是试图在数据中发现相似项的分组(簇)。

我们将实现一个名为“均值漂移”的算法。它的优点是无需预先指定簇的数量,并且能处理任意形状的簇。

算法原理

  1. 对于数据集中的每一个点,我们将其视为“兴趣点”。
  2. 计算该点到数据集中所有其他点的距离。
  3. 根据距离计算权重,距离越近的点权重越高(使用高斯核或三角核函数)。
  4. 计算所有点的加权平均,得到一个新的位置。这相当于将兴趣点向其“局部重心”移动一步。
  5. 对数据集中所有点重复上述步骤(一次迭代)。
  6. 经过多次迭代后,数据点会收敛到几个簇中心。

以下是算法核心步骤的简化代码框架:

def mean_shift_step(X, bandwidth=2.5):
    X_new = X.clone()
    for i, x in enumerate(X):
        # 1. 计算到所有点的距离
        dists = torch.sqrt(((X - x)**2).sum(1))
        # 2. 根据高斯核计算权重
        weights = torch.exp(-0.5 * (dists / bandwidth)**2)
        # 3. 计算加权平均,更新该点位置
        X_new[i] = (weights[:, None] * X).sum(0) / weights.sum()
    return X_new

创建动画可视化 🎬

为了观察聚类过程,我们可以使用Matplotlib创建动画。以下是简化步骤:

from matplotlib.animation import FuncAnimation
fig, ax = plt.subplots()
def update_frame(d):
    if d > 0:
        mean_shift_step(X) # 执行一次更新
    ax.clear()
    ax.scatter(X[:,0], X[:,1], alpha=0.3)
ani = FuncAnimation(fig, update_frame, frames=5, interval=500)
HTML(ani.to_jshtml()) # 在Jupyter中显示

使用广播和GPU进行优化

我们之前的实现有一个Python循环,这在GPU上效率很低。我们可以利用广播一次性计算一个“小批量”点与所有点的距离和权重。

关键思路:

  • 将“兴趣点”从单个向量 x (2,) 变为一个小批量矩阵 x_batch (B, 2)
  • 通过巧妙添加维度,利用广播计算 x_batch 中每个点与大数据集 X (N, 2) 中所有点的距离,得到一个 (B, N) 的距离矩阵。
  • 后续的权重计算和加权平均都可以通过广播和矩阵操作完成,消除显式循环。

通过将数据移至GPU(.cuda())并利用这种批处理广播方式,我们可以实现显著的加速,处理数千个数据点仅需毫秒级时间。

作业与挑战

  • 基础:实现K-Means等其他聚类算法,并尝试制作动画。
  • 进阶:均值漂移算法需要计算所有点对之间的距离(O(N²)复杂度)。思考如何利用局部敏感哈希(LSH)或KD-Tree等近似最近邻算法来加速,只计算附近点的权重。
  • 挑战:基于你的优化思路,尝试设计并实现一个更快的均值漂移算法变体。

微积分基础导论 📈

最后,为了给下一课学习神经网络的核心——反向传播和链式法则做好准备,我们需要回顾微积分的基础概念:导数。

什么是导数?

导数衡量的是函数在某一点处的瞬时变化率,也就是斜率。

考虑一辆汽车行驶的例子:

  • 我们可以记录在不同时间点 t,汽车行驶的距离 s(t)
  • 要计算从 t1t2 之间的平均速度,我们用距离的变化量除以时间的变化量:(s(t2) - s(t1)) / (t2 - t1)。这就是两点间连线的斜率。
  • 导数关心的是在某一个瞬间 t1 的速度。我们可以让时间间隔 d = t2 - t1 变得非常非常小,然后用 (s(t1 + d) - s(t1)) / d 来近似 t1 时刻的瞬时速度。当 d 无限趋近于0时,这个比值就是导数,记作 ds/dt

我们需要的微积分知识

好消息是,对于深度学习,你不需要掌握所有微积分技巧。

  1. 核心概念:理解导数即变化率这一基本思想。如果你需要复习,强烈推荐观看3Blue1Brown的《微积分的本质》系列视频。
  2. 不需要手动求导:我们不需要记忆 x^2 的导数是 2x 这类规则,PyTorch的自动微分会替我们完成。
  3. 关键规则:下一课我们将用到链式法则。它用于计算复合函数的导数,形式为:dy/dx = (dy/du) * (du/dx)。你可以直观地将其中的 du 视为可以“约掉”的微小量。这是反向传播算法的数学基础。

我们不会涉及积分,也尽量避免复杂的极限理论,而是采用更直观的“无穷小量”思维方式来处理导数,这对理解深度学习中的微积分已经足够。


总结

本节课中我们一起学习了:

  1. 逆问题:理解了稳定扩散模型是在近似解决从嵌入向量重建图像的逆问题,澄清了关于“完美反转”工具的误解。
  2. 高效矩阵运算:实践了爱因斯坦求和约定,并成功将矩阵乘法移植到GPU上运行,获得了数百万倍的加速。
  3. 聚类算法实践:从头实现了均值漂移聚类算法,并运用广播和GPU对其进行了优化,同时学习了如何用动画可视化算法过程。
  4. 微积分入门:为学习反向传播打下了基础,明确了导数作为变化率的本质,并预告了链式法则的重要性。

下一课,我们将结合矩阵乘法和链式法则,从零开始实现反向传播算法,这是训练神经网络的关键一步。

深度学习基础到稳定扩散模型:7:反向传播 🔄

在本节课中,我们将要学习神经网络的核心训练算法——反向传播。我们将从一个简单的多层感知机(MLP)开始,逐步推导并实现其前向传播和反向传播过程,最终训练一个能够识别手写数字的模型。

概述 📋

反向传播是训练神经网络的关键算法,它通过计算损失函数相对于网络参数的梯度,并使用梯度下降法来更新这些参数。本节课我们将从零开始,手动实现一个多层感知机的反向传播,并理解其背后的数学原理——链式法则。


神经网络基础回顾 🧠

上一节我们介绍了张量操作,本节中我们来看看神经网络的基本构成。

一个最简单的线性模型可以表示为:y = w * x + b。其中,x是输入(例如一个像素值),w是权重,b是偏置,y是输出(例如该像素属于数字“3”的概率)。

然而,单个线性模型表达能力有限。为了拟合更复杂的函数,我们可以组合多个“修正线性单元”(ReLU)。ReLU函数定义为:f(x) = max(0, x)。通过将多个ReLU“线段”相加,我们可以逼近任意形状的曲线。

当输入是多个像素(例如784个)时,我们就需要处理多维空间中的“平面”。原理是相同的:通过矩阵乘法组合多个输入,然后应用ReLU非线性激活函数,再将结果通过另一个线性层映射到输出空间(例如10个数字类别)。

以下是构建一个简单多层感知机(MLP)所需的步骤:

  1. 定义网络结构:输入层、隐藏层(带ReLU激活)、输出层。
  2. 初始化权重和偏置。
  3. 实现前向传播函数。
  4. 定义损失函数(如均方误差MSE或交叉熵损失)。
  5. 实现反向传播以计算梯度。
  6. 使用梯度下降更新参数。


实现前向传播 ➡️

首先,我们定义网络参数并实现前向传播过程。

import torch
import torch.nn.functional as F

# 定义网络尺寸
n, m, c = 50000, 784, 10  # 样本数, 像素数, 类别数
nh = 50  # 隐藏层神经元数量

# 初始化权重和偏置 (随机初始化权重,偏置初始为0)
w1 = torch.randn(m, nh) / m**0.5  # 第一层权重
b1 = torch.zeros(nh)              # 第一层偏置
w2 = torch.randn(nh, 1)           # 第二层权重 (简化版,单输出)
b2 = torch.zeros(1)               # 第二层偏置

# 线性层函数
def lin(x, w, b):
    return x @ w + b

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_54.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_56.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_58.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_60.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_62.png)

# ReLU激活函数
def relu(x):
    return x.clamp_min(0.)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_64.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_66.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_68.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_70.png)

# 模型前向传播 (简化版,单输出)
def model(xb):
    l1 = lin(xb, w1, b1)  # 第一层线性变换
    l2 = relu(l1)         # ReLU激活
    l3 = lin(l2, w2, b2)  # 第二层线性变换
    return l3

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_72.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_74.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_76.png)

# 计算均方误差损失 (MSE)
def mse(preds, targets):
    # 处理广播问题:确保preds是向量而非列向量
    diff = preds.squeeze() - targets.float()
    return (diff ** 2).mean()

我们使用验证集数据测试前向传播,并计算初始的MSE损失。由于权重是随机的,初始损失会很大。


理解梯度与链式法则 📈

为了优化网络,我们需要计算损失函数相对于每个权重(w1, b1, w2, b2)的梯度。梯度是一个多元函数的偏导数向量,它指向函数值增长最快的方向。

对于神经网络这种复合函数,我们使用链式法则来计算梯度。链式法则指出,对于函数 z = f(y)y = g(x)z 关于 x 的导数为:
dz/dx = (dz/dy) * (dy/dx)

在神经网络中,损失 L 是最终输出 o 的函数,o 又是前一层激活 a 的函数,依此类推。因此,计算 dL/dw1 需要将路径上所有中间导数相乘:
dL/dw1 = (dL/do) * (do/da2) * (da2/dz2) * (dz2/da1) * (da1/dz1) * (dz1/dw1)

这个过程从最终损失开始,向后逐层传播梯度,因此被称为反向传播

一个直观的理解方式是想象一组联动的齿轮。第一个齿轮(输入 x)的转动会带动中间齿轮(激活 a),最终带动最后一个齿轮(损失 L)。链式法则计算的就是 x 转动对 L 产生的总影响,等于每一级齿轮传动比的乘积。


手动实现反向传播 ⚙️

我们将为每个操作(线性层、ReLU、MSE损失)实现其反向传播函数,计算并存储梯度。

def mse_grad(preds, targets):
    # MSE损失 L = mean((preds - targets)^2)
    # dL/d(preds) = 2 * (preds - targets) / n
    diff = preds.squeeze() - targets.float()
    return 2. * diff.unsqueeze(-1) / preds.shape[0]  # 梯度形状需与preds匹配

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_115.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_117.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_118.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_120.png)

def lin_grad(input, output, weight, bias, output_grad):
    # 线性层 output = input @ weight + bias
    # dL/d(input) = output_grad @ weight.T
    # dL/d(weight) = input.T @ output_grad
    # dL/d(bias) = sum(output_grad, dim=0)
    input_grad = output_grad @ weight.T
    weight_grad = input.T @ output_grad
    bias_grad = output_grad.sum(0)
    return input_grad, weight_grad, bias_grad

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_122.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_124.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_126.png)

def relu_grad(input, output_grad):
    # ReLU层 output = max(0, input)
    # d(output)/d(input) = 1 if input > 0 else 0
    # dL/d(input) = output_grad * (input > 0)
    return output_grad * (input > 0).float()

然后,我们按照计算图的反向顺序调用这些函数:

# 前向传播
l1 = lin(xb, w1, b1)
l2 = relu(l1)
l3 = lin(l2, w2, b2)
loss = mse(l3, yb)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_144.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_146.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_148.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_150.png)

# 反向传播
l3_grad = mse_grad(l3, yb)          # 计算损失层梯度
l2_grad, w2_grad, b2_grad = lin_grad(l2, l3, w2, b2, l3_grad) # 计算第二层梯度
l1_grad = relu_grad(l1, l2_grad)    # 计算ReLU层梯度
_, w1_grad, b1_grad = lin_grad(xb, l1, w1, b1, l1_grad) # 计算第一层梯度

现在,w1_grad, b1_grad, w2_grad, b2_grad 就包含了损失相对于每个参数的梯度。我们可以用PyTorch的自动微分来验证我们手动计算的梯度是否正确。


模块化重构:创建Layer类 🧩

上面的代码比较冗长且重复。我们可以通过创建通用的 Module 类来重构,每个层(线性、ReLU、MSE)都继承自它。Module 类负责在正向传播时存储输入和输出,以便在反向传播时使用。

class Module:
    def __call__(self, *args):
        self.args = args          # 存储输入参数
        self.out = self.forward(*args) # 计算并存储输出
        return self.out

    def forward(self): raise NotImplementedError
    def backward(self): raise NotImplementedError

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_170.png)

class Relu(Module):
    def forward(self, inp):
        return inp.clamp_min(0.)
    def backward(self):
        inp, = self.args
        self.inp_grad = (self.out > 0).float() * self.out_grad

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_172.png)

class Lin(Module):
    def __init__(self, w, b):
        self.w, self.b = w, b
    def forward(self, inp):
        return inp @ self.w + self.b
    def backward(self):
        inp, = self.args
        self.inp_grad = self.out_grad @ self.w.T
        self.w.grad = inp.T @ self.out_grad
        self.b.grad = self.out_grad.sum(0)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_174.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_176.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_178.png)

class Mse(Module):
    def forward(self, inp, targ):
        diff = inp.squeeze() - targ.float()
        return (diff ** 2).mean()
    def backward(self):
        inp, targ = self.args
        diff = inp.squeeze() - targ.float()
        self.inp_grad = (2. * diff.unsqueeze(-1)) / inp.shape[0]

然后,我们的模型可以简洁地定义为一系列层的组合,反向传播只需按逆序调用各层的 .backward() 方法。这种设计与PyTorch的 nn.Module 设计思想一致。


使用交叉熵损失改进模型 🎯

之前我们使用了均方误差(MSE)损失,但对于分类问题,交叉熵损失是更标准且效果更好的选择。交叉熵损失与Softmax激活函数通常结合使用。

Softmax函数将神经网络的原始输出(logits)转换为概率分布:
softmax(x_i) = exp(x_i) / sum(exp(x_j)) for j in all classes

为了避免数值计算不稳定(指数运算可能产生极大值),我们使用 Log-Softmax Trick
log_softmax(x_i) = x_i - log(sum(exp(x_j - max(x)))) - max(x)
这样在计算对数时更加稳定。

对于分类问题,目标标签通常是整数形式(如“3”)。交叉熵损失可以高效地计算为:
loss = -log(softmax_preds[target]) 的均值。

PyTorch中,F.cross_entropy 函数已经集成了 log_softmaxnegative log likelihood loss 的计算。

def log_softmax(x):
    # 使用log-sum-exp技巧实现稳定的log_softmax
    m = x.max(-1, keepdim=True).values
    exp_x = (x - m).exp()
    log_sum_exp = exp_x.sum(-1, keepdim=True).log()
    return x - m - log_sum_exp

def cross_entropy_loss(preds, targets):
    # preds: [batch_size, n_classes]
    # targets: [batch_size] (整数标签)
    log_probs = log_softmax(preds)
    # 使用高级索引获取每个目标类别对应的对数概率
    nll = -log_probs[range(targets.shape[0]), targets].mean()
    return nll


实现训练循环 🔁

现在,我们将所有部分组合起来,实现一个完整的训练循环。我们使用小批量梯度下降(Mini-batch SGD)。

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

  1. 将数据划分为小批量(batch)。
  2. 对每个批量执行前向传播,计算预测和损失。
  3. 执行反向传播,计算梯度。
  4. 根据梯度和学习率更新模型参数。
  5. 重复多个周期(epoch)。

lr = 0.1  # 学习率
epochs = 20
bs = 64   # 批量大小

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_249.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_251.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_253.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_255.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_257.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/5149affbe02aaa27d202cb5dce5b7d13_259.png)

for epoch in range(epochs):
    for i in range(0, n, bs):
        # 1. 获取小批量数据
        xb, yb = x_train[i:i+bs], y_train[i:i+bs]

        # 2. 前向传播 (使用改进后的模型,输出10个类别)
        # ... (假设 model() 现在输出 [batch_size, 10])
        preds = model(xb)
        loss = cross_entropy_loss(preds, yb)

        # 3. 反向传播
        loss.backward()  # 如果使用我们的Module类,需要手动调用各层.backward()

        # 4. 更新参数 (梯度下降)
        with torch.no_grad():
            for p in [w1, b1, w2, b2]:
                p -= lr * p.grad
                p.grad.zero_()  # 清零梯度,为下一轮准备

    # 每个epoch后可以计算并打印训练集准确率
    train_acc = (model(x_train).argmax(1) == y_train).float().mean()
    print(f"Epoch {epoch}, Loss: {loss.item():.4f}, Acc: {train_acc:.4f}")

经过几个周期的训练,我们的手写数字识别模型在训练集上的准确率可以显著提升。


总结 🎓

本节课中我们一起学习了神经网络训练的核心——反向传播算法。

我们从一个简单的多层感知机出发,回顾了神经网络的基础。然后,我们深入探讨了梯度的概念和链式法则,这是理解反向传播的数学基础。接着,我们手动实现了线性层、ReLU激活层和损失层的反向传播计算。

为了使代码更清晰和可复用,我们引入了模块化设计,创建了通用的 Module 类以及具体的层类。我们还改进了损失函数,用更适合分类任务的交叉熵损失替代了均方误差损失,并解释了其实现细节和数值稳定技巧(Log-Softmax Trick)。

最后,我们将所有组件整合到一个完整的训练循环中,使用小批量梯度下降成功训练了一个手写数字识别模型。

通过本节课的学习,你应该对神经网络的内部运作机制,特别是梯度如何从损失函数向后传播并用于更新权重,有了扎实的理解。这是理解更复杂深度学习模型的重要基石。

深度学习基础到稳定扩散模型:8:构建训练循环与数据加载器

概述

在本节课中,我们将学习如何从零开始构建一个完整的神经网络训练循环,并深入理解其核心组件。我们将实现数据加载器、优化器、回调函数等关键部分,最终整合成一个简洁高效的训练流程。通过动手实践,你将掌握PyTorch底层机制,并能灵活定制自己的训练框架。


回顾与代码解析

上一节我们讨论了微积分和反向传播在神经网络训练中的高效实现。一位优秀的学生Kak Sinha对相关代码进行了详细解释,我已将链接附在课程资源中。该资源结合了数学推导与代码实现,虽然代码略有不同,但核心思想一致,有助于理解数学与代码之间的联系。

损失函数与链式法则

基本思想是,我们有一个神经网络计算损失函数。假设损失函数为 L,神经网络函数为 n,它接收权重 w 和输入 x。损失函数还需要目标值 y,但为简化讨论,我们暂时忽略。

我们关心的是:如何更新权重 w?即,损失 L 如何随权重 w 的变化而变化?我们可以使用链式法则计算:

∂L/∂w = (∂L/∂n) * (∂n/∂w)

类似地,对于输入 x

∂L/∂x = (∂L/∂n) * (∂n/∂x)

在代码中,out.g 存储了 ∂L/∂ninp.g 存储了 ∂L/∂xw.g 存储了 ∂L/∂w。通过矩阵乘法(或转置),我们实现了这些计算。

如果你对微积分和链式法则不熟悉,我推荐观看3Blue1Brown的《微积分的本质》系列或使用可汗学院资源。只需几个小时,你就能掌握基础知识,从而更好地理解后续内容。


构建训练循环

上一节我们成功创建了一个训练循环,包含以下四个步骤:

  1. 前向传播计算预测值
  2. 计算损失
  3. 反向传播计算梯度
  4. 使用梯度更新权重

令人兴奋的是,所有这些步骤我们都已从零实现。我们成功训练了一个MNIST手写数字识别模型,达到了96%的准确率。

我重构了代码,添加了一个report函数,在每个训练周期结束时打印损失和准确率。这里使用了Python的f-string和格式说明符来美化输出,例如{loss:.2f}表示将loss格式化为两位小数的浮点数。

为了高效工作,我为常用操作设置了键盘快捷键。例如,Q A运行当前单元格上方的所有单元格,Q B运行下方的所有单元格。


重构:使用nn.Module

我们的代码目前有些冗长,缺少一些功能。现在开始重构,目标是减少代码量,同时保持功能不变。我们将使用PyTorch的nn.Module,并学习如何自己构建它。

PyTorch的torch.nn子模块中有一个Module类。通常我们不直接实例化它,而是继承它来创建自定义模块。这样做的好处是,Module会自动跟踪其内部的子模块和参数。

例如,我们可以创建一个自定义的多层感知机(MLP)类:

class MLP(nn.Module):
    def __init__(self, n_in, nh, n_out):
        super().__init__()
        self.l1 = nn.Linear(n_in, nh)
        self.l2 = nn.Linear(nh, n_out)
        self.relu = nn.ReLU()

    def forward(self, x):
        return self.l2(self.relu(self.l1(x)))

创建实例后,我们可以查看其参数:list(model.parameters())会返回所有权重和偏置。这样,我们就不再需要手动将层放入列表并管理参数。我们可以直接遍历model.parameters()来更新权重,或使用model.zero_grad()清零所有梯度。

这使我们的代码更加简洁和灵活。那么,nn.Module是如何自动知道参数和层的呢?它使用了一个称为__setattr__的技巧。

实现自定义nn.Module

让我们自己构建一个简单的nn.Module。在__init__中,我们创建一个字典来存储所有子模块。然后定义__setattr__方法,每当设置属性时(如self.l1 = ...),如果属性名不以_开头,我们就将其存入模块字典,并调用父类的__setattr__来实际设置属性。

__repr__方法用于返回模块的字符串表示,方便打印。parameters方法则遍历所有子模块,并生成其参数的迭代器。这里可以使用yield from语法来简化代码,它相当于逐个yield迭代器中的每个元素。

现在,我们已经理解了nn.Module的原理,可以放心使用PyTorch提供的版本了。


重构:使用nn.Sequential

如果我们想像最初那样,将层放在一个列表中,而不是逐个定义为属性,该怎么办?我们可以查看PyTorch的nn.Sequential是如何实现的。

我们可以创建一个SequentialModel类,继承自nn.Module。在__init__中,我们接收一个层列表,然后遍历它们,使用add_module方法将每一层注册到模块中。在forward方法中,我们依次将输入传递给每一层。

PyTorch提供了nn.ModuleList,它可以自动帮我们注册列表中的所有模块。因此,我们可以这样创建顺序模型:

class SequentialModel(nn.Module):
    def __init__(self, layers):
        super().__init__()
        self.layers = nn.ModuleList(layers)

    def forward(self, x):
        for layer in self.layers:
            x = layer(x)
        return x

另一种有趣的实现方式是使用reduce函数。reduce是一个常见的计算机科学概念,称为“归约”。它从一个初始值开始,遍历序列,并对每个元素应用一个函数,将当前结果和下一个元素结合,最终返回一个值。在这里,reduce可以替代显式的循环。

当然,我们可以直接使用PyTorch内置的nn.Sequential


重构:使用优化器

遍历参数并使用梯度更新权重、然后清零梯度,这是一个非常常见的操作。因此,PyTorch提供了optim模块。让我们自己实现一个简单的SGD优化器:

class Optimizer:
    def __init__(self, params, lr):
        self.params = list(params)
        self.lr = lr

    def step(self):
        for p in self.params:
            p.data -= p.grad * self.lr

    def zero_grad(self):
        for p in self.params:
            p.grad = None

现在,我们的训练循环可以简化为:

opt = Optimizer(model.parameters(), lr)
for epoch in range(epochs):
    # ... 计算损失和梯度
    opt.step()
    opt.zero_grad()

PyTorch内置的torch.optim.SGD功能类似。我们可以创建一个get_model函数来返回模型和优化器,进一步组织代码。


重构:数据集与数据加载器

我们的代码仍然较多。接下来,我们将用更简洁的方式替换数据切片部分。

数据集 (Dataset)

我们创建一个Dataset类,它接收自变量x和因变量y,并存储起来。实现__len__方法返回数据长度,实现__getitem__方法,当使用索引(如dataset[i])时,返回对应的(x, y)元组。

class Dataset:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __len__(self): return len(self.x)
    def __getitem__(self, i): return self.x[i], self.y[i]

创建训练和验证数据集后,我们可以直接使用切片操作获取批量数据。

数据加载器 (DataLoader)

数据加载器是一个迭代器,它从数据集中按批次获取数据。我们创建一个DataLoader类,接收数据集和批次大小。实现__iter__方法,遍历数据索引范围,并yield每个批次的数据。

class DataLoader:
    def __init__(self, ds, bs):
        self.ds, self.bs = ds, bs
    def __iter__(self):
        for i in range(0, len(self.ds), self.bs):
            yield self.ds[i:i+self.bs]

现在,我们的训练循环可以简化为遍历数据加载器:for xb, yb in train_dl:

代码变得简洁后,我们可以开始添加新功能,例如打乱训练数据顺序。


添加采样器与批处理

我们希望每个训练周期中,数据的顺序是随机的。为此,我们创建Sampler类。如果不打乱,它按顺序返回索引;如果打乱,则随机排列索引。

接着,我们创建BatchSampler,它接收一个采样器和批次大小,将索引按批次分组。

然后,修改DataLoader,使其接收一个批采样器。在迭代时,它从批采样器获取一批索引,然后从数据集中获取对应的数据,并使用一个collate函数将这些数据堆叠成张量。

collate函数默认将一批(x, y)元组列表,转换为两个张量:一个堆叠所有x,一个堆叠所有y。它使用zip(*batch)来“转置”列表,然后进行堆叠。

PyTorch的数据加载器正是由这些组件构成:采样器、批采样器、整理函数和数据加载器本身。PyTorch还提供了一些快捷方式,例如可以直接将batch_sizeshuffle参数传递给DataLoader,它会自动创建相应的采样器。

一个有趣的技巧是:如果数据集本身支持通过多个索引一次性获取数据(即__getitem__可以接收索引列表),那么我们可以直接将批采样器作为采样器使用,省去遍历和整理的步骤,这可以显著提高效率。


添加验证集与最终训练循环

现在,我们在训练循环中添加验证集评估。在训练过程中,定期在验证集上计算损失和准确率,并打印出来。

我们使用PyTorch的DataLoader来创建训练和验证数据加载器。对于验证集,我们可以使用更大的批次大小,因为它不需要反向传播,内存占用更少。

最终,我们实现了一个完整、合理的训练循环。虽然代码比理想情况稍多,但每一行及其调用的代码都是我们自己构建或重新实现的。这意味着我们完全理解其原理,可以自由地创建任何我们想要的功能。


使用Hugging Face数据集

现在我们已经有了训练循环,接下来学习如何使用Hugging Face的datasets库来加载数据。这将使我们能够利用海量的开源数据集。

首先安装库:pip install datasets。我们可以加载一个数据集,例如Fashion MNIST。Hugging Face的数据集通常返回字典(包含imagelabel键),而不是元组。

为了将其用于训练,我们需要一个整理函数,将字典批次转换为张量元组。更优雅的方式是使用with_transform方法,在数据加载时应用转换函数(例如将PIL图像转换为张量)。我们可以使用装饰器来简化转换函数的编写。

此外,我们可以使用itemgetter函数和default_collate来创建一个通用的整理函数,将Hugging Face的字典数据集转换为PyTorch期望的元组格式。

为了方便复用,我们将这些实用函数导出到自定义的Python模块中。我们使用nbdev库来管理从笔记本到模块的导出。


数据可视化

良好的可视化对于理解数据和模型至关重要。我们从头开始构建一些绘图工具。

基本的图像绘制使用matplotlibimshow。我们创建show_image函数,它会处理一些细节,如确保数组顺序正确、将张量移至CPU、转换为NumPy数组、设置标题和隐藏坐标轴。

我们使用fastcoredelegates装饰器来包装imshow,这样既能扩展功能,又能自动继承原函数的文档。

为了绘制多个图像,我们需要创建子图。matplotlibsubplots函数返回一个轴(axes)对象数组。我们可以在每个轴上调用show_image来显示不同的图像。

我们进一步封装了subplots,使其能自动计算合适的图形大小,并支持为整个图设置标题。最后,我们创建了show_images函数,它可以方便地显示一批图像及其标签。

这些绘图函数也被导出到我们的自定义模块中,便于后续使用。


Python进阶概念:回调函数与特殊方法

在构建通用的训练器(Learner)之前,我们需要熟悉一些Python进阶概念,例如回调函数和特殊方法(双下划线方法)。这些在后续代码中会频繁使用。

回调函数

回调函数是一个可调用对象,在特定事件发生时被调用。在GUI编程中非常常见,例如按钮点击事件。我们也可以在自己的慢速计算(如模型训练)中使用回调,来定期打印进度或更新进度条。

回调不一定非得是函数。它可以是一个具有特定方法(如before_batchafter_batch)的类。这样可以提供更灵活的控制。

我们可以使用lambda表达式、partial函数或可调用类来定义回调。partial用于固定函数的部分参数,生成一个新函数。

特殊方法

Python中的双下划线方法(如__call____getattr__)是特殊的。它们由Python语言、PyTorch或其他库定义,用于实现特定行为。

例如,+操作符实际上调用了__add__方法。a.b属性访问调用了__getattr__方法(如果属性b不存在)。__getattr__可以用于动态创建属性或提供便捷的访问方式。

理解这些特殊方法对于阅读和编写复杂的框架代码非常重要。


总结

本节课中,我们一起完成了一个深度学习训练基础设施的搭建。我们从最基础的反向传播原理出发,逐步实现了:

  • 自定义神经网络模块 (nn.Module)
  • 优化器 (Optimizer)
  • 数据集 (Dataset) 和数据加载器 (DataLoader),包括采样和多进程加载
  • 完整的训练循环,支持训练集和验证集
  • 与Hugging Face数据集的集成
  • 数据可视化工具
  • 理解了回调函数和Python特殊方法等关键概念

现在,我们已经具备了所有必要的组件,可以构建一个通用的Learner类来封装训练循环。这将使我们能够更高效地实验和研究各种模型。下节课,我们将着手实现这个Learner,并深入探索模型训练与调优。

深度学习基础到稳定扩散模型:9:卷积自编码器

在本节课中,我们将学习如何创建一个卷积自编码器。在这个过程中,我们会发现做好这件事并不容易。如果时间允许,我们还将开始构建一个深度学习框架,以使后续工作更加便捷。

卷积神经网络基础

上一节我们介绍了多层感知机。本节中,我们来看看卷积神经网络。在创建卷积自编码器之前,我们需要先了解卷积:它是什么,以及它的用途是什么?

广义上讲,卷积是一种允许我们向神经网络告知问题结构的方法,这能使网络更容易解决问题。具体到我们的任务,我们处理的是图像。图像排列在网格上:黑白图像是2D网格,彩色图像是3D网格,彩色视频则是4D网格等等。

因此,像素在横向和纵向之间存在关联,它们往往彼此相似。这些维度上像素的差异通常具有意义。出现在不同位置的像素块常常代表相同的事物。例如,一只猫在左上角仍然是猫,即使在右下角也是如此。这种先验信息可以被卷积神经网络自然地捕捉。

一般来说,这是件好事,因为这意味着我们能够使用更少的参数和计算量,因为关于我们正在解决的问题的更多信息被直接编码到了我们的架构中。还有其他架构,如我们目前看到的多层感知机,或我们尚未了解的Transformer网络,它们没有如此强烈地编码这种先验信息。这些架构可能提供更大的灵活性,如果有足够的时间、算力和数据,它们可能发现卷积神经网络难以发现的东西。

因此,我们并不总是使用卷积神经网络,但它们是一个很好的起点,并且理解它们非常重要。卷积不仅用于图像,我们还可以利用一维卷积处理基于语言的任务。所以卷积应用广泛。

卷积操作详解

在这个笔记本中,你可能会注意到我们正在从 miniAI 导入一些东西。miniAI 是我们正在创建的一个小型库,我们使用 NBdev 来创建它。我们导入了 miniAI.trainingminiAI.datasets。例如,在数据集的笔记本中,它以声明默认导出模块为 datasets 开始。一些单元格上有导出指令,在最底部,我们调用了 NBdev export。这将创建一个名为 datasets.py 的文件,其中包含我们导出的那些单元格。它之所以被称为 miniAI.datasets,是因为 NBdev 的所有设置都存储在 settings.ini 中,其中指定了库名为 miniAI

在安装这个库之前,你无法使用它。我们还没有将其作为可pip安装的包上传到公共服务器,但你可以将本地目录安装为Python模块。为此,你使用 pip install -e . 命令,其中 -e 代表可编辑模式,这意味着将当前目录设置为一个Python模块。执行后,它将安装我的库。安装完成后,我就可以从该库导入东西了。

好的,这和之前一样,我们将获取MNIST数据集,并在其上创建一个卷积神经网络。在此之前,我们先讨论什么是卷积。我最喜欢的卷积描述之一来自学生Matt Kinsmith,他写了一篇很棒的文章《从不同角度看CNN》,我将借鉴他的思路。

基本思想是:假设这是我们的图像,它是一个3x3的图像,有九个像素,标记为A到I(大写字母)。卷积使用一个称为的东西,核只是另一个张量,在这个例子中,它是一个2x2矩阵,包含四个值:α, β, γ, δ。

当我们用这个2x2核对这个3x3图像应用卷积时会发生什么?我们取核,将其覆盖在第一个中间的2x2子网格上。具体来说,我们进行颜色匹配,所以第一个2x2覆盖的输出将是:α * A + β * B + γ * D + δ * E。这将产生某个值P,它将位于2x2输出的左上角。

对于2x2输出的右上角,我们将滑动窗口,将核滑动到这里,并将我们的每个系数应用到这些重新着色的方块上。然后滑动到底部左侧,再到底部右侧。最终我们得到这个方程:P = αA + βB + γD + δE + 某个偏置项。Q在右上角,如你所见,它只是α乘以B等等。所以我们基本上是将它们相乘然后相加。你可以想象,我们基本上是将这些展平为秩1张量(向量),然后进行点积,这是思考卷积过程的一种方式。

实现卷积

让我们尝试创建一个卷积。例如,我们获取训练图像并查看一张。然后创建一个3x3核。记住,核只是一个我们已经见过的概念。在计算机科学和数学中,“核”这个词出现很多次。我们已经见过“核”指代我们在GPU上跨许多并行虚拟设备运行的代码片段。这里有一个类似的概念,我们有一个计算,在这种情况下类似于点积,在一个网格上多次滑动发生,但有点不同,这是“核”这个词的另一种用法。所以在这种情况下,核将是一个秩2张量。

让我们用这些值创建一个核。这是一个3x3矩阵,秩2张量。我们可以画出它的样子。不奇怪,它看起来像一堆线条。

如果我们把这个核滑动覆盖到这个28x28图像的每一个3x3区域上会发生什么?例如,左上角的3x3区域有这些名称,那么我们将得到 -a1 - a2 - a3,接下来是0,所以什么都不做,然后 +a7 + a8 + a9。这为什么有趣?让我们试试这里。我获取了图像的前13行和前23列。我实际上显示了数字,并使用灰度条件格式来显示顶部这部分。我们正在看的就是这个顶部。

如果我们取第3、4、5行(记住,这是不包含右端的,所以是行3、4、5,列14、15、16),我们看这三个。如果我们用这个核乘以它们会得到什么?我们得到一个相当大的正值,因为我们有负数的三个是顶行(或0),而有正数的三个它们都接近1,所以我们最终得到一个相当大的数字。对于相同的列,但第7、8、9行呢?这里顶部全是正数,底部全是0。这意味着我们将得到很多负项。不出所料,这正是我们看到的。如果我们做这种点积等效操作,在NumPy中你只需要进行逐元素乘法然后求和。所以这将是一个相当大的负数。

也许你看到了这个核在做什么,也许你从我们创建的张量名称中得到了提示。它是一个寻找顶部边缘的核。所以这个(正数)是顶部边缘,这个(负数)是底部边缘。我们想把这个核应用到这里的每一个3x3窗口。我们可以通过创建一个 apply_kernel 函数来实现,该函数接受特定的行、列和核张量,并执行我们刚才看到的乘法和求和操作。

例如,我们可以通过调用 apply_kernel 来复制这个结果,这里指定的是那个3x3网格区域的中心。我们得到了相同的数字:2.97。现在我们可以将该核应用于这个28x28图像中的每一个3x3窗口。我们将像这样滑动红色部分,但我们实际上有一个28x28的输入,而不仅仅是5x5。为了获取所有坐标,让我们简化到5x5。我们可以创建一个列表推导式:遍历 range(5) 中的每个 i 值,然后对于每个 i,遍历 range(5) 中的每个 j 值。如果我们只看这个元组,你会看到我们得到一个包含所有这些坐标的列表的列表。

这是一个列表推导式中的列表推导式,当你第一次看到时可能会感到惊讶或困惑,但它是一个非常有用的习惯用法,我强烈建议你习惯它。现在,我们不仅要创建这个元组,还要为每个坐标调用 apply_kernel。所以我们将遍历从1到26(因为27是排他的)的所有值,然后对于每个值,再次遍历从1到26的所有值,并调用 apply_kernel。这将给我们应用该卷积核到每个坐标的结果。这就是结果。你可以看到它如我们所愿地高亮了顶部边缘。

你可能会发现,进行这种图像处理竟然如此简单。我们实际上只是对每个窗口进行逐元素乘法和求和。这就是卷积。

更多卷积核示例

我们可以做另一个卷积。这次,我们可以用一个左边缘张量,如你所见,它看起来像是我们顶部边缘张量的旋转版本或转置版本。这就是它的样子。如果我们应用那个核,这次我们将传入左边缘张量。注意,我们实际上传入的是一个函数……抱歉,实际上不是函数,它只是一个张量。所以我们将为相同的列表推导式传入左边缘张量。这次我们得到了左边缘。它高亮了数字中的所有左边缘。

基本上,这里发生的是,一个2x2核在图像上滑动,创建这些输出。你会注意到,在这个过程中,我们丢失了图像最外层的像素。我们稍后会学习如何解决这个问题。但现在请注意,当我们在5x5图像上放置3x3核时,横向只有三个位置可以放置,而不是五个位置,因为我们需要某种边缘。

这很酷,这就是卷积。如果你还记得第一课中的Zeiler和Fergus图片,你可能会认出卷积网络的第一层通常寻找边缘和梯度之类的东西,而这就是它的实现方式。然后,通过卷积层堆叠,并在它们之间使用非线性激活函数,可以将这些边缘组合成曲线、角点等等。

优化卷积计算

好的,那么我们如何快速完成这个操作呢?因为目前用Python做这个会超级慢。我见过最早公开可用的通用目的、GPU加速的深度学习库之一叫做Caffe,由杨庆(Yang Qing Jia)创建。他描述了Caffe如何实现快速卷积。基本上,他说他有两个月的时间来完成它,并且必须完成他的论文。所以他最终做的是,他说外面有一些其他代码,比如Kjaski(你可能听说过他,他和Hinton创办了一家小公司,被谷歌收购,这基本上成了谷歌大脑的起点)的库里有各种花哨的东西,但杨庆说:“我不知道怎么做那些东西,所以我想,我已经知道如何做矩阵乘法,也许我可以把卷积转换成矩阵乘法。” 这后来被称为 im2col

im2col 是一种将卷积转换为矩阵乘法的方法。实际上,我不确定杨庆是否可能无意中重新发明了它,因为我相信在他写论文的时候,这个方法已经存在一段时间了。我相信这个方法是在2006年的这篇论文中创建的。这就是那篇论文中的描述。

他们描述的是:假设你将这个2x2核覆盖在图像的3x3区域上。这个窗口需要匹配这个窗口的部分。你可以做的是,你可以将这个窗口展开为1,1,2,2(向下到这里1,2,1,2),像这样展开它,你也可以在这里展开核1,1,2,2。一旦它们以这种方式被展平并移动,然后你对下一个补丁做完全相同的事情,2,0,1,3,将其展平并放在这里2,0,1,3。所以,如果你基本上以这种格式展平这些核和输入特征,那么你最终会得到一个矩阵乘法。如果你用这个矩阵乘以这个矩阵,你会得到卷积想要的输出。所以这基本上是一种将你的核和输入特征展开为矩阵的方法,这样当你进行矩阵乘法时,你会得到正确的答案。这是一个巧妙的技巧。

这就是所谓的 im2col。实现它有点无聊,只是一堆复制和索引操作。所以我实际上没有自己实现。相反,我链接了一个NumPy实现。它的一部分是这个 get_indices 函数。如你所见,它有点繁琐,涉及重复、平铺和重塑等操作。我不称之为作业,但如果你想练习张量索引操作技能,可以尝试从头创建一个PyTorch版本。我得承认我没费心去做。相反,我使用了PyTorch内置的函数。在PyTorch中,它叫做 unfold

如果我们取我们的图像,PyTorch期望有一个批次轴、一个维度轴和一个通道轴,所以我们将为其添加两个前导单位维度。然后我们可以展开我们的输入,使用一个3x3的核。这将给我们一个9x676的输入。然后我们可以取那个,然后我们将取我们的核并将其展平为一个向量。view 改变形状,-1 表示将所有内容放入这个维度。所以这将创建一个长度为9的向量。现在我们可以像他们在这里做的那样进行矩阵乘法:核矩阵(我们的权重)乘以展开的输入特征。这给我们一个长度为676的结果,然后我们可以将其视为26x26。我们得到了我们期望的左边缘张量结果。

这就是我们如何从头开始创建一个更好的卷积实现。我在这里“作弊”的原因是因为我们确实从头开始创建了卷积。我们并不总是从头开始创建GPU优化版本,这从来不是我承诺的事情。所以我认为这是公平的。但很酷的是,我们可以像最初的深度学习库那样,自己动手实现一个GPU优化版本。

如果我们使用 apply_kernel,需要将近9毫秒。如果我们使用 unfold 加矩阵乘法,我们得到20微秒。所以快了大约400倍。这很酷。当然,我们不必使用 unfold 和矩阵乘法,因为PyTorch有 conv2d,我们可以运行它。有趣的是,至少在GPU上,速度差不多。但在GPU上这个也会工作得很好。我不确定是否总是这样,在这种情况下图像相当小,我没有做大量实验来查看这些方法之间在速度上有多大差异。显然我总是只用 F.conv2d,但如果你需要做一些更复杂的卷积,涉及通道或维度的一些奇怪操作,你总是可以尝试这个 unfold 技巧。知道它存在是很好的。

我们可以对对角线边缘做同样的事情。这是我们的对角线边缘核。或者另一条对角线。如果我们只获取前16张图像,那么我们可以一次性对整个批次应用所有核进行卷积。这是一个很好的优化操作。你最终得到26x26的输出,你有四个通道,你有16张图像。这在这里进行了总结。

所以,为了获得良好的GPU加速,我们通常做的是:一次性跨所有像素处理一批核和一批图像。这就是当我们查看特定图像的各种核时发生的情况:左边缘、顶部边缘,然后是对角线(左上和右上)。

填充与步幅

好的,这就是优化卷积,它在CPU或GPU上都能工作得很好,显然如果你有GPU会更快。现在,我们如何处理丢失每边一个像素的问题?我们可以添加一种叫做填充的东西。对于填充,我们基本上做的是:不是从这里开始我们的窗口,而是从这里开始,实际上还会向上移动一个。所以左边的这三个,我们只取每个的输入为零。所以我们基本上假设它们都是零。还有其他选项可以选择,我们可以假设它们与旁边的那个相同。有各种方法可以做,但最简单且我们通常做的是假设为零。

例如,这被称为单像素填充。假设我们做了两像素填充。对于一个5x5的输入和一个4x4的核,那么我们的核。然后我们从角落这里开始。你可以看到当我们滑动核时,它会经过的所有位置。所以这个虚线区域是我们实际上要经过的区域。但所有这些白色部分,我们都将视为0。然后这个绿色是输出大小,最终将是6x6(对于5x5输入)。我应该提一下,偶数大小的核不常用,我们通常使用奇数大小的核。如果你使用,例如,一个3x3核和一像素填充,你将得到与开始时相同的大小。如果你使用5x5核和三像素填充,你最终会得到与开始时相同的大小。所以一般来说,奇数大小的核更容易处理,以确保你最终得到与开始时相同的东西。

另一个技巧是,你不必总是每次将窗口移动一个位置,你可以每次移动不同的量。你移动的量被称为步幅。例如,这是一个步幅为2的例子,填充为1。所以我们从这里开始,然后跳两个,然后再跳两个,然后到下一行。这被称为步幅2卷积。步幅2卷积很方便,因为它们实际上将输入的维度减少了两倍。这正是我们在自编码器中经常想做的事情。事实上,对于大多数分类架构,我们正是这样做的。我们一次又一次地使用步幅2卷积和填充1,将网格大小减少两倍。

构建卷积神经网络

这就是步幅和填充。让我们继续使用这些方法创建一个卷积网络。

我们将获取训练集的大小,这和之前一样:类别数量、数字数量、隐藏层大小。之前,对于我们的顺序线性模型(MLP),我们基本上是从像素数到隐藏层数,然后一个激活函数,然后从隐藏层数到输出数。

这是卷积的等效结构。现在的问题是,你不能直接这样做,因为输出现在不是批次中每个项目的10个概率,而是批次中每个项目在每个28x28像素位置上的10个概率,因为我们甚至没有使用步幅。所以你不能直接使用我们在MLP中使用的简单方法。我们必须更加小心。

为了让事情更容易,让我们创建一个小型的 conv 函数,它执行一个 conv2d 操作,步幅为2,可选地后跟一个激活函数。所以如果 act 为真,我们将添加一个ReLU激活。所以这将返回一个 conv2d 层,或者一个包含 conv2d 层后跟 ReLU 的小型顺序模块。

现在我们可以从头开始创建一个CNN作为顺序模型。由于激活默认为真,这将接收我们的28x28图像,从一个通道开始,创建四个通道的输出。所以这是输入通道数,这是滤波器数量(有时我们说滤波器来描述卷积具有的通道数,这是输出数量)。这类似于线性层中输出数量的概念,只不过这是卷积中的输出数量。

当我创建这样的东西时,我喜欢添加一个小注释来提醒自己经过这一层后我的网格大小是多少。我有一个28x28的输入。然后我通过一个步幅2卷积,所以这层的输出将是14x14。然后我们再做同样的事情,但这次我们从四通道输入到八通道输出,然后从八到十六。到这个时候,我们现在降到了4x4。然后降到2x2。最后,降到1x1。在最后一层,我们不会添加激活函数。最后一层将创建10个输出。既然我们现在降到了1x1,我们可以直接调用 flatten,这将移除那些不必要的单位轴。

如果我们把这个小批次输入进去,我们最终得到我们想要的16x10输出。所以对于我们的16张图像中的每一张,我们都有10个概率(每个可能数字的概率)。如果我们获取训练集并将其转换为28x28图像,并对验证集做同样的事情,然后我们为每个创建两个数据集:训练数据集和验证数据集。我们现在要在GPU上训练这个网络。

如果你有Mac,你可以使用一个叫做MP的设备(如果你有Apple Silicon Mac,你有一个叫做MP的设备,它将使用你的Mac GPU)。如果你有NVIDIA,你可以使用CUDA,这将使用你的NVIDIA GPU,速度可能比Mac快10倍或更多,所以如果可能的话,你肯定想使用NVIDIA。但如果你只是在Mac笔记本上运行,你可以使用MP。基本上,你需要知道使用什么设备:我们想使用CUDA还是MP?你可以检查 torch.backends.mps.is_available() 来看看你是否在带有MP的Mac上运行。你可以检查 torch.cuda.is_available() 来看看你是否有NVIDIA GPU(如果有,你就有CUDA)。如果你两者都没有,当然你将不得不使用CPU进行计算。

我在这里创建了一个小函数 to_device,它接收一个张量或字典或张量列表等等,以及一个要移动到的设备。它只是遍历并将所有东西移动到该设备上,或者如果是字典,则将字典中的每个值移动到该设备上。这是一个方便的小函数。然后我们可以创建一个自定义的 collate 函数,

深度学习基础到稳定扩散模型:10:构建首个灵活训练框架

在本节课中,我们将学习如何构建一个灵活的训练框架,称为“Learner”。我们将从一个基础版本开始,逐步引入回调函数、指标跟踪和钩子等概念,最终构建一个功能强大且易于定制的训练循环。通过这个过程,你将能够深入理解模型训练的内部机制,并学会如何诊断和优化训练过程。


基础回调函数学习器

上一节我们介绍了一个功能有限的基础学习器。本节中,我们来看看如何通过引入回调函数来增加其灵活性。

基础回调函数学习器与之前的学习器结构相似,但关键区别在于它引入了回调函数机制。fit 函数会遍历每个周期,调用 one_epoch 进行训练或评估。one_epoch 则会遍历数据加载器中的每个批次,并调用 one_batch。在 one_batch 中,我们调用模型、损失函数,如果是训练模式则执行反向传播。

以下是 fit 函数的核心结构:

def fit(self, epochs, train_dl, valid_dl=None, lr=0.1):
    self.opt = self.opt_func(self.model.parameters(), lr)
    self.callback('before_fit')
    for epoch in range(epochs):
        self.one_epoch(True, train_dl)
        if valid_dl is not None:
            self.one_epoch(False, valid_dl)
    self.callback('after_fit')

回调函数通过 run_callbacks 函数执行。该函数会按照回调函数的 order 属性排序,然后依次调用指定名称的方法。

回调函数的工作原理

回调函数是一个类,可以定义以下一个或多个方法:before_fitafter_fitbefore_epochafter_epochbefore_batchafter_batch。学习器会在训练过程中的相应时间点调用这些方法。

例如,一个简单的完成回调函数可以这样定义:

class CompletionCallback:
    def before_fit(self): self.count = 0
    def after_batch(self): self.count += 1
    def after_fit(self): print(f'Completed {self.count} batches')

学习器会在开始训练前调用 before_fit,在每个批次后调用 after_batch,在训练结束后调用 after_fit。通过这种方式,我们可以在不修改学习器核心代码的情况下,添加各种自定义行为。

使用回调函数控制训练流程

回调函数的一个强大功能是能够通过抛出异常来控制训练流程。学习器捕获三种特定异常:CancelFitExceptionCancelEpochExceptionCancelBatchException

例如,我们可以创建一个回调函数,在完成一个批次后抛出 CancelFitException 来提前结束训练:

class SingleBatchCB:
    def after_batch(self): raise CancelFitException

通过设置回调函数的 order 属性,我们可以控制其执行顺序。这为我们提供了极大的灵活性,例如可以轻松实现只训练一个批次的功能。

指标跟踪与设备回调

为了在训练过程中跟踪和显示指标(如准确率和损失),我们引入了指标类。指标类可以累积批次数据并计算加权平均值。

同时,我们创建了一个设备回调函数,用于自动将模型和数据移动到指定的计算设备(如GPU)。这简化了多设备训练的设置。

以下是设备回调函数的核心:

class DeviceCB:
    def __init__(self, device=default_device): self.device = device
    def before_fit(self): self.learn.model.to(self.device)
    def before_batch(self): self.learn.batch = to_device(self.learn.batch, self.device)

灵活学习器与上下文管理器

在构建了基础回调函数学习器后,我们进一步创建了“灵活学习器”。其核心改进是使用上下文管理器来简化回调函数的调用和异常处理。

我们定义了一个上下文管理器 callback_context,它会在代码块执行前后自动调用相应的 beforeafter 回调方法,并处理取消异常。

@contextmanager
def callback_context(self, name):
    self.callback(f'before_{name}')
    try: yield
    except globals()[f'Cancel{name.title()}Exception']: pass
    finally: self.callback(f'after_{name}')

这使得 fitone_epoch 等函数的代码更加简洁和一致。

训练回调与进度条

训练的具体步骤(如前向传播、损失计算、反向传播、优化器步进和梯度清零)被抽象到名为 TrainCB 的回调函数中。这意味着我们可以通过替换或继承此回调函数来轻松改变训练行为。

此外,我们添加了一个进度条回调函数,用于实时显示训练进度、损失曲线和指标。这大大增强了训练过程的可视化。

学习率查找器

学习率查找器是一个重要的工具,用于寻找合适的学习率。其原理是逐步增加学习率,并观察损失的变化,从而找到损失开始上升前的临界点。

我们实现了一个学习率查找器回调函数,它会在每个批次后按一定倍数增加学习率,并在损失显著恶化时自动停止训练。

使用钩子洞察模型内部

为了深入理解模型训练时的内部状态,我们引入了PyTorch的“钩子”机制。钩子允许我们在模型的前向或反向传播过程中注册回调函数,从而捕获中间层的激活值。

我们创建了一个 Hook 类和 Hooks 类来方便地管理多个钩子。通过钩子,我们可以收集并可视化各层激活值的均值和标准差,以及其分布直方图。

以下是使用钩子收集统计信息的示例:

def append_stats(hook, mod, inp, outp):
    if not hasattr(hook, 'stats'): hook.stats = ([],[])
    hook.stats[0].append(outp.mean().cpu().item())
    hook.stats[1].append(outp.std().cpu().item())

可视化这些统计信息可以帮助我们诊断训练问题,例如梯度消失或爆炸。

彩色维度图

为了更直观地展示激活值的分布,我们创建了“彩色维度图”。该图将每个批次、每个层的激活值直方图编码为一列彩色像素,从而形成一个二维图像。通过观察该图像,我们可以快速判断激活分布是否健康(是否接近均值为0、标准差为1的正态分布)。

总结

本节课中我们一起学习了如何构建一个灵活、强大的深度学习训练框架。我们从基础的回调函数机制出发,逐步增加了指标跟踪、设备管理、进度显示、学习率查找和模型内部洞察等功能。关键收获在于理解了如何通过回调函数和钩子来非侵入式地扩展和控制训练流程,这为我们后续诊断和优化模型训练奠定了坚实基础。在下一课中,我们将探讨模型初始化等关键主题,以进一步提升训练的稳定性和效率。

深度学习基础到稳定扩散模型:11:初始化、归一化与优化器

概述

在本节课中,我们将学习如何正确初始化神经网络、使用归一化层(如批归一化和层归一化)以及实现不同的优化器(如SGD、动量和Adam)。我们的目标是训练一个在Fashion MNIST数据集上达到90%以上准确率的分类器。我们将从零开始构建这些工具,并理解它们背后的数学原理。


库更新与工具介绍

上一节我们介绍了钩子(hooks)和激活统计。本节中,我们来看看对迷你AI库进行的一些小改进。

首先,我们在回调类中添加了 __getattr__ 方法,提供了四个常用属性的快捷访问:

  • model: 对应 self.learn.model
  • opt: 对应 self.learn.opt
  • batch: 对应 self.learn.batch
  • epoch: 对应 self.learn.epoch

此外,还添加了 self.training 属性,方便在回调中检查模型是否处于训练模式。

其次,我们将训练方法(如 fit)从 MomentumLearner 子类中提取出来,放入了新的 TrainLearner 基类中。这样,MomentumLearner 只需继承 TrainLearner 并添加动量逻辑即可。

我们还改进了激活统计的可视化工具,将其封装为回调 ActivationStats,可以方便地绘制彩色维度图、死亡单元图和统计图表。现在,只需一行代码即可将这些有用的可视化添加到训练过程中。


目标与问题:为何训练困难?

我们的目标是使用一个简单的卷积网络在Fashion MNIST上达到90%的准确率。然而,初始尝试的训练曲线非常糟糕,学习率查找器也几乎无法提供有用信息。

观察激活统计图,我们发现了一个典型问题:各层的激活值均值和标准差严重偏离了理想的0和1。这会导致梯度在深层网络中爆炸或消失,使得训练极其困难甚至不可能。

问题的根源在于:

  1. 输入数据未归一化:原始图像像素的均值约为0.28,标准差约为0.35。
  2. 权重初始化不当:对于使用ReLU激活函数的网络,标准的Glorot/Xavier初始化并不适用。

理论基础:均值、方差与初始化

为了理解解决方案,我们需要回顾一些核心概念。

方差衡量数据点偏离均值的程度。对于张量 t,其方差公式为:
Var(t) = mean((t - mean(t)) ** 2)
一个计算上更便捷的等价公式是:
Var(t) = mean(t ** 2) - (mean(t)) ** 2

标准差是方差的平方根,使量纲与原始数据一致。

协方差衡量两个变量一起变化的程度。对于张量 tu
Cov(t, u) = mean((t - mean(t)) * (u - mean(u)))

初始化的重要性:深度神经网络是多个矩阵乘法的堆叠。如果权重矩阵的尺度不当,经过多层传播后,激活值的尺度会指数级地爆炸或收缩,导致训练失败。

Glorot/Xavier初始化:针对线性激活函数(如tanh),通过确保每层输出的方差为1,推导出初始化权重应服从均匀分布 U[-a, a],其中 a = sqrt(6 / (n_in + n_out)),或正态分布 N(0, sqrt(2 / (n_in + n_out)))。对于全连接层,n_inn_out 是输入和输出的神经元数量;对于卷积层,n_inkernel_size ** 2 * in_channels

Kaiming/He初始化:针对ReLU激活函数,由于其将一半的激活值置零,会减半方差。因此,初始化时需要补偿这个因子。正确的初始化是使用正态分布 N(0, sqrt(2 / n_in))


实践解决方案一:归一化与Kaiming初始化

以下是解决训练问题的具体步骤:

  1. 归一化输入数据:将输入数据的均值调整为0,标准差调整为1。这可以通过数据转换或回调实现。
    # 使用数据转换
    transform = T.Compose([T.ToTensor(), T.Normalize(0.28, 0.35)])
    # 或使用回调
    class BatchTransformCB(Callback):
        def __init__(self, func): self.func = func
        def before_batch(self): self.learn.batch = self.func(self.learn.batch)
    norm_func = lambda b: ((b[0]-b[0].mean())/b[0].std(), b[1])
    norm_cb = BatchTransformCB(norm_func)
    

  1. 应用Kaiming初始化:为卷积层的权重应用正确的初始化。
    def init_weights(m):
        if isinstance(m, nn.Conv2d):
            nn.init.kaiming_normal_(m.weight, a=0.1) # a是ReLU负半轴的斜率
            if m.bias is not None: nn.init.zeros_(m.bias)
    model.apply(init_weights)
    

应用这些步骤后,模型能够正常训练,准确率提升至85%左右,激活统计图也明显改善。然而,由于ReLU的输出不可能有负值,其均值始终为正,这限制了归一化的效果。


实践解决方案二:改进激活函数与LSUV

为了获得真正的零均值激活,我们设计了一个新的激活函数:广义ReLU。它在Leaky ReLU的基础上,增加了一个可学习的偏移量(sub),使得激活分布可以围绕零点对称。

def general_relu(x, leak=0.1, sub=0.4, maxv=None):
    x = F.leaky_relu(x, leak)
    if sub: x.sub_(sub)
    if maxv is not None: x.clamp_max_(maxv)
    return x
act_fn = partial(general_relu, leak=0.1, sub=0.4)

同时,我们需要更新权重初始化函数,将 leak 参数传递给 kaiming_normal_

使用广义ReLU和正确的初始化后,训练更加稳定,准确率提升至87%,激活统计图接近理想状态。

另一种更通用的初始化方法是 层序单位方差初始化。其思想非常直观:

  1. 将一批数据输入网络。
  2. 遍历每一层,计算该层输出的均值和标准差。
  3. 通过调整该层的偏置(减去均值)和权重(除以标准差),使该层输出的均值为0,标准差为1。
  4. 固定该层,对下一层重复此过程。

LSUV的优点是与激活函数无关,可以用于任何架构。


归一化层:BatchNorm与LayerNorm

虽然好的初始化解决了训练开始的问题,但在训练过程中,每层输入的分布仍在变化(内部协变量偏移)。归一化层将归一化操作作为模型架构的一部分,在每次前向传播时执行。

层归一化 对每个样本的所有通道、高度和宽度维度计算均值和方差,并进行归一化。它包含可学习的缩放(mult)和偏移(add)参数。

class LayerNorm(nn.Module):
    def __init__(self, dim, eps=1e-5):
        super().__init__()
        self.eps = eps
        self.mult = nn.Parameter(torch.ones(dim))
        self.add = nn.Parameter(torch.zeros(dim))
    def forward(self, x):
        # x: (batch, channels, height, width)
        dims = (1,2,3) # 对通道、高、宽求均值/方差
        mean = x.mean(dims, keepdim=True)
        var = x.var(dims, keepdim=True, correction=0)
        x = (x - mean) / (var + self.eps).sqrt()
        return x * self.mult + self.add

批归一化 对每个通道,跨批次和空间维度(高度、宽度)计算均值和方差。它同样有可学习的参数,并在训练时维护一个指数移动平均的全局均值和方差,用于推理阶段。

class BatchNorm(nn.Module):
    def __init__(self, nf, mom=0.1, eps=1e-5):
        super().__init__()
        self.mom, self.eps = mom, eps
        self.mult = nn.Parameter(torch.ones (1,nf,1,1))
        self.add = nn.Parameter(torch.zeros(1,nf,1,1))
        self.register_buffer('vars', torch.ones (1,nf,1,1))
        self.register_buffer('means', torch.zeros(1,nf,1,1))
    def forward(self, x):
        if self.training:
            dims = (0,2,3) # 对批次、高、宽求均值/方差
            mean = x.mean(dims, keepdim=True)
            var = x.var(dims, keepdim=True, correction=0)
            # 更新移动平均
            self.means.lerp_(mean, self.mom)
            self.vars.lerp_(var, self.mom)
        else:
            mean, var = self.means, self.vars
        x = (x - mean) / (var + self.eps).sqrt()
        return x * self.mult + self.add

批归一化允许我们使用更高的学习率,并通常能带来更快的收敛和更好的最终性能。在我们的实验中,结合批归一化、较小的批次大小(256)和学习率衰减,准确率达到了89.9%,接近90%的目标。

归一化层家族还包括实例归一化和组归一化,它们的主要区别在于计算均值和方差时聚合的维度不同。


优化器:从SGD到Adam

优化器负责根据损失函数的梯度更新模型参数。我们首先实现基础的 SGD

class SGD:
    def __init__(self, params, lr, wd=0.):
        self.params, self.lr, self.wd = list(params), lr, wd
        self.i = 0 # 批次计数器
    def step(self):
        with torch.no_grad():
            for p in self.params:
                # 权重衰减(L2正则化)
                if self.wd != 0:
                    p *= (1 - self.lr * self.wd)
                # 梯度下降
                if p.grad is not None:
                    p -= self.lr * p.grad
        self.i += 1
    def zero_grad(self):
        for p in self.params:
            p.grad = None

动量 通过引入梯度指数移动平均来平滑更新方向,有助于加速收敛并逃离局部极小值。

class Momentum(SGD):
    def __init__(self, params, lr, wd=0., mom=0.9):
        super().__init__(params, lr, wd)
        self.mom = mom
    def step(self):
        with torch.no_grad():
            for p in self.params:
                if p.grad is None: continue
                # 初始化或获取动量状态
                state = getattr(p, 'grad_avg', None)
                if state is None:
                    state = torch.zeros_like(p.grad)
                    p.grad_avg = state
                # 更新动量(指数移动平均)
                state.lerp_(p.grad, 1-self.mom)
                # 权重衰减
                if self.wd != 0:
                    p *= (1 - self.lr * self.wd)
                # 使用动量更新参数
                p -= self.lr * state
        self.i += 1

RMSProp 通过除以梯度平方的指数移动平均的平方根,来调整每个参数的学习率。这对于处理稀疏梯度或非平稳目标函数很有用。

class RMSProp(SGD):
    def __init__(self, params, lr, wd=0., sqr_mom=0.99, eps=1e-8):
        super().__init__(params, lr, wd)
        self.sqr_mom, self.eps = sqr_mom, eps
    def step(self):
        with torch.no_grad():
            for p in self.params:
                if p.grad is None: continue
                # 初始化或获取平方梯度状态
                state = getattr(p, 'sqr_avg', None)
                if state is None:
                    # 技巧:用第一个批次的梯度平方初始化,避免初始步长过大
                    state = p.grad ** 2
                    p.sqr_avg = state
                else:
                    state.lerp_(p.grad**2, 1-self.sqr_mom)
                # 权重衰减
                if self.wd != 0:
                    p *= (1 - self.lr * self.wd)
                # 自适应学习率更新
                p -= self.lr * (p.grad / (state.sqrt() + self.eps))
        self.i += 1

Adam 结合了动量和RMSProp的思想,是当前最流行的优化器之一。它同时维护梯度的一阶矩(动量)和二阶矩(平方梯度)估计,并进行偏差校正。

class Adam(SGD):
    def __init__(self, params, lr, wd=0., beta1=0.9, beta2=0.99, eps=1e-8):
        super().__init__(params, lr, wd)
        self.beta1, self.beta2, self.eps = beta1, beta2, eps
    def step(self):
        with torch.no_grad():
            for p in self.params:
                if p.grad is None: continue
                # 初始化状态
                state1 = getattr(p, 'avg', None)
                if state1 is None:
                    state1 = torch.zeros_like(p.grad)
                    p.avg = state1
                    state2 = torch.zeros_like(p.grad)
                    p.sqr_avg = state2
                else:
                    state2 = p.sqr_avg
                # 更新一阶矩和二阶矩
                state1.lerp_(p.grad, 1-self.beta1)
                state2.lerp_(p.grad**2, 1-self.beta2)
                # 偏差校正
                unbias1 = state1 / (1 - self.beta1 ** self.i)
                unbias2 = state2 / (1 - self.beta2 ** self.i)
                # 权重衰减
                if self.wd != 0:
                    p *= (1 - self.lr * self.wd)
                # 更新参数
                p -= self.lr * (unbias1 / (unbias2.sqrt() + self.eps))
        self.i += 1

使用动量优化器,我们可以将学习率提高到1.5,并获得更平滑的训练曲线,准确率提升至87.6%。


总结

本节课中我们一起学习了深度神经网络训练中的三个核心主题:

  1. 初始化:正确的权重初始化(如Kaiming初始化)和输入数据归一化是训练深层网络的基础,可以防止梯度爆炸或消失。我们还介绍了通用的LSUV初始化方法。
  2. 归一化层:批归一化和层归一化通过内部归一化激活值,缓解了内部协变量偏移问题,允许使用更高的学习率,并通常能加速训练、提升模型性能。
  3. 优化器:我们从基础的SGD出发,实现了带动量的SGD、RMSProp以及结合二者优点的Adam优化器,理解了它们如何通过利用梯度历史信息来更高效地更新参数。

通过综合运用这些技术——正确的初始化、广义ReLU、批归一化以及动量优化器——我们成功地将一个简单卷积网络在Fashion MNIST上的准确率从无法训练提升到了接近90%。在下一课中,我们将探索更多技术,最终突破90%的准确率大关。

深度学习基础到稳定扩散模型:12:深度学习基础到稳定扩散

在本节课中,我们将学习如何使用Excel电子表格来直观理解随机梯度下降及其加速方法(如动量、RMSProp和Adam),然后将其与PyTorch中的实现联系起来。我们还将深入探讨学习率调度、ResNet架构以及数据增强技术,最终构建一个在Fashion-MNIST数据集上达到先进水平的模型。

在Excel中理解SGD及其变体 🧮

上一节我们介绍了优化器的概念,本节中我们来看看如何在Microsoft Excel中手动实现它们,以直观地理解其工作原理。

基础SGD

我们从一个简单的线性回归问题开始。数据由公式 y = A * x + B 生成,其中斜率 A = 2,截距 B = 30。我们的目标是使用随机梯度下降来学习这些参数。

以下是SGD的基本步骤:

  1. 初始化参数:我们从随机猜测开始,例如截距和斜率都设为1。
  2. 计算预测值:对于第一个数据点 (x=14, y=58),使用当前参数计算预测值:预测值 = 斜率 * x + 截距
  3. 计算误差:使用均方误差:误差 = (预测值 - 实际值)^2
  4. 估计梯度:为了知道如何更新参数,我们需要计算损失函数关于每个参数的梯度。我们可以通过“有限差分法”来近似:将参数增加一个很小的值(如0.01),重新计算误差,然后使用公式 (误差变化量) / 0.01 来估计梯度。
  5. 更新参数:使用梯度下降公式更新参数:新参数 = 旧参数 - 学习率 * 梯度
  6. 迭代:对数据集中的每个数据点(或小批量)重复步骤2-5,完成一个“周期”。

在Excel中,我们可以逐行设置这些计算。通过一个简单的VBA宏,我们可以自动重复多个周期,并观察均方根误差逐渐下降,参数逐渐接近真实值(2和30)。然而,基础SGD,特别是对于截距的更新,可能非常缓慢。

带动量的SGD

为了解决基础SGD收敛慢的问题,我们引入了动量。动量的核心思想是保留之前更新方向的一部分,并将其与当前梯度结合,从而在持续相同的方向上加速。

以下是动量更新的公式:

更新量 = β * 上一轮更新量 + (1 - β) * 当前梯度
新参数 = 旧参数 - 学习率 * 更新量

其中,β 是动量系数(通常设为0.9)。在Excel表格中,我们添加了额外的列来计算这个“动量化”的梯度。可以看到,当梯度持续为负时(意味着我们需要增加参数值),动量项会累积,导致更新步长越来越大,从而显著加快了收敛速度。

RMSProp

RMSProp通过调整每个参数的学习率来工作,它关注的是梯度大小的历史平均值。

其更新规则如下:

缓存 = β * 旧缓存 + (1 - β) * (当前梯度^2)
新参数 = 旧参数 - 学习率 * (当前梯度 / sqrt(缓存 + ε))

这里,缓存 是梯度平方的指数移动平均。如果某个参数的梯度历史变化很大(缓存大),则其更新步长会被缩小;如果变化平缓(缓存小),则更新步长相对较大。这有助于处理稀疏或变化剧烈的梯度。

Adam

Adam结合了动量和RMSProp的思想,可以说是两者的融合。它同时计算梯度的一阶矩(动量)和二阶矩(未中心化的方差)的指数移动平均。

以下是Adam的更新公式:

动量 = β1 * 旧动量 + (1 - β1) * 当前梯度
缓存 = β2 * 旧缓存 + (1 - β2) * (当前梯度^2)
修正后动量 = 动量 / (1 - β1^t)
修正后缓存 = 缓存 / (1 - β2^t)
新参数 = 旧参数 - 学习率 * (修正后动量 / (sqrt(修正后缓存) + ε))

其中 t 是时间步。在Excel的Adam工作表中,我们实现了这些计算。通过运行宏,可以观察到Adam通常能更快、更稳定地收敛到最优解附近。

自动学习率调度(实验性)

在Adam表格的基础上,我们尝试了一个简单的自动学习率衰减策略:跟踪一个周期内平均梯度平方的最小值。如果当前周期的平均梯度平方比历史最小值下降了一半,则将学习率降低为原来的四分之一。这个启发式方法基于一个想法:当优化进入一个更平坦、更稳定的区域时,降低学习率有助于收敛。实验表明,这种方法可以自动地将参数调整到非常接近最优值。

在PyTorch中实现学习率调度 ⚙️

上一节我们在Excel中手动探索了优化器,本节中我们来看看如何在PyTorch中利用其内置的优化器和调度器。

探索PyTorch调度器

首先,我们需要了解PyTorch的优化器如何工作。与我们的自定义实现不同,PyTorch优化器将参数分组管理,每组可以有不同的超参数(如学习率)。优化器的状态(如动量)存储在一个以参数张量为键的字典中。

我们可以使用 dir(torch.optim.lr_scheduler) 来查看所有可用的调度器。例如,CosineAnnealingLR 实现了余弦退火调度。

创建调度器回调

为了在我们的Learner框架中使用PyTorch的调度器,我们创建了一个通用的调度器回调。这个回调在每次批量训练后(或每个周期后)调用调度器的 step() 方法,从而更新学习率。

我们还创建了一个Recorder回调,用于记录训练过程中的学习率(和动量等),以便可视化。

一周期策略

一周期策略是一种非常有效的学习率调度方法,它由Leslie Smith提出。其核心思想是:

  • 学习率:从一个较低值开始,在训练的前一部分线性或余弦上升到很高的值,然后在后一部分下降回很低的值。
  • 动量:与学习率的变化趋势相反,从高值开始,下降到低值,然后再上升。

这种组合有助于模型在训练初期稳定地进入一个较好的参数空间,然后利用高学习率快速穿越,最后通过低学习率精细调整。使用 OneCycleLR 调度器,我们的模型在Fashion-MNIST上仅用5个周期就达到了超过90%的准确率。

构建与优化ResNet 🏗️

之前我们使用了一个简单的CNN,现在我们来将其改造成更强大的ResNet架构。

ResNet的核心思想

随着网络加深,传统的深度网络会遇到“退化问题”:更深的网络反而有更高的训练误差。ResNet通过引入“残差连接”解决了这个问题。

一个基本的残差块执行以下操作:
输出 = 激活函数( 卷积块(输入) + 恒等映射(输入) )

其中,恒等映射 可能是一个简单的直连(如果输入输出维度匹配),也可能是一个1x1卷积(用于调整维度)。关键点在于,如果我们将残差块中最后一个批归一化层的权重初始化为0,那么在训练开始时,残差块的输出将为0,整个块就相当于一个恒等映射。这使得深层网络在初始化时表现得像浅层网络一样,从而更容易训练。

实现ResNet

我们实现了一个ResBlock类,它包含两个卷积层和一个快捷连接。然后,我们简单地用ResBlock替换了之前CNN架构中的所有卷积层。这个改动立竿见影,将准确率从91.7%提升到了92.2%。

模型分析与优化

我们使用一个summary工具来查看模型的层结构、参数数量和计算量(FLOPs)。通过分析,我们发现:

  1. 加深加宽:将第一个卷积核大小从3改为5,并将通道数翻倍(最终达到512),准确率提升至93.7%。
  2. 效率优化:我们发现最后一个残差块参数巨大但计算量占比不高。通过移除最后一个将通道数翻倍的块,参数数量从490万骤降至120万,但准确率几乎不变(92.7%)。这说明了参数数量并不直接等同于模型效率或效果。
  3. 进一步加速:第一个残差块由于在28x28的网格上执行5x5卷积,计算量最大。将其改为单个卷积层,可以显著减少计算量,同时保持精度。

最终,我们得到了一个既快速、小巧又准确的模型。

高级技巧:数据增强与集成 📈

当训练时间更长时,模型可能会过拟合(记忆训练集)。我们需要正则化技术。

数据增强

数据增强通过对训练图像进行随机变换(如裁剪、翻转)来人工增加数据多样性,从而防止过拟合。我们在GPU上使用批量变换回调来实现数据增强,包括随机裁剪、水平翻转以及我们自己实现的“随机复制”(用图像的另一部分随机覆盖一块区域,这比用噪声填充更能保持数据分布)。

使用数据增强训练20个周期后,准确率达到了93.8%。

测试时增强与集成

  • 测试时增强:在预测时,对验证集图像也进行增强(例如,同时使用原图和水平翻转的图),然后对多次预测结果取平均。这可以小幅提升性能(从93.8%到94.2%)。
  • 模型集成:训练多个不同的模型(例如,使用不同数据增强策略或初始化),然后将它们的预测结果进行平均。集成通常能获得比任何单一模型更好的性能。在我们的实验中,两个模型的集成在25个周期内达到了94.4%的准确率。

通过结合精心设计的ResNet架构、一周期调度、数据增强和集成技术,我们最终在50个周期内达到了94.6%的准确率,这在Fashion-MNIST数据集上是一个非常有竞争力的结果。

总结 🎯

本节课中我们一起学习了:

  1. 在Excel中手动实现SGD、动量、RMSProp和Adam,直观理解了它们的工作原理。
  2. 在PyTorch中配置和使用学习率调度器,包括余弦退火和一周期策略。
  3. 实现并分析了ResNet架构,理解了残差连接如何解决深度网络的退化问题,并通过模型分析进行优化。
  4. 应用了数据增强、测试时增强和模型集成等高级技巧来提升模型性能并防止过拟合。

我们从零开始,仅用常识性设计和基础组件,构建了一个在Fashion-MNIST上达到先进水平的深度学习模型。这证明了深度学习的核心思想是可理解、可构建的。

深度学习基础到稳定扩散模型:13:DDPM 从零实现

概述

在本节课中,我们将学习如何从零开始实现一个去噪扩散概率模型的核心部分。我们将重点关注DDPM的前向加噪过程、训练目标以及采样生成过程,并使用我们构建的迷你AI框架来训练一个生成Fashion-MNIST图像的模型。


上节课回顾与Dropout改进

上一节我们介绍了ResNet和BatchNorm。在论坛挑战中,Christopher Thomas提出了使用Dropout来改进模型性能。

Dropout是一个简单而强大的正则化技术。其核心思想是在训练时,以概率 p 随机将网络中的部分激活值设置为零。这可以防止网络过度依赖某些特定的神经元,从而提升泛化能力。

以下是一个基础的Dropout类实现:

class Dropout(nn.Module):
    def __init__(self, p=0.5):
        super().__init__()
        self.p = p

    def forward(self, x):
        if not self.training or self.p == 0:
            return x
        mask = (torch.rand(x.shape) > self.p).to(x.device)
        return x * mask / (1 - self.p)

在代码中,我们只在训练模式下应用Dropout。mask 是一个与输入 x 形状相同的张量,其中元素以概率 1-p 为1,以概率 p 为0。最后,我们将输出乘以 1/(1-p) 来在训练时保持激活值的总体期望不变。

通过在最后一个线性层前添加Dropout,我们在Fashion-MNIST挑战中获得了轻微的性能提升(从93.0%到93.2%)。Christopher Thomas进一步优化了Dropout的使用方式,首次突破了95%的准确率。

一个有趣的扩展是Dropout2D,它不是独立地丢弃每个激活值,而是丢弃整个特征图通道。实现Dropout2D是一个很好的练习。


生成式建模与DDPM简介

本节中,我们来看看生成式建模的核心目标,并介绍去噪扩散概率模型的基本框架。

生成式建模的目标是学习数据分布 P(x)。例如,在图像生成中,x 是一张图片,P(x) 描述了看到这张图片的概率。如果我们能近似这个分布,就可以从中采样,创造出新的、类似的数据。

DDPM是当前流行的生成模型之一。其核心思想包含两个过程:

  1. 前向过程:逐步向一张干净图片 x0 添加高斯噪声,经过 T 步后,得到纯噪声 xT
  2. 反向过程:学习一个神经网络,使其能够从噪声 xT 开始,逐步预测并移除噪声,最终恢复出干净图片 x0

在训练时,我们实际只需要学习反向过程。DDPM论文的关键简化在于,它将学习目标转化为一个简单的噪声预测任务


DDPM 前向过程与关键变量

让我们深入DDPM的数学细节。首先,我们定义几个关键变量,它们将在代码中直接出现。

前向过程每一步的加噪操作由以下公式定义:
q(xt | xt-1) = N(xt; √(1-βt) * xt-1, βt * I)

其中:

  • xtxt-1 是相邻时间步的噪声图像。
  • N 表示高斯分布(正态分布)。
  • βt 是一个预先定义好的、随时间 t 增大的方差调度参数。它控制了每一步添加的噪声量。
  • I 是单位矩阵。

由于每一步都是高斯分布,并且过程是马尔科夫的,我们可以推导出从原始图像 x0 直接得到任意时间步 t 的噪声图像 xt 的公式:
xt = √(ᾱt) * x0 + √(1 - ᾱt) * ε
其中 ε ~ N(0, I),而 ᾱt = ∏(1-βs)s 从1到 t

这个公式极其重要:

  • √(ᾱt) 是保留原始信号的系数,随着 t 增大而减小。
  • √(1 - ᾱt) 是添加噪声的系数,随着 t 增大而增大。
  • ε 就是我们添加的噪声。

在代码中,我们会预先计算好 βtαtᾱt 这些序列。


构建DDPM训练流程

现在,我们将上述数学公式转化为实际的训练代码。我们将使用一个回调函数来集成到我们的迷你AI训练框架中。

训练的核心步骤如下:

  1. 从数据集中取出一批干净图像 x0
  2. 为批次中的每个图像随机采样一个时间步 t
  3. 根据公式 xt = √(ᾱt) * x0 + √(1 - ᾱt) * ε 计算加噪后的图像 xt,其中 ε 是随机采样的噪声。
  4. xt 和对应的时间步 t 输入神经网络,让网络预测所添加的噪声 ε
  5. 计算网络预测的噪声与真实噪声 ε 之间的均方误差作为损失。

以下是回调函数中 before_batch 方法的关键代码,它负责在每批数据送入模型前进行加噪:

def before_batch(self):
    x0 = self.learner.batch[0]          # 干净图像
    t = torch.randint(0, self.n_steps, (x0.shape[0],), device=x0.device) # 随机时间步
    ε = torch.randn_like(x0)            # 随机噪声
    α_bar_t = self.α_bar[t][:, None, None, None] # 获取对应t的α_bar,并调整维度
    xt = torch.sqrt(α_bar_t) * x0 + torch.sqrt(1 - α_bar_t) * ε # 计算加噪图像
    self.learner.batch = (xt, t), ε     # 新的批次:(输入, 时间步), 目标噪声

我们的模型是一个U-Net,它接收 xtt 作为输入,并输出预测的噪声。通过自定义 predict 方法,我们可以适配Hugging Face Diffusers库中U-Net的接口。


采样生成新图像

训练好噪声预测模型后,我们就可以进行采样,从纯噪声生成新的图像。这是反向过程。

采样是一个迭代过程,从 xT ~ N(0, I) 开始:

  1. 对于 tT1
    • 将当前噪声图像 xt 和时间步 t 输入模型,得到预测的噪声 ε_θ
    • 使用 ε_θ 来估计去噪后的图像 x0_hat
    • 根据公式计算 xt-1,它是 x0_hatxt 和少量额外随机噪声的加权组合。
  2. 循环结束后,x0 就是最终生成的图像。

这个过程可以理解为:我们并不相信模型能一步到位完美去噪,因此每次只相信它一部分,并保留一部分上一步的噪声状态,同时加入一点新的随机性,然后让模型在这个新起点上再次预测。随着步骤进行,我们越来越相信模型的预测。

在我们的实现中,采样函数是DDPM回调的一部分。调用 learn.ddpm_cb.sample(model, shape) 即可生成图像。


代码实现与初步结果

我们将上述所有组件整合起来。使用Fashion-MNIST数据集(调整为32x32),一个来自Diffusers库的U-Net模型,以及我们定义的DDPM回调。

训练循环与我们之前训练分类器时几乎完全相同,这展示了回调系统的强大灵活性:

# 初始化回调、模型、学习器
ddpm_cb = DDPCallback(n_steps=1000, min_beta=0.0001, max_beta=0.02)
model = UNet2DModel(...)
learn = Learner(dls, model, cbs=[ddpm_cb], loss_func=MSELossFlat())

# 训练
learn.fit_one_cycle(5, 1e-3)

即使只训练了5个周期(约几分钟),我们也能生成出可识别的T恤、鞋子、裤子等Fashion-MNIST类别的图像,虽然细节还比较模糊。这验证了我们从零实现的核心流程是有效的。


优化与改进方向

初步实现的DDPM还有很大的优化空间。本节我们探讨一些可能的改进方向。

模型初始化:默认的PyTorch初始化可能不是最优的。我们可以尝试:

  • 将部分卷积层的权重初始化为零。
  • 对某些层使用正交初始化。
  • 将输出层的权重初始化为零,让模型初始预测为零噪声。

优化器:将Adam优化器的 eps 参数从默认的 1e-8 调整为 1e-5,可以防止在训练中期学习率有效值过大导致的梯度爆炸,从而允许使用更大的学习率。

噪声调度:原始的线性 βt 调度在应用于小图像时,很多时间步的加噪效果相似,没有充分利用所有步骤。后续论文(如Improved DDPM)提出了余弦调度等改进方案,可以更有效地利用时间步。

混合精度训练:使用16位浮点数进行计算可以大幅提升GPU上的训练速度,同时通过混合精度技术来管理数值精度,防止梯度下溢或溢出。这将是下一节课的一个重点。


总结

本节课中,我们一起学习了DDPM的核心原理并从零实现了其关键训练和采样流程。

我们首先回顾了Dropout技术及其改进。然后,深入探讨了生成式建模的目标和DDPM的框架,理解了前向加噪和反向去噪两个过程。我们定义了 βtᾱt 等关键变量,并推导了直接加噪公式 xt = √(ᾱt) * x0 + √(1 - ᾱt) * ε

在实现部分,我们构建了 DDPCallback 回调来处理训练时的随机加噪和损失计算,并实现了采样函数来生成新图像。通过将DDPM训练无缝接入我们现有的迷你AI框架,我们成功训练了一个能够生成Fashion-MNIST图像的噪声预测模型。

最后,我们讨论了包括模型初始化、优化器设置、噪声调度和混合精度训练在内的多种优化方向。这为我们后续构建更强大、更高效的生成模型奠定了坚实的基础。在接下来的课程中,我们将深入U-Net架构并实现混合精度训练,继续向稳定扩散模型迈进。

深度学习基础到稳定扩散模型:14:深度学习基础到稳定扩散

概述

在本节课中,我们将学习如何实现混合精度训练,并探索如何在不依赖特定库的情况下,通过自定义数据加载器和训练回调来优化训练流程。我们还将介绍如何使用加速库(Accelerate)来简化混合精度和多GPU训练,并探讨一些提升训练速度的技巧。最后,我们会通过风格迁移和神经细胞自动机的实例,展示如何利用深度学习框架进行创造性应用。


混合精度训练的实现

上一节我们介绍了混合精度训练的基本概念,本节中我们来看看如何具体实现它。

为了进行混合精度训练,我们需要调整训练循环中的几个关键步骤。根据PyTorch官方文档,典型的混合精度训练流程包括使用autocast上下文管理器来管理计算精度,以及使用GradScaler来缩放梯度,以防止在低精度下梯度下溢。

以下是实现混合精度训练的核心步骤:

  1. 在正向传播(计算预测和损失)时,使用torch.cuda.amp.autocast上下文管理器,将计算转换为半精度(FP16)。
  2. 在反向传播时,使用GradScaler.scale(loss).backward()来代替常规的loss.backward()
  3. 在优化器更新权重时,使用GradScaler.step(optimizer)GradScaler.update()

为了将这些步骤集成到我们的训练框架中,我们创建了一个MixedPrecision回调。这个回调利用了训练循环中新添加的钩子点(如after_predictafter_lossafter_backwardafter_step),在合适的时机插入混合精度相关的代码。

代码示例:MixedPrecision回调的核心部分

class MixedPrecision(Callback):
    def __init__(self):
        self.scaler = GradScaler()
        self.autocast = None

    def before_batch(self):
        # 在批次开始前进入autocast上下文
        self.autocast = torch.cuda.amp.autocast(enabled=True, dtype=torch.float16)
        self.autocast.__enter__()

    def after_loss(self):
        # 计算损失后退出autocast上下文
        self.autocast.__exit__(None, None, None)

    def backward(self, loss):
        # 使用GradScaler进行缩放后的反向传播
        self.scaler.scale(loss).backward()

    def step(self):
        # 使用GradScaler更新优化器
        self.scaler.step(self.opt)
        self.scaler.update()

通过这个回调,我们可以轻松地将混合精度训练添加到任何使用我们框架的模型中。为了充分利用混合精度带来的速度优势,通常需要增加批次大小以保持GPU的繁忙状态。在本例中,我们将批次大小增加了四倍,并相应地调整了学习率和训练周期数,最终在更短的时间内达到了与全精度训练相当的效果。


简化数据流程:移除DDPM回调

在实现了混合精度训练之后,我们进一步探索如何简化整个数据准备流程。我们之前使用了一个专门的DDPM回调来为扩散模型添加噪声。现在,我们尝试将其功能整合到数据加载器的整理函数(collate function)中,从而完全移除这个回调。

其核心思想是:数据加载器的整理函数负责将一批数据样本组合成模型可用的张量。默认的整理函数是default_collate。我们可以创建一个自定义的整理函数,它首先调用default_collate处理批次数据,然后对得到的图像张量应用noisify函数(即添加噪声的过程)。

代码示例:自定义整理函数

def ddpm_collate(batch):
    # 使用默认整理函数处理批次
    collated = default_collate(batch)
    # 对图像部分添加噪声
    x = collated['x']
    x_noisy = noisify(x)
    collated['x'] = x_noisy
    return collated

然后,我们创建一个ddpm_dataloader函数,它返回一个使用这个自定义整理函数的数据加载器。这样,在创建学习器(Learner)时,我们就不再需要传入特殊的DDPM回调,而是使用标准的训练回调即可。这种方法使得代码更加模块化和清晰,各个部分(数据加载、噪声添加、模型训练)的职责更加分离。


使用Accelerate库进行加速

虽然我们可以手动实现混合精度,但有一个名为Accelerate的优秀库(由Hugging Face的Sylvain Gugger创建,他曾在Fast.ai工作)可以帮我们自动处理这些细节,并且还支持多GPU和TPU训练。

Accelerate提供了一个统一的接口,只需几行代码就能为训练循环加速。使用Accelerate的基本步骤是:

  1. 初始化一个Accelerator对象,并指定所需的配置(如混合精度模式)。
  2. 使用accelerator.prepare()方法包装模型、优化器、数据加载器。这个方法会处理设备放置、数据并行分发以及混合精度上下文的创建。
  3. 在训练循环中,将loss.backward()替换为accelerator.backward(loss)

为了将其集成到我们的框架,我们创建了一个AccelerateCB回调,它继承自TrainCB,并重写了backward方法以使用Accelerate的版本。

代码示例:Accelerate回调

class AccelerateCB(TrainCB):
    def __init__(self, fp16=True):
        self.accelerator = Accelerator(mixed_precision='fp16' if fp16 else 'no')

    def before_fit(self):
        # 使用accelerator准备所有组件
        self.learner.model, self.learner.opt, self.learner.dls.train, self.learner.dls.valid = \
            self.accelerator.prepare(self.learner.model, self.learner.opt,
                                     self.learner.dls.train, self.learner.dls.valid)

    def backward(self, loss):
        # 使用accelerator的反向传播
        self.accelerator.backward(loss)

使用Accelerate后,我们不再需要手动管理autocastGradScaler,库会自动处理。对于简单的混合精度训练,这可能只是一个便捷的捷径,但其真正价值在于能够轻松扩展到多设备训练场景。


支持多输入/多输出模型

在构建更复杂的模型(如扩散模型)时,我们经常需要处理多个输入(例如,带噪声的图像和时间步)和多个输出。为了使我们的训练回调更加通用,我们对其进行了扩展,使其能够灵活地处理任意数量的输入和输出。

我们在TrainCB回调中添加了一个n_inp参数,用于指定模型期望的输入数量。在predictget_loss方法中,我们使用*操作符来解包批次数据,根据n_inp将输入传递给模型,并将其余部分传递给损失函数。

代码示例:支持多输入/输出的训练回调

class TrainCB(Callback):
    def __init__(self, n_inp=1):
        self.n_inp = n_inp

    def predict(self):
        # 根据n_inp解包输入
        preds = self.model(*self.batch[:self.n_inp])
        return preds

    def get_loss(self, preds):
        # 将预测和剩余的批次数据传递给损失函数
        loss = self.loss_func(preds, *self.batch[self.n_inp:])
        return loss

这样,我们的模型和损失函数就可以自由地定义它们所需的参数数量,而训练框架能够自动适应。例如,对于扩散模型,我们可以设置n_inp=2,并将噪声图像和时间步作为输入。


提升数据加载速度的技巧

当数据加载和预处理成为训练瓶颈时(例如在Kaggle上,CPU资源可能有限),有一个技巧可以提升效率:重复使用已加载的批次。

这个技巧的核心是创建一个包装器数据加载器,它在每次迭代时从原始数据加载器中获取一个批次,然后将这个批次重复输出多次(例如两次)。这意味着每个训练周期(epoch)的长度会翻倍,但数据加载和增强的操作次数保持不变。

代码示例:批次重复数据加载器

class RepeatDL:
    def __init__(self, dl, n_repeat=2):
        self.dl = dl
        self.n_repeat = n_repeat

    def __iter__(self):
        for batch in self.dl:
            for _ in range(self.n_repeat):
                yield batch

从优化的角度看,连续几次使用相同的批次进行参数更新通常是可行的。模型在第一次看到批次时,会在权重空间中朝某个方向更新;第二次看到相同的批次时,它可能会基于新的权重位置找到另一个有益的更新方向。这个技巧可以有效地提高GPU利用率,特别是在数据加载较慢的场景下。


风格迁移:原理与实践

现在,让我们将注意力转向一个有趣的应用:风格迁移。风格迁移的目标是将一幅图像(内容图像)的结构与另一幅图像(风格图像)的 artistic style 结合起来,生成一幅新的图像。

基础:直接优化像素

我们的起点是学习如何通过优化直接改变图像的像素值。与优化神经网络权重不同,这里我们将图像像素本身作为可优化的参数。

我们创建一个TensorImage类,它继承自nn.Module,但其参数只是一个普通的图像张量。通过将其放入nn.Parameter,PyTorch会将其视为需要优化的参数。我们使用一个虚拟的数据集和数据加载器来运行固定次数的优化迭代。

代码示例:优化图像像素的模型

class TensorImage(nn.Module):
    def __init__(self, img_tensor):
        super().__init__()
        self.img = nn.Parameter(img_tensor)

    def forward(self, x=None): # x是占位符,为了适配Learner
        return self.img

初始的损失函数是简单的均方误差(MSE),目标是让生成的图像在像素级别上接近目标内容图像。通过随机初始化图像并优化,我们可以逐渐将其转变为目标图像。

引入感知损失

然而,像素级的MSE损失过于严格,且不能很好地捕捉图像的语义内容。因此,我们引入“感知损失”(Perceptual Loss)。我们使用一个预训练的卷积神经网络(如VGG16)作为特征提取器。通过比较生成图像和目标图像在网络中间层激活值的差异,我们可以衡量它们在更高层次特征上的相似性。

公式:感知损失

PerceptualLoss(I_gen, I_target) = Σ_i || φ_i(I_gen) - φ_i(I_target) ||^2

其中,φ_i 表示预训练网络第 i 层的激活特征图。

通过优化感知损失,我们可以生成在语义结构上与目标图像相似,但不必在像素级别完全一致的图像。选择不同的层(浅层或深层)可以控制我们对细节或整体结构的关注程度。

实现风格损失:格拉姆矩阵

为了捕捉风格,我们需要一种能衡量图像纹理特征但忽略其空间位置的方法。这就是“格拉姆矩阵”(Gram Matrix)。对于一个层的特征图(形状为 C x H x W),我们首先将其重塑为 C x (H*W),然后计算该矩阵与其转置的乘积。

公式:格拉姆矩阵

G = F * F^T

其中,F 是重塑后的特征矩阵,形状为 C x NN = H*W)。

格拉姆矩阵 G 的形状为 C x C,它捕获了不同特征通道之间的相关性,即哪些纹理特征倾向于同时出现,而完全丢失了这些特征在图像中的位置信息。

完整的风格迁移

完整的风格迁移损失是内容损失和风格损失的加权和:

TotalLoss = α * ContentLoss(I_gen, I_content) + β * StyleLoss(I_gen, I_style)

其中,ContentLoss 通常使用深层特征的感知损失,StyleLoss 是多个层(通常是浅层和中间层)的格拉姆矩阵损失之和。通过调整 αβ,我们可以控制最终输出中内容与风格的比重。

优化过程从内容图像(或随机噪声)开始,通过梯度下降不断调整像素值,以最小化这个总损失,最终得到既保留内容结构又具有目标风格的新图像。


神经细胞自动机:一个创造性探索

最后,我们探索一个更具实验性的领域:神经细胞自动机(Neural Cellular Automata, NCA)。NCA受生物学中细胞自组织现象的启发,每个网格“细胞”都是一个简单的神经网络,它仅能感知其直接邻居的状态,并根据这些信息更新自身的状态。

NCA的工作原理

  1. 局部感知:每个细胞查看其3x3邻居区域(可能包括自身)。
  2. 神经网络更新:将邻居信息(可能通过一些固定的过滤器处理,如识别梯度)输入一个小型MLP(可能只有几十或几百个参数),该网络输出细胞状态的更新值。
  3. 随机更新:并非所有细胞在每一步都更新,引入一个随机更新掩码来模拟生物系统中的异步性,这有助于打破对称性并产生更丰富的模式。
  4. 环形填充:为了生成可无缝平铺的纹理,在网格边界使用环形填充(circular padding),使得边缘细胞能与对侧的细胞“通信”。

训练NCA生成纹理

训练目标是让NCA从随机初始状态开始,经过若干步迭代后,生成的纹理在风格上匹配目标风格图像。我们再次使用风格损失(基于格拉姆矩阵)作为优化目标。

训练过程采用了一个“池化”(pool)策略:

  1. 维护一个状态网格的池。
  2. 每次训练时,从池中采样一批网格。
  3. 对这些网格应用NCA更新规则多次(例如50步)。
  4. 计算最终输出与目标风格的损失,并更新NCA网络的参数。
  5. 将更新后的输出网格放回池中,作为未来训练的起点。

这种池化训练使NCA不仅学会了从随机状态生长出目标纹理,还学会了维持和修复已形成的纹理,从而提高了其鲁棒性。

由于NCA的更新规则是局部且一致的,它可以非常高效地在GPU上并行运行,甚至可以在Web浏览器中实时渲染,展示了极小参数模型通过自组织产生复杂、动态模式的强大能力。


总结

本节课中我们一起学习了多个高级主题。我们深入探讨了混合精度训练的实现细节,并学会了如何通过自定义数据流程和利用Accelerate库来优化训练。我们还掌握了提升数据加载效率的实用技巧。通过风格迁移的实例,我们理解了如何结合内容损失和风格损失(基于感知特征和格拉姆矩阵)来创造艺术图像。最后,我们探索了神经细胞自动机这一前沿领域,看到了如何用极小的、局部连接的神经网络通过自组织生成复杂纹理,这为创造性AI应用打开了新的大门。这些知识将帮助你构建更高效、更灵活、更具创造性的深度学习项目。

深度学习基础到稳定扩散模型:15:实验跟踪、评估指标与加速采样 🚀

在本节课中,我们将学习如何系统地跟踪深度学习实验,使用量化指标评估生成图像的质量,并探索更高效的采样算法来加速图像生成过程。


概述

上一节我们深入探讨了扩散模型的核心训练过程。本节中,我们将关注实验的工程化实践。首先,我们会看到如何使用工具(如Weights & Biases)来记录和管理实验。接着,我们将学习两个重要的图像生成评估指标:FID和KID。最后,我们会探索DDIM采样算法,它能在保持图像质量的同时,显著提升采样速度。


从Fashion MNIST到CIFAR-10 📈

为了验证我们的方法在更复杂数据集上的有效性,我们决定从Fashion MNIST升级到CIFAR-10数据集。CIFAR-10包含10个类别的彩色图像,尺寸为32x32像素,是图像生成和分类论文中常用的基准数据集。

以下是CIFAR-10数据的一些关键点:

  • CIFAR-10图像是3通道(RGB)的,而Fashion MNIST是单通道的。
  • 图像尺寸较小,视觉上有时难以辨认细节。
  • 由于图像质量本身不高,仅凭肉眼判断生成效果具有挑战性。

我们的代码具有良好的通用性。即使数据形状从单通道变为三通道,noisify函数和采样函数依然可以正常工作,这得益于PyTorch的广播机制。

# 即使数据形状变化,代码依然有效
noise = torch.randn_like(x)  # 自动适应 x 的形状 (batch, 3, 32, 32)

实验跟踪与Weights & Biases 📊

当实验训练时间变长、超参数组合增多时,手动记录和管理实验结果变得非常繁琐。为了解决这个问题,我们可以使用实验跟踪工具。

以下是几种常见的实验跟踪方法:

  • 简单方法:在每个训练周期保存样本图像到文件。
  • 进阶方法:在训练进度条中动态更新样本图像。
  • 专业工具:使用专门的实验跟踪平台,如Weights & Biases(W&B)。

我们重点介绍Weights & Biases。它是一个免费(针对个人和学术用途)的服务,可以自动记录损失、指标、超参数、代码版本,甚至样本图像。

# 使用回调系统集成 Weights & Biases
class WandBCallback(MetricCallback):
    def __init__(self):
        self.run = wandb.init(project="my-diffusion-project")
    def _log(self, log_vals):
        # 记录损失和指标
        wandb.log({"train_loss": log_vals[0]})
        # 记录生成的样本图像
        samples = self.learn.model.sample(16)
        fig = show_images(samples)
        wandb.log({"samples": wandb.Image(fig)})

使用W&B的优势在于:

  • 集中管理:所有实验记录在云端,便于回顾和比较。
  • 协作方便:团队成员可以共享和查看同一项目的实验。
  • 可复现性:自动保存代码快照和环境信息。
  • 远程监控:可以在任何设备上查看训练进度。

注意:虽然这类工具非常强大,但也要避免陷入无目的的“超参数轰炸”。有假设、有方向的实验与代码迭代,通常比盲目搜索更有效。


评估生成图像:FID与KID 📏

当生成图像看起来“还不错”时,我们需要一个更客观的指标来衡量其质量。目前,没有一个完美的指标能完全替代人类的主观判断,但FID和KID是两个广泛使用的近似指标。

FID(Fréchet Inception Distance)原理

FID的核心思想是:比较生成图像和真实图像在特征空间中的分布距离

计算步骤如下:

  1. 使用一个在目标任务(如图像分类)上预训练好的模型(如Inception网络,或我们自训练的Fashion分类器)。
  2. 分别将一批真实图像和一批生成图像输入该模型,提取某个中间层(通常是全局池化层之前)的激活值(特征)。
  3. 对于真实图像和生成图像的特征集合,分别计算它们的均值向量和协方差矩阵。
  4. FID分数计算这两个多元高斯分布之间的Fréchet距离。
# FID 计算的核心公式(概念性)
FID = ||μ_r - μ_g||^2 + Tr(Σ_r + Σ_g - 2*(Σ_r * Σ_g)^(1/2))
# 其中 μ 是均值向量,Σ 是协方差矩阵,Tr 是迹,下标 r 代表真实数据,g 代表生成数据。

重要提示

  • 一致性是关键:FID值只有在使用相同的特征提取模型、相同的样本数量、相同的图像预处理流程时,才具有可比性。
  • 常见问题:标准FID使用Inception网络,并要求输入图像尺寸为299x299。这对于小图像(如32x32)或大图像(如1024x1024)都会引入偏差。
  • 我们的选择:为了更准确地评估Fashion MNIST生成效果,我们使用自己训练的Fashion分类器作为特征提取器,而不是标准的Inception网络。

KID(Kernel Inception Distance)

KID是另一个衡量分布相似度的指标。它使用核方法来直接比较特征,而不是拟合高斯分布。理论上,KID对样本数量的偏差不敏感,但实践中我们发现其方差较大。

实现与应用

我们将FID/KID计算封装成一个工具类 ImageEval。利用它,我们可以做一件很有意义的事:绘制采样过程中每一步的FID曲线。这可以直观地展示图像质量是如何随着去噪步骤的进行而逐步改善的。

我们发现,在修复了一个数据范围的小bug后(将模型输入从[0,1]改为[-0.5,0.5]),模型的FID分数得到了显著提升,生成质量大大改善。


加速采样:从DDPM到DDIM ⚡

标准的DDPM采样需要迭代1000步,调用模型1000次,速度很慢。观察发现,在采样的许多中间步骤,模型预测的变化很小。这启发我们探索更高效的采样算法。

DDIM(Denoising Diffusion Implicit Models)

DDIM是一种更通用、更灵活的采样框架,其核心优势在于:

  • 可加速:可以使用远少于训练步数(如50或100步)进行采样。
  • 可控随机性:引入了一个参数 η(eta)。当 η=1 时,等价于DDPM的随机过程;当 η=0 时,采样过程变为完全确定性。
  • 数学简洁:DDIM的推导基于一个不同的非马尔可夫前向过程,但其训练目标与DDPM完全相同。这意味着我们可以直接使用训练好的DDPM模型进行DDIM采样,无需重新训练

DDIM的采样步骤核心公式如下:

# DDIM 采样步骤(简化版)
# 给定当前噪声图像 x_t, 时间步 t, 预测的噪声 ε_θ
pred_x0 = (x_t - sqrt(1 - alpha_bar_t) * ε_θ) / sqrt(alpha_bar_t) # 预测的干净图像
# 计算指向 x_t 的方向
dir_xt = sqrt(1 - alpha_bar_{t-1} - sigma_t**2) * ε_θ
# 计算下一时刻的图像
x_{t-1} = sqrt(alpha_bar_{t-1}) * pred_x0 + dir_xt + sigma_t * z
# 其中 z 是随机噪声(当 η>0 时),sigma_t 由 η 控制。

效果对比

我们使用训练好的模型,分别用DDPM(1000步)、DDIM(100步)和更激进的跳跃采样进行测试。

  • DDIM (100步):采样速度提升10倍,FID分数与DDPM(1000步)非常接近,图像质量几乎无损。
  • 跳跃采样:尝试每3步或动态间隔采样,速度更快,但图像质量(尤其是纹理细节)在步数过少时会有所下降。

实验表明,DDIM在速度和质量之间取得了出色的平衡,是稳定扩散等实际应用中常用的采样器。


总结

本节课中我们一起学习了深度学习实验中的三个重要工程实践主题。

首先,我们了解了实验跟踪的必要性,并介绍了如何使用Weights & Biases等工具来记录实验、管理超参数和可视化结果,这对于进行长期或复杂的实验至关重要。

其次,我们深入探讨了生成模型的评估指标FID和KID。我们明白了它们通过比较特征空间分布来量化图像质量的原理,同时也认识到它们的局限性和使用时的注意事项(如一致性)。我们还将FID计算工具化,并用于监控采样过程。

最后,我们探索了DDIM加速采样算法。DDIM通过改变采样过程而非训练过程,实现了数量级的速度提升,同时保持了高质量的图像生成,并且提供了控制生成随机性的能力。

通过本课的学习,我们不仅提升了生成图像的质量(通过修复输入范围bug和调整噪声计划),还装备了评估和加速这些生成过程的实用工具与方法。这为我们接下来探索更前沿的研究方向奠定了坚实的基础。

深度学习基础到稳定扩散模型:16:扩散模型简化与改进 🚀

在本节课中,我们将学习如何简化扩散模型的实现,并探索一些改进方法,包括去除离散时间步、预测噪声水平以及使用更先进的采样器。我们将从代码层面理解这些概念,并看到它们如何提升模型性能。


简化时间步表示

上一节我们介绍了余弦调度器。本节中,我们来看看如何进一步简化模型,去除离散时间步的概念。

在之前的实现中,我们使用一个总步数 T(如1000)和当前步数 t(如500)来表示“时间”。这相当于时间步0.5。为什么我们不直接使用0到1之间的浮点数来表示“在扩散过程中所处的位置百分比”呢?答案是:我们可以。

现在,我们假设时间步 t 是一个介于0和1之间的浮点数,其中0代表完全干净的图像,1代表完全噪声的图像。这样,t 就表示在前向扩散过程中所处的相对位置。

核心公式也相应简化。我们不再查表获取 alpha_bar,而是通过一个函数直接从浮点数 t 计算得出:

def alpha_bar(t):
    return math.cos((t + 0.008) / 1.008 * math.pi / 2) ** 2

有趣的是,我们也可以从 alpha_bar 反推回 t,这意味着 alpha_bar 不再是一个需要预计算的列表,而是一个即时计算的函数。

噪声化过程 也随之改变。现在,我们为每个样本随机生成一个0到1之间的浮点数作为其时间步 t,然后使用上述函数计算对应的 alpha_bar 来添加噪声。这使得整个过程在时间上变得连续,而非离散。

以下是噪声化函数的核心变化:

def noiseify(x):
    t = torch.rand(x.shape[0]) * 0.999  # 随机时间步
    ab = alpha_bar(t)  # 计算 alpha_bar
    noise = torch.randn_like(x)
    x_t = math.sqrt(ab) * x + math.sqrt(1 - ab) * noise
    return x_t, t, noise  # 返回噪声图像、时间步和噪声本身

模型的输入和输出 保持不变:输入是噪声图像 x_t 和时间步 t,输出是预测的噪声。


验证单步去噪效果

在训练模型后,我们可以直观地看到它在不同噪声水平下的单步去噪能力。

以下是验证步骤:

  1. 获取一批经过不同程度噪声化的图像及其对应的时间步 t
  2. 使用模型预测每个噪声图像中的噪声。
  3. 利用公式 x_0_hat = (x_t - math.sqrt(1 - alpha_bar(t)) * predicted_noise) / math.sqrt(alpha_bar(t)) 来估算原始图像。

结果令人印象深刻。即使在噪声水平较高(例如 t=0.45)时,模型也能大致还原出图像的轮廓和结构(例如识别出鞋子)。这证明了模型强大的单步预测能力。


改进采样过程

采样过程也进行了相应的简化。我们不再使用 range 函数生成离散时间步,而是使用 linspace 在0到1之间生成线性的连续时间步序列。

对于DDIM采样器,主要变化在于每一步的 alpha_bar 是通过当前连续时间步 t 计算得到的,而不是通过索引查表。核心采样循环如下:

def sample_ddim(model, steps=100):
    ts = torch.linspace(0.999, 0, steps+1)  # 从噪声到干净
    x_t = torch.randn(batch_size, 1, 28, 28)  # 纯噪声起点
    for i in range(steps):
        t = ts[i]
        ab = alpha_bar(t)
        # ... 使用模型预测并计算下一步的 x_t ...
    return x_t

使用这种简化后的100步DDIM采样,我们在Fashion MNIST上得到了约3.0的FID分数,比之前离散时间步版本的性能有所提升。


探索无时间步输入模型

一个有趣的问题是:模型真的需要我们将时间步 t 作为输入吗?给定一个噪声图像,模型能否自己推断出其中的噪声量?

为了验证这一点,我们训练了一个新模型。这个模型的任务是:给定噪声图像,预测其对应的 alpha_bar(t)(即噪声水平)

以下是关键实现细节:

  • 目标变量alpha_bar(t),一个介于0和1之间的值。
  • 输出处理:由于 alpha_bar(t) 的值域特性,直接预测可能导致模型对接近1的值不敏感。因此,我们对目标值取对数(logit),将其映射到整个实数范围,这使得模型能平等地对待不同噪声水平。
    target = torch.logit(alpha_bar_t)  # alpha_bar_t 是目标值
    
  • 损失函数:使用均方误差(MSE)。
  • 基准线:为了评估模型性能,我们计算了如果总是预测固定值(如0.5)或数据均值时的MSE损失,作为对比基准。

训练结果显示,该模型能够相当准确地预测噪声水平(MSE约为0.075),证实了我们的假设:模型可以从图像中推断噪声量。


构建无需时间步输入的扩散模型

既然模型可以预测噪声水平,我们尝试构建一个不接收时间步 t 作为输入的扩散模型。

修改如下

  1. 在噪声化函数中,不再返回时间步 t
  2. 在训练时,我们向模型传递一个全零张量来代替时间步输入(这是一种快速验证想法的“偷懒”方法,而非修改模型结构)。

直接训练后,模型损失(0.034)与接收时间步输入的模型(0.033)非常接近。然而,当使用标准DDIM采样时,生成结果完全失败,图像仍然非常嘈杂。

问题分析与改进:采样失败的原因是,在每一步采样中,我们假设模型恰好移除了预期量的噪声。但如果模型的预测有微小偏差,误差会累积。解决方案是:在采样每一步,使用我们训练好的“噪声水平预测模型”来动态估计当前图像的实际噪声量,并据此调整去噪步骤,而不是死板地遵循预设的时间表。

改进后的DDIM步骤核心思想:

def ddim_step_with_t_prediction(x_t, t):
    # 1. 使用T模型预测当前x_t的噪声水平 (pred_ab)
    pred_ab = t_model(x_t)
    # 2. 对预测值进行裁剪,防止异常值
    median_ab = torch.median(pred_ab)
    clamped_ab = torch.clamp(pred_ab, median_ab*0.5, median_ab*2)
    # 3. 使用裁剪后的预测alpha_bar进行去噪计算
    predicted_noise = model(x_t, torch.zeros_like(t)) # 主模型不接收t
    x_0_hat = (x_t - math.sqrt(1 - clamped_ab) * predicted_noise) / math.sqrt(clamped_ab)
    # ... 继续后续采样步骤 ...

应用此改进后,采样效果大幅提升,生成的图像质量很高(FID约3.8),与使用真实时间步输入的方法接近。这证明了“无时间步”扩散模型是可行的,并且有进一步优化的潜力。


输入缩放与噪声调度研究

我们之前偶然发现,将输入图像从 [0, 1] 缩放到 [-0.5, 0.5] 可以改善训练。最近的研究论文《The Importance of Noise Scheduling for Diffusion Models》系统地探讨了这个问题。

核心发现

  1. 噪声调度对性能至关重要,且最优调度取决于具体任务(如图像分辨率)。
  2. 对输入数据进行缩放是一种有效的策略,可以调整信号与噪声的比率(SNR),从而改善不同分辨率下的模型训练效果。
  3. 论文通过实验展示了不同噪声调度函数(如线性、余弦、S型)和不同输入缩放因子在不同图像尺寸下的表现,并提供了如何根据图像大小选择缩放因子的经验法则。

这项研究将输入缩放和噪声调度从经验性技巧提升到了可分析、可优化的设计选择层面。


Karras 方法:统一设计框架

接下来,我们介绍Karras等人的论文《Elucidating the Design Space of Diffusion-Based Generative Models》。该论文提出了一个更简洁、统一的设计框架。

核心简化:使用单个参数 sigma(相当于我们之前的 alpha_bar)来表示噪声水平,去除了 alpha, beta, alpha_bar 等复杂符号。

关键创新点

  1. 动态训练目标(C_skip):模型不再总是预测噪声或总是预测干净图像。而是根据当前噪声水平 sigma,预测一个介于原始图像和纯噪声之间的目标。公式如下:

    target = C_skip * x_0 + (1 - C_skip) * noise

    其中 C_skip = data_variance / (data_variance + sigma^2)

    • 当噪声很大时(sigma^2 大),C_skip 小,模型主要预测干净图像(困难任务)。
    • 当噪声很小时(sigma^2 小),C_skip 大,模型主要预测噪声(困难任务)。
    • 这使得对于任何噪声水平,模型需要解决的问题难度相对均衡。
  2. 输入输出缩放(C_in, C_out):为了确保输入模型的数据具有单位方差(最佳训练条件),对噪声输入 x_t 和训练目标 target 分别进行缩放。

    x_t_scaled = x_t * C_in
    target_scaled = target * C_out

    其中 C_in = 1 / sqrt(data_variance + sigma^2)
    C_out = sqrt(data_variance + sigma^2) / data_variance

    这些公式的推导基于“使输入/输出的方差为1”这一目标。

  3. 对数正态分布的噪声调度:在训练时,从对数正态分布中采样 sigma,而不是均匀分布。这更符合模型在不同噪声水平下的预测误差分布(模型在中等噪声水平预测最准,在极高或极低噪声水平预测不准)。采样公式为:

    sigma = exp(N(mean, std^2)), 通常 mean=1.2, std=1.2

实现与效果:按照Karras方法实现后,模型在单步去噪可视化中表现出色。更重要的是,其采样代码变得异常简洁,并且催生了更高效的采样器。


高级采样器

在Karras框架下,采样过程变得模块化,我们可以轻松实现不同的采样算法。

1. 欧拉采样器 (Euler)
最简单的确定性采样器,思路是计算当前点的“去噪方向”(梯度),并沿该方向移动一步。

def sample_euler(x, sigma_1, sigma_2):
    denoised = denoise_fn(x, sigma_1)  # 去噪得到估计的x0
    d = (x - denoised) / sigma_1        # 计算“噪声方向”作为梯度
    return x + d * (sigma_2 - sigma_1)  # 沿梯度方向移动

2. 欧拉祖先采样器 (Euler Ancestral)
在欧拉方法的基础上,在每一步添加少量随机噪声,使采样过程具有随机性。这需要调整确定性和随机性步长的比例。

3. Heun采样器 (Heun)
一种二阶方法,精度更高。它先做一个欧拉步得到中间点,计算该点的梯度,然后使用两个梯度的平均值来更新当前点。虽然每一步需要两次模型评估,但可以用更少的步数达到更好效果。

def sample_heun(x, sigma_1, sigma_2):
    denoised_1 = denoise_fn(x, sigma_1)
    d_1 = (x - denoised_1) / sigma_1
    x_euler = x + d_1 * (sigma_2 - sigma_1)  # 欧拉中间点

    denoised_2 = denoise_fn(x_euler, sigma_2)
    d_2 = (x_euler - denoised_2) / sigma_2

    d_avg = (d_1 + d_2) / 2  # 使用平均梯度
    return x + d_avg * (sigma_2 - sigma_1)

4. LMS采样器 (Linear Multistep)
一种更聪明的方法,它利用过去几步的梯度信息来估计当前步的最佳更新方向,而无需额外调用模型。这可以用更少的模型评估次数获得高质量的采样结果。

性能对比:在Fashion MNIST上,使用Heun采样器仅需20步(40次模型评估)就能获得FID约1.0的优异结果,远超之前100步欧拉采样的效果。这凸显了先进采样器和精心设计的噪声调度、输入缩放相结合的巨大威力。


总结

本节课中我们一起学习了扩散模型的一系列重要简化和改进:

  1. 连续时间步:用0到1的浮点数代替离散步数,使过程更连续、代码更简洁。
  2. 噪声水平预测:证明了模型能够从噪声图像中推断噪声量,为“无时间步输入”模型奠定了基础。
  3. Karras统一框架:引入了 sigma、动态训练目标(C_skip)和输入输出缩放(C_in, C_out),将训练和采样过程统一到一个更简洁、更理论化的框架中。
  4. 高级采样器:探索了欧拉、Heun、LMS等采样算法,展示了如何用更少的计算步骤获得更高质量的生成样本。

这些改进不仅提升了模型性能,也让我们对扩散模型的工作原理有了更深刻的理解。从复杂的DDPM实现出发,我们最终到达了一个代码更简洁、效果更出色、设计更优雅的境地。接下来,我们将利用这些知识,开始训练更强大的U-Net模型,并在更复杂的数据集上进行实践。

深度学习基础到稳定扩散模型:17:U-Net架构与超分辨率应用

概述

在本节课中,我们将学习U-Net架构,并将其应用于图像超分辨率任务。我们将从构建一个基础的U-Net模型开始,逐步引入感知损失和预训练权重微调等技巧,以显著提升超分辨率图像的质量。


数据准备与处理

上一节我们介绍了FID指标及其计算中的bug。本节中我们来看看如何为新的图像任务准备数据。

我们使用Tiny ImageNet数据集,其图像尺寸为64x64像素。首先需要下载并解压数据。

import tarfile
url = 'http://cs231n.stanford.edu/tiny-imagenet-200.zip'
tarfile.open('tiny-imagenet-200.zip', 'r:gz').extractall('data')

数据集的训练集和验证集结构不同。训练集图像按类别存放在子文件夹中,而验证集图像则集中存放,其标签信息在一个单独的文本文件中。

以下是创建训练集数据集的步骤:

from pathlib import Path
def get_files(path):
    return list(Path(path).glob('**/*.JPEG'))

class TinyDataset:
    def __init__(self, path, is_val=False):
        self.path = Path(path)
        self.is_val = is_val
        if not is_val:
            self.files = get_files(self.path/'train')
        else:
            self.files = get_files(self.path/'val')
            # 为验证集创建标签映射字典
            with open(self.path/'val/val_annotations.txt') as f:
                self.val_dict = dict([l.split('\t')[:2] for l in f])
    def __len__(self): return len(self.files)
    def __getitem__(self, i):
        f = self.files[i]
        if not self.is_val:
            return str(f), f.parent.parent.name # 标签是父文件夹的父文件夹名
        else:
            return str(f), self.val_dict[f.name] # 从字典中查找标签

我们创建一个通用的数据集转换类,可以方便地对输入(X)和标签(Y)应用不同的变换。

class TransformDataset:
    def __init__(self, ds, tfm_x=None, tfm_y=None):
        self.ds, self.tfm_x, self.tfm_y = ds, tfm_x, tfm_y
    def __len__(self): return len(self.ds)
    def __getitem__(self, i):
        x, y = self.ds[i]
        if self.tfm_x is not None: x = self.tfm_x(x)
        if self.tfm_y is not None: y = self.tfm_y(y)
        return x, y

对于图像,我们进行标准化处理。标签(WordNet ID)需要转换为整数索引。

import torchvision.transforms as T
from PIL import Image
# 假设已计算好数据集的均值和标准差
stats = ([0.480, 0.448, 0.398], [0.277, 0.269, 0.282])
# 图像变换:打开 -> 转Tensor -> 标准化
tfm_x = T.Compose([Image.open, T.ToTensor(),
                   T.Normalize(*stats)])
# 标签变换:将WordNet ID字符串映射为整数
with open('data/tiny-imagenet-200/wnids.txt') as f:
    wordnet_ids = [l.strip() for l in f]
str_to_id = {s:i for i,s in enumerate(wordnet_ids)}
tfm_y = lambda s: torch.tensor(str_to_id[s])
# 创建转换后的数据集
train_ds = TransformDataset(TinyDataset(path, False), tfm_x, tfm_y)
val_ds = TransformDataset(TinyDataset(path, True), tfm_x, tfm_y)

为了缓解过拟合,我们引入数据增强。对于小尺寸图像,常用的RandomResizedCrop效果不佳,因此我们采用Pad + RandomCrop的方式进行轻微位移,并配合水平翻转。

from torch import nn
import torchvision.transforms.functional as TF
class RandomArray(nn.Module):
    # 添加随机高斯噪声
    def forward(self, x):
        return x + torch.randn_like(x) * 0.1
# 训练集增强变换
train_tfms = T.Compose([
    T.Pad(4),
    T.RandomCrop(64),
    T.RandomHorizontalFlip(),
    RandomArray(),
])
# 可以将其包装为批处理回调,对整个批次应用相同的增强(速度快,但可能不稳定)
# 也可以将其集成到数据集的 `__getitem__` 中,对每个样本独立增强(更稳定)

图像分类器训练

在构建U-Net之前,我们需要一个在Tiny ImageNet上预训练好的分类器,后续将用它来提取感知损失的特征。

我们使用一个基于ResNet块构建的卷积神经网络。

def conv(ni, nf, ks=3, stride=1, act=True):
    layers = [nn.Conv2d(ni, nf, ks, stride=stride, padding=ks//2)]
    if act: layers.append(nn.ReLU())
    return nn.Sequential(*layers)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/8fc58659c06d04b164d91129ad13660a_11.png)

class ResBlock(nn.Module):
    def __init__(self, ni, nf, stride=1):
        super().__init__()
        self.conv = nn.Sequential(conv(ni, nf, stride=stride),
                                  conv(nf, nf, act=False))
        self.idconv = nn.Identity() if ni==nf else conv(ni, nf, stride=stride, act=False)
        self.pool = nn.Identity() if stride==1 else nn.AvgPool2d(2)
        self.act = nn.ReLU()
    def forward(self, x):
        return self.act(self.conv(x) + self.idconv(self.pool(x)))

def get_model():
    return nn.Sequential(
        conv(3, 16, ks=5, stride=2),
        ResBlock(16, 32, stride=2),
        ResBlock(32, 64, stride=2),
        ResBlock(64, 128, stride=2),
        ResBlock(128, 256, stride=2),
        nn.AdaptiveAvgPool2d(1), nn.Flatten(),
        nn.Dropout(0.5), nn.Linear(256, 200)
    )

我们使用AdamW优化器和混合精度训练这个分类器。通过实验,我们引入了预激活ResBlockTrivialAugment数据增强策略,将准确率提升至约67.5%,达到了该数据集上的先进水平。

预激活ResBlock将激活函数置于卷积操作之前,使得恒等映射路径更加“纯净”,有助于梯度流动,尤其在深层网络中效果显著。

class PreActResBlock(nn.Module):
    def __init__(self, ni, nf, stride=1):
        super().__init__()
        # 先Norm和Act,再Conv
        self.conv1 = nn.Sequential(nn.BatchNorm2d(ni), nn.ReLU(),
                                   nn.Conv2d(ni, nf, 3, stride=stride, padding=1))
        self.conv2 = nn.Sequential(nn.BatchNorm2d(nf), nn.ReLU(),
                                   nn.Conv2d(nf, nf, 3, padding=1))
        self.idconv = nn.Identity() if ni==nf else nn.Conv2d(ni, nf, 1, stride=stride)
        self.pool = nn.Identity() if stride==1 else nn.AvgPool2d(2)
    def forward(self, x):
        return self.conv2(self.conv1(x)) + self.idconv(self.pool(x))

U-Net架构与超分辨率任务

现在,我们开始构建用于超分辨率任务的U-Net模型。任务定义是:输入一张下采样至32x32的低分辨率图像,模型输出对应的64x64高分辨率图像。

一个简单的编码器-解码器(自编码器)结构在此任务上表现很差,输出图像模糊。U-Net的核心思想是在解码(上采样)路径中,引入编码(下采样)路径中对应层的特征图,通过跳跃连接(Skip Connections)融合多尺度信息。

以下是U-Net的关键组件:

class UNet(nn.Module):
    def __init__(self, n_channels=3, n_filters=16):
        super().__init__()
        # 下采样路径 (编码器)
        self.down_path = nn.ModuleList([
            ResBlock(n_channels, n_filters),
            ResBlock(n_filters, n_filters*2, stride=2),
            ResBlock(n_filters*2, n_filters*4, stride=2),
            ResBlock(n_filters*4, n_filters*8, stride=2),
        ])
        # 上采样路径 (解码器)
        self.up_path = nn.ModuleList([
            UpBlock(n_filters*8, n_filters*4), # 上采样并融合特征
            UpBlock(n_filters*4, n_filters*2),
            UpBlock(n_filters*2, n_filters),
        ])
        self.final = conv(n_filters, n_channels, act=False) # 输出3通道图像

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/8fc58659c06d04b164d91129ad13660a_13.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/fastai-v3-dl/img/8fc58659c06d04b164d91129ad13660a_15.png)

class UpBlock(nn.Module):
    def __init__(self, ni, nf):
        super().__init__()
        self.up = nn.Upsample(scale_factor=2, mode='nearest')
        self.conv = ResBlock(ni + nf, nf) # 注意:输入通道是 ni+nf,因为要拼接

    def forward(self, x, skip):
        x = self.up(x)
        x = torch.cat([x, skip], dim=1) # 跳跃连接:拼接特征
        return self.conv(x)

在U-Net的前向传播中,我们需要保存下采样路径每一层的输出,以便在上采样时进行拼接。

def forward(self, x):
    skips = []
    # 下采样,保存中间特征
    for layer in self.down_path:
        skips.append(x)
        x = layer(x)
    # 上采样,融合保存的特征
    for i, layer in enumerate(self.up_path):
        x = layer(x, skips[-(i+1)]) # 从后往前取对应的特征
    return self.final(x)

使用均方误差(MSE)损失训练这个基础U-Net,结果比自编码器好,但图像仍然偏模糊,细节不足。


引入感知损失

MSE损失倾向于让模型输出像素值的平均,导致模糊。感知损失(Perceptual Loss) 通过比较生成图像和真实图像在预训练分类器深层特征空间中的差异,来引导模型生成语义上更合理、视觉上更清晰的图像。

我们使用之前训练好的分类器(截断到中间层)来提取特征。

class PerceptualLoss(nn.Module):
    def __init__(self, c_model, weight=0.1):
        super().__init__()
        self.c_model = c_model
        self.weight = weight
        self.mse = nn.MSELoss()

    def forward(self, pred, target):
        # 像素级MSE损失
        mse_loss = self.mse(pred, target)
        # 感知损失:比较特征
        with torch.no_grad():
            targ_feats = self.c_model(target)
        pred_feats = self.c_model(pred)
        feat_loss = self.mse(pred_feats, targ_feats)
        # 加权总和
        return mse_loss + self.weight * feat_loss

将感知损失与MSE损失结合训练U-Net,生成的图像在细节(如眼睛、纹理)上有了显著改善。


使用预训练权重与微调

我们可以进一步“作弊”:用预训练分类器的权重来初始化U-Net的编码器(下采样路径)。因为编码器的任务是理解图像内容,这与分类器的早期层功能相似。

# 假设 `pretrained_model` 是训练好的分类器,`unet` 是我们的模型
# 复制编码器部分的权重
unet.down_path[0].load_state_dict(pretrained_model[0].state_dict())
unet.down_path[1].load_state_dict(pretrained_model[1].state_dict())
# ... 以此类推
# 冻结编码器权重,先只训练解码器
for param in unet.down_path.parameters():
    param.requires_grad = False
# 训练一个周期后,解冻所有权重进行联合微调
for param in unet.parameters():
    param.requires_grad = True

这种方法让模型训练更快,收敛更好,最终的超分辨率图像质量更高。


改进:交叉连接

在标准的U-Net跳跃连接中,下采样特征直接拼接到上采样路径。我们可以引入一个小的交叉连接(Cross Connection) 网络(如一个ResBlock)来处理下采样特征,再将其与上采样特征融合,为模型提供更多灵活性。

class UNetWithCross(nn.Module):
    def __init__(self, n_channels=3, n_filters=16):
        super().__init__()
        self.down_path = nn.ModuleList([...]) # 同前
        self.cross_cons = nn.ModuleList([
            ResBlock(n_filters, n_filters),
            ResBlock(n_filters*2, n_filters*2),
            ResBlock(n_filters*4, n_filters*4),
        ])
        self.up_path = nn.ModuleList([...]) # 同前

    def forward(self, x):
        skips = []
        for i, layer in enumerate(self.down_path):
            skips.append(x)
            x = layer(x)
        for i, (up_layer, cross_layer) in enumerate(zip(self.up_path, self.cross_cons)):
            skip_processed = cross_layer(skips[-(i+1)]) # 用交叉连接处理跳跃特征
            x = up_layer(x, skip_processed) # 融合处理后的特征
        return self.final(x)

加入交叉连接后,模型性能得到进一步提升。


总结

本节课中我们一起学习了:

  1. U-Net架构:其核心是通过跳跃连接将编码器的多尺度特征与解码器融合,非常适合图像到图像的转换任务。
  2. 超分辨率任务:将低分辨率图像重建为高分辨率图像。
  3. 感知损失:利用预训练网络的特征空间差异来指导生成,能有效提升图像的视觉质量和语义合理性。
  4. 迁移学习与微调:使用预训练权重初始化U-Net的编码器,并采用冻结-解冻的策略进行训练,可以加速收敛并提高最终效果。
  5. 模型改进:引入交叉连接等结构,为模型提供更多容量和灵活性。

U-Net及其变体在图像分割、风格迁移、去噪、着色等众多领域都有广泛应用。你可以尝试将本节课的代码应用于其他图像生成任务,探索其强大的能力。

深度学习基础到稳定扩散模型:18:从零实现扩散模型 🧠

在本节课中,我们将学习如何从零开始构建一个无条件和有条件的扩散模型。我们将从构建一个基础的U-Net架构开始,逐步加入时间步嵌入和注意力机制,最终实现一个能够根据类别标签生成图像的模型。本节课的核心是理解扩散模型的核心组件及其实现细节。


从零构建无条件的扩散模型 🏗️

上一节我们学习了扩散模型的基本原理和噪声调度。本节中,我们将动手构建一个无条件的扩散模型,其核心是一个U-Net架构。

基础组件:预激活卷积与残差块

首先,我们定义模型的基础构建块。我们将使用预激活卷积(Pre-activation Convolution),即先进行归一化和激活,再进行卷积操作。

预激活卷积的代码表示:

class PreActConv(nn.Module):
    def __init__(self, ni, nf, ks=3, stride=1):
        super().__init__()
        self.norm = nn.GroupNorm(1, ni)  # 使用组归一化
        self.act = nn.SiLU()  # SiLU激活函数,也称为Swish
        self.conv = nn.Conv2d(ni, nf, ks, stride=stride, padding=ks//2)
    def forward(self, x):
        return self.conv(self.act(self.norm(x)))

接下来是基础的残差块(ResNet Block)。与之前不同,这个残差块不包含下采样选项,下采样将在后续的“下采样块”中单独处理。

基础残差块的代码表示:

class UnitResBlock(nn.Module):
    def __init__(self, ni, nf):
        super().__init__()
        self.conv1 = PreActConv(ni, nf)
        self.conv2 = PreActConv(nf, nf)
        # 如果输入输出通道数不同,需要一个1x1卷积来调整维度
        self.idconv = nn.Identity() if ni==nf else nn.Conv2d(ni, nf, 1)
    def forward(self, x):
        return self.conv2(self.conv1(x)) + self.idconv(x)

实现U-Net的跳跃连接:保存模块

U-Net的关键特性是编码器(下采样)和解码器(上采样)之间的跳跃连接。为了优雅地实现这一点,我们创建一个“保存模块”(Save Module)的混入类(Mixin),它能在前向传播时自动保存激活值。

保存模块混入类的代码表示:

class SaveModule:
    def forward(self, *args, **kwargs):
        self.saved = super().forward(*args, **kwargs)
        return self.saved

通过多重继承,我们可以创建能自动保存输出的卷积层和残差块。

带保存功能的卷积和残差块:

class SavedConv(SaveModule, nn.Conv2d): pass
class SavedResBlock(SaveModule, UnitResBlock): pass

构建下采样块与上采样块

下采样块(DownBlock)由一系列保存残差块组成,最后可选地添加一个步长为2的卷积进行下采样。

上采样块(UpBlock)与下采样块结构类似,但方向相反。它接收来自下采样路径的保存激活列表,并在每个残差块前通过拼接(concatenation)将其与当前激活结合。

下采样块与上采样块的核心逻辑:

# 下采样块:多个SavedResBlock + 可选的Stride=2卷积
# 上采样块:接收保存的激活列表,与当前激活拼接后输入普通残差块 + 可选的最近邻上采样层

组装完整的U-Net模型

现在,我们可以将下采样块、中间块和上采样块组合成完整的U-Net。模型首先通过一个卷积增加通道数,然后经过一系列下采样块,一个中间残差块,再经过一系列上采样块,最后通过一个卷积将通道数映射回图像通道(例如3)。

U-Net前向传播流程:

  1. 初始卷积。
  2. 遍历下采样块序列,保存每一层的激活。
  3. 通过中间残差块。
  4. 遍历上采样块序列,每次从保存的激活列表中取出对应的激活进行拼接。
  5. 最终卷积输出。


引入时间步嵌入 ⏱️

上一节我们构建了基础的U-Net,但扩散模型需要知道当前处于去噪过程的哪个时间步。本节中,我们来看看如何将时间信息嵌入到网络中。

正弦时间步嵌入

我们使用正弦嵌入(Sinusoidal Embedding)将连续的时间步(或噪声水平σ)转换为向量表示。其核心思想是生成一组频率不同的正弦和余弦波,确保相近的时间步有相似的嵌入,而不同的时间步嵌入也不同。

正弦嵌入的公式:
给定时间步 t,嵌入维度 d,最大周期 max_period
对于 i[0, 1, ..., d/2-1] 范围内:

  • 频率 = exp( -log(max_period) * i / (d/2) )
  • 嵌入向量的第 2i 个元素 = sin(t * 频率)
  • 嵌入向量的第 2i+1 个元素 = cos(t * 频率)

代码实现:

def sinusoidal_embedding(t, dim, max_period=10000):
    half = dim // 2
    freqs = torch.exp(-math.log(max_period) * torch.arange(half, dtype=torch.float32) / half).to(t.device)
    args = t[:, None].float() * freqs[None]
    return torch.cat([torch.sin(args), torch.cos(args)], dim=-1)

将时间嵌入整合到残差块中

我们创建一个新的残差块(EmbResBlock),它在两个卷积之间融入时间步信息。具体做法是:将时间嵌入通过一个小型MLP投影,然后分割为缩放(scale)和偏移(shift)两部分,分别作用于第一个卷积后的激活值。

带时间嵌入的残差块前向传播步骤:

  1. 对输入进行第一个卷积。
  2. 将时间嵌入投影并分割为 scaleshift
  3. 对卷积后的激活值进行缩放和偏移:x = x * (1 + scale) + shift
  4. 进行第二个卷积。
  5. 与快捷连接(shortcut)相加。


加入注意力机制 🔍

上一节我们为模型添加了时间感知能力。本节中,我们将引入注意力机制,使模型能够在图像的远距离区域之间建立联系,这对于生成结构一致的图像很重要。

自注意力机制原理

在卷积网络中,一个像素的感受野有限。自注意力允许每个像素与图像中所有其他像素进行交互,通过加权平均的方式聚合全局信息。

单头自注意力的计算步骤(稳定扩散风格):

  1. 将输入 x(形状 [batch, channels, height, width])展平为 [batch, height*width, channels]
  2. 使用三个独立的线性投影层,得到查询(Query,Q)、键(Key,K)、值(Value,V)。
  3. 计算注意力权重:attn = softmax( (Q @ K.transpose(-2, -1)) / sqrt(d) ),其中 d 是通道数。
  4. 使用注意力权重对V进行加权求和:out = attn @ V
  5. 将输出投影回原始通道数,并重塑为原始空间维度。
  6. 最终输出为 x + out,形成一个残差连接。

多头注意力

单头注意力可能只关注一种模式。多头注意力将通道分成多组(头),每组独立进行注意力计算,从而让模型同时关注不同方面的信息。

实现多头注意力的关键技巧:
使用 rearrange 操作(来自 einops 库)将“批次数”维度与“头数”维度合并,从而将多头计算转换为批处理计算,简化了实现。

使用 einops.rearrange 实现多头:

from einops import rearrange
# 将输入从 [batch, seq, channels] 重排为 [batch*heads, seq, channels_per_head]
q = rearrange(q, ‘b s (h d) -> (b h) s d’, h=n_heads)
# 注意力计算...
# 计算完成后,再重排回来
out = rearrange(out, ‘(b h) s d -> b s (h d)’, h=n_heads)

将注意力整合到U-Net中

由于注意力计算复杂度与序列长度(即像素数量)的平方成正比,在图像分辨率很高时(如早期层)计算开销巨大。因此,我们通常只在U-Net的较低分辨率层(例如,在几次下采样之后)添加注意力模块。

在我们的实现中,可以为 EmbResBlock 添加一个可选的注意力层,并在构建U-Net时指定从第几个下采样/上采样块开始使用注意力。


实现条件扩散模型 🏷️

到目前为止,我们构建的模型都是无条件的。本节中,我们将扩展模型,使其能够根据类别标签生成特定类型的图像(例如,“生成一件T恤”)。

添加条件嵌入

条件信息(如类别标签)也需要被嵌入到网络中。处理方式与时间步嵌入非常相似:

  1. 为类别标签创建一个嵌入层(nn.Embedding),将类别索引映射为向量。
  2. 将类别嵌入向量与时间步嵌入向量相加,形成一个联合条件向量。
  3. 将这个联合条件向量输入到每个 EmbResBlock 中,方式与之前的时间嵌入完全相同。

在U-Net前向传播中整合条件:

def forward(self, x, t, y):
    # t: 时间步, y: 类别标签
    t_emb = self.time_embedding(t)
    y_emb = self.label_embedding(y)
    cond = t_emb + y_emb # 联合条件
    # 后续将 cond 传入各个 EmbResBlock

训练与条件采样

训练: 数据加载器现在需要提供三元组 (噪声图像, 时间步, 类别标签),损失函数保持不变。

采样: 在推理时,我们需要指定想要生成的类别。只需将目标类别标签转换为嵌入,并与时间步嵌入结合,然后输入给训练好的模型,模型就会朝着生成该类别图像的方向进行去噪。


总结 📚

本节课中,我们一起学习了如何从零开始构建一个完整的扩散模型。

  1. 我们首先构建了无条件的U-Net,使用了预激活卷积、残差块,并通过巧妙的“保存模块”设计实现了跳跃连接。
  2. 接着引入了时间步嵌入,使用正弦函数将连续时间编码为向量,并通过缩放和偏移操作将其融入残差块,使模型感知去噪进度。
  3. 然后加入了注意力机制,解释了自注意力和多头注意力的原理与实现,并将其整合到U-Net的深层,以捕获图像中的长程依赖关系。
  4. 最后实现了条件扩散模型,通过将类别嵌入与时间嵌入相加,使模型能够根据文本或类别标签生成特定内容的图像。

至此,我们已经复现了稳定扩散模型的核心架构(除了CLIP文本编码器和潜在扩散部分)。我们拥有了一个功能完备的、可以无条件或有条件生成图像的扩散模型。

深度学习基础到稳定扩散模型:19:稳定扩散模型

概述

在本节课中,我们将学习如何将扩散模型应用于音频生成,并深入探讨变分自编码器(VAE)在稳定扩散模型中的关键作用。我们将从音频的频谱图生成开始,然后构建自己的VAE,最后利用预训练的稳定扩散VAE在潜在空间中进行高效的图像生成。


🎵 音频的像素级扩散

上一节我们介绍了基于像素的图像扩散模型。本节中,我们来看看如何将同样的技术应用于音频生成。

核心思路是将音频信号转换为一种类似图像的表示形式——频谱图,然后对频谱图进行扩散建模。

以下是实现音频扩散的关键步骤:

  1. 数据准备:我们使用一个鸟鸣声数据集。原始音频是波形数据,采样率为每秒32000个点。直接对如此长的一维序列建模非常困难。
  2. 转换为频谱图:我们使用梅尔频谱图将音频从时域转换到频域。梅尔频谱图将频率映射到符合人耳听觉感知的梅尔刻度,并聚焦于人耳可听的频率范围。
    • 公式梅尔频谱图 = 梅尔滤波器组 * 短时傅里叶变换(音频波形)
  3. 构建扩散模型:我们将128x128的灰度频谱图视为“图像”,使用与之前图像生成完全相同的简单扩散模型架构进行训练。
  4. 采样与重建:模型生成频谱图后,使用Griffin-Lim算法等相位重建方法将频谱图转换回音频波形。这是一个有损的近似过程。

通过这种方法,我们成功生成了听起来像鸟鸣的音频样本。这项技术也可应用于音乐生成等领域。


🔍 构建变分自编码器(VAE)

在进入稳定扩散的潜在空间之前,我们需要理解VAE的工作原理。VAE是稳定扩散模型的关键组件,用于将高维图像压缩到低维潜在空间。

自编码器(AE)回顾

首先,我们构建一个简单的自编码器作为基线。它由编码器和解码器组成:

  • 编码器:将784维(28x28)的扁平化图像压缩到200维的潜在向量。
  • 解码器:将200维潜在向量重建回784维的图像。

我们使用均方误差(MSE)损失进行训练。虽然它能重建训练图像,但当我们向解码器输入随机噪声时,它无法生成有意义的图像。这是因为潜在空间没有良好的结构。

从AE到VAE

VAE通过引入随机性来改善潜在空间的结构。以下是VAE的核心改进:

  1. 编码器输出两个向量:编码器不再输出单一的潜在向量 z,而是输出两个向量:
    • mu (μ):潜在分布的均值。
    • log_var (log σ²):潜在分布方差的对数。
  2. 重参数化技巧:我们从标准正态分布中采样噪声 ε,然后利用 mulog_var 构造潜在变量 z
    • 公式z = mu + exp(log_var / 2) * ε,其中 ε ~ N(0, I)
  3. 组合损失函数:VAE的损失函数由两部分组成:
    • 重建损失:衡量解码器输出与原始图像的差异(如二元交叉熵损失)。
    • KL散度损失:鼓励潜在分布 q(z|x) 接近标准正态分布 N(0, I)。这正则化了潜在空间。
      • 公式KL损失 ≈ -0.5 * sum(1 + log_var - mu^2 - exp(log_var))

通过同时优化这两项损失,VAE学会了生成一个结构良好的潜在空间。在这个空间中,不仅编码点能重建原图,其周围的点也能解码出合理的图像。这使得从潜在空间采样随机点并解码成为可能,从而实现了图像生成功能。

我们用一个简单的VAE在Fashion-MNIST数据集上进行了实验。虽然其重建质量不如AE,但它能从随机噪声中生成可识别的服装图像轮廓。


🚀 使用稳定扩散VAE进行潜在空间扩散

现在,我们将利用预训练的稳定扩散VAE的强大能力,在潜在空间中训练扩散模型,以生成更复杂、更高分辨率的图像。

潜在空间的优势

稳定扩散VAE将256x256x3的图像压缩为32x32x4的潜在表示。这带来了巨大优势:

  • 计算效率:数据量减少了约48倍,极大降低了后续扩散模型训练和推理的内存与计算需求。
  • 利用先验知识:该VAE在大量自然图像上训练,学会了高质量的压缩和重建能力,我们可以直接受益于此。

实现步骤

以下是我们在LSUN卧室数据集上进行潜在空间扩散的步骤:

  1. 数据编码:使用稳定扩散VAE的编码器,将整个数据集的图像批量转换为潜在表示。
  2. 高效存储:使用内存映射的NumPy数组存储所有潜在向量。这种方式可以处理远超内存大小的数据集,数据会缓存在磁盘上,访问时自动加载所需部分。
    • 代码示例
      latents = np.memmap(‘latents.dat’, dtype=’float32’, mode=’w+’, shape=(n_images, 4, 32, 32))
      
  3. 训练扩散模型:将潜在向量视为新的“像素”数据。我们使用与之前相同的UNet架构和DDPM训练流程,但输入和输出通道数改为4(潜在空间的通道数)。
  4. 采样与解码:扩散模型在潜在空间中生成样本后,使用VAE的解码器将其转换回像素空间,得到最终的256x256彩色图像。

结果与扩展

经过几个小时的训练,我们成功生成了具有卧室场景特征的图像。虽然部分结果存在瑕疵,但这证明了潜在空间扩散的可行性。

为了进一步提升效果,我们可以尝试:

  • 使用预训练的潜在空间分类器作为骨干网络:我们展示了在ImageNet潜在向量上训练分类器的可行性,并达到了约66%的准确率。这可以作为扩散模型UNet的预训练骨干,可能提升生成质量。
  • 尝试其他改进:如使用残差连接、感知损失等我们在超分辨率任务中验证有效的技巧。


总结

本节课中我们一起学习了扩散模型在音频生成中的应用,深入探讨了变分自编码器(VAE)的原理与实现,并实践了利用稳定扩散VAE在潜在空间中进行高效图像生成的全流程。

关键收获在于理解VAE如何通过结构化的潜在空间连接压缩与生成,以及如何利用预训练模型的能力来处理更大规模、更复杂的生成任务。你现在已经掌握了从零开始理解和构建稳定扩散核心组件的重要基础。

posted @ 2026-03-26 08:48  布客飞龙II  阅读(0)  评论(0)    收藏  举报