优达学城无人驾驶笔记-全-
优达学城无人驾驶笔记(全)
001:课程介绍与团队见面 🚗
在本节课中,我们将要学习无人驾驶汽车纳米学位项目的整体介绍,并认识负责教授这门课程的专家团队。我们将了解课程的目标、涵盖的核心主题,以及为谁而设计。
我是塞巴斯蒂安,与优达学城及行业合作伙伴的优秀团队一起,我将担任本纳米学位项目的指导老师。
我们共同设计了这门课程,旨在让无人驾驶汽车的基础知识变得易于理解。你将有机会学习数据结构、基础数学、不同编程语言、规划、感知与控制等基于规则的内容,并亲手编写代码来实现一个非常基础的无人驾驶汽车版本。
当你完成课程后,你可以认为自己已经对无人驾驶汽车有了深入的了解,因为你已经掌握了所有基础知识。优达学城还提供了一个更深入的进阶版本,我稍后会提及。你可以将自己视为学习无人驾驶汽车技术的首批先驱者之一。
接下来,让我们认识一下团队。
大家好,我是安东尼,是优达学城“无人驾驶汽车入门”课程的产品负责人。我很高兴能帮助你开启成为无人驾驶汽车工程师的旅程。打造这个优秀的纳米学位项目需要大量工作,我们想花点时间向你介绍为此做出贡献的团队。




大家好,我是安迪,是本项目的课程负责人。我在优达学城从事教学与学习已有五年。无人驾驶汽车是一项改变世界的技术,其背后的数学和计算机科学原理非常迷人。我迫不及待地想带你进入这个精彩的世界。




大家好,我是卡桑·卡马乔,是优达学城的内容开发专家。我专攻计算机视觉,这是像无人驾驶汽车这样的机器如何视觉感知并响应世界的方式。我很高兴能教你关于视觉,以及其他所有无人驾驶汽车在路上做决策时所依赖的基础编程和数学概念。








大家好,我是艾丽西亚·怀特,是一名拥有超过20年跨行业经验的嵌入式系统工程师。我为O‘Reilly编写嵌入式系统,并主持一档名为“嵌入式.FM”的播客,探讨工程的各个方面。我在这里帮助你理解为什么C++对于无人驾驶汽车的开发如此重要。








大家好,我是特兰,是Voyage公司的人工智能总监。我非常高兴能加入课堂,教授你关于机器学习和计算机视觉的知识。






大家好,我是杰西卡,是你的纳米学位项目以及无人驾驶汽车工程师纳米学位项目的项目经理。我非常期待你加入这个项目,并对你开启这段旅程感到兴奋。如果你有任何需要,可以在Slack社区中找到我。






大家好,我是埃里卡,是学生体验协调员。欢迎随时在Slack上联系我或发送邮件至 support@udacity.com。

幕后还有更多人在尽一切努力使这个项目变得出色。现在,我们开始吧?

欢迎来到纳米学位项目。我们两人都曾参与无人驾驶汽车纳米学位项目的独立工作。我负责的课程是路径规划和定位,而我负责的则是车道线检测和计算机视觉。当我们发布无人驾驶汽车纳米学位项目时,引起了学生和业界的极大兴趣。
但事实证明,构建一辆无人驾驶汽车并不容易,因此许多课程都是从非常技术性的角度出发的。一些报名该项目的学员发现自己被数学符号、编程概念和编程语言等内容所阻碍。
那么安迪,我们这门课程是为谁设计的呢?我们在设计这门课程时,心目中的目标学员具备一些编程经验(大约40小时左右),熟悉代数,并且希望他们能从思考和解决难题中获得乐趣,即使这些难题涉及数学。如果这描述的就是你,并且你希望填补知识空白,或者只是想获得更多关于无人驾驶汽车背后编程和数学的经验,那么这门课程将为你学习更高级的课程做好准备。所以,让我们开始吧。
在深入学习之前,我们想花点时间给你一个热身技能的机会。我们创建了一个有趣的漫画和一系列问题,将帮助你评估对自己能力的信心程度。别担心,这不是分级测试,仅供你个人使用,并帮助你为课程做好准备。
本节课中我们一起学习了无人驾驶汽车入门课程的整体框架、教学目标以及强大的讲师团队。课程旨在通过实践编程和数学基础,让你掌握无人驾驶汽车的核心概念,并为后续深入学习打下坚实基础。现在,你已经准备好开启这段激动人心的学习旅程了。
002:准备开始 🚗
在本节课中,我们将学习如何使用课程的核心工具——Jupyter Notebook。这是一个用于编写和记录Python代码的强大环境。我们将了解其基本操作,并打消你可能对“破坏”学习环境的顾虑。
希望你喜欢之前的冒险故事,并且对自己的技能有了一些了解。如果你觉得那些内容很简单,这很好,说明你已经准备好深入学习。如果你感到有些困难,不用担心,这正是我们在这里的原因。在本引导课程结束时,我们会提供一些资源,供你在正式课程开始前查阅。你可以利用这些资源来复习数学和编程技能。重申一次,这些都不是强制要求的,我们只是希望帮助你顺利完成这个项目。
紧接着这个视频,你会看到一个类似这样的界面。这被称为Jupyter Notebook,它是一个用于编写和记录Python代码的绝佳工具。
认识Jupyter Notebook 📓
如你所见,这个笔记本由一个个单元格构成。我现在高亮显示了一个单元格,可以使用方向键在单元格之间上下导航。
这个单元格,如你所见,是一个Markdown单元格。关于它,我不想说太多,只想说明Markdown是一种让文本看起来更美观的“高级”方式。
有时你会看到单元格处于这种格式,这可能有点烦人,如果你不知道如何处理,甚至会成为一个大问题。基本上,这种格式向你展示了编辑模式,在这里你可以看到设置标题的语法,在这里你可以看到设置加粗的语法。
如果你不小心进入了这种模式,并想回到更美观的显示模式,只需按Control + Enter即可。
运行代码单元格 ▶️
上一节我们介绍了Markdown单元格,本节中我们来看看代码单元格。
这里我们有一个Python注释,后面跟着一个打印语句。我可以在这里按Control + Enter。果然,它打印出了“hello world”。
实际上,还有另一种运行单元格的方法,那就是按Shift + Enter。它的效果相同,但还会将光标移动到下方的单元格。这非常方便。所以,当你看到像这样的笔记本时,可以从顶部开始,通过按Shift + Enter、Shift + Enter、Shift + Enter来逐步执行。
我鼓励你自己多尝试。但首先,有两点重要事项需要说明。
重要事项一:这是你的工作区 🔧
第一点,这是你的工作区。你可以在这些笔记本中做任何你想做的事情,不会造成任何永久性的损害,没有什么会被“弄坏”。
例如,我可以来到这里,假设出于某种原因,我想删除每一个单元格。我可以按两次D键来删除一个单元格。我可以对每一个单元格都这样做。
好了,我所有的单元格都消失了。我甚至可以保存它。
你可能认为我们造成了永久性损害,再也无法回到这个练习的初始状态了,但事实并非如此。在底部这里,我们选择菜单,然后点击“重置数据”。

它看起来有点吓人,但没关系。我们只需输入“reset data”来确认。

之所以删除操作设置得这么“困难”,是因为这是你的工作区,你可能在上面做了很多不想删除的工作。所以我们只是想确保你不会做出任何你不想做的事情。果然,在重置数据之后,我们又回到了起点。
所以,这是我想说的第一点:你无法造成永久性损害。
重要事项二:自由探索与创建 🆕

第二点我想说的是,这确实是你的工作区,你甚至可以创建新文件,可以做任何你想做的事情。

所以,我要创建一个新的Python 3笔记本,也许我把它命名为“math practice”。

我可以在里面练习Python代码。让我们试试2 + 2。
2 + 2

果然,它等于4。我可以通过点击这个Jupyter标志来浏览我所有的文件。哦,它提示我保存更改,因为我还没有保存。这很好。让我完成保存更改的过程。我可以再次点击Jupyter标志。

然后我就能看到我所有的文件,“math practice”和“playground”都在这里,所以我可以点击“Playground”。

我又回到了这里。
好了,我只是想向你展示这几件事。你将在编程游乐场中通过实践学到更多,并且在整个纳米学位课程中,你会更深入地了解如何使用这个工具。
祝你玩得开心!

总结 📝

本节课中,我们一起学习了Jupyter Notebook的基本使用方法。我们了解了如何区分和操作Markdown与代码单元格,掌握了运行单元格的两种快捷键(Control + Enter 和 Shift + Enter)。最重要的是,我们明白了Jupyter环境是一个安全的工作区,你可以自由探索、创建甚至“破坏”,因为随时可以通过“重置数据”功能恢复到初始状态。现在,你已经准备好在这个强大的工具中开始你的编程之旅了。
003:贝叶斯思维导论 🧠
在本节课中,我们将要学习贝叶斯思维的基本概念及其在机器人学和无人驾驶领域中的核心重要性。我们将从课程概述开始,逐步了解贝叶斯规则如何帮助机器人处理不确定性,并最终实现定位功能。
概述
欢迎来到贝叶斯思维的奇妙世界。很久以前,托马斯·贝叶斯牧师发明了贝叶斯规则,用以证明上帝的存在。实际上,我本人在斯坦福大学获得终身教职,正是通过将贝叶斯规则应用于机器人学领域。
贝叶斯规则是一项极其迷人的技术,它正在改变机器人学的发展进程。
为何选择贝叶斯规则?
让我解释一下原因。传统的人工智能研究者通常假设机器人完全了解其所在的世界。当这个认知世界模型出错时,机器人就会犯下致命的错误。
贝叶斯规则则允许世界存在不确定性。机器人可能并不完全知晓一切,它可能需要通过探索世界来获取信息。
收集信息的数学框架被称为统计学,而贝叶斯规则正是统计学的核心。这听起来可能有些晦涩。没关系,让它听起来晦涩吧,因为本课程的目的正是深入探究这些细节。
我必须提醒你,课程会涉及一些非常基础的数学知识,例如线性代数和概率论。如果你对这些内容感到生疏,请务必回头复习一下。不过,其背后的核心概念应该是极其直观的,直观得就像数数一样。所以,欢迎来到贝叶斯法则的奇妙世界。
课程内容安排
在深入课程之前,让我们先讨论一下本课程将涵盖哪些内容。
以下是本课程的主要模块:
- 项目0:你将首先深入我们称之为“项目0”的实践环节,稍后会了解更多细节。
- 概率基础:接下来,你将学习(或复习)基础概率论和条件概率。
- 编程实践(全):之后,你将完成第一个编程练习课,这将是后续许多编程实践的开始。
- 贝叶斯规则:接着,你将学习对无人驾驶汽车最重要的数学法则——贝叶斯规则。
- 编程实践(二):随后是更多的编程练习。
- 概率分布:此后,你将学习概率分布。概率分布与贝叶斯规则相结合,对于解决机器人定位问题至关重要。机器人定位就是指机器人如何判断自己在世界中的位置。
- 项目1:构建定位器:最后,你将在项目1中实际构建一个机器人定位器,实现一个称为二维直方图滤波器的算法。祝你学习愉快。
总结
本节课中,我们一起学习了贝叶斯思维在机器人领域,特别是无人驾驶汽车中的核心地位。我们了解了传统方法的局限性,认识了贝叶斯规则在处理世界不确定性方面的优势,并预览了从概率基础、贝叶斯规则到概率分布,最终通过项目实践实现机器人定位的完整学习路径。准备好开始这段探索不确定性与智能决策的旅程吧。
004:欢乐驾驶项目 🚗
在本节课中,我们将学习一个名为“欢乐驾驶”的快速实践项目。该项目旨在让你通过编写代码来控制一辆模拟汽车,从而初步体验自动驾驶编程。项目包含三个部分,每个部分对应一个特定的驾驶场景。
项目概述 🎯
欢迎来到“欢乐驾驶”项目。这是一个让你亲自动手编写代码来控制模拟汽车的快速实践项目。项目包含三个部分,每个部分你需要编写代码来解决一个特定的驾驶场景。
第一部分:飞跃树林 🌳
在第一部分,你需要编写代码让一辆汽车成功飞跃一小片树林。虽然这不是一个常见的自动驾驶问题,但此部分的真正目标是让你熟悉后续将使用的编程接口。
以下是实现飞跃的关键步骤:
- 初始化车辆状态:设置汽车的初始位置、速度和方向。
- 计算加速时机:在接近树林前,为汽车提供足够的加速度以获得飞跃所需的动能。
- 控制空中姿态:在飞跃过程中,保持车辆稳定,避免翻滚。
- 平稳着陆:确保汽车落地时速度适中,姿态平稳,以继续行驶。
第二部分:环形赛道驾驶 🔄
上一节我们让汽车完成了飞跃,本节中我们来看看如何让汽车在环形赛道上行驶。通过完成这个任务,你将探索转向角与转弯半径之间的关系。
转向角(steering_angle)与转弯半径(turning_radius)的关系可以近似用以下公式描述:
turning_radius = wheelbase / tan(steering_angle)
其中,wheelbase是汽车的轴距。
以下是实现环形驾驶的步骤:

- 设定恒定转向角:为汽车设置一个固定的转向角,使其能够持续转弯。
- 保持适当速度:控制油门,使汽车保持一个既能顺利过弯又不会失控的速度。
- 循环控制逻辑:编写循环代码,让汽车能够持续绕圈行驶。

我们暂时不深入探讨其背后的数学原理,但在后续课程中,我们会更深入地研究这些数学知识,并回顾项目中的这一部分。

第三部分:平行泊车 🅿️
在第三部分,你将编写一个函数,执行一系列步骤来完成一项许多人类驾驶员都感到困难的操作:将汽车平行泊入车位。
以下是平行泊车的基本步骤序列:
- 初始定位:将汽车与目标车位平行对齐,并保持适当距离。
- 倒车入库:向一侧打满方向盘,开始倒车,使车尾进入车位。
- 调整姿态:当汽车与路边成一定角度时,回正方向盘继续倒车,或向反方向打方向盘以摆正车身。
- 最终微调:前后移动车辆,使其完全停入车位中央,并与前后车辆保持安全距离。
总结 📝
本节课中我们一起学习了“欢乐驾驶”项目。我们首先编写代码让汽车完成飞跃树林的任务以熟悉环境。接着,我们探索了转向角与转弯半径的关系,并实现了汽车在环形赛道上的行驶。最后,我们编写了执行平行泊车操作的函数序列。通过这三个实践环节,你初步掌握了通过代码控制车辆基本运动的方法,为后续更复杂的自动驾驶算法学习打下了基础。
005:概率基础 🎲
在本节课中,我们将要学习概率论的基础知识。概率是描述不确定性的数学语言,对于理解自动驾驶汽车如何感知世界、做出决策至关重要。我们将从最简单的抛硬币实验开始,逐步建立起概率的核心概念。
概述:不确定性、概率与自动驾驶 🤖
恭喜你完成了项目0。当我亲自解决该项目的最后一部分时,实际上尝试了好几次才让卡片分开。这个过程很大程度上是试错,因为我不确定像 car.steer() 这样的命令会对车辆行为产生什么影响。
这与自动驾驶汽车中所谓的“控制噪声”类似。事实证明,不确定性是自动驾驶汽车几乎每个组成部分都无法避免的一部分,而管理这种不确定性是让自动驾驶汽车真正工作的关键。
在本纳米学位中,“不确定性”这个词将具有非常具体的含义。为了确保我们谈论的是同一件事,请思考不确定性是如何在自动驾驶汽车中体现的。随着你对自动驾驶汽车了解的深入,你对“不确定性”这个词的理解也将不断发展和成熟。
接下来的部分叫做“概率的学习目标”。这些目标可以看作是你学完本课后对这个词应有的理解。这些学习目标实际上是一组你在本课后应该能够回答的问题。事实上,我们稍后会再次展示这些问题。我们在本纳米学位的本课和其他课程的开头包含它们,是为了具体地展示你将学习的内容。但你目前并不需要能够回答它们。如果你现在就能回答所有这些问题,那么你可以自由地跳到下一课。

在接下来的单元中,我们将讨论概率。概率与统计学正好相反,两者之间存在阴阳关系。但不同的是,在统计学中,我们被给予数据并试图推断可能与数据相关的原因;而在概率论中,我们被给予对原因的描述,并希望预测数据。我们现在学习概率而非统计学,是因为它为我们提供了描述数据与潜在原因之间关系的语言。

理论讲得够多了,让我们开始吧。

抛硬币与概率定义 🪙


你可以争论,但我认为最好的答案是“不”,这被称为一枚公平的硬币。这意味着它确实有50%的机会出现反面。让我再抛一次。毫不奇怪,这次它实际上出现了反面。
因此,概率是描述这些抛硬币预期结果的一种方法。让我们讨论一枚公平的硬币。硬币出现正面的概率用这种P符号表示。这读作“硬币出现正面的概率”。对于一枚公平的硬币,机会是50%,也就是说在一半的抛掷中,硬币会出现正面。
在概率论中,我们通常将其写为0.5,即1的一半。所以,概率为1意味着它总是发生,概率为0.5意味着它发生一半的时间。让我问你,你认为这枚硬币出现反面的概率是多少?
答案是0.5。因为对于一枚公平的硬币,正面和反面的概率是相等的。
有偏硬币与概率基本定律 ⚖️
一枚有偏的硬币是指其中一面出现的频率远高于另一面。例如,假设我有一枚总是出现正面的硬币。我会评估这枚硬币出现正面的概率是多少?正确的数字是多少?

数字是1。这与100%相同。1仅仅意味着它总是出现正面。既然如此,你现在会评估出现反面的概率是多少?是的,答案是0。
我们在这里发现了一个我想指出的定律:正面的概率加上反面的概率等于1。之所以如此,是因为硬币要么出现正面,要么出现反面,没有其他选择。所以无论发生什么,如果我同时考虑正面和反面,其中任何一个发生的几率是1,因为我们知道它会发生。
因此,我们可以用这个定律来计算其他例子中反面的概率。假设正面的概率是0.75,也就是说四分之三的次数你会得到正面。反面的概率是多少?答案是0.25,即1减去0.75,运用了下面的定律。正如你可以验证的,0.75加0.25等于1。



所以我们刚刚学到了一些重要的东西:对于一个结果(我暂时称之为A)的概率,我们了解到其相反结果(我们称之为非A)的概率是1减去A的概率。这是一个非常基本的概率定律,随着我们继续前进,它会变得很方便,所以请记住它。
从硬币到汽车:为什么学习概率? 🚗


嘿,你在学习概率方面取得了很大进展,现在你可能理解了如何设计一个抛硬币机器人。但我只想插一句话:当你真正关心的是自动驾驶汽车时,为什么要学习这么多关于抛硬币的知识呢?
概率是自动驾驶汽车运行的核心。在深入研究像建图和定位这样真正复杂的东西之前,我们真的想告诉你最基础的知识,因为弄清楚抛硬币结果的数学原理与构建真正优秀的地图、定位和控制自动驾驶汽车的数学原理完全相同。所以请继续关注,你正在取得巨大进步,让我们更深入地探讨。
复合事件与真值表 📊

在我们的例子中,我们观察到两次正面。现在我问你一个非常棘手的问题:如果你抛掷同一枚无偏硬币两次,观察到正面和正面的概率是多少?这意味着在每次抛掷中,我们假设正面的概率是0.5。请在这里回答。


这是一个棘手的问题。如果你以前从未见过概率,你可能真的不知道答案。但答案是0.25。我将用所谓的“真值表”为你推导。在真值表中,你画出你所进行的实验的每一种可能结果。有两次抛掷:抛掷1和抛掷2。每个抛掷都有可能出现正面或反面。
所以可能的结果有:正正、正反、反正、反反。如果我们看这个表,你可以看到这两次抛掷的所有可能结果。碰巧有四个。我认为因为正面和反面是等可能的,所以这些结果中的每一个都是等可能的。因为我们知道所有结果的概率之和必须等于1,所以我们发现每个结果都有四分之一或0.25的机会。
另一种看待这个问题的方法是:第一次出现正面的概率乘以第二次出现正面的概率。第一次是0.5,第二次也是0.5。如果你将这两个数字相乘,得到0.25,即四分之一。

处理有偏硬币 🎯

现在让我挑战你,给你一枚有偏的硬币。我抛了两次。对于这枚有偏硬币,我假设正面的概率是0.6。这确实改变了到目前为止表中的所有数字,但你可以应用同样的真值表方法,来得出在假设正面概率等于0.6的情况下,看到两次正面的概率是多少。我想分步骤来做这件事。
所以与其直接问问题,让我先问你反面的概率是多少来帮你推导。答案是0.4,因为正面的概率是0.6,1减去0.6是0.4。

现在请填写整个真值表,这里有四个值,请为我计算它们。运用我们的乘积法则,答案是:正正出现是0.6乘以0.6,等于0.36;正反出现是0.6乘以0.4,等于0.24;反正出现同样是0.24;反反出现是0.16,即0.4乘以0.4。


如果我们把这些数字加起来,请继续把它们加起来,告诉我这些数字的总和是多少。毫不奇怪,它是1。也就是说,真值表的总概率总是1,因为它考虑了所有可能的情况,而所有可能的情况加在一起概率为1。所以我们检查一下,确保它是正确的。

从这个表中,我们发现正正的概率是0.36,你也可以在这里做同样的计算,0.6乘以0.6等于0.36。所以这是我们的正确答案。
极端情况与更复杂的问题 🔥

现在让我们走向极端。这是一个具有挑战性的概率问题。假设正面的概率是1,所以我的硬币总是出现正面。那么连续两次出现正面的概率是多少?答案是一。要理解这一点,我们知道反面的概率是0,所有概率都归给了正面。1乘以1是1,1乘以0是0,0乘以1是0,0乘以0是0。很容易验证所有这些加起来是1。所以,正正的概率就是一。

当我们问不同的问题时,真值表变得更有趣。假设你抛我们的硬币两次,但我们关心的是两次中恰好有一次是正面,从而另一次恰好是反面。对于一枚公平的硬币,你认为抛两次恰好看到一次正面的概率是多少?



答案应该是0.5,这是一个非平凡的问题。让我们做真值表。对于第一次抛掷,结果有正、反。对于第二次抛掷,结果也有正、反。所有可能的结果是:正正、正反、反正、反反。我们知道对于公平的硬币,每个结果都是等可能的,即恰好四分之一。


既然如此,我们现在必须将真值表与我们提出的问题联系起来。那么,在哪些结果中正面恰好出现一次?请检查相应的案例。是的,是在第二种情况和第三种情况。正正和反反这两种极端情况不满足这个条件。所以现在的技巧是取这两种情况的0.25概率并将它们相加,得到0.25加0.25等于0.5。这就是这个问题的正确答案。

现在让我让它对你来说真的非常具有挑战性。我拿一枚公平的硬币抛三次。我想知道在这三次抛掷中恰好有一次出现正面的概率是多少。这个答案很棘手。我们将通过真值表来推导。

现在有八种可能的情况。第一次抛掷可以是正面或反面,第二次抛掷同样,第三次抛掷也一样。如果我们看这个,每一种可能的组合都被表示了。例如,这是正反反。现在,这些结果中的每一个都有相同的概率:八分之一。因为有八种情况。所以8乘以八分之一加起来等于1。

有多少种情况我们恰好有一个正面?结果证明只有三种情况:正面可能在第一个位置、第二个位置或第三个位置。所以八种情况中有三种情况有一个正面。这些情况中的每一种都带有八分之一的概率。所以如果我们把这些情况加起来,它们总共带有八分之三的概率,即0.375。



加载硬币的复杂计算 🧮
现在,那是一个具有挑战性的问题。我要让它对你来说更具挑战性。我给你一枚有偏硬币,正面概率为0.6。我预计这需要你在纸上花点时间来计算这里的这个概率。
但你可以做完全相同的事情:遍历真值表,应用我之前展示的乘法来计算每个结果的概率。它们不再相同了。正正正明显比反反反更有可能。当你完成这个后,把相应的概率加起来,告诉我答案是什么。

我的答案是0.288。我是怎么得到的?让我们看看三个关键案例:正反反是0.6(正面)乘以0.4(反面)再乘以另一个0.4(反面)。这给了我0.096。现在,结果证明这里的情况有相同的概率,因为我们所做的只是排序:0.4乘以0.6乘以0.4。我们知道在乘法中,顺序无关紧要,所以你得到相同的0.096。同样地,反正反也得到0.096。所以把这些0.096加在一起,得到0.288。




所以我不必填写整个真值表(你可能在推导过程中已经做了),我只需要填写我关心的案例,它们就给了我正确的结果。

扩展到掷骰子 🎲

所以还有一个最后的练习。现在我要掷骰子。骰子和硬币的区别在于有六种可能的结果。让我假设它是一个公平的骰子,这意味着每个不同的面出现的概率是六分之一。
你认为骰子出现偶数的概率是多少?我这样写:骰子的结果是偶数。你可以再次使用真值表来计算这个数字。用真值表的说法,有六种结果:1到6。每种结果的概率相同,都是六分之一。这些数字中有一半是偶数:2、4和6。所以如果我们把这些加起来,我们得到3乘以六分之一,等于二分之一,即0.5。



最终挑战:掷两次骰子 🏁


现在,我最后要给你一个非常具有挑战性的测验。假设我们掷一个公平的骰子两次。但我们想知道出现“双倍”的概率是多少。“双倍”意味着两个结果是相同的数字,无论那个数字是什么。这实际上是一个重要的数字,因为许多涉及两个骰子的游戏在出现相同数字时有不同的规则。所以知道这个概率可能很重要。
再次,我们可以用真值表来回答这个问题。现在,真值表将有36个不同的条目:第一次掷有6种可能,乘以第二次掷的6种可能。这个平板电脑上没有足够的空间画出所有36个条目。

所以让我只画出真正重要的那些:1-1、2-2,依此类推一直到6-6。所以这些结果中的每一个都有第一次结果六分之一的概率乘以第二次结果六分之一的概率,这给了我三十六分之一。同样的逻辑适用于所有地方。所以对于所有这六个结果,我有三十六分之一的机会实现这个结果。把它们全部加起来得到六分之一。为什么?因为我得到6乘以三十六分之一。我可以将其简化回六分之一。这正好是0.1667左右。所以每六次中,你会得到一次双倍。现在,当你玩像双陆棋这样用两个骰子的游戏时,可能感觉不是这样。我可以发誓我不是每六步就得到一个双倍。但这实际上是真的。这是正确的概率。
总结 📝




让我们总结一下。你实际上学到了不少东西。
- 你学习了事件的概率,就像抛硬币的结果一样。
- 你学习了相反事件的概率是1减去该事件的概率。
- 你学习了复合事件的概率,其形式为
P * P * ... * P。

从技术上讲,这里的这个东西被称为“独立性”,意思就是第二次抛硬币的结果并不真正依赖于第一次抛硬币的结果。在我们的下一个单元中,我们将讨论“依赖性”,即不同结果之间存在奇异的依赖关系。但就目前而言,你真的已经对概率有了非常基本的理解。

所以让我们进入下一个单元,一起更深入地探索概率的兔子洞。
006:条件概率 🧮
在本节课中,我们将要学习条件概率。这是自动驾驶汽车利用传感器数据推断环境状态(例如,通过图像判断前方是否有障碍物)的核心数学语言。我们将从直觉理解开始,逐步学习其数学形式化表达,并通过多个例子掌握其计算方法。
从直觉到数学
上一节我们介绍了概率的基本概念。本节中我们来看看条件概率。这个术语听起来很神秘,但在深入数学之前,我想说明你已经凭直觉理解了它。


以下是两个问题,帮助你建立直觉:
- 请估计你所在城市一年后是阴天的概率。
- 现在,请看向窗外,然后估计一分钟后是阴天的概率。
你很可能对第二个估计更有信心,概率值非常接近0或1,这取决于此刻的天气。这是因为“现在”和“一分钟后的云量”并非独立事件。你通过观察窗户获取了传感器数据,并利用这些数据更新了你的概率估计。条件概率的本质就是利用已知信息,对我们未知的事物做出更好的估计。
依赖事件:一个思想实验


在现实生活中,事物是相互依赖的。例如,一个人天生聪明与否可以看作一次抛硬币。而这个人能否成为斯坦福教授,虽然总体概率很低(比如0.001),但它依赖于这个人是否天生聪明。如果聪明,概率会更高;反之,概率则低得多。

我们可以将其视为两次连续的抛硬币,但第二次的结果受第一次结果的影响。这与我们之前学习的独立事件不同。为了表达这种依赖关系,我们需要引入新的变量和概念。
核心概念:条件概率
为了学习条件概率,让我们研究一个医学例子。假设一位病人可能患有某种癌症,患癌概率为 P(C) = 0.1。那么无癌的概率就是 P(¬C) = 1 - 0.1 = 0.9。
我们无法直接知道病人是否患癌,但可以进行血液检测。检测结果可能是阳性或阴性。这个检测结果依赖于病人的真实健康状况。
我们引入条件概率的表示法:P(Pos|C)。这个竖线“|”表示“在给定…的条件下”。所以,P(Pos|C) 读作“在病人患有癌症的条件下,检测结果为阳性的概率”。
假设:
- 如果病人患癌,检测呈阳性的概率是 P(Pos|C) = 0.9。
- 因此,如果病人患癌,检测呈阴性的概率是 P(Neg|C) = 1 - 0.9 = 0.1。
同样,我们需要知道当病人未患癌时检测的表现:
- 如果病人未患癌,检测呈阳性(假阳性)的概率是 P(Pos|¬C) = 0.2。
- 因此,如果病人未患癌,检测呈阴性的概率是 P(Neg|¬C) = 1 - 0.2 = 0.8。
构建联合概率表(真值表)
掌握了这些概率,我们现在可以为“癌症”和“检测结果”这两个变量的所有组合构建一个联合概率表。
以下是构建此表的步骤:
- 列出所有可能性:患癌/未患癌,以及检测阳性/阴性。
- 计算每种组合的概率。这需要将“癌症状态”的先验概率与“在该状态下得到特定检测结果”的条件概率相乘。
让我们来计算:



- 患癌且检测阳性:
P(C, Pos) = P(C) * P(Pos|C) = 0.1 * 0.9 = 0.09 - 患癌且检测阴性:
P(C, Neg) = P(C) * P(Neg|C) = 0.1 * 0.1 = 0.01 - 未患癌且检测阳性:
P(¬C, Pos) = P(¬C) * P(Pos|¬C) = 0.9 * 0.2 = 0.18 - 未患癌且检测阴性:
P(¬C, Neg) = P(¬C) * P(Neg|¬C) = 0.9 * 0.8 = 0.72


将所有概率相加:0.09 + 0.01 + 0.18 + 0.72 = 1。这验证了我们考虑了所有可能的情况。



全概率公式
现在,我们可以回答一个更复杂的问题:不考虑是否患癌,检测结果为阳性的总概率 P(Pos) 是多少?





从真值表中,我们看到“检测阳性”出现在两种情况下:患癌且阳性,以及未患癌且阳性。因此,我们只需将这两种情况的概率相加:
P(Pos) = P(C, Pos) + P(¬C, Pos) = 0.09 + 0.18 = 0.27



这背后的数学原理就是全概率公式:
P(Pos) = P(Pos|C) * P(C) + P(Pos|¬C) * P(¬C)



更多练习:硬币问题
为了加深理解,我们来看另一个例子。袋子里有两枚硬币:
- 硬币1是公平的,正面概率
P(H|C1) = 0.5,反面概率P(T|C1) = 0.5。 - 硬币2是灌铅的,正面概率
P(H|C2) = 0.9,反面概率P(T|C2) = 0.1。


我们以相等的概率随机抽取一枚硬币(P(C1) = P(C2) = 0.5),然后抛掷它。


问题1:抛掷这枚硬币得到正面的概率 P(H) 是多少?

我们再次使用真值表方法:
- 抽到硬币1且得到正面:
0.5 * 0.5 = 0.25 - 抽到硬币2且得到正面:
0.5 * 0.9 = 0.45 - 得到正面的总概率:
P(H) = 0.25 + 0.45 = 0.7
问题2:抽取一枚硬币后,连续抛掷两次,得到“先正面后反面”结果的概率是多少?
以下是需要考虑的所有情况:
- 抽到硬币1,然后抛掷得到“正、反”:
0.5 * 0.5 * 0.5 = 0.125 - 抽到硬币2,然后抛掷得到“正、反”:
0.5 * 0.9 * 0.1 = 0.045 - 总概率:
0.125 + 0.045 = 0.17


问题3(变体):假设硬币1总是正面(P(H|C1)=1),硬币2正面概率为0.6(P(H|C2)=0.6)。连续抛掷两次得到“反面、反面”的概率是多少?

分析:
- 如果抽到硬币1,永远不可能得到反面,所以这部分概率为0。
- 如果抽到硬币2,得到“反、反”的概率为:
P(C2) * P(T|C2) * P(T|C2) = 0.5 * 0.4 * 0.4 = 0.08 - 因此,总概率
P(T,T) = 0 + 0.08 = 0.08

总结与展望
本节课中我们一起学习了条件概率。我们认识到,像检测结果这样的变量,其输出并非像独立的抛硬币,而是依赖于其他变量(如疾病状态)。我们通过真值表来组织这些依赖关系,并通过全概率公式来计算某个结果的总概率,即使我们不知道其依赖变量的真实状态。
其核心思想是:我们将一个可观测结果(如检测阳性)的概率,表示为它在所有可能的隐藏原因(如患病或未患病)下的条件概率的加权和。公式可以概括为:
P(结果) = Σ [ P(结果|原因_i) * P(原因_i) ]




在下一单元,我们将提出一个更关键的问题:在已知某个结果(例如,医生告诉我们检测呈阳性)的条件下,如何反过来推断其原因(例如,我们真正患病的概率)? 这将把我们引向贝叶斯定理,它会带来一些反直觉却非常重要的洞见。

本节课中我们一起学习了:
- 条件概率的直觉:利用已知信息更新对未知事件的信念。
- 条件概率的表示法:
P(A|B),表示在事件B发生的条件下,事件A发生的概率。 - 联合概率的计算:
P(A, B) = P(A|B) * P(B)。 - 全概率公式:通过考虑所有可能的原因来计算某个结果的总概率。
- 真值表方法:一种系统化列出所有可能情况并计算其概率的有效工具。
007:Python概率编程
概述
在本节课中,我们将学习如何运用Python编程语言来实现概率计算。通过动手实践,你将能够将理论知识转化为实际代码,从而更深入地理解概率在自动驾驶技术中的应用。
经过前面一系列理论材料的学习,你可能已经迫不及待地想要动手实践了。
现在,课程进入了最有趣的部分。
在这个环节,你将有机会展示你的能力,并运用刚刚学到的知识来构建有趣的程序。
我坚信,真正的学习来自于实践。正如观看他人锻炼无法让你自己减肥一样,不亲自尝试编程,你也无法深入理解概率。
因此,请深入其中,享受我们为你准备的所有精彩练习,它们将帮助你学习概率。
总结
本节课中,我们一起学习了如何通过Python编程来实践概率计算。通过动手完成练习,你不仅巩固了理论知识,也掌握了将其应用于实际问题(如计算机视觉、感知控制等自动驾驶核心领域)的初步技能。
008:贝叶斯法则 🧠
在本节课中,我们将要学习贝叶斯法则。贝叶斯法则是概率论和机器人学中的一个核心概念,它允许我们利用新的测量数据来更新对某个事件(例如汽车的位置)的初始信念。这对于处理传感器数据中存在的不确定性至关重要。
为什么不确定性很重要?🤔
上一节我们介绍了概率在机器人学中的重要性,本节中我们来看看不确定性为何如此关键。
我们知道,测量汽车的速度、方向和位置是具有挑战性的。我们无法完美地测量它们,每个测量都存在一些不确定性。
我们还知道,许多测量值是相互影响的。例如,如果我们对汽车的位置不确定,可以通过收集关于汽车周围环境和其运动的数据来减少这种不确定性。
自动驾驶汽车使用传感器来测量从车速到周围场景和物体的所有信息。虽然这些传感器测量并不完美,但当它们提供的信息通过条件概率和贝叶斯法则结合起来时,可以形成对汽车位置、运动及其环境的可靠表示。
什么是贝叶斯法则?📈


