3、PyTorch基础:张量(Tensor)的创建、属性、数据类型、设备管理(CPU/GPU)
张量(Tensor)是PyTorch中最核心的数据结构,可以将其理解为多维数组的泛化形式——标量是0维张量,向量是1维张量,矩阵是2维张量,而更高维度的数据(如图像批次、视频序列、Transformer中的注意力矩阵)则对应3维及以上的张量。与NumPy的ndarray相比,PyTorch张量的关键差异在于:它能够自动记录计算图以支持反向传播,并且可以无缝地在CPU与GPU(CUDA)之间迁移,从而充分利用硬件加速能力。本章将系统讲解张量的创建方法、核心属性、数据类型体系以及设备管理策略,帮助读者建立扎实的张量操作基础。
3.1 张量的创建:从基础构造到高级初始化
核心原理
PyTorch提供了多种张量创建API,按使用场景可分为三类:直接构造(从Python数据或NumPy数组转换)、占位初始化(按形状生成全0、全1或单位矩阵)、随机采样(从特定概率分布中生成数据)。直接构造适合小规模测试数据,占位初始化常用于模型权重或掩码的预分配,随机采样则用于初始化神经网络参数或模拟噪声。理解这些API的底层差异(如是否参与梯度计算、是否共享内存)是高效使用PyTorch的前提。
操作流程与代码示例
import torch
import numpy as np
# 1. 直接构造
data = [[1, 2], [3, 4]]
t1 = torch.tensor(data) # 从Python列表创建
t2 = torch.from_numpy(np.array(data)) # 从NumPy创建(共享内存)
t3 = torch.Tensor(2, 3) # 未初始化(使用当前默认dtype)
# 2. 占位初始化
zeros = torch.zeros(2, 3) # 全0
ones = torch.ones(2, 3) # 全1
eye = torch.eye(3) # 3x3单位矩阵
full = torch.full((2, 2), 7) # 填充指定值
# 3. 随机采样
rand_t = torch.rand(3, 3) # 均匀分布 [0, 1)
randn_t = torch.randn(3, 3) # 标准正态分布
randint_t = torch.randint(0, 10, (3, 3)) # 整数均匀分布
arange_t = torch.arange(0, 10, 2) # 等差序列 [0, 2, 4, 6, 8]
linspace_t = torch.linspace(0, 1, steps=5) # 等间隔序列
案例拆解:初始化一个图像分类模型的输入张量
假设我们要训练一个CIFAR-10图像分类模型,输入为32×32像素的RGB图像,批次大小为64。我们需要创建一个形状为(64, 3, 32, 32)的张量,其中64为批次维度,3为通道数(R/G/B),32×32为空间尺寸。实际场景中,数据通常从文件加载并归一化,但为了演示张量创建,我们模拟随机数据:
batch_size, channels, height, width = 64, 3, 32, 32
# 使用randn模拟标准化的图像数据(均值0,方差1)
image_batch = torch.randn(batch_size, channels, height, width)
print(f"输入张量形状: {image_batch.shape}") # torch.Size([64, 3, 32, 32])
print(f"元素总数: {image_batch.numel()}") # 196608
这里选择randn而非rand,是因为标准化后的图像数据更接近正态分布。若使用rand生成[0,1)均匀分布,则需额外进行归一化处理。该案例展示了如何根据模型需求确定张量形状,并选择合适的随机分布。
常见误区与总结
torch.Tensor(2, 3)创建的是未初始化的张量,其值取决于内存中的残留数据,可能包含NaN或极大值。在需要确定性初始化的场景(如模型权重)中,务必使用torch.zeros、torch.ones或torch.randn等显式初始化方法。另一个常见误区是混淆torch.tensor()与torch.Tensor():前者是函数,会根据输入数据推断dtype;后者是类构造函数,默认使用torch.float32且不复制数据(若传入已有张量)。此外,torch.from_numpy()创建的张量与NumPy数组共享底层内存,修改一方会影响另一方,这在需要避免数据复制时非常有用,但也容易引发意外副作用。
| 创建方法 | 适用场景 | 注意事项 |
|---|---|---|
| torch.tensor(data) | 小规模测试数据 | 自动推断dtype,复制数据 |
| torch.from_numpy(np_arr) | 与NumPy互操作 | 共享内存,修改互相影响 |
| torch.zeros/ones/eye | 掩码、单位矩阵 | 默认float32,可指定dtype |
| torch.rand/randn | 权重初始化、噪声 | rand为均匀分布,randn为正态分布 |
| torch.arange/linspace | 生成序列数据 | 注意endpoint参数(linspace默认包含终点) |
3.2 张量的核心属性:形状、维度与内存布局
核心原理
张量的属性描述了其数据结构与内存组织方式。shape(或size())返回各维度大小,ndim表示维度数量,numel()计算元素总数。更底层地,stride定义了沿每个维度移动一个元素所需的步长(以元素个数计),storage_offset表示第一个元素在底层存储中的偏移量。这些属性决定了张量视图(view)操作的内存效率——视图共享底层数据,仅改变stride和offset,因此几乎零开销。
属性查看与操作
t = torch.randn(4, 6)
print(f"形状: {t.shape}") # torch.Size([4, 6])
print(f"维度: {t.ndim}") # 2
print(f"元素数: {t.numel()}") # 24
print(f"步长: {t.stride()}") # (6, 1) 表示第0维步长6,第1维步长1
print(f"存储偏移: {t.storage_offset()}") # 0
# 视图操作(共享内存)
t_view = t.view(3, 8) # 改变形状,但共享底层数据
t_reshape = t.reshape(3, 8) # 可能复制数据(若无法共享内存)
t_transposed = t.t() # 转置,stride变为(1, 6)
案例拆解:理解连续内存与非连续张量
在深度学习模型中,经常需要对张量进行转置或切片操作。例如,在Transformer中,注意力矩阵的形状为(batch, heads, seq_len, seq_len),转置操作transpose(1, 2)会生成非连续张量。此时若直接调用view()会报错,必须先调用contiguous():
attn = torch.randn(2, 4, 8, 8) # (batch=2, heads=4, seq=8, seq=8)
attn_t = attn.transpose(1, 2) # 变为 (2, 8, 4, 8),非连续
print(f"转置后stride: {attn_t.stride()}") # (256, 8, 64, 1)
# 以下操作会报错:RuntimeError: view size is not compatible
# attn_t.view(2, 8, 32)
# 正确做法:先contiguous()再view
attn_contig = attn_t.contiguous().view(2, 8, 32)
print(f"连续化后形状: {attn_contig.shape}")
该案例揭示了view()与reshape()的本质区别:view()要求张量在内存中连续,而reshape()在非连续时会自动复制数据。理解stride有助于预测哪些操作会触发内存复制,从而优化性能。
常见误区与总结
reshape()而非view(),因为前者更安全(自动处理非连续情况)。但若确定张量连续且追求极致性能,view()零拷贝的特性更优。另一个易混淆点是shape与size():两者等价,但shape是属性,size()是方法。此外,numel()返回的是Python整数,而shape返回的是torch.Size对象(本质上是tuple的子类),支持索引访问。
3.3 数据类型(dtype):精度与性能的权衡
核心原理
PyTorch支持多种数据类型,从8位整数到64位浮点数,以及专为深度学习设计的半精度float16和bfloat16。默认的torch.float32(单精度)在精度和性能间取得平衡,是大多数模型的默认选择。float64(双精度)用于需要高数值稳定性的场景(如某些科学计算),但内存占用翻倍且GPU计算速度显著下降。float16和bfloat16则用于混合精度训练,可大幅减少显存占用并加速计算,但需注意数值溢出风险。
数据类型转换与检查
t = torch.tensor([1.5, 2.5])
print(f"默认dtype: {t.dtype}") # torch.float32
# 显式指定dtype
t_f64 = t.double() # 转为float64
t_f16 = t.half() # 转为float16
t_i64 = t.long() # 转为int64
t_bool = t.bool() # 转为bool(非零为True)
# 创建时指定
t_custom = torch.zeros(3, dtype=torch.float64)
print(f"自定义dtype: {t_custom.dtype}")
# 查看默认dtype并修改
print(f"全局默认dtype: {torch.get_default_dtype()}") # torch.float32
torch.set_default_dtype(torch.float64)
print(f"修改后默认dtype: {torch.get_default_dtype()}")
案例拆解:混合精度训练中的dtype管理
在训练大型语言模型时,显存往往成为瓶颈。以(1.5B参数)为例,若使用float32存储,仅权重就需要6GB显存(1.5B×4字节)。改用float16后,显存占用减半至3GB。但直接使用float16训练会遇到梯度下溢问题——当梯度值小于2^-24时会被舍入为0。解决方案是采用混合精度训练:权重和激活值使用float16,优化器状态和主权重保留float32,并通过损失缩放(loss scaling)避免梯度下溢。
# 模拟混合精度训练的关键步骤
model = torch.nn.Linear(1024, 1024).half() # 模型转为半精度
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
for step in range(10):
inputs = torch.randn(64, 1024).half() # 输入也转为半精度
outputs = model(inputs)
loss = outputs.sum() # 简化损失计算
optimizer.zero_grad()
loss.backward()
# 梯度裁剪前需转为float32,避免溢出
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
实际工程中,PyTorch提供了torch.cuda.amp模块自动处理精度转换和损失缩放,但理解底层dtype机制有助于调试精度相关的问题(如损失不下降、梯度为NaN)。
常见误区与总结
float64,这会导致GPU显存占用翻倍且计算速度下降数倍。除非进行数值稳定性分析,否则保持默认的float32即可。另外,float16的表示范围约为±65504,当中间结果超过此范围时会变为inf,需配合损失缩放使用。| dtype | 位数 | 范围/精度 | 典型应用 |
|---|---|---|---|
| float32 | 32 | 约1e-38 ~ 3e38,7位小数 | 默认训练/推理 |
| float64 | 64 | 约1e-308 ~ 1e308,15位小数 | 科学计算、数值验证 |
| float16 | 16 | 约6e-5 ~ 65504,3位小数 | 混合精度训练、移动端推理 |
| bfloat16 | 16 | 与float32相同指数范围,8位尾数 | 大规模分布式训练 |
| int64 | 64 | -9e18 ~ 9e18 | 索引、标签 |
| uint8 | 8 | 0 ~ 255 | 图像像素存储 |
3.4 设备管理:CPU与GPU的无缝切换
核心原理
PyTorch通过torch.device抽象统一管理计算设备。CPU设备标识为'cpu',GPU设备标识为'cuda'或'cuda:0'(指定GPU编号)。张量通过.to()方法在设备间迁移,该操作会复制数据(若目标设备不同)。GPU计算的优势在于大规模并行,但数据从CPU到GPU的传输存在PCIe带宽瓶颈(约16GB/s),因此应尽量减少设备间数据传输次数。
设备管理操作
# 检查GPU可用性
print(f"CUDA可用: {torch.cuda.is_available()}")
print(f"GPU数量: {torch.cuda.device_count()}")
print(f"当前GPU: {torch.cuda.current_device()}")
# 定义设备对象
cpu_dev = torch.device('cpu')
gpu_dev = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')
# 创建张量并迁移
t_cpu = torch.randn(3, 3)
t_gpu = t_cpu.to(gpu_dev) # CPU → GPU
t_back = t_gpu.cpu() # GPU → CPU(或使用.to('cpu'))
# 直接在GPU上创建
t_direct = torch.randn(3, 3, device=gpu_dev)
# 模型迁移
model = torch.nn.Linear(10, 10)
model.to(gpu_dev) # 模型参数迁移到GPU
案例拆解:训练循环中的设备管理最佳实践
在典型的训练脚本中,设备管理需要贯穿数据加载、模型前向传播和损失计算的全过程。以下是一个完整的训练循环片段:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = MyModel().to(device)
optimizer = torch.optim.Adam(model.parameters())
for epoch in range(epochs):
for batch_idx, (data, target) in enumerate(train_loader):
# 关键:将数据迁移到与模型相同的设备
data, target = data.to(device), target.to(device)
optimizer.zero_grad()
output = model(data)
loss = criterion(output, target)
loss.backward()
optimizer.step()
# 定期将损失值移回CPU用于日志记录
if batch_idx % 100 == 0:
loss_cpu = loss.item() # .item()自动转为Python float,无需显式迁移
该案例展示了三个关键实践:①数据与模型必须位于同一设备,否则会报错;②loss.item()会自动将GPU张量转为Python标量,无需手动调用.cpu();③避免在循环内频繁进行设备切换,应一次性将整个批次数据迁移到目标设备。
常见误区与总结
CUDA out of memory错误。此时应检查:①是否在循环中创建了未释放的中间张量;②是否将整个数据集一次性加载到GPU;③是否使用了过大的batch size。使用torch.cuda.empty_cache()可以释放未使用的缓存显存,但不应在训练循环中频繁调用,否则会降低性能。另一个常见误区是忽略CPU与GPU之间的数据传输开销。例如,在每次迭代中将单个标量从GPU移回CPU(如loss.item())是必要的,但若将整个梯度张量移回CPU则代价高昂。此外,多GPU训练时需注意设备编号——cuda:0和cuda:1是独立的设备,数据不能隐式共享。