USF-MSAN602-程序员的机器学习笔记-全-
USF MSAN602 程序员的机器学习笔记(全)
001:随机森林入门 🎯
在本节课中,我们将学习如何构建一个随机森林模型,用于预测重型设备的拍卖价格。我们将使用Kaggle上的“Blue Book for Bulldozers”竞赛数据集,从数据加载、预处理到模型训练和初步评估,完成一个完整的机器学习工作流。
环境设置与数据获取 💻
首先,我们需要设置编程环境并获取数据。我们将使用Jupyter Notebook作为主要工具。
以下是设置环境的步骤:
- 使用Jupyter Notebook:你可以通过AWS、自己的电脑,或者使用Crestle、Paperspace等在线服务来启动Jupyter Notebook。这些服务通常预装了必要的库。
- 导入必要的库:在Python中,我们需要导入将要使用的库。为了方便,我们通常使用
from library import *的格式,以便在交互式环境中快速探索。 - 获取数据:我们将从Kaggle下载“Blue Book for Bulldozers”竞赛的数据集。如果你在本地运行,可以直接下载;如果在服务器上,可以使用浏览器的开发者工具获取cURL命令来下载。
数据探索与理解 📊






上一节我们介绍了如何设置环境和获取数据,本节中我们来看看数据本身。


数据是CSV格式,我们使用pandas库将其读入一个称为DataFrame的表格结构中。这个数据集包含拍卖日期、设备型号、使用情况等多种类型的列,我们称之为结构化数据。
我们的目标是预测salePrice列,即拍卖价格。根据竞赛规则,评估指标是对数均方根误差(RMSLE),这意味着我们更关心预测价格与实际价格的比率差异,而非绝对差值。因此,我们首先对销售价格取对数。

import numpy as np
df_raw['SalePrice'] = np.log(df_raw['SalePrice'])



数据预处理:转换为数值 🛠️
机器学习模型,包括随机森林,通常需要数值型数据作为输入。我们的原始数据包含日期和字符串(分类变量),需要将它们转换为数字。
以下是处理不同类型数据的步骤:
- 处理日期:从日期中提取有意义的特征,如年份、月份、星期几等。这被称为特征工程。
- 处理分类变量:将字符串类型的分类变量(如‘High’, ‘Medium’, ‘Low’)转换为pandas的
category类型,然后使用其.cat.codes属性获取对应的整数编码。 - 处理缺失值:对于数值型列,用中位数填充缺失值,并添加一个布尔列标记哪些值曾是缺失的。对于分类变量,pandas会自动将缺失值编码为-1。
我们使用fastai库中的proc_df函数来自动化完成这些步骤。
from fastai.structured import proc_df
df, y, nas = proc_df(df_raw, 'SalePrice')
构建随机森林模型 🌲
现在,所有数据都已转换为数值,我们可以构建第一个随机森林模型了。随机森林是一种集成学习算法,它通过构建多棵决策树并综合它们的结果来进行预测,对于回归和分类问题都适用,且通常不易过拟合。
我们使用scikit-learn库中的RandomForestRegressor。
from sklearn.ensemble import RandomForestRegressor
m = RandomForestRegressor(n_jobs=-1)
m.fit(df, y)
m.score(df, y)
参数n_jobs=-1允许模型使用所有可用的CPU核心进行并行计算,从而加快训练速度。


模型验证与评估 📈
上一节我们训练了一个模型,但直接在训练数据上评估会过于乐观。我们需要一个模型未见过的数据来检验其真实性能。
我们将数据集按时间顺序排序,取最后12,000行作为验证集,其余作为训练集。这样可以模拟预测未来拍卖的场景。
n_valid = 12000
n_trn = len(df) - n_valid
X_train, X_valid = df[:n_trn].copy(), df[n_trn:].copy()
y_train, y_valid = y[:n_trn].copy(), y[n_trn:].copy()


然后,我们在训练集上训练模型,并在训练集和验证集上分别计算R²分数和RMSLE误差。对比这两个分数可以帮助我们判断模型是否过拟合。




总结与下一步 🚀
本节课中我们一起学习了机器学习的一个完整入门流程。我们从设置环境开始,加载并探索了Kaggle竞赛数据,学习了如何将日期和分类变量等非数值数据转换为模型可用的格式。接着,我们使用scikit-learn构建了一个随机森林回归模型来预测拍卖价格。最后,我们通过划分验证集对模型性能进行了初步评估,发现即使使用默认参数,我们的模型也能在竞赛中达到不错的排名。
核心收获:随机森林是一个强大且易于使用的工具,能够处理各种类型的数据,并快速提供一个性能良好的基线模型。接下来的课程中,我们将深入探讨如何解释模型、进行调优以及理解其背后的原理。

课后任务:尝试将这个流程应用到其他Kaggle竞赛数据集或你感兴趣的数据上,实践是学习的最佳方式。如果在过程中遇到问题,欢迎在课程论坛中讨论。
002:随机森林深度解析 🚀


在本节课中,我们将深入学习随机森林算法。到目前为止,我们只了解到存在一种叫做随机森林的方法,它在某些数据集上表现非常出色,且无需太多调整。但我们还不清楚它具体是如何工作的,如果效果不佳该怎么办,它的优缺点是什么,以及我们可以调整哪些参数。接下来,我们将探讨所有这些内容。之后,我们将学习如何解释随机森林的结果,不仅是为了获得预测,更是为了以模型驱动的方式深入理解我们的数据。这就是我们接下来的学习方向。
回顾与准备 📚
首先,我们来回顾一下目前的学习进度。我们了解到有一个名为Fast AI的库,它是一个“高度固执己见”的库。这意味着我们投入了大量时间研究如何获得最先进的结果,然后将这些技术打包成代码,以便大家也能使用这些先进技术。在可能的情况下,我们会在现有代码(如Scikit-learn)的基础上进行封装或提供额外功能。因此,我们展示的许多Fast AI内容,都是为了帮助我们将数据输入Scikit-learn,并从中解释结果。
在我们的环境中,笔记本位于Fast AI仓库的courses/ml1目录下。这里有一个指向父目录的符号链接,该目录包含了一系列模块。如果你想在自己的代码中使用Fast AI库,有几种方法:可以将你的笔记本或脚本放在与ml1或dl1相同的目录下,通过符号链接导入;也可以复制这个目录到其他地方使用;或者像我们一样,从你需要使用的地方创建一个符号链接。
数据准备与评估指标 📊
我们的数据来自“蓝皮书推土机价格预测”竞赛,存储在data/bulldozers目录中。我们能够读取CSV文件,唯一需要做的就是指定哪些列是日期列。完成这一步后,我们就可以查看数据的前几行。
理解项目的评估指标至关重要。对于Kaggle竞赛,他们会告知评估指标。在这个案例中,评估指标是均方根对数误差。其公式为:
[
RMSLE = \sqrt{\frac{1}{n} \sum_{i=1}^{n} (\log(y_i + 1) - \log(\hat{y}_i + 1))^2}
]
如果我们用log(actuals)替换actuals,用log(predictions)替换predictions,那么这就等同于均方根误差。因此,我们所做的就是用sale price的对数替换sale price本身。这样,当我们优化RMSE时,实际上就是在优化对数价格的RMSE。
数据预处理与建模 🔧
我们需要将所有列转换为数值。首先,处理日期列:我们移除了原始日期列,取而代之的是添加了一系列新列,例如:该日期是否是一个季度的开始、是否是一年的结束、自1970年1月1日以来的天数、年份、月份、星期几等。这些全部都是数值。
接着,我们使用train_cats函数将所有字符串列替换为类别类型。这样做之后,数据看起来仍然是字符串,但数据类型实际上已变为category。这是一个Pandas类,你可以通过.cat.访问其属性,例如.cat.categories可以获取所有可能的类别列表。然后,我们可以使用.cat.codes来获取对应的数字编码。为了实际使用这些数据,我们需要将所有类别列替换为它们的编码值(加1,因为未知类别原本是-1,我们希望它是0)。我们使用proc_df函数来完成这个转换。
proc_df函数还做了另一件事:对于存在缺失值的连续列,用中位数填充缺失值,并添加一个名为{column_name}_na的布尔列,指示该条目是否缺失。
完成这些后,我们调用RandomForestRegressor并计算.score,得到了一个R²分数0.98。
理解R²分数 📈
R²分数衡量了模型解释数据方差的能力。其公式为:
[
R^2 = 1 - \frac{SS_{res}}{SS_{tot}}
]
其中:
- ( SS_{tot} = \sum (y_i - \bar{y})^2 ) 是总平方和(朴素均值模型的误差)。
- ( SS_{res} = \sum (y_i - \hat{y}_i)^2 ) 是残差平方和(我们模型的误差)。
简单来说,R²是比较我们的模型与仅预测均值的朴素模型之间的表现。如果我们的模型和朴素模型一样好,则R²为0。如果我们的模型完美预测,则R²为1。但R²的值域可以小于1,甚至为负。负的R²意味着你的模型比简单预测均值还要差。
在我们的案例中,R²为0.98,表明模型在训练集上拟合得非常好。然而,这可能是过拟合的迹象——模型在训练数据上表现完美,但在未见过的数据上可能表现糟糕。
验证集的重要性 🛡️
因此,我们总是需要一个验证集。创建验证集是机器学习项目建模过程中最重要的一步。我们需要一个数据集,模型在该数据集上的分数能够代表其在真实世界(如Kaggle排行榜或生产环境)中的表现。
对于这个有时间序列属性的竞赛,测试集包含了训练集之后日期的数据。为了模拟这种情况,我们的验证集也应该是一个独立的时间段。我们取数据集的最后12,000行作为验证集,其余部分作为训练集。
当我们用验证集评估模型时,发现训练集R²为0.982,而验证集R²仅为0.887。这表明存在明显的过拟合。尽管如此,其对数价格的RMSE仍然能使我们在竞赛中进入前25%。所以,虽然过拟合,但结果并不差。
决策树基础 🌳
为了理解随机森林,我们先从它的基础组件——决策树开始。我们构建了一棵深度为3、没有随机性(bootstrap=False)的小决策树。这棵树的R²只有0.4,虽然不好,但我们可以将其可视化。
一棵树由一系列二元分裂构成。在根节点,我们有20,000个样本。算法会尝试每个变量以及该变量的每个可能分割点,计算分裂后两个子组的加权平均均方误差。它选择能最大程度降低这个误差的变量和分割点进行分裂。然后,在每个子节点上重复这个过程,直到达到预设的深度限制,或者每个叶节点只包含一个样本。
从树到森林:Bagging 🌲➡️🌳🌳🌳
单棵决策树容易过拟合。Bagging是一种通过组合多个模型来减少方差、提高泛化能力的技术。其核心思想是:训练多个略有差异、预测结果互不相关的模型,然后取它们的平均值。这样,各个模型的随机误差会相互抵消,留下真正的数据关系。
随机森林就是对决策树进行Bagging。具体做法是:
- 从原始数据集中进行有放回抽样,生成多个不同的子数据集。
- 在每个子数据集上训练一棵深度决策树(允许过拟合)。
- 对于新的预测,让每棵树独立预测,然后取所有树预测结果的平均值。
通过增加树的数量,模型的性能会提升并逐渐趋于稳定。在实践中,你可以使用尽可能多的树,只要时间允许。
关键超参数调优 ⚙️
我们学习了几种调整随机森林性能的超参数:
-
n_estimators:森林中树的数量。越多越好,但会带来计算成本。通常可以先使用较少的树(如20-30棵)进行快速实验,最终确定方案后再使用大量树。 -
min_samples_leaf:叶节点所需的最小样本数。设置这个值可以阻止树生长得过深,有助于防止过拟合,使每棵树泛化得更好,同时训练更快。常用值如1, 3, 5, 10, 25。 -
max_features:每次分裂时随机考虑的特征数量。这增加了树之间的差异性,是提升模型性能的关键。可以设置为固定比例(如0.5)、'sqrt'(特征数的平方根)或'log2'(特征数的对数)。 -
oob_score:袋外分数。由于每棵树只使用了约63.2%的样本进行训练,剩余的36.8%的样本(袋外样本)可以用于验证该棵树。计算所有树的袋外预测的平均值,可以得到一个无需单独划分验证集就能估算模型泛化性能的指标。这对于小数据集或快速评估非常有用。 -
set_rf_samples:设置每棵树训练时使用的样本数量。对于超大数据集,可以使用此方法让每棵树只在一个子集上训练,从而大幅加快训练速度,同时通过增加树的数量来保证模型看到足够多的数据。
实践与总结 🏆
通过一些简单的超参数调整(例如,设置min_samples_leaf=3和max_features=0.5),我们成功地将验证集R²从0.898提升到了0.906,对应的RMSE也下降了。根据历史排行榜估算,这个成绩大约能进入该Kaggle竞赛的前20名。
随机森林的强大之处在于其稳健性和易用性。即使不做精细调参,它通常也能给出不错的结果。它对数据预处理的要求相对宽松(例如,无需对类别变量进行独热编码,简单的整数编码即可),并且能自动捕捉变量间的复杂交互关系。
本节课中,我们一起深入学习了随机森林的工作原理、如何构建决策树、Bagging如何通过组合多棵树来提升模型性能,以及几个关键超参数的调优方法。我们还强调了使用验证集和袋外分数来评估模型泛化能力的重要性。