贝叶斯法则在机器人学中极其重要,可以用一句话来描述:给定一个初始预测,如果我们收集到额外的相关数据(即我们的初始预测所依赖的数据),我们就可以改进这个预测。

例如,假设我们的初始预测(也称为先验信念)是对汽车在道路上位置的估计。这可能是一个略微不准确的GPS信号给出的位置。然后,我们使用传感器收集关于汽车周围环境和汽车如何运动的数据。
你认为这些传感器数据如何帮助我们改进初始的位置预测?
一旦我们收集了关于汽车周围环境和运动的传感器数据,我们就可以利用这些信息来改进我们的初始位置预测。例如,假设我们感知到车道线和特定的地形,并且我们知道,根据先前收集的数据,如果我们在汽车两侧附近感知到车道线,汽车很可能位于车道的中心。我们还知道,如果我们感知到轮胎指向右侧,我们很可能在道路的弯曲路段。
因此,这些传感器数据,结合我们已经知道的关于道路和汽车的信息,为我们提供了更多关于我们位置最可能在哪里的信息。利用这些传感器信息,我们可以改进初始预测,更好地估计汽车的位置。
贝叶斯法则为我们提供了一种数学方法来修正我们的测量,让我们能够从一个不确定的先验信念转向越来越可能的事物。你会在机器人学中反复看到贝叶斯法则。在本课中,你将更深入地理解贝叶斯法则。
贝叶斯法则详解:一个医学例子 🏥
这个单元有些难度。我们将讨论概率推断的“圣杯”——贝叶斯法则。贝叶斯法则以托马斯·贝叶斯牧师命名,他使用这个原理来推断上帝的存在。但在这个过程中,他创造了一个全新的方法家族,极大地影响了人工智能和统计学。
让我们深入探讨。让我们使用上一个单元中的癌症例子。
假设有一种特定癌症在人群中的发生率为1%。对这种癌症的检测,如果我们患有这种癌症C,有90%的几率呈阳性。这通常称为检测的灵敏度。但即使你没有C,检测有时也会呈阳性。具体来说,如果我们没有C,检测有90%的几率呈阴性,这通常称为检测的特异度。
我的问题是:在没有其他症状的情况下,你接受了检测,并且检测结果呈阳性。你认为患有这种特定类型癌症的概率是多少?
为了回答这个问题,让我们画一个图。暂停一下,这里代表所有人。其中恰好有1%的人患有癌症。99%的人没有癌症。我们知道有一个检测:如果你有癌症,它能以90%的几率正确诊断。所以,如果你画出检测呈阳性的区域(癌症且检测阳性),那么这个区域占癌症圈的90%。然而,这还不是全部事实。即使人没有癌症,检测有时也会呈阳性。在我们的案例中,这发生在所有情况的10%中。所以,如果你必须添加更多区域,这个区域的大小是这里大区域(减去小的癌症圈)的10%。在这个区域里,检测可能呈阳性,但人没有癌症。显然,这些圆圈之外的所有区域对应的情况是没有癌症且检测呈阴性。
让我再问你一次:假设你有一个阳性检测结果。你认为你的新机会是多少?在先验癌症概率为1%、灵敏度和特异度均为90%的情况下,你认为你现在的概率是90%、8%,还是仍然是1%?
我认为大约是8%。事实上,正如我们将看到的,数学上你会得出8又1/3%。
在这个图中看到这一点的方法是:这是检测呈阳性的区域。通过获得阳性检测结果,你知道你在这个区域内,其他都不重要。你知道你在这个圆圈里。但在这个圆圈内,癌症区域相对于整个区域的比例仍然很小。显然,拥有阳性检测结果改变了你的癌症概率,但它只增加了大约8倍,我们马上会看到。
这就是贝叶斯法则的本质,我马上会给你公式。存在某种先验,我们指的是在你进行检测之前的概率。然后你从检测本身获得一些证据。这引导你得到所谓的后验概率。不,这实际上不是一个加法操作。事实上,在现实中它更像是一个乘法,但从语义上讲,贝叶斯法则所做的是将检测中的一些证据纳入你的先验概率,从而得出后验概率。
具体到我们的癌症例子:我们知道癌症的先验概率 P(C) 是 0.01,即1%。在给定检测呈阳性(这里缩写为“阳性”)的情况下,癌症的后验概率 P(C|阳性) 是先验乘以我们的检测灵敏度(即给定我有癌症时得到阳性结果的几率 P(阳性|C))的乘积。你可能会记得这个和是 0.9 或 90%。
现在要警告你,这并不完全正确。为了使其正确,我们还必须计算非癌症选项的后验概率,即 P(非C|阳性)。我们知道 P(非C) 是 0.99(即 1 - P(C)),乘以在没有癌症的情况下得到阳性检测结果的概率 P(阳性|非C)。注意这两个方程是相同的,只是将C换成了非C。这个需要一点时间来计算。我们知道,如果检测对象没有癌症,我们的检测给出阴性结果的几率是 0.9。因此,在无癌症的情况下,它给出阳性结果的几率是 10%。
现在有趣的是,这大约是正确的方程,但概率加起来不等于1。为了说明,我要求你计算这些。请使用我们上面的例子给出第一个表达式和第二个写在这里的表达式的确切数字。
显然,P(C) * P(阳性|C) = 0.01 * 0.9 = 0.009。而 P(非C) * P(阳性|非C) = 0.99 * 0.1 = 0.099。
我们在这里计算的是这里的绝对面积(0.009)和这里的相反面积(0.099)。归一化过程分两步进行。我们只是将这些值归一化,保持比例相同,但确保它们加起来等于1。所以首先计算这两个值的和。请告诉我这个和。是的,答案是 0.108。从技术上讲,P(阳性) 意味着阳性检测结果的概率,它仍然是标记你的圆圈内的面积,根据我们上次学到的,它只是这里这两项的和,即 0.108。
现在,最后,我们可以得到实际的后验概率。而这里的这个通常被称为两个事件的联合概率,后验是通过将这里的这个除以这个归一化因子得到的。
所以让我们在这里做这个除法。让我们将这个数字除以这个归一化因子,得到在收到阳性检测结果的情况下患有癌症的后验分布。将这个数字除以这个,我们得到 0.0833。对于非癌症版本也是一样,取这里的数字除以相同的分母,答案大约是 0.9167。
为什么你不花一秒钟时间把这两个数字加起来,然后告诉我结果?答案是 1,正如你所期望的那样。
这确实很有挑战性。你可以在这张幻灯片上看到很多数学。所以让我再回顾一遍,让它对你来说容易得多。
贝叶斯法则算法 📝
贝叶斯说我们有一个情况:有一个先验,一个检测有一定的灵敏度和特异度。当你收到一个阳性检测结果时,你所做的是取你的先验,乘以给定C时这个检测结果的概率,并乘以给定非C时这个检测结果的概率。所以这是你考虑患有癌症的分支,这是你考虑没有癌症的分支。
当你完成这些后,你得到一个数字。这个数字结合了癌症假设与检测结果,既针对癌症假设也针对非癌症假设。现在,你做什么?你把它们加起来,然后通常它们加起来不等于1。你得到一个特定的量,这个量恰好是检测结果为阳性的总概率 P(阳性)。
接下来你要做的就是将这里的这个除以这里的和。在右侧,除数对两种情况是相同的,因为这是你的癌症分支,你的非癌症分支。但这个除数不再依赖于癌症变量。你现在得到的是期望的后验概率,并且它们加起来等于1,如果你一切都做对了的话,如上所示。
这就是你的贝叶斯法则算法。
现在,如果你的检测结果是阴性,同样的算法也适用。我们马上练习一下。
假设你的检测结果是阴性。你仍然可以问同样的问题:现在我患有癌症或不患癌症的概率是多少?但现在这里所有的阳性都变成了阴性。总和是阴性检测结果的总概率 P(阴性),然后现在除以这个值,你现在得到在假设你有阴性检测结果的情况下,癌症和非癌症的后验概率。当然,这对你来说会有利得多,因为我们没有人想得癌症。
所以看一会儿这个。现在让我们使用我之前给你的相同数字为阴性情况进行计算。这次我将一步一步地进行,这样我可以真正指导你完成这个过程。
我们首先给出先验概率、灵敏度和特异度。我希望你首先填写所有缺失的值。所以 P(非C) 是没有癌症的概率,P(阴性|C) 是给定癌症时阴性的概率(即阳性的否定),P(阴性|非C) 是给定非癌症时阴性的概率。显然,这些仍然是之前的 0.99、0.1 和 0.9。我希望你做对了。
现在假设检测结果呈阴性。应用与之前相同的逻辑,请给出在给定阴性检测结果的情况下,患有癌症的联合概率 P(C, 阴性) 和没有癌症的联合概率 P(非C, 阴性)。
这里的数字是 0.001。它是我的癌症先验 0.01 和在癌症情况下得到阴性结果的概率(即这里的 0.1)的乘积。如果我将这两项相乘,我得到 0.001。这里的概率是 0.891。我相乘的是没有癌症的先验概率 0.99 和在未患癌症的情况下看到阴性结果的概率(即这里的 0.9)。所以如果我们将 0.99 乘以 0.9,我实际上得到 0.891。
让我们计算归一化因子,你知道记住这是什么。答案是 0.892。你只需将这里的这两个值相加。
最后,告诉我,在已知我们有阴性检测结果的情况下,癌症的后验概率 P(C|阴性) 是多少,以及在已知我们有阴性检测结果的情况下,没有癌症的概率 P(非C|阴性) 是多少。请在这里给出你的数字。
这大约是 0.0011,我们通过将 0.001 除以归一化因子 0.892 得到。检测后没有癌症的后验概率大约是 0.9989。这是通过将这里的这个概率除以归一化因子得到的。
毫不奇怪,这两个值确实加起来等于 1。现在,这个结果值得注意的地方在于它的真正含义。在检测之前,我们有 1% 的几率患有癌症。现在我们患有癌症的几率大约是九分之一 percent。所以我们的癌症概率下降了大约 9 倍。因此,检测确实帮助我们获得了对没有癌症的信心。相反,之前我们有 99% 的几率没有癌症,现在是 99.89%。所以所有的数字都完全按照你期望的方式工作。
更复杂的例子 🔄
让我让你的生活变得更难一些。假设某种其他疾病的概率是 0.1,所以 10% 的人口患有它。我们的检测在阳性情况下确实信息量很大,但有 0.5 的几率,如果我没有癌症,检测确实会这么说。所以灵敏度高,特异度较低。
让我们从填写前三个开始。显然,这些只是 1 减去那些:P(非C) = 0.9,P(阴性|C) = 0.1,P(阳性|非C) = 0.5。注意这两个数字可能非常不同。这里没有矛盾。这些家伙必须加起来等于 1。所以给定非C,阳性和阴性的概率必须加到 1,但这些家伙不需要。需要大量练习才能理解哪些数字必须加起来等于 1,但我以你应该做对的方式设置了它。
现在困难的部分来了。P(C, 阴性) 是多少?答案是 0.01。P(C) 是 0.1,P(阴性|C) 也是 0.1。所以如果你把它们相乘,得到 0.01。
P(非C, 阴性) 是多少?答案是 0.45。P(非C) 是 0.9,P(阴性|非C) 是 0.5。所以 0.9 * 0.5 = 0.45。
这里的这个归一化因子 P(阴性) 是多少?嗯,你只需将这两个数字相加得到 0.46。
所以告诉我最后两个数字是什么。第一个是 0.01 除以归一化因子 0.46,这给我们 0.0217。第二个是这个曲线,0.45 除以 0.46,给我们 0.9783。
这些是正确的后验概率。我们开始时患有癌症的几率是 10%,我们得到了一个阴性结果,我们下降到大约 2% 的几率患有癌症。
现在让我们考虑检测结果呈阳性的情况,我希望你只给我这里的两个数字,而不是其他的。
所以再一次,我们这里有 0.9、0.1 和 0.5。很快地将这个家伙乘以这里的这个家伙:0.09。将这个家伙乘以这里的这个家伙:0.45。相加得到 0.54。相应地相除:0.09 除以 0.54 给我们 0.166...,0.45 除以 0.54 给我们 0.833...。
这意味着,有了阳性检测结果,我们的癌症几率从 0.1 增加到 0.16。显然,我们没有癌症的几率相应下降。你明白了吗?
总结贝叶斯法则 🎯
在贝叶斯法则中,你有一个你关心的隐藏变量(例如,是否患有癌症)。我们无法直接测量它。相反,我们有一个检测。我们有一个关于这个变量为真的频率的先验。检测通常通过当变量为真时它说阳性的频率(灵敏度)和当变量为假时它说阴性的频率(特异度)来表征。
基于我们的先验,乘以测量值(在本例中 soon to be 阳性测量值),为我们新的变量(联合概率)给出结果,并对我们关于隐藏变量(癌症)的相反假设下的实际测量值做同样的事情。
这个乘法给了我们这里的这个家伙。我们将这两项相加,它给了我们一个新的变量 P(测量)。然后我们除以这些家伙,得出在给定我们的检测结果的情况下,对隐藏变量 C 的最佳估计。在这个例子中,我使用阳性作为检测结果。但你也可以用阴性例子做同样的事情。
这与我们的图表完全一样。一开始,有一个所有情况中这个特定变量为真的先验。我们注意到在这个先验内部,有一个检测结果适用的区域(真阳性)。我们注意到检测结果也可能在条件未满足时适用(假阳性)。所以这里的这个表达式和这里的这个表达式完全对应于这里的红色区域和绿色区域。
但然后我们注意到这两个区域加起来不等于 1,原因是外面还有很多东西。所以我们计算了总面积,也就是这里的这个表达式 P(测量)。然后我们通过将这里的这两项除以总面积来归一化,得到分配给红色事物和绿色事物的相对面积。这是通过除以这个区域的总面积来完成的,从而摆脱了任何其他情况。
现在,我应该说,如果你理解了这一点,你就学到了关于统计学和概率的一些非常重要的东西。这完全是非平凡的,但它非常方便。
机器人定位例子 🤖
所以我要用第二个例子和你一起练习。在这种情况下,你是一个机器人。这个机器人生活在一个恰好有两个地方的世界里:一个红色地方和一个绿色地方,R 和 G。
现在,假设最初,这个机器人不知道它在哪里。所以对于任何一个地方(红色或绿色)的先验概率是 0.5。它也有传感器(你可以通过它的眼睛看到),但它的传感器往往有些不可靠。所以在红色网格单元看到红色的概率是 0.8,在绿色单元看到绿色的概率也是 0.8。
现在,假设机器人看到了红色。现在机器人位于红色单元的后验概率是多少,给定它刚刚看到了红色?相反,即使它看到了红色,它位于绿色单元的概率是多少?
现在,你可以应用贝叶斯法则并计算出来。这个例子给了我们有趣的数字。红色的联合概率 P(R, 见红) 是 0.4,绿色的联合概率 P(G, 见红) 是 0.1。0.4 加 0.1 等于 0.5。如果你现在将 0.4 除以 0.5 归一化,你得到 0.8,如果你将 0.1 除以 0.5 归一化,你得到 0.2。
如果我改变一些参数,比如机器人知道它在红色单元的先验概率是 0,因此在绿色单元的先验概率是 1。请再次使用贝叶斯法则计算这些后验概率。我必须警告你,这是一个有点棘手的情况。
答案是:先验不受测量的影响。所以如果它在红色的概率是 0,在绿色的概率是 1,尽管它看到了红色。要看到这一点,你发现位于红色且看到红色的联合概率是 0 * 0.8 = 0。绿色的相同联合概率是 1 * 0.2 = 0.2。所以我们必须将 0 和 0.2 归一化,它们的和是 0.2。所以 0 除以 0.2 仍然给我们 0,0.2 除以 0.2 给我们 1。这些正是这里的数字。
让我们进一步改变这个例子。让我们把这里的这个改为 0.5,并恢复到均匀先验。请继续计算我们的后验概率。
现在,答案大约是 0.615 和 0.385。这些是近似值。再一次,0.5 * 0.8 = 0.4。0.5 * (1 - 0.8) = 0.5 * 0.2 = 0.1。把它们加起来 0.5。归一化:0.4 / 0.5 = 0.8,0.1 / 0.5 = 0.2。等等,我犯了一个错误。让我纠正:先验是 0.5 和 0.5。P(见红|R) = 0.8, P(见红|G) = 0.2。所以联合概率:P(R, 见红) = 0.5 * 0.8 = 0.4;P(G, 见红) = 0.5 * 0.2 = 0.1。总和 P(见红) = 0.5。后验:P(R|见红) = 0.4 / 0.5 = 0.8;P(G|见红) = 0.1 / 0.5 = 0.2。这和我之前得到的一样。抱歉,我之前的计算有误。对于 P(见红|G) 是 0.2 的情况,后验确实是 0.8 和 0.2。
现在,如果我改变 P(见红|G) 为 0.5 呢?那么联合概率:P(R, 见红) = 0.5 * 0.8 = 0.4;P(G, 见红) = 0.5 * 0.5 = 0.25。总和 P(见红) = 0.65。后验:P(R|见红) = 0.4 / 0.65 ≈ 0.615;P(G|见红) = 0.25 / 0.65 ≈ 0.385。所以现在你明白了。
扩展到多个状态 🌐
我现在要让你的生活变得非常困难。世界上有三个地方,而不仅仅是两个:一个红色的和两个绿色的。为简单起见,称它们为 A、B 和 C。让我们假设它们都有相同的先验概率 1/3 或 0.333...。假设机器人看到红色,并且和以前一样,在单元格 A 中看到红色的概率是 0.9,在单元格 B 中看到绿色的概率是 0.9,在单元格 C 中看到绿色的概率也是 0.9。
所以我改变的是,我给了隐藏变量(有点像癌症和非癌症变量)三个状态。它不像以前那样只有两个(A 或 B),而是 A、B 或 C。
让我们一起解决这个问题,因为它遵循与以前完全相同的步骤,即使可能不明显。所以让我问你:在看到红色之后,位于单元格 A 的联合概率 P(A, 见红) 是多少?这和之前的联合概率一样。和以前一样,我们将先验乘以这里的这个家伙。得到 0.333... * 0.9 = 0.3。
单元格 B 的联合概率 P(B, 见红) 是多少?嗯,答案是我们乘以 1/3 的先验乘以在单元格 B 中看到红色的概率。现在看到绿色的概率是 0.9,所以红色是 0.1。所以 0.1 乘以这里的这个得到 0.0333...。
最后,当你看到红色时,位于 C 的概率 P(C, 见红) 是多少?答案和这里的这个家伙完全一样,因为 B 和 C 的先验相同,并且这些概率对于 B 和 C 是相同的,所以这应该完全相同。所以使用十万美元的问题:我们的归一化因子 P(见红) 是多少?
答案是你只需将这些加起来。现在我们计算所有三种可能结果的期望后验概率。所以请把它们代入这里。
像往常一样,我们将这里的这个家伙除以归一化因子,这给我们 0.3 / (0.3 + 0.0333 + 0.0333) = 0.3 / 0.3666 ≈ 0.818。意识到这里的所有数字都有点近似。
对于这个家伙也一样,大约是 0.091。这是完全对称的,0.091。毫不奇怪,这些家伙加起来等于 1。
你学到了什么?📚
在贝叶斯法则中,可能不止有两个潜在原因(癌症、非癌症),可能有 3、4 或 5 个,任何数字。你可以应用完全相同的数学,但你必须跟踪更多的值。
事实上,世界也可能不止有两个检测结果(这里是红色或绿色),它可能是红色、绿色或蓝色。这意味着我们的测量概率将更加复杂,必须给你更多信息,但数学保持完全相同。我们现在可以处理具有许多可能隐藏原因(世界可能在哪里)的非常大的问题,并且我们仍然可以应用贝叶斯法则来找到所有这些数字。
最终测试:旅行者问题 ✈️
让我给你一个最后的测试。这个测试实际上是直接取自我的生活,当你看到我的问题时你会微笑。我过去经常旅行。有一段时间情况很糟,以至于我发现自己躺在床上,不知道自己在哪个国家。我不是在开玩笑。
所以说我 60% 的时间在外旅行,只有 40% 的时间在家。现在是夏天。我住在加利福尼亚,夏天通常不下雨。而在我旅行的许多国家,下雨的几率要高得多。
现在说我躺在床上。我在这里躺在床上,我醒来,我打开窗户,我看到正在下雨。现在应用贝叶斯法则。你认为我在家的概率是多少,既然我看到正在下雨?只给我这一个数字。
我得到 0.0217,这是一个非常小的数字。我得到这个的方式是取 P(家) * P(下雨|家),用同样的数字在这里加上在外旅行时相同概率的计算来归一化:P(旅行) * P(下雨|旅行) = 0.6 * 0.3。那是 0.0217,或更好的 2%。你算出来了吗?
如果是这样,你现在理解了一些非常有趣的东西。你能够观察一个隐藏变量,理解一个检测如何能给你关于这个隐藏变量的信息反馈,这真的很酷,因为它允许你将相同的方案应用到世界上许多实际问题上。
恭喜!在我们的下一个单元(这是可选的)中,我让你编程所有这些。所以你可以尝试在实际的编程界面中做同样的事情,并编写实现诸如贝叶斯法则之类的东西的软件。但不用担心,这是可选的。如果你不知道如何编程,只需跳过下一个单元。

本节课中我们一起学习了贝叶斯法则。我们从理解不确定性在机器人学和自动驾驶中的重要性开始,然后深入探讨了贝叶斯法则的核心思想:利用新的证据更新先验信念。我们通过医学检测、机器人定位和旅行者问题等多个例子,逐步推导并应用了贝叶斯公式。你学会了如何处理二分类乃至多分类问题,并理解了归一化在计算后验概率中的关键作用。贝叶斯法则为我们提供了一种强大的数学工具,用于在存在不确定性的世界中做出更明智的决策。
009:概率分布 📊
在本节课中,我们将要学习概率分布。概率分布是自动驾驶汽车用来表示其对世界内部信念的一种数学方式。通过理解概率分布,我们可以更好地掌握车辆如何表示不确定性,并利用传感器信息来更新其信念。
概述
上一节我们介绍了自动驾驶汽车如何感知环境并利用信息降低不确定性。本节中,我们来看看汽车如何用数学方法——即概率分布——来具体表示这种不确定性。

概率分布是一种数学工具,用于表示所有可能结果的不确定性。例如,在定位车辆时,可能的结果就是车辆所有可能的位置。
初始不确定性
当一辆车刚启动时,它完全不知道自己在哪。如何用数学表示这种初始的不确定性呢?
如果你完全不知道车辆位于世界何处,那么其位置的概率分布将看起来是完全平坦的。车辆在旧金山或在东京的概率是完全相同的。车辆位于任何特定位置的概率都是一个恒定值,在所有位置都相同。
因此,这个概率分布可以用一条水平的直线来表示,它显示了所有可能结果的概率。

更新信念
但随着我们收集到更多信息,这个分布会发生变化。
假设我们获得了一个GPS传感器读数,它告诉我们车辆更靠近旧金山,而不是东京。那么,在GPS测量值附近的位置概率会上升,而远离GPS测量值的区域概率会下降。
即使GPS信号并不完美,你现在对车辆的可能位置也有了更多信息。车辆永远无法100%确定其位置,但通过感知,车辆增加了其确定性。概率分布的形状告诉你车辆最可能和最不可能的位置。
概率分布的应用
概率分布是一种非常有用的可视化并表示不确定性的方法,不仅适用于单车定位,也适用于跟踪车辆周围行人、自行车和其他移动车辆的位置。这些分布也用于表示传感器测量的不确定性。

请记住,自动驾驶汽车是轮子上的机器人,因此从感知、测量到移动的一切过程都涉及某种不确定性。
在本课中,你将学习如何绘制和解释概率分布。这将为你学习车辆定位和物体跟踪做好准备。
连续分布

今天,我想更深入地谈谈概率分布,特别是所谓的连续分布,因为在本课程后续学习中,有一些注意事项你需要了解。


以下是一个新道具:一把Nerf枪。我想做一个实验:我发射一个抛射物,它会击中地面,比方说在A点和B点之间的某个位置,但我不知道具体是哪里。
考虑任意位置X。假设Nerf枪的角度和抛射物背后的压力都是随机的。那么,你选择的任何特定X点是正确位置的概率是多少?
答案是0,只能是0。因为这里的任何特定值都极其、极其不可能,其可能性小到我们无法赋予它一个非零的概率。
轮盘赌示例

这是一个更常见的例子,叫做“命运之轮”。我们旋转一个物体,比如一个瓶子或一支笔,然后它会停在一个特定的角度。

我们都知道角度在0到360度之间。那么,选择一个特定的角度,比如精确的180度,我们的物体停在这个精确角度的概率是多少?答案还是0。对于任何其他数字,如1、179.99、179.98等,每个角度的概率都是0。这是因为,要恰好得到那个精确到小数点后数位的角度,你几乎不可能做到。


连续空间的悖论

这就引出了一个难题:这是否意味着物体不会停在任何地方?是的,如果你认为它不停在任何地方。但显然,它会停下来。所以正确答案是“不”。

但奇怪的是,它会停在一个像这样的角度,比如101.374819度等等。而这个具体的数字,其概率确实是0。所以,无论结果是什么,那个特定的结果都极不可能,其概率为0。这是当你进入连续空间和连续分布时,概率论中一个奇特的现象:每个结果的概率都是0。这听起来完全违反直觉,也确实如此,但理解这一点很重要。
重新定义连续分布的处理方式
让我们重新定义如何处理连续分布。
再次以我们的圆盘和旋转的瓶子为例。称结果为X,即瓶子最终停下的角度。
问:X在0到180度之间的概率是多少?你猜对了,是二分之一,即50%的概率在0-180度之间,50%的概率在180-360度之间。

那么,在260度到290度这个区间内的概率是多少?这实际上是1/12,约等于0.0833。


现在,让我们把这个区间变得非常非常小。假设我们从179度到180度,这是一个仅1度宽的微小范围。概率是多少?是0.002777,也就是1/360。
显然,对于任何由A和B定义的区间,其概率等于区间大小除以360。如果你答对了这些,你就理解了核心概念。

概率密度
现在,我要教你一个非常深刻的概念——概率密度。在连续空间中,我们可以把密度看作是概率的一种“代理”,但它并不完全等同于概率。
以瓶子为例。我们知道旋转瓶子的结果在0到360度之间。根据所学,你想把0概率分配给这个范围之外的任何值。但在这个范围内,我们希望赋予一个值,并表示:看,这里有一个函数,它使得这个区间内的每个结果都同等可能。

那么,请为我构造这个函数。我给你两个约束条件:
- 在0到360的范围内,每个结果具有相同的值。
- 这个函数下方的面积总和(积分)为1。
告诉我,对于介于0和360度之间的X,这个函数的值是多少?这里需要填入一个单一的数值。
答案是0.002777...,也就是1/360。理解方式是:这个区间的宽度是360,我们不知道高度(即我让你猜的值)。但如果我们将360乘以1/360,那么这个矩形的面积就变成了1。
另一个例子:出生时间


假设我们观察一个人的出生日期和时间,但具体只看时间中的“秒”部分。它可能正好在分钟开始时(即0秒),也可能在59.282秒,等等。
我们假设这是完全均匀的,没有偏好出生在分钟的前段或后段。现在问你两个问题:对于任何这样的时间戳秒数X,这个特定X的概率是多少?这个特定X的密度又是多少?
请注意,这是两个不同的问题。正确答案是:任何特定连续值的概率是0。但对于这个均匀分布,我们发现讨论的区间在0到60秒之间。因此,密度是1/60,即0.01666。
非均匀密度测验
在这个测验中,我们将研究非均匀密度。具体来说,我们观察人们一天中的出生时间。假设在这个练习中,在中午之前出生的可能性是下午或晚上的两倍。

那么,观察一天中的时间及其密度,你认为形状最可能是以下哪种?
- 上午密度高,下午密度低。
- 全天均匀。
- 上午密度低,下午密度高。
我会选择第一个。原因是:如果在中午之前出生的可能性是两倍,那么上午的密度应该是下午的两倍,这最好由第一个图描述。第二个图是均匀的,忽略了“两倍可能”的条件。第三个图则更糟,它强调下午而非上午。


计算非均匀密度
现在进入困难部分:我想让你计算实际的密度。
我们知道,在中午之前出生的密度是中午之后的两倍。这是我的密度函数:f(x),其中x是一天中的时间。你已经识别出了形状,现在有两个参数A和B(分别代表上午和下午的密度高度)。
我想让你算出A和B是多少,假设这里的基本单位是小时,水平区间总共有24小时。提示:确保下方的总面积总和为1。
我的计算方法是:这三个矩形的面积必须相同。因为它们加起来必须等于1,所以每个的面积正好是1/3。
从右边开始:如果面积是1/3,并且我们要覆盖12小时,那么 B = (1/3) / 12 = 1/36 ≈ 0.0277。
左边的面积完全相同,A = (2/3) / 12 = 1/18 ≈ 0.0555。
如果我们计算 A * 12 + B * 12,结果应该是1。因为 (1/18)*12 = 2/3,(1/36)*12 = 1/3,2/3 + 1/3 = 1。这只是个交叉验证。
这是一个你能够计算的非均匀密度的例子。如你所见,每个特定的出生时间概率恰好为0,这只是一个密度。
密度可以大于1
这里还有一个测验:我们从飞机上投下一个包裹,它需要3到3.5分钟落到地面。假设在这之间的概率密度是均匀的,再次问你同样的问题。
显然,3分钟之前和3.5分钟之后的密度是0。在3到3.5之间是均匀的,有一个值A。告诉我A是多少?
在我的计算中,A是2。原因是这里的归一化因子是0.5(从3到3.5)。用0.5乘以2得到面积为1,这正是我们想要的密度。

这里有趣的是:A大于1。概率永远不会大于1,它们以1为上界。但密度可以大于1。事实上,如果这个包裹在3分钟到3分01秒之间到达,那么这个密度将是60(因为一分钟有60秒,区间宽度是1/60小时,密度高度=1/(1/60)=60)。
所以,密度有可能大于1。如果你把它想象成概率,这可能会有点令人困惑。但你应该知道情况就是如此。
密度函数的条件
让我问另一个棘手的问题:对于一个函数F要成为(概率)密度函数,必须满足以下哪些条件?
- 必须处处为正
- 必须非负
- 必须连续
- 必须小于或等于1

这是一个非常棘手的问题。显然,密度不必处处为正,只要非负就足够了。我举过在某个区间外密度为0的例子,0不是正数,只是非负数。
密度不必连续。我举过一个密度像这样的例子,在跳跃点处存在不连续性。所以“连续”是不正确的。
“小于或等于1”也非常棘手。我认为这是不正确的。原因如下:假设你有一个密度,它只给0到0.1之间的值赋予均匀概率。那么这个密度的高度可能是10(即1/0.1)。因此,密度有可能超过1。这是密度与概率的一个关键区别:概率总是小于或等于1,而密度可以大于1。
总结
本节课中,我们一起学习了概率分布和概率密度的概念。你现在知道了什么是均匀密度,也遇到了一种中间有“台阶”的新型密度。在后续课程中,我们将会看到形状更奇特的密度,比如高斯分布(又称正态分布)。在我们讨论大数定律和被称为中心极限定理的有趣内容之后,会再回到这个话题。

概率分布是自动驾驶汽车表示和推理不确定性的基石,掌握它对于理解车辆的定位、感知和决策过程至关重要。
010:编程概率分布 📊
在本节课中,我们将学习如何将概率分布的概念转化为计算机代码。这对于处理自动驾驶技术中的不确定性至关重要。
上一节我们介绍了概率在自动驾驶中的重要性,本节中我们来看看如何具体地用代码实现它。
课程概述
接下来的单元具有挑战性,我们不期望你初次接触就能完全理解。你可能会遇到困难,但我们现在将专注于如何将概率分布编程到计算机代码中。理解这一点非常重要,因为大量自动驾驶代码都涉及处理不确定性和概率问题。你必须理解如何在计算机代码中实现这些概念。
如果你遇到困难,可以前往Slack社区与其他学员交流,你并非孤身一人。努力理解这些概念,我们随时为你提供帮助。
核心概念与实现
概率是量化不确定性的数学工具。在代码中,我们通常用变量和函数来表示和操作概率。
以下是一个用Python代码表示简单概率分布的示例:
# 定义一个简单的概率分布:抛硬币
coin_distribution = {‘正面’: 0.5, ‘反面’: 0.5}
学习步骤与建议
以下是顺利学习本单元的几个关键步骤:
- 理解基础:首先确保理解概率论的基本概念,如随机变量、概率密度函数和累积分布函数。
- 动手编码:跟随课程示例,亲自编写和运行代码,观察概率分布的行为。
- 利用社区:在Slack或论坛上积极提问,参与讨论,查看其他同学遇到的问题和解决方案。
- 反复实践:通过完成练习和项目来巩固知识,编程技能需要通过实践来提升。
总结
本节课中我们一起学习了将概率分布编程实现的重要性及基本方法。我们了解到,编程概率分布是处理自动驾驶系统中不确定性的核心技能。虽然初学可能有挑战,但通过理解基础、积极实践和利用社区支持,你完全可以掌握它。记住,编写代码来处理概率,是搭建智能驾驶系统感知与决策模块的基石。
011:高斯分布
在本节课中,我们将要学习高斯分布。这是一种在统计学和自动驾驶技术中都非常基础且重要的概念。
概述
高斯分布,也被称为正态分布,是一种描述数据分布的数学模型。它由数学家卡尔·弗里德里希·高斯在约200年前提出。当时,高斯在绘制地图时遇到了测量误差的问题,他发现这些误差并非完全随机,而是遵循一种特定的规律。为了描述这种误差的分布,他提出了高斯分布,其形状像一个钟形曲线。这个概念在过去的200多年里对统计学至关重要,并且是自动驾驶汽车感知与控制系统的核心基础之一。
上一节我们介绍了概率与统计的基本概念,本节中我们来看看如何用高斯分布来描述连续数据的分布规律。
高斯分布的发现
卡尔·弗里德里希·高斯在大约200年前进行土地测量和地图绘制工作。在这个过程中,他必须面对一个实际情况:测量总会存在微小但显著的误差。这些误差导致多次测量结果无法完美吻合。
因此,他发明了一种方法来描述测量误差的分布特性。这种方法所呈现的曲线形状像一个钟,你可能之前见过,这就是高斯分布。
高斯分布的特性
高斯分布的形状由两个参数决定:均值(μ)和标准差(σ)。均值决定了曲线的中心位置,而标准差决定了曲线的宽度或数据的离散程度。
其概率密度函数的公式如下:
f(x) = (1 / (σ * √(2π))) * e^(-(x-μ)² / (2σ²))
这个公式描述了在给定均值μ和标准差σ的情况下,随机变量取某个值x的概率密度。
从数据中理解高斯分布
与其直接学习复杂的数学公式,不如让我们通过实际操作数据来理解它。你可以通过收集大量的小概率事件或测量数据,将它们相加并计算其平均值。
当你进行这样的操作时,一个有趣的现象会发生:即使原始数据看起来是随机的,但大量数据的分布总和会呈现出一种特定的形状。
以下是当你处理大量随机数据时可能观察到的步骤:
- 收集大量独立的测量数据或事件样本。
- 计算这些数据的平均值。
- 绘制数据值与其出现频率的关系图。
你会发现,最终生成的图形会惊人地接近我们之前提到的钟形曲线,也就是高斯分布的形状。
😊 现在,让我们开始深入探索。
总结
本节课中我们一起学习了高斯分布。我们了解了它的历史起源,源于高斯对测量误差的研究。我们认识了高斯分布的钟形曲线特征,以及决定其形状的均值(μ)和标准差(σ)两个核心参数。最重要的是,我们明白了即使从看似随机的微小数据出发,当数据量足够大时,其整体分布也会自然呈现出高斯分布的形态。这一原理是自动驾驶汽车处理传感器噪声、进行状态估计和做出决策的数学基础。
012:机器人定位 🧭
在本节课中,我们将要学习机器人定位的核心概念。定位是让机器人或自动驾驶汽车在环境中确定自身精确位置的关键技术。我们将从直观理解开始,逐步深入到数学原理和编程实现,最终你将能够编写一个基础的定位算法。

