1. 为什么第二章节值得反复读
《动手学深度学习》这本书我完整啃过两遍,如果说后面那些CNN、Transformer、优化算法是高楼大厦,那第二章就是整栋楼的地基和承重墙。很多初学者急着去看卷积、看注意力机制,结果反手就被维度不匹配、数据没清洗、loss变成NaN这些问题按在地上摩擦,回头再看才发现,问题全出在第二章没吃透。
这一章用一句话概括就是:教你怎么把真实世界里的脏数据,变成深度学习模型能消化的干净张量,同时把线性代数从数学课本翻译成代码直觉。
具体来说,它解决三个实际问题:第一,数据处理环节怎么做,包括读CSV、处理缺失值、把类别特征变成数值;第二,张量(Tensor)作为深度学习的基本数据结构,它的创建、索引、运算规则到底是什么;第三,从标量到向量再到矩阵,这些线性代数概念在PyTorch里对应的API长什么样,广播机制又是怎么回事。
我建议的定位不是“读一遍就过”,而是把它当成工具手册:后面写模型遇到数据问题翻回来查预处理,遇到维度报错翻回来查矩阵运算,遇到广播出问题翻回来看这一章的图解。这本书第二章的代码量不多,但每段代码背后都是一类高频实战场景的浓缩。
2. 数据预处理:先学会给模型“喂饭”
2.1 为什么数据预处理这么重要
我见过不少新手拿着公开数据集直接塞进模型,结果损失不收敛、准确率上不去,第一反应是调模型结构,调了半天毫无进展。后来把数据拉出来一看,要么有大量缺失值,要么类别特征直接当成数字用了,要么数值范围差的十万八千里。模型吃得不好,再好的网络结构也白搭。
深度学习模型本质上是一个大规模的数值计算系统,它对输入数据有几个硬性要求:必须是数值型张量、形状必须固定、数值范围不能太离谱、缺失值必须处理。原始数据很少天然满足这些条件,所以预处理不是“可选项”,而是每次训练前的必做项。
书中用的工具是pandas,我实际工作中也是这么做的。pandas擅长处理表格型数据,而且接口简单,从读取文件到清洗再到转换,一条流水线下来非常顺。核心流程固定为四步:读数据、清洗(缺失值和异常值)、编码(类别特征转数值)、转换(转成张量格式)。
2.2 pandas读写数据:用代码模拟真实场景
书中第二章开头构造了一个简单的CSV文件,这个例子看着简单,我建议你亲手敲一遍,因为后面的数据预处理技巧全是在这个基础上叠加的。我自己在操作时习惯把文件写入和读取分开,方便查看中间状态。
import os import pandas as pd # 构造一个带缺失值和类别特征的数据表 data = { 'name': ['Alice', 'Bob', 'Charlie', 'David'], 'age': [25, 30, None, 35], 'city': ['Beijing', 'Shanghai', None, 'Guangzhou'], 'score': [88.5, 73.0, 92.0, 67.5] } df = pd.DataFrame(data) # 写入CSV os.makedirs(os.path.join('..', 'data'), exist_ok=True) df.to_csv('../data/student.csv', index=False) # 读回内存 df_read = pd.read_csv('../data/student.csv') print(df_read)一个小提示:to_csv里的index=False很重要,如果漏了,CSV里会多出一列无意义的索引,等会儿读回来处理时容易踩坑。我一开始就犯过这个错误,数据里凭空多了一列“Unnamed: 0”,排查了半天才发现是索引被写进去了。
读取的时候,pandas会自动把空单元格识别成NaN,这是它的默认行为。但也正因为如此,很多人第一次看到NaN时不知道该怎么处理,这就引出下一节的内容。
2.3 缺失值处理:三种策略的取舍
第二章介绍了核心思路:对NaN所在的列做插值或者删除。我实际用下来总结出三个策略,按适用场景排列如下。
策略一:直接删除含缺失值的行或列。用dropna()实现。适用于缺失比例很低(比如少于5%)或者整列基本全是缺失值的情况。缺点是数据量会减少,训练样本少的时候要谨慎。
策略二:用均值或中位数填充。这就是书里用fillna(df['age'].mean())的做法。适用于数值型特征,数据分布比较均匀时用均值,有较多离群值时用中位数更稳。
策略三:用前后值填充或者插值。时间序列数据里常见,pandas的ffill()、bfill()或interpolate()都能应付。深度学习里图像数据通常用0填充或者均值填充,文本数据则会用一个特殊token表示缺失。
书中的写法非常简洁,但要注意一个细节:fillna默认返回新对象,不会就地修改原DataFrame。如果你想直接改,要么赋值给原变量,要么加inplace=True,我个人习惯用赋值方式,因为可读性更好。
# 用均值填充数值列 df_read['age'] = df_read['age'].fillna(df_read['age'].mean()) # 用特殊占位符填充类别列 df_read['city'] = df_read['city'].fillna('Unknown')2.4 类别特征怎么变成数值:独热编码实战
分类特征(比如城市、颜色、性别)是深度学习模型无法直接处理的,因为模型只认数值,而且分类值之间没有天然的“大小关系”。这里最常见的错误是直接给类别标数字,比如“北京=0,上海=1,广州=2”,模型会误以为广州大于北京,这在语义上完全错了。
第二章用的是get_dummies,这个方法会把每个类别拆成一列,用0和1表示是否存在。我在多分类特征上实测下来,独热编码的效果非常稳定。但要注意:如果某个类别特征的可能性特别多(比如用户ID),独热编码会造成维度爆炸。这种情况下我会改用Embedding或者先做哈希分桶,这些都是后话了。
# 独热编码 df_encoded = pd.get_dummies(df_read, columns=['city']) print(df_encoded)输出会变成每行一个样本、每列一个特征的标准表格。这时候数据才勉强能喂给模型。
2.5 从DataFrame到Tensor:数据管线的最后一步
处理完的DataFrame还是一个Python对象,深度学习框架不认。第二步要把pandas数据转成NumPy数组,再转成PyTorch张量。这一步我踩过一个坑:默认转出来的是float64,但PyTorch模型的默认参数是float32,直接喂进去会报数据类型不匹配的错。
import torch import numpy as np # 提取数值部分,转成numpy数组再转成tensor data_np = df_encoded.to_numpy(dtype=np.float32) tensor_data = torch.tensor(data_np) print(tensor_data.dtype) # torch.float32这里有个容易被忽略的问题:如果DataFrame里还有字符串列没处理干净,to_numpy会把整列变成 object 类型,转张量时直接报错。所以预处理流程中,每完成一步就打印一遍dtypes和shape是好习惯,能帮你快速定位问题出在哪一环。
3. 线性代数:把数学翻译成代码直觉
3.1 标量、向量、矩阵和张量:先搞清楚四个概念
第二章的线性代数部分从四个基本概念讲起。我最初觉得这部分太基础了,甚至想跳过,后来发现很多人(包括我自己)对“张量”这个概念的理解是模糊的。这里用大白话再捋一遍:
- 标量:一个数,比如年龄25。在PyTorch里是0维张量。
- 向量:一串数,比如某个样本的所有特征。是1维张量。
- 矩阵:一个二维表格,比如多个样本的多个特征。是2维张量。
- 张量:任意维度数组的通称,0维、1维、2维都是它。N维数组在深度学习框架里统一叫张量。
PyTorch里创建它们的语法很统一,只是torch.tensor传入的列表嵌套层数不同:
import torch # 标量 scalar = torch.tensor(3.14) # 向量 vector = torch.tensor([1.0, 2.0, 3.0]) # 矩阵 matrix = torch.tensor([[1.0, 2.0], [3.0, 4.0]]) # 三维张量 tensor_3d = torch.zeros((2, 3, 4))我自己的经验是:维度概念一定要建立在“形状”上,不要用“二维、三维”这种模糊说法。每个张量都有.shape属性,打印出来是一串数字,理解这串数字的含义比背定义有用得多。
3.2 张量和NumPy的区别:为什么深度学习用张量
如果你已经熟悉NumPy,会觉得PyTorch的张量API跟它很像。确实,很多操作几乎一模一样。但两者有一个关键区别:张量支持自动求导。在训练模型时,我们需要计算损失函数对每个参数的梯度,PyTorch的张量会在计算过程中自动记录计算图,然后通过反向传播求出梯度。这个能力是深度学习的基石。
另一个区别是设备管理。张量可以显式地放到GPU上,用tensor.cuda()或tensor.to('cuda')就可以。NumPy数组只能在CPU上操作。所以理解张量,不只要理解它“存数据”的功能,还要理解它跟计算图、GPU加速之间的关系。
3.3 逐元素运算、消除和广播:三个高频操作
这一节是第二章的重头戏。首先是逐元素运算,就是两个形状相同的张量,对应位置上的元素做加减乘除。代码很直白,但工程意义很大:深度学习中大量操作(比如激活函数、损失计算)本质上是逐元素运算。
然后是消除,也就是sum()、mean()、max()这类操作。书里特别强调了一个方向问题:沿哪个轴消除。我最初总是分不清dim=0是沿行还是沿列。后来总结了一个口诀:dim等于几,就消除第几个维度。比如形状为(3, 4)的矩阵,dim=0会消除第0维,也就是行数3,得到形状(4,)的结果;dim=1消除列数4,得到(3,)。
查阅torch.sum文档确认一下这个理解,完全没有问题。这个规则在处理批量数据时非常重要,比如一批(batch_size, features)的张量,按特征维度归一化就需要dim=0(每个特征在所有样本上统计),按样本维度操作则需要dim=1(每个样本自己统计)。
3.4 广播机制:隐形的维度复制
广播是很多新手写代码时最容易懵的地方。书中用了一个加法示例:形状(3, 1)的矩阵加形状(1, 4)的矩阵,结果自动变成(3, 4)。这个机制跟NumPy一脉相承:当两个张量形状不完全一致时,PyTorch会在满足条件的维度上自动“复制”数据,使形状匹配。
我理解的广播规则可以简化为三步:从尾部维度开始向前比较;每个维度上,如果两个张量大小相同,或者其中一个为1,或者其中一个缺失,就能广播;不能满足就报错。
a = torch.ones((3, 1)) # 3行1列 b = torch.ones((1, 4)) # 1行4列 c = a + b # 结果形状 (3, 4)这个机制的实用价值太大了。常见场景是给一批数据统一加偏置,不需要手动复制偏置向量,直接广播就行。但也是个大坑:因为广播太“智能”,形状明明不匹配的矩阵经常能“蒙混过关”,导致模型计算结果跟你预期完全不一样,却不报错。这种情况比报错还难排查。
3.5 点积和矩阵乘法:维度的舞蹈
书中从点积讲到矩阵乘法。点积是两个等长向量对应元素相乘再求和,几何意义是一个向量在另一个向量方向上的投影长度。矩阵乘法则是深度学习中最核心的运算,全连接层、注意力机制、卷积操作,本质上全是矩阵乘法。
矩阵乘法有一个维度铁律:A的行数任意,A的列数必须等于B的行数,结果的形状是A的行数乘B的列数。写代码时我建议先手算清楚形状再写实现,别依赖debug试错。
PyTorch里的矩阵乘法常见三种写法:torch.mm、torch.bmm和@运算符。其中@最直观,推荐直接用。要注意mm只能处理2维矩阵,处理3维批量数据时需要bmm,或者直接用@。
3.6 范数:衡量向量大小的尺子
范数用来衡量向量的大小。书中重点讲了L2范数和L1范数。L2范数是各元素平方和的平方根,也就是向量的欧几里得长度;L1范数是各元素绝对值之和。
范数在深度学习中用处很大。最典型的是正则化:L2正则化让权重尽量小,防止过拟合;L1正则化让权重变稀疏。另一个场景是梯度裁剪,当梯度范数过大时按比例缩放,防止梯度爆炸。理解了范数的概念,后面看权重衰减、梯度裁剪这些优化技巧时会觉得非常自然。
import torch vec = torch.tensor([3.0, 4.0]) l2_norm = torch.norm(vec) # 5.04. 实操:数据预处理到矩阵运算的完整管线
4.1 场景设计:从零构造一个结构化数据集
这一节我把它拆成一个完整的实操案例。假设我们要处理一个某地区房屋销售数据,包含面积、房龄、卧室数量、是否配备电梯、成交价。这个数据集混合了数值特征、类别特征和缺失值,足够覆盖第二章所有知识点。
import pandas as pd import torch data = { 'area': [85, 120, None, 66, 98, 140], 'age': [12, 5, 20, None, 8, 3], 'bedrooms': [2, 3, 2, 1, 3, 4], 'elevator': ['Y', 'Y', 'N', 'N', 'Y', 'Y'], 'price': [520, 780, 430, 310, 690, 980] } df = pd.DataFrame(data) df.to_csv('../data/house.csv', index=False)现实世界的数据比这个乱得多,但处理逻辑是完全一样的。
4.2 预处理执行:缺失值填充和类别编码
对数值列area和age,我用中位数填充,因为房价数据受离群值影响大,中位数比均值更稳健。同时检查每列缺失情况。
# 查看缺失统计 print(df.isnull().sum()) # 用中位数填充数值列 df['area'] = df['area'].fillna(df['area'].median()) df['age'] = df['age'].fillna(df['age'].median()) # 类别列填充占位符 df['elevator'] = df['elevator'].fillna('Missing') # 独热编码电梯列 df = pd.get_dummies(df, columns=['elevator']) print(df.head())这里有个小细节:独热编码后列名会变成elevator_N和elevator_Y,很多人在后续代码里引用列名时容易写错。我建议编码完立刻打印df.columns看一眼,别凭记忆猜。
4.3 转成张量并用线性代数运算做简单预测
现在把DataFrame转成张量,我只取特征列,price作为标签暂时不放进去。
import numpy as np features = df[['area', 'age', 'bedrooms', 'elevator_N', 'elevator_Y']] labels = df['price'] X = torch.tensor(features.to_numpy(dtype=np.float32)) y = torch.tensor(labels.to_numpy(dtype=np.float32)) print(X.shape) # (6, 5) print(y.shape) # (6,)接下来我随便初始化一组权重和偏置,用矩阵乘法做个线性预测,演示一下“前向传播”的雏形。
w = torch.ones(X.shape[1], dtype=torch.float32) # 权重向量长度等于特征数 b = torch.tensor(10.0) # 批量预测:一次矩阵乘加广播 y_pred = X @ w + b print(y_pred)这一步就能体会到线性代数的工程力量了:6个样本的预测,一行X @ w + b就完成了。这里的广播机制发挥了作用:X @ w得到长度6的向量,加标量b时自动广播到每个元素上。后面学的什么全连接层、多层感知机,本质就是在这个操作外面套了更多层和更多非线性激活函数。
4.4 数据标准化:给所有特征一个公平的起跑线
如果仔细观察原始数据,面积是几十到一百多,卧室数是1到4,差距很大。这种尺度不一致会让模型训练时梯度更新不均匀,收敛速度非常慢。所以预处理最后一步通常是标准化:让每个特征的均值约等于0,标准差约等于1。
# 手动标准化 mean = X.mean(dim=0, keepdim=True) std = X.std(dim=0, keepdim=True) X_normalized = (X - mean) / std print(X_normalized.mean(dim=0)) # 接近0 print(X_normalized.std(dim=0)) # 接近1这里的keepdim=True也很关键。如果没有它,X.mean(dim=0)的形状是(5,),广播时虽然也能对上,但语义不清晰,容易埋坑。保持维度可以让我们清楚地看到均值向量的形状就是特征数,跟原张量形状对齐。
标准化之后再做矩阵运算,数值稳定性会好很多。深度学习里,输入数据的尺度问题几乎每次都影响训练效果,我现在的习惯是:只要有连续数值特征,就默认标准化。
5. 常见问题与排查技巧实录
5.1 维度不匹配报错:先把形状写在纸上
我见过最多的报错是mat1 and mat2 shapes cannot be multiplied。解决思路很简单:在写矩阵乘法前,先打印参与运算的张量的.shape,手动核对维度是否满足规则。
print(A.shape, B.shape) C = A @ B我自己犯过的错误:把(batch, features)的数据跟(features, features)的权重相乘,结果写反成weight @ x,报错后一看形状就明白了。内存里自己记的维度常常是错的,以代码输出的shape为准。
5.2 广播机制导致的“静默错误”
广播的问题是它不报错,却可能在语义上做错。最典型的是:本意是让两个形状相同的矩阵逐元素相加,结果因为一个维度是1,广播后“错位相加”了。排查这种问题,最有效的方法是看结果形状是否符合预期。如果在某个环节后张量形状“意外”变了,那多半是广播在起作用。
我建议在关键中间步骤加上形状断点:
assert y_pred.shape == y.shape, f'Shape mismatch: {y_pred.shape} vs {y.shape}'5.3 缺失值没处理干净导致NaN扩散
如果训练中损失出现NaN,第一个怀疑对象永远是输入数据。用一个torch.isnan(x).any()就能找出所有NaN位置。
if torch.isnan(X_normalized).any(): print('Found NaN in input!')预防措施是:在数据管线的每个阶段结束后都检查一次NaN。宁可多检查几次,也别等模型训到一半才开始怀疑人生。
5.4 数据类型不匹配
PyTorch对数据类型比较严格,float32和float64之间常因为混用报错。我的习惯是:所有输入张量统一用torch.float32(模型默认精度),所有整数标签用torch.long(CrossEntropyLoss要求)。如果报类型错误,直接看报错信息里写的expected和actual,一目了然。
5.5 axis/dim方向永远弄反怎么办
这是一个经典难点。我的实用方案是:不管有多少维度,先构造一个形状很小的张量(比如(2, 3)),然后在每个可能的dim值上做一次消除操作,打印结果形状。花一分钟跑个测试,比在那凭空想半天强得多。
test = torch.arange(6).reshape(2, 3) print(test.sum(dim=0).shape) # (3,) print(test.sum(dim=1).shape) # (2,)配合前面说的“dim=几就消除第几维”的规律,多试几次基本就记住了。
6. 一些过来人的学习建议
学这一章的时候最容易犯的毛病是“看懂了就划走”。代码确实短,API也确实简单,但真正内化需要的是动手做变形。我的做法是:每学一个操作,就手动构造一个非规则的张量,尝试不同的dim值、不同的形状组合,看结果跟预期是否一致。这个过程比“读十遍书”管用得多。
数据预处理和线性代数之间有一条暗线:预处理产出的干净张量,正是线性代数运算的输入;而线性代数运算的效率和正确性,又反过来依赖预处理的规范性。第二章把这两件事放在一起讲,其实是刻意为之。后面遇到的每个模型,都逃不开这两套基本功。
如果你也是刚入门,建议按这样的顺序复习:先只看数据预处理部分,把自己手头的一个数据集完整处理成张量;再看线性代数部分,把处理好的张量做一遍。两件事连起来做一遍,整个人就会有一种“通了”的感觉。