2019-05-30
普通卷积大家都熟:卷积核在图像上滑动,每次取一个局部窗口,和卷积核做对应位置相乘再求和。 `CosineConv2D` 换了一个思路:不要只看点乘结果有多大,而是看输入局部窗口和卷积核方向有多像。
普通卷积可以理解成局部点乘。
假设输入图像上取出一个局部窗口 x,卷积核是 w。把它们都摊平成向量后,普通卷积输出就是:
output = w · x
也就是:
output = w1x1 + w2x2 + ... + wnxn
这个过程非常有效,所以 CNN 能提取边缘、纹理、局部形状等特征。
但点乘有一个特点:它会同时受到两个因素影响。
方向是否相似
向量长度有多大
如果输入值整体放大,点乘结果也会被放大。卷积核权重变大,输出也会变大。
所以普通卷积输出没有天然边界,数值尺度可能变得很敏感。
余弦相似度公式是:
cos(w, x) = (w · x) / (||w|| × ||x||)
其中:
w · x 是点积||w|| 是卷积核向量长度||x|| 是输入窗口向量长度普通卷积只算分子:
w · x
余弦卷积则把点积除以两个向量的模长:
output = (w · x) / (||w|| × ||x|| + ε)
这里的 ε 是一个很小的数,用来避免除零。
余弦卷积的重点不是“数值有多大”,而是“方向有多像”。
从几何角度看,两个向量的余弦值表示它们夹角的余弦。
当两个向量方向接近时:
cos θ 接近 1
当两个向量方向差很多时:
cos θ 接近 0 或更低
这放到卷积里就很好理解:
输入局部窗口越像卷积核代表的模式,输出越大
普通卷积也有这个味道,但它还强烈受向量长度影响。
余弦卷积则更强调模式相似度。
普通卷积的输出没有固定范围。
如果输入窗口和卷积核的数值都变大,点乘结果也可能变得很大。
余弦相似度经过归一化,理论范围是:
[-1, 1]
如果后面接 ReLU 或其他非负激活,输出也可能进一步落到非负范围。
这带来几个直观好处:
原文里提到它能减小“协变量跃迁”这类现象,可以理解为:层与层之间传递的数据分布变化不会像未归一化点乘那样猛烈。
它和 BatchNorm 的目标有点像,都是让网络内部的数据更稳。但两者做法不同:
BatchNorm:对 batch 统计量做归一化
CosineConv2D:对每个局部窗口和卷积核做方向归一
CosineConv2D 的核心流程可以拆成几步:
对每个滑动窗口:
1. 取输入局部窗口 x
2. 取卷积核 w
3. 计算点积 w · x
4. 计算 ||w|| 和 ||x||
5. 用点积除以模长乘积
最终输出就是每个位置的余弦相似度。
先写一个非常小的版本,只处理单通道、单卷积核,方便看清楚原理。
import numpy as np
def cosine_conv2d_single_channel(x, kernel, eps=1e-8):
h, w = x.shape
kh, kw = kernel.shape
out_h = h - kh + 1
out_w = w - kw + 1
out = np.zeros((out_h, out_w), dtype=np.float32)
kernel_vec = kernel.reshape(-1)
kernel_norm = np.linalg.norm(kernel_vec)
for i in range(out_h):
for j in range(out_w):
patch = x[i:i + kh, j:j + kw]
patch_vec = patch.reshape(-1)
numerator = np.dot(patch_vec, kernel_vec)
denominator = np.linalg.norm(patch_vec) * kernel_norm + eps
out[i, j] = numerator / denominator
return out
x = np.array([
[1, 2, 1, 0],
[0, 1, 2, 1],
[1, 0, 1, 2],
[2, 1, 0, 1],
], dtype=np.float32)
kernel = np.array([
[1, 0],
[0, 1],
], dtype=np.float32)
print(cosine_conv2d_single_channel(x, kernel))
这个版本很慢,但思路透明。
真正训练模型时,不能用 Python 循环硬跑,需要用深度学习框架的张量操作。
在 PyTorch 中,可以用 unfold 把输入图像展开成滑动窗口,再和卷积核做余弦相似度。
import torch
import torch.nn as nn
import torch.nn.functional as F
class CosineConv2d(nn.Module):
def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0, eps=1e-8):
super().__init__()
if isinstance(kernel_size, int):
kernel_size = (kernel_size, kernel_size)
self.in_channels = in_channels
self.out_channels = out_channels
self.kernel_size = kernel_size
self.stride = stride
self.padding = padding
self.eps = eps
kh, kw = kernel_size
weight = torch.randn(out_channels, in_channels, kh, kw) * 0.02
self.weight = nn.Parameter(weight)
def forward(self, x):
batch, channels, height, width = x.shape
kh, kw = self.kernel_size
patches = F.unfold(
x,
kernel_size=self.kernel_size,
padding=self.padding,
stride=self.stride
)
# patches: batch x (C*kh*kw) x L
weight = self.weight.view(self.out_channels, -1)
# weight: out_channels x (C*kh*kw)
numerator = torch.einsum("oc,bcl->bol", weight, patches)
weight_norm = torch.norm(weight, dim=1).view(1, self.out_channels, 1)
patch_norm = torch.norm(patches, dim=1).unsqueeze(1)
out = numerator / (weight_norm * patch_norm + self.eps)
out_h = (height + 2 * self.padding - kh) // self.stride + 1
out_w = (width + 2 * self.padding - kw) // self.stride + 1
return out.view(batch, self.out_channels, out_h, out_w)
简单测试:
layer = CosineConv2d(
in_channels=3,
out_channels=16,
kernel_size=3,
padding=1
)
x = torch.randn(8, 3, 32, 32)
y = layer(x)
print(y.shape)
print(y.min().item(), y.max().item())
输出形状应该是:
torch.Size([8, 16, 32, 32])
数值通常会落在余弦相似度范围附近。
普通卷积:
conv = nn.Conv2d(3, 16, kernel_size=3, padding=1)
余弦卷积:
cos_conv = CosineConv2d(3, 16, kernel_size=3, padding=1)
你可以做一个简单对比:
x = torch.randn(8, 3, 32, 32) * 10
conv_out = conv(x)
cos_out = cos_conv(x)
print("Conv2d:", conv_out.mean().item(), conv_out.std().item())
print("CosineConv2d:", cos_out.mean().item(), cos_out.std().item())
当输入整体尺度被放大时,普通卷积输出通常也会被明显放大。
CosineConv2D 因为除以了输入窗口模长,对这种整体放大不那么敏感。
Keras 里也可以自定义一个层。下面是思路版实现,用 TensorFlow 的 extract_patches 提取滑动窗口。
import tensorflow as tf
from tensorflow.keras import layers
class CosineConv2D(layers.Layer):
def __init__(self, filters, kernel_size, strides=1, padding="SAME", eps=1e-8):
super().__init__()
if isinstance(kernel_size, int):
kernel_size = (kernel_size, kernel_size)
self.filters = filters
self.kernel_size = kernel_size
self.strides = strides
self.padding = padding
self.eps = eps
def build(self, input_shape):
in_channels = int(input_shape[-1])
kh, kw = self.kernel_size
self.kernel = self.add_weight(
name="kernel",
shape=(kh, kw, in_channels, self.filters),
initializer="glorot_uniform",
trainable=True
)
def call(self, inputs):
kh, kw = self.kernel_size
patches = tf.image.extract_patches(
images=inputs,
sizes=[1, kh, kw, 1],
strides=[1, self.strides, self.strides, 1],
rates=[1, 1, 1, 1],
padding=self.padding
)
patch_dim = kh * kw * int(inputs.shape[-1])
patches = tf.reshape(patches, [tf.shape(inputs)[0], tf.shape(patches)[1], tf.shape(patches)[2], patch_dim])
kernel = tf.reshape(self.kernel, [patch_dim, self.filters])
numerator = tf.einsum("bhwd,df->bhwf", patches, kernel)
patch_norm = tf.norm(patches, axis=-1, keepdims=True)
kernel_norm = tf.norm(kernel, axis=0)
kernel_norm = tf.reshape(kernel_norm, [1, 1, 1, self.filters])
return numerator / (patch_norm * kernel_norm + self.eps)
使用方式:
inputs = tf.keras.Input(shape=(32, 32, 3))
x = CosineConv2D(32, 3, padding="SAME")(inputs)
x = layers.ReLU()(x)
x = layers.GlobalAveragePooling2D()(x)
outputs = layers.Dense(10, activation="softmax")(x)
model = tf.keras.Model(inputs, outputs)
model.summary()
这个实现偏教学用途。如果要放到生产训练中,还要继续优化性能和边界行为。
这个问题不能简单说一定可以。
CosineConv2D 的优势很明确:
但它也有代价:
普通卷积里的“大小”有时也是有意义的。如果任务依赖强度、亮度或响应幅值,过度归一化可能会丢掉一些有用信息。
所以更稳妥的态度是:
CosineConv2D 是一种很有意思的替代模块,但是否更好要看任务和实验结果。
原文里提到,余弦卷积收缩输出范围、减小方差,这一点和 BN 有些相似。
但两者不是一回事。
BatchNorm 做的是:
对 batch 维度上的统计分布做归一化
CosineConv2D 做的是:
对每个局部窗口和卷积核的向量长度做归一化
一个偏数据分布稳定,一个偏局部相似度计算。
两者可以互相启发,但不能简单等同。
如果想验证它的效果,可以设计一个小实验:
同一个 CNN 结构
一组使用 Conv2d
一组使用 CosineConv2d
保持训练设置一致
比较收敛速度、验证集准确率、输出分布
观察几个指标:
比如在 PyTorch 中查看输出分布:
with torch.no_grad():
x = torch.randn(16, 3, 32, 32) * 5
y = cos_conv(x)
print(y.mean().item())
print(y.std().item())
print(y.min().item(), y.max().item())
如果和普通卷积对比,你会更直观看到归一化带来的数值差异。
普通卷积做的是:
output = w · x
CosineConv2D 做的是:
output = (w · x) / (||w|| × ||x|| + ε)
普通卷积关注点乘大小,余弦卷积更关注方向相似度。
这让它对输入和权重的尺度变化更不敏感,输出范围也更稳定。它的思路很干净:把卷积核看成一个模式模板,把输入窗口看成一个局部向量,然后问一句:
你们俩像不像?
不过它不是万能替代品。归一化会带来额外计算,也可能弱化某些任务需要的幅值信息。
如果你已经熟悉普通卷积,CosineConv2D 是一个很值得理解的扩展:它让卷积从“加权求和”多了一层“相似度匹配”的视角。