码到成功

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

## 普通卷积在算什么

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

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

```text
output = w · x
```

也就是:

```text
output = w1x1 + w2x2 + ... + wnxn
```

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

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

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

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

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

## CosineConv2D 的基本想法

余弦相似度公式是:

```text
cos(w, x) = (w · x) / (||w|| × ||x||)
```

其中:

- `w · x` 是点积
- `||w||` 是卷积核向量长度
- `||x||` 是输入窗口向量长度

普通卷积只算分子:

```text
w · x
```

余弦卷积则把点积除以两个向量的模长:

```text
output = (w · x) / (||w|| × ||x|| + ε)
```

这里的 `ε` 是一个很小的数,用来避免除零。

![普通卷积与余弦卷积](https://zoytown-sg.oss-ap-southeast-1.aliyuncs.com/blogs/cosine_conv_vs_conv.svg)

余弦卷积的重点不是“数值有多大”,而是“方向有多像”。

## 几何直觉

从几何角度看,两个向量的余弦值表示它们夹角的余弦。

![余弦卷积几何直觉](https://zoytown-sg.oss-ap-southeast-1.aliyuncs.com/blogs/cosine_conv_geometry.svg)

当两个向量方向接近时:

```text
cos θ 接近 1
```

当两个向量方向差很多时:

```text
cos θ 接近 0 或更低
```

这放到卷积里就很好理解:

```text
输入局部窗口越像卷积核代表的模式,输出越大
```

普通卷积也有这个味道,但它还强烈受向量长度影响。

余弦卷积则更强调模式相似度。

## 为什么说它能稳定输出

普通卷积的输出没有固定范围。

如果输入窗口和卷积核的数值都变大,点乘结果也可能变得很大。

余弦相似度经过归一化,理论范围是:

```text
[-1, 1]
```

如果后面接 ReLU 或其他非负激活,输出也可能进一步落到非负范围。

这带来几个直观好处:

- 输出尺度更可控
- 对输入幅值变化更不敏感
- 可以缓解数值过大带来的不稳定
- 在某些场景下有类似归一化的味道

原文里提到它能减小“协变量跃迁”这类现象,可以理解为:层与层之间传递的数据分布变化不会像未归一化点乘那样猛烈。

它和 BatchNorm 的目标有点像,都是让网络内部的数据更稳。但两者做法不同:

```text
BatchNorm:对 batch 统计量做归一化
CosineConv2D:对每个局部窗口和卷积核做方向归一
```

## 计算流程

CosineConv2D 的核心流程可以拆成几步:

![CosineConv2D 计算流程](https://zoytown-sg.oss-ap-southeast-1.aliyuncs.com/blogs/cosine_conv_pipeline.svg)

对每个滑动窗口:

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

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

## NumPy 手写一个局部余弦卷积

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

```python
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` 把输入图像展开成滑动窗口,再和卷积核做余弦相似度。

```python
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)
```

简单测试:

```python
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())
```

输出形状应该是:

```text
torch.Size([8, 16, 32, 32])
```

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

## 和普通 Conv2d 放在一起对比

普通卷积:

```python
conv = nn.Conv2d(3, 16, kernel_size=3, padding=1)
```

余弦卷积:

```python
cos_conv = CosineConv2d(3, 16, kernel_size=3, padding=1)
```

你可以做一个简单对比:

```python
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` 提取滑动窗口。

```python
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)
```

使用方式:

```python
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 的优势很明确:

- 输出范围更稳定
- 对输入幅值不那么敏感
- 更强调模式方向相似度
- 有助于缓解数值尺度过大的问题

但它也有代价:

- 计算中多了模长归一化
- 实现复杂度比普通卷积高
- 某些任务可能需要幅值信息
- 未必在所有数据集上都更好

普通卷积里的“大小”有时也是有意义的。如果任务依赖强度、亮度或响应幅值,过度归一化可能会丢掉一些有用信息。

所以更稳妥的态度是:

```text
CosineConv2D 是一种很有意思的替代模块,但是否更好要看任务和实验结果。
```

## 和 BatchNorm 的关系

原文里提到,余弦卷积收缩输出范围、减小方差,这一点和 BN 有些相似。

但两者不是一回事。

BatchNorm 做的是:

```text
对 batch 维度上的统计分布做归一化
```

CosineConv2D 做的是:

```text
对每个局部窗口和卷积核的向量长度做归一化
```

一个偏数据分布稳定,一个偏局部相似度计算。

两者可以互相启发,但不能简单等同。

## 一个小实验思路

如果想验证它的效果,可以设计一个小实验:

```text
同一个 CNN 结构
一组使用 Conv2d
一组使用 CosineConv2d
保持训练设置一致
比较收敛速度、验证集准确率、输出分布
```

观察几个指标:

- 每层输出均值和方差
- 梯度范数
- 训练损失曲线
- 验证集表现
- 对输入尺度变化的敏感性

比如在 PyTorch 中查看输出分布:

```python
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())
```

如果和普通卷积对比,你会更直观看到归一化带来的数值差异。


普通卷积做的是:

```text
output = w · x
```

CosineConv2D 做的是:

```text
output = (w · x) / (||w|| × ||x|| + ε)
```

普通卷积关注点乘大小,余弦卷积更关注方向相似度。

这让它对输入和权重的尺度变化更不敏感,输出范围也更稳定。它的思路很干净:把卷积核看成一个模式模板,把输入窗口看成一个局部向量,然后问一句:

你们俩像不像?

不过它不是万能替代品。归一化会带来额外计算,也可能弱化某些任务需要的幅值信息。

如果你已经熟悉普通卷积,CosineConv2D 是一个很值得理解的扩展:它让卷积从“加权求和”多了一层“相似度匹配”的视角。