在接下来的课程中,我们将学习如何解释训练好的随机森林模型,从而更深入地理解数据,并进一步改进模型。请大家在本周积极实验,尝试查看单棵树的结构、绘制误差曲线、编写自己的评估函数,以加深对随机森林和数据本身的理解。
003:性能、验证与模型解释 🧠



在本节课中,我们将学习如何评估和解释机器学习模型。我们将重点关注随机森林模型,学习如何衡量其性能、验证其有效性,并深入理解模型从数据中学到了什么。这对于构建可靠且可解释的模型至关重要。
上一节我们介绍了随机森林的基本概念及其调优方法。本节中,我们将探讨如何评估模型性能,并学习如何解释模型以更好地理解数据。
模型性能与验证 📊
为了实践这些概念,我们需要一个Jupyter Notebook环境。你可以选择在本地安装Anaconda,使用AWS,或者直接使用crestle.com或Paperspace.com等已配置好的在线环境。
假设你已经搭建好环境,并有机会在本周练习运行随机森林。需要指出的是,在我们进行任何超参数调整之前,模型的默认设置通常已经能为从Kaggle获取的真实数据集提供相当不错的结果。
但有时,调优是完全必要的。不过,很多时候,即使不做太多调整,模型也能表现良好。
今天,我们将学习一些我认为比单纯构建一个高预测性能的模型更重要的事情:学习如何解释模型,以发现它揭示了数据的哪些信息,从而通过机器学习更深入地理解数据。
这与常见的说法——随机森林等模型是隐藏意义的“黑箱”——恰恰相反。事实上,随机森林使我们能够比传统方法更深入、更快速地理解数据。
我们还将学习如何处理比默认设置能导入的更大的数据集。具体来说,我们将查看一个超过1亿行的数据集,即当前的Kaggle杂货销售预测竞赛。
竞赛数据概览 🛒
在深入模型解释之前,我们先来看看这个大型数据集。本次竞赛是“杂货销售预测”。
以下是该问题的关键信息:
- 预测目标(因变量):在接下来两周内,每家商店、每种商品每天的销售数量。
- 可用信息(自变量):
- 过去几年的历史销售数据(按日期、商店、商品)。
- 商店的元数据(如位置、类别)。
- 商品的元数据(如类别)。
- 日期的元数据(如油价)。
- 数据结构:这是一个典型的“星型模式”关系型数据集。中心是交易表(
train.csv),记录了日期、商店ID、商品ID和销售数量。其他元数据表可以连接到这个中心表上。
处理大型数据集 🚀
处理大型数据集时,不能简单地使用默认方式读取CSV文件,否则可能会耗尽内存。以下是关键步骤:
- 指定数据类型:在读取CSV时,手动为每列指定最节省内存的数据类型(如
int8,int32)。 - 处理布尔值和缺失值:将对象类型转换为布尔类型,并用
False填充缺失值(需先通过探索性数据分析确认合理性)。 - 使用高效格式:将处理好的数据保存为Feather格式,这种格式读写速度极快,因为它与内存中的数据格式几乎相同。
- 数据采样:在初始探索和模型调试阶段,使用随机样本(例如通过Unix的
shuf命令)来快速迭代,而不是直接处理全部数据。
以下是读取和处理大型数据集的核心代码示例:
# 定义列数据类型以节省内存
types = {'store_nbr': 'int8', 'item_nbr': 'int32', 'unit_sales': 'float32', 'onpromotion': 'object'}
# 读取CSV
df = pd.read_csv('train.csv', dtype=types, parse_dates=['date'])
# 处理‘onpromotion’列:填充缺失值,转换布尔类型
df['onpromotion'].fillna(False, inplace=True)
df['onpromotion'] = df['onpromotion'].map({'True': True, 'False': False})
df['onpromotion'] = df['onpromotion'].astype('bool')
# 保存为Feather格式以便快速后续读取
df.to_feather('tmp/groceries')
构建初始随机森林模型 🌲
对于这个预测问题,我们可以构建一个随机森林回归模型。流程与之前类似,但需要注意几点以适应大数据集:
- 设置
set_rf_samples:不要用全部1.2亿条记录训练每棵树,这太慢。可以先用一个较小的样本量(如100万)进行快速实验。 - 避免使用
oob_score:当使用set_rf_samples时,计算袋外分数会用到其他大量数据,仍然很慢。更好的方法是手动创建一个验证集。 - 数据转换优化:将数据框提前转换为浮点数数组(
np.array(df, dtype=np.float32)),可以避免模型内部重复转换,节省时间。 - 性能剖析:使用
%prun魔法命令分析代码运行时间,找出瓶颈并进行优化。
以下是核心建模代码:
# 设置每棵树的样本数
set_rf_samples(1_000_000)
# 将数据转换为数组以加速
X = np.array(trn, dtype=np.float32)
# 初始化并训练随机森林回归器
m = RandomForestRegressor(n_estimators=20, min_samples_leaf=3, max_features=0.5, n_jobs=-1)
m.fit(X, y_train)
# 在验证集上预测并评估
preds = m.predict(np.array(val, dtype=np.float32))
rmse = math.sqrt(mean_squared_error(y_val, preds))
验证集构建的重要性 🎯
构建一个可靠的验证集对于模型开发至关重要。验证集应该尽可能模拟测试集(或未来生产环境)的数据特性。
对于时间序列数据(如本次销售预测),一个关键原则是:验证集的时间段应在训练集之后,并且其时间特征(如星期几、是否节假日、是否发薪日)应与测试集相似。
你可以通过以下方法校准验证集:
- 构建几个不同复杂度的模型。
- 在验证集和测试集(通过提交到Kaggle)上分别计算这些模型的分数。
- 绘制散点图,X轴为测试集分数(领导板分数),Y轴为验证集分数。
- 如果验证集是好的,这些点应该大致呈一条直线。这样,你就可以信任验证集上的分数变化能反映模型在测试集上的相对表现。
模型解释:超越黑箱 🔍
现在,进入本节课的核心——模型解释。我们将学习三种从随机森林中提取洞见的方法。
1. 预测置信度 📈
随机森林由多棵树组成。对于一个数据点,每棵树都会给出一个预测。这些预测值的标准差可以衡量模型对该预测的置信度。
- 标准差小:所有树的预测很接近,模型对该数据点很自信(可能因为训练集中类似样本多)。
- 标准差大:不同树的预测差异大,模型对该数据点不确定(可能因为它是罕见或独特的样本)。
计算预测标准差的方法如下:
# 并行获取每棵树对验证集的预测
preds_per_tree = parallel_trees(m, lambda t: t.predict(X_val), n_jobs=-1)
# 将结果堆叠起来
all_preds = np.stack(preds_per_tree)
# 计算每个样本的预测均值和标准差
pred_mean = all_preds.mean(axis=0)
pred_std = all_preds.std(axis=0)
你可以将置信度(标准差)与预测值一起分析,例如按类别分组查看平均置信度,或在生产系统中对低置信度的预测采取更谨慎的策略。
2. 特征重要性 🥇
这是最常用且最重要的解释工具。它能告诉我们数据集中哪些列(特征)对预测结果最有影响力。
计算方法(排列重要性):
- 在原始数据上计算模型的性能分数(如R²)。
- 对于每一个特征列,将其值随机打乱(破坏该特征与目标值的关系)。
- 用打乱后的数据再次通过模型计算性能分数。
- 该特征的重要性就是原始分数与打乱后分数下降的幅度。下降越多,特征越重要。
Fast.ai库提供了简便的函数:
fi = rf_feat_importance(m, df_trn)
fi.plot('cols', 'imp', figsize=(10,6), legend=False)
特征重要性图几乎总是显示:少数几个特征至关重要,而大多数特征贡献甚微。这能指导你的后续工作:
- 深入理解重要特征:与业务方沟通,研究数据字典,绘制这些特征与目标值的关系图(如
YearMade与价格的关系图)。 - 发现数据问题:有时重要特征可能揭示了数据泄露(例如,某些信息在预测时实际不可用)或共线性问题。
- 简化模型:可以移除重要性极低的特征,这通常不会降低模型精度,甚至可能因减少噪声而略有提升,同时使模型更简洁、训练更快。
3. 部分依赖分析 📉
除了重要性,我们还想知道一个特征如何影响预测。例如,“制造年份”越大,预测价格是越高还是越低?部分依赖图可以显示在保持其他特征平均值不变的情况下,某个特征值变化对预测结果的边际效应。
(注:虽然视频中未详细展开代码,但这是重要的解释性工具。可以使用sklearn.inspection模块的PartialDependenceDisplay或相关库来绘制。)
总结与课后任务 ✅
本节课中我们一起学习了:
- 处理大型数据集的技巧,包括指定数据类型、使用Feather格式和采样策略。
- 构建可靠验证集的重要性,特别是在时间序列问题中,验证集应模拟测试集的时间特性。
- 模型解释的核心方法:
- 利用多棵树预测的标准差来评估预测置信度。
- 通过排列重要性计算特征重要性,识别关键驱动因素并指导后续分析和特征工程。
- 简介了部分依赖分析,用于理解单个特征对预测的具体影响模式。

课后任务:
对于推土机价格预测数据集,请尝试:
- 运行代码,生成特征重要性图。
- 深入研究排名前5-10的重要特征(如
YearMade,Coupler_System)。 - 针对每个重要特征:
- 了解它的业务含义。
- 绘制其与目标值(销售价格)的关系图(散点图、箱线图等)。
- 检查数据质量(是否有噪声、异常编码、高基数分类变量如何处理)。
- 思考是否可以从中提取更有信息的新特征(例如,从
ProductClassDesc中解析出层级信息)。
- 目标是获得关于数据的更深刻洞见,并尝试通过你的发现来改进模型性能。
004:树解释器


