ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从Tensor到Transforms:一文理清张量、归一化与矩阵变换

从Tensor到Transforms:一文理清张量、归一化与矩阵变换 做机器学习或者数据处理的人十有八九会在某个晚上被 transform 这个词搞懵。CSS 里有 transformPyTorch 里有 torchvision.transforms数学里还有各种矩阵变换你说的是哪个如果标题里再混进 tensor 和 normalization更是让人一头雾水。这篇文章我想把这条线彻底捋顺。我会以 PyTorch 里最常用的张量操作和数据预处理为骨架讲清楚 tensor 到底是什么、归一化为什么非做不可、transforms 是怎么把数据变来变去的以及如何按概率从 tensor 里 sample 一个值。最后再回头用 CSS 的 transform: matrix() 把变换的数学本质点破——你会发现这些看似八竿子打不着的东西底层全是矩阵运算。文章适合刚入门的读者也适合已经写了不少代码但没系统梳理过这些概念的人。1. Tensor 到底是什么先把这个基础概念揉碎了1.1 从多维数组理解张量很多人第一次看到 tensor 这个词觉得它是某种高大上的数学对象其实你可以直接把它理解为多维数组。0 维是标量一个数1 维是向量一排数2 维是矩阵一张表格3 维以上就是一堆表格摞在一起。PyTorch 里所有数据、模型参数、梯度全部是 tensor它是整个框架的通用语言。打个比方如果一张 Excel 表格是矩阵那一整个工作簿的多张表格就是 3 维张量一个文件夹里存了上百个工作簿就是 4 维张量。图像数据通常是 4 维的形状是 (batch, channel, height, width)也就是一批图片每张图有 RGB 三个通道每个通道是一张二维像素矩阵。你不需要把它想得多玄它就是一个有形状、有数据类型、能自动求导的数组容器。1.2 在 PyTorch 里创建和查看张量创建张量的方式很直接我也建议你动手敲一遍import torch a torch.tensor([1, 2, 3]) # 1 维张量 b torch.zeros(3, 4) # 3x4 全 0 矩阵 c torch.randn(2, 3, 4) # 2x3x4 标准正态分布随机张量 d torch.arange(12).reshape(3, 4) # 0~11 排成 3x4拿到张量第一件事永远是看三样东西shape形状、dtype数据类型、device在 CPU 还是 GPU。我自己排查 bug 时九成问题都出在这里——形状对不上、dtype 是 long 还是 float 没注意、模型权重在 GPU 而输入数据在 CPU报错报得你怀疑人生。print(a.shape, a.dtype, a.device) # torch.Size([3]) torch.int64 cpu如果你希望张量参与自动求导创建时要指定requires_gradTrue或者用torch.nn.Parameter包装。这是神经网络训练的基础但很多新手第一步就漏了导致后面.backward()直接报错。1.3 张量的存储与形状变换view 和 reshape 的差异处理张量时最常用的两个形状变换是view和reshape。它们看起来都能把数据改成目标形状但有个关键区别view要求底层内存连续它只是换了个角度去解释同一块内存不复制数据reshape更灵活底层不连续时它会自动拷贝一份再调整结果等价但多了一次内存开销。x torch.arange(12) print(x.view(3, 4)) # 内存连续可以 y x.t() # 转置后内存不连续 # y.view(6, 2) # 这句会报错 print(y.reshape(6, 2)) # reshape 自动处理能行什么时候用哪个简单记确定内存连续就用view不确定或者图省事就用reshape。但要注意如果你用view之后对数据做了修改原张量也会变因为它们是同一块内存用reshape同样如此除非它发生了拷贝。这个共享内存的特性在工程里是双刃剑我用它做过 zero-copy 的预处理也因为它踩过数据被悄悄改掉的坑。建议在关键逻辑里显式.contiguous()再view语义更清晰。2. Normalization为什么训练前一定要做这一步2.1 尺度不同如何毁掉梯度下降归一化normalization是数据预处理里优先级最高的一步。很多人见过这个操作但不知道为什么必须做其实一句话就能解释优化算法的收敛速度极度依赖各特征的尺度一致。想象你有一个二维特征第一个特征范围是 0 到 1第二个特征范围是 0 到 100000。损失函数的等高线会被拉成极狭长的椭圆梯度下降在这个椭圆里走之字形每一步都往峡谷壁上撞迭代慢得让人抓狂。把两个特征都缩放到相近的区间后等高线接近圆形梯度方向直指中心收敛速度快一个数量级都不夸张。这个问题在深度学习里更严重因为层与层之间会互相放大尺度差异。我见过一个真实的模型把某个特征从万元单位改成元单位没做归一化直接训loss 横跳了很久不降归一化之后不到 20 个 epoch 就收敛到可用精度。所以不是数据科学家的仪式感而是数学上必然要做的优化前处理。2.2 两种常见归一化Min-Max 与 Z-score最常见的两种归一化公式Min-Max 归一化x (x - min) / (max - min)结果落在 [0, 1]。适合对范围有明确要求、数据分布比较均匀的场景比如图像像素从 [0, 255] 压到 [0, 1]。Z-score 标准化x (x - mean) / std结果均值 0、方差 1。适合数据分布近似正态、后续要算距离或做正则化的场景。在 PyTorch 里手动实现都很简单data torch.tensor([[1.0, 200.0], [2.0, 400.0], [3.0, 600.0]]) mean data.mean(dim0) std data.std(dim0) normalized (data - mean) / std一个必须注意的细节mean和std必须从训练集统计然后直接用到验证集和测试集上不能混在一起算更不能把测试集的统计量泄露进训练过程。这是机器学习入门最容易犯的错也是竞赛里常见的作弊行为。对图像任务来说ImageNet 的均值标准差mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]已经成了事实标准因为大量预训练模型是用它训练的你推理时也得用同一套参数才能对齐。2.3 层归一化BatchNorm/LayerNorm与数据归一化不是一个东西这里要澄清一个高频混淆点。前面说的数据归一化是对输入特征做预处理而深度学习里常见的 BatchNorm、LayerNorm是对网络中间层的激活值做归一化。它们解决的问题不同但思想一脉相承——把分布拉回稳定区间让每一层的输入不会因为前一层参数变化而剧烈漂移。BatchNorm 按 batch 维度统计均值方差适合 CNN 这类固定 batch 的场景LayerNorm 按单条样本的特征维度统计适合 NLP、Transformer 这类序列模型因为序列长度变化时 batch 统计不稳定。你要是见过训练曲线突然炸掉很多时候就是 BatchNorm 在 batch size 很小或者数据分布剧烈变化时统计量抖动导致的。别把这两类归一化混为一谈面试和实操都经常考这个。3. torch 里的 transform把数据预处理写成流水线3.1 torchvision.transforms 的核心算子torchvision.transforms是 PyTorch 生态里处理图像数据的事实标准工具包。你训练一个 CV 模型几乎逃不掉这几个算子ToTensor把 PIL Image 或 numpy 数组转成 tensor同时把像素值从 [0, 255] 缩放到 [0, 1]维度从 HWC 变成 CHW。Normalize对 tensor 做逐通道标准化公式就是上面说的 Z-score。Resize/RandomCrop/RandomHorizontalFlip几何变换和数据增强增加样本多样性缓解过拟合。ColorJitter、RandomRotation、RandomAffine进一步做亮度、颜色、旋转等扰动。这里有个顺序问题Resize、Crop、Flip这些操作通常要在ToTensor之前因为它们处理的是 PIL ImageNormalize必须在ToTensor之后因为它处理的是 tensor。很多新手把Normalize放在前面直接报错——类型都对不上。3.2 Compose 组合与顺序到底怎么定常用组合是这样的from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])为什么Normalize一定是最后一步因为ToTensor把像素范围压到了 [0, 1]而 ImageNet 的 mean/std 是按这个范围统计的先标准化才能让预训练模型正常工作。如果顺序反过来等于用 [0, 255] 的像素直接减 mean 除 std分布完全错位。还有个容易忽略的点RandomCrop和RandomHorizontalFlip这类随机变换在验证集和测试集上应该去掉只保留Resize CenterCrop ToTensor Normalize。你训练时做数据增强是为了泛化推理时需要的是稳定可复现的输出把随机性带到线上等于自己给结果加噪声。3.3 自己写一个 transform 函数transforms本质上就是一组输入某种数据、输出另一种数据的 callable。你想自定义变换写一个普通类实现__call__方法就行class GaussianNoise(object): def __init__(self, std0.01): self.std std def __call__(self, tensor): return tensor torch.randn_like(tensor) * self.std train_transform transforms.Compose([ transforms.ToTensor(), GaussianNoise(std0.02) # 加一点高斯噪声做数据增强 ])我自己的习惯是把所有 transform 封装成独立的类而不是散落一堆函数这样 Compose 的流水线逻辑一眼能看懂。另一个建议是 transform 类里尽量保持无状态随机性靠内部调用torch.rand或 Python 的random产生避免多个 worker 之间互相干扰。4. 按概率从 tensor 里采样torch.multinomial 实操4.1 最常见的需求从概率分布里选一个值语言模型生成文本、强化学习采样动作、蒙特卡洛方法里选状态这些场景都有一个共同需求模型输出一个概率分布通常是 softmax 之后的 tensor你要按这个分布随机抽一个索引。很多人直接写torch.argmax那是最佳索引而不是采样如果想让结果有随机性就要用torch.multinomial。举例模型输出了 4 个类别的 logits先转成概率再采样logits torch.tensor([2.0, 1.0, 0.5, 0.1]) probs torch.softmax(logits, dim-1) idx torch.multinomial(probs, num_samples1) value values[idx] # 根据采样索引取对应的实际值注意multinomial的输入是概率权重它并不要求权重加起来严格等于 1只要非负内部会自动归一化。不过为了语义清晰我建议还是先 softmax 再传入。4.2 torch.multinomial 的原理与参数torch.multinomial(input, num_samples, replacementFalse)的底层逻辑是把输入权重归一化成概率后沿着累积概率分布撒一个均匀随机数落在哪个区间就选哪个索引。如果你想手动实现代码是这样的probs torch.tensor([0.1, 0.2, 0.7]) cum torch.cumsum(probs, dim0) rand torch.rand(1) idx torch.searchsorted(cum, rand)这能帮你理解为什么multinomial在权重全为 0 或者出现 NaN/Inf 时会出错——累积概率没法算随机数没地方落。我实际遇到过一次线上服务崩溃就是因为某个 logits 里出现了float(inf)softmax 之后概率变成 NaNmultinomial直接抛异常。排查半天才定位到是上游特征异常放大导致的。replacement参数也很关键。replacementFalse表示不放回采样适合从一组有限元素里一次性抽多个不重复的replacementTrue表示放回适合连续生成序列的场景。语言模型逐 token 生成时每个位置都是从完整词表里采一个所以必须用放回采样。4.3 采样时的 temperature 控制与随机种子采样不是只有均匀随机和贪心两个极端。文本生成里常用的 trick 是加一个 temperaturelogits / T。T 越大分布越平滑采样越随机T 越小分布越尖锐越接近 argmaxT 趋近 0 时退化成贪心搜索。logits torch.tensor([2.0, 1.0, 0.5]) for temp in [0.5, 1.0, 2.0]: p torch.softmax(logits / temp, dim-1) print(fT{temp}: {p})从输出能直观看到T0.5 时概率集中在高分项T2.0 时各项差距被抹平。实际使用中T 过小会导致文本重复、动作单一T 过大会胡说八道、毫无章法。我自己做生成式任务时喜欢在 0.7~1.2 之间先跑一批验证集看结果再定温度。复现性方面记得在采样前设置torch.manual_seed(seed)。如果要保证多进程 DataLoader 里数据增强和采样都可复现还要在 worker 初始化里单独设置子进程种子否则每次跑结果都不同排错会很痛苦。5. transform 的矩阵本质从 CSS matrix() 到张量运算5.1 CSS transform: matrix() 的参数到底什么意思看到热搜词里有人问transform: matrix()怎么用这里正好把变换的数学本质讲透。CSS 的transform: translate(20px, 30px)、scale(2, 1)、rotate(30deg)这些花哨写法最终都会被浏览器统一换算成一个 3x3 仿射变换矩阵对应的就是matrix(a, b, c, d, tx, ty)六个参数。CSS 官方文档定义这六个参数按列优先排成矩阵| a c tx | | b d ty | | 0 0 1 |一个点的坐标[x, y, 1]左乘这个矩阵就得到变换后的坐标x a*x c*y tx y b*x d*y tytx、ty是平移量matrix(1, 0, 0, 1, 40, 20)就是把元素右移 40px、下移 20px。a、d控制缩放matrix(2, 0, 0, 2, 0, 0)是放大两倍。b、c控制斜切和旋转。绕原点旋转 30 度cos300.866, sin300.5对应matrix(0.866, 0.5, -0.5, 0.866, 0, 0)。style .translate { transform: matrix(1, 0, 0, 1, 40, 20); } .rotate { transform: matrix(0.866, 0.5, -0.5, 0.866, 0, 0); } /style这里有一个很多前端新手看不懂的地方为什么平移要用 3x3 矩阵而不是 2x2因为平移不是线性变换用 2x2 矩阵表达不了加一个常量的操作。引入齐次坐标多一维恒为 1之后平移、旋转、缩放、斜切全部统一成矩阵乘法可以任意叠加和合并。这个思想在后端图形学、机器人学、深度学习的几何变换里一模一样。5.2 矩阵乘法如何同时搞定平移缩放旋转矩阵之所以强大是因为连续多个变换可以合并成一个矩阵。CSS 里你写translate(20px) rotate(30deg)浏览器会先旋转后平移还是先平移后旋转结果完全取决于矩阵相乘的顺序因为矩阵乘法不满足交换律。这跟 PyTorch 里张量变换的顺序问题同源。5.3 在 torch 里做等价的张量变换PyTorch 里最接近 CSSmatrix()的操作就是torch.matmul矩阵乘法和各类形状变换。如果你想对一个坐标集合做仿射变换完全可以照搬 CSS 的思路points torch.tensor([[1.0, 2.0], [3.0, 4.0]]) ones torch.ones(points.shape[0], 1) homogeneous torch.cat([points, ones], dim1) # 齐次坐标 matrix torch.tensor([[1.0, 0.0, 40.0], [0.0, 1.0, 20.0], [0.0, 0.0, 1.0]]) transformed torch.matmul(homogeneous, matrix.T)[:, :2]这段代码的意义在于所有 transform不管叫 CSS 还是 PyTorch transforms本质都是对一个数据对象做线性或仿射变换。数据归一化 (x - mean) / std 也是一个仿射变换等于先平移再缩放。理解了这一点你对变换这个词的恐惧感会立刻消失——它就是一个函数输入是数据输出是数据中间可能乘个矩阵。6. 我踩过的坑归一化、采样与变换的实战提醒6.1 训练时归一化预测时忘了做这是我在图像分类项目里真实踩过的坑。训练流程里用了Normalize(mean..., std...)模型收敛得很好准确率 90% 出头。后来部署到推理服务时我图省事只写了ToTensor()觉得像素归一化这么简单的事怎么会影响结果。结果线上准确率直接掉到 60% 左右而且错得很随机。原因不复杂训练时模型看到的是均值 0、方差 1 的特征分布推理时输入却还是 [0, 1] 范围的原始像素相当于给模型灌了一组完全没见过的数据。解决方式是把预处理封装成一个类训练和推理共用同一个函数不要把归一化逻辑散落在训练脚本和部署脚本里。我后来凡是做模型部署第一件事先对齐预处理再谈后面的优化。6.2 sample 遇到 NaN/Inf 概率torch.multinomial对非法输入非常敏感。我之前在强化学习项目里网络输出的 logits 偶尔会冒出一个极大值softmax 之后那一项变成 1其他项趋近 0这还算好的严重时 logits 本身有inf或nansoftmax 输出直接变成 NaN采样器要么报错要么返回无效索引。现在的处理习惯是采样前先检查torch.isfinite(logits).all()不合法就回退到argmax或者上一层缓存实在要兜底给 logits 加一个torch.nan_to_num。另外数值稳定性上优先用logits / T - logits.max()再 softmax避免 exp 溢出。6.3 随机种子与数据增强的复现问题很多新人以为设置了torch.manual_seed(42)就能完全复现实验其实不全对。DataLoader 多进程加载时每个 worker 默认继承主进程的随机状态但数据增强里用的random、numpy.random可能各自有独立状态导致换一台机器或者改 batch size 结果就变了。我的做法是尽可能把随机性收敛到 PyTorch 的随机数生成器上增强操作优先用torch.rand而不是random.random如果项目必须完全复现就在worker_init_fn里给每个 worker 单独设种子并把torch.backends.cudnn.deterministic设为 True。代价是慢一点但可复现的价值远大于这点性能损耗。6.4 view 和 reshape 在模型部署中的坑最后提醒一个和变换直接相关的坑。view要求内存连续这个约束在你做模型导出比如转 ONNX时会变成隐藏炸弹。我遇到过前向脚本里写tensor.view(...)在 PyTorch CPU 上运行正常但一换成 GPU 或导出后推理就报input tensor is not contiguous的错误。排查后发现问题出在permute之后直接调了view而permute的结果内存不连续。解决方案要么permute后加.contiguous()要么直接用reshape。这里没有谁绝对好关键是你得知道每个算子会不会改变内存布局。我的经验是——跨设备、跨框架跑代码时reshape比view安全得多性能差距在绝大多数场景下可以忽略。我个人在实操里最大的体会是数据处理这条链路出问题往往不在某个高大上的算法而在训练和推理不一致。张量、归一化、transform、采样每个环节都要盯住输入输出的形状、数值范围、设备和随机状态。把这几个变量管住你的模型训练和部署都能少掉一半的头发。最后再分享一个判断习惯拿到一个别人写的模型我第一件事不是看网络结构而是看它数据预处理那段代码——归一化参数是什么、transform 顺序怎么排、采样有没有兜底。数据管好了模型表现不会差到哪里去数据管不好再强的网络也白搭。希望这篇文章能帮你把这条线彻底捋顺。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进