课程概述与动机 🚗
我大约六年前开始在优达学城工作,我的第一个项目就是帮助制作一门名为“如何编程一辆自动驾驶汽车”的课程。这门课程后来更名为“机器人人工智能”。你现在要观看的课程正是那门课程的第一课。在过去的六年里,我学习了大量关于自动驾驶汽车的知识,远超这门课程所涵盖的内容。但我仍然会反复观看这些课程,每次都能学到新东西。到目前为止,我可能已经看过你即将观看的这节课大约十次了。所以,如果你是优达学城的老学员,你可能会认出这节课以及在本纳米学位中会遇到的其他几节课。当然,如果你不想重看,可以跳过任何课程。但我怀疑,如果你选择观看,你很可能会学到一些新东西。无论如何,祝你学习愉快。
欢迎来到“机器人人工智能”课程。你将进入一个激动人心的七周课程,学习如何为自动驾驶汽车编程。
为了激发我们在这门课程中想要实现的目标,让我先展示一些视频。我对自动驾驶汽车的兴趣始于2004年的DARPA大挑战赛。当时,我在斯坦福大学的团队开发了Stanley,一个能够在莫哈韦沙漠中自主行驶的机器人。这辆车基于一辆大众途锐,配备了各种传感器,如GPS和激光雷达,并且能够在没有任何人工输入的情况下自主决策。


DARPA大挑战赛是2005年举行的一场政府赞助的比赛。我们看到机器人Stanley在沙漠中完全无人驾驶地移动。任务是沿着约130英里的沙漠小径行驶,最先完成比赛的队伍获胜。在比赛进行到约110英里时,我们超过了卡内基梅隆大学的另一辆机器人。我们的机器人能够导航非常陡峭的山路,并能够避免与岩石碰撞或坠崖。这一切都基于我将在本课程中教给你的能力。经过近7小时和131英里的行驶,我们的机器人第一个返回了起点,赢得了斯坦福大学200万美元的奖金,而Stanley也被收藏于美国史密森尼国家历史博物馆。
这项工作引领了城市挑战赛,我们建造了另一个名为Junior的机器人,最终获得了第二名。城市挑战赛是DARPA的后续比赛,要求汽车在交通中行驶。如果说大挑战赛是在静止的沙漠地面上进行,那么这次则是在一个模拟的城市环境中,机器人需要与其他交通参与者互动,并遵守交通规则,例如这里的左转。它必须能够以非常高的精度保持在车道内,适应对向交通,并像在一个小城市中一样自信地驾驶。
这促使谷歌进行了一系列被称为“谷歌自动驾驶汽车”的实验。我相信这些是目前最好的机器人汽车。我们看到其中一辆车在帕洛阿尔托的大学大道上行驶,就像人类司机一样,但这辆车是自动驾驶的。我们的汽车已经能够在加利福尼亚州和内华达州的部分地区行驶数十万英里,包括旧金山等市中心区域和繁忙的高速公路。在加利福尼亚的沿海小城蒙特雷,有大量的行人,这些都是完全自动驾驶的时刻,汽车能够应对诸如深夜车灯前的鹿,甚至是旧金山蜿蜒的伦巴底街等情况。
这就是我日常工作的一部分。我和我的团队非常热爱建造自动驾驶汽车。我们相信这将改变世界。而在这门课程中,我希望能够让你也具备这样的能力。那么,让我们开始吧。
定位问题与GPS的局限性 🗺️
我们要解决的第一个问题叫做“定位”。它涉及一个在空间中迷失的机器人。这可能是一辆汽车,也可能是一个移动机器人。这里有一个环境,而这个可怜的机器人对自己在哪里一无所知。同样,我们可能有一辆在高速公路上行驶的汽车,这辆车想知道它的位置:它是在车道内,还是在跨越车道线?
解决这个问题的传统方法是使用卫星。这些卫星发射信号,汽车可以感知到这些信号。这就是GPS(全球定位系统)。如果你的汽车有GPS,它会在仪表盘上显示地图和你的位置。然而,不幸的是,GPS的问题在于它并不十分精确。汽车认为自己在这里,但实际上可能有高达10米的误差,这是很常见的。如果你试图在10米误差的情况下保持在车道内,你会偏离很远,可能会在这里行驶并发生事故。因此,为了让我们的自动驾驶汽车能够使用定位技术保持在车道内,我们需要大约2到10厘米的误差精度,然后我们才能使用GPS在车道内行驶。
那么,问题来了:我们如何才能以10厘米的精度知道自己的位置?这就是定位问题。在谷歌自动驾驶汽车中,定位起着关键作用。我们记录道路表面的图像,然后使用我即将教给你的技术来精确找出车辆的位置,精度可达几厘米。这使得即使车道标记缺失,汽车也能保持在车道内。
定位涉及很多数学知识。但在深入数学细节之前,我想让你对基本原理有一个直观的理解。我想给你讲一个关于机器人如何定位的故事,然后我们可以一起学习数学,以便理解它。我还想让你编写自己的定位器,这样你就可以为自动驾驶汽车编程了。
定位的直观理解:一个故事 📖
让我从一个机器人所在的世界开始我的故事。我们假设机器人完全不知道自己在哪。我们将用一个函数来建模这种情况,我将在这个图表中画出这个函数。纵轴是这个世界中任何位置的概率,横轴对应这个一维世界中的所有地点。我将通过一个均匀函数来建模机器人当前对自己可能在哪里的信念(即它的困惑状态),这个函数为世界中的每个可能位置分配相等的权重。这就是最大困惑状态。
现在,为了定位,世界必须有一些独特的特征。我们假设世界上有三个不同的地标:这里有一扇门,这里有一扇门,后面还有一扇门。为了论证方便,我们假设它们看起来都一样,所以无法区分彼此,但你可以区分门和非门区域(墙壁)。现在,让我们看看机器人如何通过感知来定位自己。假设它感知到,并且感知到自己正站在一扇门旁边。所以它现在只知道自己很可能在一扇门旁边。这将如何影响我们的信念?
这是定位的关键一步。如果你理解了这一步,你就理解了定位。对门的测量将我们定义在可能位置上的信念函数,转换成一个新的函数,它看起来很像这样。对于三个与门相邻的位置,我们现在有更高的信念认为自己在那里,而所有其他位置的信念则降低了。这是一个概率分布,它为靠近门的位置分配了更高的概率,这被称为后验信念。“后验”这个词意味着这是在测量之后。
这个信念的关键方面是,我们仍然不知道自己在哪。有三个可能的位置。实际上,传感器可能出错,我们可能意外地在没有门的地方看到了门。所以,在这些地方仍然存在剩余的概率。但这三个凸起共同表达了我们当前对自己位置的最佳信念。这种表示是概率和移动定位绝对核心的部分。
现在假设机器人移动了。假设它向右移动了一定的距离。那么我们可以根据运动来移动信念。这可能看起来像这样。所以,这里的这个凸起移动到了这里,这个凸起移动到了这里,而这个凸起移动到了这里。显然,这是一个机器人。它知道自己的前进方向。在这个例子中,它正在向右移动。它大致知道自己移动了多远。然而,机器人运动有些不确定。我们永远无法确定机器人是否移动了。所以,这些凸起会比之前的那些稍微平坦一些。将信念向右移动的过程在技术上称为“卷积”。
现在让我们假设机器人再次感知。为了论证方便,我们假设它再次看到自己在一扇门旁边。所以测量结果和之前一样。现在,最神奇的事情发生了。我们最终将我们的信念(现在是第二次测量之前的先验信念)乘以一个看起来很像这里的函数,这个函数在每个门处都有一个峰值。结果产生了一个如下所示的信念。有几个小的凸起,但唯一真正大的凸起是这里的这个。这个凸起对应先验中的这个凸起。它是这个概率分布中唯一真正与门的测量结果相符的地方,而其他门的位置的先验信念都很低。因此,这个函数非常有趣。它是一个分布,将其大部分权重集中在“机器人在第二个门处”的正确假设下,而对远离门的位置赋予非常少的信念。此时,机器人已经定位了自己。
如果你理解了这一点,你就理解了概率和定位。所以恭喜你,你理解了概率和定位。你可能还不知道,但这确实是理解我今天将在课程中教你的一系列内容的核心方面。
编程练习:初始化信念 🧑💻
现在让我们进入第一个编程练习,一起编写机器人定位的第一个版本。
这里有一段程序代码,一个空列表。我希望你编程一个包含五个不同单元格或位置的世界,每个单元格具有相同的概率,即机器人可能位于该单元格。所以概率加起来等于一。这里有一个关于单元格 x1 到 x5 的简单测验。这些 x 中任何一个的概率是多少?索引 i 从 1 到 5。答案是 0.2,也就是总概率 1 除以 5 个网格单元格。
所以,在我们的 Python 界面中,我希望你获取这里的这段代码,它给 P 分配了一个空列表,然后修改代码,使 P 成为 5 个网格单元格上的均匀分布,表示为一个包含五个概率的向量。
这里有一个最简单的解决方案:你只需用五个 0.2 初始化向量。让我们看看你能否修改它,以生成一个长度为 n 的向量,其中 n 的值可以变化。对于宽度为 5 的情况,应该得到与之前相同的结果。但对于宽度为 10 的情况,我应该得到一个长度为 10 的向量,每个元素的值为 0.1。答案很简单,使用一个 for 循环,如下所示。你向列表追加 n 个元素,每个元素的大小为 1/n。这里的点非常重要,它给你浮点数版本。不幸的是,如果我们省略它,结果将只是零,这不是你想要的。
现在,我们能够初始化车辆在这个世界上的初始信念了。
测量更新:整合感知信息 📡
让我们看看这个机器人在其世界中的测量情况,这个世界有五个不同的网格单元格,x1 到 x5。假设其中两个单元格是红色的,另外三个是绿色的。和之前一样,我们为每个单元格分配 0.2 的均匀概率。现在我们的机器人可以进行感知,它看到的是红色。这将如何影响我对不同位置的信念?显然,x2 和 x3 的概率应该上升,而 x1、x4 和 x5 的概率应该下降。

现在我将告诉你如何用一个非常简单的规则——乘法,将这个测量结果整合到我们的信念中。对于颜色正确的单元格(任何红色单元格),将乘以一个相对较大的数字,比如 0.6。这个数字看起来小,但正如我们稍后将看到的,它实际上是一个大数字。而所有绿色单元格将乘以 0.2。我们看看这些数字的比例。那么,在红色单元格中的可能性似乎大约是在绿色单元格中的三倍,因为 0.6 是 0.2 的三倍。

让我们为这五个单元格中的每一个进行乘法运算。你能告诉我结果会是什么吗?按照我所说的方式乘以测量结果。请为这五个框填写数字。答案很明显,对于红色单元格,我们得到 0.12,而对于绿色单元格,我们得到 0.04,这是 0.2 乘以 0.6 与 0.2 乘以 0.2 的结果。原则上,这就是我们的下一个信念。但它有一个问题:它不是一个有效的概率分布。原因是概率分布的总和必须始终为 1。所以,如果你问所有这些值的总和是多少,你会发现它加起来不等于 1。请键入所有这些值的总和。如果我们把这些值加起来,得到 0.36。
为了将其变回概率分布,我们现在将每个数字除以 0.36。换句话说,我们进行归一化。所以,请在这五个字段中输入将 0.04 或 0.12 除以 0.36 的结果,并请检查这些数字的总和是否确实为 1。0.12 除以 0.36 等于 12 除以 36,也就是三分之一,或 0.333。0.04 除以 36 等于 4 除以 36,也就是 1/9。如果你看这些数字,三分之一加三分之一再加三个九分之一(即另一个三分之一)正好等于一。所以这是一个概率分布,通常写成以下形式:在观察到测量 Z 后,每个单元格 i(i 可以从 1 到 5)的概率。概率学家也称之为给定测量 Z 后位置 X_i 的后验分布。
让我们来实现这一切。这是我们的初始分布。同样,这是我们颜色正确或错误的因子。让我们首先从一个未归一化的版本开始。编写一段代码,输出在相应位置乘以因子后的 P。
一种方法是显式地遍历这五个不同的情况(从 0 到 4),并手动乘以未命中或命中的情况。这并不特别优雅,但可以完成工作。当我点击运行按钮时,我们得到了正确的答案。这是未归一化的。我的下一个问题是:你能打印出这些值的总和以便归一化它们吗?修改程序,让你得到所有 P 的总和。事实证明,Python 提供了一个名为 sum 的函数。如果你现在点击运行按钮,你会得到正确的答案。
我想让这更美观一些。我将引入一个变量 world。对于五个单元格中的每一个,world 指定单元格的颜色:绿、红、红、绿、绿。此外,我将测量 Z 定义为红色。你能定义一个名为 sense 的函数吗?这是一个测量更新函数,它以初始分布 P 和测量 Z 以及其他全局变量作为输入,并输出一个归一化的分布 Q,其中 Q 反映了我们的输入概率(将是 0.2 等)与相应的命中或未命中因子的非归一化乘积,具体取决于这里的颜色是否一致。
所以,调用 sense(p, Z),我期望得到与之前相同的向量作为输出,但现在是以函数的形式。我希望这里有一个函数的原因是,稍后当我们构建定位器时,我们将对每个测量反复应用这个函数。所以这个函数应该真正响应任何任意的 P 和任意的 Z(无论是绿色还是红色),并给我非归一化的 Q,也就是向量 0.04, 0.12 等等。
这是我的解决方案:我从一个空列表开始,并使用 append 命令随时间构建它,通过迭代我的输入概率 P 中的所有元素来实现。我设置一个二进制标志 hit,判断我接收到的测量结果是否与我从那个列表中预期的第 i 个网格单元格的颜色相同。如果是这种情况,hit 为正(True),我们将 P 乘以 p_hit。如果为假,则 hit 值为 0,1 - hit 将为 1,所以你将 P 乘以 p_miss。我构建列表,返回它并运行它。输出是预期的 0.04, 0.12, 0.12, 0.04 和 0.04。
让我们采用相同的代码片段,修改它以给我一个有效的概率分布。请修改此代码,使其对函数 sense 的输出进行归一化,使其总和为一。这是我用来编程的三行代码:首先,我使用 sum 函数计算向量 Q 的总和,这使其变得非常容易。然后我遍历 Q 中的所有元素,并将其除以 S(即归一化因子)。当我运行它时,我得到 1/9, 1/3, 1/3, 1/9 和 1/9。
我刚刚实现了定位的绝对关键功能,称为测量更新。让我们回到我们的例子,看看你刚刚编程的惊人之处。我们有一个位置上的均匀分布。每个位置的概率为 0.2。然后你编写了一段代码,使用测量结果将这个先验转化为后验,其中两个红色单元格的概率是绿色单元格的三倍。你做的正是我一开始直观地告诉你的定位秘诀:你通过整合测量结果,将一个位置上的概率分布操作成一个新的分布。
事实上,让我们回到代码中,测试当我们将测量变量中的红色替换为绿色时,你的代码是否能得到好的结果。所以请将你的测量变量改为绿色,并重新运行你的代码,看看是否得到正确的结果。我现在将这里的红色替换为绿色。我重新运行我的代码,输出这些有趣的数字。其中某处是除以 44 的结果。但你可以看到,第一个、第四个和第五个网格单元格的值比中间的网格单元格大得多。
处理多个测量 🔄
让我们深入研究。事实上,我希望你稍微修改一下这段代码,以便我们能够处理多个测量。所以,我们将用一个名为 measurements 的测量向量来代替 Z。我假设我们先感知红色,然后感知绿色。你能修改代码,使其更新概率两次,并在整合了这两个测量后给我后验概率吗?事实上,你能修改它以处理任意长度的任何测量序列吗?
修改很简单。我们将多次调用 sense 过程。实际上,我们有多少次测量就调用多少次,也就是这里的 for 循环。我们获取测量列表的元素,将其应用于当前的信念,然后递归地将该信念更新到自身。在这种情况下,我们运行两次,打印输出。对于这个具体的例子,我们得到了均匀分布。这些都大约是 0.2。原因是,我们为每个单元格向上乘以了一次 0.6,向下乘以了一次 0.2,这些影响对每个单元格总体上是相同的。因此,我们在这里得到了相同的输出。这非常了不起。
机器人运动:精确与不精确移动 🚶
在我们完成定位之前,我想谈谈机器人运动。假设我们有一个在这些单元格上的分布,比如这个:1/9, 1/3, 1/3, 1/9, 1/9。即使我们不知道机器人在哪里,它移动了,并且它向右移动。实际上,我们处理这个问题的方式是假设世界是循环的。所以如果它从最右边的单元格掉下来,它会发现自己回到最左边的单元格。假设我们确切地知道,世界正好向右移动了一个网格单元格。考虑到循环运动,你能告诉我在这运动之后,所有这五个值的后验概率是多少吗?答案是,所有这些都向右移动了。所以最左边的 1/9 移动到这里,1/3 移动到这里,最后,最右边的 1/9 发现自己到了左边。在精确运动的情况下,我们有一个完美的机器人。我们只是将概率按实际运动量移动。这是一个退化的情况,但它是首先编程的好例子。让我们来编程这个。
我将定义一个函数 move,输入为分布 P 和一个运动数字 U,其中 U 是向右或向左移动的网格单元格数。我希望你编写一个函数,在移动后返回新的分布 Q,其中如果 u 等于 0,Q 与 P 相同;如果 u 等于 1,所有值都严格向右移动 1;如果 u 等于 3,则移动 3;如果 u 等于 -1,则向左移动。
请用参数 P 和向右移动 1 来调用该函数。我已经注释掉了我的测量部分,因为现在我只想做运动更新,不做测量更新。除此之外,我将使用一个非常简单的 P,它在第二个位置为 1,其他地方为 0。否则,如果你使用均匀的 P,我们甚至无法看出运动是否被正确编程。
这是解决方案:我们从一个空列表开始。我们遍历 P 中的所有元素。这是关键的一行:我们将通过访问相应的 P 来逐个元素地构造 Q,而 P 被移动了 U。如果这个移动超出了 P 在左侧的范围,我们应用以状态数(本例中为 5)为参数的模运算符。这里有一个减号的原因是,为了将分布向右移动(U=1),我们需要在 P 中找到左边一个位置的元素。所以,与其直接向右移动 P,我所做的是通过搜索机器人可能来自哪里来构造 Q,这当然是从左边来的。因此,这里有一个减号。思考一下,这有点不简单,但当你继续定义概率卷积并将其推广到有噪声的情况时,这将很重要。
不精确的世界运动模型 📉
现在让我们谈谈不精确的世界运动。我们再次给定五个网格单元格。假设机器人执行其动作时,有很高的概率(0.8)正确完成,但有 0.1 的概率发现自己未达到预期动作,还有 0.1 的概率发现自己超出了目标。你可以为其他 U 值定义相同的情况。假设 U 等于 1,那么有 0.8 的机会,它会到达这里;0.1 的机会,它停留在同一个元素;0.1 的机会,它向前跳了两个元素。这是一个不精确世界运动的模型。这个机器人试图移动 U 个网格单元格,但偶尔会达不到目标或超出目标。这是一个更常见的情况。机器人移动时会积累不确定性。建模这一点非常重要,因为这是定位困难的主要原因,因为机器人不是很精确。
现在我们将首先从数学方面研究这个问题。我将给你一个先验分布,我们将使用 U 等于 2 的值。对于恰好移动两步的运动模型,我们相信有 0.8 的机会,我们为机器人欠冲或过冲恰好一步的情况分配 0.1 的概率。这有点像用这个公式写的,其中“2”得到 0.8 的概率,“1”和“3”最终得到 0.1 的概率。所以我现在要问你,对于我在这里写出的初始分布,你能给我运动后的分布吗?答案是我们预期的字段(位置 2)有 0.8,两个邻居(位置 1 和 3)有 0.1,而这里(位置 0 和 4)是 0 和 0。做得好。
让我们用不同的初始分布再做一次。假设我们在这个单元格中有 0.5,在这个单元格中有 0.5。记住,这是一个循环运动模型。所以任何从右边掉下来的东西,你都会在左边找到。你能再次为 U=2 填写后验分布吗?这是一个相当棘手的问题,我将分两个阶段回答。让我们看看这里的 0.5。其中的 0.8,也就是 0.4,最终到达这里。其中的 0.1,也就是 0.05,最终到达这里。我写得这么小的原因是,这还不是完全正确的答案。
让我们看看另一个 0.5。0.4 移动两步(1, 2)最终到达这里的左边,但 0.1 未达到目标,使得这里有 0.05,第二个网格单元格有 0.05。有趣的是,对于右边的单元格,有两种可能的方式可以到达那里:要么通过过冲(从第二个单元格开始),要么通过欠冲(从右边的单元格开始)。所以这里的总概率是这两项之和,0.1。所以最终答案是 0.4, 0.05, 0.05, 0.4 和 0.1。
让我给你最后一个例子,我假设一个均匀分布,我希望你为我填写运动后的分布。答案,结果证明,到处都是 0.2。原因是,每个网格单元格的可能性相同,应用这个运动模型仍然会使每个网格单元格的可能性相同。所以让我们选其中一个,比如这里的这个。我们可能通过三种不同的方式到达这里:也许我们从下一个(位置 2)开始,并且我们的运动顺利。这给我们 0.2 乘以 0.8。也许我们从 x1 开始并且我们过冲,这给我们 x1 单元格的 0.2 乘以过冲的 0.1。或者也许我们从下一个(位置 3)开始并且我们欠冲,这是 0.2 乘以 0.1。当我们把这些加起来,我们发现它等于 0.2 乘以 1,因为这里的因子加起来正好是 1,得到 0.2,结果是 0.2。你可以将相同的逻辑应用于所有其他单元格。这里的这个可能来自这个、这个和这个,其中这个是 0.8,另外两个是 0.1。这被称为卷积。正如我们稍后将看到的,有一种非常好的数学方法来写这个,叫做全概率定理。但就目前而言,我想编程实现它。
所以我要给我们一个精确概率 0.8,过冲概率 0.1,欠冲概率 0.1,我希望你修改 move 过程以适应这些额外的概率。这是实现的一种方法:我们引入辅助变量 s,我们在三个不同的步骤中构建它。我们像以前一样乘以精确设置的 P 值乘以 p_exact。然后我们再加上另外两项,乘以 p_overshoot 或 p_undershoot,其中我们通过比 U 多走一步来过冲,或者通过少走一步来欠冲。然后我们把它们加起来,最后将这些的总和附加到我们的输出概率 Q 中。当我们运行这个时,对于我们的示例先验 [0, 1, 0, 0, 0],我们得到答案 [0, 0.1, 0.8, 0.1, 0]。
极限分布与平衡性质 ⚖️
这里有一个有点复杂的问题给你,你可能想检查一下你的直觉。假设我们有五个网格单元格,初始分布为第一个网格单元格分配 1,其他所有单元格分配 0。让我们假设我们执行 U=1,这意味着在每个动作中,有 0.8 的机会我们向右定位 1,有 0.1 的机会我们根本不移动,还有 0.1 的机会我们跳过并移动两步。再次假设世界是循环的,所以每次我从右边掉下来,我都会发现自己回到左边。问题是:假设我运行了无限多次运动步骤。那么我实际上得到了一个所谓的极限分布。所以,如果我的机器人从不感知,但一直执行动作“向右移动 1”,在我们的小环境中,最终会发生什么?最终所谓的极限或平稳分布会是什么?你可能已经猜对了,是均匀分布。这背后有一个直观的推理:每次我们移动,我们都会丢失信息。也就是说,在初始分布中,我们确切地知道我们在哪里。一步之后,我们有 0.8 的机会,但 0.8 会随着我们继续移动而下降到更小的值,比如 0.64,依此类推。绝对信息量最少的分布是均匀分布,它没有任何偏好。这确实是移动很多很多次的结果。有一种数学推导方法,但我可以证明一个高度相关的性质,即平衡性质。假设我们取 x4,我们想理解某个时间 t 的 x4 如何对应于前一个时间对所有变量的分布。为了使这个分布平稳,它必须相同。换句话说,x4 的概率必须等于 0.8 乘以 x2 的概率加上 0.1 乘以 x1 的概率加上 0.1 乘以 x3 的概率。这正是我们之前做的计算,我们问:在 x4 的机会是多少?嗯,你可能来自 x2、x1 或 x3。这些概率 0.8、0.1 和 0.1 决定了你来自那里的可能性。当分布不再移动时,这些必须在极限情况下成立。现在,你可能认为有很多不同的方法来解决这个问题,而 0.2 只是一个解,但事实证明 0.2 是唯一的解。所以如果我们在这里代入 0.2,这里代入 0.2,这里代入 0.2,我们得到 1 乘以 0.2,右边是 0.2,所以很明显,这里的这些 0.2 满足定义有效极限解所必需的平衡。
然后让我们回到我们的代码,并移动很多次。让我们移动两次。所以请编写一段代码,让机器人移动两次,从如下所示的初始分布开始 [0, 1, 0, 0, 0]。这是一段移动两次相同距离的代码。现在的输出是一个向量,其中 0.66 是最大值,不再是 0.8。让我们移动一千次。编写一段移动 1000 步并给我最终分布的代码。这是我的代码:我们有一个 1000 步的循环。我们移动 1000 次,然后打印相应的分布。正如预期的那样,每个情况都是 0.2。
完整的定位循环:感知与移动 🔁
哇,你基本上已经编程实现了谷歌自动驾驶汽车的定位,尽管你可能还不知道。让我告诉你我们现在的位置。我们讨论了测量更新,讨论了运动,并编写了这两个例程 sense 和 move。现在,定位无非就是 sense 和 move 的迭代。有一个初始信念。它被投入这个循环。如果你先感知,它进入左侧。然后定位循环到这个移动、感知、移动、感知、移动、感知、移动、感知、移动、感知的循环。每次机器人移动时,它都会丢失关于世界在哪里的信息。这是因为远程运动不精确。每次它感知时,它都会获得信息。这表现在运动后概率分布稍微平坦一些,更分散一些;感知后则更集中一些。事实上,作为脚注,有一种称为“熵”的信息度量。这里,你可以写它的多种方式之一是每个网格单元格概率的期望对数似然。不深入细节,这是分布所拥有的信息量的度量,并且可以证明,更新步骤(运动步骤)使熵减少,而测量步骤使其增加。所以你实际上是在丢失和获取信息。
我现在很想在我们的代码中实现这个。所以,除了我们之前有的两个测量(红色和绿色)之外,我将给你两个运动:1 和 1,这意味着世界向右移动,然后再向右移动。你能计算后验分布吗?如果它们先感知到红色,然后向右移动 1,然后感知到绿色,然后再向右移动。让我们从均匀先验分布开始。这里的例程很短:它遍历测量。它假设有与测量一样多的运动。它首先像以前一样应用测量,然后应用运动。完成后打印输出,输出很有趣。世界有一个绿色、一个红色、一个红色、一个绿色和一个绿色字段。机器人看到了红色,接着是向右移动,然后是绿色。这表明它很可能从网格单元格 3 开始,这是两个红色单元格中最右边的一个。它正确地读取了红色,然后向右移动了一个,所以正确地看到了绿色,再次向右移动,现在发现自己最有可能在最右边的单元格。这只是看这里的这些值,没有任何概率数学和任何想象。让我们看看输出:0.02, 0.1, 0.08, 0.16, 0.38。非常正确,它们最有可能将这个位置分配给最右边的单元格,考虑到这里的这个观察序列,这是应该的。
让我们选一个不同的地方。假设机器人看到了两次红色。所以它感知红色,移动,感知红色,再次移动。最可能的单元格是哪个?运行程序,我们发现最可能的单元格是第四个单元格。这是有道理的,因为红色-红色与世界的最佳匹配就在这里和这里。在看到第二个红色后,世界仍然向右移动了一个,发现自己位于第四个单元格,如这里所示。
庆祝与总结 🎉
现在,我想和你一起庆祝你刚刚编写的代码。这是一段实现了谷歌自动驾驶汽车定位方法精髓的软件。正如我一开始所说,汽车确切知道其相对于道路地图的位置是绝对关键的。为什么道路不是涂成绿色和红色?那里有车道标记。代替这里的这些绿色和红色单元格,我们插入了车道标记相对于路面颜色的颜色。每个时间步不仅仅是一个观察,而是整个观察流,整个相机图像。但你可以用相机图像做同样的事情。所以,只要你能将模型中的相机图像与测量中的相机图像对应起来,然后,一段代码,并不比你刚才自己编写的代码复杂多少,就负责定位谷歌自动驾驶汽车。所以你刚刚实现了一个让谷歌汽车能够自动驾驶的主要、主要功能。
所以我认为你应该真的感到高兴和自豪。你应该对自己说:我刚刚实现了定位。那么,谷歌为什么要花这么长时间来制造一个能够自动驾驶的产品呢?嗯,我认为情况有点困难。有时道路会被覆盖和改变,我们正在努力解决这个问题。但你实现的是谷歌自动驾驶汽车定位的核心。
让我总结一下我们学到的基本要点。我们了解到,定位维护着一个覆盖机器人可能所在的所有可能位置的函数,其中每个单元格都有一个相关的概率值。测量更新函数或 sense 无非是一个乘积,我们取这些概率值并根据具体测量结果将其乘以上下调整的因子。因为乘积可能违反概率和为 1 的事实,所以是乘积后跟归一化。运动是卷积。这个词本身可能听起来很神秘,但它真正的意思是,对于运动后的每个可能位置,我们逆向推导情况,猜测可能来自哪里,然后收集并添加相应的概率。所以,像乘法和加法这样简单的东西解决了所有的定位问题,并且是自动驾驶的基础。
形式化定义:概率、贝叶斯规则与全概率 📚
我想花几分钟时间回顾一下定位的形式化定义。我将介绍概率,并问你很多问题。形式上,我们定义一个概率函数为 P(x)。它是一个值,下界为 0,上界为 1。X 通常可以取多个值。我们有五个网格单元格的情况。假设只能取两个值。只有两个网格单元格,X1 和 X2。如果 X1 的概率是 0.2,那么 X2 的概率是多少?请以测验形式输入数字,显然答案是 0.8,因为概率总是加起来等于 1。让我问第二个问题,我知道这并不特别难。如果 P(x1) 等于 0,那么 P(x2) 是多少?答案是 1,你答对了。
对于我们有五个不同网格单元格的世界,我们知道前四个的概率是 0.1。第五个也是最后一个网格单元格的概率是多少?答案是 0.6。它们必须加起来等于 1。我们减去四次 0.1,即 0.4,得到 0.6。所以这是一个有效的概率。
让我们看看测量,它们会导致一种叫做贝叶斯规则的东西。你可能以前听说过贝叶斯规则。它是概率推理中最基本的考虑因素。但贝叶斯规则真的非常简单。假设 X 是我的网格单元格,Z 是我的测量。那么测量更新试图计算在看到测量后对我位置的信念。这是如何计算的?嗯,在我们的定位例子中计算起来真的很容易。现在我要让它更正式一些。事实证明,贝叶斯规则看起来像这样。这可能有点令人困惑。但它所做的是取我的先验分布 P(X),并为每个可能的位置乘以看到红色或绿色瓦片的几率,然后输出,如果我们只看分母,就是我们之前有的未归一化的后验分布。认出这个:这是我们的先验,这是我们的测量概率。如果我们对所有网格单元格都这样做,我们在这里加上小索引 i。那么,先验网格单元格乘以测量概率的乘积,如果测量对应于正确的颜色,这个乘积就大;如果对应于错误的颜色,就小。这个乘积给了我们网格单元格的未归一化后验分布。你记得这个,因为你编程实现了它:你编程实现了先验概率分布和一个数字之间的乘积。
归一化现在是这里的常数 P(Z)。从技术上讲,那是在没有任何位置信息的情况下看到测量的概率。但让我们不要混淆自己。理解正在发生什么的最简单方法是认识到,这里的这个函数为每个网格单元格分配一个数字,而 P(Z) 没有网格单元格作为索引。所以无论你考虑哪个网格单元格,P(Z) 都是相同的。这里的技巧是:无论 P(Z) 是什么,因为最终的后验必须是一个分布,通过归一化这些未归一化的乘积,我们将精确计算出 P(Z)。换句话说,P(Z) 是所有 i 的这个乘积的总和。所以这使得贝叶斯规则非常简单。它是我们的先验分布与测量概率的乘积,我们知道测量概率在颜色正确时大,否则小。我们这样做,并将其分配给所谓的未归一化概率,我用 P 上面的一个小横杠表示。然后我计算归一化因子,我称之为 alpha,它是所有这些的总和。然后我只是归一化。所以我的最终概率将是未归一化概率的 1/alpha。这正是我们所做的,这正是贝叶斯规则。
让我在一个完全不同的例子中问你贝叶斯规则,看看你是否理解如何应用贝叶斯规则。这次是关于癌症检测。这是一个统计学课程中常研究的例子。假设存在某种类型的癌症。但这种癌症很罕见,只有千分之一的人患有这种癌症,而千分之九百九十九的人没有。用癌症的概率和非癌症的概率来说明。假设我们有一个测试,测试结果可以是阳性或阴性。如果患有癌症,测试呈阳性的概率是 0.8;如果没有癌症,测试呈阳性的概率只有 0.1。所以很明显,测试与是否患有癌症有很强的相关性。这是一个非常困难的问题。你能为我计算一下,在我刚刚收到阳性测试结果的情况下,患有癌症的概率吗?让我强调一下,这不是一个简单的问题,但基于我教给你的知识,你应该能够计算出这个结果。把癌症/非癌症想象成机器人的位置,把阳性想象成观察到的颜色是否正确。
答案是 0.0079,换句话说,尽管测试结果呈阳性,但你患有癌症的机会只有 0.79%,即千分之七点九。你将应用与我们之前完全相同的机制。根据贝叶斯规则,C(癌症)给定阳性(Pos)的未归一化结果简单地是我的先验概率 0.001 乘以 0.8(癌症状态下阳性结果的概率)的乘积,结果是 0.0008。相反事件(非癌症事件)给定阳性测试的未归一化概率是 0.999 乘以 0.1,显然是 0.0999。我们的归一化因子是这两者的总和,即 0.1007(只需把这两个包加在这里)。所以,将未归一化概率 0.0008 除以 0.1007,得到 0.0079。我们刚刚应用贝叶斯规则计算了在看到测试结果后患有癌症的一个非常复杂的概率。
运动:全概率定理 🎲
让我们看看运动,结果将是我们称之为全概率的东西。你记得我们关心一个网格单元格 X_i,我们问在运动之后,位于 X_i 的机会是多少。为了表示前后,让我添加一个时间索引。所以这里的 T 是时间的索引,我用大写字母写它,以免与索引 i(网格单元格)混淆。你可能记得我们计算这个的方式是查看机器人可能来自的所有网格单元格,这些单元格由时间 T-1 的索引 j 表示。我们查看这些网格单元格在时间 T-1 的先验概率,并将其乘以我们的运动命令将我们从 X_j 带到 X_i 的概率,这写成一个条件分布,如下所示。

