在深度学习里摸爬滚打一段时间后,我发现很多人对张量形状的处理都到了“看着代码愣半天”的程度。view、permute、squeeze、unsqueeze这一整套操作,在调试的时候经常让我有种“这个维数到底对应哪个维度”的混乱感。尤其是写多头注意力或者图像的 patch 划分时,一个形状写错,后面全盘报错,报错信息还晦涩难懂。直到我用上 einops,这个痛点才算真正解决。它是一个让你用“可读的字符串语法”来操作张量的库,核心就是rearrange、reduce、repeat这三个函数。这篇文章,我就把 einops 的基本使用思路和实际踩坑经验一次性讲透,尤其适合正在使用 PyTorch、TensorFlow 这些框架处理多维数组的开发者。
1. 为什么需要 einops?传统张量操作的三宗罪
先聊聊传统方式的痛点。以 PyTorch 为例,我们经常会做这些操作:
# 假设输入 x 形状是 (batch, channel, height, width) x = torch.randn(32, 3, 224, 224) # 想把 H 和 W 合并 x1 = x.view(32, 3, 224 * 224)问题来了。view只是按照“内存顺序”去展平,如果你的 tensor 不是连续的(比如前面遇到过transpose),直接view就会报错,还必须先contiguous()。这只是浅层问题,更痛的是下面几类场景:
- 维度顺序调整:
x.permute(0, 2, 1)这种写法,谁能一眼看出你是在交换哪两个维度?数字索引稍不留神就写反了。 - squeeze / unsqueeze 的误区:
squeeze默认会把所有长度为 1 的维度都去掉,这根本不是你想要的结果,你往往只是想压缩某一个特定的维度。 - 可分步完成但不可维护:做 transformer 的多头注意力时,要把
(batch, seq_len, hidden_dim)拆成(batch, num_heads, seq_len, head_dim),用原生 API 要写好几行view、transpose、contiguous。代码跑起来没问题,但过一个月再回来看,基本要靠注释才知道自己在干嘛。
einops 改变了这一切,它借鉴了爱因斯坦求和约定的思想,让你直接用“描述性字符串”来表达张量的重新排列、合并和拆分。比如上面那个view(32, 3, 224*224),用 einops 可以写成:
from einops import rearrange x1 = rearrange(x, 'b c h w -> b c (h w)')你看,输入维度b c h w,输出维度b c (h w),括号表示合并,一目了然,根本不会猜错。而且它不需要你在脑子里反复推演维度顺序,报错信息也比原生框架的“shape mismatch”要直观得多。
无论你是做图像处理、NLP 还是多模态,只要你在跟数组、张量打交道,einops 都能让代码可读性上升一个台阶,同时降低因形状操作出错的概率。下面的内容,我会从安装开始,逐步拆解它的核心功能、实际应用和避坑方法。
2. einops 核心 API 拆解:rearrange、reduce、repeat
2.1 安装与导入
安装非常简单,直接 pip 就行:
pip install einops没有其他依赖,装完就能用。在 Python 里导入最常用的是这三个函数:
from einops import rearrange, reduce, repeat如果你使用 PyTorch,einops 对它的支持最友好;TensorFlow、JAX 甚至普通 NumPy 数组也都能用,底层就是通用张量操作。
2.2 rearrange:重排、合并、拆分维度
rearrange有两个参数,第一个是待处理的张量,第二个是描述维度的字符串。字符串左侧是输入轴的命名,右侧是输出轴的命名,中间用->连接。
rearrange(tensor, '输入轴名称 -> 输出轴名称')轴名称你随意起,只要方便记忆,比如b代表 batch,c代表 channel,h是 height,w是 width。关键是右侧怎么写:
- 括号内合并:
'b c h w -> b c (h w)',这样就 H 和 W 展成一个维度。 - 括号外拆分:
'b (h w) -> b h w',把一个维度拆成两个,但拆成多少需要你手动在调用时指定:
einops 知道x = torch.randn(32, 64, 224 * 224) y = rearrange(x, 'b c (h w) -> b c h w', h=14, w=16)h * w = 224*224,再加上你提供的h、w,就能推导出另一个。注意这里拆分的总元素个数必须匹配。 - 维度交互:
'b c h w -> b (c h) w'。c 和 h 合并成一个维度,同时保留 w。 - 省略号
...:当输入维度比较多,但你只关心某些维度时,可以用...占位符代表任意数量的维度。比如rearrange(x, '... h w -> ... (h w)'),前面不管有多少维度,都原样保留,最终只要把最后的 h 和 w 合并。
顺带一提,rearrange内部自动处理contiguous()的逻辑。你从没见过它会因为内存不连续而报错,它够聪明,会优先尝试view,不行就自动转permute加contiguous,再从底层走。这也是我特别愿意用它替代原生 API 的原因之一。
2.3 reduce:在指定维度上做归约操作
reduce的作用是“压缩”某个维度,你可以用字符串指定在哪些轴上做操作,比传统的sum(dim=...)好理解得多。调用格式是:
reduce(tensor, 'b c h w -> b c', reduction='mean')这里的reduction可以是mean、sum、max、min等常见归约操作。比如,对一张(batch, channel, height, width)的图像做全局平均池化,传统写法要x.mean(dim=(2, 3)),而现在直接:
pooled = reduce(x, 'b c h w -> b c', reduction='mean')这个reduce特别适合做图像金字塔、注意力池化、或者对序列做长度压缩。值得注意的是,右侧的维度只能是左侧维度的子集或通过括号合并后的结果,你可以把多个维度归约成单个维度,也可以直接跨多个轴归约,只要右侧没有的轴,einops 就会按指定操作“收掉”它们。
2.4 repeat:沿维度重复数据
repeat是反向扩展,它会将某个维度复制多份。格式是:
repeat(tensor, 'b c -> b c t', t=4)这段代码把(batch, channel)的 tensor 在最后新增一个维度并且重复 4 次,变成(b, c, 4)。它最常见的用途包括,将单特征图广播到多个头、生成多个候选框,或者在注意力机制的 KV 复制中。
有人可能会疑惑,这不就跟tensor.unsqueeze(-1).repeat(1,1,4)差不多吗?对,功能是差不多,但repeat的直观性碾压原生 API。特别是当你要在多个维度上同时做重复时,用一句话写清楚比写一串repeat参数要安全得多。
再看一个组合操作:
# (batch, hidden) -> (batch, num_heads, hidden) repeat(x, 'b h -> b n h', n=8)这种写法在生成位置编码、扩充 batch 内的样本时非常方便。
3. 深度学习实战:这些场景你每天都在踩坑
掌握了三个核心函数,我们直接进入最常用的深度学习场景。看看 einops 如何让那些“看一眼就头大”的代码段变得优雅。
3.1 图像 Patch 划分(Vision Transformer 风格)
做 ViT 时,需要把(batch, channel, height, width)的图像切分成(num_patches, patch_size, patch_size)的小块。传统写法要torch.nn.functional.unfold,或者多层reshape和permute。用 einops 一行搞定:
import torch from einops import rearrange x = torch.randn(2, 3, 32, 32) # (batch=2, channels=3, h=32, w=32) # 切成 4x4 大小的 patch,输出形状为 (num_patches=64, patch_size=4*4*3=48) patches = rearrange(x, 'b c (h p1) (w p2) -> (b h w) (p1 p2 c)', p1=4, p2=4) print(patches.shape) # torch.Size([64, 48])你看,(h p1)就把原来的 H 拆成块数 和 每个块大小 两个维度,p1通过参数指定。这种写法不仅省去了复杂的切片逻辑,而且可读性极强,别人一看就知道你是在做 patch 划分。
3.2 多头注意力机制里的维度重排
这是我在 transformer 里使用频率最高的一个场景。假设输入q的形状是(batch, seq_len, hidden_dim),需要把它拆分成多头。标准写法通常是:
# 传统写法 q = q.view(batch, seq_len, num_heads, head_dim).transpose(1, 2)如果你前后逻辑不顺,很容易记错到底是view在前还是transpose在前。用 einops 就明确多了:
q = rearrange(q, 'b s (h d) -> b h s d', h=8)这句话直接告诉读者:原始维度是b s (h d),把h和d分开,然后交换到第二维。最后再拿去做注意力矩阵乘法,完全不担心维度错位。
同样的场景,在多头注意力的末尾,需要把(b, h, s, d)重新合并回(b, s, h*d),再逆变换即可:
q = rearrange(q, 'b h s d -> b s (h d)')我个人的体会是,用 einops 写注意力时,代码的思路会跟着字符串走,整个人都松弛下来,不再在纸上画维度矩阵了。
3.3 特征图池化与空间维度压缩
做一个全局池化或者多尺度特征融合,我经常用reduce直接操作。比如一个分类网络最后要输出(batch, channel)的 logits:
from einops import reduce x = torch.randn(2, 128, 7, 7) global_pooled = reduce(x, 'b c h w -> b c', reduction='mean') print(global_pooled.shape) # torch.Size([2, 128])如果换成自适应平均池化,你得写nn.AdaptiveAvgPool2d((1, 1))再 squeeze,而 einops 的表达几乎已经接近自然语言,直接“在除 b 和 c 外的所有维度上求平均”。此外,通过括号合并还能实现“把 h 和 w 分块取平均”类似空间金字塔的效果,非常灵活。
3.4 位置编码的批次扩展
常见的可学习位置编码,形状是(seq_len, hidden_dim)。你要广播到一个 batch 的时候,传统做法是unsqueeze(0).expand(batch, -1, -1)。用repeat是这样:
pos_emb = torch.randn(50, 128) pos_emb_expanded = repeat(pos_emb, 's d -> b s d', b=4)如果你还需要把 hidden_dim 扩展到多个 head,可以连着写:
pos_emb_expanded = repeat(pos_emb, 's d -> b h s d', b=4, h=8)这种连续维度的扩展,在传统写法下你要嵌套好几层 repeat,而这里的字符串一眼到底,且完全不存在索引错乱的问题。
4. 常见问题与排查技巧实录
4.1 遇到 “Shape mismatch” 报错别慌
einops 的报错信息比原生框架友好得多,它会直接提示你在哪个轴出现了不匹配。最常见的报错是einops.error: Shape mismatch in ...。比如:
x = torch.randn(4, 6) rearrange(x, 'a b -> c', c=24) # 4*6=24,这样没问题 rearrange(x, 'a b -> c', c=25) # 这里报错,因为 4*6 != 25报错会明确告诉你是a b的乘积(24)与目标c(25)不匹配。遇到这种情况,先检查你自己是不是在括号里多写了一个轴,或者参数传错了。
有一类很隐蔽的错误,是把b c h w这种顺序在字符串里写错,比如把输入忘写了w,导致它跑去读取不存在的轴。排查的方法很简单:先打印x.shape,然后对照字符串的轴名字一一确认。
4.2 括号的使用是维度推导的核心
einops 中括号的语义,其实就是“括号里的轴合并成一个轴”。当你看到(h p1),意思是把原来的一个维度h拆成h和p1两个维度,但h * p1必须等于原始维度长度。很多人一开始不习惯,总觉得h明明是高度,为什么还能拆?
打个比方,假设原来高度是 32,你想切成 4 个 patch,每个高度 8,那字符串里就是(h p1),调用时传p1=8,einops 就能自动算出h=4。只有当你同时传了h和p1且它们乘积不等于原始大小时,才会报错。
还有一个心得:不要在左右两侧使用同名的维度却处于不同位置。比如rearrange(x, 'a b c -> b a c')这种轴交换是允许的,einops 会理解成交换顺序,但如果你写了rearrange(x, 'a b c -> a b c a')这种重复轴,它一定会报错,因为同一个轴不能凭空消失或凭空增加。
4.3 性能与内存的那点事儿
很多人担心 einops 的封装会影响性能。实测下来,在 GPU 上跑大模型任务时,它的性能开销和手写view、permute基本一致,因为它核心就是一个轻量级的维度解析器,真正执行时还是走底层的张量操作。唯一额外的开销体现在首次调用时的字符串解析,这部分会做缓存,后续同字符串的调用几乎没有额外成本。
不过有几个细节要提醒:
rearrange有些情况会产生内存拷贝(当顺序无法用 view 解决时,会自动走 permute + contiguous)。这和原生permute后需要contiguous()的开销本质相同,绕不开。如果你是做极致的性能优化,且手写 view 能明确知道不拷贝,那还是保选手写。- 注意别在非常高频的循环里,每次传入一个动态拼接的字符串。这样缓存会失效,字符串解析开销就会累积,性能就会受影响。如果要在循环里用,把字符串常量提前定义好。
4.4 与传统 API 混用时的注意事项
einops 可以和view、permute混用,但返回的全新 tensor 都是创建过的。如果你在nn.Module里想让 einops 操作参与反向传播,完全没问题,它是纯函数式操作,梯度能够正常回传。
另外,在 PyTorch 中,如果输入 tensor 是某种带步长的非连续视图(比如先切片过),einops 依旧能正确操作,它不会像view那样给你扔一个RuntimeError: view size is not compatible with input tensor's size and stride。我习惯把 einops 视为“对任意混乱布局的最终兜底方案”,不需要时刻关心那个 tensor 是不是contiguous。
5. 工程化集成与最佳实践
5.1 在 torch.nn.Module 中优雅使用
你能在模型的forward方法里直接使用 einops 函数,但为了让代码更整洁,官方还提供了einops.layers.torch这样的整合层。比如Rearrange、Reduce这两个nn.Module,可以直接放在nn.Sequential里:
import torch.nn as nn from einops.layers.torch import Rearrange model = nn.Sequential( nn.Conv2d(3, 64, 3, padding=1), nn.ReLU(), # 将 (b, c, h, w) 展成 (b, c*h*w),然后接全连接 Rearrange('b c h w -> b (c h w)'), nn.Linear(64 * 32 * 32, 10) )这样模型的每一层输入输出形状,会直接体现在模型定义的字符串里,对调试和阅读非常有帮助。如果你做的是 NLP 或表格数据,Reduce也可以作为池化层直接嵌入。
5.2 与类型注解结合提升可维护性
在团队协作或大型项目中,我给张量操作加上类型注解后,代码的稳定性明显提高。比如:
from einops import rearrange def process_features(x: torch.Tensor) -> torch.Tensor: # 明确标注形状 # x: (batch, dim, seq) # return: (batch, seq, dim) return rearrange(x, 'b d s -> b s d')虽然类型注解没法约束张量的形状,但字符串本身就是一种非常“活跃”的形状注释,别人读代码的时候不再需要靠上下文猜测输入输出形状。
5.3 多框架兼容与迁移技巧
einops 不仅是 PyTorch 专用,我在 TensorFlow 里也是这么用的:
import tensorflow as tf from einops import rearrange x = tf.random.normal([2, 3, 4]) y = rearrange(x, 'b h w -> b (h w)')这个特性在我做模型迁移的时候特别实用,可以把一段 PyTorch 代码原封不动地贴在 TensorFlow 代码里,只要张量格式一致,字符串不用改。如果你在做 JAX 或 NumPy 数组实验,它同样通用。所以从某种角度讲,einops 甚至可以作为框架之间的“张量操作翻译器”。
5.4 什么情况下不要用 einops
说实话,einops 并非银弹。如果只是简单地x.view(-1, 5)这种琐碎操作,或者你在写一个对性能要求极其极致的内核算子,频繁调用动态字符串会影响效率,那直接用原生 API 就好。此外,阅读 einops 代码需要付出一点点学习成本,如果团队里其他成员不熟悉这个库,强行秀操作反而会增加维护负担。我的建议是,把它用在复杂的高维张量重排、跨框架代码共享、需要严格可读性的核心模块里,其余地方按你习惯来。
最后分享一个我在调试时的小习惯。我会在正式写模型前,先用 einsum 或 einops 写一个“形状推导测试脚本”,把每个中间张量的输出形状打印出来。一旦字符串写错,报错信息会精确到轴名称,这比在一堆数字里找哪个维度不匹配要省心得多。如果你现在还在为别人代码里的permute(0,2,3,1)感到头疼,不妨试着用 einops 重构一下,我相信你也会很快上瘾。