在本节课中,我们将要学习如何解释随机森林模型。我们将涵盖特征重要性、处理版本控制与Jupyter笔记本、随机森林超参数的影响、单热编码、使用树状图识别冗余特征,以及使用偏依赖图和树解释器来理解模型如何做出预测。这些技术对于理解数据、改进模型以及向他人解释模型决策至关重要。
版本控制与Jupyter笔记本
在开始模型解释之前,我们先解决一个常见问题:如何处理Jupyter笔记本与Git版本控制的冲突。
Jupyter笔记本在后台是JSON文件。每次运行单元格,即使没有修改内容,它也会更新单元格编号等元数据,导致文件发生变化。这使得合并笔记本更改变得非常困难。
一个简单的解决方法是:当你想开始修改某个笔记本时,首先点击“文件” -> “制作副本”。然后,将副本重命名为以“TmpP”开头的名称。这样,Git会忽略这个文件。现在,你可以自由地在这个副本上实验。当你执行git pull拉取原笔记本的更新时,不会与你的副本产生冲突,你可以同时看到两个版本。
还有其他方法,例如在提交到Git前移除所有单元格输出。但在本课程中,我们希望仓库中的笔记本包含输出,以便在GitHub上阅读。这是一个小问题,但每个人都会遇到。
随机森林超参数与过拟合
上一节我们介绍了随机森林的基础,本节中我们来看看其关键超参数如何影响模型性能,特别是过拟合和处理共线性。
以下是几个核心超参数及其作用:
set_rf_samples:此参数决定每棵树构建时使用的样本行数。公式上,我们从完整数据集D中随机抽取n行子集S,然后基于S构建一棵树。- 影响:减小
n会降低单棵树的预测能力(因为决策更少),但会增加树之间的相关性。这可能导致验证集结果变好或变差,取决于具体的权衡。它也能显著加快训练速度,尤其是在处理大型数据集时。
- 影响:减小
min_samples_leaf:此参数设置叶节点所需的最小样本数。默认值为1。- 影响:增加此值会减少树的深度(大约减少
log2(min_samples_leaf)层)和叶节点数量(大约除以min_samples_leaf)。这使得每个叶节点的预测(平均值)更稳定,单棵树预测能力可能下降,但树之间的相关性也会降低,有助于防止过拟合并加速训练。
- 影响:增加此值会减少树的深度(大约减少
max_features:此参数决定每次进行分裂时,随机考虑的特征数量(例如设为0.5表示考虑一半特征)。- 影响:与上述参数类似,它会降低单棵树的准确性,但通过确保每棵树、甚至每个分裂点考虑不同的特征子集,极大地增加了树之间的多样性(降低相关性)。这对于防止某个强预测特征主导所有树的初始分裂特别有用,从而提升模型的泛化能力。
n_jobs:此参数控制使用的CPU核心数,设为-1表示使用所有核心,可以加快训练速度。oob_score:设为True可以计算袋外(OOB)误差,当没有单独的验证集时,这可以作为一个免费的验证指标。
在实践中,max_features 常设置为 None(使用所有特征)、0.5、sqrt(平方根)或 log2。min_samples_leaf 可以尝试 1, 3, 5, 10, 25, 100 等值,通过验证集性能来判断最佳值。
特征重要性
现在,让我们深入探讨模型解释的核心部分。特征重要性告诉我们哪些特征对模型预测最有影响力。
其工作原理是:对于训练好的随机森林模型,我们一次一列地随机打乱验证集中某个特征的值,然后观察模型预测准确性的下降程度。准确性下降越多,说明该特征越重要。
这种方法非常强大,因为它考虑了特征之间的交互作用。相比之下,如果单独为每个特征训练一个模型,或者使用线性回归的系数,都会因为忽略交互作用或强相关特征(共线性)而导致误导性的结果。例如,在推土机数据集中,“制造年份”和“销售日期”单独看可能不重要,但它们的组合(即设备年龄)可能至关重要。随机森林的特征重要性能够捕捉到这种交互。
计算出的重要性数值本身的具体单位(如R平方的变化量)并不关键,我们更关注特征的相对排序。通常,我们会绘制一个条形图来可视化重要性,并设定一个阈值(例如0.005)来移除不重要的特征。移除这些特征后,模型性能(如验证集RMSE)应保持不变或略有提升,同时简化了模型并可能减少共线性影响,使重要特征的重要性更加凸显。
单热编码
对于分类变量,随机森林默认将其转换为数字代码(标签编码)。但有时,分类变量的某个特定类别可能非常重要。
单热编码为每个类别创建一个新的二进制列(0或1)。例如,一个具有“高”、“中”、“低”三类的变量,可以转换为三个列:“是高”、“是中”、“是低”。这样,随机森林可以在一次分裂中直接识别出重要的特定类别,而不需要通过多次分裂来近似。
在FastAI库中,可以通过 proc_df 函数的 max_n_cat 参数来控制。任何唯一值数量少于 max_n_cat 的分类变量都会被自动进行单热编码。对于高基数(类别很多)的变量(如邮政编码),通常保留为标签编码以避免维度爆炸。
尝试单热编码(例如设置 max_n_cat=7)可能不会总是提升预测精度,但它能改变特征重要性的格局,有助于解释。例如,在推土机数据中,单热编码后显示“带空调的封闭式防滚架”是最重要的特征,这提供了更具体的业务洞察。
识别冗余特征
特征之间可能存在高度相关性,这会使特征重要性分散,并增加不必要的计算。我们可以使用树状图(基于层次聚类)来识别这些冗余特征。
我们使用斯皮尔曼秩相关系数来衡量特征之间的相似性,因为它基于排序,与随机森林的决策方式更匹配。计算所有特征对的秩相关系数后,通过聚类算法生成树状图。在图中,较早连接在一起的特征对更为相似。
例如,在推土机数据中,“销售年份”和“销售已过天数”几乎完全相关;“履带”、“液压流量”和“耦合系统”似乎测量的是同一类东西。我们可以从每组高度相关的特征中尝试移除一个,并观察袋外(OOB)分数是否显著下降。如果下降很小,就可以安全地移除该特征,从而简化模型。
偏依赖图
偏依赖图是一种强大的工具,用于理解单个或两个特征如何影响模型的平均预测,同时控制所有其他特征不变。
其工作原理是:对于某个特征(如“制造年份”),我们选取数据的一个子集(例如500行)。然后,我们将该特征列的所有值依次替换为一个固定值(如1960, 1961, … 2010)。对于每个固定值,我们用训练好的随机森林模型为这500行数据做预测,并计算预测的平均值。最后,我们绘制这个固定值与平均预测值的关系图。
与简单的单变量散点图加平滑曲线相比,PDP图控制了其他变量的影响,能更清晰地揭示真实的因果关系。例如,简单的“制造年份 vs 价格”图可能显示1990-1997年间价格下降,但PDP图显示,在控制其他条件不变后,价格随制造年份增长而线性上升,之前的下降可能是由于当时销售的其他特性(如车型)或经济环境所致。
PDP还可以展示两个特征的交互作用,并可通过聚类分析揭示数据中存在的不同响应模式组别。
树解释器
最后,我们介绍树解释器,它用于解释模型对单个样本的预测。
对于一个给定的数据行(例如一次具体的拍卖),树解释器可以分解随机森林的预测结果。它返回三个部分:
- 预测值:模型对该行的最终预测。
- 偏差:整个训练集目标变量的平均值(对于第一棵树,这是根节点的值)。
- 贡献值:每个特征对该预测的贡献程度。它通过遍历森林中所有树的决策路径,累计每个特征分裂点带来的预测值增减,然后取平均得到。


这样,我们就可以回答诸如“为什么这个推土机的预测价格是X?”的问题。答案可能是:“基准价格是Y,因为它的制造年份较新,贡献了+Z;但因为它的使用年限较长,贡献了-W;等等。”
这对于需要向用户、客户或监管机构解释具体决策的应用场景(如保险定价、信贷审批)至关重要。

本节课中我们一起学习了如何全面解释随机森林模型。我们从处理实用问题开始,回顾了超参数的影响,然后深入探讨了特征重要性、单热编码、识别冗余特征、使用偏依赖图理解特征的整体影响,以及使用树解释器理解单个预测。掌握这些工具将使你不仅能构建准确的模型,还能理解它们,并从数据中提取有价值的商业或科学见解。
005:外推与随机森林
概述
在本节课中,我们将要学习机器学习中关于泛化能力评估的核心概念,包括训练集、验证集、测试集以及OOB(袋外)误差。我们还将深入探讨决策树解释器的工作原理,并开始动手构建我们自己的随机森林模型。课程内容将涵盖如何正确评估模型性能、处理时间序列数据中的外推问题,以及使用面向对象编程(OOP)方法实现机器学习算法。
回顾:泛化与数据集划分
上一节我们介绍了机器学习的基本目标。与许多其他领域不同,机器学习关注的核心是模型的泛化能力,即模型在未见过的数据上的表现。为了评估这种能力,我们需要对数据进行恰当的划分。
数据集划分的目的
我们有一个包含多行数据的数据集,以及一个因变量。机器学习的目标是构建一个能够准确预测新数据的模型,而不仅仅是完美拟合已有数据。例如,一个能完美区分5张猫和5张狗图片的模型,如果无法正确分类新的猫狗图片,就说明它没有学会区分猫狗的本质特征,而是记住了训练样本的特定细节。这就是泛化失败。
标准划分方法
最常见的评估泛化能力的方法是随机抽取一部分数据作为测试集,剩余数据作为训练集。模型在训练集上构建,最终在测试集上评估其性能。
然而,这里存在一个问题:如果模型在测试集上表现不佳,我们可能会返回调整超参数或进行数据增强,然后再次测试。反复尝试后,我们可能偶然找到一个在特定测试集上表现良好的模型,但这并不意味着它真正具有泛化能力。我们可能只是过拟合了这一个测试集。
为了解决这个问题,我们通常采用两层划分:
- 训练集:用于构建模型。
- 验证集:用于在模型开发过程中评估和调整模型,选择超参数。
- 测试集:在项目最终阶段,用于对选定模型进行最终、无偏的评估。
通过这种方式,验证集帮助我们避免在测试集上进行“数据窥探”,确保最终评估的公正性。
随机森林的OOB误差
对于随机森林模型,我们有一个技巧可以避免单独划分验证集,即使用OOB(袋外)误差。
OOB误差的原理
在训练随机森林的每一棵树时,我们使用自助采样法(bootstrap)从训练集中有放回地抽取样本。平均而言,每次采样大约有37%的原始样本不会被抽中,这些样本称为该树的袋外样本。
OOB误差的计算方法是:对于数据集中每一行,找出所有没有使用该行进行训练的树,用这些树对该行进行预测,然后对所有预测结果取平均,作为该行的最终OOB预测。最后,计算所有行的OOB预测误差。
OOB误差与验证集分数的比较
OOB分数在概念上类似于验证集分数,但平均而言,OOB分数会略差一些。原因在于:验证集预测使用了森林中所有的树,而OOB预测对于每一行只使用了森林中一个子集的树(即那些该行未被抽中的树)。使用更少的树进行预测,平均准确度自然会稍低一些。
尽管如此,OOB误差通常足够接近,可以作为模型泛化能力的一个有用估计,尤其是在数据划分的早期阶段。
验证集构建的注意事项
使用随机抽样创建验证集是最简单的情况,但并非总是适用。
时间序列数据
对于涉及时间序列或预测的问题,我们不能简单地随机抽取验证集,因为这会破坏数据的时间顺序。模型的目标是预测未来,因此验证集必须代表“未来”的数据。
正确的做法是:将数据按时间排序,用最近一段时间的数据作为验证集,用更早的数据作为训练集。这样,我们测试的是模型对“未来”事件的预测能力,这才是实际应用中所关心的。
处理时间依赖性的策略
如果模型在训练集和OOB上表现良好,但在时间序列验证集上表现不佳,说明模型未能捕捉时间趋势。此时可以尝试:
- 仅使用最近的数据进行训练。
- 在随机森林中为每条数据赋予权重,使近期数据有更高概率被选中。
一个实用的工作流程是:
- 在训练集上构建多个不同配置的模型(例如,使用全部数据、仅用近期数据、使用不同特征集等)。
- 在验证集上评估这些模型,得到一系列(验证集分数,测试集分数)点对。
- 绘制这些点对。如果验证集是有效的,那么验证集分数的提升应该与测试集分数的提升呈正相关关系(大致呈一条直线)。如果不是,则需要调整验证集的构建方式(例如,尝试不同时间窗口),直到找到能有效指示测试集性能的验证集。
构建一个能真实反映生产环境情况的测试集,是机器学习项目中最关键的步骤之一。 一个糟糕的测试集会让你误以为模型表现良好,从而导致在生产环境中部署失败。


交叉验证
交叉验证是另一种评估技术,但需要谨慎使用。
交叉验证的步骤
- 将数据随机打乱。
- 将数据平均分成k份(例如5份)。
- 进行k轮训练和验证:每一轮,将其中一份作为验证集,其余k-1份作为训练集。
- 计算k轮验证分数的平均值作为最终评估指标。
交叉验证的优缺点
优点:
- 在小数据集上能更充分地利用数据。
- 可以得到k个模型,可用于集成。
缺点:
- 计算成本高:需要训练k个模型。
- 可能不适用:对于时间序列等非独立同分布数据,随机打乱会破坏数据的内在结构,使得交叉验证的结果不具有参考价值。此时,基于时间顺序的验证集划分更为合适。
因此,在数据量充足且问题不涉及强时间依赖时,简单的单次验证集划分通常是更高效、更直接的选择。
决策树与随机森林解释
理解模型为何做出特定预测至关重要。对于随机森林,我们可以使用treeinterpreter库来分解预测结果。
单棵决策树的解释
考虑一棵决策树,其根节点包含了所有训练样本因变量的平均值。随着我们在树上向下遍历,根据特征进行分裂,每个节点代表了满足之前所有分裂条件的样本子集的平均值。
对于一个具体的预测,我们可以追踪从根节点到最终叶节点的路径。路径上每个分裂点带来的因变量平均值的变化,就是该特征对最终预测的“贡献”。最终预测值等于根节点的平均值加上路径上所有特征贡献的总和。
瀑布图
这种贡献可以直观地表示为瀑布图。瀑布图从左到右显示了起始值(全体平均值),然后依次增加或减少每个特征的贡献,最终到达预测值。蓝色条表示正向贡献,红色条表示负向贡献。
随机森林的解释
对于随机森林,treeinterpreter对森林中所有树的贡献进行平均。对于一条数据,它可以输出:
bias:森林中所有树的根节点平均值(即训练集全局平均值)。contributions:一个列表,包含每个特征对预测的平均贡献(正值表示提升预测,负值表示降低预测)。prediction:bias与所有contributions之和,即模型的最终预测值。
这种方法使得复杂的随机森林模型变得高度可解释,我们可以清楚地看到是哪些特征及其取值导致了特定的预测结果。
处理外推问题
随机森林在处理外推(预测训练数据范围之外的情况)时存在挑战,尤其是在时间序列中。
问题描述
随机森林通过查找历史数据的平均值来进行预测。如果数据具有时间趋势(例如,价格随时间上涨),而模型没有显式的时间特征,那么当预测未来时间点时,它只能给出历史上相似条件的平均价格,无法捕捉趋势性的增长。
识别时间依赖性特征
一个有用的技巧是:将训练集和验证集合并,创建一个新的二元因变量,例如is_valid(验证集为1,训练集为0)。然后训练一个随机森林来预测这个新变量。
- 如果模型能够完美预测(AUC接近1),说明验证集和训练集在特征分布上有显著差异,很可能是因为存在强时间依赖的特征。
- 通过检查这个模型的特征重要性,我们可以找出哪些特征与时间高度相关(例如,序列ID、日期、设备年龄等)。
优化策略
找到时间依赖性特征后,我们可以尝试:
- 移除或替换:直接移除这些特征,看是否还有其他特征能提供相似的预测能力但不依赖于时间。
- 比较验证集与OOB:在移除某些时间特征后,如果模型在时间验证集上的性能提升,而OOB误差变化不大或也提升,说明我们找到了更适用于外推的、时间无关的特征组合。
通过迭代这个过程,我们可以构建一个在时间维度上泛化能力更强的模型。
动手构建随机森林(上)
最后,我们开始探讨如何从零开始实现随机森林,并引入了面向对象编程(OOP)的概念。
顶层设计:树集合
我们采用自顶向下的设计方法。首先,我们设想已经有一个可用的随机森林类TreeEnsemble。
# 目标使用方式
m = TreeEnsemble(x_train, y_train, n_trees=10, sample_sz=1000, min_leaf=3)
preds = m.predict(x_valid)
构造函数
TreeEnsemble的构造函数(__init__)需要接收必要的参数并将其存储为对象属性(self.xxx)。
class TreeEnsemble():
def __init__(self, x, y, n_trees, sample_sz, min_leaf=5):
np.random.seed(42) # 设置随机种子以保证结果可复现
self.x, self.y, self.n_trees, self.sample_sz, self.min_leaf = x, y, n_trees, sample_sz, min_leaf
self.trees = [self.create_tree() for i in range(n_trees)]
预测方法
随机森林的预测是所有树预测的平均值。
def predict(self, x):
# 对每棵树进行预测,然后对结果取平均
tree_predictions = np.array([t.predict(x) for t in self.trees])
return tree_predictions.mean(axis=0)
创建单棵树
create_tree方法负责创建单棵决策树。它需要从数据中随机抽取一个子样本(这里我们使用无放回抽样)。
def create_tree(self):
# 生成随机索引来进行子采样
idxs = np.random.permutation(len(self.y))[:self.sample_sz]
return DecisionTree(self.x.iloc[idxs], self.y[idxs], idxs=np.array(range(self.sample_sz)), min_leaf=self.min_leaf)
决策树类的框架
DecisionTree类将包含构建真实决策树的逻辑。在顶层设计中,我们暂时只定义其框架。
class DecisionTree():
def __init__(self, x, y, idxs, min_leaf=5):
self.x, self.y, self.idxs, self.min_leaf = x, y, idxs, min_leaf
self.n, self.c = len(idxs), x.shape[1]
self.val = np.mean(y[idxs]) # 当前节点(或叶节点)的预测值
self.score = float('inf') # 用于后续分裂
self.find_varsplit() # 后续需要实现的方法:寻找最佳分裂点
def predict(self, x):
# 后续需要实现:遍历树并对输入x进行预测
return np.array([self.predict_row(xi) for xi in x])
面向对象编程(OOP)要点
self:Python类中每个方法的第一个参数,代表对象实例本身。用于访问和存储该对象的属性和方法。__init__:构造函数,在创建对象时自动调用。- 属性:通过
self.attribute_name存储和访问的数据。 - 方法:定义在类中的函数,可通过
object.method()调用。
OOP允许我们将数据和相关操作封装在一起,使代码结构更清晰,更易于管理和扩展。在实现机器学习算法时,这是一种非常有效的组织代码的方式。
总结

本节课我们一起学习了机器学习模型评估的核心流程。我们深入探讨了训练集、验证集和测试集的正确使用方法,理解了OOB误差的原理与局限,并特别强调了在时间序列数据中构建验证集的重要性。我们还学习了如何使用treeinterpreter来解释随机森林的预测,并介绍了处理时间外推问题的实用技巧。最后,我们开始了从零实现随机森林的旅程,初步了解了如何用面向对象编程的思想来组织代码。在接下来的课程中,我们将深入实现决策树的分裂逻辑,完成我们的随机森林模型。
006:数据产品与实时编码 🚀





在本节课中,我们将探讨机器学习模型的实际应用,特别是如何将预测模型转化为能够创造商业价值的数据产品。我们将重点介绍一个名为“驱动链”的框架,并讨论模型可解释性在业务决策中的关键作用。


为什么需要机器学习?🤔
上一节我们介绍了多种随机森林的可解释性技术。一个常见的问题是:这些技术真的能帮助我在Kaggle上获得更好的分数吗?我的回答是:不一定。因此,我们需要更深入地思考:我们为什么要做机器学习?它的意义是什么?
为了回答这个问题,我将展示一些机器学习在商业中的实际应用案例。这些案例要么是我亲身参与过的,要么是我直接了解到的同行的工作。它们都不是假设性的,而是真实世界中的应用。
我将这些应用分为两类:横向应用和纵向应用。
- 横向应用指的是在不同类型业务中通用的应用。
- 纵向应用指的是在特定业务、供应链或流程内部的应用。
一个典型的横向应用例子是市场营销。几乎每家公司都需要向客户销售更多产品,因此都会进行营销活动。
驱动链方法:从目标到模型 🛠️

为了将机器学习项目转化为真正有用的东西,我使用一个名为“驱动链”的框架。这个框架包含四个步骤:

-
目标:首先要明确你想要实现什么。这不是指获得高的AUC分数,而是指直接的商业目标,例如“卖出更多书”、“减少下月流失的客户数量”或“更早地检测出肺癌”。
-
杠杆:杠杆是组织为了推动目标实现而可以采取的具体行动。例如,为了减少客户流失,公司可以打电话、发邮件、提供激励措施或调整产品价格。构建模型本身从来不是一个杠杆,但它可以帮助你更好地使用杠杆。
-
数据:在做出决策时,组织拥有或可以收集哪些数据来帮助设置杠杆以实现目标?这里需要考虑实际操作中的约束条件。
-
模型:这里的模型不是指预测模型,而是指仿真模型。它将多个预测模型与业务逻辑结合起来,模拟不同杠杆设置对最终目标的影响。


以下是驱动链方法的可视化表示:



在实践中,你通常更关心仿真模型的结果,而不是单个预测模型本身。大多数公司目前并没有有效地做到这一点。

案例研究:客户流失建模 📉
让我们以客户流失建模为例。假设我们为一家大型电信公司工作,目标是预测哪些客户即将离开。
仅仅构建一个高预测性的模型,指出“Jeremy Howard下个月几乎肯定会离开”可能帮助不大,因为那时可能为时已晚,挽留成本过高。
我们需要运用驱动链方法来思考:
- 目标:减少下个月流失的客户数量。
- 杠杆:我们可以给客户打电话、发送关怀邮件、提供特殊折扣或免费礼品。
- 数据:在决定是否联系客户时,我们拥有哪些数据(例如,客户居住地、上月通话记录、联系客服次数)?
- 模型:我们需要一个仿真模型来评估不同行动(如打电话 vs. 发折扣券)对特定客户留存概率的影响,并计算其成本和收益。
这样,我们就能从“预测谁会离开”转变为“决定采取什么行动来改变结果”。

预测模型 vs. 优化模型 ⚖️
大多数机器学习项目止步于预测模型。但更有价值的是构建优化模型或仿真模型。
例如,亚马逊的推荐系统。如果我读完了道格拉斯·亚当斯的所有书,系统再推荐我购买他的合集是无效的,因为这不会改变我的购买行为。一个优化模型会模拟:如果向Jeremy展示特里·普拉切特的书(他从未买过),他购买的概率会增加多少?这种模型关注的是反事实——即如果不采取行动会发生什么,从而最大化地改变用户行为。
在客户流失的例子中,如果模型预测我(Jeremy)很可能离开,公司需要仿真模型来评估“打电话给我”这个行动是会改善情况,还是反而会激怒我立即取消服务。
可解释性的核心作用 🔍
当我们以这种方式思考问题时,会发现大多数应用的核心并不完全是预测模型,而是解释和理解。
我们可以将随机森林的特征重要性与可用的业务杠杆结合起来:
- 特征重要性告诉我们哪些因素是结果的重要驱动因素。
- 杠杆告诉我们组织可以改变哪些事情。
- 两者的交集就是我们可以拉动且真正重要的杠杆。
然后,我们可以使用部分依赖图来构建仿真模型,回答“如果我们改变了那个因素,会发生什么?”。
横向与纵向应用实例 📊
除了市场营销,机器学习还有许多其他横向和纵向应用。
横向应用(跨行业):
- 欺诈检测
- 人力资源(员工流失分析)
- 销售线索优先级排序
- 制造业故障分析
纵向应用(特定领域):
- 医疗保健:再入院风险预测。预测模型本身有帮助,但结合树解释器,我们可以知道高风险是因为“缺少近期心电图”,从而采取“做一次心电图”的针对性措施,而不是简单地“让病人多住院两周”。
- 零售:店铺选址、商品陈列优化。
- 航空:航班调度与登机口管理,预测延误及其连锁影响。
这些应用大多不是为了完全自动化决策,而是为了向人类决策者(从战略制定者到一线客服)提供更好的信息,帮助他们做出更明智的决策。
随机森林可解释性技术回顾 🌲
现在,让我们回顾一下学过的随机森林可解释性技术,并思考它们如何服务于上述目标。
以下是主要技术及其要点:

基于树方差的置信度
- 它告诉我们什么:对单个预测值的置信度(即森林中各棵树预测结果的方差)。
- 为何有用:在需要承担高风险的决策中(如信贷审批),我们不仅关心平均预测值,还关心预测的稳定性。低置信度的预测可能需要人工复核或更保守的决策。
- 如何计算:对于单个观测值,计算随机森林中所有树对其预测值的方差或标准差。

特征重要性
- 它告诉我们什么:每个特征对模型整体预测能力的贡献度。
- 为何有用:识别最关键的影响因素,从而指导数据收集、流程改进或与业务方沟通。有时能发现数据泄露问题(如特征重要性极高的字段实际上在目标发生后才被填写)。
- 如何计算:打乱某个特征列的所有值(破坏其与目标的关系),然后使用训练好的模型重新预测,观察模型性能(如R²、RMSE)下降的程度。下降越多,该特征越重要。
部分依赖图
- 它告诉我们什么:在保持其他所有特征不变的情况下,某个特征与预测目标之间的平均关系。
- 为何有用:避免被单变量图表误导,揭示“其他条件不变”时特征与目标的真实关系。这对于理解杠杆作用至关重要——如果改变X,Y会如何变化?
- 如何计算:将数据集中某个特征的所有值统一替换为一个特定值(如1960),用训练好的模型进行预测,得到平均预测值。对所有可能取值重复此过程,绘制出关系曲线。
树解释器
- 它告诉我们什么:对于单个观测值,每个特征对其最终预测值的贡献是多少。
- 为何有用:提供个案层面的解释。例如,向客户解释保费上涨的原因(“因为您搬到了事故率更高的邮编区”),或向医生解释某病人再入院风险高的具体原因。
- 如何计算:追踪该观测值在决策树中的路径。从所有样本的平均值(偏差)开始,每经过一个基于特征的节点,根据分支调整预测值。将所有调整值(贡献)列出,其总和即为最终预测值。可以用瀑布图直观展示。
外推问题与实时编码演示 💻
随机森林的一个关键局限性是它不擅长外推(预测训练数据范围之外的情况)。它本质上是对训练集中相似样本取平均。
我们通过一个简单的合成数据实验来演示这一点:
import numpy as np
import matplotlib.pyplot as plt
from sklearn.ensemble import RandomForestRegressor
# 创建合成数据:简单的线性关系加噪声
x = np.linspace(0, 10, 50)
y = x + np.random.uniform(-0.2, 0.2, x.shape)
# 分割数据:前40个点训练,后10个点测试(模拟时间序列外推)
X_train, X_val = x[:40, None], x[40:, None] # 使用 None 增加一个维度,将向量变为矩阵
y_train, y_val = y[:40], y[40:]
# 训练随机森林
m = RandomForestRegressor(n_estimators=20, min_samples_leaf=3)
m.fit(X_train, y_train)
# 绘制训练集预测效果
plt.scatter(X_train, y_train, label='Train Data')
plt.scatter(X_train, m.predict(X_train), label='Train Pred')
plt.legend()
plt.show()
# 绘制测试集(外推)预测效果
plt.scatter(X_train, y_train, label='Train Data')
plt.scatter(X_val, y_val, label='Val Data (True)')
plt.scatter(X_val, m.predict(X_val), label='Val Pred')
plt.legend()
plt.show()
运行代码会发现,模型在训练集上拟合良好,但在测试集(外推区域)的预测几乎是一条水平线,因为它无法预测从未见过的更高值。它只能输出训练集中相似X值对应Y的平均值。
如何应对外推问题?
- 避免不必要的时间依赖性:在特征工程中谨慎处理时间变量。
- 使用能够外推的模型:如神经网络(可以学习函数形式)或专门的时间序列模型(如ARIMA)。
- 去趋势后建模:先用时间序列技术去除数据中的趋势和季节性,然后用随机森林等模型预测残差。
- 使用梯度提升机:GBM通过序列化的小树拟合残差,能更好地处理具有强趋势的数据,但它同样无法可靠地进行范围外的外推。
总结 🎯
本节课我们一起学习了:
- 机器学习的商业目标:不仅仅是构建高精度模型,更是要解决实际的商业问题,创造价值。
- 驱动链框架:一个将机器学习项目转化为数据产品的系统性方法,包括明确目标、识别杠杆、评估数据和构建仿真模型。
- 模型可解释性的核心价值:特征重要性、部分依赖图、树解释器等技术帮助我们理解模型、与业务方沟通、识别 actionable 的杠杆,并构建更有效的仿真模型。
- 丰富的应用场景:机器学习广泛应用于市场营销、金融、医疗、零售、航空等各个领域,其价值在于赋能人类决策。
- 随机森林的局限性:不擅长外推,在涉及时间序列或预测未知范围数据时需要特别小心,并考虑结合其他技术。

记住,优秀的数据科学家不仅需要精通模型构建,还需要深刻理解战略背景,并能够通过可解释的模型架起技术与业务之间的桥梁。
007:随机森林与梯度下降 🎯
在本节课中,我们将学习随机森林的构建,并探讨梯度下降的基本概念。我们将从零开始构建一个随机森林,并讨论一些实际应用中的关键问题,如验证集大小的确定、处理不平衡数据集等。最后,我们将简要介绍神经网络的基本概念,为后续课程打下基础。
随机森林的构建 🛠️
上一节我们介绍了随机森林的基本概念,本节中我们将从零开始构建一个随机森林。首先,我们需要创建一个树集成(Tree Ensemble),其中包含多棵决策树。
树集成(Tree Ensemble)
以下是树集成的基本结构:
class TreeEnsemble:
def __init__(self, x, y, n_trees, sample_size, min_leaf=5):
self.x = x
self.y = y
self.n_trees = n_trees
self.sample_size = sample_size
self.min_leaf = min_leaf
self.trees = [self.create_tree() for _ in range(n_trees)]
def create_tree(self):
idxs = np.random.permutation(len(self.y))[:self.sample_size]
return DecisionTree(self.x.iloc[idxs], self.y[idxs], self.min_leaf)
def predict(self, x):
return np.mean([t.predict(x) for t in self.trees], axis=0)
决策树(Decision Tree)
接下来,我们需要实现决策树类。决策树的核心是找到最佳的分裂点。
class DecisionTree:
def __init__(self, x, y, min_leaf=5, idxs=None):
self.x = x
self.y = y
self.min_leaf = min_leaf
self.idxs = idxs if idxs is not None else np.arange(len(y))
self.val = np.mean(y[self.idxs])
self.score = float('inf')
self.find_varsplit()
def find_varsplit(self):
for i in range(self.x.shape[1]):
self.find_better_split(i)
if self.score == float('inf'):
return
x = self.split_col
lhs = x <= self.split
rhs = x > self.split
self.lhs = DecisionTree(self.x, self.y, self.min_leaf, self.idxs[lhs])
self.rhs = DecisionTree(self.x, self.y, self.min_leaf, self.idxs[rhs])
def find_better_split(self, var_idx):
x = self.x.values[self.idxs, var_idx]
y = self.y[self.idxs]
sort_idx = np.argsort(x)
x_sorted = x[sort_idx]
y_sorted = y[sort_idx]
n = len(y)
for i in range(1, n - 1):
if x_sorted[i] == x_sorted[i + 1]:
continue
lhs = y_sorted[:i]
rhs = y_sorted[i:]
score = self.calc_score(lhs, rhs)
if score < self.score:
self.var_idx = var_idx
self.score = score
self.split = x_sorted[i]
def calc_score(self, lhs, rhs):
lhs_std = np.std(lhs)
rhs_std = np.std(rhs)
return lhs_std * len(lhs) + rhs_std * len(rhs)
预测方法
最后,我们需要为决策树添加预测方法:
def predict(self, x):
if self.score == float('inf'):
return self.val
if x[self.var_idx] <= self.split:
return self.lhs.predict(x)
else:
return self.rhs.predict(x)
验证集大小的确定 📊
在构建模型时,验证集的大小是一个关键问题。验证集的大小取决于我们需要多准确地知道模型的性能。
二项分布与标准误差
如果我们有一个二项分布,其均值为 np,标准差为 √(np(1-p))。标准误差(即均值的标准差)为 √(np(1-p))/√n。
以下是计算验证集大小的步骤:
- 确定模型在验证集上的准确率 p。
- 计算标准误差 SE = √(p(1-p)/n)。
- 根据业务需求,确定可接受的误差范围。
例如,如果验证集大小为2000,准确率为99.4%,则错误样本数为12。如果准确率下降到99.2%,则错误样本数增加到16。虽然差异看似微小,但在某些场景下(如欺诈检测),这可能意味着成本增加25%。
经验法则
一个经验法则是,每个类别的样本数应至少为22。这是因为当样本数达到22时,t分布近似于正态分布,结果更加稳定。
处理不平衡数据集 ⚖️
当数据集中某些类别的样本数较少时,我们需要采取特殊措施。
以下是处理不平衡数据集的常用方法:
- 过采样(Oversampling):复制少数类样本,使其与多数类样本数量相等。
- 类别权重(Class Weight):在训练时,为少数类样本分配更高的权重。
- 分层抽样(Stratified Sampling):在每次抽样时,确保少数类样本被选中的概率更高。
研究表明,过采样少数类样本通常是处理不平衡数据集的最佳方法。
随机森林的优化 🚀
随机森林的性能可以通过优化算法进一步提升。例如,我们可以使用Cython将Python代码编译为C代码,从而显著提高运行速度。
Cython示例
以下是一个简单的Cython示例:
%load_ext Cython
%%cython
def fib_cython(int n):
cdef int a = 0, b = 1, i
for i in range(n):
a, b = b, a + b
return a
通过添加类型声明,Cython可以进一步优化代码性能。


神经网络简介 🧠
随机森林属于决策树集成方法,而神经网络是另一类强大的机器学习技术。神经网络可以处理结构化数据和非结构化数据(如图像、音频、文本),并具有更强的表达能力。
神经网络的优势
- 可扩展性:神经网络可以通过增加层数和参数来处理复杂问题。
- 泛化能力:神经网络能够从数据中学习复杂的非线性关系。
- 灵活性:神经网络可以应用于各种任务,如分类、回归、生成模型等。
在接下来的课程中,我们将深入探讨神经网络的基本原理和应用。
总结 📝
本节课中,我们一起学习了如何从零开始构建随机森林,并讨论了验证集大小的确定、不平衡数据集的处理方法以及随机森林的优化技巧。最后,我们简要介绍了神经网络的基本概念。通过本节课的学习,你应该能够理解随机森林的工作原理,并掌握其在实际应用中的关键技巧。

在接下来的课程中,我们将进一步探讨梯度下降和神经网络,为更复杂的机器学习任务打下基础。祝你学习愉快!😊
008:逻辑回归 🧠



在本节课中,我们将要学习逻辑回归,这是神经网络的基础构建模块。我们将从使用高级库构建一个简单的模型开始,然后逐步深入,最终从零开始实现我们自己的逻辑回归模型。通过这个过程,你将理解神经网络背后的核心数学原理。

从决策树到神经网络 🌳➡️🧠



上一节我们介绍了决策树集成方法(如随机森林)的局限性。本节中我们来看看为什么我们需要转向神经网络。
随机森林和决策树本质上是执行一种“最近邻”操作。它们通过返回一系列其他数据点的平均值来进行预测。这种方法存在一些限制:
- 难以外推:它们无法预测从未见过的数据点,例如将价格提高20%后的销量,或者明年的销售情况。
- 计算能力有限:它们只能进行大约
log₂(N)次决策。对于需要多步计算才能捕捉的复杂关系(如时间序列),其建模能力有限。
虽然随机森林对非顺序的分类变量也能处理,但可能需要更多的分裂点,效率上并非最优。此外,对于像图像识别(如MNIST手写数字)这类任务,像素间的空间结构关系很重要,随机森林这种“巧妙的最近邻”方法效果并不理想。
神经网络则在这类任务上表现出色,无论是时间序列预测还是图像、语音识别。实际上,随机森林和神经网络结合起来,几乎可以覆盖我们遇到的大部分问题领域。
认识MNIST数据集 🔢
我们的任务是识别手写数字。我们将使用著名的MNIST数据集,它包含28x28像素的灰度图像,每个像素值在0到255之间(0为白色,255为黑色)。
以下是加载和准备数据的步骤:
# 加载MNIST数据(假设数据已下载并解压)
def load_mnist():
import gzip, pickle
with gzip.open('mnist.pkl.gz', 'rb') as f:
((x_train, y_train), (x_valid, y_valid), _) = pickle.load(f, encoding='latin-1')
return x_train, y_train, x_valid, y_valid
x_train, y_train, x_valid, y_valid = load_mnist()
数据被扁平化为长度为784(28*28)的一维数组。在深度学习中,我们经常需要改变张量的形状(即“重塑”)。
# 查看训练数据的形状
print(x_train.shape) # 输出: (50000, 784)
print(y_train.shape) # 输出: (50000,)
# 将验证集的一个图像重塑回28x28以便显示
import numpy as np
sample_image = x_valid[0].reshape(28, 28)
张量术语回顾:
- 向量 / 1D数组 / 一阶张量:例如
[1, 2, 3] - 矩阵 / 2D数组 / 二阶张量:例如
[[1,2], [3,4]] - 对于更高维度,我们统称为 N阶张量。
数据标准化 📏
对于参数化模型(如神经网络),我们需要对输入数据进行标准化(减去均值并除以标准差),这有助于模型更有效地训练。这与随机森林不同,随机森林只关心特征的排序,不关心具体数值大小。
# 使用训练集的统计量来标准化训练集和验证集
train_mean = x_train.mean()
train_std = x_train.std()
x_train = (x_train - train_mean) / train_std
x_valid = (x_valid - train_mean) / train_std # 关键:使用训练集的均值和标准差


重要原则:任何应用于训练集的预处理变换(标准化、分类编码、缺失值填充),都必须以完全相同的方式应用于验证集和测试集,以确保数据含义的一致性。



构建第一个神经网络(实为逻辑回归)⚙️
我们将使用PyTorch库来构建模型。PyTorch允许我们编写类似NumPy的代码,并能在GPU上高效运行,还能自动计算梯度。
首先,我们以“自上而下”的方式,使用高级组件快速构建一个模型:
import torch.nn as nn
# 定义一个简单的神经网络(实际上是一个逻辑回归)
net = nn.Sequential(
nn.Linear(28*28, 10), # 线性层:输入784维,输出10维(10个数字类别)
nn.LogSoftmax(dim=1) # 非线性激活函数:LogSoftmax
).cuda() # 将模型移至GPU
这个网络只有一层线性变换加一个激活函数,本质上就是一个逻辑回归模型。线性层执行 y = Ax + b 操作,其中 A 是权重矩阵,b 是偏置向量。
接下来,我们定义损失函数和优化器,并训练模型:

from fastai import *
from fastai.vision import *
# 创建数据对象
data = ImageClassifierData.from_arrays('.', (x_train, y_train), (x_valid, y_valid))
# 定义损失函数(负对数似然损失,也称交叉熵损失)和优化器
loss_func = nn.NLLLoss()
optimizer = optim.Adam(net.parameters())
# 训练模型一个周期(epoch)
fit(net, data, n_epochs=1, crit=loss_func, opt=optimizer, metrics=[accuracy])
训练后,我们可以在验证集上进行预测并计算准确率:
preds = net(torch.from_numpy(x_valid).cuda()) # 得到10000x10的概率矩阵
preds = preds.argmax(dim=1) # 取每行最大值的索引,即预测的数字
accuracy = (preds.cpu() == torch.from_numpy(y_valid)).float().mean()
print(f"准确率: {accuracy:.4f}") # 输出大约为 0.918
我们得到了约91.8%的准确率。通过可视化一些预测错误的样本,我们可以直观理解模型的局限——它只是简单地将每个像素与每个数字的“平均模板”进行加权比较。
深入原理:从零实现逻辑回归 🔧
现在,我们抛开高级封装,自己实现逻辑回归的每一个部分。这将帮助我们理解背后的数学。
1. 理解损失函数:交叉熵
我们使用的损失函数是负对数似然损失(NLL),对于二分类问题也称为二元交叉熵,对于多分类(如10个数字)则称为分类交叉熵。
二元交叉熵公式:
Loss = -[y * log(p) + (1-y) * log(1-p)]
其中 y 是真实标签(0或1),p 是模型预测为正类的概率。
对于多分类(如MNIST),我们使用分类交叉熵。它相当于对每个类别进行二元交叉熵计算,然后求和。在实践中,我们通常将标签进行独热编码,然后计算预测概率分布与真实分布之间的交叉熵。


2. 手动定义模型层

我们将创建一个PyTorch模块,手动定义权重矩阵、偏置向量和前向传播过程。
import torch
import torch.nn as nn
import math


class LogisticRegression(nn.Module):
def __init__(self):
super().__init__() # 必须首先初始化父类
# 初始化权重矩阵 (784, 10) 和偏置向量 (10,)
# 使用特定的初始化方法防止梯度爆炸/消失
self.weights = nn.Parameter(torch.randn(28*28, 10) / math.sqrt(28*28))
self.bias = nn.Parameter(torch.zeros(10))
def forward(self, x):
# x 的形状: [batch_size, 28, 28]
x = x.view(-1, 28*28) # 扁平化为 [batch_size, 784]
x = torch.matmul(x, self.weights) + self.bias # 线性变换: y = Ax + b
x = torch.softmax(x, dim=1) # Softmax激活函数,将输出转化为概率
return x
代码解析:
nn.Parameter:告诉PyTorch这些张量是模型参数,需要在训练中更新。view:PyTorch中重塑张量形状的方法,等同于NumPy的reshape。-1表示自动推断该维度大小。torch.matmul:矩阵乘法。torch.softmax:Softmax函数。公式为:对于向量z的每个元素z_i,softmax(z_i) = exp(z_i) / sum(exp(z))。它能确保输出所有值之和为1,且每个值在0到1之间,适合解释为概率。
3. Softmax的作用
为什么需要Softmax?
- 输出概率化:它将线性层的原始输出(logits)转换为概率分布。
- 放大差异:由于使用了指数函数
exp,输入中较大的值在输出中会占据主导地位,使得预测结果更“明确”(一个概率接近1,其他接近0)。 - 匹配目标:我们的标签是独热编码(一个位置为1,其余为0),Softmax的输出形式与之匹配,使得损失函数计算更直接。
现在,我们可以用这个自定义类替换之前的nn.Sequential模型,并应该能得到相似的训练结果。
总结与练习 📚
本节课中我们一起学习了逻辑回归,它是神经网络的基础。我们:
- 回顾了从树模型转向神经网络的原因。
- 学习了如何处理和标准化像MNIST这样的图像数据。
- 使用PyTorch高级API快速构建并训练了一个逻辑回归模型,达到了约92%的准确率。
- 深入原理,从零开始实现了逻辑回归模型,包括权重初始化、线性变换和Softmax激活函数。
本周练习建议:
- 尝试手动实现二元交叉熵和分类交叉熵损失函数,先用if语句版本,再尝试向量化版本。
- 使用
torch.randn、torch.matmul等函数创建随机张量并进行操作,熟悉PyTorch张量运算。 - 查阅PyTorch官方教程,巩固张量重塑、切片、维度变换等操作。
- 思考:如果不在逻辑回归后使用Softmax,而使用其他函数(如Sigmoid),会怎样?

通过动手实践这些基础组件,你将为理解更复杂的深度神经网络打下坚实的基础。下次课我们将在此基础上,探索如何堆叠更多的层来构建真正的深度网络。
009:正则化
在本节课中,我们将回顾旧金山大学学生们在机器学习课程中构建的一些精彩项目,并深入探讨随机梯度下降(SGD)的内部工作原理,特别是如何从零开始构建一个逻辑回归模型。我们将逐步摆脱对高级库的依赖,理解其背后的核心机制。
学生项目展示



上一节我们介绍了课程背景,本节中我们来看看学生们在过去一周里完成的一些出色工作。其中许多项目已经在互联网上广泛传播。
以下是几个值得关注的示例:
-
Tyler的可视化项目:Tyler创建了一个合成数据集,其中自变量是X和Y坐标,因变量是颜色。他首先构建了一个决策树模型,并成功绘制了其决策边界。接着,他通过重采样生成了多棵树,并可视化展示了装袋法(Bagging) 和随机森林的平均效果。这个项目通过图像直观地解释了复杂的集成学习概念。
![]()
![]()
![]()
-
Jason的Parfit库:Jason创建了一个名为Parfit的新库,用于并行化拟合多个模型以进行超参数选择。该库提供了清晰的API,并采用了我们几周前学习的正确验证集技术。其代码简洁、文档完善,便于他人使用。

- Vannay的组合应用:Vannay将Parfit库与上节课学习的加速SGD分类方法相结合,使用Parfit来寻找SGD逻辑回归的最佳参数。




-
Prince的随机森林解读:Prince总结并详细解释了随机森林的各种解读方法(如特征重要性、树解释器),并提供了从零实现的代码。他还展示了如何将树解释器的输出与另一位学生Chris构建的瀑布图包结合,进行可视化。
![]()
-
Devveche的Kaggle内核:Devveche在一个极具挑战性的Kaggle竞赛(检测冰山与船只)中,处理了难以可视化的双通道卫星雷达数据。他推导了雷达散射公式,并编写代码重建了3D冰山或船只图像,然后构建神经网络进行解读。
![]()
![]()
![]()
这些项目表明,将所学知识付诸实践并分享给社区,是巩固学习和获得反馈的极佳方式。
从零开始构建神经网络
现在,让我们回到SGD和神经网络的核心主题。我们将跟随一个笔记本,从使用高级库开始,逐步剥离它们,直到从零实现逻辑回归。
初始设置:使用库
我们的目标是构建一个神经网络来识别MNIST手写数字。数据已下载并进行了标准化处理(减去均值,除以标准差)。每个图像被展平为一个长度为784的向量。
以下是基本步骤:
- 构建最简单的神经网络(即逻辑回归,无隐藏层)。
- 使用PyTorch的
nn库和fastai库进行训练。 - 逐步摆脱这些库,自己实现所有组件。
初始模型使用PyTorch的nn.Sequential定义了一个线性层后接一个log-softmax层。训练后,准确率大约在91%-92%。
# 使用PyTorch nn库的示例
model = nn.Sequential(nn.Linear(784, 10), nn.LogSoftmax(dim=1))
第一步:自定义网络模块
接下来,我们不使用nn.Sequential和nn.Linear,而是通过继承nn.Module类来从头定义我们的逻辑回归网络。
在__init__构造函数中,我们手动创建权重矩阵和偏置向量,并用nn.Parameter包装它们,以便PyTorch知道这些是需要优化的参数。
class LogisticRegression(nn.Module):
def __init__(self):
super().__init__()
self.l1_w = nn.Parameter(torch.randn(784, 10) / math.sqrt(784)) # 权重
self.l1_b = nn.Parameter(torch.zeros(10)) # 偏置
def forward(self, x):
# 线性变换: x @ w + b
x = x @ self.l1_w + self.l1_b
# 激活函数: log-softmax
return F.log_softmax(x, dim=1)
在forward方法中,我们定义了数据通过网络的前向传播过程:矩阵乘法(线性变换)后接log-softmax激活函数。softmax函数将输出转换为概率分布,其公式为:
[
\text{softmax}(x_i) = \frac{e^{x_i}}{\sum_{j} e^{x_j}}
]
log-softmax则在此基础上取对数,常用于数值稳定性。
实例化这个类并训练后,我们得到了与使用库相近的准确率。
前向传播与模块调用
在PyTorch中,继承自nn.Module的类实例可以像函数一样被调用,这实际上会触发其forward方法。这种设计使得网络、层和函数可以灵活地组合和互换。
net2 = LogisticRegression().cuda() # 实例化并移至GPU
x_var = Variable(x_mini_batch).cuda() # 将数据包装为Variable并移至GPU
preds = net2(x_var) # 调用网络,执行前向传播
扩展:添加隐藏层



将逻辑回归扩展为具有一个隐藏层的神经网络非常简单。只需在nn.Sequential中添加更多的线性层和非线性激活函数(如ReLU)。
# 具有一个隐藏层的神经网络
model = nn.Sequential(
nn.Linear(784, 100), # 隐藏层
nn.ReLU(), # 非线性激活函数
nn.Linear(100, 10), # 输出层
nn.LogSoftmax(dim=1)
)
通过添加一个具有100个单元的隐藏层和ReLU激活函数,模型在MNIST上的准确率提升到了约96%。ReLU函数的定义是:
[
\text{ReLU}(x) = \max(0, x)
]
核心概念:广播(Broadcasting)
为了后续从零实现矩阵乘法等操作,我们需要理解一个关键概念:广播。广播是NumPy和PyTorch中处理不同形状数组间运算的强大机制。
广播规则:从维度的尾部开始向前比较。两个维度兼容的条件是:
- 它们相等。
- 其中一个是1。
- 其中一个不存在(可视为1)。
如果维度不兼容且不为1,则无法广播。
示例:
import numpy as np
# 向量与标量相加(标量广播到向量)
a = np.array([10, 6, -4])
result = a + 1 # 结果: [11, 7, -3]
# 矩阵与向量相加(向量广播到矩阵的每一行)
M = np.array([[1,2,3], [4,5,6], [7,8,9]])
c = np.array([10, 20, 30])
result = M + c # c被广播为 [[10,20,30], [10,20,30], [10,20,30]],然后相加
广播允许我们无需显式循环即可高效执行元素级运算,这对于利用现代CPU/GPU的并行计算能力至关重要。
手动实现训练循环
现在,我们的目标是摆脱fit函数,手动实现训练循环。这涉及以下步骤:
- 获取数据:使用数据加载器(DataLoader)迭代获取小批量数据。
- 前向传播:将数据输入网络,得到预测。
- 计算损失:使用损失函数(如负对数似然损失)比较预测和真实标签。
- 反向传播:调用
loss.backward()自动计算所有参数相对于损失的梯度。 - 更新参数:使用优化器(如SGD)根据梯度和学习率更新权重。
梯度下降的核心思想:我们通过计算损失函数关于参数的梯度(导数)来确定使损失下降最快的方向。然后沿该方向移动一小步(步长由学习率控制),反复迭代直至收敛。
更新公式(简化)为:
[
w_{\text{new}} = w_{\text{old}} - \text{learning_rate} \times \frac{\partial \text{Loss}}{\partial w}
]
以下是手动训练循环的关键代码片段:
# 假设 net2 是我们的模型,opt 是优化器,loss_func 是损失函数
for i in range(100):
x, y = next(data_iterator) # 获取小批量
x_var, y_var = Variable(x).cuda(), Variable(y).cuda() # 包装为Variable
preds = net2(x_var) # 前向传播
loss = loss_func(preds, y_var) # 计算损失
opt.zero_grad() # 清零梯度
loss.backward() # 反向传播,计算梯度
opt.step() # 更新权重(优化器步骤)
# 每隔10次迭代打印准确率
if i % 10 == 0:
acc = (preds.argmax(dim=1) == y_var).float().mean()
print(f"Iter {i}: Loss={loss.data[0]:.4f}, Acc={acc.data[0]:.4f}")
反向传播与链式法则
反向传播是训练神经网络的核心算法,它本质上是链式法则的高效应用。对于一个由多个函数复合而成的网络(如 loss(g(f(x)))),其关于输入 x 的导数可以通过链式法则计算:
[
\frac{d}{dx} \text{loss}(g(f(x))) = \frac{d \text{loss}}{dg} \cdot \frac{dg}{df} \cdot \frac{df}{dx}
]
在神经网络中,每一层(线性变换、激活函数)都可以看作一个函数。反向传播从最终损失开始,逐层向后计算每个参数的梯度,并将这些梯度相乘(链式法则)。PyTorch的自动微分系统为我们自动完成了这一复杂过程。
总结
本节课中我们一起学习了以下内容:
- 欣赏了同学们将机器学习知识应用于实践的优秀项目,包括模型可视化、新工具开发、技术整合与分享。
- 深入探讨了如何从零开始构建神经网络,从使用高级库到逐步实现自己的网络模块、前向传播和训练循环。
- 掌握了广播(Broadcasting)这一关键概念,它是高效进行数组运算和实现复杂操作(如自定义矩阵乘法)的基础。
- 理解了随机梯度下降(SGD)的基本训练流程:前向传播、损失计算、反向传播(自动微分)和参数更新。
- 认识了反向传播的实质,即利用链式法则计算复合函数梯度的高效算法。

通过本讲,我们揭开了机器学习库的一些神秘面纱,理解了模型训练背后的基本原理。在下一讲中,我们将继续深入,尝试实现自己的优化器,从而完全从零完成MNIST分类任务。
010:自然语言处理 📚
在本节课中,我们将学习自然语言处理的基础知识,特别是如何使用简单的线性模型(如逻辑回归)和“词袋”模型来处理文本分类任务。我们将从零开始构建一个模型,并理解其背后的数学原理。
概述
本节课我们将探索如何使用机器学习处理文本数据。我们将从构建一个简单的逻辑回归模型开始,逐步引入更复杂的概念,如正则化和特征工程。通过本课,你将学会如何将文本数据转换为模型可以理解的数值形式,并利用这些数据进行分类预测。
1. 文本数据预处理 📝
在自然语言处理中,我们首先需要将文本数据转换为数值形式。一种常见的方法是使用“词袋”模型,它忽略单词的顺序,只关注单词是否出现在文档中。
1.1 词袋模型
词袋模型将每个文档表示为一个向量,其中每个元素对应词汇表中的一个单词,值表示该单词在文档中出现的次数(或是否出现)。以下是创建词袋模型的步骤:
- 构建词汇表:收集所有文档中的唯一单词。
- 生成特征向量:对于每个文档,统计每个单词的出现次数,形成一个向量。
例如,假设我们有四个简单的电影评论:
- “This movie is good”
- “The movie is good”
- “This movie is bad”
- “The movie is bad”
词汇表为:["this", "movie", "is", "good", "the", "bad"]。每个评论可以表示为以下向量:
- “This movie is good”:
[1, 1, 1, 1, 0, 0] - “The movie is bad”:
[0, 1, 1, 0, 1, 1]
1.2 使用 CountVectorizer
在实际应用中,我们可以使用 scikit-learn 的 CountVectorizer 来自动完成这一过程。以下是具体步骤:
from sklearn.feature_extraction.text import CountVectorizer
# 初始化 CountVectorizer
vectorizer = CountVectorizer()
# 拟合训练数据并生成词袋矩阵
train_term_doc = vectorizer.fit_transform(train_texts)
# 使用相同的词汇表处理验证集
val_term_doc = vectorizer.transform(val_texts)
CountVectorizer 还支持 ngram_range 参数,可以生成二元组和三元组特征,从而捕捉更多上下文信息。
2. 朴素贝叶斯分类器 🧠
朴素贝叶斯是一种基于贝叶斯定理的分类方法,它假设特征之间相互独立。尽管这一假设在现实中很少成立,但朴素贝叶斯在某些任务中表现良好。
2.1 朴素贝叶斯的数学原理


朴素贝叶斯通过计算每个类别的条件概率来进行分类。对于文本分类任务,我们可以使用以下公式:
其中:
- \(P(\text{class})\) 是类别的先验概率。
- \(P(\text{word}_i | \text{class})\) 是给定类别下单词出现的条件概率。
为了避免零概率问题,我们通常使用拉普拉斯平滑,即在计数中添加一个小的常数(如1)。
2.2 实现朴素贝叶斯
以下是朴素贝叶斯的简单实现:
import numpy as np
# 计算每个单词在正类和负类中的出现次数
positive_counts = train_term_doc[y_train == 1].sum(axis=0) + 1
negative_counts = train_term_doc[y_train == 0].sum(axis=0) + 1
# 计算条件概率
positive_probs = positive_counts / positive_counts.sum()
negative_probs = negative_counts / negative_counts.sum()
# 计算对数比率
log_ratios = np.log(positive_probs / negative_probs)
# 预测
predictions = (train_term_doc @ log_ratios.T) > 0
3. 逻辑回归模型 📈
逻辑回归是一种更灵活的线性模型,它通过优化损失函数来学习特征权重。与朴素贝叶斯不同,逻辑回归不需要特征独立的假设。
3.1 逻辑回归的数学原理
逻辑回归使用 sigmoid 函数将线性组合映射到概率:
其中:
- \(w\) 是权重向量。
- \(b\) 是偏置项。
- \(\sigma\) 是 sigmoid 函数。
3.2 实现逻辑回归
我们可以使用 scikit-learn 的 LogisticRegression 来训练逻辑回归模型:
from sklearn.linear_model import LogisticRegression
# 初始化逻辑回归模型
model = LogisticRegression(C=0.1, dual=True)
# 训练模型
model.fit(train_term_doc, y_train)
# 预测
predictions = model.predict(val_term_doc)
参数 C 控制正则化的强度,较小的 C 表示更强的正则化。dual=True 在特征数远大于样本数时可以提高计算效率。
4. 正则化与特征选择 🔧
正则化是防止过拟合的重要技术。在逻辑回归中,我们通常使用 L1 或 L2 正则化。
4.1 L1 与 L2 正则化
- L1 正则化:倾向于将某些权重压缩为零,从而实现特征选择。
- L2 正则化:倾向于让所有权重均匀减小,避免某些权重过大。
在 scikit-learn 中,可以通过设置 penalty 参数来选择正则化类型:
# L1 正则化
model_l1 = LogisticRegression(penalty='l1', C=0.1)
# L2 正则化
model_l2 = LogisticRegression(penalty='l2', C=0.1)
4.2 特征选择
除了正则化,我们还可以通过限制特征数量来防止过拟合。例如,在 CountVectorizer 中设置 max_features 参数:
vectorizer = CountVectorizer(max_features=800000, ngram_range=(1, 3))
5. 模型评估与优化 📊
在训练模型后,我们需要评估其性能并进行优化。
5.1 评估指标
常用的评估指标包括准确率、精确率、召回率和 F1 分数。对于文本分类任务,准确率通常是一个直观的指标:
from sklearn.metrics import accuracy_score
accuracy = accuracy_score(y_val, predictions)
print(f"准确率: {accuracy}")
5.2 学习率调度
在训练神经网络时,学习率调度可以帮助模型更快收敛。例如,可以在训练后期降低学习率:
# 降低学习率
optimizer.set_learning_rate(0.01)
总结
本节课中,我们一起学习了自然语言处理的基础知识。我们从词袋模型开始,逐步介绍了朴素贝叶斯和逻辑回归模型,并探讨了正则化和特征选择的重要性。通过这些方法,我们可以在文本分类任务中取得不错的效果。

在接下来的课程中,我们将进一步探索如何结合这些技术,构建更强大的深度学习模型,以应对更复杂的自然语言处理任务。
011:嵌入
在本节课中,我们将学习如何优化多层函数,回顾逻辑回归在自然语言处理中的应用,并深入探讨嵌入的概念。我们将从链式法则开始,逐步理解如何计算复杂函数的导数,并探讨如何将先验知识融入模型以提升性能。
回顾:使用SGD优化多层函数
上一节我们讨论了如何优化多层函数。本节中,我们来看看具体的数学原理。
我们有一些数据,然后对这些数据进行一系列操作。例如,首先将数据乘以一个权重矩阵,然后通过一个非线性激活函数(如softmax或sigmoid),最后计算损失函数(如交叉熵损失或均方根误差)。整个过程可以表示为一个标量输出。
公式表示:
设输入为 x,权重为 W,函数可以表示为:
L = loss(activation(linear(x, W)))
为了计算损失 L 关于权重 W 的导数,我们使用链式法则。将整个函数分解为多个函数的复合:
L = h(u),其中 u = g(v),v = f(x, W)
链式法则:
dL/dW = (dh/du) * (dg/dv) * (df/dW)
即使网络有多个线性层,计算原理也相同。我们只是将多个函数复合在一起,并使用链式法则一次性计算所有参数的导数。最终,导数是一个向量,其长度等于参数数量,表示每个权重的微小变化对损失的影响。
为了直观理解这些计算,建议手动使用PyTorch的 .grad 属性和 .backward() 方法,设置小型向量输入和输出,观察梯度的形状和变化。
从朴素贝叶斯到逻辑回归
在自然语言处理中,我们最初使用朴素贝叶斯方法。基本思想是将文档(如“this movie is good”)转换为词袋表示,记录每个词出现的次数。我们使用SKLearn的CountVectorizer生成词汇表和词袋表示,整个集合称为词项-文档矩阵。
我们可以计算正类评论中包含某个词的概率,以及负类评论中包含该词的概率,然后取两者的比值。如果比值大于1,则该词更常出现在正类评论中;如果小于1,则更常出现在负类评论中。
使用贝叶斯规则并取对数后,我们可以将各项对数相加,并与零比较。如果结果大于零,则预测文档为正类;如果小于零,则预测为负类。这就是朴素贝叶斯规则。
然而,朴素贝叶斯假设特征之间相互独立,这在实际中往往不成立。当我们实际计算概率比值并取对数后,发现这实际上是一个标准的权重矩阵乘积加上偏置项。因此,我们意识到可以通过逻辑回归来学习这些系数和偏置,而不是依赖理论计算。逻辑回归的公式如下:
公式表示:
y = σ(W * x + b)
通过从数据中学习参数,我们通常能获得更好的结果,特别是在加入正则化之后。
正则化与特征工程
正则化在防止过拟合方面起着关键作用。我们通常在损失函数中添加一个惩罚项。对于L2正则化,损失函数变为:
公式表示:
L = loss(predictions, actuals) + λ * sum(W^2)
L1正则化则使用权重的绝对值。我们主要关心损失函数的导数,因为它用于更新权重。L2正则化的导数简单,且在神经网络文献中常被称为“权重衰减”。
对于分类问题,我们使用交叉熵损失函数。二分类交叉熵的公式如下:

公式表示:
L = -[y * log(ŷ) + (1 - y) * log(1 - ŷ)]

多分类交叉熵是二分类的扩展。交叉熵损失函数具有简单的导数,并且与sigmoid或softmax激活函数结合时,表现良好。

在实践中,我们通常尝试不同的损失函数,选择在特定数据集上表现最好的那个。如果参加Kaggle竞赛,则应使用与竞赛评估指标相同的损失函数。
为了进一步提升模型性能,我们可以通过添加二元组和三元组来创建更多特征。即使使用完整的7000万特征,模型仍然表现良好且速度不会太慢。我们使用二元化词项-文档矩阵,并拟合逻辑回归模型,最终获得了90%的准确率。
融入先验知识:NBSVM模型


朴素贝叶斯模型中,我们为每个特征计算一个比值R。在NBSVM模型中,我们将词项-文档矩阵乘以R,然后使用这个变换后的矩阵作为逻辑回归的输入。这样,模型在正则化时,会倾向于使权重接近R,而不是零。
代码表示:
# 假设X是词项-文档矩阵,R是朴素贝叶斯比值
X_transformed = X * R
# 使用X_transformed拟合逻辑回归模型

这种方法实际上是将朴素贝叶斯的先验知识融入了逻辑回归模型。正则化项现在惩罚的是权重与R的差异,而不是权重本身的大小。通过实验,我们发现添加一个常数(如0.4)到权重上,可以进一步提升模型性能:

代码表示:
# 在权重上添加常数
adjusted_weights = W + 0.4
predictions = adjusted_weights * X_transformed
这种方法在多个数据集上表现良好,并且简单高效。通过融入先验知识,我们可以在保持模型简单性的同时获得更好的性能。

嵌入:高效的类别处理
在神经网络中,处理高基数类别变量时,我们可以使用嵌入层。嵌入层本质上是一个查找表,它将类别索引映射为稠密向量。数学上,这与使用独热编码矩阵乘以权重矩阵是等价的,但计算效率更高。

代码表示:
import torch.nn as nn

# 创建一个嵌入层,num_embeddings为类别数,embedding_dim为输出维度
embedding_layer = nn.Embedding(num_embeddings, embedding_dim)
# 输入是类别索引的列表
output = embedding_layer(indices)


嵌入层允许我们处理大规模类别变量,而无需实际构建庞大的独热编码矩阵。在自然语言处理中,这尤其有用,因为词汇表可能非常庞大。
应用:罗斯曼销售额预测
我们将嵌入的概念应用于罗斯曼销售额预测竞赛。数据集中包含连续变量和类别变量。我们首先进行数据清洗和特征工程,然后构建一个神经网络模型。
以下是数据处理的关键步骤:
- 合并多个表格,使用左连接确保数据完整性。
- 添加日期元数据,如星期几、月份等。
- 将某些连续变量(如竞争对手开店月数)转换为类别变量,以减少基数。
在神经网络中,我们为每个类别变量创建一个嵌入层,为连续变量创建普通的线性层。通过这种方式,模型可以同时处理不同类型的数据,并从中学习有效的表示。
代码表示:
# 假设cat_vars是类别变量列表,cont_vars是连续变量列表
embeddings = [nn.Embedding(cat_size, emb_size) for cat_size in cat_sizes]
linear_continuous = nn.Linear(len(cont_vars), hidden_size)
通过这种结合嵌入和线性层的方法,我们可以在保持模型简单性的同时,处理复杂的现实世界数据。
总结
本节课我们一起学习了以下内容:
- 使用链式法则计算多层函数的导数。
- 从朴素贝叶斯过渡到逻辑回归,并通过学习参数提升模型性能。
- 正则化的重要性及其在防止过拟合中的作用。
- 交叉熵损失函数及其在分类问题中的应用。
- 通过NBSVM模型将先验知识融入逻辑回归,进一步提升性能。
- 嵌入的概念及其在处理高基数类别变量时的高效性。
- 将嵌入应用于罗斯曼销售额预测竞赛,构建能够处理混合类型数据的神经网络。

通过这些技术,我们可以在各种机器学习任务中获得更好的结果,同时保持模型的简洁和高效。
012:时间序列预测与结构化数据分析回顾
在本节课中,我们将完成罗斯曼销售预测案例的学习,回顾整个课程的核心概念,并探讨机器学习技术的伦理与社会影响。
罗斯曼案例:时间序列事件特征工程
上一节我们介绍了如何将“开业月份”等变量转换为分类变量以利用嵌入层的灵活性。本节中,我们来看看如何处理时间序列中的事件,并创建相关特征。
事件持续时间特征
在时间序列数据中,事件(如促销、节假日)的发生时间点本身很重要,但事件发生前后的时间段往往也包含有价值的信息。例如,在杂货销售预测中,节假日前后销量可能升高,而节假日期间可能降低。
因此,对于每个事件(如促销、州假日、学校假日),我们创建两个新特征:
- 自上次事件发生以来的天数
- 距离下次事件发生的天数
以下是实现此功能的函数核心思路:
def get_elapsed(df, field):
df = df.sort_values(['store', 'date']) # 按商店和日期排序
last_date = None
last_store = 0
res = []
# 使用zip高效遍历多个NumPy数组
for s, h, d in zip(df.store.values, df[field].values, df.date.values):
if s != last_store:
last_date = None # 新商店,重置
if h: # 如果当前行是该事件
last_date = d
if last_date is not None:
res.append((d - last_date).days)
else:
res.append(np.nan) # 或一个极大/极小值
last_store = s
df[f'elapsed_{field}'] = res
return df
关键点:
- 使用
df.sort_values确保按时间和分组排序。 - 使用
zip函数同时遍历多个列表(如商店ID、事件标志、日期),这比逐行迭代DataFrame快得多。 - 为每个商店独立计算持续时间。
- 对训练集和测试集都需要应用此处理。在笔记本中,可以通过先设置
df = train_df运行相关单元格,再设置df = test_df重新运行的方式交互式处理。
滚动窗口函数
除了事件特征,时间序列分析中常用到滚动窗口函数(如移动平均)。Pandas的 rolling 方法可以高效实现。
# 计算每个商店每周销量的7天滚动和
weekly_sales = df.groupby(['store'])['sales'].rolling(7, min_periods=1).sum()
关键点:
rolling(window, min_periods)创建指定大小的窗口。- 对于预测任务,通常需要使用“仅包含过去”的窗口(即设置
center=False,默认值)。 - Pandas拥有强大且高度优化的时间序列API,值得深入学习。
数据预处理与模型构建
完成特征工程后,我们需要为神经网络准备数据。
分类变量与连续变量
- 分类变量(如星期几、商店类型):转换为Pandas分类类型,并确保训练集和测试集的编码一致(使用
apply_cats)。它们将通过嵌入层处理。 - 连续变量(如温度、距离):转换为浮点类型。对于神经网络,归一化至关重要,因为它能确保所有特征的梯度处于相近量级,有利于优化。
# 使用proc_df处理数据框,包括归一化
df, y, nas, mapper = proc_df(df, 'sales', do_scale=True)
# mapper对象保存了用于归一化的均值和标准差,需用于测试集
关键点:
- 使用
proc_df的do_scale=True参数对连续变量进行归一化(减去均值,除以标准差)。 - 必须使用训练集计算得到的
mapper对测试集进行相同的归一化。 - 本竞赛评估指标是均方根百分比误差(RMSPE),对因变量取对数可以将比率差异转换为差值差异,更便于优化。
验证集划分与最终训练
对于时间序列问题,验证集通常应选取最近的时间段,而非随机抽样。
# 按日期划分验证集(最后一段时间)
n_valid = int(len(df) * 0.1) # 例如最后10%
n_trn = len(df) - n_valid
train_df, valid_df = df.iloc[:n_trn].copy(), df.iloc[n_trn:].copy()
当确定最终模型架构和超参数后,为了获得最佳性能,可以使用全部数据(包括之前的验证集)重新训练。此时,可以设置一个极小的验证集(如仅一行)以使代码正常运行,但需确保训练配置与之前验证过的最佳配置完全相同。
嵌入维度设定
为每个分类变量设定嵌入维度。一个经验法则是:嵌入维度取 min(50, 类别数//2)。例如,星期几有7类,则嵌入维度为 min(50, 7//2) = 3。
关于过拟合:在现代机器学习中,我们主要通过正则化(如Dropout、权重衰减)而非减少参数数量来控制过拟合。因此,初始设定时可以相对慷慨地设置参数数量。
模型训练与结果
构建并训练模型后,在罗斯曼竞赛中获得了约0.1的验证集误差。提交后,私人排行榜排名第5,但公开排行榜排名第340。这突显了精心构建并信任自己的验证集远比依赖Kaggle公开排行榜更重要,因为后者可能与最终私人排行榜的数据分布不同。
课程核心概念回顾
现在,让我们回顾本课程学到的两种主要建模方法。
决策树与基于梯度下降的模型


- 决策树方法(随机森林、GBM):
- 可以直接使用序数编码的分类变量(即简单的整数标签)。
- 不关心连续变量的尺度,因为它只关注排序。
- 通过寻找最佳分割点来构建模型。

- 基于梯度下降的方法(线性模型、神经网络):
- 分类变量通常需要独热编码或更高效的嵌入层。
- 必须对连续变量进行归一化,以确保优化稳定。
- 通过迭代更新参数来最小化损失函数。
关键区别:
- 对于线性模型,独热编码可能导致共线性问题(在解析解中),但使用SGD和少量正则化可以缓解。
- 嵌入层是独热编码与权重矩阵相乘的计算捷径,数学上等价,但效率更高。对于神经网络,嵌入层可以学习到更丰富的类别表示。

模型解释
- 随机森林:我们学习了特征重要性、偏依赖图、树解释器等工具。
- 神经网络:同样的思想可以迁移。例如,可以通过计算输出对输入的梯度来近似特征重要性,或通过修改输入值观察输出变化来创建类似偏依赖图的图表。这是一个有待探索的有趣领域。
关于统计显著性:在大数据时代,我们更应关注实际显著性(影响是否足够大)而非统计显著性。对于小数据集,可以使用自助法来估计置信区间。
机器学习伦理与社会影响 🌍
最后,我们探讨本课程最重要的部分:如何负责任地使用机器学习技术。
影响力与预测准确性
机器学习的目标不应仅仅是优化预测准确性,而应是积极影响行为。例如,推荐系统不应只推荐你很可能购买的商品,而应推荐能促使你产生新购买行为的商品。
现实中的伦理挑战
工程师可能无意中造成严重后果:
- 历史教训:IBM的打卡系统曾被纳粹用于管理集中营,协助了种族灭绝。
- 当代案例:Facebook的算法为了最大化用户参与度,可能加剧了缅甸罗兴亚人危机中的仇恨言论传播。其广告系统也曾被揭露允许基于种族的住房歧视。
偏见与反馈循环
- 数据偏见导致算法偏见:如果训练数据存在社会偏见(如种族、性别偏见),算法会学习并放大这些偏见。
- 图像软件:面部美化应用让用户看起来更“白”;照片分类器将黑人错误标记为“大猩猩”。
- 自然语言处理:词向量中固化着“父亲:医生 :: 母亲:护士”等性别刻板印象;谷歌翻译将土耳其语中性代词“o”根据上下文错误地译为“he”或“she”。
- 反馈循环:算法决策会影响现实,产生的新数据又反过来强化算法的原有偏见,形成恶性循环。
- 预测性警务:如果历史逮捕数据存在种族偏见,算法会建议更多巡逻黑人社区,导致更多逮捕,从而“证明”该社区犯罪率更高。
- 社交媒体:推荐系统会向用户推送越来越极端的内容,加剧观点极化。
量刑软件中的偏见
一些法院使用的再犯风险预测软件,被证明对非洲裔美国人存在系统性偏见:将他们错误标记为高风险的比例几乎是白人的两倍。这些软件甚至使用“你父亲是否曾被逮捕”等问题,这引发了关于“因出身而受罚”是否合理的深刻伦理质疑。
如何应对:关键问题清单

在开发数据产品时,请思考以下问题:
- 数据中可能存在什么偏见?
- 开发团队是否多元? 多元化的团队能发现更多潜在问题。
- 代码能否被审计?是否开源?
- 不同群体间的错误率是否有差异?
- 是否存在更简单、可解释性更强的规则?
- 如果出现问题,是否有妥善的处理机制?

行动与责任
- 认识到你的价值:掌握编程和机器学习技能的人才极度稀缺。这赋予了你选择工作环境和坚持伦理原则的资本。
- 不要盲目服从:历史上,只是“执行命令”的程序员也曾承担法律责任(如大众汽车排放作弊案)。
- 提前思考,敢于发声:在技术讨论中,要能识别伦理问题,并思考如何应对。如果环境有毒,离开是一个可行的选择。
- 将伦理视为工程问题:如何构建更公平、无偏见的模型,本身就是一个有价值的技术挑战和商业机会。

总结

本节课中,我们一起完成了罗斯曼时间序列预测案例,学习了事件特征工程和滚动窗口函数。随后,我们回顾了决策树与基于梯度下降的模型之间的核心区别。最后,我们深入探讨了机器学习伦理这一至关重要的话题,理解了数据偏见、算法反馈循环可能带来的社会危害,并思考了作为从业者应有的责任和行动方案。希望这些知识不仅能帮助你构建更有效的模型,也能引导你成为负责任的技术实践者。


浙公网安备 33010602011771号