这正是我们实现的。如果这里有我们的网格单元格,我们问一个时间步后关于这里的一个特定单元格,我们会将这里的 0.8、这里的 0.1 和这里的 0.1 组合到这个网格单元格的概率中,这与这里的公式相同。这是 X_i。我们找到 X_i 的先验概率的方法是,遍历所有我们可能来自的可能位置(所有不同的 j),查看先验概率乘以给定我的运动命令(在这种情况下是向右移动 1)我从 j 转移到 i 的概率。在概率术语中,人们经常这样写:P(A) 等于对所有 B 求和,P(A|B) 乘以 P(B)。这只是你在教科书中找到的方式。你可以直接看到 A 对应于时间 T 的位置 i,而所有不同的 B
013:Python中的直方图滤波器 🧭
在本节课中,我们将学习如何构建一个二维直方图滤波器。这是一种用于机器人或自动驾驶汽车定位的概率方法。我们将从一维概念扩展到二维空间,并动手实现代码。
概述
上一节我们介绍了一维直方图滤波器的基本原理。本节中,我们将挑战构建一个二维直方图滤波器。这个滤波器将用于为一个近似真实的自动驾驶汽车模型实现定位功能。
二维直方图滤波器概念
直方图滤波器通过将环境划分为离散的网格(或称为“格子”)来工作。每个格子代表机器人可能处于的一个小区域,并附有一个概率值,表示机器人位于该格子内的置信度。
在二维情况下,我们的状态空间由两个变量定义,例如世界中的 x 和 y 坐标。滤波器通过以下两个主要步骤循环更新所有格子的概率:
- 测量更新:根据传感器测量数据调整概率。
- 运动更新(或预测):根据机器人的运动命令预测概率分布的变化。
其核心贝叶斯更新公式可以简化为:
P(new) = η * P(measurement | state) * P(old)
其中 η 是一个归一化常数,确保所有概率之和为1。
实现步骤
以下是构建二维直方图滤波器的主要步骤。
1. 初始化概率网格
首先,我们需要创建一个二维网格,并为每个单元格分配初始概率。在没有任何先验信息时,通常使用均匀分布。
# 示例:初始化一个4x5的网格
grid_size = (4, 5)
initial_prob = 1.0 / (grid_size[0] * grid_size[1])
belief = [[initial_prob for _ in range(grid_size[1])] for _ in range(grid_size[0])]
2. 实现测量更新
当传感器获得新的测量数据(如激光测距、地标识别)时,我们需要根据测量模型来更新每个格子的概率。测量模型 P(z|x) 表示在状态 x 下观察到测量值 z 的可能性。
def measurement_update(z, belief, sensor_model):
new_belief = []
total_prob = 0.0
for row in belief:
new_row = []
for cell_prob in row:
# 假设 get_likelihood 函数根据状态计算 P(z|x)
likelihood = sensor_model.get_likelihood(z, cell_state)
new_cell_prob = likelihood * cell_prob
new_row.append(new_cell_prob)
total_prob += new_cell_prob
new_belief.append(new_row)
# 归一化
new_belief = [[prob / total_prob for prob in row] for row in new_belief]
return new_belief
3. 实现运动更新
当机器人移动后,我们需要根据运动模型来平移或扩散概率分布。这通常通过卷积操作实现,将当前的概率分布与一个表示运动不确定性的核(kernel)进行卷积。
def motion_update(belief, move, motion_model):
# motion_model.kernel 表示运动不确定性,例如 [[0.1, 0.8, 0.1], ...]
# 这里使用简单的二维卷积作为示意
from scipy.signal import convolve2d
shifted_belief = convolve2d(belief, motion_model.kernel, mode='same', boundary='wrap')
return shifted_belief
4. 循环执行
将测量更新和运动更新步骤放入一个循环中,即可持续进行定位。
def histogram_filter_loop(initial_belief, measurements, motions, sensor_model, motion_model):
belief = initial_belief
for z, u in zip(measurements, motions):
belief = measurement_update(z, belief, sensor_model)
belief = motion_update(belief, u, motion_model)
return belief
总结
本节课中,我们一起学习了二维直方图滤波器的实现。我们从一维扩展到二维,定义了概率网格,并逐步实现了测量更新和运动更新两个核心步骤。通过循环执行这两个步骤,滤波器能够有效地利用传感器数据和运动信息,为自动驾驶汽车在二维环境中提供持续的定位估计。这是概率机器人学中一个基础而强大的工具。
014:课程概述 🚗
在本节课中,我们将要学习构建无人驾驶汽车所使用的一系列强大工具。这些工具是理解自动驾驶技术中感知、规划与控制等核心模块的数学基础。
上一节我们介绍了课程的整体结构,本节中我们来看看即将深入学习的核心数学工具。
核心工具:矩阵与线性代数
这些工具被称为矩阵、线性代数或向量。初次接触时,它们可能显得有些令人畏惧,但实际上它们非常直观。根据经验,许多学生在掌握这些工具时会遇到困难。因此,我们的团队精心准备了一份全面而深入的介绍。
如果你已经熟悉这些概念,你将能轻松掌握本节内容。如果你还不熟悉,本节课程旨在帮助你学习并揭开这些知识的神秘面纱。当你在维基百科或谷歌上搜索常见滤波器(如卡尔曼滤波器)时,经常会遇到这些看似晦涩的概念,本节将为你澄清它们。
以下是本节课程将要涵盖的主要内容列表:
- 矩阵与向量的基本概念与运算。
- 线性代数在计算机视觉与感知中的应用。
- 如何利用这些工具进行车辆的状态估计与控制。
总结
本节课中我们一起学习了即将开启的数学工具单元的重要性。我们了解到,矩阵和线性代数是构建无人驾驶汽车不可或缺的基础,它们虽然初看复杂,但通过系统的学习可以变得直观易懂。接下来,我们将正式踏入这个强大的数学世界,为后续的自动驾驶技术学习打下坚实的基础。
015:卡尔曼滤波器简介 🚗
在本节课中,我们将要学习卡尔曼滤波器。这是一种用于估计系统状态的极其流行的技术,在无人驾驶汽车中,它被广泛应用于追踪其他车辆的位置和速度,从而避免碰撞。我们将从基础概念开始,逐步理解其工作原理,并最终用代码实现一个一维的卡尔曼滤波器。
从斯坦福的自动驾驶汽车说起
上一节我们介绍了定位的概念,本节中我们来看看如何追踪其他车辆。

课程始于对斯坦福大学自动驾驶汽车“Junior”的介绍。这辆车装备了多种传感器,使其能够实现自动驾驶。

以下是使这辆车能够自动驾驶的关键设备:
- 激光雷达:一个旋转的激光测距仪,每秒扫描10次,产生约一百万个数据点。它的主要功能是探测其他车辆,防止碰撞。这些距离测量数据是卡尔曼滤波器的重要输入。
- 立体摄像头系统:用于视觉感知。
- GPS天线:用于全球定位,与本地传感器数据结合,估计车辆在世界中的位置。
这些传感器共同感知环境,为卡尔曼滤波器提供原始数据。
为什么需要追踪?
在定位中,我们让机器人确定自己在环境中的位置。但在自动驾驶中,我们还需要知道其他车辆在哪里,以及它们移动得有多快。
仅仅知道其他车辆的当前位置是不够的。为了安全驾驶,避免未来的碰撞,我们必须能够预测它们将要去往何处。这对于车辆、行人和自行车骑行者都至关重要。
因此,本节课我们将讨论追踪技术,而核心方法就是卡尔曼滤波器。
卡尔曼滤波器概览
卡尔曼滤波器与我们上一节课讨论的概率定位方法(蒙特卡洛定位)非常相似。
它们的主要区别在于:
- 卡尔曼滤波器估计的是连续状态,并用单峰分布(高斯分布)来表示不确定性。
- 蒙特卡洛定位将世界划分为离散位置,可以处理多峰分布。
两者都适用于机器人定位和车辆追踪。后续课程中我们还会学习粒子滤波器,它是另一种能处理连续、多峰分布问题的方法。但目前,我们专注于卡尔曼滤波器。
让我们从一个简单例子开始理解其思想。
一个直观的例子
假设我们通过传感器观测一个物体的位置,在时间 T=0, 1, 2, 3 分别得到如下测量点。

基于这些观测,你会预测该物体在 T=4 时刻的位置在哪里?你可能会预测它在这里。

这是因为你从观测中估计出了物体的速度方向。假设速度没有剧烈变化,你就会预测下一个位置在此处。
卡尔曼滤波器正是这样工作的:它接收像这样(可能带有噪声和不确定性)的观测点,自动估计物体的未来位置和速度。谷歌自动驾驶汽车就使用类似的方法,基于雷达和激光数据来理解其他交通参与者的状态。
高斯分布:卡尔曼滤波器的核心
在蒙特卡洛定位中,我们用离散网格的直方图来表示概率分布。卡尔曼滤波器则使用高斯分布(又称正态分布)来表示连续空间中的概率。
高斯分布是一个关于位置空间的连续函数,其曲线下面积为1。在二维平面上,它看起来像一个钟形曲线。
对于一个一维空间变量 x,高斯分布由两个参数定义:
- 均值(μ):分布的中心点。
- 方差(σ²):分布的宽度,衡量不确定性。
其数学公式如下:
f(x) = (1 / √(2πσ²)) * exp(-0.5 * (x - μ)² / σ²)
其中,exp 是指数函数。公式中的常数项是为了确保总概率为1,但在理解原理时我们可以暂时忽略它。核心部分是 exp(-0.5 * (x - μ)² / σ²),它描述了概率随 x 偏离 μ 的程度呈指数衰减。
方差 σ² 是衡量不确定性的关键:σ² 越大,分布越宽,我们对真实状态越不确定;σ² 越小,分布越窄,我们越确定。
在追踪其他车辆时,我们显然更希望得到一个方差小、更确定的分布,因为这意味着我们更了解目标车辆的位置,碰撞风险更低。
编程实现高斯函数
让我们通过编程来巩固对高斯函数的理解。我们需要实现一个函数,给定 mu, sigma2(方差), 和 x,返回高斯函数值。
def f(mu, sigma2, x):
'''一维高斯函数'''
return 1 / (sqrt(2 * pi * sigma2)) * exp(-0.5 * (x - mu)**2 / sigma2)
测试:当 mu=10, sigma2=4, x=8 时,计算结果约为 0.12。当 x=mu=10 时,函数取得最大值。
卡尔曼滤波器的两个步骤
与定位问题类似,卡尔曼滤波器循环执行两个步骤:
- 测量更新:利用传感器测量值来修正当前估计。
- 运动预测:根据系统的运动模型来预测下一个状态。
这两个步骤分别对应概率论中的:
- 测量更新 → 贝叶斯规则 → 乘法(乘积)
- 运动预测 → 全概率定理 → 卷积(加法)
我们将首先探讨更复杂的测量更新步骤。
测量更新:融合高斯分布
假设我们有一个关于车辆位置的先验分布(Prior),它是一个较宽的高斯分布(不确定性大)。然后我们得到一个测量值,它也是一个高斯分布,但更窄、更精确(不确定性小)。
测量更新的目标是计算一个后验分布(Posterior),它融合了先验信息和新的测量信息。这通过将两个高斯分布相乘来实现。
结果会怎样呢?
- 新的均值(μ‘):是先验均值(μ)和测量均值(ν)的加权平均。不确定性更小的分布权重更大。因此,新的均值会偏向于更确定的那个均值。
- 新的方差(σ²‘):满足公式
1/σ²‘ = 1/σ² + 1/r²,其中r²是测量方差。新的方差比任何一个输入分布的方差都小。这意味着融合后我们获得了更多信息,估计变得更加确定!
这是一个关键且反直觉的结论:融合两个信息源(即使其中一个不太确定)总能得到一个比任一单独信息源更确定的估计。
计算公式如下:
新均值 μ‘ = (r² * μ + σ² * ν) / (σ² + r²)
新方差 σ²‘ = 1 / (1/σ² + 1/r²)
让我们通过编程实现这个更新步骤。
def update(mean1, var1, mean2, var2):
'''测量更新:融合两个高斯分布(相乘)'''
new_mean = (var2 * mean1 + var1 * mean2) / (var1 + var2)
new_var = 1 / (1/var1 + 1/var2)
return new_mean, new_var
测试这个函数,例如输入先验 (10, 8) 和测量 (13, 2),得到后验约为 (12.4, 1.6)。可以看到均值偏向更确定的测量值13,且方差(1.6)小于先验方差(8)和测量方差(2)。
运动预测:叠加高斯分布
预测步骤要简单得多。假设我们当前有一个位置估计的高斯分布,然后我们执行一个运动(例如向右移动一定距离),这个运动本身也有不确定性(由高斯分布建模)。
预测的结果是:
- 新的均值(μ‘) = 旧均值(μ) + 运动均值(u)
- 新的方差(σ²‘) = 旧方差(σ²) + 运动方差(r²)
直观上,移动后你的预期位置增加了,但由于运动不精确,你的不确定性也增加了。
计算公式非常简单:
新均值 μ‘ = μ + u
新方差 σ²‘ = σ² + r²
编程实现如下:
def predict(mean1, var1, mean2, var2):
'''运动预测:叠加两个高斯分布(加法)'''
new_mean = mean1 + mean2
new_var = var1 + var2
return new_mean, new_var
测试:当前状态 (8, 4),运动 (10, 6),预测新状态为 (18, 10)。
构建一维卡尔曼滤波器
现在,我们可以将更新和预测函数组合起来,形成一个完整的一维卡尔曼滤波器。它将处理一系列的测量值和运动命令。
以下是主程序逻辑的示例:
# 初始估计 (可以非常不确定)
mu = 0
sig = 10000
# 测量和运动序列
measurements = [5, 6, 7, 9, 10]
motions = [1, 1, 2, 1, 1]
# 测量和运动的不确定性
measurement_sig = 4
motion_sig = 2
# 卡尔曼滤波循环
for i in range(len(measurements)):
# 测量更新
mu, sig = update(mu, sig, measurements[i], measurement_sig)
print(f‘更新后: [{mu}, {sig}]‘)
# 运动预测
mu, sig = predict(mu, sig, motions[i], motion_sig)
print(f‘预测后: [{mu}, {sig}]‘)
运行这个程序,滤波器会逐步处理数据。即使初始估计很差(例如位置为0),随着更多测量信息的融入,估计值也会被“拉”向真实值。最终,在经历一系列移动后,它能准确估计出最终位置(例如从位置0开始,经过移动和测量,最终估计位置接近11)。
扩展到高维:追踪位置与速度
现实世界中的状态空间通常是多维的。例如,我们可能想同时追踪一辆车的二维位置(x, y)和二维速度(vx, vy)。这才是卡尔曼滤波器真正强大的地方。
考虑一个场景:传感器(如雷达)只提供位置观测(x, y)。
- 在时间 T=0,观测到目标在点A。
- 在时间 T=1,观测到目标在点B。
- 在时间 T=2,观测到目标在点C。
一个多维卡尔曼滤波器能够做一件非常神奇的事情:即使它从未直接测量速度,它也能通过连续的位置观测推断出目标的速度(vx, vy)。然后,它利用这个估计的速度,对未来位置做出更准确的预测。

这种从间接观测中推断隐藏状态(如速度)并用于预测的能力,是卡尔曼滤波器在人工智能和控制理论中如此受欢迎的重要原因之一。
总结
本节课中我们一起学习了卡尔曼滤波器的基础知识。
我们了解到:
- 卡尔曼滤波器是一种用于估计系统状态(如位置、速度)的递归算法,特别擅长处理带有噪声的传感器数据。
- 其核心是用高斯分布来表示状态的不确定性。
- 算法循环执行两个步骤:测量更新(利用新数据修正估计,使用乘法)和运动预测(根据模型预测未来状态,使用加法)。
- 测量更新的一个关键结果是融合信息会减少不确定性。
- 我们成功编程实现了一个一维卡尔曼滤波器,它能够处理序列化的测量和运动数据。
- 卡尔曼滤波器可以扩展到高维,从而能够推断未直接测量的变量(如速度),并进行更准确的预测。

通过本节课的学习,你已经掌握了卡尔曼滤波器的基本原理和实现方法,这是理解现代自动驾驶和机器人追踪技术的重要基石。
016:状态与面向对象编程 🚗
在本节课中,我们将学习如何表示和预测汽车的运动状态。我们将从回顾常见的定位步骤开始,然后深入探讨如何用代码和数学来描述状态及其变化。
概述
自动驾驶汽车通常遵循一系列步骤来安全导航。您一直在学习第一步:定位。在汽车能够安全行驶之前,它首先需要使用传感器和其他收集到的数据来估计自己在世界中的位置。本节课,我们将讨论如何表示和预测汽车的运动。在此之前,我们先回顾一下常见滤波器定位汽车所采取的所有步骤。
定位步骤回顾
一个常见的滤波器通过以下步骤来定位汽车:
首先,我们从对汽车位置的初始预测开始,并用一个概率分布来描述对该预测的不确定性。
以下是一个一维示例:我们知道汽车在这条单车道上,但不知道其确切位置。因此,我们的先验概率分布是均匀的。
然后,我们感知汽车周围的世界。这被称为测量更新步骤,我们在此步骤中收集更多关于汽车周围环境的信息,并优化我们的位置预测。
假设我们测量到汽车大约在停车标志前两个网格单元的位置。我们的测量并不完美,但我们对汽车的位置有了更好的了解。
下一步是移动,也称为时间更新或预测步骤。我们根据已知的汽车速度和当前位置来预测汽车将如何移动,并相应地移动我们的概率分布以反映这种运动。
这个例子展示了一个向右移动一个单元格的情况。这为我们提供了汽车位置的新状态估计。
常见滤波器简单地重复感知和移动(测量和预测)步骤,以在汽车移动时对其进行定位。
常见滤波器的优点在于,它们将有一定误差的传感器测量值与有一定误差的运动预测结合起来,从而得到一个比仅来自传感器读数或仅来自运动知识的任何估计都要好的滤波位置估计。这就是为什么常见滤波器是一种强大的定位方法。
什么是状态?
当您定位一辆汽车时,您只关心汽车的位置和运动。这通常被称为汽车的状态。任何系统的状态都是我们关心的一组值。在您一直处理的情况下,汽车的状态包括汽车的当前位置 x 和速度 v。
在代码中,这看起来像这样:
state = [x, v]
汽车的状态为我们提供了预测汽车未来位置所需的大部分信息。在本课中,我们将看到如何表示状态以及它如何随时间变化。
预测状态:一个简单例子
例如,假设我们的世界是一条单车道,我们知道汽车的当前位置在这条路的起点,即 0 米标记处。我们还知道汽车的速度:它正以每秒 50 米的速度向前行驶。这些值就是它的初始状态。
您认为 3 秒后汽车的状态会是什么?
让我们仔细看看最后一个例子。汽车的初始状态是位置 0 米,并以每秒 50 米的速度向前移动。我们假设汽车继续以恒定速度向前移动。
每秒,它移动 50 米。所以 3 秒后,它将到达 150 米标记处,其速度不会改变。这就是恒定速度的含义。它的新预测状态将是位置 150 米,速度仍为每秒 50 米。
这是一个合理的预测,我们仅使用以下两点就做出了这个预测:
- 汽车的初始状态。
- 汽车以恒定速度运动的假设。

最后一个假设可以用物理方程数学表示:行驶距离 = 速度 × 时间。这个方程也被称为运动模型。有很多方法可以建模运动。
运动模型
这个模型假设速度恒定。在我们的例子中,我们以每秒 50 米的恒定速度运动了 3 秒,我们使用恒定速度方程形成了新的位置估计:150 米 = 50 米/秒 × 3 秒。

为了预测汽车在未来某个时间点的位置,您必须依赖一个运动模型。需要注意的是,没有运动模型是完美的。考虑外部因素(如风、海拔,甚至轮胎打滑和其他不确定性)是一个挑战。但这些模型对于定位仍然非常重要。
接下来,您将被要求编写一个使用运动模型来预测新状态的函数。
更复杂的运动模型
现在,如果我给您一个更复杂的运动示例呢?我告诉您,我们的汽车从同一点(0 米标记处)开始,以每秒 50 米的速度向前移动,但它也以每秒 20 米/秒² 的速率减速。这意味着每秒速度减少 20 米/秒。
这个值被称为加速度。在这种情况下,加速度等于 -20 米/秒²。这个加速度意味着,如果汽车以 50 米/秒的速度开始,下一秒它将变为 50 - 20 = 30 米/秒。再下一秒,它将变为 30 - 20 = 10 米/秒。
这种减速也是连续的,这意味着它是随时间逐渐发生的。
在接下来的几个测验中,我希望您记住这个问题:在这种初始状态下,3 秒后汽车将在哪里?我还想问您:您需要哪些变量来回答这个问题?换句话说,状态中应包含什么值?我们应该使用什么运动模型来解决这个问题?
状态与运动模型的关系
现在您已经看到了两个汽车运动模型的例子。在第一个例子中,我们假设汽车以恒定速度运动。但在第二个例子中,我们说汽车以恒定加速度减速。
经过 3 秒后,我们根据所依赖的运动模型,得出了不同的汽车状态估计。
在汽车减速的情况下,我们必须在计算中包含一个加速度值才能预测它的移动位置。因此,我们也将这个值添加到了汽车的状态中。
事实上,我们的汽车状态包含多少变量取决于我们使用的运动模型。对于恒定速度模型,位置和速度就足够了。但对于恒定加速度,您还需要包含一个加速度值。
这些都只是模型。对于任何状态,您都应该始终选择与所选运动模型配合所需的最小值集合。
本课的两个主线
本课的一个统一主题是表示和预测状态,但我们将通过两条主线来探索这个想法:
- 编程方面:我们将使用一种称为面向对象编程的方法来表示状态和代码。我们将使用变量来表示状态值,并创建函数来更改这些值。
- 数学方面:我们将使用向量和矩阵来跟踪状态并改变它。随着我们对预测汽车状态的更多了解,我们将学习所有必要的数学符号和代码。
使用函数跟踪状态
您一直在使用一个 predict_state 函数,它接收一个当前状态和一些时间变化 dt,并基于恒定速度运动模型输出一个新的状态估计。
事实证明,恒定速度模型实际上是一个很好的简单模型,尤其是在时间变化非常小的情况下。
现在,为了在汽车移动时跟踪其随时间变化的状态,您必须重复调用此函数,在每个时间步传入新的状态估计。
例如,假设我们有这个初始状态:位置 x = 0,速度 v = 50 米/秒。我将这两个值放入一个列表(我们的初始状态)并打印出来。
接下来,我想知道 2 秒后的下一个状态。因此,我将使用我的 predict_state 函数,传入初始状态和 2 秒的时间差,并将状态称为 s1(第一个状态估计),然后打印这个结果。
我们的位置 x 现在在 100 米处,速度保持 50 米/秒。
然后,假设又过了 3 秒。我再次调用 predict_state 函数。这次我传入我们最新的状态估计 s1 和一个 3 秒的时间差,并打印这个值。
我们的位置现在是 250 米,速度保持恒定的 50 米/秒。再过 3 秒呢?我必须传入我们最新的状态估计 s2 和又一个 3 秒的时间差,然后打印结果。
如您所见,我们基本上是在一遍又一遍地调用同一行代码,但修改状态输入为最新的状态估计。这种重复充其量是乏味的,而且代码质量不佳。作为程序员,您应该始终尽量避免不必要的重复。
引入对象
如果我们可以直接告诉汽车移动,并让它自动更新其状态,而不是手动编写相同的代码行并一遍又一遍地跟踪状态,那该多好?
我们可以借助对象来自动跟踪状态。对象持有状态。它们持有一组变量(也称为属性)和函数。您可以把对象想象成一个盒子。在那个盒子里,有定义对象状态的变量,比如汽车对象的位置和速度。对象也持有告诉我们对象能做什么的函数,比如汽车能否移动、转弯等等。
但我们现在有点超前了。让我们先看一个对象在运行中的例子。
与汽车对象交互
这里,我将向您展示如何与一个能够跟踪自身状态的汽车对象交互。首先,您会看到一些导入语句和一些代码,我们很快会详细讲解。但在深入研究构成对象的代码之前,我们将学习如何与它交互。
接下来,您会看到一些初始变量:首先是一个 world,然后是一些应该看起来很熟悉的变量:初始位置和速度。这次,位置是二维的(y 和 x),速度也分解为垂直和水平分量(v_y 和 v_x)。
然后,为了创建一辆名为 Carla 的汽车,我必须说 Car.Car 并传入一些初始状态。我传入一些 y, x 位置和速度。最后,我传入一个 world(只是一个二维数组)。然后我打印出 Carla 的初始状态。
您会注意到,我可以通过 carla.state 访问 Carla 的状态。我们看到初始位置是 (0, 0),初始速度是 (0, 1),这意味着它有一些向右的水平速度。
现在,这个汽车对象 Carla 也有一些可视化代码。我们可以使用 display_world 函数在任何给定点看到汽车的位置。
现在,Carla 还能做什么?我们可以告诉 Carla 移动,说 carla.move()。Carla 将沿着初始速度的方向移动一个网格单元,在这种情况下是向右。让我们通过再次显示世界来测试这个移动。
我们可以看到 Carla 向右移动了一个空格。在这次移动之后,我们还可以跟踪 Carla 状态的变化,我可以再次使用 carla.state 打印出它的值。我们可以看到状态确实更新了:位置向右移动了一个网格单元,速度保持不变。
所以,Carla 以某种方式在跟踪她自己的状态。如果我们再移动几次呢?让我们跟踪状态的变化。我们看到我们又向右移动了两个位置,到了 (0, 3)。我们可以通过再次调用 carla.display_world() 在世界地图上看到移动。
我们知道汽车的状态会自动更新。Carla 也可以左转。所以,让我们告诉 Carla 左转并移动一个空格,然后显示世界。我们可以看到 Carla 在这个位置左转,实际上在世界中绕了一圈。
如果我们通过 str(carla.state) 打印出 Carla 的状态,我们看到位置和速度都发生了变化。我们的位置是 (3, 3),这反映在我们的网格中。v_y 现在是 -1,v_x 是 0。这意味着没有水平速度,只有负的垂直速度,这意味着 Carla 正在我们的网格世界中向上移动。
目前,移动和左转是 Carla 仅有的能力。但我们很快就会看到如何扩展她的能力列表。
查看汽车类代码
这里是我们的 car.py 文件。让我们逐行查看这里的代码。
首先,您会注意到红色的部分,它们只是描述类的注释。它描述了类的作用以及它具有哪些属性。像这样的注释是很好的做法,特别是如果您希望其他开发人员理解您的代码。
现在,如果我们向下滚动一点,首先看到的是我们的 class Car。这看起来有点像函数声明,但 class 这个词让 Python 知道后面的代码应该描述汽车对象的状态和功能。对象名称也总是大写,所以我们看到这里的 Car 是大写的。
接下来,我们看到 __init__ 函数,它负责创建空间和内存来制作一个特定的汽车对象,比如 Carla。因此,当您运行像 Car.Car() 并传入一些初始参数的代码时,调用的就是这个函数。这是根据传入的位置和速度变量创建初始状态的地方。我们还看到了汽车的 world、color 和 path 的变量。我们知道 world 只是一个二维网格。color 我们稍后会再谈。但现在要知道,这就是为什么我们的汽车在网格中显示为红色。path 将只是汽车访问过的位置列表,我们可以将其可视化。所以,这里最重要的代码行是 state 和 world。这里的所有变量都是汽车对象跟踪的内容。
接下来,如果我们向下滚动,是 move 函数。描述说,move 函数使汽车沿速度方向移动并更新状态。这可能看起来与我们的 predict_state 函数很相似。move 使用恒定速度模型使汽车沿其速度方向(v_y 和 v_x)移动。
如果我们看这个方程的一部分,我们可以看到它是我们的恒定速度模型。它说:y(初始位置)加上 y 速度乘以时间变化 dt 成为我们新的预测位置。最后,我们更新在 __init__ 中初始化的汽车状态变量。这就是我们在移动时跟踪汽车状态的方式。
接下来,我们看到我们的 turn_left 函数,它只是将速度值向左旋转。这个函数再次用新的速度更新我们的状态。
最后,在末尾,我们有这个 display_world 函数,您不应该更改它,但可以随意阅读。它负责显示网格世界和其中的汽车,并使用 pyplot 进行可视化。
接下来,将由您来阅读这段代码,理解像 Carla 这样的汽车对象如何跟踪自己的状态。我们将一起帮助在这个 Car 类中编写一些额外的函数。
修改对象:添加颜色变量
我们回到了我们的 car.py 文件。让我们看看如何实际修改这个汽车对象并添加一个颜色变量。
在我们的类代码内部,我们可以看到汽车有一个默认颜色,红色,由字符 'R' 表示。那么您认为我们如何自定义它呢?就像我们的状态变量和世界一样,我们可以在初始化参数中传入它。要做到这一点,通常我们只需在 __init__ 函数中添加一个额外的参数。
我将称这个参数为 color。我还可以为 color 指定一个默认值,等于红色,等于字符 'R'。color='R' 意味着当且仅当在创建汽车对象时没有指定 color 参数,它才会默认为红色。我们的其他变量都没有默认值。
然后我们还必须更改另一行代码。与其说 self.color = 'R',不如说 self.color = color(这是传入的变量)。
现在,我们的 __init__ 函数初始化了汽车的状态,为汽车提供了一个二维世界来遍历,并指定了汽车的颜色。
剩下的就是在可执行代码中测试这个。所以让我们进入一个新的笔记本。
测试自定义颜色
以下是通常的导入语句,我们在这里导入我们的汽车类文件。
接下来,我将创建汽车对象。这里我定义了初始参数,并以通常的方式创建了 Carla。然后我将定义一些新的初始参数,并创建一个新的汽车对象。这次,我将这辆汽车命名为 Jeanette。传入一个初始状态、一个世界和一种颜色。
我将说 jeanette = Car.Car() 并传入我们的初始状态变量 position2 和 velocity2,传入同一个 world,并且我还传入一个颜色,指定 Jeanette 具有黄色,字符 'Y' 作为我们最后传入的变量。
这里的顺序很重要。这应该与我们的汽车类文件中的顺序匹配。所以现在我应该有两辆汽车,Carla 和 Jeanette。
现在,如果在此步骤出现错误,请确保在更改后保存了您的汽车类文件,并通过单击“内核”->“重启并清除输出”来重启内核。
由于我没有收到任何错误,我的下一步将是为这两辆汽车编写一些移动代码。这只是为了好玩和可视化。
首先,我要移动 Carla,告诉 Carla 移动、左转、再移动,然后显示 Carla 的世界。我们可以看到 Carla 的移动:向前移动,然后左转并在世界中循环。我们看到 Carla 是红色的。红色是默认颜色,因为我们在创建 Carla 时没有指定颜色。
接下来,我要移动 Jeanette,左转、移动、再左转、再移动一些,然后显示 Jeanette 的世界。我们可以看到 Jeanette 从一个不同的点开始,并循环绕行,创建了一条不同的路径。我们看到 Jeanette 是黄色的,包括路径。
这很酷。添加更多变量(如汽车颜色)可能只需要几行代码。函数也是如此。您可以将它们的定义添加到类中,然后就能够访问它们。
状态向量与矩阵
到目前为止,您已经看到汽车的状态包含多个值,我们一直将其放入 Python 列表中,但这些值通常包含在一种数据类型中:状态向量。向量类似于 Python 中的列表,因为它包含多个值,但它们在数学上非常不同。
向量是矩阵的一种,现在您可以将其视为数字网格。矩阵是一种数学数据类型,类似于整数或浮点数。与整数或浮点数类似,我们可以对矩阵进行乘法、缩放、相加等操作。这被称为线性代数。线性代数在自动驾驶汽车系统中会反复出现,从汽车运动到深度学习算法和图像分析,无处不在。
在本节中,我们将看到状态向量和矩阵如何一起使用,以帮助我们有效地预测新状态并定位汽车。
向量在数学中的应用
我们一直表示状态的方式是作为一个值列表,它只有一个长度(在这种情况下是两个值的长度)。但状态向量是一个值列,其维度是宽度为 1,高度为 m(同样,在这种情况下 m 是 2)。这使得状态向量成为一种特殊的矩阵,我们很快就会看到为什么列向量很重要。
让我们再想想我们的 predict_state 函数。它接收一个初始状态,我们存储位置 x 和速度 v,然后我们有几行代码基于恒定速度运动模型预测下一个状态。
我们计算新位置 new_x,并将两个值(新位置和速度)放入一个新的预测状态(一个新的两个值的列表)中。但是,对于一个包含这两个值 x 和 v 的状态向量,我们不需要将它们分开来执行这个计算。我们实际上可以在一个乘法步骤中执行相同的计算。
这个步骤是矩阵乘法步骤。矩阵乘法将两个数字网格相乘,比如这个 2x2 矩阵和这个 2x1 状态向量(它有一个位置 x 和一个速度 v)。以下是这个乘法的工作原理:
这个操作首先将第一个矩阵中的行乘以第二个矩阵中的列。所以这些维度(第一个矩阵的列数和第二个矩阵的行数)必须相同。
逐步来看,这看起来像是 1 * x(就是 x),然后 v * dt。所以现在我们有了第一个矩阵的第一行乘以第二个矩阵的列。
下一步是将这两个值相加以形成这个新矩阵中的一个新值:x + v * dt。然后我们对下一行做同样的事情。我们得到 0 * x + 1 * v。将这些值相加得到 v。
就是这样。您可以看到,这创建了一个新的 2x1 向量,其中包含两个可能看起来很熟悉的值。事实上,这些正是我们恒定速度运动模型的方程。
经过一些时间变化后,x 变为 x + 速度 * 时间变化,而 v 保持不变。因此,矩阵乘法让我们只需一个乘法步骤就能创建一个新的预测状态向量。
这是一种如此常见的预测新状态的方法,以至于这个 2x2 矩阵通常被称为状态转移矩阵。
接下来,我们将测试您关于矩阵乘法的知识,并看看我们如何使用这些知识来改进 predict_state 函数。
总结

