摘要
滑动窗口 是一种在序列或网格数据上提取局部子区域进行高效计算的核心技术。NumPy提供了 sliding_window_view 函数来创建滑动窗口视图,但其步幅固定为1。本文首先介绍内置函数的原理与应用,然后提出一个统一的 strided_sliding_window_view 函数,它使用单个 as_strided 调用同时处理窗口提取和步幅控制。我们将详细解释滑动轴和窗口轴的概念,并应用该函数重新实现深度学习中的卷积、池化和局部响应归一化等核心操作,展示其简洁性和高效性。
目录
- 1. NumPy内置的
sliding_window_view 函数说明 - 3. 统一滑动窗口视图:
strided_sliding_window_view 设计与实现
1. NumPy内置的 sliding_window_view 函数说明
NumPy从版本1.20.0开始,在 numpy.lib.stride_tricks 模块中提供了 sliding_window_view 函数。该函数用于在数组上创建滑动窗口视图,返回一个数组的视图,其中每个窗口都是原始数组的一个子数组。
函数签名:
numpy.lib.stride_tricks.sliding_window_view(x, window_shape, axis=None, *, subok=False, writeable=False)
参数说明:
| | |
|---|
x | | |
window_shape | | 滑动窗口的尺寸。 如果是整数,则被转换 为单元素元组。 |
axis | | 沿其应用窗口的轴。 如果为None,则应用 于所有轴,窗口形状 必须与轴数相同。 |
subok | | 如果为True,则子类 数组将被传递,否则 返回的数组将被强制 转换为基类数组。 |
writeable | | 如果为True,则允许 写入返回的视图(默 认为False,因为写 入视图可能产生意 想不到的效果)。 |
数学表达式:
设输入数组 的形状为 ,窗口形状为 ,其中 。如果指定了轴 ,则输出数组 的形状为:
输出元素 对应原始数组的元素:
示例:
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
# 一维数组滑动窗口
x = np.arange(10)
windows = sliding_window_view(x, 3)
# 二维数组滑动窗口
x2d = np.arange(12).reshape(3,4)
windows2d = sliding_window_view(x2d, (2,2))
注意:该函数返回的是视图,即共享内存,修改视图会影响原始数组,除非设置
writeable=True,但通常不建议修改,因为多个窗口可能重叠。
步幅的限制:内置的 sliding_window_view 函数每次滑动一个元素,即步幅固定为1。如果需要步幅大于1,则需要通过对结果进行切片来实现,例如 windows[::2]。但这样会导致两次索引操作,效率较低。
2. 滑动轴与窗口轴的概念解析
在滑动窗口操作中,有两个关键概念需要明确区分:滑动轴和窗口轴。
滑动轴 (Slide Axes):
滑动轴是指在原始数组上实际进行滑动操作的维度。这些轴决定了窗口在哪些维度上移动。例如,对于一个形状为 的图像,如果我们在高度和宽度维度上滑动,那么轴 就是滑动轴。
数学上,对于输入数组 ,我们选择 个轴 作为滑动轴。在每个滑动轴 上,窗口以固定步幅 滑动,窗口大小为 。
窗口轴 (Window Axes):
窗口轴是新增的维度,表示窗口内部的结构。每个窗口本身是一个 维数组,形状为 。这些维度在输出数组中作为新的轴添加。
输出数组的形状可以分解为两部分:
- 1. 外部索引部分:对应滑动窗口的起始位置,维度为 ,其中:
关系表达式:
设输出数组为 ,则 对应原始数组的元素:
其中 是滑动轴上的索引, 是窗口轴上的索引。
示例说明:
# 输入数组形状为 (4, 5),在轴(0,1)上滑动,窗口大小(2,2),步幅(2,2)
# 滑动轴:0, 1(原始数组的第0和第1维)
# 窗口轴:新增的第2和第3维
x = np.arange(20).reshape(4,5)
windows = strided_sliding_window_view(x, window_shape=(2,2), strides=(2,2))
print(windows.shape) # 输出: (2, 2, 2, 2)
# 解释:前两个维度(2,2)是滑动后的位置索引,后两个维度(2,2)是窗口内部结构
维度变换公式:
设原始数组维度为 ,选择 个滑动轴,则输出数组维度为:
其中前 个维度中,滑动轴的维度大小变为 ,非滑动轴保持不变;后 个维度为窗口轴。
3. 统一滑动窗口视图:strided_sliding_window_view 设计与实现
为了支持任意步幅并提高效率,我们设计一个统一的 strided_sliding_window_view 函数,它使用单个 as_strided 调用同时完成窗口提取和步幅控制。
设计思路:
通过调整输出数组的步幅,使得在滑动轴上的步幅乘以给定的步幅因子,从而实现在提取窗口的同时跳过指定数量的元素。
实现代码:
import numpy as np
from numpy.lib.stride_tricks import as_strided
def strided_sliding_window_view(x, window_shape, axis=None, strides=1):
"""
统一的滑动窗口视图,支持步幅控制
参数:
x: 输入数组
window_shape: 窗口形状,整数或元组
axis: 应用窗口的轴,None表示所有轴
strides: 步幅,整数或元组
返回:
滑动窗口视图
"""
x = np.asarray(x)
ndim = x.ndim
if axis is None:
axis = tuple(range(ndim))
elif isinstance(axis, int):
axis = (axis,)
if isinstance(window_shape, int):
window_shape = (window_shape,) * len(axis)
if isinstance(strides, int):
strides = (strides,) * len(axis)
if len(window_shape) != len(axis) or len(strides) != len(axis):
raise ValueError("参数长度不一致")
out_shape = list(x.shape)
for i, (ax, w, s) in enumerate(zip(axis, window_shape, strides)):
if out_shape[ax] < w:
raise ValueError(f"窗口大小{w}超过维度{ax}的大小{out_shape[ax]}")
out_shape[ax] = (out_shape[ax] - w) // s + 1
out_shape.insert(ndim + i, w)
out_strides = list(x.strides)
for i, (ax, w, s) in enumerate(zip(axis, window_shape, strides)):
out_strides[ax] = out_strides[ax] * s
out_strides.insert(ndim + i, out_strides[ax] // s)
return as_strided(x, shape=tuple(out_shape), strides=tuple(out_strides))
数学原理:
设原始数组 的步幅为 ,窗口形状为 ,应用于轴 ,步幅为 。
输出数组 的步幅计算如下:
输出形状为:
其中 。
示例:
# 对比内置函数和我们的函数
x = np.arange(20).reshape(4,5)
# 内置函数,步幅为1
windows_builtin = sliding_window_view(x, (2,2))
# 我们的函数,步幅为2
windows_strided = strided_sliding_window_view(x, window_shape=(2,2), strides=(2,2))
# 验证:我们的函数相当于先使用内置函数再切片
windows_sliced = sliding_window_view(x, (2,2))[::2, ::2]
np.array_equal(windows_strided, windows_sliced) # 应返回 True
优势:
- • 一步完成:将窗口提取和步幅控制融合为单个操作,避免了产生中间数组和二次索引。
- • 灵活步幅:直接控制步幅,适用于需要跳跃采样或降低计算密度的场景。
- • 内存高效:保持了视图的特性,无需复制数据,内存效率高。
- • 性能优化:减少了不必要的内存访问和临时数组创建,理论上能提升性能。
4. 滑动窗口在传统计算机视觉中的应用
滑动窗口在传统计算机视觉中广泛应用于特征提取和目标检测。早期方法如HOG(方向梯度直方图)和SIFT(尺度不变特征变换)都依赖于滑动窗口来提取局部特征。
3x3滑动窗口的示意图应用模式:
- 1. 图像滤波:滤波器核(如Sobel、Gaussian)在图像上滑动,计算每个窗口内的卷积结果,用于边缘检测或平滑。
- 2. 特征提取:在每个窗口内计算统计特征(如HOG的梯度方向直方图、局部纹理或颜色分布)。
- 3. 目标检测:在图像金字塔的不同尺度上,用固定大小的分类器窗口滑动,在每个位置提取特征并进行分类。
统一窗口实现优势:
使用 strided_sliding_window_view 可以直接且精细地控制特征提取的密度。通过调整步幅参数,可以在计算效率和特征分辨率之间取得平衡,例如在图像背景区域使用较大步幅以加速,在感兴趣区域使用较小步幅以获取更密集的特征。
5. 基于统一滑动窗口的深度学习核心算法实现
5.1 卷积操作
卷积是滑动窗口最典型的应用,每个卷积核在输入特征图上滑动并计算点积。使用统一的 strided_sliding_window_view 可以简洁地实现卷积操作。
3x3卷积示意图数学表达式:
设输入特征图 ,卷积核 ,步幅 ,填充 。
输出特征图 的每个元素计算为:
其中 是填充后的输入,,。
实现代码:
def conv2d_strided_window(X, K, stride=1, padding=0):
C_in, H, W = X.shape
C_out, C_in_, Kh, Kw = K.shape
assert C_in == C_in_
if isinstance(stride, int):
stride = (stride, stride)
sh, sw = stride
if isinstance(padding, int):
padding = (padding, padding)
ph, pw = padding
if ph > 0 or pw > 0:
X_padded = np.pad(X, ((0, 0), (ph, ph), (pw, pw)), mode='constant')
else:
X_padded = X
# 核心:一步获取带步幅的滑动窗口
windows = strided_sliding_window_view(
X_padded,
window_shape=(Kh, Kw),
axis=(1, 2),
strides=(sh, sw)
)
# windows形状: (C_in, H_out, W_out, Kh, Kw)
# 调整维度以进行广播乘加
K_expanded = K[:, :, np.newaxis, np.newaxis, :, :] # (C_out, C_in, 1, 1, Kh, Kw)
windows_expanded = windows[np.newaxis, ...] # (1, C_in, H_out, W_out, Kh, Kw)
# 执行卷积运算(点积并求和)
output = np.sum(K_expanded * windows_expanded, axis=(1, 4, 5))
# 对C_in, Kh, Kw维度求和,输出形状: (C_out, H_out, W_out)
return output
算法特点:
- 1. 一步窗口提取:
strided_sliding_window_view 直接生成具有指定步幅的窗口,避免了“先取所有窗口再切片”的额外开销。 - 2. 维度对齐:通过插入
np.newaxis 巧妙地扩展维度,使卷积核张量与窗口张量对齐,便于NumPy的广播机制进行计算。 - 3. 高效求和:利用
np.sum 的向量化能力,一次性对所有输入通道和空间窗口维度进行求和,得到最终输出特征图。
5.2 最大池化操作
最大池化是卷积神经网络中的下采样操作,在每个窗口内取最大值作为代表值,用以提供一定程度的平移不变性并减少参数数量。
数学表达式:
设输入特征图 ,池化窗口大小 ,步幅 ,填充 。
输出特征图 的每个元素计算为:
其中 是填充后的输入, 和 的计算公式与卷积相同。
实现代码:
def maxpool2d_strided_window(X, pool_size=2, stride=None, padding=0):
C, H, W = X.shape
if isinstance(pool_size, int):
pool_size = (pool_size, pool_size)
Kh, Kw = pool_size
if stride is None:
stride = pool_size # 常见情况:步幅等于池化窗口大小
elif isinstance(stride, int):
stride = (stride, stride)
sh, sw = stride
if isinstance(padding, int):
padding = (padding, padding)
ph, pw = padding
if ph > 0 or pw > 0:
X_padded = np.pad(X, ((0, 0), (ph, ph), (pw, pw)), mode='constant')
else:
X_padded = X
# 获取带步幅的滑动窗口
windows = strided_sliding_window_view(
X_padded,
window_shape=(Kh, Kw),
axis=(1, 2),
strides=(sh, sw)
)
# windows形状: (C, H_out, W_out, Kh, Kw)
# 在每个窗口内取最大值
output = np.max(windows, axis=(3, 4))
# 输出形状: (C, H_out, W_out)
return output
算法特性:
- 1. 灵活步幅:支持任意步幅。当步幅小于窗口大小时,实现的是重叠池化;等于窗口大小时,是非重叠池化。
- 2. 边界处理:支持填充操作,可以精确控制输出特征图的空间尺寸。
- 3. 完全向量化:
np.max 操作在整个窗口数组上执行,无需任何显式循环,效率极高。
5.3 局部响应归一化
局部响应归一化(LRN)对局部邻域内的通道进行归一化,旨在模仿生物神经元中的侧抑制机制,增强模型的泛化能力。虽然现代网络多使用批量归一化,但LRN是通道维度滑动窗口的典型应用。
LRN示意图,以深绿色方格为中心形成窗口数学表达式:
设输入特征图 ,LRN窗口大小为 ,参数 。
输出特征图 的每个元素计算为:
实现代码:
def lrn_strided_window(X, size=5, alpha=1e-4, beta=0.75, k=2.0):
C, H, W = X.shape
# 1. 计算每个位置通道值的平方
X_sq = X ** 2
# 2. 在通道维度两端填充0,以便边界通道也能有完整邻域
half_size = size // 2
pad_width = ((half_size, half_size), (0, 0), (0, 0))
X_sq_padded = np.pad(X_sq, pad_width, mode='constant')
# 3. 核心:在通道轴(axis=0)上创建滑动窗口,步幅为1
windows = strided_sliding_window_view(
X_sq_padded,
window_shape=size,
axis=0,
strides=1
)
# windows形状: (C, H, W, size)
# 4. 对每个窗口内的平方值求和(沿最后一个维度size)
scale_sum = np.sum(windows, axis=3) # 形状: (C, H, W)
# 5. 根据LRN公式计算归一化尺度并应用
scale = k + alpha * scale_sum
output = X / (scale ** beta)
return output
算法细节:
- 1. 通道维度滑动:与卷积/池化在空间维度滑动不同,LRN在通道轴(
axis=0)上应用滑动窗口。窗口大小 size 定义了参与归一化的邻近通道数。 - 2. 边界处理:通过在通道维度两端填充零,确保每个原始通道(包括边缘通道)都有一个由
size 个通道(部分可能是填充的0)组成的邻域,使求和公式在边界处也成立。 - 3. 归一化计算:严格遵循原始LRN公式,先计算局部邻域内通道值的平方和,再与参数 , , 结合,对原始激活值进行缩放。
6. 底层原理:as_strided 的步幅计算
as_strided 函数是滑动窗口视图的基石,它通过直接操作内存步幅来创建数组视图,不复制任何数据。理解其步幅计算是掌握该技术的关键。
步幅定义:
步幅是一个元组,表示为了沿数组的某个轴移动到下一个元素,需要在内存中跳过的字节数。对于形状为 的数组,其元素 的内存地址为:
其中 是第 轴的步幅,base 是数组起始地址。
统一滑动窗口的步幅推导:
设原始数组 的步幅为 。我们希望在轴 上应用窗口 和步幅
。
strided_sliding_window_view 输出数组 的步幅计算逻辑如下:
- 1. 对于滑动轴 :在 中沿此轴移动一个位置,对应于在原始数组 中跳过 个元素。因此,新步幅为原始步幅乘以步幅因子:。
- 2. 对于新增的窗口轴:在窗口内部移动一个元素,对应于在原始数组 的同一滑动轴上移动一个元素。因此,其步幅与原始步幅相同:。
- 3. 对于其他非滑动轴:这些轴不受滑动窗口操作影响,其步幅保持不变。
示例推导:
对于一个形状为 (4, 5) 的C顺序、float64 类型数组,其原始步幅为 (5*8=40, 8)。应用窗口 (2,2) 和步幅 (2,2) 后,输出数组的步幅计算为:
- • 滑动轴0(原第0维):新步幅 =
40 * 2 = 80 - • 滑动轴1(原第1维):新步幅 =
8 * 2 = 16 - • 窗口轴0(对应原第0维窗口内):步幅 =
40 - • 窗口轴1(对应原第1维窗口内):步幅 =
8
因此,最终输出步幅为:(80, 16, 40, 8)。
内存安全考量:
as_strided 是一个强大但危险的函数,它不进行任何边界检查。调用者必须自行确保:
- 2. 步幅和窗口参数的组合不会导致索引计算超出原始数组分配的内存边界。错误的参数可能引发访问违规,导致程序崩溃或读取到垃圾数据。
7. 在PyTorch中的实现考量
PyTorch提供了类似 as_strided 的功能,但因其动态计算图和GPU支持,需要额外的安全考量。
PyTorch实现:
import torch
def strided_sliding_window_view_torch(x, window_shape, axis=None, strides=1):
x = torch.as_tensor(x)
ndim = x.ndim
if axis is None:
axis = tuple(range(ndim))
elif isinstance(axis, int):
axis = (axis,)
if
isinstance(window_shape, int):
window_shape = (window_shape,) * len(axis)
if isinstance(strides, int):
strides = (strides,) * len(axis)
# 计算输出形状(逻辑与NumPy版本相同)
out_shape = list(x.shape)
for i, (ax, w, s) in enumerate(zip(axis, window_shape, strides)):
out_shape[ax] = (out_shape[ax] - w) // s + 1
out_shape.insert(ndim + i, w)
# 计算输出步幅(逻辑与NumPy版本相同)
out_strides = list(x.stride())
for i, (ax, w, s) in enumerate(zip(axis, window_shape, strides)):
out_strides[ax] = out_strides[ax] * s
out_strides.insert(ndim + i, out_strides[ax] // s)
return torch.as_strided(x, size=tuple(out_shape), stride=tuple(out_strides))
PyTorch特定考量:
- 1. 自动微分:PyTorch的
as_strided 生成的张量,如果其内存访问模式非常不规则,可能会破坏计算图,导致梯度计算错误或失败。对于需要求导的操作,需格外谨慎。 - 2. GPU内存访问:在GPU上,非法或未对齐的内存访问会导致运行时错误。PyTorch的检查可能比NumPy更严格。
- 3. 优化建议:对于生产代码,实现类似滑动窗口的操作,强烈推荐使用PyTorch内置的
torch.nn.functional.unfold 函数。unfold 操作有明确定义且优化的前向传播和反向传播,是安全且高效的选择。
安全建议:
- 1. 审慎使用:仅在确定需要且理解潜在风险时使用
torch.as_strided。 - 2. 优先使用安全抽象:尽可能使用
unfold、fold、Conv2d、MaxPool2d 等高级API。 - 3. 充分测试:如果必须使用
as_strided,务必进行充分测试,包括前向计算和梯度检查,确保其在CPU和GPU上的行为都符合预期。
8. 总结与性能分析
技术贡献:
- 1. 统一接口:
strided_sliding_window_view 函数提供了一个简洁统一的接口,将窗口提取和步幅控制融合为一个操作。 - 2. 高效实现:通过单次
as_strided 调用完成所有工作,避免了传统“先获取所有窗口再切片”方法中的中间表示和二次索引开销。 - 3. 算法清晰性:基于该统一函数实现的卷积、池化和LRN算法,代码更加紧凑,逻辑清晰,直观地揭示了这些操作与滑动窗口的本质联系。
性能对比:
| | | |
|---|
| 卷积 | | 单次内存视图创建 | |
| 池化 | | 单次内存视图创建 | |
| LRN | | 完全向量化通道滑动 | |
内存效率:
两种基于as_strided的方法都创建视图而非副本,因此基础内存占用相同。然而,统一方法 (strided_sliding_window_view) 的显著优势在于避免了“提取+切片”过程中可能产生的临时数组。如果后续操作触发了拷贝,统一方法也只需要拷贝最终结果一次,而非中间状态的临时数组。
应用建议:
- 1. 研究与原型开发:
strided_sliding_window_view 是快速实现和验证自定义滑动窗口类算法的理想工具。 - 2. 教学与理解:该函数及其应用实例是深入理解滑动窗口机制、卷积网络底层原理以及NumPy/PyTorch步幅技巧的绝佳材料。
- 3. 性能关键场景:在对NumPy操作有极致性能要求的特定场景中,使用该统一视图可能带来微小的性能提升。
局限性与注意事项:
- 1. 填充需外部处理:函数本身不处理填充,需要在调用前使用
np.pad 等函数完成,这增加了一些调用复杂度。 - 2. 无内置边界检查:与
as_strided 一样,需要调用者确保窗口和步幅参数的合法性,以防内存越界访问。 - 3. 框架差异:虽然概念相通,但在PyTorch中使用需要特别注意计算图和GPU内存安全的问题。
未来扩展方向:
- 1. 支持膨胀卷积:可通过引入“膨胀率”参数,修改步幅计算公式来支持膨胀卷积(空洞卷积)。
- 2. 支持分组卷积:扩展函数以更自然地处理分组卷积中通道维度的分组滑动。
- 3. 更复杂的窗口模式:支持非矩形窗口或跨维度的关联滑动等高级模式。
结论:
本文提出的 strided_sliding_window_view 函数,通过精妙的步幅计算,将滑动窗口提取与步幅控制优雅地统一在单个 as_strided
调用中。这一设计不仅提升了代码的简洁性与执行效率,也为我们理解计算机视觉和深度学习中诸多核心操作(卷积、池化、归一化)提供了统一的“滑动窗口”视角。它充分展示了底层数组步幅操作在现代数值计算中的强大能力。然而,必须清醒认识到,在追求灵活性与性能的同时,也带来了更大的风险(如内存安全)。因此,在实际的深度学习项目或生产环境中,除非有充分的理由和把握,否则应优先使用PyTorch、TensorFlow等框架提供的经过充分优化和测试的高级API(如 nn.Conv2d, nn.MaxPool2d, F.unfold)。本工作更适用于算法研究、教学演示以及对性能有极端要求的特定自定义操作的实现场景。