最近在调试一个PyTorch训练脚本时,我又遇到了那个熟悉的画面:数据最初是Python的list,中间转成了np.array,最后又被封装成torch.Tensor;shape从(128,)变成(128,3,32,32),最后在模型输出里变成torch.Size([128, 10])。如果你是刚开始接触深度学习,看到这三种“装数据的容器”在代码里来回换来换去,肯定会犯迷糊:它们到底有什么区别?为什么不能从头到尾只用一种?为什么模型里到处都在看shape?
这篇文章想把这些基础概念一次性讲透。目标读者包括:学过Python但刚接触NumPy的人、已经写了几个PyTorch训练脚本却还没弄懂张量形状的人、以及想把代码里那些看似“魔法”的reshape操作真正搞明白的人。我不打算照搬官方文档,而是从实际用到的角度出发,解释list、np.array、torch.Tensor各自的职责边界,再讲清楚shape、reshape、view、flatten这套“形状操作”背后的逻辑。等你读完,再看到一段数据从list变成tensor再经过reshape进入模型,就不会觉得它神秘了。
1. 三种数据容器,其实是同一条数据流水线上的不同角色
1.1 从仓库到货架,再到带计算能力的货架
我习惯用一个不太严谨但很好记的比喻来理解这三者:list是仓库,np.array是仓库管理员整理好的标准货架,torch.Tensor则是把货架搬到了带有计算加速器的车间里,还顺便给每个货品挂上了“怎么算出来的”账本。
先看它们最直接的差异。Python原生list最大的特点是什么?随手就能创建,里面什么都能装,长度也能随时变。Python写起来舒服,很多临时数据处理都用它。但list不是为数值计算设计的,你不可能对list里的每一个元素都做“整体加3”这种操作,而且它也没有多维数组的概念。
np.array就是来解决这个问题的。NumPy会强制把数据放进一个同类型的、连续内存块里,然后你就可以对整块数据做批量运算。比如一个二维数组arr,arr + 3就是每个元素都加3,这种向量化运算速度远快于用for循环挨个处理list。代价是:数组创建后大小固定,而且里面的元素类型通常必须统一。
torch.Tensor则在np.array的基础上又加了两样关键的东西:device(设备)和自动微分。device决定了你的数据是在CPU上还是在GPU上算,这一条就把numpy在很多深度学习场景下淘汰掉了。自动微分则意味着你在构建网络和计算损失时,框架会记录张量之间的计算关系,backward的时候能自动算梯度。
这三者之间的转换代码也很简单:
import numpy as np import torch a = [1, 2, 3] # Python原生list b = np.array(a) # 一维numpy数组,shape是(3,) c = torch.tensor(a) # 一维张量,shape是torch.Size([3]) print(type(a)) # <class 'list'> print(type(b)) # <class 'numpy.ndarray'> print(type(c)) # <class 'torch.Tensor'>1.2 一份可供快速查阅的能力对比表
| 角度 | list | np.ndarray | torch.Tensor |
|---|---|---|---|
| 元素类型 | 可混装任意类型 | 通常是同一种数值类型 | 通常是同一种数值类型,且更严格 |
| 长度可变性 | 可append、pop | 创建后固定 | 创建后固定 |
| 是否有shape | 没有 | 有,返回tuple | 有,返回torch.Size |
| 是否支持批量数值运算 | 不支持 | 支持且高效 | 支持且高效 |
| 是否支持自动求梯度 | 不支持 | 不支持 | 支持requires_grad、backward |
| 是否可直接上GPU | 不可以 | 不可以 | 可以,通过.to(device) |
| 典型使用阶段 | 数据读取、临时收集 | 传统运算、数据预处理 | 模型前向、反向传播 |
为什么一个搞深度学习的人会被这三种容器弄晕?因为一个完整训练项目里,它们真的会被用在不同的阶段。数据刚读进来时,通常是Python list在存储;做清洗、预处理、维度整理时,很多人会先用numpy;一旦要交给模型训练,就必须转成torch.Tensor并送到GPU。这本身就是一个递进过程。
1.3 转换方向很方便,但别忽视“是否共享内存”
代码写多了,你会发现list、np.array、torch.Tensor三者互相转换基本是无痕的:
list_a = [1, 2, 3] arr_b = np.asarray(list_a) tensor_c = torch.as_tensor(arr_b)不过有个坑值得提前说:torch.from_numpy(arr)返回的张量和原numpy数组共享底层内存。也就是说,你改了arr的值,对应的tensor也会变;反过来也一样。这在一些需要追求性能的场景很好用,能省一次数据拷贝,但在你没意识到共享存在的时候,它可能会变成让你摸不着头脑的“灵异修改”。如果你不想要这种共享关系,建议主动用.clone()拷贝一份。关于共享问题,在后面第七节还会再展开。
2. list没有shape,但AI项目里到处都需要它做“临时容器”
2.1 数据处理开始前,全世界的Python代码几乎都在用list
很多人学完numpy和torch之后,会觉得list是个该被淘汰的“低级货”。真不是。实际项目里,越是靠近数据源头,list越常见。比如写一个自定义Dataset,你通常要先把所有样本的文件路径放进list,再把对应的标签放进另一个list;读图后,你又把一张张图片暂存在list里,方便后面统一转成batch。
list之所以适合干这个,是因为它动态、灵活且不需要预先知道总长度。你可以边扫描目录边append,也可以在循环里把多个batch的预测结果先攒起来,最后再统一拼接。这类“边跑边收集”的需求,用np.array或torch.Tensor都很别扭,因为它们创建后大小固定,想动态扩容只能重新建一个更大的再拷贝,效率低,代码也丑。
典型的收集过程长这样:
preds_list = [] labels_list = [] with torch.no_grad(): for images, labels in valid_loader: outputs = model(images) preds_list.append(outputs.argmax(dim=1).cpu()) labels_list.append(labels.cpu()) preds = torch.cat(preds_list) labels = torch.cat(labels_list)这里list容器就承担了batch之间的“暂存区”角色,最终一次性拼接成完整张量,避免每一步都事先分配大块显存。
2.2 为什么list没有shape属性
很多初学者第一反应是“list也有行和列啊,为什么没有shape?”因为list根本没有“规则形状”这种概念。
一维list的len()可以返回元素个数,这个好理解。但二维list,严格说并不是语言层面的“二维结构”,它只是“外层list套了内层list”。你写len(matrix)只能拿到外层有几个元素,而内层子list的长度是否一致,Python不管。
row1 = [1, 2, 3] row2 = [4, 5, 6] matrix = [row1, row2] # 视觉效果是2行3列 print(len(matrix)) # 2 print(matrix[0]) # [1, 2, 3] print(matrix[0][2]) # 3 # matrix.shape会直接报错: # AttributeError: 'list' object has no attribute 'shape'重点在于:当嵌套list每一层长度都规规矩矩时,我们心里可以把它当“二维数组”,但Python并不承诺它一定长这样。你可以随时写row2.append(7),于是matrix就变成了2行3列加2行2列的混合体。这一点对后续转换到np.array极其重要。
2.3 用list时最容易犯的“规则形状”错误
既然list不保证规则形状,当你想把它转成np.array时,意外就来了:
bad_data = [[1, 2, 3], [4, 5]] arr = np.array(bad_data)这段代码在很多NumPy版本里不会直接报错,而是给出一个warn,然后生成一个dtype为object的数组,形状可能变成(2,),而不是你期望的(2, 3)。object数组之所以麻烦,是因为它内部存的还是Python对象引用,数值批量运算和GPU加速全都用不上,放到深度学习里很容易变成后续报错的源头。
所以我的经验是:如果数据是从不同来源收集的图片或文本序列,第一件事就要确认长度是否一致。尤其文本任务里,每个句子长度天然不同,必须先做padding,把所有样本补齐到同一长度,再转成tensor。torch.nn.utils.rnn.pad_sequence就是干这个的:
import torch seqs = [torch.tensor([1, 2, 3]), torch.tensor([4, 5])] padded = torch.nn.utils.rnn.pad_sequence(seqs, batch_first=True) print(padded.shape) # torch.Size([2, 3])短的那条会用0自动补全。这才是把不等长数据送进模型前该做的操作,而不是直接丢给np.array或torch.tensor。
3. np.array的轴思维:从np.arange(1,13).reshape(3,4)说起
3.1 热身案例:把1到12排成三行四列
如果你搜索过numpy相关的问题,一定见过这个经典表达式:np.arange(1,13).reshape(3,4)。它到底做了什么?先看看结果:
import numpy as np arr = np.arange(1, 13).reshape(3, 4) print(arr)输出是:
[[ 1 2 3 4] [ 5 6 7 8] [ 9 10 11 12]]这里的核心秘密是:reshape没有改变底层数据的先后顺序,只是把一段长度为12的连续数据重新“切