到目前为止做得很好。现在您已经看到,我们可以使用矩阵乘法来转换汽车的状态。这种线性代数可以用于仅用一行代码更新多个状态变量,当您处理大数据集和代表我们三维世界的变量时,这变得非常有用。
接下来,您将看到线性代数如何用于创建二维常见滤波器。在这个过程中,您将学习更多关于矩阵运算和符号的知识,并且您将离创建实际用于定位自动驾驶汽车的算法更近一步。
在本节课中,我们一起学习了:
- 自动驾驶汽车定位的基本步骤(感知与移动)。
- 如何定义和表示汽车的状态。
- 如何使用运动模型(如恒定速度模型)预测未来状态。
- 如何利用面向对象编程创建能自动跟踪和更新自身状态的对象。
- 状态如何用向量表示,以及如何使用矩阵乘法高效地进行状态预测。
- 状态的内容(如是否包含加速度)取决于所选的运动模型。
这些概念是构建更复杂定位和导航算法的基础。
017:矩阵与状态变换 🚗➡️📊


在本节课中,我们将学习卡尔曼滤波器如何扩展到多维空间,以及矩阵和向量如何成为描述状态变换的核心工具。我们将从一维卡尔曼滤波器的回顾开始,逐步理解为什么在跟踪车辆等物体时,我们需要处理包含位置和速度等多维状态,以及矩阵运算如何实现这一过程。


从一维到多维卡尔曼滤波器
上一节我们介绍了一维卡尔曼滤波器,它能够通过高斯分布来估计单一变量(如位置)并融合测量与运动预测。本节中我们来看看,当我们需要同时跟踪物体的位置和速度时,情况会变得如何不同。



在现实世界中,例如使用雷达检测车辆位置时,我们通常需要处理二维(X和Y坐标)甚至更高维的状态空间。多维卡尔曼滤波器的一个强大之处在于,它能够从仅观测位置的数据中,推断出从未直接测量过的速度信息。
假设在时间点 T0、T1、T2 观测到物体分别位于三个不同的位置点。仅凭这些离散的位置点,人类直觉也能推断出物体具有向右运动的速度,从而预测它在 T3 时刻最可能的位置。卡尔曼滤波器通过数学方式自动化了这一推理过程。
其核心在于,它将状态从单一的位置 x,扩展为一个包含位置和速度的状态向量。例如,状态可以表示为:
x = [位置, 速度]


即使传感器只测量位置,滤波器也能通过状态内部变量(位置与速度)之间的物理关系(新位置 = 旧位置 + 速度),从连续的位置观测中“学习”到速度信息。
高维高斯分布
为了在数学上处理多维状态,我们需要使用多维高斯分布(也称为多元高斯分布)。
- 均值(Mean):从一个数值变为一个向量,向量的每个元素对应状态的一个维度。例如
μ = [μ_x, μ_v]。 - 方差(Variance):从一个数值扩展为一个协方差矩阵。这是一个 D×D 的矩阵(D 是状态维度),它描述了各个维度自身的不确定性以及不同维度之间的相关性。
一个二维高斯分布可以在平面上用等高线表示。等高线的形状揭示了不确定性:
- 圆形轮廓表示在X和Y方向上的不确定性相同。
- 椭圆形轮廓表示在一个方向上的不确定性大于另一个方向。
- 倾斜的椭圆形轮廓则表明两个变量(如位置和速度)是相关的。这意味着,如果我们获得了关于变量X(如位置)的新信息,它也会影响我们对变量Y(如速度)的信念。
这种相关性是多维卡尔曼滤波器能够从位置观测中推断速度的关键。
状态变换与矩阵
为了设计一个多维卡尔曼滤波器,我们需要定义两个核心函数,它们通常用矩阵来表示:
- 状态转移函数(State Transition Function):描述状态如何随时间演变。例如,对于一维运动(状态为
[位置, 速度]),其物理规律是:- 新位置 = 旧位置 + 速度
- 新速度 = 旧速度(假设速度暂时不变)
这可以用一个矩阵 F 来表示:
状态更新通过矩阵乘法实现:F = [[1, 1], [0, 1]]新状态向量 = F * 旧状态向量。



- 测量函数(Measurement Function):描述如何从状态中得到观测值。例如,如果我们只观测位置,不观测速度,那么测量函数可以用一个矩阵 H 表示:
它从状态向量H = [[1, 0]][位置, 速度]中提取出位置分量:观测值 = H * 状态向量。



多维卡尔曼滤波器方程

多维卡尔曼滤波器的完整方程涉及矩阵运算,看起来比一维情况复杂得多。以下是其核心步骤的简化表示:
预测步骤(Predict):
x' = F * x(预测新状态)P' = F * P * F^T + Q(预测新不确定性,其中^T表示矩阵转置,Q是过程噪声)

更新步骤(Update):
y = z - H * x'(计算测量残差)S = H * P' * H^T + R(将状态不确定性映射到测量空间,R是测量噪声)K = P' * H^T * S^(-1)(计算卡尔曼增益,其中^(-1)表示矩阵求逆)x = x' + K * y(更新状态估计)P = (I - K * H) * P‘(更新不确定性估计,其中 I 是单位矩阵)

请注意:你不需要记忆这些公式。关键在于理解它们的作用——它们是一维卡尔曼滤波器公式在多维空间中的推广,其核心思想(预测、比较测量、更新信念)是完全一致的。矩阵只是处理多个相互关联变量的数学工具。
线性代数:实践所需的工具
既然我们不需要死记硬背公式,那么作为工程师,我们需要掌握什么?我们需要的是将数学公式转化为有效代码的能力。这意味着我们需要对线性代数(矩阵数学)有基本的、实用的了解。
当我们未来需要实现一个卡尔曼滤波器时,很可能会去查阅维基百科等资料,看到上面那些充满矩阵和运算符号的方程。我们的目标是能够理解并实现它们。
以下是实现这些方程所必需的核心线性代数概念:
- 向量与矩阵:理解其表示和区别。通常小写字母(如
x)表示向量,大写字母(如F,P)表示矩阵。 - 矩阵加法与乘法:掌握其运算规则,尤其是矩阵乘法不满足交换律(
A*B ≠ B*A)。 - 单位矩阵:一种特殊的矩阵,相当于标量乘法中的“1”。
- 矩阵转置(T):将矩阵的行和列互换。
- 矩阵求逆(-1):类似于标量的倒数,但并非所有矩阵都可逆。
学习这些概念的目的不是进行理论推导,而是获得一种“功能性的熟悉感”,以便在遇到时知道如何查找、理解并使用代码库(如NumPy)来实现相应的运算。

总结

本节课中我们一起学习了卡尔曼滤波器从一维到多维的扩展。我们了解到,通过将状态定义为包含可观测变量(如位置)和隐藏变量(如速度)的向量,并利用描述它们之间物理关系的矩阵(状态转移矩阵 F 和测量矩阵 H),卡尔曼滤波器能够从间接观测中推断出隐藏状态。虽然多维卡尔曼滤波器的方程看起来复杂,但其核心思想与一维情况一脉相承。作为工程师,我们的重点是掌握将矩阵数学转化为代码的实践能力,为后续在真实多维场景(如车辆跟踪)中实现卡尔曼滤波器打下基础。
018:实现矩阵类1 🧮
在本节课中,我们将学习如何结合面向对象编程与矩阵运算,从头开始构建自己的矩阵和向量类。这是一个具有挑战性但收获巨大的项目。
项目挑战与目标 🎯
接下来要进行的项目,如果你能成功完成,脸上一定会露出巨大的笑容。编程实现这个项目是最令人惊叹的事情之一,但它并不简单。
上一节我们介绍了矩阵的基本概念,本节中我们将动手实践,用代码来实现它。
以下是本项目的核心要求:
- 你需要结合面向对象编程和矩阵知识,构建你自己的矩阵类和向量类。
- 你可以查阅维基百科或其他资料来寻找信息。
- 但你必须独立完成代码实现。
- 我们希望你真正学会如何用优雅的面向对象代码来表示像矩阵这样复杂的事物。
核心概念与实现方法 💻
开始深入实践吧。这个过程会很艰难。但我也向你保证,一旦你完成它,你脸上将绽放出最灿烂的笑容。😊
本节课中我们一起学习了实现自定义矩阵类的意义与挑战。关键在于运用面向对象的思想,将矩阵的属性和操作封装在类中,例如使用 __init__ 方法初始化,并重载 __add__、__mul__ 等运算符来实现矩阵运算。
019:05 实现矩阵类(第二部分)
在本节课中,我们将继续深入学习如何实现一个完整的矩阵类,这是构建自动驾驶系统中感知与控制模块的基础数学工具。
上一节我们介绍了矩阵类的基本框架,本节中我们来看看如何为这个类添加更多实用的功能。
核心功能实现
以下是矩阵类需要实现的核心运算方法。
矩阵加法
矩阵加法要求两个矩阵的维度必须相同。其运算规则是:将两个矩阵中对应位置的元素相加。
公式:C[i][j] = A[i][j] + B[i][j]
def add(self, other_matrix):
if self.rows != other_matrix.rows or self.cols != other_matrix.cols:
raise ValueError(“矩阵维度不匹配,无法相加。”)
result_data = [
[self.data[i][j] + other_matrix.data[i][j] for j in range(self.cols)]
for i in range(self.rows)
]
return Matrix(result_data)
矩阵减法
矩阵减法与加法类似,同样要求维度一致,运算规则是对应元素相减。
公式:C[i][j] = A[i][j] - B[i][j]
def subtract(self, other_matrix):
if self.rows != other_matrix.rows or self.cols != other_matrix.cols:
raise ValueError(“矩阵维度不匹配,无法相减。”)
result_data = [
[self.data[i][j] - other_matrix.data[i][j] for j in range(self.cols)]
for i in range(self.rows)
]
return Matrix(result_data)
矩阵乘法
矩阵乘法分为两种:标量乘法和矩阵乘法。标量乘法是将矩阵的每个元素乘以一个常数。矩阵乘法规则是:第一个矩阵的列数必须等于第二个矩阵的行数,结果矩阵的第i行第j列元素等于第一个矩阵第i行与第二个矩阵第j列的点积。
标量乘法公式:B[i][j] = k * A[i][j]
矩阵乘法公式:C[i][j] = sum(A[i][k] * B[k][j]) for k in range(A.cols)
def multiply(self, other):
# 标量乘法
if isinstance(other, (int, float)):
result_data = [[self.data[i][j] * other for j in range(self.cols)] for i in range(self.rows)]
return Matrix(result_data)
# 矩阵乘法
elif isinstance(other, Matrix):
if self.cols != other.rows:
raise ValueError(“第一个矩阵的列数必须等于第二个矩阵的行数。”)
result_data = [
[
sum(self.data[i][k] * other.data[k][j] for k in range(self.cols))
for j in range(other.cols)
]
for i in range(self.rows)
]
return Matrix(result_data)
else:
raise TypeError(“参数类型必须是数值或Matrix对象。”)
矩阵转置
矩阵转置是将矩阵的行和列互换。对于一个m×n的矩阵,其转置是一个n×m的矩阵。
公式:B[j][i] = A[i][j]
def transpose(self):
result_data = [
[self.data[j][i] for j in range(self.rows)]
for i in range(self.cols)
]
return Matrix(result_data)
代码的优雅性与复用性
实现这样一个结构清晰、功能完备的矩阵类非常有价值。整洁、美观且可复用的代码是优秀软件工程师的标志。对这种编码工作充满热情,能促使你写出真正出色的代码。
本节课中我们一起学习了如何为矩阵类实现加法、减法、乘法及转置等核心运算。掌握这些基础操作,是你在无人驾驶领域进行计算机视觉、感知与控制算法开发的坚实第一步。
020:C++入门指南 🚗💻
在本节课中,我们将开始学习C++编程语言。C++是嵌入式系统(例如运行在自动驾驶汽车内部小型处理器上的软件)的首选语言。我们将了解C++与Python的区别,学习其核心概念,并开始将Python代码转换为C++代码。
从Python到C++的过渡
上一节我们介绍了课程背景,本节中我们来看看学习C++的初始阶段。
现在我们将转向C++。C++是嵌入式系统的首选语言,例如驱动汽车内部小型处理器运行的软件。
起初,看到完全不同的语法和另一种语言可能会令人畏惧。这几乎就像从中文切换到日语一样困难。但随着你对不同语法越来越熟悉,你会发现底层工具与Python完全相同,只是C++速度更快。
在本单元中,我们将把你从“猴子”变成“机器人”。你将看到用Python编写的内容,我们需要你将其翻译成C++。这样你将消除对不同语法的恐惧,并变得非常熟练地在两种不同的编程语言之间来回切换。我保证在课程结束时,你会发现它们基本相同,只是写法不同,但底层概念是等价的。这是理解编程语言的好方法。
C++是自动驾驶汽车的语言。与Python这样的高级语言相比,它也是一种更接近Nvidia PX2等设备所能理解的语言。如果你想在这个领域找到工作,这是你必须学习的东西。
虽然C++在大多数方面与Python等语言相似,但它也有几个使其不同的特性。在这个学生学位项目中,你将通过两门课程学习C++。在本课程中,我们将重点介绍C++与Python共有的所有元素:变量、循环、类和对象等。在下一门课程中,我们将重点介绍C++的一些独特之处。
但本课程的角度相当直接:编写可运行的C++代码。具体来说,你将需要将用Python编写的二维直方图滤波器代码翻译成C++。当你成功运行它时,你就朝着最终掌握C++迈出了极其重要的第一步。
开始前的准备工作
在继续之前,有一些基本的准备工作需要在开始编写和运行C++代码之前完成。
以下是开始前需要遵循的步骤说明。
现在你已经准备就绪,是时候开始编写一些代码了。但在我们开始之前,我们应该说明一下这些课程的格式。
本课程的目标在某些方面很简单:将Python代码翻译成C++。为了实现这个目标,你需要学习很多东西,但其中大部分在概念上并不复杂。是的,编写C++需要你知道for循环的语法,但你可能不需要关于for循环是什么的讲座。因此,考虑到这一点,接下来课程中的大部分教学将包含示例代码和文本解释。当我们遇到C++中一些不可避免的新内容时,例如编译或声明变量,部分教学将借鉴我对Alicia White的采访,她撰写了关于嵌入式系统的书籍。
C++与Python的核心区别
上一节我们提到了准备工作,本节中我们来看看Alicia White对C++核心特性的解释。
欢迎Alicia,非常感谢你今天到来。你好,Andy,谢谢你的邀请。我一直在尝试学习C++,我自己不是专家。当我尝试学习这门语言时,我做的第一件事就是尝试转换我们在本纳米学位第一门课程中编写的一些直方图滤波器代码。我尝试将其从Python转换为C++,那是一次相当艰难的经历,但我想这段代码可以作为讨论这门语言是什么的一个很好的起点。我们来看看其中的一些内容。我们可以更详细地讨论它,让我们深入代码的细节。但在我们开始之前,你想从高层次上解释一下什么是C++吗?C++和Python有什么区别?为什么我们需要多种编程语言?
有些人说你应该学习多种编程语言。你应该学习不同的语言,因为它们有不同的用途。Python非常适合解决问题、制作原型、确定目标以及如何实现目标。但你并不一定关心你使用什么工具来实现目标,无论你使用的是轻便摩托车还是一辆好车。
另一方面,C++知道它在什么上运行,它了解处理器,了解它如何与世界交互。它是一种非常战术性的语言,它的根基在于它所运行的计算机。它拥有许多Python的特性,可以帮助你实现问题的解决方案。但它也有方法可以运行得非常快,这就是C++对这类开发如此重要的真正原因。
在研究一些C++内容时,我不断看到“静态类型”这个术语,语言被称为静态类型的,C++就是其中之一。你能解释一下语言是静态类型意味着什么吗?当你使用C++时,你必须告诉编译器你想要什么类型的变量,无论是浮点变量、字符还是字符串。你需要提前知道你是想要高精度(如float64或double可以提供的),还是其他类型。你这样做是因为它告诉编译器很多关于它必须做什么的信息。
在Python中,你不必这样做。你可以让变量成为任何类型,它们可以在中途改变。这没关系。但如果你在C++中声明一个变量float f,它必须是一个浮点数,只要f存在。现在,如果你转到不同的函数,你可以有一个int f和一个string f,尽管这看起来很傻。在名称的作用域之外,这无关紧要。但在作用域内部,它必须始终是该类型。所以它是一个固定类型。它是静态类型,这就是静态类型的意思。
在设计编程语言时,如果我正在创建自己的编程语言,并且可以选择使用静态类型语言或非动态类型语言(我认为是这个词),我如何做出权衡?在做出这个权衡时发生了什么?静态类型语言可能更快,因为你提前知道你在做什么。动态类型语言可能更灵活,让用户稍后决定他们想要什么。所以这是灵活性与速度的权衡。
好的,我想我对这个有了一些理解,但是……例如,如果double比float更精确,为什么不总是使用double呢?因为它们更大。如果你在做一个小程序,这并不重要。但如果你需要做一个有一百万个变量的程序,double的大小是float的两倍。我们的计算机现在有如此多的内存,我们笔记本电脑里的东西令人惊叹。但当你转向嵌入式系统时,有时你没有那么多内存。你需要意识到你的资源限制,所以你选择使用float,甚至选择使用定点数。但对于一辆自动驾驶汽车,我可以在后备箱里放很多电脑。你放进后备箱的每一台电脑都是能源浪费,这意味着它无法多行驶一英里。
C++中的函数与类型
上一节我们讨论了类型系统,本节中我们来看看C++中函数和声明的具体细节。
你提到了头文件,它们有函数声明。如果你想要定义一个类型,它们可以有更多内容。那就是网格类型。与其使用这个vector<vector<float>>,因为你有点厌倦了反复输入它,我经常写这个。是的,你可以改为使用typedef vector<vector<float>> Grid。T或Grid通常表示它是一个类型。然后你就不必每次都输入它。你可以在任何地方只使用Grid。那会很好。输入量少得多。知道这一点很好。你知道,任何时候你发现自己反复输入相同的东西,很可能有人已经为你解决了这个问题。找到它可能不那么容易,但很可能它存在。好的,谢谢。
我注意到的另一件有趣的事情是关于C++中的方法或函数。我实际上是偶然发现这一点的,当时我写了两个同名的函数,让我看看是否能找到它们。我写了两个都叫close_enough的函数,一个用于比较两个浮点数,你知道,解决浮点数接近但不完全相等的问题。如果两个浮点数足够接近,我想返回true。另一个用于两个网格,如果它们足够接近相等,我想返回true,这只是意味着它们内部的所有实际值都足够接近。我实际上能够做到这一点,我简直不敢相信这能工作。但我写了一个close_enough,它接受两个vector<vector<float>>或网格,另一个版本接受两个浮点数。这是怎么回事?为什么这是允许的?编译器不认为它们是同一个东西。它们有完全不同的签名。我的意思是,如果你签署了什么东西,我签署了什么东西,它们会是不同的。它做的是将这些函数及其接受的类型,以及这个close_enough(vector<vector<float>>),都作为函数名的一部分。你看不到这一点,但如果你查看编译器内部,你就能看到它开始处理所有函数的方式。用名称和所有参数定义。这被称为重载。如果你想要一个函数对浮点数进行close_enough操作,或对整数进行close_enough操作,这会非常有用。你不必为每个都取不同的名字,你希望它知道是否足够接近。这是C++的一个特性。是的,能够这样做感觉真的很好。
那么什么是函数声明,为什么它很重要?函数声明有多个部分。这在代码中更容易查看。也许让我们看看你的test_normalized函数。当你只看函数的定义方式时,有bool,然后是test_normalized,开括号和闭括号。那个bool表示它将返回true或false。与Python非常相似,你可以返回任何你想要的东西,只是在C++中你必须告诉它你要返回的类型。这有一些优点。例如,在Python中,如果你不小心只返回了行而不是整个网格,它不会告诉你,你直到运行时才会知道,可能在你做了三个小时后突然出现错误。代码会运行到一个有bug的地方,而你却不知道。但C++会立即告诉你,如果你没有返回东西,或者返回的东西类型不对,你甚至无法编译。我在Python中经常做的一件事是完全忘记return语句。然后我永远在处理一个None类型的对象,这实际上可能是一个很难调试的bug。所以我能看出这可能很有价值。确切地说,你至少可以保证各部分正确组合在一起,不一定是它们做了应该做的事,而是它们组合正确,就像乐高积木能拼在一起一样。而且你没有忘记一个。忘记return或者因为想着别的事情而返回错误的东西太容易了。希望C++有一些安全检查为你把关。
那么对于test_normalized函数,我们有返回类型bool,名称test_normalized,开括号和闭括号,但我想这不是完整的画面,因为这个函数不接受任何参数。确实如此。如果它在一个头文件中,而不是那个开大括号,你会有一个分号来表示:这是函数声明,而不是定义。如果我们想接受参数,它会在那些开括号里。以另一个函数normalize为例,你有一个有点复杂的函数签名,你有vector<vector<float>> normalize(vector<vector<float>>)。这表示你想返回这个vector<vector<float>>,也就是浮点数的网格。你想进行normalize操作,这只是你的函数名。然后在里面,你有另一个网格。当你改变它,使你不一定以同样的方式传递它并复制它时,你会发现你不需要所有这些参数。所以我想,我之前的误解是,函数的唯一标识符是名称close_enough,但事实并非如此。它是签名。是整个东西。是返回值、名称和所有输入及其类型。是的,好的,这很有帮助,很好。
课程总结与鼓励
你已经到达本课的结尾,你不必记住在这里学到的所有东西,你可以查阅它们,但很好的是你理解了函数在C++中是如何工作的,以及类似的构造。
拍拍自己的肩膀,好的,你已经到达了这一点,然后明天早上,让我们继续前进。
在本节课中,我们一起学习了C++的基础知识,包括其作为嵌入式系统和自动驾驶汽车首选语言的重要性,与Python在静态类型、性能、内存管理等方面的核心区别,以及函数声明、重载等基本语法概念。我们了解到,虽然初学新语法可能令人畏惧,但底层编程概念是相通的。通过将Python代码翻译成C++的实践,我们可以逐步掌握这门强大的语言,为在自动驾驶领域的深入学习打下坚实基础。
021:无人驾驶汽车纳米学位项目-十位大牛亲授自动驾驶技术,硅谷前沿科技(计算机视觉⧸车道识别⧸感知控制⧸人工智能⧸谷歌) p21 21. Part 04-Module 01-Lesson 03_Practical C++ [BV1Vd4y1r7QG_p21]
概述
在本节课中,我们将要学习Python与C++之间的一个核心区别:解释型语言与编译型语言。我们将了解C++代码如何在本地计算机上运行,并初步接触编译过程。

课程内容
4.1.3:解释型语言与编译型语言 🖥️
到目前为止,你一直在优达学城的课堂环境中编写代码,环境界面如下所示。

但你也会希望能在自己的计算机上本地运行你的程序。
这引出了Python和C++之间的另一个重要区别。
你在上一课开始时学习了第一个主要区别。
那就是Python是动态类型语言,而C++是静态类型语言。
这就是为什么你必须在C++中编写诸如 int 这样的类型声明。
另一个主要区别是,Python被称为解释型语言。
而C++是一种编译型语言。
关键在于,当你用Python或C++编写代码时。
计算机无法直接理解你写的内容。
代码首先需要被翻译成计算机能理解的语言。
对于Python代码。
有一个被称为解释器的翻译器,它会逐行进行翻译。
先翻译,然后执行每一行代码。翻译这一行,执行它。
翻译这一行,执行它。这个翻译、执行、翻译、执行的过程会一直持续。
直到你到达文件的末尾。或者在这种情况下,到达代码的末尾。
因此,运行单元格会得到预期的结果。
实际情况比我描述的要复杂一些。
但这就是像Python这样的解释型语言的大致原理。
对于像C++这样的编译型语言。
所有代码在任何部分被执行之前,都会被翻译成编译器能理解的语言。
这个步骤被称为编译。
只有在程序被编译之后,它才能被后续执行或运行。
直到现在,我们一直在向你隐藏这个编译步骤。
当你在浏览器中编写代码,然后向下滚动并按下“测试运行”按钮时。
我们一直在后台秘密地编译并执行代码。
所以到目前为止,这门语言对你来说感觉像是解释型的。
但当你在自己的计算机上编写C++代码时,你将需要自己完成编译步骤。
让我向你展示一下这在我的计算机上是什么样子。


这里我有同一个文件,我把它命名为 demo.cpp。
如果我确实想在我的计算机上运行这段代码。
我必须先打开一个叫做终端的东西。
现在不要太担心这个终端是什么。
我只想向你展示的是,当我输入 ls 命令时。
这是一个列出我当前目录中所有文件的命令。
目前我只看到这个 demo.cpp 文件。所以现在,在我运行这段代码之前。
我需要先编译它。对于我的计算机,我通过输入 g++ 命令来完成。
后面跟上文件名,在这个例子中是 demo.cpp。我按回车键。然后什么也没发生。
但如果我再执行一次 ls 命令。我现在看到了第二个名为 a.out 的文件。
这是 demo.cpp 中代码的翻译版或编译版。
如果我输入 ./a.out 并按回车键。我得到了我期望的输出。
在本课的剩余部分。
你将学习更多关于编译的知识,并开始在你自己的计算机上使用C++代码。

总结

本节课中我们一起学习了Python(解释型语言)与C++(编译型语言)在代码执行流程上的根本区别。我们了解到,C++代码需要先经过编译步骤,生成一个可执行文件(如 a.out),然后才能运行。我们还初步接触了在终端中使用 g++ 编译器进行编译和运行的基本命令。这是将C++代码从课堂环境迁移到本地计算机进行开发的第一步。
022:C++ 面向对象编程 🚗💻
在本节课中,我们将学习C++面向对象编程(OOP)的核心概念及其在无人驾驶汽车开发中的重要性。我们将探讨为何OOP对于构建复杂、模块化且可测试的系统至关重要。
C++是一个需要终身学习的领域。我分享一个个人经历。在我职业生涯的大部分时间里,我都使用C++编写代码。但时至今日,回头看自己当年写的C++代码,我可能也无法完全理解。原因在于,C++包含许多非常复杂的特性。
因此,在接下来的单元中,我们将引导你自主学习C++。你的任务是将Python代码翻译成C++。在这个过程中,你可能会遇到未曾学过的结构、关键字等概念。我们的教学方式不是直接教授所有这些内容,而是要求你自行上网查找资料,并将所学应用到编程练习中。祝你学习愉快。
上一节我们提到了C++的复杂性以及自主学习的方式。本节中,我们来看看为何在例如设计自动驾驶汽车时,我们需要使用面向对象编程。
我常常倾向于用函数来解决问题,但我知道以面向对象的方式思考有其重要价值。面向对象编程的优势在于其模块化和可测试性,这一点至关重要。此外,对于自动驾驶汽车,你可以直观地看到“对象”——它们就是物理实体。代码中的对象越接近你可触摸的真实事物,就越有助于你定义代码结构。
这反过来促进了不同代码块之间的良好分离。例如,你可以有一个Wheel(车轮)类(虽然我们可能不深入讨论这个例子)。车轮会转动,它有胎面等属性。你可以拥有四个车轮实例,它们相似但不完全相同。
同样,你可以有一个用于识别车道的Sensor(传感器)类,它可能包含一个Camera(摄像头)类。而这个Camera类又可能被其他开发人员复用。
最终,你可能会构建一个极其复杂的系统。但如果每个组件都是自包含的、独立的,并且被封装成各自独立的对象,那么当你需要更换不同类型的车轮,甚至升级到飞行汽车时,你只需替换相应的模块即可。
以下是面向对象编程在自动驾驶系统中的几个关键优势:
- 模块化:代码被组织成独立的类(对象),每个类负责特定的功能。
- 可测试性:每个类可以独立进行单元测试,确保其行为正确。
- 封装性:将数据(属性)和操作数据的方法(函数)捆绑在一起,隐藏内部实现细节。
- 可复用性:像
Camera这样的类可以在系统的不同部分甚至不同项目中被重复使用。 - 易于维护:系统结构清晰,修改一个模块(如
Wheel)不会轻易影响其他模块。
本节课中我们一起学习了C++面向对象编程的基本理念。我们了解到OOP通过创建代表现实世界实体的对象,使得像自动驾驶系统这样复杂的软件更易于设计、测试和维护。其核心在于模块化、封装和代码复用,这些特性对于构建可靠且可扩展的自动驾驶汽车技术栈至关重要。
023:Python与C++速度对比 🚗💨
在本节课中,我们将通过一个具体的例子,直观地对比Python和C++两种编程语言在执行速度上的差异。我们将编写功能相同的代码,并观察它们的运行时间。
概述
在之前的课程中,我们学习了Python和C++的基础语法。你可能会产生一个疑问:既然Python已经是一门功能强大的语言,为什么我们还要学习C++?本节课程将通过一个并排运行的实例,让你亲眼看到两者在性能上的区别。
性能对比实验
为了清晰地展示差异,我们将编写一个执行简单但计算量较大的任务。以下是实验的核心步骤。
任务定义
我们将计算从1累加到一个非常大的数字(例如10亿)的总和。这是一个纯粹的CPU密集型计算任务,非常适合用来测试语言本身的执行效率。
Python实现
首先,我们使用Python来实现这个累加功能。Python的语法简洁明了。
total = 0
for i in range(1, 1000000001):
total += i
print(total)
C++实现
接下来,我们使用C++来实现完全相同的功能。请注意C++在语法上与Python的不同之处。
#include <iostream>
int main() {
long long total = 0;
for (long long i = 1; i <= 1000000000; ++i) {
total += i;
}
std::cout << total << std::endl;
return 0;
}
运行与计时
现在,我们将在相同的硬件环境下分别运行这两段代码,并使用计时工具来记录它们的执行时间。
以下是运行和比较的关键点:
- Python运行:在命令行中使用
time python3 sum.py来执行Python脚本并查看耗时。 - C++运行:首先需要使用编译器(如g++)将C++代码编译成可执行文件:
g++ -o sum_cpp sum.cpp,然后使用time ./sum_cpp来运行并计时。 - 结果对比:观察两个命令输出中的“real”时间(即实际流逝的墙钟时间),你会发现C++版本的执行速度通常比Python版本快一个数量级。
原因分析
为什么会产生如此巨大的速度差异?上一节我们运行了代码,本节中我们来看看背后的核心原因。这主要涉及两种语言的本质区别。
以下是导致速度差异的几个关键因素:
- 解释执行 vs 编译执行:Python是解释型语言,代码在运行时由解释器逐行读取、解析并执行。而C++是编译型语言,代码在执行前被编译器一次性转换成高效的机器码。
- 动态类型 vs 静态类型:Python是动态类型语言,变量类型在运行时才确定,这增加了额外的检查开销。C++是静态类型语言,变量类型在编译期就已确定,运行效率更高。
- 全局解释器锁(GIL):Python的GIL机制限制了多线程并行执行CPU密集型任务的能力,而C++则没有这个限制。
总结
本节课中,我们一起学习了Python与C++在执行速度上的直观对比。通过一个累加计算的实例,我们亲眼验证了C++在纯计算性能上显著优于Python。这种差异主要源于两者在编译执行与解释执行、静态类型与动态类型等根本设计上的不同。在无人驾驶这种对实时性和性能要求极高的领域,C++往往是核心系统模块的首选。理解这些差异,将帮助你在未来的项目中选择合适的工具。
024:第 01 章
概述
在本节课中,我们将学习 C++ 代码优化的基础知识。我们将探讨计算机如何执行程序,理解内存和 CPU 的工作原理,并学习如何通过减少不必要的指令和内存访问来提升代码效率。课程分为两部分:首先介绍计算机内部工作原理,然后应用这些知识进行实际代码优化。
计算机如何执行程序
上一节我们完成了从 Python 到 C++ 的代码翻译。本节中,我们来深入了解计算机执行 C++ 程序时的内部机制。
优化代码的核心在于消除不必要的指令,同时确保程序输出正确结果。程序访问内存和进行计算操作的次数越少,效率就越高。优化不仅仅是让代码通过某个“黑箱”来提高性能,更是要理解计算机如何工作并执行你的代码,并利用这些知识。
除了硬盘等存储介质,计算机运行程序主要涉及两个部分:中央处理器 和 随机存取存储器。
- 中央处理器 包含一个控制单元,负责执行代码中的指令;以及一个算术逻辑单元,负责计算数学和逻辑表达式。
- 随机存取存储器 是程序用来存储变量和指令以支持程序执行的地方。它是一种类似硬盘的存储介质,但读写速度更快,并且是易失性的,这意味着关闭计算机时,其中的所有数据都会消失。
本质上,CPU 中的控制单元会读取代码中的下一条指令,并通过向 RAM 写入数据、从 RAM 读取数据或让算术逻辑单元执行计算来执行它。
与 Python 不同,在 C++ 中编写完程序后,需要先编译才能执行。编译器会将你的代码重写为 CPU 可以理解的一组指令,称为机器码,这基本上是 CPU 的语言。因此,编译器充当了你理解和编写代码的方式与 CPU 理解和读取代码的方式之间的翻译器。
例如,你可能会写一行代码 int x = 5;。编译器会将这行代码转换成类似这样的内容:
store the value of 5 to a specific location in RAM that can be accessed with an address tied to the variable x.
CPU 会理解这意味着将值 5 存储到 RAM 中一个特定位置,该位置可以通过与变量 x 绑定的地址访问。另一条指令可以通过向 CPU 发送查询与 x 绑定的地址处存储的值的指令来检索 x 的值。还有一条指令可以获取存储在 x 地址处的值,将其增加 10,然后将新值更新到内存中。
所有这些指令都需要时间执行,而某些操作,如三角函数和 if 语句的分支,已知效率特别低。
让我们看一系列 if 语句的例子:
if (x == 0) {
y = 0;
}
if (x != 0) {
y = 10;
}
这段代码效率低下,因为它导致 CPU 对 x 的值进行两次比较。然而,如果第一个条件为假,第二个条件自动为真。因此,一个优化方案是使用 if-else 语句重写:
if (x == 0) {
y = 0;
} else {
y = 10;
}
这个版本的代码只需要进行一次比较。你可能会认为这样微小的改变对速度影响不大,对于单次出现的情况,可能确实如此。但想象一下,如果这些语句位于一个运行数千次或数百万次的 for 循环内部,微小的低效就会开始累积。
理解计算机如何运行有助于你确定哪些计算可能拖慢程序。你对计算机内部发生的事情了解得越多,就越能成功地提高程序的效率。
课堂命令行工具介绍
在“C++ 优化入门”和接下来的“C++ 优化实践”课程中,你将使用一个之前未见过的新课堂功能。

