1.1
大家好,好久不见,大概有一两个月没和大家见面了。
大家可能知道,今年秋季我们将在斯坦福开设一门新课,叫做「实用机器学习」。这门课的授课语言是英文,课件(slides)也是英文制作的。但考虑到很多中文母语的同学,我们会用中文为大家重新讲解这些内容,帮助大家更好地理解。
我们这门课的第一个单元是课程的整体介绍。这门课的重点是讲机器学习在工业界的实际应用。
十几年前,机器学习主要还是在一些大型科技公司中使用,比如百度、腾讯和阿里。但如今,它已经广泛应用于各行各业,成为很多公司最核心、最关键的技术支撑。这里说的“关键应用”,指的是那些能直接支撑公司主要收入的项目。
目前,使用机器学习较多的行业包括高科技、汽车、制造业、零售、金融和医疗健康等。而近几年,特别是疫情之后,机器学习的应用有了显著增长。因为它可以替代很多重复性、基础性的人力工作,从而让人们更安心地在家办公,线下的部分则可以更多依赖自动化技术完成。
下面这个图是麦肯锡在2019年发布的一份报告,展示了采用机器学习的公司在一年内的收入变化。图中浅蓝色表示收入增长低于5%,中蓝色代表增长在6%到10%之间,深蓝色表示增长超过10%。可以看到,不同行业的公司在应用机器学习后,几乎有一半实现了超过6%的收入增长。这也促使更多企业加大对机器学习的投入,以提升产品质量、优化服务、创造更多收益。
接下来我们看看在不同领域中,机器学习具体有哪些应用。
制造业
一个典型应用是预测设备什么时候需要维护。比如在一条生产线上,不同设备的使用年限和磨损程度都不同,安装传感器后可以提前判断某台设备是否即将出现故障,做到及时维修。
另一个是产品质量检测。通过高清摄像头或其他传感器,可以自动判断产品是否合格,减少人工检查的需求。
零售业
最常见的是商品推荐系统。大家用电商平台时,经常会收到推荐商品,这背后就是根据你过往的浏览和购买习惯做出的个性化推荐。 还有客服方面,一些简单的用户咨询可以用聊天机器人来自动回答,减轻人工客服的压力。 此外,商家也可以利用模型预测某个产品未来几天的销量,从而提前安排库存和物流。
医疗健康
随着智能穿戴设备的普及,比如能检测心率、体温等的手环或手表,机器可以实时监测用户的身体状况。一旦检测到异常,就可以自动发出警报,通知亲人或紧急联络人,甚至直接呼叫救护车。
再比如,去医院做CT或核磁共振检查后,传统是由医生解读影像。但现在越来越多系统可以用机器自动分析这些图像,帮助医生更快地做出判断,甚至提前发现潜在的问题。
金融行业
最典型的应用是欺诈检测。例如如果你的账户突然出现异常消费,系统可以立即识别出来并阻止交易。 此外,在申请贷款或购买金融产品时,系统可以自动评估你是否符合条件,大大提高了处理效率。
汽车行业
现代汽车配有大量传感器,比如监测胎压、油量等,机器学习可以帮助预测汽车是否需要保养,从而避免行驶中的故障风险。 当然,自动驾驶是当前最热门的领域之一。通过安装激光雷达、摄像头等传感器,汽车可以“看懂”周围环境,实现无人驾驶。
以上就是机器学习在几个主要行业中的典型应用。我们这里只是讲了一小部分,很多例子大家可能已经在日常生活中接触过了。
其实过去这些年,我们能看到机器学习已经在各种听起来很“神奇”甚至让人眼前一亮的场景中得到了广泛应用。
所以如果你对某个行业或者某家公司感兴趣,深入去了解他们到底在哪些地方用上了机器学习,往往会发现非常有意思的东西。
在课程中,我们经常会用一个具体的案例作为主线,通过它来介绍各种技术。上一门课我们也用了同样的例子——房价预测。这是一个比较贴近美国实际生活的场景。在美国买房子是怎么回事呢? 通常是卖家先定个价格,比如“我这个房子标价330万美元”,然后大家来看房,看完后买家决定出多少钱来买。这个过程中,你需要“出价”,最后出价最高的人才会买到房子。
所以如果你特别想买,就得出一个比别人更高的价格,但又不能高太多,不然就会花冤枉钱。几千甚至几万美元的差价,在美国的房市里是很常见的。那我们怎么决定应该出多少钱呢?很多人会参考一些算法给出的“房屋估价”。这个估价,就是机器学习能发挥作用的地方。
比如我们举的这个例子,是斯坦福附近的一套房子。2021年7月12日,卖家挂牌价格是330万美元;到了8月31日,房子成交,成交价大约是340万美元。 这时候就有一个网站,它每天都会更新这个房子的预测价格。这些预测是基于模型算出来的,当然我们课上给出的例子是房子卖出之后的预测,所以看上去会非常接近实际成交价。
但真正的挑战在于:我们希望在房子成交之前,就能预测得足够准确——预测出最终的“赢家价格”。
有了这个例子,我们就可以继续深入,来看看一个完整的机器学习流程是怎样的。
首先,我们得把一个现实中的问题转化成机器学习能解决的问题。 不是所有问题都适合用机器学习来处理。有些听起来很简单的事情,其实对机器来说非常难,比如说——叠衣服。我们人闭着眼都能叠,但对机器学习来说是个相当复杂的任务。 再比如无人驾驶,很多人觉得“开车谁不会啊?”但让机器学会开车其实难度也非常高。
相反,有些对人来说挺难的任务,机器反而不难做。比如翻译中文到法语,我如果不会法语,要学上很久才能搞定。但机器学习系统做这件事却相对容易。所以,在工业界应用机器学习时,你会发现很多问题都不是“教科书上”出现过的,它们往往有很多独特的业务背景。这就要求你必须具备对机器学习的整体理解,知道哪些任务是可以用机器解决的,怎么建模,该用什么方法,怎么处理数据,等等。
当你完成了第一步,也就是已经把一个现实问题转化成了一个机器学习问题,接下来要做的事情就是收集数据了。 因为没有数据,模型是没法训练的,对吧?所以你得先去采集一些数据,然后对这些数据进行处理和清洗。
等你准备好数据之后,就可以开始训练模型了。这一步你需要选择一个合适的模型,比如是用决策树、线性回归,还是更复杂一点的神经网络。选好模型之后开始训练,然后还得调参,优化模型表现。
训练完成之后,我们就可以把模型部署上线了。部署的意思是让它真正开始“工作”——比如预测房价、推荐商品、识别图像等等。
不过上线不是终点。你还需要持续地监控这个模型的表现,比如它的预测准确率怎么样?是不是越来越偏?响应速度、延迟是否正常?这些都需要关注。而且部署之后会不断有新的数据进来。我们会把这些新数据再收集起来,经过处理后加入训练数据中,进一步优化模型。这样,整个过程就进入了一个迭代循环,模型会随着时间不断更新、变得更适应新的环境。
回到我们前面讲的房价预测的例子,其实这个流程在这个场景下也是一一对应的:
首先,我们的问题是预测一个房子的价格——这是一个标准的回归问题,也就是预测一个连续数值。有了问题之后,我们开始收集数据。比如,我们可以把过去一年的房屋交易记录拿过来作为训练数据。接着就是特征工程,也就是从原始数据中提取对预测有用的信息,比如房子的面积、卧室数量、地段、建造年份等等。
然后我们进入模型训练阶段。很多时候,我们并不会一上来就用特别复杂的模型,比如深度学习。 相反,很多时候我们会先试一个非常简单的模型,比如线性回归——这是几十年前提出的方法。目的是先看一下数据的质量怎么样,跑一个简单模型试试看。
如果运气好,线性模型表现还不错,我们甚至可以直接拿它上线使用。上线之后,当有新的房子需要估价时,我们的系统就可以立刻给出一个预测价格,给用户提供一个定价的参考。如果你的模型预测得够准,用户就会更愿意使用你的网站或者服务,从而提升整个产品的价值。之后,新的房源、新的交易记录会不断产生,我们就把这些数据继续收集起来,加入训练数据中。这样我们的模型就能学到最新的市场变化,对市场的反应也会更敏感、更准确。
所以,从这个例子你可以看到,整个机器学习的流程——从问题定义、数据收集、模型训练、上线部署到后续的迭代优化——是如何一步步落地到一个具体场景中的。
在整个机器学习的应用过程中,其实会面临很多实际的问题。首先,第一个挑战就是怎么把现实问题转化为机器学习问题。
你会发现,在一家公司中,有些问题是天然适合用机器学习来解决的,而有些问题则非常棘手、不太容易建模。 但现实中,公司并不会只根据“这个问题是不是适合做机器学习”来决定要不要做。 往往更重要的是:这个问题能不能带来商业价值。 如果某个问题对业务影响很大,即使它很难,公司也会愿意投入资源去攻克它。相反,如果某个问题就算很简单,但带来的收益微乎其微,那也可能不会优先去做。
其次,数据和隐私之间的权衡也是个非常现实的问题。 机器学习模型的性能很大程度上依赖于数据的丰富程度,尤其是关于用户行为或者个人信息的数据。 但这类数据往往涉及隐私,特别是在医疗健康领域——像心率、血压、体温这些生理信号,非常敏感。 在使用这类数据时,就需要格外小心,既要能提取有价值的信息,又要遵守相关的隐私保护规定。
再来看模型训练本身的复杂性。 随着深度学习的发展,模型的规模越来越大,计算成本也越来越高。 你可以看这张图(来自《经济学人》的统计):从上世纪60年代到现在,模型的复杂度呈指数级增长,特别是过去七八年,深度学习的崛起让这个趋势变得更加陡峭。 结果就是:训练一个大型模型,可能需要投入上百万美元的算力资源。更大的模型也意味着你需要更多的数据,形成了一个“模型越大、数据越多、成本越高”的循环。
即便你花了大量资源训练出了一个性能很强的模型,问题也还没结束。 模型部署又是另一大挑战。 你可能会发现,这个模型虽然准确率很高,但根本用不起。 比如你要在线部署,可能需要大量的GPU服务器,成本非常高; 又比如你要做的是一个对响应速度要求极高的服务,比如电商推荐系统,要在用户浏览网页时在100毫秒内完成推荐,太大的模型压根来不及出结果。
所以最终,企业更关注的是: 我这个模型能不能为我最核心的业务带来收益最大化?
比起去优化一个只占公司收入1%的边缘业务,企业可能更倾向于集中资源去优化那些对利润影响大的部分,比如占10%甚至20%的主力业务。当然,并不是所有问题都一样难。 有时候你运气好,碰上一个比较简单的任务,很快就能落地。 但如果你面对的是像自动驾驶这样的难题,那就复杂得多了。自动驾驶已经被研究了很多年,每年都说“快了快了”,但直到现在,除了像特斯拉这样的公司相对激进地在推进,大多数无人车系统还没真正做到全面上路。
类似的还有无人商店,比如 Amazon Go。 对于零售业来说,店员的人力成本很高,如果能做到全自助,既能省成本,又能提升用户体验。 但你要让机器准确地识别“谁拿了什么、拿了几样、有没有付钱”,在一个开放、复杂的环境中,这其实是个非常难的技术问题。
另外一个现实挑战是数据质量。 现在这个时代不缺数据,但我们非常缺“高质量的数据”。 想要训练出一个表现好的模型,不光要有大量数据,更要对这些数据进行清洗、标注,确保它们是准确的、有意义的。你可以把这个过程类比成“教小孩”: 你不能只是随便和他聊两句,就指望他自己悟出所有知识;你得系统地、结构化地教他,比如告诉他什么是猫、什么是狗、怎么区分颜色…… 就像我们现在上这门课一样,如果我只是随便讲两句,大家听完可能一头雾水;但如果我提前花时间设计好教学内容、整理讲义,大家学起来就会更顺利。这就是为什么“高质量的数据”能让模型更快地学习、学得更准,也更容易真正应用到实际中去。
这里面一个关键问题是数据分布的变化。 举个例子,你在某个网站上训练了一个模型,训练时用户多是年轻人,但随着时间推移,用户群体慢慢变老了,他们的行为模式自然也会不同。 这时候你原先的模型可能就不再适用了。 或者遇到节假日,用户的行为突然发生改变,模型在平时数据上训练得再好,也可能在假期“翻车”。这就是所谓的“概念漂移”(concept drift),在工业界是非常常见的问题。解决它的方式往往需要定期更新数据、重新训练模型,甚至要重构整个特征体系。
最后,还有一个现在越来越重要的问题——公平性(fairness)。当机器学习模型被用于做决策,比如判断一个人能不能拿到贷款、能不能入学、能不能获得某种服务时,我们就不能只关注模型的准确率。 因为模型本身虽然不会“偏见”谁,但它背后的数据可能是有偏的。 如果某些群体的数据收集得比较完整、质量比较高,那模型在这些人身上的预测就会更准; 但如果另一部分人的数据少、质量低,模型对他们的判断就可能出现较大误差,进而导致系统性的偏见和不公平。
我们特别想强调的一点是:有些人被称为“机器学习专家”。那么,机器学习专家到底是做什么的?
简单来说,他们能够解决一些非常关键的问题。举个例子,如果某个产品为公司带来了超过50%的收入,甚至像搜索引擎这样,广告业务可能占了90%以上的营收——那它背后的模型就极其重要,自然值得投入大量精力去优化和定制。机器学习专家的价值就在于,他们能够根据产品的特点,设计和调整模型。比如,这个模型需要具备哪些性质?是不是要响应特别快?是不是要特别擅长处理图像或文本?这些都不是传统算法在学术数据集上训练出来就能直接应对的。很多经典的算法虽然是在标准数据集上表现不错,但放到具体业务场景中,往往还需要大量定制化的调整。机器学习专家就能根据实际需求,去重新设计模型结构、优化性能,比如让模型在大规模数据下高效运行,或者让模型尽可能小,以便部署在资源受限的环境中。
除了机器学习专家,还有一类非常重要的角色是软件开发工程师(SDE)。在实际项目中,光是训练一个模型、跑个Python代码还远远不够。真正把模型应用到产品中,涉及到的是整套工程流程:如何从线上系统实时采集数据、怎么高效地做数据预处理、如何在大规模集群中同时训练几十上百个模型、如何确保不同团队的实验不会相互影响等等。而且这些模型中,有的是为了实验,有的是为了线上部署,有些需要定时训练,有些还得根据实时数据不断更新。如果哪天某台机器出问题,训练任务就会被卡住;如果集群资源被大量占用,也可能导致模型无法及时更新。
因此,如何高效管理这些资源、保证关键模型能按时训练完成,同时又能在有限资源下支持更多实验,这就是一项需要非常扎实工程能力的工作。
模型部署之后也不是一劳永逸的事。线上的模型通常有很多个,有的是老模型,有的是测试中的新模型。每个模型都可能有不同的版本,基于不同的数据训练而成。你还得实时监控这些模型的运行状况,发现异常及时下线,或者当预测超时时,迅速调整资源或更换模型。所以,从数据到训练、从部署到维护,这整套流程的开发和保障都需要大量人力投入。
最后,如果你关心的是“如何持续提升自己的技能”,那么职业规划就显得格外重要。即便你不是纯粹做机器学习的,无论是作为软件工程师还是业务专家,多多少少也都会接触、学习到机器学习相关的知识。作为SDE(软件开发工程师),一开始其实不需要懂机器学习,只要能写代码就行。而对于一些领域专家来说,哪怕一开始不会写代码也没关系。
但无论是哪一类人,在逐步参与实际项目之后,都会慢慢接触并学习如何处理数据、训练模型、选择模型、调参和部署等内容。渐渐地,你会意识到,自己也能从头到尾完成一个机器学习流程:整理数据、训练模型、部署上线——其实也没那么难,对吧?
所以说,很多SDE和领域专家最终都会逐步成长为数据科学家。为什么要往这个方向发展呢?因为虽然“数据科学家”这个职业已经被热炒了很多年,但在业界实际上依然非常稀缺。现在每年高校能培养出来的合格数据科学家其实不多,而需要机器学习应用的公司和产品却越来越多。所以,很多企业都会鼓励软件工程师或业务专家往数据科学家的方向发展。
而当你不断深入理解问题,并持续改进模型以提升产品性能,你就会慢慢地成长为真正的机器学习专家。到那个阶段,你不仅能定制模型,甚至有机会做出具有创新性的工作——比如撰写论文,发表在顶级机器学习会议上。现在的机器学习会议里,越来越多地能看到来自工业界的工作。像Google就经常在 NeurIPS、ICLR 等会议上“霸榜”,这是因为他们的很多数据科学家在某些方向上深入钻研,解决了实际问题,也做出了原创性的贡献。
所以建议大家也思考一下自己当前所处的位置,以及未来的成长路径。而这门课,正是聚焦在“数据科学家”这个角色上展开的。我们会帮助大家系统理解机器学习的各个关键环节:从数据处理、模型训练,到部署与运维。当然,我们也会介绍一些机器学习专家所需要掌握的前沿技术,在专题部分进行更深入的讲解,希望对不同背景的同学都有帮助。
既然课程聚焦的是数据科学家,那我们不妨来看一下数据科学家到底在做什么。这是 Anaconda 公司发布的一份调查(你可能用过他们的 Conda 工具)。他们调研显示,数据科学家有:
- 19% 的时间用于读取数据
- 26% 的时间用于清洗数据
- 21% 的时间用于数据可视化
- 剩下的时间才分配给模型选择、训练与部署
将近70%的时间都花在了处理数据上。这也解释了为什么叫“数据科学家”而不是“机器学习科学家”,因为“数据”确实是机器学习中最关键的一环。
而我们这门课,也会在前几章重点讲“数据”相关的内容。这也是我们和很多传统机器学习课程最大的不同点之一——我们真的会讲很多关于数据的知识。当然,模型部分我们也会涉及,包括新模型的介绍、如何选择合适的模型、以及部署实践。所以整门课会涵盖数据科学家在工作中会遇到的方方面面。可以说,这是一门非常“接地气”的课程,关注的是机器学习在工业界的实际应用场景。
我们会针对数据科学家这条路径,提供实用技能的教学。很多这些内容,在传统的机器学习、统计或编程语言课程中其实都不会系统地讲。
市面上也很少有经典教材能完整覆盖我们想讲的这些内容。因此,我们会结合自己在实际项目中的经验,为大家挑选重要主题进行深入讲解。
这门课并不是为了替代你去学机器学习、统计或编程语言的课程。如果你还没有这些基础,建议先补一补相关知识,比如了解基础的机器学习概念、一些统计基础,以及至少会用 Python 写代码。
由于 Stanford 的课程只有20个课时,我们不会再从基础讲起,而是更注重实战和思维的训练。接下来我们会深入展开一些在普通课程中不太容易学到的话题。整体上,我们将内容划分为四大方面:
- 数据
数据是我们最核心的一块。我们会讲到如何收集和处理数据。在实际应用中,你会遇到很多数据偏移的问题,比如训练时的数据和部署时的数据场景发生变化,导致模型效果变差——这可能是因为 概念发生变化(Concept Shift),也可能是标签本身变了。
传统机器学习往往假设数据是**独立同分布(i.i.d.)**的,也就是每个样本是随机、互不相关的。但现实世界的数据,几乎都不是这样。比如很多数据是结构化的、有图结构的、有时间顺序的,我们会专门讲讲如果数据是图结构、或者具有依赖关系时该怎么办。
- 模型
这门课不会再详细讲每种模型的结构,如果你对深度学习模型本身感兴趣,可以回看我们前面的课程。
这里我们更关注实际中常用的一些训练策略,比如:
- 如何做模型验证与融合
- 如何调优超参数
- 如何做迁移学习(Transfer Learning)
- 以及近年来非常热门的多模态学习(Multimodality)
现实世界的数据,特别是来自产品的数据,往往不是单一模态的。比如在 B 站刷视频,你可能同时会接触到视频、弹幕、评论、点赞、点击行为等等。这些都是不同模态的数据,我们会讨论如何用合适的技术将它们有效融合,构建出一个能够理解复杂场景的大模型。
- 部署
模型训练好之后,如何部署到线上系统是另一个关键环节。
- 我们会讲如何优化模型的部署效率、选用什么样的硬件、如何压缩模型。
- 特别是现在的大模型越来越大,直接上线并不现实,通常需要通过模型蒸馏(Distillation)——也就是先训练一个大模型,然后提炼出一个更小、更快的模型,便于部署和响应速度。
- 监控与反馈
部署之后,并不是一劳永逸。你需要实时监控模型的表现,判断它是否公平。公平性本身是一个很复杂、也不容易定义的问题,我们会探讨几种不同的公平性概念。另一个重要方向是可解释性(Interpretability)。复杂模型往往是黑盒,我们会介绍一些技术,帮助你理解模型在做出某个预测时到底依据了什么。
总的来说,这门课聚焦的是 工业界中的机器学习应用。我们处在一个机器学习快速落地的时代,越来越多的公司在使用机器学习,因此现在是个非常好的机会去了解在实际业务中,什么才是真正重要的技术。
我们会从完整流程的角度出发,包括:
- 如何将实际问题转化为机器学习问题
- 如何准备和处理数据
- 如何训练模型并进行优化
- 如何部署到线上系统
- 最后是如何监控和持续迭代模型
这门课面向的是数据科学家,重点讲述他们在每个阶段可能遇到的一些关键技术细节。这些内容在传统的机器学习、统计或编程课程中往往接触不到。因此,我们的课程并不是替代这些基础课,而是建立在它们之上的进阶内容。如果你还没有机器学习、统计或编程基础,建议先补一下相关知识。因为这是 Stanford 的一门短课,只有20节课时,我们不会花太多时间讲基础内容,而是专注于分享我们在真实项目中总结出的、对工业界有实际价值的技术点。
1.2
在接下来的几个话题中,我们会围绕“数据”展开讨论。这一小节的重点是数据的获取。我们之前提到,当开始一个机器学习项目时,第一步是把实际的业务问题转化为一个可以用机器学习方法来解决的问题。现在我们假设这一步已经完成了,接下来要面对的第一个问题就是:你有没有足够的数据?
如果你比较幸运,已经手头有了足够多的数据,那我们后面会讲如何处理这些数据。但在大多数情况下,数据往往是不够的。这时你可以思考两个方向。
-
第一个是:你能不能找到其他数据来源?也就是说,是否存在一些你可以利用的已有数据集?如果有,我们就需要思考如何去寻找这些数据,以及怎样将来自不同来源的数据进行整合。
-
第二个方向是:如果你找不到现成的数据,但你大致知道数据是怎么来的,是否可以自己生成一些数据?也就是说,如果你有一种合理的方式来模拟或合成这些数据,那数据生成就是另一个可行的方案。
本小节我们将围绕这两个方向展开介绍:一个是如何“发现数据”,另一个是如何“生成数据”。
所谓数据的发现,最直接的方法就是去寻找已有的数据集。特别是在做研究时,我们通常会使用那些已经整理好的、广泛使用的数据集,来验证自己的新想法。比如说,我正在研究一个新的超参数调节算法,那么我就需要测试它在多个数据集上的表现。通常不止一个,可能要找十个、二十个。这些数据集还不能太大,否则训练会很慢。理想情况下,我会选择一些小型或中等规模的数据集。
同时,为了全面评估算法的效果,我还会选一些类型不同的数据集,比如图像类的、文本类的,或者标签类别很多的、含有较多噪音的。通过这样的组合,我能更客观地看出这个超参数调节方法在不同场景下的表现。再比如,如果我要训练一个大型的深度神经网络,那显然就需要一个更大规模的数据集,这样模型才能充分学习到有用的特征,发挥应有的效果。
但如果你不是在做研究,而是要开发一个实际的产品,情况通常会不太一样。你可能根本找不到现成的数据集可以用。“数据”和“数据集”是两个不同的概念。数据集通常是别人已经整理好的、可以直接用于机器学习的格式,而数据只是原始材料。如果你是在做一个具体应用,比如无人驾驶,往往就需要自己收集原始数据。
比如说你在做无人车,就需要在车上安装各种传感器,比如摄像头、激光雷达等,然后让人开车,尽量覆盖各种驾驶场景,比如不同天气、不同时间段、不同道路状况下的驾驶过程,通过这些手段来收集大量数据。此外,也可以考虑是否有人已经做过类似的工作,是否可以获取他们的数据。但无论是哪种方式,核心目标都是要确保采集到的数据足够丰富,能够覆盖各种可能出现的情况,才能保证后续模型训练的质量。
接下来我们来看一些常见的机器学习数据集,它们是怎么来的。先举几个例子。比如 MNIST,这是一个非常经典的手写数字识别数据集,很多人在刚接触机器学习时都会用到。它最初是由美国一个政府部门的员工手写的数字样本收集而成,大概是二三十年前整理的。
再比如大家可能听说过的 ImageNet,这是推动深度学习迅速发展的一个关键数据集。它的规模非常大,包含了上百万张图片。正是因为数据量足够大,才使得更深层次的神经网络得以训练,从而展现出深度学习在大数据环境下的强大性能。ImageNet 的图片主要来源于图片搜索引擎,比如 Google。比如说你想收集“猫”的图片,就去搜索“猫”这个关键词,把搜索结果中的图片都下载下来。可以尝试多个搜索引擎,比如 Google、微软的 Bing、百度等,把它们的结果汇总,然后通过人工方式进行筛选和标注,把不相关或错误的图像去掉,这样就构建出了一个高质量的数据集。
类似的,还有 AudioSet,这是基于 YouTube 的音频数据集,把音频剪辑出来用于声音分类;Kinetics 也是从 YouTube 视频中截取的一段段视频片段,每段都标注了人物的具体动作行为;KITTI 是一个专门用于无人驾驶研究的数据集,通过车载摄像头、激光雷达等传感器在真实驾驶中采集数据整理而成。
还有 Amazon Review,它是从亚马逊网站上收集的用户产品评论,用来研究情感分析、评分预测等任务;SQuAD 是从维基百科中构建的问答数据集,把文章中的信息提取为问题和答案的形式,比如“某某人出生在哪一年”,答案就是具体的年份;LibriSpeech 是一个基于有声读物的语音识别数据集,包含了超过 1000 小时的录音,用于语音识别和语音合成任务。
总体来说,这些数据集大致可以分为两种获取方式:
-
第一种是“爬取”已有的数据资源。也就是说从网上已有的平台获取数据,比如从 YouTube、Google、Amazon、Wikipedia 等网站中抓取图片、音频、文本等内容。
-
第二种是“采集”数据。像 MNIST 或 KITTI 这种,就是通过人为行为主动收集,比如让人手写数字,或者开车时用传感器采集各种信息。
这两种方式都是学术界和工业界在构建数据集时最常用的做法。
那如果你不打算从头开始自己做一个数据集,而是想直接使用别人整理好的,应该去哪里找呢?现在其实比过去方便太多了。十年前,公开的数据集还非常有限。但随着机器学习的发展,越来越多的数据集被整理出来,特别是在写论文或者做小型项目演示时,很容易就能找到合适的数据资源。
其中一个很有用的平台是 Papers with Code,这是近年来非常火的网站。它最初是为了解决研究复现的问题:很多论文没有公开代码,难以复现。这个网站收集了大量论文的代码实现,同时也整理了这些论文使用的数据集。更重要的是,它还汇总了在不同数据集上,各个方法的性能表现,比如准确率、精度等,非常方便做横向对比。如果你想找某个具体任务常用的数据集,比如图像分类、文本分析、视频理解等等,这个平台是一个非常好的起点。它还提供了一些分类图表,比如按任务类型列出每种任务有多少个数据集可选,非常直观。
第二个我们常用的方式是去 Kaggle。Kaggle 是一个机器学习竞赛平台,里面不仅有很多比赛提供的数据集,还有大量用户自己上传的数据集。上传这些数据的人很多是数据科学家,他们可能为了完成某个项目,自己收集整理了一批数据,然后分享在平台上。你可以在 Kaggle 上搜索各种主题的数据集,种类很丰富。不过因为是用户上传的,质量也会参差不齐,有些整理得很好,有些可能比较粗糙。所以需要你自己去挑选,看看数据是不是适合你要做的任务。
第三个推荐的是 Google 的 Dataset Search,它其实就是一个专门搜索数据集的搜索引擎。它会聚合各个网站上发布的数据集,比如 Kaggle、Papers with Code,甚至大学研究组或政府网站公开的数据。简单来说,它提供了一个统一入口,你输入关键词,它就帮你在全网找相关的数据集,非常方便。
最近几年,随着开源社区的兴起,很多开源框架也自带了不少数据集。比如 TensorFlow 有自己的 TensorFlow Datasets,里面收录了几百个常见数据集;Hugging Face 是以文本任务为主的开源社区,它的 datasets 库收集了大量文本类的数据,涵盖了问答、翻译、情感分析等各种任务。
还有一种获取数据集的方式是参加竞赛。每年有不少机器学习相关的会议和公司会组织各种竞赛。这些竞赛一般会提供一个精心准备过的数据集,可能是一个研究团队专注某个方向,比如鸟类识别,或者是一家公司从真实业务中抽取的一部分数据。竞赛的数据集通常质量比较高,因为组织方会投入资源整理好数据,同时希望借助大家的参与找到更好的模型方法。一般这种数据集也比较新颖、独特,不会是重复老题目。
举个例子,还有像 AWS 的 Open Data 项目,这是在云平台上开放的一些大型原始数据集,比如美国气象局的卫星数据,每小时更新、数据量非常大,是以 TB 或者 PB 级别来计的。这种数据主要适合做一些大规模分析。再比如 Common Crawl,这是一个非营利组织提供的数据集。他们会像搜索引擎一样,把整个互联网网页爬下来,原始数据体量达到数百 TB。虽然这些数据很原始,需要你自己做不少处理,但对于构建大语言模型、训练搜索引擎等任务是很宝贵的资源。
这些就是常见的几种找数据集的方式。
其实我们也可以把这些数据集大致分成三类,每一类的用途和特点也有所不同。
第一类是学术数据集,比如 MNIST、ImageNet,这类数据集在研究论文中经常出现,结构清晰、处理干净,非常适合用于验证算法。它们的优点是干净、标准、训练和测试方式明确,而且你能查到很多已有的基准结果,便于比较不同方法的表现。数据难度通常适中,不会太简单也不会太难。缺点是种类有限,可能就几十到一百多个,大多集中在图像分类、自然语言处理等主流任务。如果你是做产品的,往往很难直接找到和你业务高度相关的学术数据集。
第二类是竞赛类的数据集。这些数据集通常比较新、关注实际问题,常常来源于真实业务需求。数据质量高,应用背景清晰,适合想要验证新模型、做挑战性实验的人。缺点是你拿到的数据可能已经有一定偏向性,或者适配过某些竞赛规则,不一定完全适合你自己的任务。
第三类是原始数据集,比如气象卫星数据、网页爬虫数据、公司内部的 Data Lake。这类数据体量大、处理难度高,但自由度大、覆盖面广。如果你有足够的资源,能从中挖掘出很有价值的信息,非常适合做真正的应用落地或大模型训练。
在工业界,这部分工作通常非常耗时,也是数据科学家花最多时间的环节—— 60% 到 70% 的时间都花在“弄数据”上,指的就是处理原始数据。而且这类数据往往涉及到多个团队,牵扯到数据的采集流程、存储方案,还要考虑法律和隐私问题。比如一个公司收集了用户行为数据,是否可以合法用于模型训练?是否会有隐私风险?数据是否能共享给其他团队?这些都是需要明确考虑的。
如果你在公司里工作,还经常会遇到一个叫“数据湖”(Data Lake)的东西。它是企业内部集中存放各种原始数据的地方,可能涵盖了各个业务线、不同系统的数据源。你需要自己去探索,比如联系相关团队了解数据放在哪、怎么访问、有没有权限。因为很多时候,数据湖中的数据访问是有严格权限控制的,特别是涉及用户数据时,还可能受到法规约束,不能随便使用或共享。
假设我们已经找到了需要的数据,下一步就会涉及“数据融合”的问题。也就是说,如何把来自不同来源的数据整理成一个统一、结构清晰的数据集。为什么这一步很重要?因为在实际工作中,数据通常是分散在不同地方的。举个例子,如果你在一家房地产公司工作,想做一个预测房屋销售的模型,你的数据信息很可能分散在几张不同的表中。
比如说,一张表记录的是房屋的基本信息,包括面积、位置、户型等等;另一张表记录的是房屋的交易信息,比如什么时候挂牌、什么时候成交、成交价格等;还有一张表可能存着卖房中介的信息,比如中介的 ID、名字、所在公司、历史记录等等。为什么会分这么多张表?主要是为了便于管理和维护。通常我们会把相对稳定的信息(比如房屋结构或中介资料)和经常变动的信息(比如交易记录)分开存储。这样每张表结构更简单,也便于单独更新。比如一个中介加入平台一次就行,不用频繁改;但一套房子可能会卖很多次,交易信息就更新得更频繁。
当你拿到的数据分散在不同的表里时,下一步就需要把它们整合在一起。因为通常一张表不可能包含你建模所需要的所有信息。比如你要预测一个房子的售价,除了房子的本身信息,还需要它的历史交易记录,甚至是销售这个房子的经纪人信息。这些内容很可能分别存在不同的表里。 如果你有数据库的背景,其实这就是我们熟悉的 table join 操作。比如说我们有两个表,都用房子的 ID 来表示某一条记录,一个表里是房屋属性,另一个表是交易信息,我们就可以用 ID 把它们连起来。简单来说,就是找到共享的 key,然后把对应的那几行数据拼在一起,这样最终得到的表里,每一行就包含了房子的完整信息:位置、面积、过往交易记录、销售人员等等。
但实际操作中会有一些问题。比如,不同的表中 ID 可能不完全对得上,一个表有 ID 1 和 2,另一个可能是 1 和 3。这种情况下你就要选择不同的 join 策略:
- Inner join:只保留两个表都拥有的 ID,比如只有 ID 1;
- Left join:保留左边表的所有 ID,比如 1 和 2,右边没有匹配的地方就填空。
你可以根据实际需求来选哪种 join 类型。
另一个常见问题是 缺失数据,也就是某些行在另一个表中没有对应的属性。遇到这种情况你要决定是保留这行数据、并对缺失部分填充默认值(比如用 NaN),还是直接丢弃。
还有就是 列的重复问题,比如不同的表中都有叫 "price" 的列,但可能代表不同含义,或者单位不同。你要检查这些列是否重复、有冲突,甚至是数值上不一致。冲突可能来自录入错误,也可能是因为单位不同,比如一个表里的价格是以“千”为单位,另一个是美元。你需要在融合前统一格式和单位。
解决好这些问题,才能真正把多个表合成一个一致性的、可用于模型训练的数据集。那如果你找不到现成的数据集该怎么办?我们之前也提到过一个思路是:自己生成数据。下面说几个常见的做法。
第一种是用 生成模型(如 GAN) 来生成数据。比如 GAN 可以生成跟真实图片非常相似的图像。一个例子是“这些人并不存在”这个网站,展示的都是 GAN 生成的人脸图。你可以用这些合成图像来训练人脸识别、分类等模型,因为它们足够真实,但不涉及隐私问题。
另一个例子是 Amazon 在 ICCV 上的一个研究,他们希望展示产品(比如一盏灯)在家居场景中的效果,但实际拍摄场景图既费时又费钱。所以他们用生成模型构建复杂的客厅场景(有沙发、书柜等),然后把灯“嵌入”到场景里,做到看起来非常自然。这种方式也可以转化为一个检测任务的数据集,比如判断灯在画面中的位置,做目标检测等。
第二种方式是 数据增强。这是一种非常常用的技术,尤其在图像任务里。比如你有一张松鼠的照片,可以通过旋转、加噪音、拉伸、颜色变化等方式生成几十张不同版本的图像,从而扩大数据量、增加模型鲁棒性。
在文本任务中,也有类似的做法。一个有趣的方法是“翻译增强”——比如你有一句话是“我没有时间”,你先翻译成法语,再从法语翻回英语,可能得到“I do not have time”。这两句话语义差不多,但形式不同。你甚至可以多语种翻译,比如先翻成中文再回来,也能得到一些语法结构不同的句子,从而扩充数据。
总结一下,这一节我们讲了关于数据的几个关键问题。
-
首先,是找到合适的数据集。这一步往往是整个流程里最有挑战性的。我们提到,工业界用的大多是原始数据,需要花大量时间去清洗和处理;而学术界和一些竞赛平台则提供已经整理好的数据集,更适合算法验证和研究工作。
-
其次,我们讲了数据的整合。实际项目中,数据往往来自多个地方,要通过 join 的方式融合到一起,形成一个结构统一、信息丰富的数据集。
-
最后,如果数据不够怎么办?那就用数据增强来扩展已有数据,或者通过合成数据的方式生成你需要的样本。现在像 GAN、3D 模型等工具越来越成熟,也为我们提供了更便捷、更可控的数据生成方式。
1.4
前面几节我们讲了数据的收集,这一节我们来聊聊数据的标注。
首先要问的是:你有没有足够的数据? 如果没有,那就得回到我们之前讲到的一些数据生成方法。如果你已经有了足够的数据,那么接下来就有几个选择。你可以考虑是要提升标注的质量,还是想提升模型性能。这两部分我们后面会分别讲到。
这节的重点是讲标注。如果你决定提升数据标注的质量,第一步就要问自己:有没有足够多的标注?如果有,也就是说你手上已经有一些质量还不错的标注数据,那你就可以尝试训练一个小模型,这时候可以考虑用半监督学习。如果一开始你几乎没有标注数据,那下一个问题就是:有没有足够的预算?有钱的话,可以考虑众包,招很多人来帮忙标注。那如果你既没有数据,也没有钱,那就得靠一点“聪明才智”了。也就是说,你可以自己从数据中提取一些比较粗略、带有不确定性的标注,也就是所谓的弱监督学习。
接下来我们重点来看这三种方法里的第一个:半监督学习。
很多人可能听说过,它其实是机器学习的一个分支,用来处理这样的情况:我们手上有一小部分带标注的数据,同时还有大量未标注的数据。这种情况在工业界特别常见。比如,你可能从部分用户那里收到了明确的反馈(有标注),但绝大多数用户的行为我们都无法判断他们具体的意图(无标注)。所以问题就是:如何把这小部分有标注的数据和大量无标注的数据结合起来使用?
半监督学习的核心是在一些基本假设的基础上进行的:
- 连续性假设:如果两个样本在特征上相似,那它们很可能属于相同的类别。
- 聚类假设:数据往往是成簇存在的,比如用户可能可以被分成若干行为相似的群体。同一簇里的数据倾向于有相同的标签。
- 流形假设:虽然数据表面上看是高维的,但它们可能实际上集中在一个低维空间中。因此我们可以通过降维找到更有代表性、更“干净”的数据结构。
接下来介绍一个经典的半监督学习方法:自学习(self-training),它是最简单、也是最常见的做法。它的流程大致是这样的:
-
第一步,用已有的标注数据训练一个初始模型。
-
第二步,用这个模型去预测未标注的数据,为它们生成伪标签。这些标签不是人工标注的,而是模型预测出来的,因此准确性可能会有差距。
-
第三步,把这些带伪标签的数据和原来的标注数据合在一起,重新训练一个新的模型。
然后重复这个过程。每轮训练都会使用更多数据,模型也会逐步改进。不过这里有个关键问题:伪标签的质量不一定高,有时候甚至是错误的。为了解决这个问题,我们通常只选择模型预测比较有把握的数据。比如:
- 假设你在做三分类任务,模型对某个样本判断是“类别1”的概率高达98%,另外两个类别概率只有2%,那说明它对这个预测很有信心,可以用这个伪标签。
- 但如果模型对三个类别的判断概率都差不多,比如各占30%左右,那就说明它不确定,这种情况下我们就不使用这条伪标签。
这样做的好处是避免噪音太大的样本影响模型训练,同时也让模型逐步在“容易的样本”上提升能力。随着训练的推进,一些原本模型不太有信心的样本,在后续迭代中可能会逐渐被正确识别出来,从而也能被加入训练。
最后还有两个补充点:
- 因为这些模型只是用于给数据打标签,不是上线部署的模型,所以可以不用太在意计算成本。你完全可以用更大的模型,比如深层神经网络。
- 还可以用模型集成的方式,多个模型共同预测,进一步提升伪标签的质量。这在实践中也是很常见的策略。
所以说,这里其实有两个关键点:
- 第一个是如何选择置信的数据,也就是模型对于哪些样本的预测结果足够自信,值得我们采信;
- 第二个是在不考虑计算成本的情况下,怎么让模型的精度做到更高。
我们再来看另一种常见的做法:
如果你有足够的预算,怎么办?那就请人来标注数据。这其实也是我们在绝大多数数据集中所采用的方式。我们之前也讲过,那些学术界广泛使用的数据集,大部分其实都是靠人工标注的。最早期的时候,通常是请专家来标注。所谓专家,指的是对某个领域有深入了解的人,或者你也可以说,很多大学生也可以算作“标注专家”。不过请专家标注的成本高,而且人也不好找。
过去十几年里,一个非常大的转变就是**众包(crowdsourcing)**的兴起。 众包的意思是:我在网上找一批人来帮我做标注,他们可能不是专家,但具备完成简单任务的能力,而且人很多、成本也更低。
这方面有一个非常有代表性的例子,就是 ImageNet 数据集。虽然它不是最早采用众包的方法,但它的影响力非常大,基本上开启了深度学习的热潮。ImageNet 使用了一个叫 Amazon Mechanical Turk 的平台,在上面找了大量的人来做图片标注。这个项目持续了好几年,最终标注了上百万张图片。
当然,这个工作是在十几年前完成的,那个时候还是探索阶段,所以也投入了相当的时间和成本,保守估计大概用了将近一百万美元。而在过去这十年里,通过众包来标注数据已经变成了行业常态。尤其在国内,涌现出大量所谓的“数据工厂”,也被称为“数据标注村”或“AI 村”,成千上万的人在为各种AI项目做数据标注,也诞生了很多专门提供数据服务的公司。整体来看,标注的成本已经比过去大大降低。
以美国为例,Amazon 在 AWS 上有一个叫 SageMaker Ground Truth 的服务,帮你做数据标注。他们的估算是:
- 如果你用 Amazon Mechanical Turk 来做简单的分类,比如一张图片或一段文字贴上一个标签,一个标签大概是 1.2 美分;
- 如果是画一个 bounding box(也就是在图片中圈出一个物体),每个框大约是 2.4 美分;
- 更复杂的任务,比如 语义分割(semantic segmentation)——给每个像素都标注所属类别(比如地面、汽车、人)——那就贵多了,一张图片大约是 0.84 美元。
这个听上去似乎不贵,但放到大规模项目里,成本就非常可观了。比如你要做 100 万张语义分割的图片,那标注费用就至少是 100 万美元。更何况,一张图往往不止标注一次,反复确认和清洗也是成本。相比之下,图片分类是最简单、最便宜的标注任务,但无论哪种任务,大规模下总是需要不少预算。
那如果你决定用众包来做数据标注,有哪些要点需要注意呢? 这是做数据集时经常遇到的实际问题。首先要清楚的一点是:这些标注任务是由人来完成的。而这些人不一定是技术背景出身,可能学历只有小学、初中或高中。所以你需要把任务设计得尽可能简单易懂。 界面不能太复杂,规则不能太绕。否则对方根本做不明白。
比如说,如果你给一个人一张 CT 医学图像,然后让他判断肺部有没有病变,这就太难了——因为这需要医学专业知识。所以任务的设计要足够清晰、简单。要配合好的说明文档和示例,让人一看就知道该怎么做,界面也要直观、容易上手。因为任务的难度会直接影响你能找到多少人来标注,也会影响成本。如果任务太难,就只能找有经验的人,费用自然也高;但如果任务简单,普通人就能做,那就能找到很多人,整体成本也会低很多。
因此在用众包做数据标注时,设计一个“简单但有效”的任务流程,是非常关键的一步。
第二个我们当然要关注的就是成本,毕竟这是实打实要花钱的。一般来说,成本是按小时来算的,也就是说一个标注工每小时多少钱。
那你怎么估算总成本呢?就得看你整个数据集要产生多少个标注任务,以及每个任务大概需要多长时间。把这两个数相乘,再乘上标注工的时薪,就大致能算出整个标注项目要花多少钱。当然你也可以直接找专业的标注公司,他们会帮你把整个流程打理好,从招人、培训到质检都会覆盖。不过价格也会贵一些。如果你数据量不大,又预算充足,那交给他们处理确实省心;但如果你的数据量特别大,预算又有限,那就需要自己设计任务,控制流程,把成本压下来。
除了成本,其实更关键的是标注质量。我们花钱找人标数据,当然是希望标得准确。但现实中,你请的往往不是专业人士,而是通过众包平台找到的普通人,这里就会面临一个问题:他们会犯错。
这些错误可能是因为他们本身不太懂任务内容,也可能是故意敷衍——比如为了快速赚钱,随便点一点就交了。还有一种情况是任务本身太难,导致他们根本没看懂怎么做。这种情况很常见。
举个例子,任务是让人把图里的“人”圈出来:
- 第一张标得很好,是正确答案;
- 第二张圈得太大(too big);
- 第三张只圈了上半身(too small);
- 第四张直接圈错了,比如圈到栏杆上去了。
这些错误在实际项目中非常常见,尤其是像 too big 或 too small 这种主观偏差类的问题。那我们该怎么办呢?
有几个常见的策略:
最简单的方法是:同一个任务发给多个人,然后做投票汇总。比如每条任务发给 5 个标注工,只要有 3 个以上认定是正类,那我们就把它当作最终结果。这种方法简单、有效,但也有缺点——那就是成本会直接乘以 5。原本10万元的标注预算,瞬间就变成了50万。
为了节省开销,我们可以用一些更智能的策略。比如:
- 一开始只发给 2 个人,如果结果一致就直接采纳;
- 如果结果有分歧,再发给更多人做“加判”;
- 或者结合模型的预测结果,比如人和模型都认为是正类,那我们就认为比较可靠;反之再增加人工确认。
这种做法可以实现**“重点复审”**,也就是说只对那些“模型和人都不太确定”的样本进行多次标注,从而在保证质量的同时降低重复成本。
另一个提升质量的方式是:评估标注工的能力。你可以给他们发一些你已经知道正确答案的样本,也就是所谓的“金标准(golden set)”。通过对这些样本的标注准确率,判断哪些人是靠谱的,哪些人不靠谱。如果某个工人准确率太低,就直接剔除;而那些表现好的工人,就可以继续用,甚至给予更多任务。通过这些机制,我们就可以在控制成本的同时,把标注质量提升到一个比较理想的水平。
那如果我没钱、又没标注数据,怎么办?其实这种情况在实际工作中非常常见。很多时候我们在公司里拿到一大批数据,结果发现压根没人标。自己一个个去标当然可以,但又麻烦又耗时。请别人标?那得说服公司掏钱,公司不一定愿意。要自己掏钱?更舍不得。在这种资源有限的情况下,工业界经常采用的一种方法叫做 弱监督学习(weak supervision)。这几年这个方向慢慢火起来了,也有越来越多的研究在深入。
弱监督的核心思想是:半自动地生成标注数据。虽然这些标注的质量可能没有人标得那么好,但通常也不差,足以训练出一个“能用”的模型。虽然叫“弱”标注,但其实很多时候已经“够用”。一个比较常见的方法叫 数据编程(Data Programming),它的基本思路是:用各种启发式规则,自动给数据打标签。
举个例子,比如你要判断一条 YouTube 评论是不是垃圾信息(spam)。如果让人来判断,一眼大概就能分出来。但我们要是不用人去一个个标,而是想办法写一些“规则”来判断呢?
比如:
- 规则1:评论里包含“check out”这样的短语,大概率是广告。像“Check out my website” 或 “Check out how I made $1000 in 1 week”,听着就像 spam。
- 规则2:用情绪分析模型分析文本情绪。如果情绪特别正面,比如超过 90%,可能是用户真心好评,可以标成正常评论。
- 规则3:评论字数特别少,比如少于 5 个词,像“加一”、“赞”、“牛逼”这种,通常是真人用户的短评,也可以标成正常。
每条规则其实就是一个“弱分类器”——它可能不完美,甚至经常出错,但只要你有足够多的规则,它们就能“互相补充”。你可以让它们投票,比如 10 条规则里有 7 条判断是 spam,3 条不是,那我们就认为它大概率是 spam。你也可以训练一个模型专门来融合这些规则的判断结果,自动学出更合理的标签。
这些规则可以非常灵活:关键词匹配、正则表达式、情感分析、已有模型、API 返回的结果等等。比如图片可以丢给某个图像识别 API,看看它怎么分类,也能作为一个参考标签来源。这种方法的最大优点就是成本低。你只需要写规则,就能标注上千上万条数据。哪怕标签有点噪声,也足以训练出效果不错的模型。
这就是弱监督学习的基本思路。
我们来简单总结一下目前常用的三种“数据标注”的方式:
-
自训练(self-training):当你手上有一部分已经标注的数据,可以先训练一个模型,然后让模型在未标注数据上进行预测,选出那些“置信度高”的伪标签,加入训练集,不断迭代。适合那些能“自我提升”的场景。
-
众包(crowdsourcing):把模型不确定的样本,或者你不确定的数据,发给人类标注工,由人来人工确认。适合处理模型搞不定的“难样本”。
-
弱监督学习(weak supervision):用规则、工具、已有模型来“自动”给数据打标签。虽然这些标签不是特别准,但胜在便宜且量大,能有效辅助模型训练。
实际项目中,这三种方法常常是结合使用的: 比如,先用弱监督方法快速生成一批“粗标签”,然后用自训练提升模型性能,再通过众包精修一些关键样本。这种组合方式既节省了成本,又能保证模型效果。
2.1 探索性数据分析
我们这门课的第二讲是关于数据预处理的。 在第一节里,我们先来看一个常见的步骤:探索性数据分析(EDA)。
我们用的数据集,是之前讲网页数据抽取时提到过的一个房产销售数据集。我们从网上爬下了一些卖房信息,然后抽取出了其中的结构化数据,最后把它们保存成了一个 CSV 文件。CSV 格式大家应该都熟悉了,一行代表一个样本,每列之间用逗号分隔。
接下来,我们把这个文件放进 Jupyter Notebook 里,来看看这个数据集到底长什么样。
首先,我们要引入一些常用的 Python 库。
numpy是做数值计算的基础库,几乎所有数据分析任务都会用到;pandas是处理表格数据(tabular data)最常用的工具,功能非常强大。只要你的数据可以放进内存,pandas基本上就是首选。- 如果你的数据太大,比如几个百 GB,甚至更大,那就可能需要考虑使用专门的大数据处理框架。但在数据分析阶段,
pandas绝对够用了。实在太大,也可以先做个采样,减少处理量。
除了数据处理,我们还引入了两个可视化工具:
matplotlib:这是最早的可视化工具之一,风格偏向早期的 MATLAB;seaborn:它是基于matplotlib封装的,提供了更多、更美观的图表选项,用起来也更方便。
下面两行代码,是用来设置图表输出格式的,我们把图片格式设成了 SVG。这种格式分辨率更高,特别适合我们这种用 4K 录课的场景。
默认情况下,matplotlib 输出的图像分辨率可能偏低,因为它是很早期的工具,设计时没考虑现在这么高分辨率的需求。
不过要注意的是,这个设置在某些 Python 或 Jupyter 的版本下可能已经被弃用了(deprecated),会弹出 warning。不过不用担心,就算提示了 warning,实际效果还是可以的。如果你愿意,也可以改用更新的方式来设置图像格式。
总之,前面这几个包,就是做数据探索时最常用的工具:一个处理数据,一个画图展示。
好了,工具准备好后,我们就可以把数据读进来看看了。我们之前讲网页数据抓取的时候(这个没整理)没有细讲代码实现。简单来说,我们从网页中提取每套房子的信息,存成了一行,最后写入 CSV 文件。CSV 本质上是个文本文件,文件体积也不小——大概有 700MB 左右。
一个建议是:可以把 CSV 文件先压缩成 ZIP 或 TAR 格式。压缩之后体积会小很多,比如从几百 MB 压到几十 MB。而且现在主流的数据读取工具(比如 pandas 的 read_csv)都支持直接读取压缩文件,你不用先手动解压。
为什么压缩还可能更快?因为硬盘读取是比较慢的操作,而压缩文件体积小,读取速度更快;虽然读取之后还要解压,但解压是在内存中完成的,整体反而可能比直接读未压缩文件还要快一些。
当然,这个优势主要体现在文本数据上。对于图片、视频这类数据,它们本身的格式已经做了很好的压缩,额外压缩意义就不大了。但对于 CSV 这种文本数据,压缩确实是非常推荐的。我们把数据读进来之后,可能会看到一堆 warning,这也没关系。 这些 warning 通常是因为数据中有很多异常值或者缺失值,pandas 在尝试猜测每一列的数据类型时,会发现有些列类型不确定,就会弹出提示。这其实反映了我们的数据是原始数据,也就是俗称的“脏数据”。后面我们就要来处理这些问题。
现在我们先看看数据的基本情况。从数据量来看,这个数据集大约有 16 万行,也就是 16 万个房源;每个房子我们抽取了大约 1,800 个特征。主要是加州的房子信息。我们可以用 head() 方法,打印出前几行,快速看一下数据长什么样。
比如:
id是房子的唯一编号;address是地址;price是挂牌价格;date是上架时间;summary是房子的描述信息;type是房子的类型;year是建造年份,不过也有很多是no data,说明没爬到或者本身没提供。
你会看到很多列的数据都不完整,有些是 NaN(缺失值),有些是 no data(没抓到),甚至还有些是格式错误的数据。
这就说明我们的数据集虽然大,但确实很乱。这其实很真实,数据科学家日常的第一件事,往往就是处理这种“真实世界的脏数据”。
所以在这一节里,我们主要做的就是:探索数据,了解数据的基本情况,然后为后续的清洗和处理做好准备。好,那接下来我们要做什么呢?我们刚才看过了数据集的结构,会发现有大量的列其实是缺失严重的。为了让分析更简单、模型训练更有效,我们首先做一件最基础的清洗工作:删除缺失值太多的列。
一个简单的处理办法是:如果某一列缺失的比例超过 30%,我们就把这列丢掉。 换句话说,只保留那些至少有 70% 数据是完整的列。具体做法是,先用 data.isna() 来判断每个单元格是不是缺失值(NaN),然后对每一列求和,就能得到每一列一共有多少个缺失项。再根据数据的总行数乘以 0.3(也就是 30% 的阈值),我们就能判断哪些列缺失太多,哪些可以保留。
留下来的这些列里,有很多我们关心的核心信息,比如:
- 房子的
id、地址、价格、发布日期 - 建造年份、有没有暖气/空调、停车位数量
- 卧室和卫生间的数量
- 房屋的使用面积(如居住面积、地块大小)
- 所属学区的信息
- ZIP code、税务相关字段等等
接下来我们用 pandas 的 drop 方法把那些“没被选中”的列删掉。比如:
data.drop(columns=要删除的列, inplace=True)
这里的 inplace=True 意思是我们直接修改原数据,不会返回一个新的 DataFrame。这种做法的好处是节省内存,但也有个坑——这个操作是不可逆的,你如果再运行一次同样的代码,程序可能就会报错了,因为那些列已经被删掉了。所以如果你需要重复运行代码,建议要么事先备份数据,要么不要用 inplace=True,而是赋值给一个新变量。
清理完缺失值比较严重的列之后,我们下一步要检查的就是:每一列的数据类型对不对。这其实是很重要的一步,因为如果数据类型不对,后续的分析或模型训练可能会出错。
我们先看几个典型的例子:
address是地址字段,数据类型是object,也就是字符串,这没问题。- 但像
price卖价,显然应该是数字类型,现在如果它还是object,说明我们需要手动转换。 date卖出的时间,也不应该是字符串,而应该是datetime类型。summary、type这些字段本身就是文本,保留为object没问题。year建造年份,如果显示为object,那也不太对,它应该是整数类型(int)。
再来看其他的数值字段:
- 比如
bathrooms(卫生间数量)是float类型,这就对了。因为有的房子会标成“2.5 个卫生间”,意思是两个全卫加一个半卫。半卫一般只包含洗手间,不含淋浴,所以在数据上常用 0.5 来表示。
但有一些字段,比如 lot size(地块面积),如果还是 object 类型,那就说明可能里面混入了不规范的数据,导致 pandas 没法正确推断类型。也有些字段是数字没错,但里面有些非数字的字符,比如“1,200 sqft” 这种带单位的,导致 pandas 无法自动识别为数字。所以,当你看到某一列被识别为 object,而你预期它应该是数字或时间类型,那很有可能是数据中混进了格式不统一的值。
这时候就需要做一些数据类型的转换和清洗,比如:
- 去除单位(比如“sqft”)或特殊字符(比如美元符号);
- 把字符串转成
int或float; - 把时间字段转成
datetime类型; - 对无法转换的值做处理,比如设成缺失或用默认值代替。
这些操作,我们在后面的内容中都会讲到。
总之,到目前为止,我们完成了数据预处理中的两个基础步骤:
- 删除缺失值太多的列,让数据更整洁;
- 检查并转换数据类型,为后续分析和建模做好准备。
接下来我们要处理的,是数据中最重要的几类信息——跟钱相关的字段。
这些字段原本是字符串格式的,比如像 $1,200,000,前面带有美元符号 $,中间还有逗号分隔,有时候甚至是一个横线(-)来表示没有数据。我们要做的事情,就是把这些表示金额的字段,统一转成 float 类型的数字,这样后续才能用于建模和计算。
那有哪些字段是和钱相关的呢?主要有这几个:
售价(卖出的价格)挂牌价(列出来的价格)纳税评估价(税务局认定的估值)每年缴纳的房产税
对于这些字段,我们要做的步骤大概是这样:
- 用正则表达式,把
$符号、逗号,以及表示缺失的-都去掉; - 如果某个值是空字符串(
""),我们就把它转成numpy.nan,也就是缺失值; - 最后用
astype(float)把清理好的字符串转成 float 类型。
这样处理完之后,这几列本来是字符串的金额信息,就变成了可以参与计算的数字类型。
除了金额,接下来我们还处理了一些跟面积相关的字段,比如:
居住面积(Living area)地块面积(Lot size)
这些字段的原始数据中,单位一般是 square feet(平方英尺),也有可能是 acre(英亩)。
- Square feet 是美国常用的单位,1 平方米 ≈ 10.76 square feet,所以比如说一个房子有 1,000 square feet,其实大概也就 90 多平米。
- Acre 更大,差不多类似我们说的“亩”。1 acre = 43,560 square feet。
这些面积字段通常的格式是字符串,比如 "1,200 sqft" 或 "0.5 acre",我们要做的事情也是类似的:
- 把
sqft、square feet、acre这些单位标记都删掉; - 去掉逗号;
- 把字符串转成 float;
- 如果是 acre 的数值,还要乘上 43,560,把它统一转成 square feet。
这样处理完后,我们就得到了统一单位的、数值型的面积字段。当然,除了金额和面积,其实还有很多其他的字段也需要类似的处理。但我们这里先把最重要的几列处理好——因为金额是我们预测的目标,而面积又是影响房价的关键因素,所以这两个是我们最优先要处理的。
数据转好之后,我们可以用 describe() 来快速看一下当前数据的概况。这个方法会自动提取所有数值型的字段,然后列出一些统计信息,包括:
- 非空样本数(count)
- 平均值(mean)
- 标准差(std)
- 最小值(min)
- 25%、50%、75% 分位数(quartiles)
- 最大值(max)
这些统计信息可以帮我们初步判断数据有没有问题。 比如某一列的最小值是负数,但它本来应该只有正数;或者最大值远远超过正常范围,那就可能存在脏数据或极端值。通过这些描述性统计,我们就能更好地了解数据的质量,也为接下来的处理、建模做好准备。
我们来举个例子,比如 total space 这个字段。
这个字段到底指的是什么我们也不是特别确定,但不管它代表什么,总归是和空间相关的,应该是个非负数。结果我们看了一眼,最小值居然是 -26,这明显是不对的。最大值是 9999,这个数也看着有点异常,可能是乱填的,或者是数据清洗遗漏了。
另一个字段是 garage space,也就是车库的空间,或者说能停几辆车。这个字段里也有一些奇怪的值,显然也需要我们注意。
再来看 bathrooms(卫生间数量)。 平均值是 2.3,听起来还比较合理。一个普通家庭住两三个卫生间也算正常。 但最大值居然是 256?这个一看就是有问题——256 这个数字,明显是一个 2 的指数,可能是哪位程序员图方便随便填了个默认值。所以,一个房子有 256 个卫生间,听着就很离谱。
还有一个字段是 livable area(可居住面积)。 这个字段的平均值是 3000 多 square feet,也就是 300 平米左右。考虑到我们爬的是加州的房子,这样的面积虽然偏大,但也不是完全不可能。 但问题在于它的最大值居然是 160,000 square feet,也就是将近 15,000 平米。这……虽然不能说绝对不可能,但这种超级豪宅出现得太频繁就说明数据可能存在问题了,很可能是异常值。
不过也不是所有字段都有问题。 比如说小学的评分(elementary school rating)字段。美国这类评分通常是从 1 到 10,最小值是 1,最大值是 10,看起来非常合理。平均值大概是 5.6,也符合预期——处于中等偏上的水平。
所以我们从这些统计量里可以看到:有些列是相对健康的,但有些列确实存在极端值或脏数据,需要我们后续进一步处理。
那我们接下来就先来做一个最简单的处理。比如我们刚才提到的 livable area(可居住面积),我们设定一个“合理区间”:
- 如果面积 小于 10 square feet(也就是还不到 1 平米),
- 或者 大于 10,000 square feet(也就是超过 1,000 平米),
我们都认为这些数据是不太正常的,先去掉它们。当然啦,超过 1,000 平米的豪宅肯定是存在的,但这种数据非常少见,而我们现在的目标是先做一些可视化分析,所以更关心的是主流房子的整体分布,不是那些极端豪宅。所以这一步,我们先保留居住面积在 10 到 10,000 square feet 范围内的数据,其他都先丢掉。
过滤完之后,我们发现被删掉的样本大概有 4 万个。原本我们有 16 万个房子,过滤掉 4 万个,其实问题不大。 数据清洗的时候,删掉一半、甚至三分之二,都是常见的情况。只要留下来的数据足够代表性就可以。
接下来,我们就可以看一下我们要预测的核心变量——**卖价(sale price)**的分布。在看这个分布之前,我们做了一步处理:对价格做了一个 log10 的变换,也就是取以 10 为底的对数。为什么要这么做?因为价格这个东西在房产中差异特别大,有的房子几十万,有的上千万,跨度非常大。如果直接用原始数值来画图或者建模,可能效果不太理想。log 变换之后,价格分布会更加平滑,高价房的影响被压缩了,整体分布更均匀,更有利于可视化和建模。而且在我们后面做模型预测的时候,也更可能是对 log 后的价格做预测,而不是原始值。
画出直方图之后,我们可以看到:
- 横轴是 log10 后的价格(也就是说,log10=6 对应的是 10^6,也就是 100 万美金);
- 纵轴是对应价格区间下有多少房子。
我们发现,这个分布其实是有两个小峰值的:
- 一个主峰在 **10^6(100 万美金)**左右,这和我们预期差不多,加州房价确实普遍偏高;
- 另一个小峰值出现在 10^3 左右(1000 多美金),甚至低于 1 万美金。
这就很奇怪了,房子哪有卖几千块的? 我们推测,这部分可能是我们在爬虫时不小心把出租房的价格也爬了下来。也就是说,那些几千美金的价格,可能是房子的月租或季租,而不是售价。
这种情况就是一个典型的“爬虫带来的噪音数据”。
总的来说,经过 log 变换之后,整个价格分布看起来还是比较合理的。大部分房子的价格都集中在几十万到一两百万之间,尾部还有一些价格更高的豪宅,甚至超过千万美金的。
这些分析帮我们了解了: 我们即将预测的目标值(价格)大致处于什么范围,分布是否合理,以及有没有需要先清理掉的异常值。
另外,我们来看一下房子的类型有哪些。首先,我们用 type 这一列来查看每种房子的数量,这里使用了 value_counts() 这个函数。它的作用是统计每个不同类型的房子分别出现了多少次,并且我们只显示排名靠前的20种类型。
从结果可以看到,排名第一的是 SingleFamily,翻译过来就是独栋别墅,也就是说你有一块单独的土地,上面建了一个独立的房子。这种房子在美国是最常见的一种户型。接下来是 Condo,一般来说就是我们常说的公寓。MultiFamily 指的是在一块地上建了多个房子,属于多户住宅。然后是一个空值类型。再下面是 Townhouse,也就是我们说的联排别墅,就是房屋之间是共用墙壁连接在一起的住宅。之后还有一些不太明确的类别,比如 Unknown 和 Apartment。再继续往下看的时候,你会发现有一些奇怪的类别,比如带空格的 Single Family,甚至还有一些带上了 Lot 的类别。显然,这些都是数据中的噪音,我们后面在进行数据清理时再处理。目前,我们只需要关注最主要的这几类就可以了。
接下来,我们再进一步分析一下不同类型房子的价格分布情况。这里我们只关注 SingleFamily、Condo、MultiFamily 和 Townhouse 这四种主要的房子类型。因此,我们先从数据中选取这四种类型的数据,再单独建立一个新的表格(DataFrame),然后利用 seaborn 库提供的绘图函数绘制出不同类型房子的价格分布图。
具体地说,我们使用了 seaborn 的密度分布图(density plot),以房子的售价作为横坐标(X轴),不同颜色的线表示不同类型的房子。纵轴(Y轴)显示的是每种类型房屋价格的密度分布情况。从图中可以明显看到,蓝色的曲线代表的是独栋别墅(SingleFamily)的价格分布,它的峰值大概在略高于100万美元的位置,也就是独栋别墅的价格大多集中在这个区间。
接下来再看公寓(Condo),它的曲线整体上位置稍微往左,也就是价格稍低一些。这是合理的,因为公寓一般房屋面积较小,没有单独的土地,通常比独栋别墅便宜一些。这样,通过这个密度分布图,我们能够直观地了解到房屋类型确实与房屋的售价有一定的关联。
另外,我们还可以看一下房子的单位面积价格,这也是买房时经常关注的一个指标,因为通常我们更关心每平方米(或每平方英尺)的售价。同样品质更好的地区,每平方米的价格肯定更高一些;而相对一般的地区,这个价格自然就便宜一些。
下面我们通过一种叫做箱型图(box plot)的方式,直观地分析一下不同类型房子在单位面积售价上的差异。
首先,每一种颜色代表了一种房屋类型,比如这里的 Condo、SingleFamily、MultiFamily 和 Townhouse 四种类型。箱型图的中间这条横线表示这一类房子的价格中位数,也就是说,有一半房子的单位面积价格高于它,另一半低于它。中间的这个方框(box)表示的是中间50%的房子的价格范围。方框的下边缘表示价格的25%分位数(即25%的房子单位价格低于这个值),而上边缘表示75%的分位数(即75%的房子单位价格低于这个值)。上下延伸出的这两条线则表示在合理范围内的最低和最高价格的范围,超过这个范围的价格则被视为异常值。
通过图我们可以清楚地看到,对于 Condo 这种类型来说,单位面积的中位数价格比 SingleFamily(独栋别墅)要稍高一些。也就是说,虽然 Condo 的总价通常比别墅要便宜一点,但它的单位面积价格却更高。这是因为 Condo 面积一般相对较小,而价格中又包含了土地以外的其他成本,比如公共设施维护费等。
再看 MultiFamily,也就是多户住宅类型,因为在一块地上建了多户房子,所以单位面积的成本可能更低,导致其单位面积价格相对较低。
总之,通过箱型图,我们可以非常清晰地对比不同房屋类型之间单位面积价格的差异。不仅可以看到价格的中位数位置,也能直观看到价格分布的范围,这对我们理解不同房屋类型的市场定位是很有帮助的。
接下来,我们也同样分析一下不同区域(这里用的是邮政编码)房屋单位面积价格的差异。具体的实现方式跟之前类似,这里就不重复代码了。我们将房屋数量最多的前20个邮政编码地区挑选出来,通过箱型图观察每个地区房屋单位面积价格的分布情况。图中横轴代表的是不同的邮政编码区域,纵轴代表的是房屋单位面积的售价。
通过图示,可以明显看出区域对房屋的单位价格影响很大。比如前两个邮政编码的区域,房屋单位价格明显比较便宜,即使是这两个区域价格的最高值也相对较低。但第三个区域很可能是一个富人区,我们可以看到它单位面积的最低价格,都明显比前面第二个区域的最高价格还要高得多。这说明地段或位置对房屋价格的影响是非常显著的。因此,位置特征在房屋定价上肯定是非常重要的因素之一。
除此之外,我们还可以看一下不同特征之间的相关性(Correlation),也就是它们之间的相关程度。例如,我们特别关注的是房屋实际成交价(售价)。一般而言,你可能会觉得房屋最终的成交价和卖方的挂牌价之间应该非常接近。然而,通过实际计算它们之间的相关性大约只有0.34。虽然存在一定关联,但比我们想象中的要弱一些。
事实上,在美国,每套房子每年都需要缴纳房产税。税务局每年会对房屋做一次估价,以此作为征税依据。根据分析,我们发现税务局对房屋的估价与房屋最终实际成交价的相关性更高,甚至比卖家的挂牌价格和实际成交价之间的关联更为密切。也就是说,尽管我们直觉上认为卖家的挂牌价与最终成交价应该很接近,但实际上挂牌价很可能受到卖家的主观调整,比如故意定低一些,以吸引更多买家参与竞价。因此,从数据上来看,税务局的房产评估价反而比卖方的挂牌价更能准确反映房屋的真实市场价值。
另外,我们还发现一个有趣的现象,就是房子的总价跟单位面积价格之间的相关性其实没有那么强。这一点其实也能理解,毕竟房屋的总价可能更依赖于整体的面积、位置和其他因素,而不是简单地由单位面积价格决定的。
接下来,我们再关注一下学校因素对房价的影响。在美国,孩子通常是根据房子的所在位置就近入学,这就形成了我们常说的“学区房”现象。也就是说,房子周围的小学评分和高中评分都可能会对房屋的售价产生一定影响。从我们的数据分析来看,小学评分与房价的相关性达到0.16,高中评分则达到0.19,这说明学校的质量与房屋售价之间确实有明显的正相关。另外值得注意的是,小学评分与高中评分之间也存在明显的相关性,尽管在美国,小学、初中和高中一般是分开的,甚至有些区域的小学和高中并不是同一个学校,但如果它们处在同一个社区或相邻的区域,往往也会有类似的质量水平。
通过这样的分析,我们可以清晰地了解到数据中不同特征之间的关系,也能直观地找到哪些特征跟我们的预测目标(房屋售价)更密切相关,以及哪些特征之间存在关联,哪些没有明显关联。
当然,这里只是进行了最基本的分析,实际上我们还有很多特征没有仔细研究,比如房屋的具体地址、房间数量、卫生间数量等等。此外,我们还可以研究地理位置之间的影响,比如相邻区域的房价是不是更接近,或者有没有其他更复杂的关系。一般来说,一次深入的数据探索分析可能会包含几十个甚至更多代码块,从多个不同维度去详细了解数据的各种特征。
最后,当我们完成了探索性数据分析之后,这个分析过程可以被完整地保存下来,用于后续与团队分享或者进一步的建模分析。这样做不仅可以帮助大家直观地了解数据特征,也能快速判断数据本身是否可靠。如果数据存在很多缺失值、异常值,或者数据分布不合理,那么我们后续的数据预处理和建模过程可能会更加复杂,需要做更多的工作。
2.2 数据清理
大家好,上一小节我们介绍了如何对数据进行探索性分析,让我们对数据有了更直观的了解。有了这种初步认识之后,我们接下来需要做的就是对数据进行清理,去提高数据的质量。
我们先回顾一下之前的思维导图,之前我们提到,对于机器学习来说,有了数据以后,我们要考虑怎样去提升数据的质量。如果数据本身存在很多错误或噪音,我们需要进行数据清理,去纠正这些错误;如果数据本身已经比较干净,但不适合模型直接使用,那我们需要对数据进行转换;如果数据格式合适,但对于模型来说并不容易学习,我们则需要进行特征提取或特征工程。这些内容我们会在后续的课程逐步讲解。
本小节我们重点关注的是如何清理带有噪音的数据,使其变得干净一些。那什么是数据的错误呢?简单来说,数据错误指的是我们收集到的数据与实际真实数据(Ground Truth)之间存在不一致的地方。比如,我们之前的数据分析中看到过,有些数值缺失,明明应该有但却没采集到;有些数据的值明显错误,比如本来该填的是房屋地址,但采集过程中出现了问题,误填了其他无关信息;还有一些极端值或异常值,比如房屋面积过大,或者房间数量为负数,这些都是典型的数据错误。
噪音几乎是不可避免的,但是数据中的明显错误却是可以避免的。不过,有个有趣的问题是,实际上优秀的机器学习模型对于数据中的错误具有较高的容忍性。换句话说,就算你提供的数据里面包含不少错误,模型仍然可以正常地完成训练和收敛,只不过最终效果可能会比使用干净数据时稍差一些,比如精度降低1%或2%。而这个差异却很难直接被发现,特别是当你不知道干净的数据究竟能达到什么样的精度水平时。标准的数据集(如ImageNet)还能让我们知道自己的模型与最优水平的差距是多少,但如果是实际的产品数据,你根本不知道真正干净数据下的精度能达到多少。
尽管如此,如果我们不对数据进行清理,直接把带有错误的数据用于模型训练,那么当模型上线时,新产生的数据可能会受这些错误模型的影响,产生不准确的预测结果。而这些不准确的预测又可能影响到用户的决策和反馈,进而导致收集到更多不准确的数据。如此反复,会使得数据质量和模型质量逐渐下降,造成严重的影响。
那么,数据中具体有哪些类型的错误呢?我们可以大致将数据错误分为三类:
-
第一类叫做异常值(Outliers),也就是数据中某些特征的数值超出了正常的分布范围,这些值可能是错误数据或采集时出现的问题导致的。
-
第二类是违反规则的错误(Rule Violations),指的是数据违背了我们明确设定的一些规则或限制。比如,对于预测房屋价格的数据来说,我们规定价格这一列必须是有值的,不能缺失;又比如,每个房屋ID应该是唯一的,如果出现重复,那么数据就违反了唯一性规则。
-
第三类是模式或格式错误(Pattern Violations),也就是说数据的格式、类型或语义违反了我们预定的要求。比如价格本应以美元为单位,但收集到的数据中出现了其他币种;或者本应填写
SingleFamily,却由于某种原因变成了Single Family,出现了多余的空格,这也属于格式或语义上的错误。
以上就是数据清理过程中我们要面对的典型错误类型,接下来我们会逐一讲解如何识别这些错误并进行有效处理。
首先来看一下如何处理类别型数据的异常值。比如之前我们提到过房屋类型这一列,正常情况下,独栋别墅(SingleFamily)应该是连在一起写的。但是我们发现数据里出现了像 Single Family 中间带空格的情况。这显然是一种异常值,虽然出现的次数并不多,只有69个房子,但它们不属于正常的分类范围。如何修正这个问题呢?我们只需要把空格去掉,就能把这类数据合并到正常类别中去。同样的道理,Single Family Lot 这种情况也是类似的,我们只需删除多余的空格和多余的单词,比如“Lot”,然后归并到正确的类别中就可以了。
对于类别型数据的异常值处理方式一般有两种:一种是直接删掉这些数据,另一种是手动或自动修正为正确的类别。
接下来我们再看一下,如果数据是数值型的,又该怎么检测异常值呢?之前我们提到过的箱型图(Box Plot)就能很好地解决这个问题。箱型图里,中间这根横线表示的是数据的中位数,盒子的下边缘代表25%的数据点,也就是第一四分位数,上边缘表示75%的数据点,即第三四分位数。这两个值之间的范围(盒子的高度)我们称为四分位距(IQR)。通常我们认为,这个范围内的数值属于正常范围。
接下来,我们可以用这个范围确定一个上下界,通常的做法是以盒子高度的1.5倍作为阈值,也就是说,在盒子上边缘向上延伸1.5倍的IQR,形成一个上界,在盒子下边缘向下延伸1.5倍的IQR,形成一个下界。在这两个边界之外的点,我们都将其认定为异常值(Outlier),它们通常会被单独标记出来。当然,1.5倍这个阈值并非固定不变,你也可以根据需要调整,比如使用2倍或其他值。这是一个超参数,可根据你的业务或数据特征自由调整。
除了数值型和类别型数据,我们还可以用基于规则的方法去发现数据中的异常情况。常见的方法包括两种:
-
第一种叫做函数依赖(Functional Dependencies),来自于数据库领域。它表示某一列的值能够完全确定另一列的值。比如一个邮政编码(zip code)能够唯一确定所在的州(state)。同理,每个公司的纳税编号(EIN)也能够唯一确定该公司的名称。因此,如果出现了某个邮政编码和对应的州不一致的情况,我们就能轻松发现这种错误并进行修正,或者直接剔除这种异常数据。
-
另一种更灵活的方法叫做拒绝约束(Denial Constraint),它允许你通过一阶逻辑表达式更加灵活地定义一些规则,以便更精确地发现数据问题。举个例子,比如你定义一个规则说,“如果公司有纳税标识(EIN),那么它对应的电话号码就必须填写,不能为空”。这种灵活的规则使得我们能够定义更加细致和复杂的数据质量约束,以保证数据的正确性和一致性。
再举一个基于规则的例子,比如我们现在处理动物图片的数据集,每个动物都有一个标识号码(tag number)。假如发现同一个动物对应了两张不同的照片,而这两张照片都有相同的标识号码,这种情况下,我们可以定一个规则:第一张出现的照片是原始照片(original),而第二张或更多的照片属于附加数据,如果只需要保留一张图片,其他的可以直接删除。
这样一来,通过设定这些规则,我们就能自动地去除多余或重复的数据。通常这种规则并不是一次性制定好的,而是一个不断迭代的过程:每次你分析数据时,都可能发现一些新问题,这时候你就可以把它总结成新的规则,逐步完善。
除了基于规则的检测方法,我们还可以进行基于模式的检测,这又可以分成语法检测和语义检测两种。
语法检测指的是针对数据格式或表达方式进行的检测。比如说,我们有一列表示语言类型的数据,可能出现像eng这样的缩写,这时候我们就希望把它统一转换成正式的英文名称English,并且首字母要大写。另外一种情况是,比如有一列数据本来应该全部都是整数,但却出现了一些非整数的值,那么我们需要判断这些值能否合理地转化成整数,不能转化的话,我们可能就要把它们去除掉。
语义检测则是利用知识图谱或者常识来进行的。知识图谱是一种图结构,包含了现实世界里大量的常识性知识,比如某个国家对应的首都是哪个城市,某个名人出生在哪一年等等。这种方式在自然语言处理中经常被用来辅助数据质量检查。
举个例子,比如我们数据中有一列叫做Country(国家),根据知识图谱里对国家的定义,每个国家都应该有对应的首都。如果在数据中出现了Stanford这个名字,那么我们查询知识图谱时,会发现斯坦福只是一个城市而不是国家,且它也不会有自己的首都,因此我们就能判断数据中的这个值在语义上存在问题。
无论是哪种检测方法,实际的数据清理过程都不是一次完成的,而是通过不断地交互、检查数据、发现问题、总结规律,再制定或调整规则的迭代过程。因此,市面上大部分的数据清理工具都会提供可视化的图形界面,让你能够直观地检查数据、选择你感兴趣的行和列,快速地定位异常情况,并通过简单的界面操作来定义和调整规则,而不需要自己每次都编写复杂的代码或手动操作。
好,我们最后再总结一下:
首先,数据中总是存在各种错误,这是难以避免的,即使是学术界非常常用的数据集,比如 ImageNet 甚至是其他流行的数据集,也依然存在着不少错误。哪怕是人工标注的数据,也难免会出现人为的失误。因此,数据错误是一种普遍存在的问题,我们只能尽量减少它,但无法完全消除。
通常来说,数据中的错误分为三大类:
-
第一类叫做异常值(Outlier),也就是明显偏离正常数据分布范围的数据点。对于数值型的数据来说,就是远离主要分布区间的数据点;而对于类别型数据来说,就是那些出现次数极少或不应该存在的长尾类别。
-
第二类是规则冲突(Rule Violations),数据本身与我们设定的某些约束规则发生冲突。比如数据应该满足某些特定的关系,但实际上却不满足,导致数据之间的一致性出现问题。
-
第三类是模式或格式上的错误(Pattern Violations),也就是数据在语法或语义层面不符合我们的要求。比如某些值的表示方式或表达方法与我们所期望的模式不匹配。
针对以上这些错误,我们通常可以采用自动或半自动的方法进行检测和修正。比如:
- 对于异常值,我们可以使用数据分布的检测方法,如箱型图,识别那些明显超出正常范围的数据;
- 对于规则冲突的情况,我们可以利用数据库中常用的一致性约束(integrity constraints),如函数依赖或拒绝约束等方法,自动检查和修正数据;
- 而对于模式或格式错误,我们则可以通过语法规则或借助知识图谱进行语义检查,以保证数据在逻辑和语义上的合理性。
当然,最基础的检查方法,比如直接打开Excel或利用pandas逐行逐列地查看数据,也是一种可行的方式。不过在实际的生产中,一般都有一些专门的数据清理工具,它们提供了直观的图形界面,支持交互式地探索和修改数据,同时也提供了一些自动化或半自动化的异常检测与修正功能,让数据清理变得更加高效。
不过需要指出的是,目前市面上的数据清理工具各有各的优缺点,最新的一些研究表明,一个工具可能只擅长处理某几种特定类型的错误,但无法涵盖所有情况。因此,在实践中,我们通常会选择使用多个工具进行综合处理,以尽可能确保数据质量满足实际要求。
2.3 数据的变换
我们来讲第二课的第三小节——数据的变换。我们知道,数据从原始的样子到最后进入机器学习模型中,往往需要经过多次的变换。不同的机器学习算法对数据的要求也各不相同。通常来说,数据变形包括了数据预处理中的大部分步骤,这些步骤包括我们上一节讲过的数据清理,以及下一节要讲的特征工程。
这一节我们特别关注的是这两个部分之间的数据变换。具体来说,首先我们收集了原始数据,接着进行了标注和清理,这些内容我们之前已经讲过。然后,我们要对数据进行变形,变形后的数据再进行特征提取,最后用来训练模型。所以,数据标注清理之后、特征工程之前的这一部分,我们就称为数据变形。虽然数据变形有时也可以发生在特征工程之后,甚至之前,但我们通常将其放在这个阶段来讨论。
接下来,我们来看一下常见的数据类型如何进行变形。首先,如果我们关注的是一些实数值特征,常用的变换方法之一是规范化(normalization)。这里列出了四种常见的方法,分别是我们后续处理中经常使用的方式。第一种是将一个特征列的最小值和最大值限定在一个固定的区间内,然后通过线性变换把所有原始值映射到这个区间。具体来说,如果我们要将这一列的最小值设为a,最大值设为b,那么对于这一列中的第i个元素,我们首先减去该列的最小值,再除以该列的最大值和最小值之差,这样就得到了该元素在原始区间中的相对位置。然后,乘以新的区间大小(b - a),最后加上最小值a。经过这种线性变换后,新的值就会在a和b之间。这样做是为了避免机器学习算法在处理数据时遇到数值不稳定的问题,特别是当某些特征的数值范围过大时,可能会导致算法在计算梯度或更新时不稳定。通过规范化处理,可以确保不同特征的数值范围差异不至于影响模型的训练。
另一种常用的规范化方法是Z-score标准化。与第一种方法不同,Z-score标准化不需要指定区间,而是将特征的均值调整为0,方差调整为1。具体做法是,对每个元素减去该列的均值,再除以该列的标准差,得到标准化后的值。这个方法非常常用,因为它简单有效,而且通常能得到不错的效果。
除了这两种,还有两种方法也偶尔会用到。第一种是将特征值缩放到0到1之间,或者-1到1之间。这是通过将特征值除以一个合适的常数(比如10^j)来实现的,其中j是使得所有数值的绝对值都小于1的最小值。第二种方法是对数变换,通常用于处理那些总是大于零且数值变化较大的特征。比如我们在做房价预测时,常常会对房价数据进行对数变换。这样做的好处是可以将数据的尺度压缩,使得模型在处理这些数值时更为稳定。尤其是当数据的波动范围较大时,使用对数变换后,模型的误差就变得更加平滑,计算和预测的误差也更容易控制。例如,对于房价预测,增加或减少1000块钱对价格较低的房产影响较大,但对高价房产的影响就较小。通过对数变换,模型更加关注相对误差,而不是绝对误差,从而提高了对不同价格水平房产的预测准确性。
通过这些数据变换,我们可以让数据在模型训练过程中变得更加稳定,减少极端数值对模型的影响,从而提高机器学习算法的表现。
我们刚才讲了对实数值进行规范化(normalization)的一些方法,现在我们来讨论另一种方法——对实数值进行离散化(discretization)。这种方法是将原始的数值数据,比如年龄、收入等,转换为不同的类别。我们并不关心具体的数值,而是关注这些数值所属的类别。例如,对于年龄,我们可能更关心一个人是青年、中年还是老年,而对于收入,我们可能关心的是高收入、中等收入还是低收入。
常见的离散化方法有几种。第一种是等宽分箱,它将整个数值区间平均地分成若干个区间。比如说,如果我们要处理的年龄范围是0到100岁,我们可以将其分为10个区间,每个区间长度为10。然后,我们就把每个年龄值归入相应的区间。第二种是等频分箱,这种方法不是按区间划分,而是保证每个箱子包含相同数量的样本。假设我们有100个样本,要分成5个箱子,那么每个箱子里就应该有20个样本。我们按照数值的大小对样本进行排序,再按每20个一组进行划分。
第三种方法是基于聚类的方法,比如使用k-means聚类,将数据看作一维向量,然后进行聚类,最后根据聚类结果把数据归为不同的类别。
还有一种方法是基于业务规则的离散化,例如我们根据行业经验,将30岁以下的人定义为青年,30到50岁的人定义为中年,50岁以上的人定义为老年。这种方法的优点是可以根据实际业务需要进行分类,同时把数值型变量转化为类别型变量,这样对于某些模型(如树模型)来说,处理起来会更加方便,且具有更好的解释性。我们可以说某个人属于高收入群体,而不是单纯地给出他的具体收入。
接下来我们讲一下类别变量。很多时候,数据中的变量是类别数据,比如性别(男或女)或城市(北京、上海、广州等)。这些类别变量不能直接用于模型,因为模型只能处理数字类型的输入。所以,我们需要对这些类别数据进行编码。常见的编码方法有几种。最常见的是one-hot编码,这种方法将每一个类别值转化为一个新的列。比如,性别有“男”和“女”两个类别,我们就创建两列,分别表示“男”和“女”,如果一个样本的性别是男,那么“男”这一列为1,其他列为0。这样,类别变量就转化成了多个0-1的变量,适用于像逻辑回归或神经网络等模型。
另一种方法是标签编码(label encoding),即将类别变量直接转换为数字,比如将“男”编码为0,“女”编码为1。对于像“北京”“上海”“广州”这样的城市,也可以按顺序编码为0、1、2。虽然这种方法简单,但它有一个问题:模型可能会错误地认为这些数值之间有大小关系,实际上类别之间并没有顺序关系。因此,这种方法适用于那些本身有顺序关系的类别变量,例如教育程度(小学、初中、高中、大学等),因为这些类别有明确的顺序。
还有一种方法是目标编码(target encoding),它根据目标变量的值来编码类别变量。比如,如果我们预测用户是否购买产品,可以计算每个类别下的购买概率,并将这个概率作为该类别的编码。比如,北京用户的购买率是0.2,上海是0.3,广州是0.5,那么我们就将“北京”编码为0.2,“上海”编码为0.3,“广州”编码为0.5。这种方法能够捕捉类别与目标变量之间的关系,但也容易导致过拟合,因此我们通常需要采用平滑技术或交叉验证来防止信息泄漏。
此外,还有一些更复杂的编码方法,比如通过embedding技术将类别变量转化为向量,这在神经网络中应用广泛,尤其是在自然语言处理中,如词嵌入(word embedding)方法。
最后,我们讨论一下时间类型的数据。时间数据也经常需要变换。假设我们有一个时间戳,我们可以从中提取出年、月、日、星期、小时等信息,这些才是对模型有用的特征。例如,用户是在周末购物还是工作日购物,或者是早上购物还是晚上购物,这些时间相关的行为模式对模型非常有价值。
此外,我们还经常使用滑动窗口来构造一些时间特征。例如,可以计算过去7天内用户的平均活跃次数,或者过去30天的消费总额,这些特征都是基于时间序列构造的。我们还可能对时间序列进行差分,比如计算今天和昨天的差值,或者计算增长率等,这些也是常用的数据变形手段。
总结一下,这一节我们讲了从数据清洗到进入特征工程之前,数据变形的常见方法。包括对实数值进行规范化和离散化,对类别变量进行编码,以及对时间类型数据的处理。这些变形方法有助于将数据转化为更适合模型处理的形式,提高建模的效果。
2.4 特征工程
本小节我们讲的是特征工程。为什么需要特征工程呢?这是因为机器学习算法往往更喜欢那些预定义好、结构清晰的输入输出,而且这些输入通常是固定长度的。早在深度学习普及之前,大约十年前,特征工程几乎是整个机器学习流程中最重要的一部分。当时,机器学习算法选择相对较少,比如SVM或线性模型。那时候,我们通常需要花很多时间去做特征工程,将应用场景中的数据转换成这些模型能够理解的形式。
即使在计算机视觉领域,那时的研究大多集中在如何从图片中提取特征,最后用SVM等算法进行分类。比如,给定一张图片,你的任务就是通过一些手动的方法(如SIFT等)将每个像素的特征提取成固定长度的向量,并使得这些向量适合SVM算法。这种方法有一个问题,就是手动提取特征往往无法找到最优的特征选择,但当时在机器学习中,这个过程几乎没有什么改变,主要就是调参和改进特征工程。
深度学习没有改变特征工程的流程,但它带来了一个重大变化:在处理图像时,不再需要手动选择特征。人工选择特征的问题在于,我们可能只发现了部分有用的特征,而有些潜在的特征我们可能没能识别到。深度学习通过神经网络来自动学习这些特征。神经网络通过不同的层次结构,使得它能够从原始数据中自动抽取有用的特征,从而不需要我们手动干预。最终,这些特征可以通过简单的线性分类器来进行处理,甚至不需要像SVM这样的复杂模型。
深度学习的关键在于,它将特征提取和模型训练结合在一起。也就是说,神经网络不仅在学习如何处理输入数据,还会学习如何从数据中抽取最适合的特征。这和传统方法不同,传统方法中我们通常先手动抽取特征,然后再进行模型训练。深度学习通过联合训练,使得模型能够在训练过程中自动优化特征抽取的过程,从而减少了人为的偏差。
虽然深度学习让特征工程变得相对简单,但它的缺点在于对数据和计算资源的需求非常高。
接下来我们讲几种常见的数据特征工程方法。首先,我们从表格数据(Tabular)开始。如果某一列是整数型或者浮点型数据,你可以直接将这些数据放入机器学习模型中使用。但如果需要进行处理,可以将这些数值变成离散的区间。具体方法是,先找出数据的最大值和最小值,然后将数据划分为n个区间(bins)。接着,如果某个数据点落在某个区间内,就给该数据点分配一个区间编号,即第i个区间。在这种情况下,原本是一个实数值的特征,可以转化为一个长度为n的向量,其中第i个元素为1,表示该数据点落在第i个区间内。
这种方法的好处是,它避免了机器学习算法过于依赖数值的精确度。比如在房价预测中,房价是100万和101万,虽然数值上有差异,但实际上这两个数值对我们来说可能没有太大区别。然而,机器学习模型可能会因为数值上的微小差距而把它们视为不同的输入。通过区间划分,我们就可以减少这种细粒度的区别,告诉模型这些细微的差距不需要特别关注,只需要根据更粗粒度的特征进行处理。
对于类别变量(categorical variable),常见的处理方法是独热编码(one-hot encoding)。具体操作是,假设你有一个包含若干类别的数据列,你首先需要抽取出该列的所有不同值,形成一个词典。比如,有一个列包含"鱼"、"猫"、"狗"这些类别,那么你的词典就包括这三种类别。如果有5种类别,那么你就会为每个类别创建一个新的列,每个新列代表一个类别。对于每一行数据,如果它属于某个类别(比如"猫"),那么对应的类别列中就标记为1,其他列标记为0。比如,"猫"的行在"猫"这一列中会是1,其他列都是0。
这种编码方式的优点是将类别变量转化为数值型变量,便于机器学习算法处理。这里的"one-hot"意思是,虽然我们可能会创建多个新列,但是每行数据中只有一个列会是1,其他列都是0,这样就能确保每个类别都有一个唯一的标识。
然而,如果类别的数量特别多,使用独热编码会生成大量的列。例如,如果你的数据列中有10万个不同的类别,独热编码就会创建10万个新列,这可能会导致维度爆炸,增加计算的复杂度。为了减少这种情况,我们可以对类别进行简化。举个例子,如果有很多种房屋类型,虽然它们的种类很多,但实际使用中只有前10种类型比较常见,其余的很少出现,可能只是噪音或无关信息。这个时候,我们可以将这些不常见的类别合并为一个统一的类别,比如"Unknown",将它们归为一类,这样就能有效减少新列的数量,使得特征工程更简洁、更易管理。
通过这种方式,我们能够保留重要的类别信息,同时避免过多不常见的类别占用过多的计算资源。
接下来,我们讲一下时间特征的处理。如果你有时间数据,比如交易发生的日期,时间特征的编码方式有很多种。举个例子,我们可以将时间信息展开成几个不同的列,例如:年、月、日、小时、分钟等。这些时间特征能够帮助模型更好地理解时间的影响。
比如说,如果我们把年份、月份、日期分别提取成独立的特征,机器学习模型就能够直接使用这些信息进行训练。另外,我们还可以把日期转换为一年中的第几天(最多365天或者366天,如果是闰年),或者把日期转化为这一年中的某个星期。我们也可以提取出一周中的星期几(例如0表示星期天,1表示星期一,以此类推)。这样做的好处是让模型能够识别时间上的周期性,例如工作日和周末的行为差异。假设某个交易发生在周末,或者是节假日,人的行为通常会有所不同。通过这些时间特征,模型能更好地捕捉到这些行为模式,从而做出更准确的预测。
接下来,第四个方法是特征组合。我们可以将多个特征组合起来,创建新的特征。例如,假设我们有两个特征:一个是动物的种类(猫或狗),另一个是动物的性别(男或女)。我们可以将这两个特征进行组合,得到新的特征。具体来说,假设我们使用了独热编码处理这两个特征,那么对于“猫”和“男”这两个类别,模型会创建两个新的列:一个是“猫-男”,另一个是“猫-女”。这样,如果某个样本同时属于“猫”和“男”,那么对应的“猫-男”这一列的值就是1,其他列的值都是0。
如果我们有多个类别特征,并将它们组合起来,生成的新的特征列数将是原始列数的乘积。例如,如果一个特征有n个类别,另一个特征有m个类别,那么组合后的特征就会有n \times m个新的列。这种方法能够帮助模型捕捉不同特征之间的相关性。例如,“猫”和“男”可能会有特定的行为模式,我们通过将它们组合成一个新的特征,就能够让机器学习模型更清晰地识别这些相关信息。
这种特征组合的方式可以从数学的角度看作是两个特征矩阵的外积(outer join),通过这种方式,我们可以显式地告诉模型某些特征组合的重要性。这使得模型能够更好地理解特征之间的相互关系,并在训练过程中挖掘出潜在的模式。
总结来说,这些方法是常见的表格数据(Tabular data)特征工程技术。通过时间特征的处理,我们可以让模型理解时间上的周期性;而通过特征组合,我们可以让模型捕捉不同特征之间的关联,从而提升模型的表现。
接下来我们讲一下文本数据的特征工程。对于文本数据,首先我们需要将文本转化为机器学习模型可以理解的数值格式。通常,文本会被分解成词元(token),这些词元可以是一个单词或一个词组。然后,基于这些词元,我们可以使用不同的方式来表示文本。
一种常见的方式是使用词袋模型(Bag of Words)。在这个方法中,我们将文本中的每一个词元使用独热编码(one-hot encoding)表示出来,并把这些词的表示合并成一个向量。举个例子,如果我们有一句话,包含词语“狗”,“猫”和“恐龙”,我们首先会构建一个词典,然后将这些词转化为独热编码。如果“恐龙”不在词典中,它就会被标记为一个“未知”类别(Unknown)。这样,句子中的每个词就被转换成了一个向量,向量中的每个元素代表一个词是否在该句子中出现。
然而,这种方法的一个问题是,虽然词袋模型能捕捉到文本中的单词信息,但它忽略了单词之间的顺序关系。对于许多自然语言处理任务,顺序信息是非常重要的,例如在翻译、情感分析等任务中,单词的顺序可能会极大地影响句子的意义。词袋模型在处理这类信息时是有限的,因为它只关心词语是否出现,而不关心它们出现的顺序。
另一种常见的文本表示方法是词嵌入(word embedding)。词嵌入通过将每个词映射到一个高维空间中的向量,保留了词语之间的语义关系。常见的词嵌入方法有Word2Vec,它通过一个神经网络模型训练,学习到词语的上下文信息。具体来说,Word2Vec通过预测一个词的上下文来训练模型,使得语义相似的词在向量空间中更靠近。例如,"猫"和"狗"可能会有相似的向量表示,因为它们在语义上是相似的。词嵌入的好处是,它不仅能够捕捉到词语的基本信息,还能捕捉到词语之间的语义相似性。
对于句子的表示,我们可以将句子中的每个词的词嵌入向量加起来,或者取它们的平均值,来表示整个句子。这样,整个句子的向量表示就包含了所有词的语义信息,可以用于进一步的分析。
此外,现在更为流行的方法是使用预训练的语言模型,比如BERT和GPT-3。这些模型与Word2Vec的原理相似,但它们更为强大。BERT和GPT-3等基于Transformer结构的模型,通过大量文本数据的预训练,能够学习到更加丰富的上下文信息。与Word2Vec相比,Transformer模型不仅仅是基于单个词的上下文,还能够考虑到句子中所有词的上下文信息。这使得它们在文本理解和生成任务中表现得非常强大。
BERT和GPT-3是通过自监督学习进行训练的,即它们通过学习如何预测文本中的某些部分来学习语言的结构和语义。在实际应用中,我们可以将文本输入到这些预训练的模型中,模型会为每个词返回一个向量表示,这个表示包含了该词在上下文中的信息。
虽然这些预训练模型非常强大,但它们也有一些缺点。首先,训练这样的模型需要大量的计算资源,而且它们的模型非常庞大,存储和计算开销也非常高。此外,在某些特定任务中,虽然BERT和GPT-3能够提供强大的特征表示,但在资源有限的情况下,你可能需要做一些微调(fine-tuning),以便更好地适应具体任务。
总结来说,文本数据的特征工程方法主要包括词袋模型和词嵌入两种方法。词袋模型虽然简单,但无法保留词语的顺序信息;而词嵌入则能保留词语之间的语义关系,常用的词嵌入方法有Word2Vec。现在,预训练的大型语言模型如BERT和GPT-3能够提供更强大的特征表示,尽管它们需要更多的计算资源。选择哪种方法,取决于你的任务需求和计算资源的限制。
接下来我们讨论图片和视频数据的特征提取。在深度学习普及之前,处理图片数据通常需要手动提取特征,例如使用SIFT(尺度不变特征变换)等方法来提取关键点特征。然而,随着深度学习的发展,特别是在文本领域的成功应用后,图像和视频的特征提取也发生了巨大变化。现在,几乎所有的图像处理都使用预训练的深度神经网络来进行特征抽取。
举个例子,如果我们处理图像数据,通常会使用在ImageNet数据集上预训练的深度网络模型,比如ResNet(残差网络)。在这种情况下,处理图像时,我们会将图像输入到预训练好的ResNet模型中,得到倒数第二层的输出特征向量,而不是最终的分类结果。这个特征向量可以作为图像的表示,之后我们可以将这些特征应用到其他任务中,例如图像检索、图像分类等。
对于视频数据,处理方法与图像类似。我们也会使用预训练好的深度神经网络来提取视频的特征。例如,使用在Kinetics数据集上预训练的I3D(Inflated 3D Convolutional Network)模型来提取视频中的动态特征。将视频输入到这个模型中后,模型会输出一个特征向量,代表视频的内容。这些特征可以用于其他任务,如视频分类、动作识别等。
这种方法类似于文本处理中的方法,都是通过在大规模数据集上预训练的深度学习模型来提取有用的特征。尽管这些模型是基于不同的数据集训练的,但它们能够捕捉到一些具有通用性的特征,因此可以迁移到其他任务中,这也就是迁移学习的核心思想。
总结来说,特征提取在机器学习中至关重要。模型的选择固然重要,但如何将原始数据转化为模型能够处理的特征也是一个巨大的挑战。在深度学习流行之前,特征工程是机器学习过程中的核心部分,尤其是在处理图像、视频和文本等非结构化数据时,手动特征提取占据了大部分工作量。而如今,深度学习方法已经成为图像、视频、文本等数据特征提取的主流方法,预训练模型的迁移学习为特征提取提供了强大的支持。
然而,对于表格数据(Tabular Data),情况略有不同。由于缺乏像ImageNet、Kinetics这样的大规模公共数据集来训练大规模神经网络,表格数据的特征提取仍然需要更多的人工干预和传统的方法。因此,虽然深度学习在处理非结构化数据上表现出色,但对于表格数据,特征工程依然非常重要。
在后续的迁移学习部分,我们将深入讨论如何使用预训练模型进行迁移学习,以及如何在不同的应用场景中有效地提取和利用特征。
3.1 机器学习简单介绍
在这一小节,我们将对机器学习模型做一个简单的介绍。机器学习算法大致可以分为几类,首先我们来看看监督学习。
监督学习(Supervised Learning) 是最常见的一种机器学习方法。在监督学习中,我们会使用带有标签的数据来训练模型。模型的任务是通过学习数据中的输入与标签之间的关系,来预测新的数据的标签。例如,在房价预测中,我们使用已知房价的数据来训练模型,模型学习如何根据不同特征(如房间数、面积等)来预测房子的价格;在图片分类任务中,我们通过已标注的图像数据来训练模型,使其能够识别新的图像属于哪个类别。
最近,自监督学习(Self-supervised Learning) 作为一种新兴的技术,也引起了广泛关注。自监督学习本质上也是一种监督学习,但它的标签不是来自人工标注,而是从数据本身生成的。比如在自然语言处理(NLP)中,BERT这类模型就采用自监督学习方法,它通过掩盖句子中的一部分词语,然后训练模型去预测这些被掩盖的词,从而生成标签。
半监督学习(Semi-supervised Learning) 是介于监督学习和无监督学习之间的一种方法。在半监督学习中,我们拥有一部分已标注的数据,但也有大量未标注的数据。半监督学习的目标是利用已标注数据和未标注数据来训练模型,从而提高预测准确性。例如,在自训练方法中,我们使用已有标注的数据训练一个初步模型,然后利用这个模型预测未标注数据的标签,最后将高置信度的预测结果作为新的标注数据,持续训练模型,直到达到预期的效果。
与此相对的是无监督学习(Unsupervised Learning),在这种学习方法中,数据没有标签,模型的任务不是预测标签,而是发现数据中的结构或模式。无监督学习常见的任务包括聚类(Clustering),即根据数据的相似性将其分成不同的类别;以及密度估计(Density Estimation),即估计数据的分布。近年来,**生成对抗网络(GAN)**也属于无监督学习的一种,它通过两个网络的对抗训练来生成逼真的图像、音频或其他数据。
强化学习(Reinforcement Learning) 与上述几种方法不同,它是通过模型与环境的交互来进行学习。强化学习的核心是通过模型与环境互动,获取观察值,并根据这些观察值执行行为,进而获得环境的反馈(奖励或惩罚),模型通过不断调整自己的行为策略,最大化长期奖励。强化学习最典型的应用包括机器人学、游戏AI等。比如,在训练机器人走路时,机器人需要不断尝试不同的动作,看看是否能够保持平衡,摔倒就意味着需要调整策略,而不摔倒则继续执行现有的策略。
尽管强化学习的表现令人印象深刻,但它的学习过程较为复杂,因为它需要与环境持续互动,并且在很多情况下是难以控制的。因此,强化学习并不是我们通常讨论的机器学习类型,大多数应用仍然集中在监督学习和无监督学习上。
在我们这门课中,主要讨论的是监督学习,它是工业界中最常用、最主流的任务类型。接下来我们讲解监督学习的训练过程。要进行监督学习的模型训练,我们需要关注几个重要的部分。
首先是模型本身。模型的作用是将输入转换为输出。在房价预测的例子中,模型的输入可能是房子的各种特征(如面积、房间数等),输出是房子的预测价格。模型的核心任务就是将原始数据转化为我们关心的目标,比如在这个例子中就是预测房价。
然后是损失函数(Loss Function)。损失函数用于衡量模型预测值和真实值之间的差距。简单来说,我们需要一个度量标准来告诉模型它的预测结果与真实结果相差多少。比如在回归问题(例如房价预测)中,我们通常使用**均方误差(Mean Squared Error, MSE)**作为损失函数,它计算的是预测值和真实值之间差异的平方。公式如下:
- 其中,y_i 是真实值,\hat{y}_i 是预测值,n 是样本数。通过这个损失函数,我们能够衡量模型的预测效果。
接下来是目标函数(Objective Function)。目标函数通常是我们希望优化的内容,目标是通过优化过程最小化损失函数。在监督学习中,目标就是最小化模型在训练数据上的预测误差,使得模型预测的值尽量接近真实值。这是我们训练模型的基本目标。
最后是优化(Optimization)。优化的目标是调整模型中的参数,使得损失函数最小化。在训练过程中,模型的参数(如权重、偏置等)是通过不断迭代和调整来优化的。我们通常使用梯度下降等优化算法来更新这些参数,以减少损失函数的值,从而使得模型更加准确。
总结一下,监督学习的训练过程包含四个关键部分:
- 模型:决定如何将输入映射到输出。
- 损失函数:衡量模型预测值与真实值之间的差异。
- 目标函数:定义我们优化的目标,通常是最小化损失。
- 优化:通过调整模型参数,使损失函数最小化。
接着,我们来介绍几种常见的监督学习模型,它们可以大致分为以下四类:
-
决策树(Decision Trees):决策树使用树形结构进行决策。每个内部节点代表一个特征的判断,每个叶子节点代表一个类别或数值。决策树通过逐步分割数据空间来做出决策。
-
线性模型(Linear Models):线性模型通过输入特征的线性组合来进行决策。例如,在回归问题中,线性回归模型会将特征的加权和作为预测值。
-
核方法(Kernel Methods):核方法使用一个核函数来衡量样本特征之间的相似度,通过核函数的变换,将数据映射到更高维的空间,从而达到非线性的效果。这类方法广泛应用于支持向量机(SVM)等算法。
-
神经网络(Neural Networks):神经网络通过多层神经元的连接来学习数据的特征表示。在最后一层,通常接上一个线性层来进行最终预测。神经网络能够学习复杂的非线性关系,适用于大规模数据集。
在接下来的几个小节中,我们将分别对决策树、线性模型和神经网络进行简要介绍,帮助大家更好地理解这些常见的监督学习模型。
3.2 决策树
顾名思义,决策树就是用树状结构来辅助做决策。我们先通过两个简单的例子来看看它是怎么工作的。
第一个例子是分类问题。其实我们之前已经见过它了——在讲数据处理那一部分时,用到过决策树。我们来看一个简化版本:
假设我们要启动一个机器学习任务,首先要判断有没有足够的数据。这就是一个内部结点,你需要在这里做出“是”还是“不是”的判断。如果答案是否,那接下来的操作就是收集更多数据,这是一个叶子结点,对应一个具体的操作或输出。如果答案是,那就继续判断,比如是否需要对数据进行标注。如果是,那就进入“标注数据”这个叶子结点;如果不是,还可以继续判断,直到最后落到某个叶子结点,输出分类结果。
整个过程中,内部结点就是做判断的地方——你拿到一条数据,在每个结点上根据特征的值决定往左走还是往右走,直到到达一个叶子结点,得到最终的输出类别。
如果是用于回归,整体思路是一样的,不同之处在于叶子结点不再输出类别,而是一个实数值。
比如说,我们想预测一套房子的价格。这个简化版的决策树可能先判断房子是不是在Palo Alto(一个城市的名字,我家就在那儿)。如果是,就继续看房子的居住面积是不是大于2000平方英尺(大概相当于200平方米,已经很大了)。如果面积也符合,那预测的价格是420万美元;如果面积没那么大,那可能是280万美元。反过来,如果房子不在Palo Alto,那可能更便宜,比如120万美元。
这只是一个非常简化的示例。实际中,真实的数据和建出来的树通常要复杂得多。但这个例子足以说明基本原理。
那决策树的优点是什么呢?一个很大的好处是可解释性强。模型训练出来之后,我们可以清楚地看到每个叶子结点代表什么,每一步决策是怎么做出来的。这在机器学习模型里是比较少见的特性,也让它在像银行、保险等行业中很受欢迎。比如在贷款审批场景中,银行需要给出“为什么批”或“为什么不批”的理由,决策树可以很直观地提供解释,也方便与客户沟通。另外,决策树既能处理数值型特征,也能处理类别型特征。数值型的判断通常是“大于某个值还是小于某个值”;类别型的判断则是“是否等于某个特定的值”。
当然,它的缺点也很明显。一个问题是稳定性差。它对数据的变化非常敏感,哪怕数据中有一些噪音,生成出来的整棵树的结构可能就会完全不同。比如原本某个结点用的是“房屋面积”这个特征,数据稍有变化可能就变成用“房龄”了,整个决策路径都跟着变了。再加上它是基于训练数据不断分裂出来的结构,所以树的形状和数值很容易被数据噪音干扰。针对这个问题,后面我们会介绍一种叫做集成学习(Ensemble Learning)的方法,用多个模型组合起来提高稳定性和性能。
第二个问题是,如果数据本身非常复杂,那决策树在试图覆盖所有情况时,可能会生成一棵非常庞大、复杂的树。这种情况下,虽然模型在训练数据上表现得很好,但往往会出现过拟合。我们稍后会专门讲什么是过拟合。
针对过拟合,一个常用的解决办法是对树进行“剪枝”。具体来说,可以在训练过程中设定一个复杂度阈值,当模型开始变得过于复杂时就提前停止生成;或者在训练完成后,把一些过深、对整体预测贡献不大的结点剪掉,简化模型。
第三个问题是决策树在结构上是顺序生成的,也就是说,从根节点开始一步步往下分裂,需要大量的 if-else 判断语句。这种结构不太适合并行化处理。而现代机器学习,尤其是深度学习,通常会在 GPU 上运行,利用上千个计算核心同时处理任务,甚至在 CPU 上我们也常常用几十个线程并行计算。相比之下,决策树就不太容易在这些平台上发挥出并行的优势,这也在实际部署中可能会影响它的执行效率。
之前我们提到,决策树的稳定性较差,不过我们可以通过一些方法提升它的稳定性,其中最常见的就是随机森林(Random Forest)。随机森林的基本思想是:一棵树可能不稳定,那我就生成很多棵树,把它们组合在一起,构成一片“森林”。也就是说,我们训练多个决策树,每棵树都是独立训练的,最后把它们的预测结果综合起来使用。
对于分类任务,我们可以采用投票的方式来决定最终类别:多数树的判断结果就是最终的分类结果。对于回归任务,可以取所有树预测值的平均值作为最终输出。当然,这种方法也有代价。比如说,如果我们训练了10棵树,那么训练时间和预测成本大约是原来的10倍。但好处是显而易见的:整体模型变得更加稳定、鲁棒。
“随机”森林中的“随机”并不是随便说说的,它背后有具体机制。主要来自两个方面:
-
第一是 Bagging(Bootstrap Aggregating)。它的做法是在原始训练集上有放回地进行随机采样,生成多个不同的数据子集。比如原始样本是 1 到 5,我们每次从中随机选出 5 个样本(允许重复),可能得到的数据子集是 1、2、2、3、4。每个这样的子集都会被用来训练一棵树。
-
第二个随机性来自特征选择。在训练每棵树时,我们不仅在样本维度上进行了采样,在特征维度上也会随机选择部分特征来训练。也就是说,每棵树在训练时使用的是样本子集 + 特征子集的组合,这样每棵树看到的数据都略有不同。
通过这种双重随机性——样本的随机采样加上特征的随机选择——我们就能训练出一批多样性较高的决策树。这些树组合起来,构成一个更强大、更稳定的模型,也就是我们所说的随机森林。
另一种常用来提升决策树模型性能的方法叫做 Boosting,其中最常见的一种是 Gradient Boosting(梯度提升)。
Boosting 和前面的随机森林一样,也是通过训练多棵树来提升整体模型的表现。但两者的主要区别在于,随机森林中的树是“并行”训练的,彼此独立;而 Boosting 中的树是“顺序”训练的,每棵树都在试图弥补前面模型的不足,最终形成一个更强的整体模型。
具体来说,假设我们打算训练 n 棵树。我们从时间步 t = 1 开始,一开始可以用一个很简单的模型,比如常数函数或一棵空树,作为初始预测函数。记当前时刻的模型为 F_t(x),它是前 t-1 棵树输出的总和,也就是:
与随机森林中的投票或平均不同,这里是“求和”。每一棵树的预测都是一个函数,我们把这些函数加起来,得到当前整体的模型输出。
在第 t 步,我们训练一棵新的树 f_t(x),但不是在原始的目标值 y_i 上训练,而是在“残差”上。所谓残差,指的是当前模型预测值与真实值之间的差异,也就是:
我们用这些残差作为新的训练目标,训练下一棵树 f_t 来拟合这部分还没有学好的信息。训练好之后,把这棵树加入到当前模型中:
这个过程不断迭代,每一步都在纠正前一步的误差,逐步逼近真实值,直到模型收敛或达到预设的棵树数量。
这个思想其实和深度学习中的 ResNet(残差网络)非常相似,ResNet 也是每一层都在尝试去“补上”前面网络没有学好的那部分。为什么叫 Gradient Boosting 呢?是因为这个“残差”,可以被看作是在最小化某个损失函数(比如均方误差)时,对当前模型输出求梯度后的负值。也就是说,每一步我们都在用一棵新树去逼近损失函数的负梯度方向——这就是梯度下降的核心思想。
总结一下,树模型是机器学习中少有的既能分类也能回归、同时又具有良好可解释性的模型。但它也有不足,比如容易受到数据噪音影响,稳定性较差。为了解决这个问题,我们可以通过集成多个树来减少偏差和方差。
我们介绍了两种集成方法:
- 随机森林(Random Forest):并行训练多棵树,每棵树在不同的数据子集和特征子集上训练,最后通过投票或平均得到预测结果;
- 梯度提升树(Gradient Boosting Tree):顺序地训练多棵树,每棵树都针对前面模型预测中没学好的部分进行补充,逐步提高预测能力。
树模型是工业界最常用的一类模型之一。因为它结构简单、训练效率高、不需要太多超参数调优,通常都能在初期给出不错的效果。即使后续我们可能会尝试更复杂的模型(比如深度神经网络),但树模型往往能作为一个非常可靠的起点,帮我们快速了解数据、建立初步的模型效果。
所以,在实际工作中,当你拿到一个新任务时,如果发现用树模型能顺利训练出一个还不错的结果,那很可能就是个好兆头:说明你的数据质量不错、问题本身具备可解性,也为后续深入优化打下了良好的基础。
3.3 线性模型
我们先从一个非常简单的例子讲起,看看什么是线性模型。
还记得我们之前预测房价的例子吗?假设我们有三个特征,觉得它们对房价有很大影响:
- 卧室的数量
- 卫生间的数量
- 房子的居住面积
我们用 x_1、x_2、x_3 来表示这三个特征,它们的值都是实数。
在线性模型中,我们假设预测值是所有特征的加权和。也就是说,每个特征会乘上一个权重,分别是 w_1、w_2 和 w_3,然后再加上一个偏移量 b。这个模型可以写成:
这里的权重 w_1, w_2, w_3 和偏移 b 都是我们希望通过数据来“学”出来的,也就是所谓的可学习参数。
如果我们把特征和权重都写成向量,比如 x 和 w 都是长度为 p 的向量(p 是特征的数量),那么这个预测公式就可以简化为:
在代码实现里,我们通常是把两个向量做点乘,然后再加上偏移项 b,得到预测值 \hat{y}。我们用 \hat{y} 来表示预测值,是为了和真实标签 y 区分开。
现在我们来看一下这个模型的目标函数。假设我们有 n 个训练样本,特征矩阵用 X 表示,是一个 n \times p 的矩阵(n 行表示样本数,p 列表示特征数),对应的真实房价是向量 y,长度为 n。
我们的目标是最小化平均均方误差(Mean Squared Error, MSE),也就是目标函数 L:
简单来说,就是让每个样本的预测和真实值的差距平方后求平均。
刚才讲的是如何用线性模型做回归问题,接下来我们看分类问题。
和回归不同,分类的目标是预测一个样本属于哪一类。特别是在多分类的情况下,比如识别图片中的物体属于哪种类别,我们需要让模型输出的不只是一个数,而是一个长度为 m 的向量,其中 m 是类别数。
这个向量的第 i 个元素表示模型判断样本属于第 i 类的“可信度”或“分数”。值越高,就表示模型越确信样本属于这一类。我们依然可以用线性模型来计算这个分数。对于每个类别 i,我们有一组权重 w_i 和一个偏移 b_i,那么类别 i 的得分 o_i 就是:
总共可以得到 m 个这样的分数组成向量 o。
对于真实的标签 y,我们也可以表示成一个独热编码(one-hot encoding)的向量:如果一个样本的真实类别是第 i 类,那么它的第 i 个位置是 1,其它位置是 0。然后我们可以像回归一样,计算预测向量 o 和真实向量 y 之间的平均均方误差:
不过,这样做有个问题:它强迫模型对所有类别都输出一个尽可能“正确”的分数,即使有些类别跟当前样本无关。这样模型就会花很多精力在不必要的类别上,尤其当类别数很多时,这种方法就显得效率低下。
为了让模型更专注于正确类别,我们可以把输出分数变成一个“概率分布”,这时候就要用到一个叫 softmax 的函数。
具体做法是:我们对每个分数 o_i 取指数,然后除以所有分数指数的总和,变成:
这样就得到了一个新的向量 \hat{y},它的每个元素都在 0 到 1 之间,而且所有元素加起来刚好是 1。我们可以把它当成每个类别的预测概率。
虽然 softmax 是一个非线性的变换,但模型本质上还是基于线性组合做决策的,所以这个模型仍然被叫做线性模型,只不过加上了一个 softmax 操作,我们也常叫它 softmax 回归。这种方法的好处是,我们只关心让正确类别的概率尽可能高,而不用对其它类别太过纠结。这使得模型在多分类任务中更加高效、有效。
虽然我们是用线性模型配上 softmax 来预测概率,但本质上我们是用它来解决分类问题的,特别是 m 类分类。如果你要在 PyTorch 中实现这个过程,基本思路是这样的:
假设我们的输出 o 是一个矩阵,其中每一行代表一个样本,每一列代表对应类别的得分。我们首先对这个矩阵做指数操作,然后对每一行求和(也就是每个样本的总得分),最后让每个元素除以对应那一行的和。这样,我们就得到了一个概率矩阵 y^{\hat{}},每一行表示一个样本属于各个类别的预测概率。
在代码里,这种“对每一行求和”的操作,其实是对某一个维度(也就是列的维度)做 sum(dim=1, keepdim=True),确保保持二维结构,然后做除法。而对于真实的标签 y,我们也把它表示成一个 one-hot 向量:每一行只有一个元素是 1,其他都是 0,表示该样本的真实类别。
接下来我们就要比较这两个概率分布(y 和 \hat{y})之间的差别。有很多种方法可以做这个比较,其中一种非常常用的叫做 交叉熵(cross entropy)。
它的公式是:
不过因为 y 是 one-hot 编码的,所以实际上这个和式里只有一个项不为 0,也就是说,损失函数只关注模型在真实类别上预测出来的概率。
从优化的角度来说,我们希望这个损失越小越好。因为前面有个负号,而 \log 函数是单调递增的,所以实际的意思就是:我们要让模型在真实类别上预测的概率越大越好。
这个方法的好处是:它只关注模型在正确类别上的表现,其他类别的预测我们就不太关心了(当然,也不是完全忽略,只是影响小很多)。只要正确类的预测值够大,其他类自然就相对小了,但我们在损失函数里不需要对它们逐个进行约束。
所以,使用交叉熵作为损失函数,可以让模型的注意力更集中在“把正确类判对”这件事上,这样训练效果通常会更好,也更符合我们做分类任务的目标。
3.4 随机梯度下降
前面我们介绍了两个模型,接下来我们要讨论的是目标函数,以及如何求解我们线性模型的参数。
虽然线性模型可以通过显式解析解来直接求解,但在实际应用中,更常用的一种方法是随机梯度下降(Stochastic Gradient Descent, 简称 SGD)。它的基本思想源自梯度下降,区别在于加入了随机性。在现代深度学习中,最常用的版本是小批量随机梯度下降(mini-batch SGD)。
我们之所以在这里重点介绍这个方法,是因为它不仅可以用于线性模型的训练,也几乎适用于我们这门课程中除了决策树以外的所有模型。更重要的是,它是目前深度学习中最主流、几乎是唯一的优化方法。我们来看它是怎么应用到线性模型中的。
首先,假设我们的参数是向量 w。为了方便起见,我们会把偏置项 b 也合并进 w 向量中。具体做法是在输入特征中加一列全为1的特征项,这样偏置就变成了一个与特征相乘的权重,使模型表示更统一。
在小批量随机梯度下降中,我们使用一个称为批量大小的超参数,记作 b,表示每次更新时我们从训练集中随机抽取的样本数量。另一个关键参数是学习率,记作 \eta_t,表示在第 t 次迭代时参数更新的步长。整个优化是一个迭代过程。初始时我们设置 t = 1,然后不断进行迭代,直到模型“收敛”。收敛的意思是说,我们观察到目标函数(比如平均损失)不再显著下降,或者验证集上的性能(如准确率)趋于稳定。
在每一次迭代中,我们会从训练集中随机采样一个小批量,记作 \mathcal{I}_t,它包含 b 个样本。然后,我们基于这部分数据计算当前的目标函数值,对参数 w 求梯度,并按照以下更新公式进行参数更新:
这里 \nabla_w \mathcal{L}(w_t) 表示在当前参数 w_t 下、基于小批量数据计算得到的梯度。这个梯度给出了一个方向:如果我们沿着这个方向调整参数,目标函数的值将会上升。而我们希望最小化目标函数,因此要沿着梯度的负方向走,也就是加上一个负号。
学习率 \eta_t 的作用是控制我们“走多远”。太小的话,更新步子太慢,优化效率低;太大的话,可能会直接越过最优点,甚至导致模型发散,训练失败。这个过程不断重复,就可以得到我们最终的模型参数。
不过,这个算法虽然通用而强大,也有它的挑战点:超参数的选择非常重要而且敏感。批量大小 b 和学习率 \eta_t 都需要根据具体任务和数据进行调试。我们不会在这里深入讨论如何选择这些参数,但在我们以往的“动手学深度学习”课程中有过详细讲解,比如在什么情况下选小批量、学习率调整的策略等等。
总之,小批量随机梯度下降虽然原理简单,却是解决绝大多数机器学习和深度学习模型的核心方法之一。它适用于各种目标函数,能够高效地在大规模数据上进行训练,是现代AI不可或缺的工具之一。
我们来看看,刚才提到的实现具体该怎么做。我们会用一段代码来演示,如何在一个线性模型上应用我们的小批量随机梯度下降(SGD)方法。在这个例子中,我们会用几个超参数:批量大小记作 b,学习率是 \eta_t。这里我们使用的是一个固定的学习率,也就是说每一步迭代中,学习率的数值不会变化。还有一个参数是 number\_epochs,也就是我们希望将数据完整扫几遍。
完整代码我们已经准备好,大家可以参考,我们这里只挑其中一个关键部分讲一下。
首先,我们看第一个函数的作用。它的主要任务是帮助我们每次从数据中取出一小批样本,也就是一个迭代器(iterator)的实现。在 Python 中,如果一个函数是可迭代的,每次调用它就会返回一部分内容,非常适合用来处理小批量数据。
我们传入几个参数:批量大小、特征矩阵 X(这是一个 n \times p 的矩阵,n 是样本数,p 是特征数)和标签向量 y(长度为 n)。首先我们获取样本总数,然后生成一个包含所有样本索引的列表,并将其打乱,这一步是为了确保每次采样是随机的。
接下来,每次我们从这个打乱后的索引列表中,取出 b 个索引,获取对应的样本特征和标签。这是通过 yield 实现的,它和 return 不同,可以被反复调用,每次返回一个批量。
然后是模型的初始化。假设每个样本有 p 个特征,那我们的权重 w 就是一个 p \times 1 的向量。我们用一个均值为 0、方差为 0.01 的高斯分布来初始化权重。这个选择没有特别的意义,主要是希望初始值不要太大也不要太小。偏置 b 我们通常直接初始化为 0。
有些模型中,如果所有权重都初始化为 0,会导致训练过程中各部分表现完全一致,从而影响模型的学习能力。虽然在线性模型里这个问题不明显,但在其他更复杂的网络中就可能出问题,大家可以思考一下为什么。
在 PyTorch 中,为了能够进行反向传播,我们在创建权重和偏置时,需要设定它们是需要梯度的。
训练部分的代码分为两层循环。外层是遍历整个数据集 number\_epochs 次,内层则是利用我们之前写的小批量迭代器,从数据集中不断读取一个个批量。每次我们拿到一批数据(大小为 b),就用当前的权重 w 和偏置 b 来做预测。预测公式是
接着,我们计算预测值 \hat{y} 和真实标签 y 之间的差,用来构造损失函数。这里用的是均方误差(MSE):
有了损失之后,我们可以对它进行反向传播,计算出 w 和 b 的梯度。PyTorch 会自动把结果保存在 w.grad 和 b.grad 中。然后我们用这些梯度更新参数:
最后,更新完参数之后,需要将梯度清零,以便下一轮迭代时不受上一次的影响。
整个过程就是一个基本的小批量 SGD 的训练流程。如果你对更多细节感兴趣,可以参考我们提供的完整代码(有中文版),也可以回看我们之前的《动手学深度学习》的讲解视频。
3.5 多层感知机
我们接下来会对神经网络做一个快速的总结。虽然是快速介绍,但内容还是不少,所以我们会分成几个小段来讲解。当然,我们不可能在短短一个小时内,把整个神经网络系统地讲清楚。如果你想了解更多细节,推荐去看看我们之前的《动手学深度学习》,里面会一步步讲清楚每种神经网络的具体原理和用法。
还记得我们前面讲的线性模型吗?在线性模型中,我们通常需要对原始数据做特征工程,把数据转换成适合线性模型处理的形式。我们也讲过一些基础的特征处理方法,像是把原始数据变成数值向量,要求这些特征之间的语义要尽量明确,这样模型才能通过一个线性分割面把它们分开。
那神经网络要做的事情是什么呢?简单来说,现在大家说的“深度学习”或“深度神经网络”,可以理解为用网络结构来替代手工特征工程的部分。原来我们依靠人的经验和知识,从原始数据中提取特征。现在,神经网络可以自己学习如何提取更有效的特征——而且它“更懂机器学习”。很多时候,神经网络提取出来的特征,对后续的线性模型或 softmax 回归来说效果更好。
这带来的好处是显而易见的:我们不用再绞尽脑汁设计特征了。你只需要把原始数据交给模型,它会自动去学习应该关注哪些信息。结果往往是,它提取出来的特征比我们人工设计的更有效,模型精度也会因此提高。
但天下没有免费的午餐。神经网络虽然省去了我们人工设计特征的步骤,但它对数据量和计算资源的要求也大大增加了。这里说的“多”,不是说增加 10 倍、100 倍这么简单,而是成千上万倍的提升。这背后的原因在于:神经网络并不像人那样有大量先验知识,只能依靠海量数据和大量训练去“试错”和“学习”。所以你得准备更多的数据,并投入更多的计算资源来支持它。
此外,不同类型的神经网络结构,也适用于不同的数据和任务。比较常见的几类网络包括:
- 多层感知机(MLP)
- 卷积神经网络(CNN)
- 循环神经网络(RNN)
近年来,还有一个非常热门的架构——Transformer(变形金刚结构),它在很多任务上表现非常出色。在本节中,我们会快速介绍前面三种常见结构。至于 Transformer,我们会留到后面专门讲解。
我们先来看一下,怎么从之前讲到的线性模型,过渡到多层感知机(MLP)。
在多层感知机中,一个最核心的结构叫做全连接层(也叫 dense 层、线性层或稠密层,这里我们统一称作“全连接层”)。一个全连接层主要包含两个可以学习的参数:权重 W 和偏置 b。
其中,W 是一个 m \times n 的矩阵,b 是一个长度为 m 的向量。这里的 n 表示输入特征的维度(之前我们用 p 表示,这里换成了 n),而 m 是这个层的输出维度。
给定一个长度为 n 的输入向量 x,经过这个全连接层的计算过程是:
-
其中,输出 y 是一个长度为 m 的向量。
-
如果 m = 1,也就是这个层只有一个输出,那你可以把它看成是我们之前的线性回归模型。
-
如果 m 是类别数,再加一个 softmax 操作,那就是 softmax 回归。
所以,其实线性模型都可以用一个“输入 → 全连接层 → 输出”的结构来表示。在神经网络中,这种方式也非常常见。那什么叫“多层”感知机呢?刚刚那个只有一层输出,我们也可以叫它“单层感知机”。为了让模型具有非线性的表达能力,我们需要堆叠多个全连接层。
但要注意,如果你只是简单地堆多个线性层在一起,最终效果仍然只是一个线性变换。原因是线性操作的叠加仍然是线性的。所以我们必须加入非线性的部分,这就引出了激活函数的概念。
激活函数是一个对每个元素单独作用的非线性函数,常见的有:
-
sigmoid:将输入压缩到 [0, 1] 区间,公式为
\sigma(x) = \frac{1}{1 + e^{-x}}如果输入是向量或矩阵,那就是对每个元素分别应用这个函数。
-
ReLU(Rectified Linear Unit):更常用,也更高效。公式是:
\text{ReLU}(x) = \max(0, x)简单来说,大于 0 的数不变,小于等于 0 的直接变成 0。画出来是折了一下的两段直线,因此它虽然是分段线性,但整体并不是线性的。
通过在全连接层之间加入这些激活函数,我们就能构造出真正的非线性模型。
比如,一个最基础的多层感知机结构是这样的: 输入 → 全连接层 → 激活函数 → 全连接层 → 输出
这种结构中间的那一层叫“隐藏层”,所以这个模型也可以说是有一个隐藏层的 MLP(多层感知机)。通过加入更多隐藏层和激活函数,我们可以让模型学到更复杂、更抽象的特征表示。为什么叫“隐藏”呢?因为从输出的角度来看,这一层并不是直接可见的。输入进入网络后,会经过隐藏层的处理,但最终模型只输出结果,看不到中间过程,所以我们说它是“隐藏”的。
当然,如果你能加一层隐藏层,那也可以加很多层,层数多了,模型就更“深”了。比如你可以这样构造模型:输入 → 隐藏层 → 激活函数 → 输出层(通常是线性层)。我们可以在中间插入多个隐藏层,这就是我们常说的“深度”神经网络。
在构建多层感知机时,有一些超参数可以自由设定:
- 隐藏层的层数(你可以决定要叠多少层)
- 每一层隐藏层的输出大小(也就是隐藏单元的个数)
这两个超参数是可以调节的。相比之下,输入和输出的形状是相对固定的:
- 输入维度由你的数据特征数决定(不能改)
- 输出维度由任务本身决定,比如分类的类别数或回归输出的维度(也不能改)
所以我们在设计 MLP 时,通常重点就在于调隐藏层的数量,以及每一层的隐藏单元数。
接下来,我们来看一个简单的多层感知机实现。这里我们只用一个隐藏层,它的输出大小(也就是隐藏单元个数)是一个超参数,记作 num\_hiddens,我们先定义激活函数——这里使用 ReLU。
然后我们开始初始化模型参数:
- 第一个隐藏层的权重 W_1:它的输入维度是 p(特征数),输出维度是 num\_hiddens。我们从标准正态分布中采样初始化,然后乘以 0.01 缩放,这样可以避免初始值过大或过小。
- 对应的偏置 b_1:长度为 num\_hiddens。
- 输出层的权重 W_2:它的输入来自隐藏层的输出,维度是 num\_hiddens,输出维度为 num\_outputs(这个由任务决定)。
- 输出层的偏置 b_2:长度为 num\_outputs。
前向传播的过程如下:
- 给定输入 X,先和 W_1 做矩阵乘法,加上偏置 b_1,然后通过 ReLU 得到隐藏层输出 H。
- 然后将 H 和 W_2 做矩阵乘法,加上偏置 b_2,得到最终的输出 y。
注意,输出层不需要再加激活函数,因为我们已经在隐藏层引入了非线性——这正是隐藏层发挥的作用。也就是说,这样的结构就突破了线性模型的限制,使得网络可以学习更复杂的模式。
3.6 卷积神经网络
在讲完 MLP(多层感知机)之后,我们接下来要看的是另一个非常重要的模型:卷积神经网络(CNN)。
我们先来看看,怎么从一个全连接层过渡到卷积层。我们还是从一个实际的例子出发,比如做图片分类任务。假设我们要在 ImageNet 这个数据集上训练一个模型。这个数据集的图片平均大小大概是 300 \times 300 像素,类别数有 1000 个。
我们先用 MLP 来试试看。如果我们做一个很简单的 MLP,只有一个隐藏层,我们可能会把这个隐藏层的大小设为 10,000。为啥是这个数呢?因为输入有 300 \times 300 = 90,000 个像素点,输出是 1000 维,我们就取个中间值,大概设成 1 万。
这时候你会发现,这个模型的第一个隐藏层就已经有:
差不多 9 亿个参数,再加上偏置就将近 10 亿了。
这么多参数,问题就来了:
- 一方面是数据不够,ImageNet 也就一百万张图,用十亿个参数去学,很容易过拟合;
- 另一方面是算力开销太大,训练和存储都很困难。
所以,光靠 MLP 来处理图片问题并不现实。
那有没有更好的办法?有的兄弟,有的。我们可以思考:图片这个任务有没有什么先验知识,是我们可以利用的?答案是有的,至少有两个非常关键的观察:
-
平移不变性(Translation Invariance)
比如你要在图片里找一个戴红帽子的人,不管这个人出现在图的左上角还是右下角,他的模样差不多。换句话说,物体在图里换个位置,它的外观不会变得太多。所以如果模型能在某一块区域识别出这个人,在别的区域也应该能识别出来。
-
局部相关性(Locality)
一般来说,我们判断一个区域是什么东西,其实只需要看它周围的像素。一个像素和它附近的像素通常是强相关的,但和很远的像素没啥关系。比如,一个人的眼睛、鼻子、嘴巴是聚在一起的,而不会分布在图像的四个角上。
基于这两个观察,卷积神经网络就应运而生了。它和全连接层最大的不同在于:
- 全连接层:每个输出神经元都连接到输入中的所有像素,所以每个输出要学习一整张图那么多的权重;
- 卷积层:每个输出神经元只看输入图像的一小块区域(也就是一个滑动窗口),比如 k \times k 的小块。
举个例子,如果我们使用一个 3 \times 3 的卷积核(也可以叫“过滤器”),那么这个核就会在整张图上滑动。每次它只看 3 \times 3 的小区域,对这个区域的像素和卷积核的参数做加权和,得到一个输出值,然后移动到下一个位置,重复这个操作。
所以,对于一个输出值,它只用到了输入图像中的一小块区域,而且这个卷积核在整个图上是共享的——也就是说,无论滑到哪里,它的权重是一样的。这就大大减少了模型的参数数量。
再拿一个简单点的例子来说:
- 假设一张图是 4 \times 4 的大小
- 我们用一个 2 \times 2 的卷积核
- 那么这个卷积核每次看图中 2 \times 2 的一块,共有 4 个值,对应 4 个参数
- 每个输出值就只和这一小块的输入有关,而不是整个图
相比全连接层(需要为每个输出学一个完整图像大小的权重向量),这种局部计算+参数共享的方式,既减少了参数数量,又保留了空间结构信息,特别适合处理图片。所以,卷积层的设计本质上就是利用了图像数据的两个特点:局部性和空间平移不变性,这样我们在训练模型的时候可以既高效又准确地提取图片中的模式。
我们前面提到的平移不变性或者更一般的变换不变性,意思就是:如果我们在图像的某个区域学到了一组能识别某种特征的权重,比如一个 k \times k 的小块可以检测出某个图案,那么这个特征在图片的其它位置出现时,我们也希望能用同样的权重去识别它。
换句话说,如果一组权重在左下角能识别出某个图案,那在右上角我们就不需要重新学一套新的权重——直接用原来那套就可以了。这就意味着,模型在不同位置共享同一组权重,这也是卷积神经网络的一个核心思想。
有了这个“权重共享”的机制后,卷积层的参数数量就不再随着输入大小和输出大小成比例增长了,而是只跟卷积核的大小(k)有关。
比如说,每一个输出值只跟输入图像中一个 k \times k 的小窗口有关,而所有输出值都用同一组 k \times k 的权重来计算。这一组权重我们通常叫作卷积核(kernel)或者卷积权重(kernel weights)。
那卷积核到底在学什么东西呢?它其实会被训练成去识别图像中的某种模式。举个例子:
- 某个卷积核可能学会去识别绿色通道中一个特定形状的块
- 另一个可能是识别红色区域中某种边缘
- 也有可能学到的是红色和绿色之间的颜色过渡
- 有些卷积核专门识别某个方向上的纹理,比如水平线或者斜线
- 还有的会学习区分图像中两个区域的边界或轮廓
这些都是卷积核自动从数据中学习出来的,不是我们手动设置的。每个卷积核就像是在图片里“寻找特定的局部特征”。
而为了让模型能学到多种不同的模式,我们通常不会只用一个卷积核,而是用很多个,每一个都产生一组输出(也叫一个通道)。这样,经过卷积层之后,我们就能得到一个多通道的输出特征图,每个通道对应一种特征。因为一张图片里的信息非常丰富,光靠一个模式显然不够,我们需要多个卷积核去捕捉不同的特征,最后把它们综合起来,才能做出更准确的判断。
至于更复杂的部分,比如多输入通道(比如 RGB 图像的三个颜色通道)和多输出通道(多个卷积核的叠加)怎么处理,这里就不展开讲了。如果大家感兴趣,可以去参考我们之前的课程资料,那里有更详细的介绍。我们接下来讲一下最简单的 单通道卷积是怎么实现的。
从实现的角度来看,假设我们已经有了一组学好的卷积核参数(也就是权重)k,它是一个 h \times w 的矩阵,通常这两个维度相等,比如 3 \times 3 或 5 \times 5。输入 X 是一张图片(或特征图),也是一个二维矩阵。我们要计算的输出是 Y,它的每个位置表示卷积操作后对应的位置上的输出值。
注意:卷积是一个滑动窗口的操作。窗口从图像的左上角开始,一格一格向右、向下滑动。如果滑到图像边缘,窗口就不能再往外扩了,所以如果我们不做“填充”(padding),输出的尺寸会比输入小。
具体来说,输出的高度是输入的高度减去卷积核的高度再加 1,也就是:
接下来我们看每个输出元素 Y_{i,j} 是怎么计算的:
- 它对应的是输入中从第 i 行开始、往下 h 行,从第 j 列开始、往右 w 列的一个 h \times w 小块
- 然后把这个小块的每个元素和卷积核 k 中的对应位置做点乘
- 最后把所有乘积加起来,得到一个加权和
这就是我们说的“滑动窗口 + 局部加权求和”,它是卷积的基本操作。
严格来说,这个过程更准确地叫做 交叉相关(cross-correlation),而不是数学意义上的卷积。因为真正的卷积操作是需要把卷积核翻转一下再乘的。不过在深度学习里,大家都默认这个实现方式,两者在模型训练中的效果一样,所以我们就直接称它为卷积了。
需要注意的一点是:卷积操作对位置非常敏感。举个例子,如果输入图像 X 中的一个物体平移了一个像素,那么对应的输出 Y 中的激活值也会平移一个像素。这会带来一个问题——图像中的物体并不总是出现在固定位置,比如你拍照时手抖了一下,就会导致像素级别的平移,但物体本身并没有变。
为了让模型对这种轻微的平移更有鲁棒性,我们通常在卷积层后面加一个 Pooling 层,也叫汇聚层(比“池化层”这个翻译更准确些)。汇聚层和卷积一样,也使用滑动窗口,不过它不是计算加权和,而是从窗口里:
- 取出 最大值(叫最大汇聚 Max Pooling),或者
- 取出 平均值(叫平均汇聚 Average Pooling)
比如用一个 k \times k 的汇聚窗口,那么每次就从对应的 k \times k 区域里选出最大(或平均)值作为输出。这样的操作带来了一个很有用的性质:对小范围内的平移不敏感。假设卷积输出中某个位置的值很大(表示检测到了某个模式),那么只要这个模式在附近几格之内,汇聚层就仍然能把这个大值保留下来。
换句话说,即使输入图像中的物体在空间中稍微移动了(比如 k 个像素以内),经过卷积 + 汇聚后,对应的输出特征位置变化不大,信息不会丢失。
实现上也很简单,假设我们使用的是 h \times w 的窗口来做最大汇聚,输出 Y_{i,j} 就是从输入 X 中第 i 行、第 j 列开始,向下 h 行、向右 w 列这个小块中的最大值:
这样,我们就能让模型的感知能力更强,不容易被图像中微小的平移影响,提升整体的分类或识别性能。
当然,这一段我也为你整理优化过了,保持你原本的讲解风格和节奏,让表达更清晰、逻辑更顺,同时更适合拿来讲给学生听或写成讲义:
那么最后我们来总结一下,什么是卷积神经网络(CNN)。
简单来说,卷积神经网络就是一种神经网络结构,它由多个卷积层堆叠而成。通过这些卷积层,我们可以从输入数据中逐层提取出具有空间结构的信息。虽然我们通常用 CNN 来处理图像数据,但它其实可以应用在任何具有空间特征的数据上。只要你的任务满足我们前面提到的两个条件:局部性和变换不变性,卷积网络就能派上用场。
卷积层本质上可以看作是一种特殊的全连接层。区别在于:
- 它限制了权重的连接范围,每个输出节点只与输入中的一小块区域相连(而不是全部连接);
- 并且在整张图上共享同一组权重。
虽然这些限制让卷积层变得更“经济”,但它本质仍然是一个线性变换。如果我们一层层地堆叠卷积层,但中间不加任何非线性操作(比如激活函数),那么整个网络最终还是一个线性模型——不管你堆多少层都一样。所以,我们必须在每个卷积层后面加一个激活层(比如 ReLU、Sigmoid 等),让模型具备非线性表达能力。
另外,前面也说了,卷积操作本身对输入的位置是敏感的。为了解决这个问题,我们通常会在卷积后面加一个汇聚层(Pooling Layer),让输出对小范围的位置变化更有鲁棒性。
所以,一个典型的 CNN 网络结构会是这样的:
图片 → 卷积层 → 激活层 → 汇聚层 → 卷积层 → 激活层 → 汇聚层 → …… → 全连接层 → 输出
举个经典例子,就是上世纪 80 年代末的 LeNet。它就是最早用于数字图像识别的 CNN 架构之一。虽然现在已经不用它了,但它的结构还是很有代表性的:
- 输入是二维图像
- 先接一个二维卷积层
- 然后是一个激活层(LeNet 用的是 Sigmoid)
- 接下来是一个平均汇聚层(Average Pooling)
- 然后再来一组卷积 + 激活 + 汇聚
- 最后把卷积输出的结果用 Flatten 展成一个向量
- 接入几个全连接层,最后输出分类结果
这里的 Flatten 操作,就是把卷积层输出的四维张量(比如 batch 大小、通道数、高、宽)拉平成二维的矩阵,供后面的全连接层使用。
LeNet 是一个早期的模型,现在我们当然不会直接使用它了。现代的 CNN 模型在结构设计上有了非常多的优化,既更强大,也更高效。从 AlexNet 开始,到 VGG、Inception、ResNet、MobileNet 等,每一个架构都有自己独特的设计思路:
- 有的通过加深网络层数来提升表现
- 有的尝试在结构中引入“跳连”、“瓶颈结构”、“分支结构”等
- 它们的目标都是在准确率、计算效率、参数量之间取得更好的平衡。
3.7 循环神经网络
多层感知机(MLP)在处理图片分类任务时已经很有效,但当我们遇到需要处理序列数据的场景,比如自然语言处理,就会发现它的局限。我们下面通过一个例子来看看,为什么需要引入循环神经网络(RNN)。
自然语言处理里有一个非常经典的任务叫做语言模型。它的目标是:给你一句话的前几个词,让你预测下一个词。比如我告诉你第一个词是“hello”,你可能会猜下一个词是“world”。然后我再告诉你“hello world”,你可能会接着猜“!(感叹号)”。这个过程其实就是在根据前面的词,预测下一个词——这是一个典型的时间序列问题。
如果我们尝试用 MLP 来解决这个问题,会怎么做呢?最简单的方法是用一层全连接加 softmax 进行分类。比如说,我们有 1000 个不同的词汇,那我们就把预测下一个词的问题变成一个 1000 类的分类问题。
每次输入一个词,我们可以用 one-hot 编码把它变成一个向量,然后通过全连接层输出一个预测结果。比如输入“hello”,输出中得分最高的那个词可能就是“world”。接下来,在下一个时间点,也就是处理下一个词时,我们用同一个模型(参数不变)来预测下一个词,比如输入是“world”,模型可能会预测“!”。
但问题来了:在预测“!”时,模型只看到了“world”,却不知道“hello”这个词之前出现过。也就是说,它无法利用更早前的信息,因为这些信息没法自然地传递过来。
你可能会想:“那我能不能把‘hello’也一起输入进去?”当然可以,但问题是句子的长度在变,比如一会儿两个词,一会儿三个词。MLP 的输入维度是固定的,如果输入向量长度是 p,那每次都必须是 p,不能变。
我们当然可以用像词袋(Bag-of-Words)那样的方法,把所有词的 one-hot 向量加起来,得到一个固定长度的表示。但这样做的问题是:时序信息完全丢了。你把“hello world”变成“world hello”,最终的表示是一样的。这对于需要考虑词语顺序的语言模型来说是致命的。
所以我们真正的问题是:如果还想用 MLP,要怎么把一个变长的序列变成一个固定长度的向量表示?这正是最早的循环神经网络想要解决的问题。
那 RNN 是怎么做的呢?其实思路跟我们之前讲的差不多。首先我们把“hello”输入进去,做一次 softmax 得到预测结果。这个时候,模型内部会得到一个输出,记作 H。这个 H 就是隐藏状态(hidden state)。在下一个时间点,我们输入“world”,但同时也把之前得到的隐藏状态 H 也一起输入。我们把 H 和“world”的向量拼接(concat)起来,一起送入下一层,继续做预测,比如预测“!”。这样,模型既能看到当前的词“world”,也能“记得”之前的“hello”带来的信息。
这个隐藏状态 H 为什么叫“隐藏”?因为它是模型内部产生的,不直接输出,但它的大小是固定的,不管你输入的序列多长,H 的维度不变,取决于你定义的隐藏层大小。
我们假设这个 H 一直沿用下去,就能不断累积它看到的所有历史信息。所以我们说,H 存储了过去的记忆。当我们把当前词的表示和 H 合并后,就等于结合了过去和现在的信息,得到一个更全面的预测。
说到底,RNN 就是在 MLP 的基础上,多加了一条“记忆通道”,让模型能考虑到时间上的连续性。
接下来我们来看一下,RNN 是怎么具体工作的。
我们先把它形式化地写出来。你可以把刚才提到的那一层理解为一个 RNN 层。RNN 其实说白了,就是在普通的全连接层基础上,加上了时间维度的信息传递。我们可以把它在时间轴上展开来看。
在当前时刻 t,我们输入一个词 X_t,比如 “hello” 或 “world”,它会输出一个结果。这个输出我们记作 y_t。同时,我们也把这个输出复制一份,作为当前时刻的隐藏状态 h_t。在最基础的 RNN 中,y_t 和 h_t 本质上是一样的。
然后,h_t 会传递到下一个时间点 t+1,作为它的输入之一。换句话说,在时刻 t 时,我们除了当前的输入 X_t,还会接收到上一个时刻传来的隐藏状态 h_{t-1}。那计算是怎么做的呢?我们会对 h_{t-1} 乘上一个可学习的权重矩阵 W_{hh},这个矩阵的维度是 k \times k,其中 k 是隐藏状态的维度。同时,我们把当前的输入 X_t 乘上另一个权重矩阵 W_{xh},维度是 k \times p,其中 p 是输入向量的维度。
然后我们把两个结果加起来,再加一个偏置项 b,最后通过一个激活函数(比如 \tanh 或 \text{ReLU})处理,得到当前的隐藏状态 h_t。
如果没有 h_{t-1} 的话,这就只是一个普通的 MLP。但现在我们多了一条“记忆”路径——上一个时间点的隐藏状态被引入当前的计算中。这样模型在每一步都能“记住”前面的信息,这就是最基本的 RNN,也叫 Simple RNN。
虽然思路很清晰,但实际使用中,我们常用的 RNN 结构会更复杂一些,比如带“门”的结构,包括 LSTM 和 GRU。这些结构能更精细地控制信息是怎么流动的。比如说,在计算 h_t 的时候,模型可能会自动“忽略”当前的输入 X_t。这就类似我们在读一句话时,自动跳过一些不太重要的词,比如介词、标点、感叹号之类,而把注意力更多地放在名词、动词这些关键内容上。
也就是说,模型会学习一个机制,去判断当前的 X_t 是否重要。如果不重要,就不要让它影响隐藏状态 h_t。这个判断是通过另一组可学习的参数实现的。模型会根据当前的输入 X_t,甚至结合以往看到的内容,自动学会“屏蔽”那些对当前任务帮助不大的信息。
同样的道理,它也会学习何时该“忘记”过去的信息,专注于现在的输入。比如新的句子开始了,之前的信息可能不再相关了,或者已经过了很久,旧的信息对当前预测帮助不大了,模型就会选择忘掉它们。这些机制,像“遗忘门”“更新门”等,都是通过额外的参数学习出来的。它们的作用就是:在计算 h_t 时,动态决定要保留多少过去的信息,又要吸收多少新的信息。
总结来说,LSTM 和 GRU 就是在传统 RNN 基础上,增加了对信息流的精细控制。它们背后的核心思想和我们刚才讲的一样,只不过实现方式更复杂一些。至于具体的公式细节,大家可以参考我们之前的视频内容。
接下来我们来看一下,最简单的 Simple RNN 是怎么实现的,也就是我们怎么搭建这样一个网络层。
假设输入的维度是已经确定的,输出的维度是一个超参数,记作 num\_hiddens。和之前的 MLP 不同的是,RNN 需要两个主要的权重矩阵:一个是 W_{xh},用来处理当前的输入;另一个是 W_{hh},用于处理前一个时间步的隐藏状态。
W_{xh} 的行数是输入的维度,列数是 num\_hiddens;而 W_{hh} 的行列数都是 num\_hiddens,因为它是从一个隐藏状态映射到另一个隐藏状态。通常我们会对这两个权重矩阵进行随机初始化,并乘上一个缩放因子,比如 0.01,来防止初始值过大。偏置项 b_h 的设置和之前在全连接层中是一样的。唯一新增的,就是 W_{hh} 这个“记忆”的权重矩阵。
还有一个关键点是初始的隐藏状态 H。在时间 t = 0 的时候,网络还没有接收到任何输入,因此我们通常就用一个全零的向量来表示初始的隐藏状态,也就是 h_0 = 0。
然后我们来说一下 RNN 的输入格式。和之前的全连接层不同,RNN 的输入是一个序列,而不是一个单独的数据点。
比如你输入的是一个句子,句子里有 10 个词,这里我们把 10 作为 num\_steps,也就是时间步的数量。所以整个输入 X 是一个三维张量,形状是:
这表示我们有一个 batch 的句子,每个句子有 num\_steps 个词,每个词的表示是 input\_dim 维的向量。
如果你处理的是图像序列,那维度可能更多,比如四维、五维。但对于文本来说,三维就足够了。
RNN 的计算过程是一个时间上的循环:我们对输入序列的每一个时间步进行迭代。在每一步 t,我们会从 X 中取出当前时间步的输入 X_t,然后进行如下操作:
- 当前输入 X_t 乘以 W_{xh}
- 上一个时刻的隐藏状态 H(初始是全零)乘以 W_{hh}
- 两项相加,再加上偏置项 b_h
- 通过激活函数(我们用的是 \tanh)
最终得到当前时间步的隐藏状态 h_t,这也是该时间点的输出。每一次的 h_t 都会被记录下来,形成最终的输出序列 Y。如果句子长度是 10,那么输出也会包含 10 个隐藏状态。
同时,当前时间步计算出来的 h_t,会被传递到下一时间步,作为它的 h_{t-1}。这个“循环”的过程,就是循环神经网络名称的由来。
需要特别注意的是,虽然我们在图中把每一个时间点都画成了一个完整的网络单元,实际上这些时间点共享同一个参数集合。也就是说,无论是 W_{xh} 还是 W_{hh},在不同时间步里用的都是同一组权重。所以,时间上的展开只是为了理解方便,本质上网络的参数在每一个时间点都是一样的。
我们刚刚讲的是最简单的一层 Simple RNN,现在我们再拓展一下。
之前我们看到的 RNN 模型,是沿着一个方向在时间轴上处理序列信息,也就是说它只从过去到现在地“看”数据。每个时刻的隐藏状态只依赖于前一个时刻的状态,然后不断往后传播。
但实际上,还有一种更丰富的方式来理解一个序列——那就是从两个方向同时看它。比如我们处理一个句子时,从左往右是一种方式,但从右往左其实也是有意义的。某些语言(像阿拉伯语)本身就是从右往左书写的。所以,我们可以构建一个“双向”的 RNN,也就是 Bi-RNN。在双向 RNN 中,我们会同时构建两层 RNN:一层是从前往后处理序列,另一层则是从后往前处理。具体来说,对于某个时刻 t,我们会得到两个隐藏状态:一个是正向 RNN 计算出来的,另一个是反向 RNN 得到的。然后我们把这两个隐藏状态合并(比如拼接)起来,作为该时刻的最终表示 y_t。这样一来,y_t 就不仅包含了“过去”的信息,还结合了“未来”的上下文,在很多自然语言处理任务中能得到更好的效果。
当然了,双向 RNN 并不适用于所有场景。比如在进行预测未来的任务时,你可能只能使用历史信息,这时就不能用反向的部分。但如果你的任务类似于完形填空、文本补全,能够看到完整句子,那双向结构就非常有用。
从实现角度来看,Bi-RNN 其实也可以被看作是一个普通的网络层,只不过内部包含了两个小的 RNN 子层,分别处理两个方向。
除了双向结构,我们还可以将 RNN 像 MLP 一样堆叠多层,构建一个深度的循环神经网络(Deep RNN)。你可以用最基础的 Simple RNN 作为每一层,也可以使用结构更复杂的 LSTM 或 GRU,每一层之间的输出作为下一层的输入,构成更强大的特征抽取能力。
总结一下,我们目前讲到神经网络模型的选择,往往取决于你要处理的数据类型。
- 如果是结构化的表格数据(tabular data),比如数据库中的信息,最常用的是树模型、线性模型,或者简单的 MLP。
- 对于文本数据,比如新闻、对话、演讲稿等,由于有明确的时序结构,我们会用 RNN 系列模型来处理。
- 图像数据有空间结构,可以使用 CNN,它利用卷积操作抓取空间上的局部特征。
- 音频数据在变换为频谱图之后,也可以看成一种图像,同样适合用 CNN。
- 视频数据既有时间维度,又有空间维度,处理方式会更复杂,但也常用 CNN 加上时间建模方式来处理。
最近几年,还有一种新的思路逐渐成为主流,那就是基于注意力机制的 Transformer 模型。它不再局限于传统的时间步或卷积窗口,而是通过注意力机制,让模型能“全局”地看到序列中任意位置的信息。
Transformer 模型既可以处理有时序信息的数据,比如文本;也可以处理空间信息,比如图像。从本质上来说,时序信息是一维的、空间信息是二维的,但 Transformer 的核心思想是将它们统一看成在某个维度上的“依赖关系”。它不再依赖 RNN 那种一步一步处理的方式,而是一次性建模所有位置之间的联系。
我们之后会专门讲 Transformer 的原理和实现,它在很多任务中取得了非常优秀的效果,而且具有很强的通用性。
我们在这一节里介绍了神经网络中最常见的三类模型:
- 多层感知机(MLP):通过堆叠多层全连接层加激活函数,构建出具有非线性建模能力的基本网络。
- 卷积神经网络(CNN):利用卷积操作捕捉空间局部特征,特别适合处理图像、音频等数据。它可以看成是参数更少、计算更高效的局部全连接结构。
- 循环神经网络(RNN):在传统全连接的基础上,引入了时间上的“记忆”通路,让模型可以处理变长的序列,保持对历史信息的记忆。RNN 的发展中也出现了更复杂的结构,比如带门机制的 LSTM 和 GRU,以及能够从两个方向捕捉信息的双向 RNN。
RNN 的核心优势在于:它能把任意长度的序列,转化成固定长度的表示,并进行分步骤输出,是处理时序数据的关键工具。