1. 这个预处理到底在做什么
如果你刚把深度学习的环境配好、正向传播反向传播背得滚瓜烂熟,然后第一次打开实际数据集准备练手,多半会遇到一个尴尬情况:模型还没跑起来,先报一堆数据格式的错。我见过不少同学在这个环节卡住,甚至有人误以为是自己代码写错了,反复调学习率调了半天,最后发现是输入的数据里有空值、有文本列,张量形状还对不上。这一课聊的数据预处理,就是解决这个“模型之前”的问题。
数据预处理解决的是“数据能不能直接喂给模型”的问题。原始数据很少是干净的,大概率包含缺失值、异常值、非数值类型的类别特征,以及量纲差距很大的数值列。如果这些内容不管不顾直接扔进模型,轻则训练崩掉,重则模型训出来了但结果完全没意义。比如一个特征量纲是0到1,另一个特征动辄几千,梯度更新就会被大数值特征主导,模型学的其实是“那个数字的大小”,而不是数据的实际规律。
这一课的内容适合所有刚开始接触深度学习的人,尤其是已经会用框架搭简单网络、但还没处理过真实数据集的朋友。课程里的内容不复杂,核心就是pandas读取数据、处理缺失值、编码类别特征、标准化数值特征,最后把处理完的数据转成深度学习框架能用的张量格式。整套流程走一遍,你就能理解,深度学习项目的起点不是网络结构,而是拿到一份能用的数据。
2. 整体设计思路:为什么用pandas而不是纯NumPy
2.1 pandas在深度学习流程中的定位
很多人一开始会有个疑问:为什么数据预处理不直接用NumPy?毕竟深度学习框架的张量操作都是基于NumPy风格的,转起来也更顺。这个想法单独看没错,但真实世界的数据集几乎不会以纯数值矩阵的形式出现。一个典型的原始数据集往往包含ID列、类别列(比如城市、颜色、产品类型)、日期列,甚至还有缺失值,这些内容用NumPy处理非常别扭。
pandas真正擅长的是表格数据的操作。它提供了DataFrame这种二维表格结构,可以给每一列命名,可以按列做筛选、映射、填充,还可以方便地处理异构数据——也就是一列是数字、另一列是文本的情况。而NumPy的ndarray要求所有元素类型一致,一旦数据里混入了NaN(空值)或者字符串,操作起来就会很麻烦。
课程里选择pandas作为预处理工具,不是因为深度学习框架必须要pandas,而是因为pandas是最适合“人类理解和清洗数据”的工具。预处理流程的核心目的不是做高性能计算,而是把原始数据整理成框架能够直接消费的格式。这个“整理”过程,用pandas做起来最顺手,效率也最高。我在实际项目中处理几万行、几十列的数据,pandas的开销完全在可接受范围内,真正大规模的数据集也不需要一次性载入内存,那是另一个话题了。
2.2 为什么预处理和模型训练要分开
课程里把数据预处理单独拎出来讲,而不是和模型训练混在一起,这个设计是有讲究的。预处理和数据加载是两个阶段。预处理是把原始数据变成干净、规整的结构,比如填充缺失值、把字符串类别变成数字编号、把数值特征缩放到合理范围。这个阶段通常是一次性的,处理完可以保存成新的文件,下次直接加载。
数据加载则是训练循环里的高频操作,每次迭代都要把一批数据送到模型里。如果把预处理逻辑塞进数据加载的代码里,每加载一次就要重复执行一次填充和编码,既浪费时间又容易因为不同批次的处理方式不一致而出问题。课程这个安排的合理之处在于,它让读者从一开始就养成“预处理前置”的习惯。
这里还要说明一个关键的概念:训练和测试数据处理方式必须保持一致。什么意思?假如训练集用均值填充了缺失值,测试集也必须用同一个均值去填充,而不是重新计算测试集的均值。如果测试集单独算均值和标准差,就可能造成数据分布不一致,模型在训练时学到的特征变换规则和测试时对不上,最终预测结果就会偏移。这个细节在后面的实操演示里会具体看到。
3. 核心细节解析:从DataFrame到模型输入
3.1 pandas读取数据的两个关键点
课程用的是pandas的read_csv函数读取CSV格式的数据文件,这是最常见的数据格式。这里有个容易被新手忽略的点:读入数据之后,先看一眼数据长什么样,再动手处理。课程里使用print()打印DataFrame确实是最直接的方式,但更好的做法是掌握df.head()、df.info()、df.describe()这几个方法。
head()默认展示前5行,让你快速了解数据的列结构和大致内容。info()可以看到每一列的非空值数量、数据类型,这是发现缺失值最快的方法。describe()则是对数值列做统计摘要,可以直观看到均值、标准差、最小值、最大值,帮助判断哪些列的量纲差异过大。真实项目的经验是:拿到任何数据集,先跑这三条命令,比直接写处理逻辑要靠谱得多。
课程里的数据规模很小,只有几行,用print就能看全。但换成实际项目,一个数据集动辄几十万行,你不可能把全部数据打印出来看。学会用这些轻量级的查看方法,是数据预处理的基本功。还有一个容易被忽略的点是文件路径问题,比如CSV文件不在当前工作目录下,会提示找不到文件。我建议在项目里用相对路径,并且每次都确认一下当前工作目录是什么,避免这种低级错误消耗时间。
3.2 如何观察数据:先看再动手
课程提供了一个非常典型的小数据集示例,包含两个数值列和一个类别列,其中既有正常的房价数值,也有明显异常的一行(面积和房价都是空值)。这个设计其实是在模拟真实数据里的两种典型问题:数值列缺失和类别列多样性。
在处理这类数据的时候,一定要先搞清楚每一列的含义和取值范围,再决定处理策略。比如课程里的类别列是房屋的巷子类型,取值相对集中,用one-hot编码是合理的。但如果你拿到一个真实项目的数据集,某个类别列有几十种取值,并且有少数类别出现频率极低,就需要考虑是否合并这些稀有类别,否则编码之后会产生大量稀疏列,占用内存而且对模型帮助不大。
这就是我反复强调的:预处理不是一个机械的执行步骤,而是需要结合业务理解来做出的决策。课程里的代码虽然看似简单,背后代表的是“观察数据→发现问题→选择方案→验证结果”的完整思路。你在实际项目中处理自己的数据时,也需要重复这个流程。数据的问题往往是拼凑出来的,你需要结合上下文判断哪些是真实信号,哪些是脏数据。
3.3 缺失值处理的两个核心思路
对于数值列的空值,课程里有两个处理思路:插值法和删除法。插值法就是用某种规则把缺失的位置填上,课程里用的是均值填充。删除法则是直接把包含空值的行丢弃。这两种方法各有适用场景,不能一概而论。
均值填充适用于缺失比例较低、且缺失值对整体分布影响较小的情况。比如一个几十万行的数据集里只有几百行缺失,用均值填充几乎不会改变分布。但如果缺失比例很高,比如某一列有30%都是空的,均值填充就会引入很大的偏差,因为真实数据的分布可能和填充后的分布差距很大,这种情况需要更谨慎地评估,甚至可能需要考虑这一列是否还有使用价值。
删除法则有前提条件:被删除的行不能太多。如果因为删除缺失行导致数据量骤减,模型可能学不到足够的模式。课程里的示例数据只有几行,删除一行影响不大,但真实场景下,一个好的经验是先算一下缺失行占总数据量的百分比,再决定用哪种策略。
顺带一提,对于某些特定类型的特征,缺失值本身可能是有信息量的。比如在用户行为数据里,“某用户没填写年龄”和“某用户年龄为0”含义完全不同。对于这种情况可以单独生成一个布尔特征,标记该值是否缺失,让模型自己学习这个信号。课程里的方法是一个很好的基础模板,但真正动手处理自己的数据时,这些扩展思路会更有用。
3.4 类别特征编码:为什么用one-hot
类别特征直接喂给模型是不行的,因为大多数模型只能处理数值。常见方案有两种:标签编码和one-hot编码。标签编码就是把类别值映射成数字,比如A变成0、B变成1。但这种方式有一个隐含的危险:类别之间没有大小关系,数字的大小纯粹是人为规定的,如果模型把这些数字当成连续数值来理解,就会强行学习到一个不存在的大小顺序。
课程中使用的是pd.get_dummies()做one-hot编码,这是处理类别特征的主流方案。one-hot的原理很简单:假设“巷子类型”有A、B两个取值,就生成两列,分别表示“是否为A”和“是否为B”,取值是0或1。这样既消除了无序类别之间的人为大小关系,又保留了完整的信息。如果类别有K种取值,就会生成K列(课程里有一个细节是用了dummy_na=True,这会额外生成一列表示缺失情况,所以两列变成了三列)。
我在自己的项目里,对于类别数不多的列(比如少于几十种取值),通常直接用one-hot编码。对于类别特别多的列,会考虑先用频次统计把低频类别归为一类,再做one-hot。课程中虽然没展开这些内容,但原理是同一个,理解了基础版本之后,这些都属于自然的延伸。
3.5 标准化:为什么要让数值落在一个合理的区间
标准化(或者说归一化)是深度学习数据预处理里几乎必不可少的一步。课程里的示例数据只有一个数值列,不太能看出标准化的必要性,但这个步骤在实际项目里极其关键。
深度学习模型在训练时依赖梯度下降来更新参数。如果不同特征的数值范围差异过大,比如一个特征是0到1,另一个是10000到50000,那么梯度更新时会受到大数值特征的主导,模型会偏向于重点学习那个数值大的特征,而忽略数值小的特征。这不是模型“偷懒”,而是梯度数值天然会被大规模特征“带偏”。更麻烦的是,有些激活函数(比如sigmoid)在输入值过大或过小时会进入饱和区,梯度趋近于零,训练几乎停滞。
课程里用的是(x - min) / (max - min)的min-max归一化方法,把数值缩放到0到1之间。这个方法的优点是简单直观,缺点是受异常值影响很大:如果数据里有一个极大的离群值,那么大部分正常数据都会被压缩到非常小的区间内。实际项目中更常用的是z-score标准化,即(x - mean) / std,让数据变成均值为0、标准差为1的分布,它对异常值的鲁棒性更好。课程选择min-max归一化是为了入门友好,但你需要知道两种方法各有适用场景,并且要能把它们灵活应用。
4. 实操过程与核心环节实现
4.1 完整代码实现:读取到张量转换
下面这段代码基本复刻了课程的核心流程,我加了一些注释,方便你理解每一步到底在做什么。跑通这段代码之后,你就具备了一条完整的数据预处理流水线。
import pandas as pd import torch # 读取CSV文件,这里使用课程里的示例数据 data = pd.read_csv('house_tiny.csv') print("原始数据:") print(data) # 1. 处理缺失值:数值列用均值填充 # 这里inputs是原始数据里去掉目标列(价格)之后的部分 inputs, outputs = data.iloc[:, 0:2], data.iloc[:, 2] # 对数值列填充均值 inputs = inputs.fillna(inputs.mean(numeric_only=True)) print("填充后的数据:") print(inputs) # 2. 处理类别特征:one-hot编码 # dummy_na=True会额外生成一列表示缺失情况 inputs = pd.get_dummies(inputs, dummy_na=True) print("编码后的数据:") print(inputs) # 3. 转为NumPy数组,再转为PyTorch张量 X = torch.tensor(inputs.to_numpy(dtype=float)) y = torch.tensor(outputs.to_numpy(dtype=float)) print("特征张量X:") print(X) print("目标张量y:") print(y)4.2 逐步拆解:每个关键动作的意图
data.iloc[:, 0:2]的意思是取所有行的前两列(索引0和1),data.iloc[:, 2]是取第三列(索引2)。这里需要理解iloc的切片规则:0:2包含索引0但不包含索引2,所以实际取到的是第0列和第1列。这是pandas里非常常见的操作方式,在实际写代码时,我会额外确认一次列的顺序对不对,避免把特征列和目标列搞混。
fillna(inputs.mean(numeric_only=True))这一行代码背后有一个重要细节:mean()默认计算的是所有数值列的平均值,返回一个Series对象,fillna()拿到这个Series之后,会自动按照列名对齐填充。如果某些列不是数值类型,mean()会直接忽略它们,但如果DataFrame里还有非数值列,pandas在旧版本里可能会悄悄把非数值列跳过,新版就会明确警告,甚至需要指定numeric_only=True。课程里的代码贴出了这个参数,说明作者踩过这个坑。我在处理真实项目时也遇到过这个问题,pandas的版本变更可能会影响行为,代码里明确写出参数会减少很多不必要的麻烦。
pd.get_dummies(inputs, dummy_na=True)是one-hot编码的核心。默认情况下get_dummies只对数据类型是object或category的列做编码,数值列会原样保留。如果不加dummy_na=True,缺失值不会被单独编码成一列;加上之后,系统会额外生成一列,标记原值是否为NaN。这样的好处是模型可以学习“缺失与否”本身带来的规律。从这个细节你可以看到,课程在数据预处理上是考虑了实际场景的,不是随便写了个最小示例。
把pandas的DataFrame转成PyTorch张量,中间需要一个中转:先调用to_numpy(dtype=float)转成NumPy的浮点数组,再调用torch.tensor()转成张量。这里要注意dtype=float,如果不指定,pandas可能会保留整数类型,但DataFrame里存在缺失值的时候,pandas会直接给你一个浮点数类型,因为NaN只能在浮点数里表示。
4.3 一个完整的模拟数据实操记录
为了让你更好理解这套流程,我用一个稍真实一些的例子来走一遍。假设手头有一份房屋数据,包含面积、房龄、所在区域、总价四列,其中面积和总价各有少量缺失值,区域是类别列(市区/郊区/远郊),代码如下:
import pandas as pd import torch data = pd.DataFrame({ 'area': [50, 80, None, 120, 95], 'age': [1, 5, 15, None, 3], 'region': ['市区', '郊区', '远郊', '市区', '郊区'], 'price': [300, 480, None, 720, 560] }) print(data) # 划分特征和目标 X_data = data.drop(columns=['price']) y_data = data['price'] # 数值列均值填充 X_data = X_data.fillna(X_data.mean(numeric_only=True)) # 类别列one-hot编码 X_data = pd.get_dummies(X_data, dummy_na=True) X_tensor = torch.tensor(X_data.to_numpy(dtype=float)) y_tensor = torch.tensor(y_data.to_numpy(dtype=float)) print(X_tensor) print(y_tensor)在这个例子里可以看到几个有趣的细节。面积列有一个缺失值,填充之后用的是其余四个非缺失面积的平均值。房龄列也有一个缺失值,同样处理。对象区域列做了one-hot之后,会生成三列(市区/郊区/远郊),再加上dummy_na又生成了一列,但这个例子里区域列本身没有空值,所以dummy_na那一列全为0,这种情况下它其实没有贡献多少信息,但保留了也无妨。
价格列没有做填充,因为它是我们要预测的目标。测试阶段如果目标有明显异常,要谨慎处理,不能直接把它当作普通特征清洗掉。真实项目中,如果预测目标的缺失比例过高,要考虑是不是数据采集本身有问题;如果只是个别缺失,直接删除这些样本可能更安全,因为预测目标完全是靠标签学出来的,不能拿均值去糊弄它。
4.4 训练集和测试集:统一处理贯穿全局
课程里没有显式讨论训练集和测试集的拆分,因为核心是预处理流程本身。但实际做深度学习项目时,必须注意一个问题:预处理规则要从训练集上计算,然后应用到测试集上,这样才能防止数据泄露。
数据泄露举个具体的例子:如果先对整个数据集做均值填充、计算归一化参数,再划分训练集和测试集,那么测试集的信息就已经“泄露”进了训练流程。模型在训练时看到的均值、最小最大值其实是包含测试样本信息的,这相当于提前偷看了答案,会导致评估结果虚高。在真实项目里,模型上线后表现远不如验证集,就是这个原因造成的。
正确的做法是:先划分训练集和测试集,然后只在训练集上计算填充值(均值)、编码规则(类别列表)、归一化参数(min和max或mean和std),再把这些规则原封不动地用到测试集上。具体到代码层面,比如均值填充需要先保存训练集的均值,再分别对训练集和测试集做填充;one-hot编码要保证训练集和测试集有完全相同的列;归一化的min、max也要用训练集的值来计算。课程里的代码是面向教学场景的简化版本,逻辑上是内核正确的,但你在自己的项目里一定要加上“先切分再统一处理”的思路。
5. 常见问题与排查技巧实录
5.1 数据读取阶段的高频报错
我经常会收到初学者的问题,其中最多的就是文件读取失败。FileNotFoundError大概率是工作目录不对,或者文件路径写错了。排查思路很简单:先打印当前工作目录(os.getcwd())确认文件应该放在哪里,或者直接使用绝对路径。还有一种情况是用pandas读取CSV时遇到编码问题,如果文件是从Excel导出的,可能默认编码不是UTF-8,需要在read_csv()里指定encoding='gbk'或encoding='utf-8',取决于文件实际格式。遇到ParserError通常说明CSV文件里某个字段包含了未转义的引号或分隔符,可以尝试指定quoting参数或换一种分隔符方式。
5.2 pandas编码过程中的低版本兼容问题
get_dummies这个函数本身很稳定,但如果你遇到“所有列都变成了一堆0和1,数值列全没了”的情况,很可能是DataFrame里的数值列被意外地转成了object类型,也就是字符串类型。排查方法是打印df.dtypes看每一列到底是什么类型。此外,pandas不同版本对fillna(inputs.mean())的行为有细微差异,旧版本会自动忽略非数值列,新版本则会明确要求你指定numeric_only=True,遇到报错不要慌,照着提示加上参数就行。
5.3 张量转换时的类型陷阱
to_numpy()之后经常出现一个问题:DataFrame里的缺失值填充完了,但某列还是object类型,转出来的NumPy数组也是字符串类型,再交给torch.tensor()就直接报错。这个问题的根源是类型不够干净,而不是张量转换写错了。解决办法是在to_numpy()时显式指定dtype=float,强制所有数据都转成浮点数。如果转换时报“无法将字符串转换为浮点数”,说明你的数据里还有漏网的非数值内容,需要返回上一步检查。
5.4 一个容易忽略的列:用户ID类特征
真实数据里经常会遇到用户ID、样本编号这类列。它们看起来是数字,但本质上只是标识符,完全没有数值含义。如果直接把ID列当作特征喂给模型,模型会非常“聪明”地记住每个ID对应的答案,导致训练集分数极高,测试集一塌糊涂,这叫过拟合。处理方式是:任何ID类、日期类、文本描述类的列,在预处理阶段就要确定是否保留。日期列可以做特征工程提取年、月、星期几等有用信息,但原始字符串最好不要直接进模型。ID列则通常会直接删除,除非你有充分理由相信ID本身携带了某种先后顺序或分组信息。
5.5 处理列名本身的一个小坑
在真实项目里,列名可能会包含空格、特殊符号,甚至是以数字开头。这些列名在用data.列名这种属性访问方式时很可能会报错,因为pandas只允许合法的Python标识符作为属性访问。规避方法是统一使用data['列名']这种方式,并且在一开始就把列名清洗一遍,比如去掉首尾空格、替换空格为下划线、检查是否有重复列名。这是一个很小但能避免后续大量麻烦的习惯。
6. 从课程到实战:扩展思路与个人体会
课程里的这套流程是一个地基,真正到实际项目里,会遇到更多复杂情况。比如超大文件怎么分段读取、图像和文本数据的预处理方式完全不同、时间序列数据不能随机打乱等等。但只要你把这一课里的核心逻辑吃透了,后面这些场景都可以顺着同一个思路去拆解:先观察数据,再决定处理策略,最后把数据转成模型能吃的格式。
我个人在实际操作中最深的体会是:数据预处理不是一个“做完一遍就结束”的步骤。你很可能在跑完模型之后发现效果不理想,回过头来调整预处理方案,比如换一种缺失值填充策略、把数值型特征做一次分箱、合并低频类别,然后重新训练。这种循环在真实项目里非常常见。所以建议你在一开始就给数据预处理留出充足的时间,不要想着“快速跑通一个基线”,而是尽量把数据质量搞扎实,这样后续调模型时能省下大量精力。
另一个让我印象深刻的是训练集和测试集必须统一处理这件事。我第一次做真实项目的时候,因为偷懒对全量数据直接做了归一化再切分,结果验证集分数虚高,模型上线后效果崩得一塌糊涂。那次之后,我养成了一个习惯:所有预处理相关的参数,只从训练集上计算,测试集一律用训练集的参数来转换。这个教训在课程里可能只是几句话,但实际踩过一次坑之后才真正理解它的分量。
最后推荐一个值得动手做的练习:找一份真实数据集(不用太大,几百行就够了),按照这一课的流程完整走一遍,然后故意制造几种错误场景,比如把数值列改成字符串类型再跑代码,看看报错长什么样。这个过程会让你对数据预处理的细节理解得更扎实。这些报错信息以后会经常见到,见多了自然就不怕了。