CosineConv2D:用余弦相似度重新理解卷积




2019-05-30

blog_main_img

普通卷积大家都熟:卷积核在图像上滑动,每次取一个局部窗口,和卷积核做对应位置相乘再求和。 `CosineConv2D` 换了一个思路:不要只看点乘结果有多大,而是看输入局部窗口和卷积核方向有多像。

普通卷积在算什么

普通卷积可以理解成局部点乘。

假设输入图像上取出一个局部窗口 x,卷积核是 w。把它们都摊平成向量后,普通卷积输出就是:

output = w · x

也就是:

output = w1x1 + w2x2 + ... + wnxn

这个过程非常有效,所以 CNN 能提取边缘、纹理、局部形状等特征。

但点乘有一个特点:它会同时受到两个因素影响。

方向是否相似
向量长度有多大

如果输入值整体放大,点乘结果也会被放大。卷积核权重变大,输出也会变大。

所以普通卷积输出没有天然边界,数值尺度可能变得很敏感。

CosineConv2D 的基本想法

余弦相似度公式是:

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 的核心流程可以拆成几步:

CosineConv2D 计算流程

对每个滑动窗口:

1. 取输入局部窗口 x
2. 取卷积核 w
3. 计算点积 w · x
4. 计算 ||w|| 和 ||x||
5. 用点积除以模长乘积

最终输出就是每个位置的余弦相似度。

NumPy 手写一个局部余弦卷积

先写一个非常小的版本,只处理单通道、单卷积核,方便看清楚原理。

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 版 CosineConv2D

在 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])

数值通常会落在余弦相似度范围附近。

和普通 Conv2d 放在一起对比

普通卷积:

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 里怎么写

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 是一种很有意思的替代模块,但是否更好要看任务和实验结果。

和 BatchNorm 的关系

原文里提到,余弦卷积收缩输出范围、减小方差,这一点和 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 是一个很值得理解的扩展:它让卷积从“加权求和”多了一层“相似度匹配”的视角。