当你进入课程的下一部分时,会加载一个嵌入式命令行工具。通常,当你使用计算机时,是通过鼠标和图形用户界面来移动文件、打开文件、创建新文件和打开程序。还有另一种使用计算机的方式,那就是通过键盘直接向计算机输入命令。这正是我们在课堂中为你设置的功能。当你打开这个部分时,课堂的这一部分实际上连接到一个远程计算机,该计算机包含文件并允许你运行 C++ 程序。
在 Mac 或 Linux 机器上,有一个名为“终端”的程序,可以让你执行我们将在这里做的相同类型的事情。在 Windows 中的等效程序称为“控制台窗口”。
当你进入课程的下一部分“演示机器码”时,这个工具会打开。你会看到有三个主要部分,加上底部和顶部的菜单。顶部的菜单用于导航课堂,你应该已经很熟悉了。这部分显示远程计算机上的所有文件。因为我们在课程的“演示机器码”部分,所以它自动设置为进入 demo_machine_code 文件夹,并为你自动打开这两个文件。你可以用鼠标在这里导航,查看工作空间和本课程中的所有演示。双击文件或文件夹会将其打开,你可以看到其中的文件。点击返回按钮会带你回到之前的位置,你可以像在计算机上习惯的那样进行导航。
如果你右键点击,会看到一些选项,如“打开”、“移动”(将文件移动到不同文件夹)、“重命名”、“复制”、“下载”、“删除”。这个加号按钮用于添加文件、添加文件夹或从你的桌面上传内容。请注意这里显示“Lchan 已保存”,所以你在这里做的所有事情都会自动保存,你在这里输入的任何内容也会自动保存,因此你无需担心保存问题。
这部分是一个文本编辑器。你可以看到这个 instructions.md 文件实际上是一个解释演示内容的文本文件。然后你还有 machine_code.cpp,这是一个 C++ 文件。你可以关闭这些文件,也可以通过双击左侧来打开它们,它的工作方式类似于你的常规计算机。
接下来是底部这个大块区域,显示“新终端 - 无打开的终端”。这是你实际能够直接向远程计算机写入命令的地方。如果我点击这里的“新终端”或这个加号,它会打开一个新窗口。这里显示 /home/workspace,这告诉我实际上在 workspace 文件夹中。请注意,点击这个部分与点击那个部分实际上并不相连。如果你想在终端中切换到不同的文件夹,必须实际输入命令来实现。
说明文件实际上会告诉你需要在这里输入什么命令以及按什么顺序。你可以直接来这里复制这些命令并粘贴,它们会显示出来,然后你按回车键,它们就会运行。或者,你也可以手动输入。
以下是使用该工具运行 C++ 代码的示例步骤:
- 输入
cd demo_machine_code命令。这表示“更改目录”。现在你可以在终端中看到已经切换了文件夹,进入了demo_machine_code文件夹。 - 输入
g++ -c machine_code.cpp命令来编译 C++ 代码。编译后,你可以看到这里出现了一个.o文件。 - 按照说明文件输入后续命令来查看演示结果。
另一个需要提及的部分是底部的这个菜单。如果由于某种原因工具冻结或运行不正常,你可以随时点击这里的“刷新工作空间”。它只是重新连接,你的工作将保持不变,所有内容都已保存,但它让你可以重新开始。如果你想重置一切并恢复到我们为你提供的原始代码,你必须点击“重置数据”,这会打开一个窗口,提示“重置数据将清除你所有的工作,将项目恢复到原始状态”。然后你输入“重置数据”并点击。我强烈建议除非你已经将所有工作外部保存并下载,否则不要重置数据。例如,如果你只想重置这些文件而点击了重置数据,它将重置所有内容,你将丢失所有工作。因此,请确保如果需要重置数据,你已经右键点击了所有内容并下载了所有代码到桌面或其他地方,以便在重置后可以重新上传。
再次强调,每一个这样的课程部分,例如这里的“演示二进制”,都会为你打开文件,提供说明并告诉你该做什么。如果你想查看与此相关的所有代码,只需点击这里“演示二进制”,你就会看到 instructions.md 文件和 main.cpp 文件。所以,你将始终执行的操作是:打开这个工具,阅读说明,打开一个新终端,逐个输入这些命令,按回车,一切就会运行。我会提供一个关于所有这些命令是什么以及你能做什么的链接,但这不是你现在需要的。不过,如果你想更深入地学习,并了解如何在课堂外为自己在计算机上使用终端,这是非常有帮助的,也是许多软件工程师需要掌握的技能。
二进制与内存表示


从高层次来看,你已经了解到限制 CPU 指令可以使代码运行得更快。作为人类,我们通过 C++ 这样的编程语言来思考与计算机的交互。然而,计算机只理解二进制指令,所以你写的所有内容在计算机看来都是一系列的 0 和 1。
让我们更深入地探讨计算机如何使用二进制在内存中存储信息。理解你的 C++ 程序如何存储信息可以帮助你避免不必要的内存访问,从而浪费宝贵的 CPU 周期。
让我们思考如何用二进制表示一个变量。二进制依靠 2 的幂次方来描述一个值,最低有效位通常在右侧。对于二进制表示中的每个位置,你取该位置的二进制值 1 或 0,乘以与该位置相关的 2 的幂次方(使用从 0 开始的编号),最后将结果相加得到十进制值。
现在,通过查看数字 1 到 5 的二进制表示来举几个例子:

- 十进制数 0 在二进制中也是 0。计算方法是
0 * 2^0 = 0。 - 十进制数 1 在二进制中是 1。计算方法是
1 * 2^0 = 1。 - 二进制中的 10 等于十进制数 2。计算方法是
1 * 2^1 + 0 * 2^0 = 2。 - 遵循相同规则,二进制中的 11 是十进制中的 3。计算方法是
1 * 2^1 + 1 * 2^0 = 3。 - 二进制中的 100 是十进制中的 4。计算方法是
1 * 2^2 + 0 * 2^1 + 0 * 2^0 = 4。 - 最后,二进制中的 101 是十进制中的 5。计算方法是
1 * 2^2 + 0 * 2^1 + 1 * 2^0 = 5。
你可以使用二进制表示任何十进制数字。例如,数字 25 将是 11001。你可以看到如何计算得到这个结果。
计算机用二进制表示所有变量,不仅仅是整数。这意味着即使是字符和浮点数也用二进制表示。但你的计算机不能一次存储任意数量的 0 和 1。相反,内存以八个二进制数字的集合形式存储,其中每个数字称为一个位。你可以将计算机的内存想象成这些 8 位槽。每个 8 位槽称为 1 个字节。
在 C++ 中,最小的变量是 8 位或 1 字节的字符。一个 16 位整数占用 2 个字节,一个 32 位整数使用 4 个字节。一个浮点数也使用 4 个字节。在 C++ 中,你的变量将始终使用变量类型指定的内存量。
例如,数字 3378 只需要 12 位。但表示为 16 位整数时,它将需要两个字节,并在左侧填充额外的零。作为 32 位整数,它将有更多的零填充,这基本上意味着最高有效位将是零。
在 C++ 中,你可以声明 16 位、32 位甚至 64 位整数。这仅仅意味着该整数使用 2、4 或 8 个字节的内存。你可以开始体会到可用位数成为一个限制。如果一个整数只能使用 32 位,那么最大值将是所有位都等于 1。十进制等效值是 4,294,967,295。这个数字是 32 位整数可以存储的最大值,如果你使用有符号数,这个值会更小。
C++ 如何使用内存
现在你已经了解了如何用二进制表示变量,接下来将学习 C++ 如何存储和提取这些信息。请记住,优化代码的一部分涉及限制程序读写内存的次数。你需要深入了解这些读写操作何时以及为何发生。
让我们详细了解一下 C++ 如何使用 RAM。你可以将 RAM 视为一组可以存储信息的槽。当你的程序执行数据时,数据通常存储在 RAM 中。这些数据可以被覆盖,事实上,当你关闭计算机时,RAM 中的数据会被擦除,因为 RAM 是易失性的,因此 RAM 用于临时存储。
举一个简单的例子,比如声明一个字符变量:
char x = 'a';
当变量 x 被声明时,你的程序会进入计算机的 RAM 并预留空间。预留多少空间取决于变量的类型。对于字符变量,会预留一个字节。然后当变量 x 被定义时,该字符的二进制值被存储在 RAM 中。在 C++ 中,你的程序可以跟踪的最小内存单位是一个字节。
那么,如果你的程序定义了另一个字符变量会发生什么?编译器会将这个字符分配给下一个可用的字节。那么一个整数变量呢?一个 32 位整数将占用 4 个字节的内存。因此,一个整数变量将占用 RAM 中的四个空间。编译器和计算机架构将精确确定每个变量需要多少空间,以优化程序。通常,下一个变量被放置在前一个变量之上。每个字节也有一个地址,以便程序知道在哪里找到变量。
C++ 放置变量值的整个区域有一个特殊的名称,称为栈。C++ 使用栈来高效地为你管理内存的读写。当一个函数终止且变量超出作用域时,编译器会释放这些内存位置,并反向工作。
除了栈,编译器还为不同的任务提供其他内存部分,比如存储代码文本和为全局变量预留空间。C++ 还提供了一个称为堆的内存区域,你可以在其中手动控制变量何时从内存中移除。
优化实践与总结
那么,这一切与代码优化有什么关系呢?从内存读取和向内存写入都需要时间,因此可能使你的程序变慢。如果你声明并定义了一个实际上不需要的变量,你的程序可能会变慢。如果你不必要地复制一个变量,你的程序也可能会变慢。栈往往比堆更高效,因此你声明变量的方式最终会影响程序的性能。因此,你越理解每一行代码的后果,就越能更好地进行优化。这就是我们谈论“与计算机共情”时的意思。理解硬件的限制有助于做出更好的编码决策。
至此,你已经看到了低效代码如何通过执行不必要的 CPU 操作和内存访问导致程序变慢。接下来我们将讨论优化代码的实用性。
代码优化是一个涉及分析算法、理解计算机如何执行指令以及学习你所使用的编程语言和编译器细微差别的大主题。代码效率还取决于你使用的编译器和硬件。许多编译器会尝试为你优化代码,不同的编译器可能以不同的方式优化。在一个案例中运行良好的代码在另一个应用程序中可能效率不高,并且一种 CPU 架构的指令集可能有非常高效地执行某个操作的指令,而另一种可能没有。
因此,在优化代码时不能只依赖直觉。你需要测试你的代码,找出对 CPU 要求高的地方,无论是与时间、内存使用还是功耗相关。然后,你对代码所做的任何更改都需要进行测试,以确保一切确实如你预期的那样运行得更高效。

接下来,你将有机会优化 C++ 直方图滤波器。你可能还记得在 C++ 入门课程中,当 Andy 向 Alicia 展示他的代码时,Andy 已经将他的 Python 直方图滤波器代码翻译成了 C++。Alicia 指出他的代码有一些低效之处。我们将为你提供那段低效的代码,你的任务是让它尽可能快地运行。

总结
在本节课中,我们一起学习了 C++ 代码优化的基础。我们探讨了计算机执行程序时 CPU 和内存的协作机制,理解了二进制表示和内存存储的原理。我们认识到,优化本质上是减少不必要的指令和内存访问。通过理解计算机的“思维方式”,我们可以做出更明智的编码决策,从而提升程序性能。在接下来的实践中,你将应用这些知识来优化一个具体的 C++ 程序。
025:一个由指针和内存管理引发的真实案例 🚗💥
在本节课中,我们将通过一个真实的、代价高昂的自动驾驶汽车软件故障案例,来理解掌握C++底层特性(如指针和内存管理)的重要性。这个故事将说明,在工程实践中,每一个微小的细节都可能导致灾难性的后果。
上一节我们探讨了C++中指针和内存分配等基础概念,本节中我们来看看一个因忽视这些细节而引发的真实事故。
事故背景:斯坦利号与莫哈韦沙漠
故事发生在2005年。当时,我们正驾驶着名为“斯坦利”的自动驾驶汽车穿越莫哈韦沙漠。最初,车上只安装了一台计算机,一切运行正常。






后来,由于计算机负载过重,我们决定在车上加装第二台计算机。
诡异故障的出现
加装第二台计算机后,一个诡异的问题出现了:每隔大约40英里,机器人就会在正前方“看到”一堵并不存在的障碍物墙。
这导致车辆执行一次疯狂且剧烈的转向避让动作,几乎让我们车毁人亡。每次我们都不得不进行人工干预,接管车辆控制权。
我们当时知道整个比赛路线长约120英里。这意味着,如果每隔40英里就发生一次这样的故障,我们将无法完成比赛。
漫长而艰难的排查

我们开始排查问题。在一台计算机上单独运行时,系统表现良好;但两台计算机同时运行时,故障就出现了。我们当时感到非常困惑。
最终,问题根源被找到了。我们曾在软件中设置了一个数据时效性计时器,用于限制障碍物大小的判定边界。我们暂且接受这个设定。





问题的关键在于,当我们使用两台计算机时,发现它们的内部时钟运行速度不同。这在不同的计算机硬件上是相当普遍的现象。
我们使用C++编写了软件来同步这两台计算机的时钟。这个同步程序会偶尔将某个时钟值向后调整。
这导致我们一项依赖于时间流逝的测试中,出现了负的时间流逝值(因为时间差变成了负数)。
这个负的时间值,结合车辆在通过小土坡时产生的上下颠簸,共同“制造”出了一个虚假的障碍物信号。
这个故障非常棘手,我们花了整整好几个月才最终定位到它。我们每天进行路测,大约每40英里就会遇到这个现象,却始终无法理解原因。
案例的启示
我分享这个故事的原因是:当你成为一名软件工程师时,每一个微小的细节都至关重要。
你必须理解:
- 内存分配是如何工作的。
- 指针是如何工作的。
- 数据结构是如何工作的。
因为如果你不理解这些,并且错误地分配了两次内存,或者在某个时刻忘记释放内存,那么你的自动驾驶汽车最终可能会“自杀”。
感谢你坚持学习到这里,希望你能够真正重视刚刚学到的知识。
挑战任务:优化你的直方图滤波器
现在,你已经学习了C++的一些底层特性。真正的挑战来了:你能让你的直方图滤波器运行得多快?
我向你发起挑战:运用你学到的所有特性,编写出尽可能快的C++实现。
当你找到最优方案后,请前往Slack频道:
- 告诉大家你的解决方案有多快。
- 与他人比较代码。
因为,真正的乐趣就在于此。
本节课总结:我们一起学习了一个因时钟同步和数据处理错误导致自动驾驶汽车严重故障的真实案例。这个案例深刻揭示了在C++编程中,深入理解内存、指针和时间处理等底层细节对于构建安全、可靠系统的重要性。最后,我们提出了一个优化挑战,鼓励你将所学知识应用于实践,追求极致的代码性能。
026:直方图滤波器代码优化教程 🚗💻
在本教程中,我们将学习如何优化一个功能完整但效率不高的C++直方图滤波器代码。我们将应用课程中学到的优化技巧,通过减少内存读写、优化循环结构等方法,显著提升代码的运行速度。

项目概述 📋
本项目提供了一个已能正常运行的C++直方图滤波器代码。代码功能正确,但存在多处效率低下的问题。你的任务就是应用所学的优化知识,让这段代码运行得更快。项目本身不计分,但提供了一个最终解决方案供你对比。完成项目后,欢迎到Slack的C++频道分享你的成果,与其他学员一较高下。
项目结构与说明 🗂️
打开项目后,你会看到两个文件夹:
Andy_histogram_filter: 包含原始的、未优化的代码。optimized_code: 用于放置你优化后的代码。
我们提供两份相同代码,是为了让你始终保留一份原始版本,方便对比。此外,还有一个 hints.md 文件,在你遇到困难时提供优化思路。
main.cpp 是程序的入口。它定义了迭代次数 iterations = 10000,并依次测试 sense、blur、normalize、move 等函数的运行时间。你的工作就是深入这些函数对应的文件,找出并解决导致程序缓慢的原因。
以下是编译和运行代码的命令:
cd Andy_histogram_filter
g++ -std=c++11 *.cpp -o a.out
./a.out
优化顺序建议 🔧

我们建议按以下顺序对文件进行优化:
zeros.cppinitialize_beliefs.cppsense.cppblur.cppnormalize.cppmove.cpp
这个顺序的依据是:zeros.cpp 代码量小,优化简单,适合热身;并且 blur.cpp 等函数会调用 zeros.cpp,先优化它能为后续优化带来即时收益。

优化实战:以 zeros.cpp 为例 🛠️
上一节我们介绍了项目的整体情况和优化顺序,本节中我们来看看如何具体优化第一个文件 zeros.cpp。
zeros.cpp 的功能是初始化一个指定高度和宽度的二维零向量。原始代码中可能存在效率问题。
打开 zeros.cpp,你可能会看到类似以下的提示:
- 优化:为向量预留内存空间
- 优化:不需要嵌套循环,因为矩阵的每一行完全相同
基于第一点提示,我们可以进行优化。在C++中,如果事先知道向量的大小,使用 reserve() 方法预先分配内存可以避免向量在动态增长时多次重新分配和复制数据,从而提升性能。
优化前的代码可能没有预留空间。优化后,我们可以这样做:
std::vector<std::vector<float> > newGrid;
newGrid.reserve(height); // 为外层向量预留空间
std::vector<float> newRow;
newRow.reserve(width); // 为内层行向量预留空间
for (int i = 0; i < height; ++i) {
newRow.clear();
for (int j = 0; j < width; ++j) {
newRow.push_back(0.0);
}
newGrid.push_back(newRow);
}
重要:每次修改后都必须测试! 优化不能只凭感觉。编译并运行代码,比较优化前后的耗时。你会发现,即使只是这样一个简单的改动,也可能让调用 zeros 函数的 move 操作显著提速。
核心优化流程 🔄
对于每个需要优化的文件,请遵循以下流程:
- 分析代码:仔细阅读代码,结合文件中的提示,思考可能的瓶颈(如不必要的变量、未预留内存的向量、低效的嵌套循环等)。
- 实施修改:应用你认为可行的优化策略。
- 编译测试:重新编译并运行程序,记录耗时。
- 对比验证:与修改前的运行时间进行对比,确认优化是否有效。有时修改可能适得其反,测试是唯一的检验标准。
以下是其他需要注意的文件:
blur.cpp,initialize_beliefs.cpp,move.cpp,normalize.cpp,sense.cpp: 这些是主要的优化目标文件,内部都有明确的优化提示。print.cpp和main.cpp: 你不需要修改这两个文件。main.cpp负责测试,print.cpp是提供的调试工具。
项目资源与总结 🏁

在本教程中,我们一起学习了如何分析并优化一段既有的C++代码。我们了解了预留内存、简化循环等基础但有效的优化手段,并强调了“修改-测试-验证”这一核心工作流程。

项目还提供了额外资源:
- 提示文件 (
hints.md): 提供更详细的优化思路。 - 解决方案: 在项目界面的“Project Solution”部分,我们提供了一个优化后的代码版本,其中用注释标明了所有修改点。建议你先独立完成优化,满意后再进行参考。

完成优化后,你可以获得巨大的成就感,并深刻理解如何通过洞察CPU和内存的工作方式,用一些小改动换来程序性能的大幅提升。祝你优化愉快,并在Slack上晒出你的傲人成绩!
027:无人驾驶汽车纳米学位项目-十位大牛亲授自动驾驶技术,硅谷前沿科技(计算机视觉⧸车道识别⧸感知控制⧸人工智能⧸谷歌) p27 27. Part 06-Module 01-Lesson 01_How to Solve Problems

在本节课中,我们将要学习如何系统地解决复杂的编程问题。我们将通过一个具体的实践案例——“计算两个日期之间的天数”——来学习一套通用的问题解决框架。这套方法将帮助你理解问题、分解问题,并最终编写出正确且高效的代码。
1️⃣:理解问题


上一节我们介绍了本节课的目标,本节中我们来看看解决问题的第一步:理解问题。
在开始编写任何代码之前,我们必须确保自己完全理解问题。所有计算问题都包含输入和期望的输出。一个问题的解决方案,就是一个能够接受任何有效输入并产生符合预期关系的输出的过程。
对于我们的案例,输入是两个日期(生日和当前日期),输出是这两个日期之间的天数。
以下是理解问题的关键步骤:
- 明确输入:输入是两个日期,每个日期由年、月、日三个整数表示。我们假设第二个日期不早于第一个日期(即“没有时间旅行”),且日期都是公历中的有效日期。
- 明确输出:输出是一个整数,代表两个日期之间的天数。我们选择“返回”这个数字,而不是“打印”它,这样结果可以被用于后续计算。
- 通过例子验证理解:通过手动计算几个简单例子,可以验证我们对输入输出关系的理解是否正确。
2️⃣:构思解决方案
上一节我们明确了问题的输入和输出,本节中我们来看看如何构思一个解决方案。
在理解了问题之后,下一步是思考如何以系统化的方式解决问题。我们可以先尝试像人类一样手动计算。
例如,计算2013年1月24日到2013年6月29日之间的天数。我们可能会这样算:1月剩余7天,加上2月全月28天,加上3月31天,加上4月30天,加上5月31天,再加上6月的29天。总和是156天。
基于这个思路,我们可以写出一个初步的算法(伪代码):
days = 当月总天数 - 起始日
while 当前月份 < 目标月份:
days += 当前月份的天数
当前月份 += 1
days += 目标日
然而,这个算法存在很多未处理的特殊情况(如同一个月、跨年、闰年等),会使得代码变得复杂且容易出错。
3️⃣:追求简单方案
上一节我们构思了一个初步但复杂的算法,本节中我们来看看一个更简单、更“笨”但更可靠的方法。
对于计算机而言,最直接的方法往往是最可靠的。我们可以从一个日期开始,一天一天地往后数,直到到达第二个日期,每数一天计数器就加一。
伪代码如下:
days = 0
while date1 < date2:
date1 = next_day(date1) # 跳到下一天
days += 1
return days
这个方法对人类来说很繁琐,但对现代计算机来说完全可行。即使计算100年的间隔(约36500天),也只需要几十毫秒。在不确定是否需要优化性能之前,优先选择简单、易于实现和调试的方案。
4️⃣:分步实现与测试
上一节我们确定了一个简单的“逐日计数”方案,本节中我们来看看如何将其转化为代码,并采用分步开发的策略。
我们不应该一次性编写所有代码。相反,应该将大问题分解成小部分,逐个实现和测试。对于我们的方案,核心是next_day函数(计算下一天)和date_is_before函数(比较日期先后)。
以下是推荐的实现顺序:
- 实现
next_day函数(简化版):先假设每个月都是30天,快速实现一个能工作的版本。 - 实现
date_is_before函数:用于判断循环何时停止。 - 实现
days_between_dates函数:利用上面两个函数完成主逻辑。 - 用桩函数替换假设:创建一个
days_in_month函数(桩函数),总是返回30,并让next_day调用它。 - 完善
days_in_month函数:使其能返回正确的月份天数(暂不考虑闰年)。 - 实现
is_leap_year函数:判断闰年。 - 最终完善
days_in_month函数:结合闰年判断,正确处理二月的天数。
每一步完成后,都应编写相应的测试用例进行验证。这种增量开发的方式能让你快速定位错误,并随着每一步的成功获得信心。
5️⃣:课程总结
本节课中我们一起学习了解决复杂编程问题的系统方法。
我们通过“计算日期差”这个具体例子,实践了以下核心步骤:
- 不要慌张。
- 理解输入和输出。
- 通过手动计算例子来厘清关系。
- 构思一个简单、机械的解决方案(通常暴力法对计算机很友好)。
- 采用增量式开发,分步实现并测试。

掌握这套问题解决框架,将帮助你在未来面对任何编程挑战时,都能有条不紊地分析、拆解并最终攻克它们。解决问题的技能需要终身练习,希望本节课的内容能成为你一个良好的起点。
028:数据结构 🧱
在本节课中,我们将要学习两种新的Python数据结构:字典和集合。我们将探讨它们与列表的区别,理解如何根据数据的特性选择合适的数据结构,并学习如何运用它们来编写更易读、更高效、更简洁的代码。
从列表到更多选择
到目前为止,在纳米学位项目中,你已经大量使用了Python列表。你曾用列表表示一维和二维世界,以及矩阵和向量。在后续的标准课程中,你还会看到图像也可以表示为列表。
列表非常有用,尤其是当它们包含的数据具有某种有意义的顺序时。
但列表并非你唯一可用的数据结构。在本课中,你将学习另外两种数据结构:字典和集合。学会正确使用这些数据结构,将使你编写的代码更易阅读、更易编写,并且运行效率更高。这些数据结构在编程面试中也经常出现,因此多了解它们总是有益的。
理解数据结构的意义
在深入探讨新数据结构之前,我们先来理解“数据结构”的含义。
当你加入一个自动驾驶汽车团队时,你最终需要进入车辆并实际观察其驾驶行为。当车辆出现任何意外情况时,你可能需要提交一份“工单”。
在实际工作中,这通常是在电脑上完成的。但思考一个模拟的工单追踪系统,有助于我们理解数据结构的概念。
在这个模拟系统中,工单是一个物理实体。如图所示,工单包含多个字段:日期、优先级和描述。
填写完工单后,你需要将其归档,可能放入一个存放待处理工单的盒子中。这个盒子很可能按日期排序,最旧的工单在后面,最新的在前面。因此,你可以直接将新工单放在盒子的最前面。
需要注意的是,选择按日期顺序将工单存储在单个盒子中,是一个设计决策。这是规划此工单追踪系统时做出的决定,但并非唯一的设计方式。
设计权衡与思考
现在,请思考这个特定设计及其带来的一些权衡。
我们之前讨论的工单系统使得查找最旧的工单变得非常容易。我们只需取出盒子最后面的工单即可。这是该系统的一个优点。处理工单的人可能希望按时间顺序处理问题,以确保最旧的问题优先得到解决。
但是,如果我们想优先处理最重要的问题呢?该系统没有提供任何好的方法来实现这一点。
因此,请花些时间思考你将如何设计这样的系统。
当我思考这个问题时,我想到了三个方案。我考虑了它们的优缺点,并最终选定了我认为最好的一个。接下来,我将带你了解我的思考过程,因为我们在设计工单系统时的思考方式,与编程时选择数据结构的思考方式几乎完全相同。
方案一:按优先级分袋
我的第一个尝试是直面问题:如果我只关心工单的优先级而不关心其他,那么我可以准备三个袋子。
填写完每张工单后,我只需将其扔进相应的袋子。例如,高优先级、中优先级或低优先级。
这并非一个糟糕的解决方案。事实上,如果工单的优先级确实是我们唯一关心的数据结构,那么这是一个很好的方案。但很可能并非如此,我们可能也关心工单的提交时间。
方案二:插入时按优先级排序
这种想法引出了第二个方案:在插入时按优先级排序。
在这个方案中,我们重新使用一个盒子。但现在的目标是确保这些工单首先按优先级排序,然后按日期排序。因此,所有高优先级工单都在后面,中间是中优先级工单,前面是低优先级工单。
现在,让我们思考如何使用这个系统。例如,当我去提交一份高优先级工单时,我首先需要找到它应该放置的位置。在这个例子中,我需要搜索盒子中已有的所有工单,直到找到第一份高优先级工单,然后我必须将这份新工单放在它的前面。
起初,我确实很喜欢这个解决方案。但提交工单所需的额外时间开始让我感到困扰。当我在车辆中时,我应该专注于车辆,而不是提交工单。因此,当我提交工单时,这个过程应该尽可能快。
方案三:多个盒子(最终选择)

这引出了第三个方案,也是我最终选择的方案:使用多个盒子。

在这个方案中,我将一个工单盒子替换为三个盒子,每个优先级一个。例如,一个低优先级盒子、一个中优先级盒子和一个高优先级盒子。为了保持整洁,我将这三个盒子放在一个小箱子内。
现在,当我想提交工单时,我只需将其放在相应盒子的最前面,无需翻阅旧工单。
从物理世界回到数字世界
到目前为止,我们一直在讨论使用盒子和袋子在物理世界中存储多个工单的系统。现在,我们将回到数字世界,思考如何表示单个工单。
请记住,物理工单看起来像这样。但我们想讨论数字工单,这意味着我们只关心这张工单包含的数据,在本例中是文本。
但这不仅仅是文本,使用字符串在这里感觉并不合适。因为这里实际上有两种不同类型的文本。在左侧,我们有各个字段的名称:每个工单都有名为“日期”、“优先级”和“描述”的字段。而右侧的文本则因工单而异。
此时,我想指出关于这些数据的一点:正如我所展示的,日期的字符串出现在优先级之前,优先级又出现在描述之前。但这种排序背后并没有真正的意义。事实上,我们同样可以很容易地将描述放在第一位。
当数据本身没有任何固有的顺序时,这通常(但不总是)意味着我们应该使用列表以外的数据结构。
在下一节中,我们将探讨与各种数据结构相关的权衡。最终,我们将确定使用Python字典作为存储无序但相关数据的首选方式。
字典:键值对结构
你刚刚看到了字典如何通过允许我们使用像“日期”或“优先级”这样的键来访问数据,从而提高代码可读性并帮助我们避免错误。这个键与一个值相关联。因此,工单的“描述”键可能与值“车辆在绿灯时意外停止”相关联。
集合:仅存储键
但有时,我们实际上并不希望将键与任何值相关联。有时键本身就足够了,例如,如果我们想为工单添加标签。
让我展示一下Udacity实际上如何使用名为Jira的工单追踪软件来处理工单。
这是Jira的“创建问题”对话框,他们使用的“问题”一词就像我们一直使用的“工单”一样。如你所见,一个问题有一个关联的项目、一个问题类型(在本例中,我们称之为“缺陷”)、一个摘要(在本例中,可能是“所有功能都坏了”)。
这是一个相当严重的缺陷。我将忽略这里的一些字段,但我会将其优先级设为“最高”。让我们给它添加一些标签。也许我认为这个问题出在Python代码中,所以我输入“pyt”,我可以看到自动补全建议“Python”作为标签,我可以点击它。也许我还想给它添加“book”标签。这也是一个建议。
现在,让我们看看如果我不假思索地尝试再次添加“Python”会发生什么。没有匹配项。不知何故,Jira确保这个数据结构不会包含重复的相同标签。
在Python中,有一种数据结构正是做这件事的,它叫做“集合”,这就是我接下来要教你的内容。

但在我们继续之前,让我们创建我们的问题并点击这个Python标签。如你所见,这是Jira支持的功能,它向我展示了所有带有“Python”标签的内容,并显示了每个关联了该标签的问题。
在下一个笔记本中,你将为我们一直在研究的工单追踪器实现类似的功能,在此过程中,你将学习一种新的Python数据类型:集合。
总结与鼓励
选择一个好的数据结构来解决难题,实际上需要你做好很多事情。首先,你必须很好地定义问题。然后,你必须了解你可用的数据结构,如列表、字典和集合,以及它们的权衡和局限性。接着,你必须选择一个好的数据结构来实际表示问题,以便你能编写一个好的算法来解决它。
这需要很多练习才能变得容易。
随着你继续学习这个纳米学位,我真诚地鼓励你积极思考你编写的算法和你选择使用的数据结构。当你感到不确定时,与他人讨论这些事情非常有帮助。因此,请使用纳米学位的Slack频道,提出问题,并参与关于你正在学习的所有新概念的讨论。
029:搜索问题

在本节课中,我们将要学习问题求解的理论与技术,特别是如何构建能够提前规划以解决问题的智能体。课程的核心在于理解搜索问题,其复杂性源于存在大量可能的状态,智能体需要从中选择一系列正确的动作序列来达成目标。
问题定义
上一节我们介绍了搜索问题的概念,本节中我们来看看如何形式化地定义一个搜索问题。一个搜索问题可以分解为以下几个核心组件:


- 初始状态:智能体开始时所处的状态。在路线查找问题中,初始状态是智能体位于城市Arad。
- 动作函数:
actions(state)。该函数接收一个状态作为输入,返回在该状态下智能体可以执行的所有可能动作的集合。在路线查找问题中,动作取决于当前所在的城市。 - 转移函数:
result(state, action)。该函数接收一个状态和一个动作作为输入,输出执行该动作后到达的新状态。例如,在Arad状态执行“沿E671公路前往Timisoara”的动作,结果状态是Timisoara。 - 目标测试函数:
goal_test(state)。该函数接收一个状态,返回布尔值(True或False),判断该状态是否为目标状态。在路线查找问题中,只有位于Bucharest市的状态会返回True。 - 路径成本函数:
path_cost(path)。该函数接收一个由状态-动作转移构成的路径(序列),返回一个表示该路径总成本的数字。通常,路径成本是各步骤成本的累加和,因此我们常用一个步骤成本函数step_cost(state, action, result_state)来定义单次行动的成本,例如行驶的公里数或分钟数。


搜索空间与算法框架
理解了问题的构成后,我们需要在由所有可能状态构成的状态空间中进行导航。搜索算法通过在状态空间上叠加一棵搜索树来工作。
以下是搜索算法的通用框架,称为树搜索:
function tree_search(problem):
frontier = [Path(problem.initial_state)] # 初始化边界,仅包含初始状态路径
while frontier is not empty:
path = remove_choice(frontier) # 从边界中选择一条路径(选择策略不同,算法不同)
s = path.end_state
if problem.goal_test(s):
return path # 找到目标,返回路径
for action in problem.actions(s):
new_path = path.extend(action, problem.result(s, action))
add new_path to frontier # 扩展路径,将新路径加入边界
return failure # 边界为空,无解
这个框架是一个算法家族,其核心区别在于从边界(frontier)中选择下一条扩展路径的策略。
无信息搜索算法
首先,我们来看几种不利用问题领域特定知识的搜索算法,它们仅根据路径的基本属性(如长度或成本)进行选择。
广度优先搜索
广度优先搜索总是优先扩展边界中最短(步数最少)的路径。





- 特点:如果解存在,它能找到步数最少的解(对于步数成本最优)。在状态空间无限但解位于有限深度时,它也是完备的。
- 空间复杂度:在最坏情况下,需要存储与搜索树最宽层节点数成比例的空间,对于分支因子为b、解在深度d的情况,约为 O(b^d)。


深度优先搜索
深度优先搜索总是优先扩展边界中最长的路径,即尽可能深入搜索树的一条分支。

- 特点:不能保证找到最短路径(非最优)。在存在无限路径的状态空间中,可能永远找不到解(不完备)。
- 空间复杂度:优势在于空间需求小,只需要存储从根节点到当前叶节点的路径,约为 O(b*m),其中m是最大搜索深度。


一致代价搜索( cheapest-first )

一致代价搜索总是优先扩展边界中总路径成本最低的路径。它使用步骤成本函数 step_cost 来计算累计成本。
- 特点:当所有单步成本为非负时,它能保证找到总成本最低的解(最优)。它也是完备的。
- 搜索方式:它的搜索边界类似于在地图上按成本从低到高画出的“等高线”,逐步向外均匀扩展,直到触及目标。


有信息(启发式)搜索算法
无信息搜索在大型状态空间中可能效率低下。为了更有效地导向目标,我们可以利用问题领域的知识,即启发函数 h(state),它估计从某个状态到目标的剩余成本。
贪婪最佳优先搜索

贪婪最佳优先搜索总是优先扩展边界中启发值 h(state) 最小(即看起来离目标最近)的路径。
- 特点:通常能快速找到解,因为它直接朝向目标努力。但它不保证找到最优解,甚至可能因为局部最优而陷入困境(例如遇到障碍时选择绕远路)。

A* 搜索
A* 搜索结合了路径实际成本和对目标距离的估计,它扩展边界中 f = g + h 值最小的路径,其中:
g(path)是从起点到当前状态的实际路径成本。h(state)是从当前状态到目标的估计成本(启发值)。

- 特点:在启发函数
h可采纳(即永不高估到达目标的实际成本)且一致的条件下,A* 搜索既能保证找到最优解,又通常比一致代价搜索更高效。 - 为什么有效:
g保证了路径的成本最优性倾向,h将搜索导向目标,两者结合实现了在保证最优性的前提下最小化搜索范围。

启发函数与可采纳性
一个好的启发函数能显著提升A*的效率。例如,对于15数码问题:
h1= 错位方块数:可采纳,因为每个错位方块至少需要移动一次。h2= 曼哈顿距离和(每个方块到其目标位置的水平和垂直距离之和):也可采纳,且通常比h1更准确(值更大),能引导A*扩展更少的节点。
我们可以通过松弛问题的方法自动生成可采纳的启发函数,例如移除原问题定义中的某些约束条件(如“目标方格必须为空”),从而得到一个更容易求解的问题,其最优解成本即为原问题的一个可采纳启发值。
搜索的适用范围与实现
搜索技术非常强大,但它适用于满足以下条件的规划问题:
- 完全可观测:智能体能获知初始状态。
- 已知领域:智能体知晓所有可用的动作。
- 离散:状态和动作的数量是有限的。
- 确定:执行动作的结果是确定的、可知的。
- 静态:除了智能体自身的动作,没有其他因素会改变世界状态。
算法实现要点


