import torch
import torch.nn as nn
import torch.nn.functional as F
from torch import Tensor
print('PyTorch version:', torch.__version__)PyTorch version: 2.12.1+cpu
num_groups=1:所有通道属于同一组num_groups=C:每个通道单独成组num_groups 应该如何选择jshn9515
2026-06-27
2026-06-27
上一节我们介绍了 Instance Normalization。对于形状为 (N, C, H, W) 的图像特征,InstanceNorm 会固定样本和通道,只沿空间维度 H、W 计算均值和方差。它不依赖 batch 中的其他样本,因此即使 batch size 很小,也不会出现 BatchNorm 那样的统计量不稳定问题。
但是,InstanceNorm 也带来了一个新的问题:每个通道都被完全独立地归一化,不同通道之间不再共享统计信息。对于风格迁移和图像生成,这种性质往往很有用;但对于目标检测、语义分割等视觉任务,我们可能希望在不依赖 batch 的同时,仍然保留一部分通道之间的联系。
Group Normalization (GroupNorm) (Wu and He 2018) 正是在这两个目标之间做折中。它把通道划分为若干组,然后在每个样本内部,对同一组中的通道和空间位置一起计算均值和方差。
因此,理解 GroupNorm 的关键仍然是统计维度:
num_groups=1 和 num_groups=C 分别对应什么?这一节会从一个小型张量开始,逐步建立 GroupNorm 的统计视角,并从零实现一个与 nn.GroupNorm 对应的版本。
PyTorch version: 2.12.1+cpu
BatchNorm 对输入 (N, C, H, W) 固定通道 C,沿 batch 和空间维度 N, H, W 计算统计量。它在 batch 足够大时通常工作的很好,但当显存限制使得每个设备只能容纳很少的样本时,batch statistics 会变得不稳定。这在目标检测和语义分割中尤其常见。高分辨率图像会消耗大量显存,因此单张 GPU 上的 batch size 往往很小,有时甚至只有 1 或 2。InstanceNorm 可以避免这个问题,因为它完全不跨样本统计。但它把每个通道都单独处理,可能会移除过多的通道级信息。
GroupNorm 的思路是:
不跨样本统计,但也不把每个通道完全分开,而是把若干通道组成一组,在组内共享统计量。
假设输入有 8 个通道,我们可以把它们分成 4 组,每组包含 2 个通道:
Group 1: channel 0, channel 1
Group 2: channel 2, channel 3
Group 3: channel 4, channel 5
Group 4: channel 6, channel 7
每个样本都会独立完成这次分组和归一化,因此 batch 中的其他样本不会影响当前样本的输出。
设输入为:
\[ X\in\mathbb{R}^{N\times C\times H\times W} \]
GroupNorm 使用 G 个组,并要求:
\[ C \bmod G = 0 \]
也就是说,通道数必须能被组数整除。每组包含的通道数为:
\[ C_g=\frac{C}{G} \]
实现时,我们可以先把输入从 (N, C, H, W) reshape 为 (N, G, C/G, H, W),这样第二个维度对应 group,第三个维度对应组内通道。
Original shape: torch.Size([2, 8, 2, 2])
Grouped shape: torch.Size([2, 4, 2, 2, 2])
对于每个样本和每个 group,GroupNorm 会沿组内通道和空间维度计算统计量,也就是沿:
进行归约。
因此,均值和方差的形状为 (N, G, 1, 1, 1)。
Mean shape: torch.Size([2, 4, 1, 1, 1])
Variance shape: torch.Size([2, 4, 1, 1, 1])
可以把 GroupNorm 的规则概括成一句话:
对于
(N, C, H, W)输入,GroupNorm 固定样本和 group,沿组内通道以及空间维度进行归一化。
对于第 \(n\) 个样本和第 \(g\) 个 group,设该组包含的元素集合为 \(S_{n,g}\),其元素个数为:
\[ |S_{n,g}|=\frac{C}{G}HW \]
组内均值为:
\[ \mu_{n,g} = \frac{1}{|S_{n,g}|} \sum_{i\in S_{n,g}}x_i \]
组内方差为:
\[ \sigma_{n,g}^2 = \frac{1}{|S_{n,g}|} \sum_{i\in S_{n,g}}(x_i-\mu_{n,g})^2 \]
然后对组内每个元素进行标准化:
\[ \hat{x}_i = \frac{x_i-\mu_{n,g}}{\sqrt{\sigma_{n,g}^2+\epsilon}} \]
最后,GroupNorm 和其他归一化层一样,使用可学习参数恢复表达能力:
\[ y_{n,c,h,w} = \gamma_c\hat{x}_{n,c,h,w}+\beta_c \]
这里需要注意,虽然统计量是按 group 计算的,但可学习参数仍然是每个通道一组:
\[ \gamma,\beta\in\mathbb{R}^{C} \]
Weight shape: torch.Size([8])
Bias shape: torch.Size([8])
因此,同一个 group 内的通道共享均值和方差,但仍然可以通过不同的 weight 和 bias 学习不同的缩放和平移。
下面我们先不使用可学习仿射变换,只手动完成分组、标准化和 reshape。
num_groups = 4
x = torch.randn(2, 8, 4, 4)
n, c, h, w = x.size()
dim = (2, 3, 4)
x_grouped = x.reshape(n, num_groups, c // num_groups, h, w)
mean = x_grouped.mean(dim, keepdim=True)
var = x_grouped.var(dim, correction=0, keepdim=True)
x_hat_grouped = (x_grouped - mean) / (var + 1e-5).sqrt()
x_hat = x_hat_grouped.reshape_as(x)
output_grouped = x_hat.reshape(n, num_groups, c // num_groups, h, w)
print('Output means by group:')
print(output_grouped.mean(dim))
print('\nOutput variances by group:')
print(output_grouped.var(dim, correction=0))Output means by group:
tensor([[ 3.7253e-09, 3.7253e-09, -5.5879e-09, 2.2352e-08],
[-1.4901e-08, 8.3819e-09, 3.7253e-09, -7.4506e-09]])
Output variances by group:
tensor([[1.0000, 1.0000, 1.0000, 1.0000],
[1.0000, 1.0000, 1.0000, 1.0000]])
每个样本的每个 group 都会得到接近 0 的均值和接近 1 的方差。为了避免除 0,我们在方差上加了一个 \(\epsilon\)。
GroupNorm 的统计过程不会沿 batch 维度进行,因此改变 batch 中的其他样本,不会改变当前样本的输出。
sample = torch.randn(1, 8, 4, 4)
other_sample1 = torch.randn(1, 8, 4, 4)
other_sample2 = torch.randn(1, 8, 4, 4) * 100.0 + 500.0
batch1 = torch.concat([sample, other_sample1], dim=0)
batch2 = torch.concat([sample, other_sample2], dim=0)
group_norm = nn.GroupNorm(4, 8, affine=False)
output1 = group_norm(batch1)[0]
output2 = group_norm(batch2)[0]
max_diff = (output1 - output2).abs().max()
print('Maximum difference for the fixed sample:', max_diff.item())Maximum difference for the fixed sample: 0.0
由于第一个样本完全相同,因此它的 GroupNorm 输出也完全相同。这和 BatchNorm 不同。BatchNorm 会把 batch 中同一通道的所有样本一起统计,所以其他样本发生变化时,当前样本使用的均值和方差也会变化。
GroupNorm 的这一性质意味着:
num_groups=1:所有通道属于同一组当 \(G=1\) 时,所有通道都被放进同一个 group。对于 (N, C, H, W) 输入,统计量会沿 C、H、W 一起计算。这在统计维度上和 nn.LayerNorm((C, H, W)) 很相似。两者都会为每个样本在整个 (C, H, W) 上计算均值和方差。
Maximum difference: 2.384185791015625e-07
在关闭仿射变换后,两者的标准化结果相同,但默认仿射参数的形状并不相同:
GroupNorm(1, C) 的参数形状是 (C,);LayerNorm((C, H, W)) 的参数形状是 (C, H, W)。因此更准确的说法是:
num_groups=1时,GroupNorm 的统计方式等价于对(C, H, W)做 LayerNorm,但默认仿射参数化方式不同。
num_groups=C:每个通道单独成组当 \(G=C\) 时,每个 group 中只有一个通道。此时 GroupNorm 会固定样本和通道,沿空间维度 H、W 计算统计量。这正是 InstanceNorm 的统计方式。
Maximum difference: 4.76837158203125e-07
两者输出相同,因为它们都为每个样本的每个通道独立计算空间统计量。
因此,GroupNorm 可以看成 LayerNorm 和 InstanceNorm 之间的桥梁:
这也是 GroupNorm 名字的来源:组数决定了通道之间共享统计信息的粒度。
下面实现一个函数版 GroupNorm。这里我们支持任意空间维度,而不仅限于二维图像。
def group_norm(
x: Tensor,
num_groups: int,
weight: Tensor | None = None,
bias: Tensor | None = None,
eps: float = 1e-5,
) -> Tensor:
"""Apply group normalization to an input tensor."""
if x.ndim < 2:
raise AssertionError(
f'Expected input tensor to have at least 2 dimensions, but got {x.ndim}.'
)
if num_groups <= 0:
raise AssertionError(
f'Expected `num_groups` to be greater than 0, but got {num_groups}.'
)
num_channels = x.size(1)
channels_per_group = num_channels // num_groups
if num_channels % num_groups != 0:
raise AssertionError(
f'Expected the number of channels ({num_channels}) to be divisible '
f'by `num_groups` ({num_groups}).'
)
# (N, C, H, W) -> (N, G, C // G, H, W)
grouped_shape = (x.size(0), num_groups, channels_per_group, *x.shape[2:])
grouped_x = x.reshape(grouped_shape)
# Reduce over the channels in each group and all spatial dimensions.
# (N, G, C // G, H, W) -> reduce_dims = (2, 3, 4)
reduce_dims = tuple(range(2, grouped_x.ndim))
group_mean = grouped_x.mean(dim=reduce_dims, keepdim=True)
group_var = grouped_x.var(dim=reduce_dims, correction=0, keepdim=True)
grouped_y = (grouped_x - group_mean) * (group_var + eps).rsqrt()
y = grouped_y.reshape_as(x)
# (C,) -> (1, C, 1, 1)
broadcast_shape = (1, num_channels) + (1,) * (x.ndim - 2)
if weight is not None:
y = y * weight.reshape(broadcast_shape)
if bias is not None:
y = y + bias.reshape(broadcast_shape)
return y现在与 F.group_norm 对照一下:
Maximum difference: 4.76837158203125e-07
两者的误差应该只来自浮点数计算顺序。
整个实现最核心的三步是:
(N, C, ...) reshape 为 (N, G, C/G, ...);接下来把函数封装成一个模块。
class GroupNorm(nn.Module):
"""Apply group normalization over channel groups."""
weight: Tensor | None
bias: Tensor | None
def __init__(
self,
num_groups: int,
num_channels: int,
eps: float = 1e-5,
affine: bool = True,
):
"""Initialize a group normalization module."""
super().__init__()
if num_channels % num_groups != 0:
raise AssertionError('`num_channels` must be divisible by `num_groups`.')
self.num_groups = num_groups
self.num_channels = num_channels
self.eps = eps
self.affine = affine
if affine:
self.weight = nn.Parameter(torch.empty(num_channels))
self.bias = nn.Parameter(torch.empty(num_channels))
else:
self.register_parameter('weight', None)
self.register_parameter('bias', None)
self.reset_parameters()
def reset_parameters(self) -> None:
if self.weight is not None:
nn.init.ones_(self.weight)
if self.bias is not None:
nn.init.zeros_(self.bias)
def forward(self, x: Tensor) -> Tensor:
if x.size(1) != self.num_channels:
raise AssertionError(
f'Expected {self.num_channels} channels, but got {x.size(1)} channels.'
)
return group_norm(
x,
self.num_groups,
weight=self.weight,
bias=self.bias,
eps=self.eps,
)
def extra_repr(self) -> str:
return (
f'{self.num_groups}, {self.num_channels}, eps={self.eps}, '
f'affine={self.affine}'
)将参数复制到 PyTorch 模块中,再比较输出:
x = torch.randn(2, 8, 4, 4)
group_norm1 = GroupNorm(4, 8)
group_norm2 = nn.GroupNorm(4, 8)
with torch.no_grad():
group_norm2.weight.copy_(group_norm1.weight)
group_norm2.bias.copy_(group_norm1.bias)
actual = group_norm1(x)
expected = group_norm2(x)
max_diff = (actual - expected).abs().max()
print('Maximum difference:', max_diff.item())Maximum difference: 4.76837158203125e-07
结果应该只有浮点数计算顺序带来的误差。
和 LayerNorm 一样,同一个 nn.GroupNorm 可以处理 (N, C, *),只要第二个维度是通道维度即可。
1D output shape: torch.Size([2, 4, 10])
2D output shape: torch.Size([2, 4, 8, 8])
3D output shape: torch.Size([2, 4, 4, 8, 8])
对于这些输入,GroupNorm 总是:
num_groups 组;因此,不存在 GroupNorm1d、GroupNorm2d 和 GroupNorm3d 的分类方式。
GroupNorm 不维护 running_mean 和 running_var。无论训练还是推理,它都会使用当前样本自己的 group statistics。因此,train() 和 eval() 不会改变 GroupNorm 的计算方式。
x = torch.randn(2, 8, 4, 4)
group_norm = nn.GroupNorm(4, 8)
group_norm.train()
train_output = group_norm(x)
group_norm.eval()
eval_output = group_norm(x)
max_diff = (train_output - eval_output).abs().max()
print('Maximum difference:', max_diff.item())
print('State dict keys:', dict(group_norm.state_dict()))Maximum difference: 0.0
State dict keys: {'weight': tensor([1., 1., 1., 1., 1., 1., 1., 1.]), 'bias': tensor([0., 0., 0., 0., 0., 0., 0., 0.])}
state_dict 中只有可学习的 weight 和 bias,没有 BatchNorm 中的:
running_mean;running_var;num_batches_tracked。这也意味着 GroupNorm 不需要像 BatchNorm 那样在推理前确保 running statistics 已经正确估计。
num_groups 应该如何选择GroupNorm 最重要的超参数是 num_groups。它必须满足:
\[ C \bmod G = 0 \]
组数越小,每组包含的通道越多,更多通道会共享统计量;组数越大,每组包含的通道越少,归一化行为越接近 InstanceNorm。
经典 GroupNorm 工作中常用 32 组,但它不是对所有网络都固定最优的规则。当通道数较小时,应该选择能整除通道数、且不会让每组过小的组数。
例如:
都让每组包含 8 个通道。
因此,选择 num_groups 时,更值得关注的是每组包含多少通道,以及不同层的通道数是否能被组数整除。
在 CNN 中,GroupNorm 通常放在卷积层之后、激活函数之前:
由于 GroupNorm 自带可学习偏置,紧跟在它前面的卷积层通常可以设置:
原因和 Conv-BN 结构类似:卷积产生的常数偏置会在标准化时被减去,随后 GroupNorm 的 bias 可以完成平移。
但是,GroupNorm 通常不能像推理阶段的 BatchNorm 那样简单融合进卷积层。BatchNorm 在 eval() 模式下使用固定的 running statistics,因此整体是固定仿射变换;GroupNorm 在推理时仍然需要根据当前输入动态计算均值和方差,所以不能预先吸收到卷积权重中。
GroupNorm 最典型的使用场景是小 batch 的视觉任务,例如:
它的主要优点包括:
但 GroupNorm 也不是所有任务中的默认最优选择。
当 batch 足够大,并且训练和推理数据分布接近时,BatchNorm 往往仍然是 CNN 中非常有效的选择。而且 BatchNorm 使用跨样本统计,还会引入一定的随机扰动,这可能带来额外的正则化效果。此外,GroupNorm 需要人为选择 num_groups。不同层的通道数可能不同,因此必须保证每一层的通道数都能被组数整除。最后,GroupNorm 在推理时仍然需要计算当前输入的均值和方差,不能像 BatchNorm 那样进行简单的 Conv-BN Fusion。
这一节我们介绍了 Group Normalization。
对于输入 (N, C, H, W),GroupNorm 首先把通道划分为 G 组,将输入 reshape 为:
\[ \left(N, G, \frac{C}{G}, H, W \right) \]
然后固定样本和 group,沿组内通道以及空间维度计算均值和方差。
它最重要的特点是:
num_groups=1 时统计方式接近 LayerNorm;num_groups=C 时统计方式等价于 InstanceNorm;到这里,我们已经分别介绍了 BatchNorm、LayerNorm、InstanceNorm 和 GroupNorm。它们都先计算均值和方差,再完成中心化与尺度归一化。真正不同的是,哪些元素被放进同一组中共享统计量。
不过,并不是所有归一化方法都会同时使用均值和方差。在现代 Transformer 和大语言模型中,RMSNorm 也已经成为非常常见的选择。它不再减去均值,而是只根据特征的均方根控制隐藏向量的整体尺度。
下一节我们将介绍 Root Mean Square Normalization (Zhang and Sennrich 2019),并比较它与 LayerNorm 的联系和区别。之后再把 BatchNorm、LayerNorm、InstanceNorm、GroupNorm 和 RMSNorm 放进统一框架中,从“对哪些维度计算什么统计量”这个角度重新比较它们。