在计算机中,我们使用节点数据结构来表示路径:
class Node:
state: # 路径末端的状态
action: # 导致此状态的行动
cost: # 到达此状态的总路径成本
parent: # 指向父节点的指针
主要的数据结构包括:
- 边界:通常实现为优先队列(用于快速获取最佳节点)并结合集合(用于快速检查成员资格)。
- 已探索集:通常实现为一个集合(哈希表或树),用于记录已访问过的状态,避免重复探索。


总结
本节课中我们一起学习了搜索问题的核心框架与多种算法。我们从形式化定义问题开始,了解了状态空间和搜索树的概念。接着,我们探讨了广度优先、深度优先和一致代价这三种无信息搜索策略,分析了它们的最优性、完备性和空间需求。然后,我们引入了启发式信息,学习了贪婪最佳优先搜索和强大的A*搜索,理解了可采纳启发函数对保证A*最优性的关键作用,以及如何生成启发函数。最后,我们明确了搜索技术适用的五大条件,并简要了解了搜索算法在计算机中的实现方式。掌握这些搜索原理,是构建能自主规划的问题求解智能体的重要基础。
030:实现路径规划器 🧭
在本节课中,我们将学习如何将A算法转化为计算机代码。上一节我们介绍了A算法的核心思想,本节中我们来看看如何具体实现它。
概述
A*算法由Peter Hart、Nils Nilsson和Bertram Raphael等人在斯坦福大学开发。它是一种高效的最短路径搜索算法。现在,我们的挑战是将这个算法编写成可运行的代码。
算法回顾与实现思路
A*算法的核心是评估函数 f(n) = g(n) + h(n),其中:
- g(n) 是从起点到节点
n的实际代价。 - h(n) 是从节点
n到目标的预估代价(启发函数)。
我们的目标是编写代码,在给定的路网图中找到从起点到终点的最优路径。
以下是实现A*算法所需的关键步骤:


- 初始化数据结构:我们需要一个开放列表(优先队列)来存储待探索的节点,一个关闭列表来记录已探索的节点,以及一个字典来记录每个节点的父节点和累积代价。
- 将起点加入开放列表:起点的
g值为0,f值为h(start)。 - 进入主循环:只要开放列表不为空,就继续执行。
- 选择当前节点:从开放列表中取出
f值最小的节点作为当前节点。 - 检查是否到达目标:如果是,则回溯构建路径并返回。
- 处理相邻节点:对于当前节点的每一个邻居,计算其临时的
g值和f值。 - 更新节点信息:如果该邻居是首次被发现,或者找到了一条到达它的更优路径(
g值更小),则更新其g、f值和父节点,并将其加入开放列表。 - 将当前节点移入关闭列表:表示已探索完毕。
总结

本节课中我们一起学习了A*算法的代码实现框架。你需要理解如何将算法的每一步逻辑转化为具体的编程指令,包括管理开放/关闭列表、计算代价函数以及回溯路径。成功实现后,你将能够为自动驾驶汽车等应用编写基础的路由规划器。
031:实现路径规划器2 🧭
在本节课中,我们将回顾并总结路径规划器的实现过程。你已经成功构建了一个类似谷歌地图的路径规划系统,这是一个重要的成就。
课程概述
上一节我们介绍了路径规划器的核心算法与数据结构。本节中,我们将对完成的项目进行总结。
项目成就总结
你已经出色地完成了构建谷歌地图式路径规划器的任务。
以下是对你工作的评价:
- 你完成了一项杰出的工作。
- 如果你在15年前谷歌地图尚未出现时完成此项目,你或许可以创立一家公司并将其出售给谷歌。
- 这确实是一项伟大的成就。
课程总结
本节课中我们一起回顾了你实现的路径规划器项目。你掌握了将图论算法应用于实际地理路径规划的关键技能,这是无人驾驶汽车感知与决策模块的重要基础。
032:里程计、速度计与导数
在本节课中,我们将学习车辆运动的基本概念,特别是如何通过测量位置变化来计算速度和加速度。我们将从里程计和速度计的工作原理入手,逐步引入微积分中的核心概念——导数,并理解它如何描述运动的瞬时变化率。
企业家与自动驾驶的愿景
我的名字是 Sha Maximoff,我是 Phantommatoto 公司的首席执行官。我创立 Phantommatoto 是为了为无人驾驶汽车提供远程操作服务。
我人生中有三样热爱的事物:游戏、通信和汽车。真正困扰我的是交通问题,我们都面临着通勤的难题。我认为自动驾驶汽车真正解决了这个问题。我对汽车、通信和游戏的热情,恰好在此交汇。
当时的情况是,我正坐在一个带有 VR 头显的赛车模拟器中。我突然意识到,如果能通过虚拟现实设备驾驶一辆真实的汽车会怎样?就像你有一个方向盘和踏板,如果你转动方向盘,汽车就会随之移动。这个想法能有什么用呢?这就是我们支持自动驾驶汽车并在它们需要帮助时提供远程协助的想法的起源。
这是远程操作控制台,是我们通过蜂窝信号远程控制汽车的方式。你可以看到我有三个屏幕,显示 180 度的视野,并且可以看到车内情况以观察乘客并与他们交谈。要控制汽车,我可以启动线控驾驶。
一旦汽车启动,你可以看到我对方向盘施加的任何动作都会转化为汽车的运动。这样,驾驶员就可以解放双手,利用通勤时间发送短信或电子邮件,成为社会中富有成效的一员,而不是仅仅坐在车里。
你可以看到 Ben 正在实时转动方向盘,即远程操作员。我们还有一个带有 GPS 位置的地图,可以进行导航。
我正在构建一项服务,并希望与所有制造这些汽车的大公司合作。因此,优达学城是进入这个行业、学习一切工作原理的基础知识、然后编写一些代码进行实践并了解挑战的绝佳方式。之后,我就可以立即围绕它创办自己的初创公司。

对我来说,自动驾驶汽车确实是下一个重大事件。
欢迎回来:里程计简介
欢迎回来,同学们。很高兴今天再次见到你们。我们来谈谈里程计。
里程计听起来很复杂,它是机器人整合内部测量数据(如车轮旋转等)以计算其行驶距离的能力。在深入探讨之前,我想和你做一个人类里程计实验。
请站起来,闭上眼睛。向前走 5 步。转身 180 度,再向前走 5 步。睁开眼睛。你成功回到原点了吗?
当你这样做时,你的大脑执行了里程计功能,它整合了来自肌肉和传感器的运动信息,从而判断你的位置。和我一样,你并不完美,可能有一点偏差。机器人也不完美。但机器人会持续从内部获取传感器数据,并计算它们行驶了多远。
机器人使用的数据略有不同,它们使用车轮的旋转、惯性测量数据。惯性测量包括磁力计、陀螺仪和加速度计。将这些数据整合成一个连贯的、关于我们行驶了多远的信念,正是下一课的全部内容。
车辆运动与控制入门
想象你闭着眼睛、堵住耳朵坐在一辆汽车的乘客座位上。你能知道关于自己运动的哪些信息?你可能能感觉到汽车何时快速加速、突然停止或急转弯。但这种知识的极限是什么?你能重建整个驾驶路径吗?如果能,如何做到?
本课程是关于车辆运动的速成课程。在这个过程中,你将探索这个确切的问题。同时,你也会学习微积分和三角学的基础知识,这些数学工具在自动驾驶汽车中被广泛使用。
我喜欢驾驶,但有时会觉得有点无聊。在我上次的公路旅行中,我决定记录一些关于我驾驶的数据。我在旅行开始时将行程里程表重置为 0,然后开始驾驶。
下午 2 点整,我注意到我的行程里程表刚好达到 30 英里。后来,在 3 点整,我再次查看,注意到里程表刚好达到 80 英里。
你能告诉我下午 2 点到 3 点之间我的平均速度是多少吗?
计算平均速度
计算下午 2 点到 3 点之间的平均速度。
平均速度等于行驶距离除以经过时间。用更数学化的术语来说,行驶距离由 Δx(位置变化)给出,经过时间由 Δt(时间变化)给出。
这个三角形符号是大写希腊字母 Delta,通常表示“变化”。当你看到像 Δx 这样的符号时,不应将其视为 Δ 乘以 x,而应将其视为一个名为 Δx 的变量。
计算 Δx 的方法是:最终位置减去初始位置。同样,计算时间变化 Δt。在这个例子中,就是 (80 - 30) / (3 - 2),即 50 / 1,也就是每小时 50 英里。
这应该很容易理解:要知道下午 2 点到 3 点之间的平均速度,我需要知道在那段时间内我移动了多远(Δx),以及实际经过了多长时间(Δt)。
关于这个计算,有几点需要说明。首先,我们在这里计算的是平均速度。这不是你看速度计时测量的速度,我们稍后会进一步探讨。其次,我想引入“位移”这个术语。我将交替使用“位移”、“行驶距离”或 Δx。
之前,我告诉了你 2 点和 3 点的里程数,你可以计算出我的平均速度为 50 英里/小时。在那个例子中,我们可以说在 1 小时间隔内对我的位置或位移进行了两次测量。
本节课和下一课我们将要探索的是,当我们将这个时间间隔变得越来越小时会发生什么。
位置与时间图
数据常常讲述一个故事,而一个好的图表可以帮助揭示这个故事。现在,我想向你展示我如何思考这些位置-时间图,以理解数据讲述的故事。
这里我们有两条轴。在这个例子中,时间是横轴(x 轴),位置是纵轴(y 轴)。首先,我们从故事的最简单版本开始,添加两个数据点:一个代表下午 2 点 30 英里的起始位置,另一个代表下午 3 点 80 英里的结束位置。
每当我看到这样的数据时,想象甚至实际绘制一条连接这些点的线通常很有帮助。这条线代表了如果汽车在整个时间段内以单一恒定速度行驶,那么在 2 点到 3 点之间的每一刻汽车所在的位置。
这样的线对于理解这些图表非常有帮助,因为一旦有了这条线,就更容易看到位置变化 Δx(即两点之间的垂直距离)和时间变化 Δt(即两点的水平距离)。正如你之前所见,你也可以通过 Δx 除以 Δt 来计算平均速度。
现在我想指出一点,这对微积分有着相当深远的影响。计算 V_平均 与计算这条线的斜率是相同的计算。如果你还记得代数,斜率是图表的垂直变化除以水平变化来计算。或者像我学的那样,斜率等于上升量除以前进量,在这种情况下就是 Δx 除以 Δt。
所以你可以看到,这条线的斜率给出了这两点之间的平均速度。当斜率更陡时,意味着平均速度更快。
我们必须小心记住,这条线实际上只是我们画出来的东西,它不一定反映整个旅程中汽车的真实位置。如果我们想知道更完整的故事,就需要更多的数据。
假设我们以 20 分钟为间隔进行采样,发现在 2:20,里程表读数为 40,在 2:40,读数为 70。再一次,我要画一些线来连接这些点。
现在,记住任意两点之间连线的斜率给出了这两点之间的平均速度,我立刻可以看出,在前 20 分钟,汽车的平均速度比接下来的 20 分钟慢得多,因为斜率没有那么陡。然后在最后 20 分钟,汽车的平均速度再次变慢。
这确实有助于使故事更清晰。如果我想要更好的理解,就需要以更短的时间间隔采样更多的数据,比如每 10 分钟或每 5 分钟。有了这么多数据,我们甚至不再真正需要连接线作为视觉提示。
现在我可以看到,平均速度似乎在 2:30 到 2:35 之间最大。我知道这一点是因为我看到这里的斜率最陡,这意味着这里的 Δx / Δt 最大。
从平均速度到瞬时速度
现在我们将向微积分再迈进一步。到目前为止,你已经看到了如何以不同的分辨率或时间间隔观察一次旅程。在早期的例子中,我们首先使用 1 小时间隔,然后是 20 分钟,依此类推。每次我们减少时间间隔,我们就更接近完整的故事。
你也看到了如何使用这些位置和时间的测量值来计算平均速度。但仔细想想,这是一种相当奇怪的计算汽车速度的方式。如果你想知道你开得多快,你总是可以看看速度表,因为速度表直接测量速度,而速度表显示的速度是你的瞬时速度。它是你在看仪表盘的瞬间行进的速度。
速度表非常有用,但出于本课的目的和学习一些关于微积分的知识,我需要在下一个笔记本中思考和探索的问题是:我们知道用速度表可以测量瞬时速度,但是否可能使用我们一直在讨论的位置和时间测量值来计算瞬时速度?如果可能,如何计算?如果不可能,我们能做些什么来尽可能接近地近似瞬时速度?
速度与位置的关系
到目前为止,我们讨论了很多关于速度和位置的内容,原因是这两个量以三种重要方式相互关联。
第一,速度是位置的瞬时变化率。我们在前面的例子中尝试近似瞬时变化率,即让 Δt 越来越小。我们看到,随着 Δt 越来越接近 0,我们对真实瞬时速度的估计越来越好。
第二,速度是位置函数切线的斜率。这意味着如果我们有一个位置-时间的连续图像,并且我们想知道某个点(比如这里)的速度,那么我们要做的是首先绘制一条所谓的切线,即在该点刚好擦过图像的一条线。
在这种情况下,下面的线不是切线,尽管它穿过了橙色点,但它没有擦过图像,而是直接穿过了它。该点的切线看起来更像这样,因为这条线刚好在橙色点擦过图像。这使得第二个陈述成为一个相当惊人的事实:当你有一个位置-时间图,并且你想知道任何时刻的瞬时速度时,你只需找到该时刻图像的切线并计算其斜率。
第三,速度是位置的导数。在微积分中,导数被定义为瞬时变化率。它告诉你一个变量(如位置)相对于另一个变量(如时间)如何变化。导数往往出现在我们关心各种量如何一起变化的情况下。
例如,当我将方向盘转动 1 度时,我的车轮会转动多少?或者我的加速度会增加多少?或者当我将传感器更新频率提高 10% 时,我的定位不确定性将如何变化?为了从数学上回答这些问题,你需要使用导数。
如果这些问题或这三个陈述还不太明白,别担心,我们将在本课的剩余部分继续探索导数。
现实世界中的数据与导数
在这个典型问题中,车辆的位置以这个漂亮的代数表达式和相应的图像形式给出给你。当你知道一个函数(如位置)的代数描述时,你可以做各种巧妙的事情来精确计算导数。所谓精确,我的意思是,你实际上可以找到另一个代数方程,给出你在任何一点的导数。
对于这个函数,导数结果是 -2t + 10。它作为图像看起来像这样。能够从位置的代数描述中得到像速度这样的东西的代数描述,这实际上非常惊人。
但问题是,在现实中,当你处理在现实世界中导航的真实汽车时,你永远不会得到这种运动描述。没有人会告诉自动驾驶汽车:“嘿,你接下来 10 秒的位置将由以下方程描述。” 在现实中,你将得到的是数据。
这些数据将来自你的加速度计、里程计、摄像头、激光雷达和雷达,而且它们不一定很规整。在本课的剩余部分,你将做的正是这件事。你将使用里程数据来实现一个速度计,然后是一个加速度计。
总结

恭喜!在本课中,你对导数有了一个简要的介绍,并看到了一个函数的导数既是该函数的瞬时变化率,也是图像切线的斜率。
在下一课中,我们将继续探索自动驾驶汽车的运动。再见。
033:加速度计、速率陀螺仪与积分 📊
概述
在本节课中,我们将学习如何从加速度数据出发,计算速度和位置的变化。我们将探讨积分这一数学概念,并了解其在处理自动驾驶汽车传感器数据(如加速度计和速率陀螺仪数据)中的应用。
从加速度到位置:反向推导
上一节我们主要使用了里程计数据,即关于行驶距离的数据。我们了解到,可以通过计算车辆位置相对于时间的变化率来求得车辆的速度。
在上一节的最后,我们看到可以对速度数据做完全相同的事情来获得加速度。计算这些变化率的过程被称为微分或求导。因此,位置的导数是速度,速度的导数是加速度。
理解导数意味着,只要你能获取位置数据,你就能计算出速度和加速度数据。
但是,如果我们从加速度数据开始,想要了解速度或位置呢?在本节中,你将通过学习计算加速度数据的积分来掌握具体方法。
理解运动量:单位分析
位置、速度和加速度都是相互关联的。我们已经看到速度是位置的导数,加速度是速度的导数。在深入微积分之前,理解加速度的实际含义很重要,一种方法是查看与这些量相关的单位。
- 位置:例如,可以用英里或公里来测量。但最常用的是米,缩写为 m。
- 速度:通常以公里每小时、英里每小时或更常见的米每秒(m/s)为单位。注意,这些单位在分子上是距离单位,分母上是时间单位。这是因为速度是行驶距离的导数,是距离相对于时间的变化率。
- 加速度:它是速度相对于时间的变化率。这意味着分子应具有速度单位,分母应具有时间单位。因此,加速度的单位是 米每秒每秒(m/s/s),也常写作 m/s²,读作“米每二次方秒”。
我个人更喜欢第一种写法,因为它很好地提醒了加速度的实际含义:速度相对于时间的变化率。这意味着加速度只是告诉你速度变化得有多快。

案例分析:电梯加速度
你刚刚绘制了一部电梯从底层向上运行并在两层楼后停止时的加速度-时间图。数据大致如下所示。

我想请你注意数据的五个区域:
- 第一部分:从我开始收集数据到电梯刚开始向上移动。
- 第二部分:加速度为正的时期。这是电梯加速到正常速度的阶段,此时你可能会感到胃部稍有不适。
- 第三部分:加速度在一段时间内基本为0。这是电梯以大致恒定的速度向上移动的阶段。
- 第四部分:加速度在几秒钟内变为负值。这是电梯实际减速的阶段。
- 第五部分:电梯停止后、但我关闭加速度计之前收集的所有数据。
区域2和区域4中的这两个凸起看起来非常相似。最明显的区别是一个为正,一个为负。除此之外,它们基本上像镜像。事实上,如果你计算这条绿色曲线下的面积并与这条红色曲线下的面积进行比较,你会发现它们完全相同,只是红色区域的面积在某种意义上是负的,因为它延伸到对应零加速度的水平线以下。

这并非巧合。在接下来的几个部分,我将向你证明,绿色曲线下的面积对应于该时间间隔内发生的总速度增加量,而红色曲线下的面积对应于该时间间隔内发生的总速度减少量。现在,我希望你再多思考一下这部电梯的运动。
积分的概念与符号
当你计算曲线下的面积时,你实际上是在进行积分。积分的数学符号是这个拉长的“S”形,通常在底部和顶部有所谓的积分限。

例如,如果 t₁ 是下限,t₂ 是上限,积分表达式为:∫(从 t₁ 到 t₂)f(t) dt。
积分是导数的逆运算。既然速度是位移的导数,这意味着位移是速度的积分。
让我们回到之前的图表。记住,这张图下的面积给出了总位移。现在,我想向你展示如何用积分符号来表达这个事实。
表达这一确切陈述的积分看起来像这样:Δx = ∫(从 2 到 4)100 dt。
解读方式如下:
- Δx:总位移,这应该很熟悉。
- 积分符号与积分限:表示从时间 t=2 积分到 t=4。
- dt:积分中总有一个“d某物”。这里的“某物”是 t,它告诉你积分限的含义(是时间 t=2 和 t=4,而不是 x=2 或 v=2)。
- 100:这里恰好是简单的 100,因为速度函数非常简单。如果速度函数更复杂,比如
v(t) = 50 + 20t,那么这里就会是50 + 20t。
一般来说,这个 v(t) 可以是任何函数。所以我们可以写出这个通用版本的积分,它适用于任何奇怪的速度函数。
如果现在感觉有点难以理解,这是正常的。我只是想向你介绍一种你将在整个自动驾驶汽车生涯中都会看到的符号。幸运的是,计算机让使用这种符号变得相当简单。
我们将稍后回到电梯的例子。首先,我想提醒你在这个纳米学位项目早期课程中看到的一些内容。
重温:面积即位移
早些时候,当你学习矩阵和运动模型时,你可能记得看到过一个类似这样的图表,这是一个速度(米/秒)与时间(秒)的关系图。
当时,Czanne 告诉你,如果你想计算 t₁ 和 t₂ 之间的位移或移动距离,你可以通过找到这条曲线下的面积来实现。
在这个例子中,如果 t₁ 是 2,t₂ 是 4,那么这个矩形的宽度就是 4 - 2 = 2 秒,高度就是 100 米/秒。
我可以使用公式 面积 = 宽度 × 高度 来计算面积。在这种情况下,就是 2 秒 × 100 米/秒。这样写出来,你会发现有趣的事情:秒 单位实际上抵消了,最后只剩下 200 米 这个面积。
现在你明白为什么我一直如此关注与运动相关的单位了。因为如果你看这个图表的坐标轴单位,垂直轴的单位是米/秒,水平轴的单位是秒。当你将这些单位相乘时,就只剩下米。
每当你计算积分时,你都会进行某种类似的乘法运算,并且你会查看图表坐标轴上的单位,以获取关于积分或曲线下面积实际代表什么的提示。
现在,我希望你思考几个涉及运动图和曲线下面积的问题。
数值积分:用循环近似面积
在接下来的练习中,你将学习一种技术,让你能够对任何单变量函数进行积分,而这一切只需要一个循环。
在你深入研究代码之前,让我简要解释一下你将要做的事情背后的理论。
假设你想对速度关于时间进行积分,但你的速度是由某个相当复杂的函数给出的(现实世界经常如此)。假设你想使用这个速度数据来计算从 t=1 到 t=5 的位移。


当形状不是矩形时,如何计算面积?诀窍是:你并不直接计算,而是进行近似。

让我们从一个非常糟糕的近似开始:我们假设开始时的速度是整个四秒时间间隔内的速度。那么,我们的面积就是高度乘以宽度。在这种情况下,高度是速度函数在 t=1 时的值,宽度是时间间隔 Δt,这里是 4 秒。
显然,这是一个非常糟糕的近似,它没有考虑这条线上方的所有面积。但没问题,让我们改进近似。

一种方法是将这个 Δt 为 4 秒的单个区间,分解为四个 Δt 为 1 秒的时间区间。

然后,对于第一个时间区间(从 t=1 到 2),我们画一个矩形,再次使用 t=1 时的速度作为整个区间的速度。接着,我们对 2 到 3、3 到 4 以及 t=4 到 5 重复这个过程。

这将给出曲线下实际面积的更好近似。但你可能注意到它并不完美:除了在前两个区间低估了,它在最后一个区间实际上高估了。

所以,虽然这是一种改进,但仍然不够好。如何解决这个问题?我们将图表切割成更小的矩形。例如,我们可以使用半秒的时间间隔。这肯定更好了,但我们还可以更小。

随着我们将这些时间间隔的宽度减小到越来越接近 0,我们将越来越接近曲线下的真实面积。
好了,我说得够多了。我对积分感到非常兴奋。现在,你何不自己动手试试呢?

扩展到角速度与航向
到目前为止,你已经看到如何对加速度计数据进行积分以获得速度变化,也看到了如何对速度进行积分以获得位置变化或位移。
位移和速度对自动驾驶汽车来说是重要的量,因此我们能够从加速度计数据计算它们是非常幸运的。但是,航向呢?自动驾驶汽车需要知道它指向哪个方向,而一种方法是跟踪它转了多少。
幸运的是,所有自动驾驶汽车上的惯性测量单元都包含所谓的速率陀螺仪,这些陀螺仪测量一种叫做角速度的量。在本节剩余部分及接下来的练习中,你将看到如何使用来自这些速率陀螺仪的角速度数据来跟踪汽车的航向。
现实挑战:传感器噪声与误差
至此,你已经计算了速度数据、加速度数据和角速度数据的积分。希望你对进行积分意味着什么建立了良好的直观感受。
现在,我要坦白一件事:我向你展示的一些“真实”数据实际上并不真实。事实上,我偶尔可能生成了虚假数据,以便你绘制的图表干净、规整,并且积分都有意义。
但在现实中,传感器从来都不是完美的,它们有噪声,并且常常存在偏差。事实证明,当你对不完美的数据进行积分时,你的误差往往会放大。
我不希望你学完这个纳米学位后认为,定位机器人只需要一个加速度计和一个速率陀螺仪。虽然这些工具有帮助,但它们也有自身的缺陷。在接下来的部分,你将积分真实数据,并探索这样做时出现的一些问题。
总结与展望
恭喜你完成本课!在上一课中,你看到了像位移这样的量的导数如何给出速度。在本课中,你看到了积分如何让你反向而行,从速度计算位移,甚至从加速度计算速度。
我希望此时你对积分是什么以及为什么它对理解自动驾驶汽车的运动有用有了概念性的理解。
但我也想说的是,如果你以前从未上过微积分课,或者即使上过,你现在可能感觉有点不知所措,这完全正常。微积分真的很难,传统的微积分课程要花几个月来覆盖积分。
随着你不断使用和讨论运动、变化率以及变化的累积,你会对导数和积分等概念越来越熟悉。现在,我建议你接受可能感受到的任何智力上的不适,并为下一个挑战做好准备,因为在下一课中,你将参加另一个惊人数学分支——三角学的速成课程。

034:二维机器人运动与三角学 📐
在本节课中,我们将学习如何利用三角学,将车辆的航向角和行驶距离分解为X和Y方向上的位移变化。这是后续项目中利用传感器数据重建车辆轨迹的关键技能。
概述
在之前的课程中,我们学习了如何将加速度计数据转换为行驶距离,以及如何使用陀螺仪数据确定航向。然而,当车辆以任意角度行驶一段距离后,如何精确计算其在X和Y方向上的位移变化呢?这正是本节课要解决的问题。我们将借助三角学,特别是正弦、余弦和正切函数,来完成这一任务。
从特定角度到一般情况
上一节我们介绍了当车辆朝向正东、正北等方向时,位移计算非常简单。本节中我们来看看,当车辆航向是任意角度时,情况会如何变化。
当车辆以某个角度(非坐标轴方向)前进时,其X和Y坐标的变化量(Δx和Δy)都将大于0但小于行驶距离D。为了精确计算这些值,我们需要找到一个直角三角形,并利用三角学知识。
首先,我们从一个具体的例子开始。假设车辆航向角θ为53.13度,行驶距离D为5米。在这个特定情况下,Δx恰好是3米,Δy是4米。这是因为它们构成了一个经典的3-4-5直角三角形。
这意味着,对于53.13度的航向,位移与坐标变化之间存在固定的比例关系:
- Δy = D * (4/5)
- Δx = D * (3/5)
然而,这只是针对一个特定角度。我们需要一个适用于任何角度θ的通用方法。
三角学基础:命名与比率
为了将问题一般化,我们首先需要建立直角三角形各边的命名约定。
在一个直角三角形中,我们通常只关注其中一个角,称为参考角(θ)。三条边的命名如下:
- 斜边:直角所对的边,也是三角形中最长的边。
- 对边:与参考角相对的边。
- 邻边:与参考角相邻的边(不是斜边的那条)。

基于这些边的长度,我们定义了三个核心的三角函数,它们都是参考角θ的比率:
以下是三个核心的三角函数定义:
- 正弦:对边与斜边的比值。
sin(θ) = 对边 / 斜边 - 余弦:邻边与斜边的比值。
cos(θ) = 邻边 / 斜边 - 正切:对边与邻边的比值。
tan(θ) = 对边 / 邻边
让我们用之前的53.13度三角形来验证:
- sin(53.13°) = 对边/斜边 = 4/5 = 0.8
- cos(53.13°) = 邻边/斜边 = 3/5 = 0.6
- tan(53.13°) = 对边/邻边 = 4/3 ≈ 1.333

应用三角学解决运动分解问题
现在,我们可以将车辆运动问题重新表述为三角形问题:已知直角三角形的参考角(航向角)和斜边长度(行驶距离D),求邻边(Δx)和对边(Δy)的长度。
三角学为我们提供了完美的工具。以下是系统性的解题步骤:

- 识别三角形:将车辆的位移视为直角三角形的斜边,航向角视为参考角θ。
- 标记已知与未知量:斜边长度D已知,需要求解邻边Δx和对边Δy。
- 选择正确的三角函数:
- 要求Δx(邻边),使用余弦函数:
cos(θ) = Δx / D - 要求Δy(对边),使用正弦函数:
sin(θ) = Δy / D
- 要求Δx(邻边),使用余弦函数:
- 进行计算:重新排列公式并计算。
Δx = D * cos(θ)Δy = D * sin(θ)
让我们通过一个例子来演示。假设车辆航向为65度,行驶了17米,求Δx。


解题过程如下:
- 参考角θ = 65°,斜边D = 17米,未知边是邻边Δx。
- 使用余弦函数:
cos(65°) = Δx / 17 - 重新排列公式:
Δx = 17 * cos(65°) - 计算
cos(65°)(注意计算器或代码应设置为角度制),约等于0.423。 - 最终计算:
Δx = 17 * 0.423 = 7.18米。

现在,请你自己尝试计算Δy。记住使用正弦函数:Δy = D * sin(θ)。
总结

本节课中我们一起学习了如何运用三角学分解二维运动。我们掌握了正弦、余弦和正切函数的定义,并学会了如何利用公式Δx = D * cos(θ)和Δy = D * sin(θ),从航向角θ和行驶距离D计算出X和Y方向的位移变化。
这项技能至关重要,你将把它与导数、积分知识结合,在本课程的最终项目中,利用车辆运动传感器的原始数据来重建其完整的XY轨迹。请注意,该项目比之前的项目更具开放性,且不强制提交。但我们强烈建议你尝试完成,如果遇到困难,欢迎在课程讨论区交流代码和想法。
祝你好运!
035:计算机视觉与分类
在本节课中,我们将要学习计算机视觉的基础知识,这是让自动驾驶汽车“看见”并理解周围世界的关键技术。我们将从图像的基本表示方法开始,逐步学习如何通过预处理、特征提取和分类算法来构建一个图像分类器。
我是 Danny Shapiro,我是 NVIDIA 汽车业务的高级总监。
NVIDIA 是一家技术公司,开发了许多不同的计算平台。具体来说,我们为自动驾驶汽车开发了一款人工智能超级计算机。
无论是汽车、班车、卡车,还是未来可能出现在我们道路上甚至天空中的任何其他车辆。
NVIDIA 并不制造汽车,但 NVIDIA 拥有自己的自动驾驶测试车队。
我们将其中的一辆先进机器人车辆命名为 BB8。

我们正在加利福尼亚州、新泽西州以及德国的街道上测试这些车辆。
我们与汽车行业合作已近二十年。事实上,每一辆汽车、卡车、飞机或其他交通工具,甚至消费产品,都是在 NVIDIA 的图形硬件和软件系统上设计的。
大约十年前,我们开始将技术引入汽车,开始研究信息娱乐系统、数字驾驶舱和平视显示器。
然而,最近,由于我们在人工智能领域扮演的角色,我们的技术被用于自动驾驶汽车。
因此,我们不再仅仅是将信息以像素形式输出到显示器上,现在也开始处理输入的信息,例如雷达、激光雷达和摄像头信息,所有这些都输入到 NVIDIA 系统中,我们正在使用人工智能来理解这些信息。
我们有一款专门为车辆设计的产品,名为 Drive PX。
这是一款人工智能超级计算机,其计算能力相当于超过 150 台 MacBook Pro,但体积非常小巧,大约只有车牌大小。
本质上,所有输入(来自摄像头、雷达、激光雷达和超声波等传感器)都进入 Drive PX,产生大量数据。因此,我们的处理过程必须接收所有这些信息并理解它们。
基本上,需要识别出哪些是其他车辆、哪些是行人、哪些是人行横道、哪些是交通标志。所有这些都必须在几分之一秒内完成。我们的摄像头每秒生成 30 帧图像,我们必须分析这些帧并理解车辆周围 360 度的完整环境。
GPU(图形处理单元)由 NVIDIA 于 1999 年发明。这是一种大规模并行处理器,与你在个人电脑中找到的 CPU(中央处理单元)不同。你可能听说过 CPU 被描述为双核或四核,这本质上意味着信息可以同时通过两条或四条“车道”流过 CPU。而另一方面,GPU 现在拥有超过 5000 个核心。想象一下,如果这是一条高速公路,那么我们现在拥有的不是双车道或四车道,而是 5000 条车道。因此,大量信息可以同时通过该处理器。
然后,我们能够将所有这些计算能力带入汽车中的 Drive PX,以便实时处理所有传感器数据。
Drive PX2 是 NVIDIA 多款产品之一,它们都使用深度学习。
因此,我们有用于汽车的系统,但其他开发人员可以在个人电脑上开发,可以在云端使用深度学习,或者如果你是一名爱好者或创客,可以使用我们名为 Jetson 的嵌入式设备。这种统一架构的好处在于,你可以在一个平台上开发,然后部署到任何平台上。因此,我认为当今的挑战在于让学生和行业开发新的算法、新的深度神经网络,并利用人工智能来构建一个专为车辆设计的完整自动驾驶汽车系统。
考虑到当今市场上的职位类型和人才的缺乏,我认为对于任何刚刚起步、能够通过学习课程来理解当今计算基础的人来说,都有很多机会。现在,自动驾驶挑战中有许多独特之处。因此,理解开发自动驾驶汽车整个计算流程中的复杂性,对于在这个行业找到工作至关重要。

现在,我们来到本课程中真正的魔法,也是我最喜欢的话题之一:计算机视觉,让自动驾驶汽车“看见”。
当你睁开眼睛时,你的大脑会神奇地理解什么是咖啡杯、什么是智能手机、谁是 Sebastian。你的大脑不会告诉你原始的像素信息,而是告诉你场景中的所有物体。
然而,不幸的是,自动驾驶汽车在这方面处于劣势。它们只有一个像这样的摄像头。摄像头会产生一张像这样的图像。图像是一个巨大的数字矩阵。左上角有一个像素,那是像素 (1,1)。然后它旁边是像素 (2,1),接着是像素 (3,1),依此类推,直到像素 (2024, 024) 或其他数字。每个像素都有颜色值,包含一定量的红色、蓝色和绿色。这就是你得到的所有信息。所以,你不会得到“哦,天哪,图像中这里显然有个反光板”这样的信息。你只会得到这个像素矩阵。而计算机视觉的全部意义就在于,从那个庞大的图像数据矩阵中提取出你能看到的物体。
想象一下,现在你得到了一张停车标志的图像,对于每个数字字段,你得到了正确的颜色编号,然后你必须说出停车标志在图像中的位置。不是你自己,不是你的大脑,不是你的视觉皮层,而是你的计算机软件。
你已经建立了坚实的编程和数据分析技能基础。在本节中,我很高兴向你介绍另一个自动驾驶汽车工具:计算机视觉。计算机视觉是像自动驾驶汽车这样的机器如何视觉感知世界并对其做出反应的方式。

想象一下,你正在驾驶一辆汽车,你必须使用你的感官来注意行人、其他汽车、骑自行车的人以及周围所有的道路和交通标志。
这与自动驾驶汽车观察世界的方式类似。它通过摄像头和其他传感器收集数据,然后利用这些输入来安全地导航和在世界中移动。
在本课中,我们将学习用于分析摄像头图像并从中提取重要信息的常见计算机视觉技术,例如不同物体的颜色或形状。我们还将简要讨论机器学习,以及如何将其与计算机视觉结合使用,为机器提供从数据中学习并识别图像模式的方法。让我们开始吧。
为了帮助我们学习计算机视觉技术和应用,我们邀请到了行业专家 Terran Ziae,他是自动驾驶汽车公司 Voyage 的联合创始人兼首席技术官。
你好,谢谢。Suzanne,非常高兴来到这里。谢谢你邀请我。是的,计算机视觉在 Voyage 这里非常重要。它在行业中无处不在,也非常关键。计算机视觉在这里的明显用途包括,例如,识别车辆所在的车道或检测车道线等。
然而,计算机视觉的一个很酷的地方在于,我们将要学习的这些技术不仅可以用于摄像头图像,还可以用于其他传感器生成的图像。事实上,只要你的数据具有我们所说的空间相干性,你就可以使用计算机视觉技术。
空间相干数据可以被认为是任何在空间上可预测变化的数据,例如声音。如果你靠近扬声器听声音,声音会很大,但你离得越远,声音就越小。因此,声音的音量可以给你提供空间信息。没错。因此,除了摄像头,自动驾驶汽车还使用声纳、雷达和激光雷达等传感器,这些传感器利用声波和电磁波来收集汽车周围环境的数据。接下来,让我们更仔细地看看这些传感器。
传统上,雷达用于长距离探测,而摄像头用于丰富的感官输入。在讨论自动驾驶汽车的传感器配置时,我们必须注意其中的细节。激光雷达和雷达被称为主动传感器。也就是说,它们基于能量发射来感知环境。而另一方面,摄像头是被动传感器。它们只能基于场景中已有的能量(光子)来感知环境。这些传感器细节对我们最终使用的算法类型有重大影响。
计算机视觉有许多强大的工具,但良好设计的一部分也意味着知道不应该做什么,即使它是可能的。此外,计算机视觉不一定只与摄像头图像相关联。你也可以用激光雷达传感器构建激光雷达图像,从而为你提供测量深度,同时还能对像素进行分类。以下是一些结果。
学习各种计算机视觉技术的一个好方法是构建一个图像分类器。我们在本课中将重点关注的是将图像数据分组到不同的类别中,例如人类、自行车和汽车,或者白天拍摄的图像与夜晚拍摄的图像。其中一些分类任务相当具有挑战性。
我们将探索两种主要的数据分离方法:通过编程显式规则将数据分组,或者通过机器学习技术自动分离数据。这两种方法都可用于构建图像分类器。那么,究竟什么是图像分类器呢?
图像分类器是一种算法,它接收图像作为输入,并输出一个标识该图像的标签或类别。例如,自动驾驶汽车中使用的一种分类器会查看不同的道路图像,并能够识别该道路是否包含人类、汽车、自行车等。根据其内容对每张图像进行区分和分类。
有许多类型的分类器用于识别特定物体,甚至行为,例如一个人是在走路还是在跑步。但所有这些分类器都涉及一系列相似的步骤。我将这些步骤称为图像分类流程的一部分。
首先,计算机从成像设备(如摄像头)接收视觉输入。这通常被捕获为一张图像或一系列图像。然后,每张图像都会经过一些预处理步骤,其目的是标准化每张图像。常见的预处理步骤包括调整图像大小或旋转以改变其形状,或者将图像从一种颜色转换为另一种颜色,例如从彩色转换为灰度。只有通过标准化每张图像(例如,使它们大小相同),你才能以相同的方式比较和进一步分析它们。
接下来,我们提取特征。特征帮助我们定义某些物体,通常是关于物体形状或颜色的信息。一些区分汽车和自行车的特征是:汽车通常形状更大,并且有四个轮子而不是两个。形状和轮子将是汽车的区分特征。我们将在本课后面更多地讨论特征。
最后,这些特征被输入到一个分类算法(也称为模型)中,该算法对图像进行分类。这一步查看来自上一步的任何特征,并预测,例如,这张图像是汽车、行人、自行车等。
你将手动编程实现这些分类步骤中的每一步,以便真正理解每一步。到本课结束时,你将拥有完成最终项目所需的所有技能:构建一个交通灯分类器,该分类器接收交通灯图像,并将它们分为三类:红灯、黄灯或绿灯。
你刚刚看到了一个图像分类器的完整分类流程,从一些输入图像开始,使用计算机视觉技术处理这些图像并提取特征(如图像中区分颜色或形状的特征),然后分类器查看这些特征并输出一个类别,即描述该图像的标签。
分类器应该预测具有相似形状或颜色的图像具有相同的类别。我们通常告诉分类模型要寻找什么。例如,假设我们正在查看一堆图像,我们想将它们分为两类:汽车和非汽车。为了对汽车进行分类,我们可能会编写一个程序来寻找汽车的不同部分:轮子、车灯、车窗等。然后,如果找到了这些东西,我们就会将图像分类为汽车。我们决定哪些特征是重要的。但是,还有另一种创建分类器的方法,那就是使用机器学习。
机器学习允许计算机通过给它大量示例来自行理解事物。因此,使用机器学习时,我们不是告诉模型要寻找哪些特征,而是只给它大量汽车和非汽车的图像,让它学会识别区分它们的特征。它可以学会识别轮子和车窗,以及哪种分类算法最适合准确地将任何给定图像分类为汽车或非汽车。
现在你可能想知道,这样的模型究竟是如何学会对不同的图像进行分类的呢?接下来,我们将看看机器学习技术如何被训练来对图像集进行分类。
当我们谈论机器学习时,你经常会听到深度学习和神经网络这些术语,这可能会让你联想到大脑或一些奇怪的数学公式和数据层图形。但在核心,所有这些学习技术都是关于将数据分离到不同的类别中。


这在我脑海中唤起的图像是一个孩子在沙滩上玩耍。孩子看到沙子里有一些蓝色和黄色的贝壳。然后有人对孩子说:“把这些贝壳分成几组,并在它们之间画一条线。”如果我不告诉你其他任何信息,你会如何分组这些贝壳?你可能会根据颜色和形状将它们分开,并在沙子上画一条线。
对于计算机来说,这个场景可能是我们给它单个贝壳的图像。就像一个孩子一样,神经网络可以根据给定示例中的相似性或差异来学习如何分离这些贝壳图像。在这个分离步骤之后,如果网络看到一张它以前没有见过的新图像,它会根据它落在线的哪一侧来进行排序和分类。
实际上,数据通常比这复杂得多,但神经网络只是在分离层之上叠加分离层,以创建更复杂的边界并对各种数据进行分组。接下来,我们将看看如何训练神经网络来分离数据。
你的第一个任务将是对一个二元数据集进行分类:白天或夜晚拍摄的图像。但在你能够完成这个任务之前,你首先必须了解机器是如何“看到”图像的。让我们以这张汽车图像为例。这实际上是一辆在路上的自动驾驶汽车,看看计算机是如何理解它的。
我们将首先处理这样的灰度图像,因为颜色增加了另一层复杂性。但正如我们很快将看到的,相同的一般原则也适用。所以,当我向你展示这张图像时,你可能会说:“哦,这是一张汽车的照片。”确实如此。但它也是一个二维数值网格,也称为具有宽度和高度的数组。让我告诉你我的意思。
这张以及所有数字图像都是由像素网格组成的,像素是单个颜色或强度的非常小的单位。如果我们放大汽车的图像,比如车轮周围的这个区域,我们可以更好地观察这些像素。现在你可以看到它开始看起来更像一个网格了。
这个网格中每个像素的颜色都有一个对应的数值。对于像这样的灰度图像,每个像素的值范围从 0 到 255。0 是黑色,255 是白色,灰色介于两者之间。因此,大约 120 的值是介于黑色和白色之间的中等灰色,而大约 20 的值将是非常非常深的灰色,接近黑色。这些像素除了具有颜色值外,在图像网格中还有一个位置 (X, Y)。这些轴很像图形的轴,只是对于数字图像,左上角的坐标位于原点,即点 x=0, y=0。
现在,我们的汽车图像高度为 427 像素,宽度为 640 像素。像素位置位于一个从索引 0 开始的网格上,从 0 到 369 列,从 0 到 426 行。例如,在位置 x=190, y=375 处,我们在图像左下角的这个车轮上有一个像素。该像素值为 28,一种非常深的灰色。你可能会问我是怎么知道的。嗯,在代码中,我们实际上可以通过位置找到任何单个像素值。让我们来做一下。
我们将读入我们的汽车图像。但首先,我将导入我需要的库。这包括 Matplotlib.image,它允许我们读入任何图像。你还会看到 CV2,这是一个计算机视觉库,你很快就会了解更多。我还将使用 Matplotlib Qt。Qt 使图像在我显示时弹出一个交互式窗口。
所以,我将使用 Matplotlib 的 imread 函数读入我们的汽车图像。我将传入我们的图像文件名。我的汽车图像位于与这个笔记本相同位置的 images 目录中。接下来,我将实际打印出关于这个图像的一些信息。我想通过引用 image.shape 来打印出它的尺寸。现在,我们可以看到它的高度和宽度(以像素为单位)。我们还看到另一个值 3,它对应于该图像具有的颜色通道数,我们很快就会了解更多关于这个值的信息。目前,我们将把图像转换为灰度。我们将使用我们的计算机视觉库将其转换为灰度。现在,请知道它有内置的颜色转换代码,例如将图像从红绿蓝颜色转换为灰度。然后我将显示这张灰度图像。
这会打开我们的交互式窗口。当我用鼠标划过这张图像时,你可以在屏幕左下角看到显示的 X、Y 位置,以及相应的像素值。在车轮附近这里,我们有大约 28、29 的暗像素值。而在天空这里,我们有一些亮的像素值。你可以看到大约 220 甚至更高。如果我们回到我们的笔记本,我们可以通过位置访问来打印出单个像素的值。我会说 x=190,y=375。我可以通过查看灰度图像中该位置的值来访问该像素值:gray_image[y, x]。最后,我将打印出该值。我们可以看到它是 28。
图像中的每个像素都只是一个数值,我们也可以改变这些像素值。我们可以将每个像素乘以一个标量来改变图像的亮度。我们可以将每个像素值向右或向左移动,以及进行更多操作。将图像视为数字网格是许多图像处理技术的基础。大多数颜色和形状变换只是通过对图像进行数学运算并逐个像素地改变它来完成的。
现在,你已经看到了一个被分解为二维灰度像素值网格的图像示例,该网格具有宽度和高度。但彩色图像有点不同。彩色图像被解释为具有宽度、高度和深度的三维数值立方体。深度是颜色通道的数量。大多数彩色图像可以仅由三种颜色的组合来表示:红色、绿色和蓝色值。这些被称为 RGB 图像,对于 RGB 图像,深度为 3。将深度视为三个堆叠的二维颜色层会很有帮助:一层是红色,一层是绿色,一层是蓝色。当它们堆叠在一起时,就创建了一个完整的彩色图像。
现在,彩色图像包含比灰度图像更多的信息,它们会增加不必要的复杂性并占用更多的内存空间。然而,彩色图像对于某些分类任务也非常有用。例如,假设你想对这张道路图像中的车道线进行分类。其中一条线是黄色的,一条是白色的,但哪条是哪条呢?在灰度图像中,你可能会看到车道线灰度强度有细微差别,但差异非常小,并且在不同的光照条件下会变化。因此,这张灰度图像没有提供足够的信息来区分黄色和白色的车道线。让我们看看彩色图像进行比较。在这里,我们可以清楚地看到白色和黄色车道线之间的区别。因此,我们也可以告诉机器识别这种差异。
所以,因为这个识别任务依赖于颜色,所以使用彩色图像很重要。一般来说,当你考虑计算机视觉应用(如识别车道线、汽车或人)时,你可以通过思考你自己的视觉来决定颜色信息和彩色图像是否有用。如果识别问题对我们人类来说在彩色中更容易,那么对算法来说,看彩色图像也可能更容易。
现在你知道了图像如何表示为数值网格,让我们谈谈分类流程中的下一步:预处理。预处理图像就是关于标准化输入图像,以便你可以沿着流程进一步前进,并以相同的方式分析图像。最常见的预处理步骤包括:第一,通过使用几何变换来改变图像的空间外观,这可以缩放图像、旋转图像,甚至改变物体看起来的距离。第二,改变颜色方案,例如选择使用灰度图像而不是彩色图像。
通过一个例子最容易看出这些变换是如何有用的。假设我们正在尝试构建一个交通标志分类器,用于识别两种类型的图像数据:停车标志和非停车标志。我们的分类流程可能如下所示:查看输入图像并计算图像中红色像素的数量。如果有很多红色像素超过某个阈值数字,比如 300 个像素,那么我们就将其标记为停车标志。但如果红色像素不够多,我们就将图像标记为非停车标志。现在,为了让这在任何图像上都能工作,我们需要有一个一致的颜色红色度量标准。我们不能只是在一张图像中累加灰度像素值,在另一张图像中累加 RGB 值,我们需要将这些图像转换为相同的颜色空间,并在分析每张图像时使用相同的红色像素度量标准。
让图像大小相同也很有用,因为我们计划逐个像素地累加红色值。因此,标准化每张输入图像的颜色和形状是实现最终目标(识别图像数据中的重要模式并对图像进行分类)的必要步骤。
我们了解了彩色图像如何用数值表示,接下来我想向你展示颜色如何在图像分析和变换中使用。我们将从学习如何使用图像中的颜色信息来隔离图像中的特定区域开始。这在一个例子中最容易理解。我们将使用颜色阈值选择一个感兴趣区域。颜色阈值在许多应用中使用,包括在计算机图形和视频中广泛使用。一个常见的用途是绿幕。绿幕用于基于识别和替换大面积的绿色区域来叠加两个图像或视频流。
那么这一切是如何工作的呢?第一步是隔离绿色背景,然后用你选择的图像替换那个绿色区域。这个任务看起来很简单。所以让我们看看如何通过编程用我们自己的图像来实现这一点。我们将从这张绿幕背景上的汽车图像开始。我们首先必须识别绿幕区域。然后,稍后,你将用背景图像替换它。我们在一个 Python 笔记本中。我的第一步是导入我需要的库,我们将在整个课程中使用这些库。
为了绘图和读入图像,我将导入 Matplotlib.pyplot 和 image。为了对图像进行操作,我将使用 NumPy 和计算机视觉库 OpenCV,它被命名为 CV2。接下来,你可以看到我使用 mpimg.imread 读入了汽车图像。这张图像位于与这个笔记本相同目录下的一个名为 images 的文件夹中。一旦读入,我实际上想打印出关于图像的一些信息。这些信息在你以后想要为绿色部分添加不同背景时会很有帮助。所以我会打印它的 shape,这将告诉我们图像的尺寸。我还会显示图像。我们看到了预期的输出。图像是一个数值数组,其形状包含三个值,代表图像数组的维度:首先是它的高度,450 像素;然后是它的宽度,660 像素;最后是它有多少个颜色分量,在这种情况下是 3,分别对应红色、绿色和蓝色值。
好的,现在我们有了绿幕图像,让我们开始绿幕遮罩。遮罩只是意味着遮挡图像的某个区域。为了创建一个遮罩,我将创建一个颜色阈值。我将定义要隔离的颜色(绿色)的下限和上限。所以我会定义一个下限阈值,包含仍被视为绿幕背景一部分的红色、绿色和蓝色的最低值。这将是一个包含三个值的数组,顺序为红色、绿色和蓝色值。对于红色和蓝色,我将它们设置为 0,这意味着在识别这个绿幕区域时,可以没有红色或蓝色。但绿色的最低值应该仍然相当高,不是完全达到 255,但假设是 200。然后我将定义上限阈值。我将定义它允许稍微多一点的红色和蓝色,比如各 50,并将绿色的最高值设置为 255。现在,任何介于这个低阈值和高阈值之间的颜色都将是强烈的绿色。
不过,我是在做一些估计,所以如果我发现这个范围没有找到我想要的绿幕区域,我会回来改变这些值。接下来,我将使用这些颜色阈值创建一个图像遮罩。遮罩是一种非常常见的隔离选定感兴趣区域并对该区域进行操作的方法。我们可以使用 OpenCV 的 inRange 函数在绿色区域上创建一个遮罩。
这个函数接收一个图像以及我们的下限和上限颜色阈值。
你已经看到了如何检测和遮罩绿幕背景。但这种检测依赖于一些假设才能工作。它假设场景光照非常好,并且幕布是非常一致的绿色。如果光照改变,或者背景部分处于阴影中或过曝变亮,会发生什么?在这种情况下,简单的 RGB 阈值效果不会很好。那么,我们如何在变化的光照条件下一致地检测物体呢?嗯,除了仅由红色、绿色和蓝色值组成外,还有许多其他方式来表示图像中的颜色。这些不同的颜色表示通常称为颜色空间。
RGB 是红绿蓝颜色空间。你可以将其视为一个三维形状,在这种情况下是一个立方体,其中任何颜色都可以由 R、G、B 值的三维坐标表示。例如,白色在这个角落,其红色、绿色和蓝色值均为 255。
还有 HSV 颜色空间(色调、饱和度、明度)和 HLS 颜色空间(色调、亮度、饱和度)。这两种空间将图像中的表观亮度和颜色分离到不同的分量中。例如,在 HSV 颜色空间中,明度(V)分量是亮度的良好度量。高值意味着非常亮,低值意味着非常暗。饱和度分量是颜色强度的良好度量。色调是表示某物实际颜色的度量,例如它是红色、绿色还是紫色等,并且在阴影或过度亮度下应保持相当一致。
因为这些空间将颜色和亮度分离到不同的通道中,所以它们是图像处理中最常用的颜色空间之一。接下来,我们将通过一个将图像转换为 HSV 颜色空间的例子来看看它们为什么如此有用。
我们将通过一个在 RGB 和 HSV 颜色空间中进行图像处理的例子。HSV 颜色空间隔离了图像中每个像素的明度(V)分量,这是在变化光照条件下变化最大的分量。色调(H)通道在阴影或过度亮度下保持相当一致。如果我们主要依赖这个通道并丢弃 V 通道中的信息,我们应该能够比 RGB 颜色空间更可靠地检测彩色物体,如绿幕背景。
所以在这个笔记本中,我导入了通常的资源并读入了一张 RGB 图像。这是我们的绿幕汽车。只是这次,它被不均匀地照亮,有阴影和不同亮度的区域。我将尝试遮罩这个绿色区域,同时查看 RGB 颜色空间和 HSV,看看每种情况下什么有效。现在,这张图像中的每个像素都有其位置的 X 和 Y 值,以及其颜色的 RGB 值。我要做的第一件事是重新创建你之前见过的 RGB 阈值。
所以,再次回顾这些步骤:我在 RGB 颜色空间中创建了下限绿色和上限绿色阈值。我使用 cv2.inRange 创建了一个遮罩。然后我实际上遮罩了图像中遮罩不等于 0(即白色)的部分。然后我将显示图像。现在,这使用了与之前情况完全相同的阈值。只是这次,你可以看到,由于阴影,这只遮罩了大约一半的绿幕背景。让我们看看我们是否能在 HSV 颜色空间中做得更好一些。
所以首先,我需要将图像转换为 HSV 颜色空间。我将使用 OpenCV 的 cvtColor 函数,这是一个颜色转换函数,我将使用转换代码 RGB2HSV。这将返回一个新的转换后的图像,我将其称为 hsv。然后,我将实际分离这些颜色通道中的每一个,以便我可以可视化它们。为了分离色调通道,我可以直接取图像数组:我将取前两个数组列中的所有 X 和 Y 值,然后取第三列的零索引,即每个像素的色调值。类似地,对于饱和度和明度,我将取图像像素的所有 X 和 Y 坐标,并取第三列的第一和第二索引,以获得每个像素的饱和度和明度分量。
然后,我可以用灰度绘制这些颜色通道中的每一个,以查看它们的相对强度。这是三个通道以灰度强度表示。较亮的像素分别表示较高的色调、饱和度或明度值。我们可以看到,即使在阴影中,色调水平也相当一致,但饱和度和明度分量变化更大,尤其是在阴影下。
现在,你知道如何创建颜色阈值和遮罩图像了。接下来,将由你利用这些信息以及你对 HSV 颜色空间的了解来遮罩这张图像。我不会向你展示我的确切值,但我确实使用了 HSV 颜色空间的色调通道来遮罩这张图像的绿色背景,你应该能够产生类似的结果。
现在,你正朝着能够构建一个完整分类器的方向前进。你知道如何分析给定图像中的颜色和亮度,仅这一项技能就可以帮助你区分,例如,红色的停车标志和绿色的信息标志。我要给你一个分类挑战。如果我要求你对在白天或太阳落山后的夜晚拍摄的两种类型的图像进行分类,你会怎么做?我想让你将这些图像分为两类:白天或夜晚。
这实际上是自动驾驶汽车面临的一个重要分类挑战。这些汽车需要知道它们正在什么条件下行驶,以便它们能够在一天中的任何时间安全地在道路上导航,并且仍然能够识别其他车辆和周围物体,无论外面是黑暗还是明亮。我们将一起完成每个分类步骤,但你认为创建白天和夜晚图像分类模型的第一步是什么?
在对任何图像集进行分类之前,你必须先查看它们。可视化你正在处理的图像数据是识别图像数据中任何模式并能够对该数据进行预测的第一步。因此,我们将首先加载这些图像数据,并了解一些我们将要处理的图像的信息。
在探索了白天和夜晚图像数据之后,你可能已经注意到了我们尚未讨论的数据的一部分:与每张图像相关联的标签。那么,究竟什么是标签,为什么我们需要它呢?标签有点像附加在特定图像上的标签,它告诉你关于该图像的某些信息。你可以把标签想象成一种名牌。我在结识新朋友的活动时会佩戴名牌,我的名牌将我标记为 Suzanne。现在,一张图像可以有多个描述它的标签,就像如果我有人类或戴眼镜等多个标签,每个标签都描述了我的某些方面。但对于本课,我们将处理每张图像一个标签。这些标签将图像数据分离成类别。类别就像一般分类。所以,我的标签可能是人类,这是一个与桌子、汽车或任何其他事物的标签不同的类别。它比像 Suzanne 这样的标签更通用。
因此,对于我们处理的图像数据集,我们应该拥有与类别一样多的标签。在我们的白天和夜晚图像的情况下,我们有两个标签:白天和夜晚。现在,为什么我们需要这些标签?你可以判断一张图像是夜晚还是白天,但计算机不能,除非我们通过标签明确告诉它。当我们测试分类模型的准确性时,这一点变得尤为重要。分类器接收图像作为输入,并应输出一个预测标签,告诉我们该图像的预测类别。
现在,当我们加载数据时,就像你看到的那样,我们加载的是所谓的真实标签。真实标签只是该图像的正确标签。为了检查分类器的准确性,我们比较预测标签和真实标签。如果真实标签和预测标签匹配,那么我们就正确分类了图像。但有时标签不匹配,这意味着我们错误分类了图像。在查看了许多许多图像之后,分类器的准确性被定义为正确分类的图像数量(即预测标签与真实标签匹配的图像)除以图像总数。假设我们尝试分类总共 100 张图像,我们正确分类了其中的 81 张,这意味着我们错误分类了 19 张。那将意味着我们有 0.81 或 81% 的准确性。只有当我们有这些预测标签和真实标签进行比较时,我们才能告诉计算机检查分类器的准确性。
我们还可以从分类器犯的任何错误中学习,正如我们将在本课后面看到的那样。需要注意的是,使用数字标签而不是字符串或分类标签是一种良好做法。数字更容易跟踪和比较。因此,对于我们的白天和夜晚二元分类示例,我们将使用数字标签 0 表示夜晚,1 表示白天。
好的,现在你熟悉了白天和夜晚图像数据,知道了什么是标签以及为什么使用它们。你已准备好进行下一步。我们将从头到尾构建一个分类流程。让我们首先集思广益,讨论一下我们将采取哪些步骤来对这些图像进行分类。
好的,101 特征提取。特征提取是你的大脑在你观察时一直在做的事情。所以,当图像进入你的视网膜时,你得到相同的颜色效果(不完全相同的矩阵,但暂时假设相同)。然后,你的视觉皮层(从眼睛后面一直到大脑后部的脑组织)会提取越来越高级的特征,直到认出这是 Sebastian,这是停车标志。在这些特征提取层中,你变得越来越抽象。你现在要从非常简单的开始,你将查看交通场景并提取简单的特征,比如是白天还是夜晚。你将被要求开发实现这一点的计算机软件,作为使自动驾驶汽车智能化的第一步。稍后,我们将给你交通灯。我们将让你从交通灯中提取特征。这不会是自动驾驶汽车的全部,但希望你能理解让汽车“看见”是多么美妙、富有想象力且伟大。
当你面对一个分类挑战时,你可能会问自己:我如何区分这些图像?这些图像有哪些特征可以区分它们?我如何编写代码来表示它们的差异?此外,我如何忽略这些图像中不相关或过于相似的部分?你可能已经想到了许多区分特征。白天图像通常比夜晚图像亮得多。夜晚图像也有这些非常明亮的小光点。因此,整个图像的亮度变化比白天图像大得多。白天图像中也有更多的灰蓝色调。
有许多可测量的特征可以区分这两种类型的图像,这些可测量的特征被称为特征。特征是一个图像或物体的可测量组成部分,理想情况下,在变化的条件(如变化的光照或相机角度)下是独特且可识别的。我们很快就会更多地了解特征,但我们有点超前了。要从任何图像中提取特征,我们必须对它们进行预处理和标准化。接下来,我们将看看在能够一致地提取特征之前应该采取的标准化步骤。
构建白天和夜晚图像分类器的第一步是可视化输入图像并将它们标准化为相同大小。为此,我们导入了我们通常的资源并加载了图像数据集。我们创建了一个包含所有图像及其标签的标准化列表。最后,我们可以可视化标准化后的数据。在这里,我选择了我们标准化列表中的第一张图像及其标签。然后我显示选定的图像及其一些信息。你可以看到它的尺寸和它的标签(1 表示白天)。最后,我们准备好查看所有这些图像,并开始将它们分为两类:白天和夜晚。
我们将根据图像的平均亮度水平来区分白天和夜晚图像。这将是一个单一的值,并且我们将假设白天图像的平均亮度高于夜晚图像。现在,为了计算图像的平均亮度,我们将使用 HSV 颜色空间。具体来说,我们将使用明度(V)通道,它是亮度的度量,并累加 V 通道中的像素值。然后,我们将该总和除以图像的面积,以获得图像的平均明度值。
所以首先,我将一张测试图像转换为 HSV 颜色空间。我想查看几张白天图像和几张夜晚图像,以找出两者之间的差异。在这个例子中,我分别绘制了 H、S 和 V 通道。所以这里是一张白天图像及其颜色通道 H、S 和 V。我们可以看到 V 通道在天空中特别高。这个分类是基于假设白天的天空比夜晚的天空更亮。所以我们的下一步将是使用明度通道找到平均亮度。
现在,我将定义一个函数来查找图像的平均明度值。这个函数 average_brightness 将接收一个 RGB 图像。第一步是将其转换为 HSV 颜色空间。接下来,我想累加明度通道中的所有像素值。我将使用 NumPy 的 sum 函数来实现。这会查看我们 HSV 图像的 V 通道并累加所有像素值。然后,我将计算图像的面积,由于我们标准化了每张图像,我知道它是 600 x 1100。为了找到图像的平均亮度,我们将这个亮度总和除以图像的面积。然后,这个函数将返回该平均值。所以这给了我们一个值:图像的平均亮度或平均明度值。
我们的下一步将是查看白天和夜晚图像及其平均亮度值。目标将是查看它们的平均亮度,看看是否能找到一个能清楚区分白天和夜晚图像的值。所以让我们先看看我们的标准化图像编号 0,我们知道这是一张白天图像。我们看到它的平均亮度大约为 175。现在,让我们看一张夜晚图像。这是一张相当暗的图像,平均亮度值只有大约 35。我们想查看各种这样的图像。这是另一张白天图像,它的平均亮度大约为 143。
现在,考虑到这些值,你可能在思考如何使用平均亮度来预测每张图像的标签:0 表示夜晚,1 表示白天。将由你来定义那个阈值。下一步将是将这些数据输入分类器,分类器可能简单到是一个条件语句,检查平均亮度是否高于你定义的某个阈值。现在,这个平均亮度值被认为是一个特征。特征只是图像的一个可测量组成部分,理想情况下有助于将其与其他图像区分开来。我们很快就会更多地了解测试这种模型的准确性。接下来,我们将更多地了解特征,以及为什么它们对自动驾驶汽车有用。
所以,它是一个特征。特征可以很容易地被认为是某物的总结。因此,图像的特征实际上只是图像数据的简洁摘要。此外,正如图像实际上只是数组中的数字集合一样,特征也只是数组中的另一个数字集合,尽管通常它们比图像小得多。那么这意味着什么呢?让我们更简单一点。忘记图像。考虑人类。一个人有很多方面,可能很难完整地描述一个人。然而,我们可以提取关于一个人的哪些紧凑特征呢?这里的“紧凑”是因为你希望这些特征描述这个人的某些方面。但我们希望这个描述是某种相关的总结。例如,如果你想将拳击手分配到他们的重量级别,我们可能希望对每个拳击手进行特征提取,我们会提取一个二维特征:身高和体重。在这个意义上,这些是特征,因为它们巧妙地忽略了不相关的信息。它们描述了此人的体重和身高,这些特征对于将拳击手分配到适当的重量级别很有用。它们也忽略了肤色或头发长度等。因此,从这个意义上说,你可以将特征提取视为一种提取相关信息,同时巧妙地忽略不相关信息的方式。一个好的特征总是非常简洁。
特征是图像中独特且可测量的信息片段。我们将通过特征的例子以及如何计算它们来进行讲解。计算机视觉的一个突破实际上来自于能够自动计算那些好的特征。然而,你也可以自己手动完成。事实上,你已经一直在创建自己的特征。没错。在上一个练习中,你必须想出一个区分特征来将图像分类为白天或夜晚拍摄。这个特征基于图像的整体亮度。
在本节中,我们将看到特征可以基于图像中的颜色或形状,并且我们将看到这些特征如何用于物体识别和分类任务。让我们从讨论特征在行业应用中的使用方式开始。现在,我们已经看到了如何利用颜色来帮助隔离图像的所需部分,甚至帮助对图像进行分类。除了利用颜色信息外,我们还了解图像中灰度强度的模式。强度是明暗的度量,类似于亮度,我们可以利用这些知识来检测其他感兴趣的区域或物体。例如,你通常可以通过观察强度的突然变化来识别物体的边缘,当图像从非常暗的区域变为亮的区域,或者反之亦然时,就会发生这种情况。
为了检测这些变化,你将使用和创建特定的图像滤波器,这些滤波器查看像素组并检测图像中强度的巨大变化。这些滤波器产生一个显示任何边缘的输出。让我们更多地了解这些滤波器,看看它们在处理图像和识别感兴趣特征方面的用处。

让我们确切地看看高通滤波器是如何工作的。我提到高通滤波器检测小区域内强度的巨大变化。强度模式在灰度图像中最好观察。例如,如果我们将这张汽车图像通过一个高通滤波器,我们期望检测到汽车的边缘,比如汽车顶部的边缘。在这一点上,图像从汽车上非常亮的区域变为暗背景。有很多像这样的小区域从亮变暗或从暗变亮。完整的滤波图像可能看起来像这样,汽车的边缘和道路线以白色突出显示。你还可以看到原始图片中强度没有变化或变化很小的区域,例如在这些大面积变暗或变亮的区域,高通滤波器会阻挡这些区域并将像素变为黑色。
我将讨论的滤波器以矩阵形式存在,通常称为卷积核,它们只是修改图像的数字网格。这是一个进行边缘检测的高通滤波器示例。它是一个 3x3 的核,其所有元素之和为 0。对于边缘检测,所有元素之和为 0 很重要,因为这个滤波器计算相邻像素之间的差异或变化。差异是通过从一个像素值中减去另一个像素值来计算的。在这种情况下,减去围绕中心像素的像素值。如果这些核值加起来不为 0,那将意味着这个计算出的差异将具有正或负的权重。这将具有使整个滤波图像变亮或变暗的效果。
为了应用这个滤波器,输入图像(我称之为 F(x, y),表示 x 和 y 空间的函数)与这个核(我称之为 K)进行卷积。这称为核卷积,卷积用星号表示,不要误认为是乘法。核卷积是计算机视觉应用中的重要操作,也是卷积神经网络的基础。它涉及取一个核(我们的小数字网格),并将其逐个像素地传递到图像上,根据网格中的这些数字创建另一个边缘检测输出图像。我们将看到,通过改变核中的数字,我们可以创建许多不同的效果,从边缘检测到模糊图像。
我将使用这个 3x3 边缘检测滤波器进行一个数学示例,以更好地理解像素级操作。我将放大这张图像,就在汽车的这个边缘附近,以查看灰度像素值。首先,对于这张灰度图像中的每个像素,我们将核放在它上面,使一个像素与核的中心对齐。我只是选择这个像素作为示例。然后我们查看以这个像素为中心的 3x3 像素网格。然后,我们取核中的数字,并将它们与它们对应的像素成对相乘。所以左上角的这个像素值 150 乘以核角 0。接下来,我们将值 45 乘以 -1。再下一个,25 乘以 0。然后我们移动到下一行。我们对所有 9 个像素-核值对都这样做。注意,中心像素的值为 200,将乘以 4。最后,所有这些值被求和,得到一个新的像素值 175。像这样的
036:课程完结与进阶之路 🎉
在本节课中,我们将回顾你在“无人驾驶汽车入门纳米学位”项目中的成就,并了解完成此项目后为你开启的进阶机会。

课程概述
你已成功完成优达学城(Udacity)的“无人驾驶汽车入门纳米学位”项目。在此,我们向你表示祝贺。你在此项目中付出的承诺与努力,已为你迈出成为自动驾驶汽车工程师的第一步奠定了坚实基础。
学习成果总结
上一节我们概述了课程完结的意义,本节中我们来具体看看你已取得的核心成果。
以下是你在本项目中取得的主要成就:
- 巩固了编程与数学技能:你通过实践强化了解决复杂问题所需的基础能力。
- 解决了自动驾驶新颖问题:你甚至应用了一些创新方法来解决特定的自动驾驶汽车挑战。
这些成果表明,你已经为将技能提升至更高水平做好了充分准备。
进阶之路
那么,所谓的“更高水平”具体指什么呢?它就在此向你招手。
这位是大卫·席尔瓦(David Silver),他负责教授优达学城的“高级无人驾驶汽车纳米学位”项目。通过完成“无人驾驶汽车入门纳米学位”项目并毕业,你不仅证明了自己具备相关的技能,更重要的是,展现了在优达学城取得成功所必需的决心。
因此,你也为自己赢得了优达学城“无人驾驶汽车工程师纳米学位”项目的保录资格。
我们期待在进阶课堂中与你相见!😊
课程总结

本节课中,我们一起回顾了你完成“无人驾驶汽车入门纳米学位”的里程碑。我们总结了你在编程、数学及解决实际问题方面取得的进步,并介绍了由此获得的、通往更高级课程(“无人驾驶汽车工程师纳米学位”)的保录资格。恭喜你圆满完成此阶段的学习,并预祝你在自动驾驶领域的旅程中继续前行,取得更大成就。

浙公网安备 33010602011771号