对比学习:MoCo代码详解

本文代码来源:

  • https://colab.research.google.com/github/facebookresearch/moco/blob/colab-notebook/colab/moco_cifar10_demo.ipynb#scrollTo=C69VJZiNxgzn

本文记录我学习代码的一般过程,共勉!

1.导入包

from datetime import datetime
from functools import partial
from PIL import Image
from torch.utils.data import DataLoader
from torchvision import transforms
from torchvision.datasets import CIFAR10
from torchvision.models import resnet
from tqdm import tqdm
import argparse
import json
import math
import os
import pandas as pd
import torch
import torch.nn as nn
import torch.nn.functional as F
from model.MoCo import ModelMoCo

没有什么好说的

2.参数设置

在这里插入图片描述
打印参数就是这个样子

Namespace(arch='resnet18', batch_size=512, bn_splits=8, cos=True, epochs=1, knn_k=200, knn_t=0.1, lr=0.06, moco_dim=128, moco_k=4096, moco_m=0.99, moco_t=0.1, results_dir='./cache-2022-06-12-18-41-15-moco', resume='', schedule=[], symmetric=False, wd=0.0005)

这里有两点需要注意:第一个圈设置权重的保存路径,如果不设置就会用它设置的保存路径,第二个圈是这个是在cmd 的黑框中运行还是在jupyter中运行,可自行测试。
说明,本文中很多维度信息,都可以参考这里的参数和自己数据集的规模。比如这里数据集用的是cifar 10,batch size 为512。那么看到50000就是指数据规模,看到512就是batch size 大小。

3.数据预处理

class CIFAR10Pair(CIFAR10):
    """CIFAR10 Dataset.
    """
    def __getitem__(self, index):
        img = self.data[index]
        img = Image.fromarray(img)

        if self.transform != None:
            im_1 = self.transform(img)
            im_2 = self.transform(img)

        return im_1, im_2

train_transform = transforms.Compose([
    transforms.RandomResizedCrop(32),
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.RandomApply([transforms.ColorJitter(0.4, 0.4, 0.4, 0.1)], p=0.8),
    transforms.RandomGrayscale(p=0.2),
    transforms.ToTensor(),
    transforms.Normalize([0.4914, 0.4822, 0.4465], [0.2023, 0.1994, 0.2010])])

test_transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize([0.4914, 0.4822, 0.4465], [0.2023, 0.1994, 0.2010])])

# data prepare
train_data = CIFAR10Pair(root='E:\HSI_Classification\datasets\cifar', train=True, transform=train_transform, download=True)
train_loader = DataLoader(train_data, batch_size=args.batch_size, shuffle=True, num_workers=0, pin_memory=True, drop_last=True)

# memory_data 的作用?
memory_data = CIFAR10(root='E:\HSI_Classification\datasets\cifar', train=True, transform=test_transform, download=True)
memory_loader = DataLoader(memory_data, batch_size=args.batch_size, shuffle=False, num_workers=0, pin_memory=True)

test_data = CIFAR10(root='E:\HSI_Classification\datasets\cifar', train=False, transform=test_transform, download=True)
test_loader = DataLoader(test_data, batch_size=args.batch_size, shuffle=False, num_workers=0, pin_memory=True)

这里有三点需要注意:

  • 第一这里CIFAR10Pair 就是构造一个样本对。输出的 target 就是输入。所谓对比学习,无监督学习的精髓。
  • 第二这里的train_loader, memory_loader 和 test_loader。 train和 memory 都是训练集的数据,他们的不同之处在于, 数据增广的方式不同,数据的组成也不同。 train的增广是用来训练的,标签就是图像本身,memory是用来测试的,它和test是一致的,标签就是标注的标签。memory主要用于在测试的时候,如果用到 knn,则可以构造memory bank 监控学习精度,test在测试的时候评估模型精度
  • 第三这里可以改成自己的数据集。照猫画虎我的数据集:
TrainPair:
train shape:  (10249, 33, 33, 3)

# 数据增强
train_transform = transforms.Compose([
    transforms.RandomResizedCrop(32),
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.RandomApply([transforms.ColorJitter(0.4, 0.4, 0.4, 0.1)], p=0.8),
    transforms.RandomGrayscale(p=0.2),
    transforms.ToTensor(),
    transforms.Normalize([0.4914, 0.4822, 0.4465], [0.2023, 0.1994, 0.2010])])

test_transform = transforms.Compose([
    transforms.RandomResizedCrop(32),
    transforms.ToTensor(),
    transforms.Normalize([0.4914, 0.4822, 0.4465], [0.2023, 0.1994, 0.2010])])

""" Training dataset"""
class TrainPair(torch.utils.data.Dataset):
    def __init__(self, transform=None):
        self.transform = transform
        self.len = train.shape[0]
        self.data = train
        self.classes = np.max(train_label)

    def __getitem__(self, index):
        img = self.data[index]
        img = Image.fromarray(np.uint8(img))

        if self.transform != None:
            im_1 = self.transform(img)
            im_2 = self.transform(img)
        return im_1, im_2

    def __len__(self):
        # 返回文件数据的数目
        return self.len

""" memory dataset"""
class TrainDS(torch.utils.data.Dataset):
    def __init__(self, transform=None):
        self.transform = transform
        self.len = train.shape[0]
        self.data = train
        self.targets = train_label
        self.classes = np.max(train_label)
        
    def __getitem__(self, index):
        img = self.data[index]
        img = Image.fromarray(np.uint8(img))
        img = self.transform(img)

        target = self.targets[index]

        # 根据索引返回数据和对应的标签
        return img, target

    def __len__(self):
        # 返回文件数据的数目
        return self.len

""" Test dataset"""
class TestDS(torch.utils.data.Dataset):
    def __init__(self, transform=None):
        self.transform = transform
        self.len = test.shape[0]
        self.data = test
        self.targets = test_label
        self.classes = np.max(train_label)
        
    def __getitem__(self, index):
        img = self.data[index]
        img = Image.fromarray(np.uint8(img))
        img = self.transform(img)
        
        target = self.targets[index]

        # 根据索引返回数据和对应的标签
        return img, target
        
    def __len__(self):
        # 返回文件数据的数目
        return self.len
 
# # 创建 trainloader 和 testloader
train_data = TrainPair(transform=train_transform)
memory_data = TrainDS(transform=test_transform)
test_data  = TestDS(transform=test_transform)
train_loader =  torch.utils.data.DataLoader(dataset=train_data, batch_size=args.batch_size, shuffle=True, num_workers=0, pin_memory=True, drop_last=True)
memory_loader = torch.utils.data.DataLoader(dataset=memory_data, batch_size=args.batch_size, shuffle=False, num_workers=0, pin_memory=True, drop_last=True)
test_loader  = torch.utils.data.DataLoader(dataset=test_data,  batch_size=args.batch_size, shuffle=False, num_workers=0, pin_memory=True, drop_last=True)

4. 模型

# create model
model = ModelMoCo(
        dim=args.moco_dim,
        K=args.moco_k,
        m=args.moco_m,
        T=args.moco_t,
        arch=args.arch,
        bn_splits=args.bn_splits,
        symmetric=args.symmetric,
    ).cuda()
# print(model.encoder_q)

模型的理解是重点但是不是难点,因为对比学习的重思想,而不是模型的复杂程度。下面是训练过程的数据流图
请添加图片描述
有四个点需要关注一下:

4.1moment update key encoder

就是每次更新的的时候用上一部分权重的α 加上另一个分支的权重(1-α)。α 是自己设置的比例,即 moco_m。第二个是deqeue_and_enqeue。我模拟了一下这个过程。

4.2进队出队

做了一个小例子

import torch

# 输入
K = 12
dim = 2
queue = torch.rand(dim, K)
print(queue)

ptr = 3
batch_size = 3
keys = torch.randn(batch_size, dim)   # shape : batch, dim
print(keys)

------------------------
tensor([[0.4031, 0.8880, 0.2820, 0.4275, 0.2731, 0.0415, 0.8560, 0.7286, 0.4262,
         0.5516, 0.7505, 0.9933],
        [0.6802, 0.3471, 0.5023, 0.6566, 0.1310, 0.8469, 0.0206, 0.3822, 0.8273,
         0.7649, 0.0019, 0.9808]])
tensor([[-0.7294,  0.0370],
        [ 0.0453,  1.8152],
        [-0.0329,  0.7642]])
------------------------

# replace the keys at ptr (dequeue and enqueue)
queue[:, ptr:ptr + batch_size] = keys.t()   # transpose
print("queue", queue.shape)               # [128, 4096]
print(queue)

------------------------
queue torch.Size([2, 12])
tensor([[ 0.4031,  0.8880,  0.2820, **-0.7294,  0.0453, -0.0329**,  0.8560,  0.7286,
          0.4262,  0.5516,  0.7505,  0.9933],
        [ 0.6802,  0.3471,  0.5023,  **0.0370,  1.8152,  0.7642**,  0.0206,  0.3822,
          0.8273,  0.7649,  0.0019,  0.9808]])
------------------------

for i in range(10):
    ptr = (ptr + batch_size) % K              # move pointer
    print(ptr)

------------------------
6
9
0
3
---
------------------------

如上面那个小例子,随机初始化之后,每次更新队列中的一个batch的值,代码中用双星表示。这样就扩充了负样本。

4.3shuffle

在为GPU分配数据之前先将数据进行打乱在进行分配。论文中提出仅对于关键字的网络的输入进行shuffle。不是很重要,有兴趣的可以看一看。我看很帖子说BN层不好,少用,这个shuffle 也是为了克服bn层泄露样本的。

4.4损失计算

损失计算中作者把正样本计算的值放在了第一个位置,即

logits = torch.cat([l_pos, l_neg], dim=1)

那么,根据(带温度的)交叉熵的计算规则,我们只需要取第一个值的位置就行了,而不用管其它位置的值,所以直接可以把所有标签设置为0。这里我理解了好久,看了不少帖子,感觉算是理解了。最重要的一点就是,这里不是标签全设置成了0,而是因为标签正样本的位置在0位置,所以这里是全零的矩阵。具体如果有必要我放在下篇博客里吧,这里太多了。在这里举一个小例子。假设预测pred = tensor.size(4,6).常规做法,标签是随机分布对,即target = [0,1,5,3],正样本的位置在0,1,5,3。
损失为:

-(log_result[0][0] + log_result[1][1] + log_result[2][5]+ log_result[3][3])/4

如果我们把正样本都放在零位置,那么target = [0,0,0,0]:

-(log_result[0][0] + log_result[1][0] + log_result[2][0]+ log_result[3][0])/4

这个例子如此优秀,我感觉我不用写 loss 函数的了。

5.训练

# train for one epoch
def train(net, data_loader, train_optimizer, epoch, args):
    net.train()
    adjust_learning_rate(optimizer, epoch, args)

    total_loss, total_num, train_bar = 0.0, 0, tqdm(data_loader)
    for im_1, im_2 in train_bar:
        im_1, im_2 = im_1.cuda(non_blocking=True), im_2.cuda(non_blocking=True)

        loss = net(im_1, im_2)
        
        train_optimizer.zero_grad()
        loss.backward()
        train_optimizer.step()

        total_num += data_loader.batch_size
        total_loss += loss.item() * data_loader.batch_size
        train_bar.set_description('Train Epoch: [{}/{}], lr: {:.6f}, Loss: {:.4f}'.format(epoch, args.epochs, optimizer.param_groups[0]['lr'], total_loss / total_num))

    return total_loss / total_num

# lr scheduler for training
def adjust_learning_rate(optimizer, epoch, args):
    """Decay the learning rate based on schedule"""
    lr = args.lr
    if args.cos:  # cosine lr schedule
        lr *= 0.5 * (1. + math.cos(math.pi * epoch / args.epochs))
    else:  # stepwise lr schedule
        for milestone in args.schedule:
            lr *= 0.1 if epoch >= milestone else 1.
    for param_group in optimizer.param_groups:
        param_group['lr'] = lr

常规训练,没有什么好说的。

6.测试

# test using a knn monitor
def test(net, memory_data_loader, test_data_loader, epoch, args):
    net.eval()
    classes = len(memory_data_loader.dataset.classes)
    # print("classes", classes)                             # 10

    total_top1, total_top5, total_num, feature_bank = 0.0, 0.0, 0, []

    with torch.no_grad():
        # generate feature bank
        for data, target in tqdm(memory_data_loader, desc='Feature extracting'):
            # print("data1", data.shape, "target1", target.shape)

            feature = net(data.cuda(non_blocking=True))
            feature = F.normalize(feature, dim=1)
            # print("feature1", feature.shape)

            feature_bank.append(feature)
            # print("feature_bank1", feature_bank.shape)
        # [D, N]
        feature_bank = torch.cat(feature_bank, dim=0).t().contiguous()
        # print("feature_bank2", feature_bank.shape)         # 512,50000

        # [N]
        feature_labels = torch.tensor(memory_data_loader.dataset.targets, device=feature_bank.device)
        # print("feature_labels", feature_labels.shape)      # 50000

        # loop test data to predict the label by weighted knn search
        test_bar = tqdm(test_data_loader)
        for data, target in test_bar:
            data, target = data.cuda(non_blocking=True), target.cuda(non_blocking=True)
            # print("data2", data.shape, "target2", target.shape)   # 512.3,32,32, 512

            feature = net(data)
            feature = F.normalize(feature, dim=1)
            # print("feature2", feature.shape)           # 512,128
            
            pred_labels = knn_predict(feature, feature_bank, feature_labels, classes, args.knn_k, args.knn_t)
            # print("pred_labels", pred_labels.shape)    # 512*10

            total_num += data.size(0)
            # print("total_num", total_num)              # 512

            total_top1 += (pred_labels[:, 0] == target).float().sum().item()
            test_bar.set_description('Test Epoch: [{}/{}] Acc@1:{:.2f}%'.format(epoch, args.epochs, total_top1 / total_num * 100))

    return total_top1 / total_num * 100

这里值得说。我自认为这是个难点。有两种精度的测试模式,一个是利用Knn,一个是利用线性预测,这里也会用到上面的 memeory_data。但是这个代码只用到了knn预测,没有线性预测器。我应该不会写有关线性预测器的了,我会把有线性预测器的代码连接放在下面,不难理解,就像深度学习的迁移学习,冻结主干网络,换个头,继续训练而已。

6.1 Knn monitor

请添加图片描述

    with torch.no_grad():
        # generate feature bank
        for data, target in tqdm(memory_data_loader, desc='Feature extracting'):
            # print("data1", data.shape, "target1", target.shape)

            feature = net(data.cuda(non_blocking=True))
            feature = F.normalize(feature, dim=1)
            # print("feature1", feature.shape)           # 512 * 128

            feature_bank.append(feature)
            # print("feature_bank1", feature_bank.shape)  # # 1,2,3....->50000
        # [D, N]
        feature_bank = torch.cat(feature_bank, dim=0).t().contiguous()
        # print("feature_bank2", feature_bank.shape)         # 128,50000

        # [N]
        feature_labels = torch.tensor(memory_data_loader.dataset.targets, device=feature_bank.device)

图片的上半部分这一部分代码。feature_bank 记录了所有训练集数据。数据维度如图中和代码中所示。整个过程即为把所有数据的特征记录下来。每个输入的图片转化为一个128维的向量。

test_bar = tqdm(test_data_loader, desc="KNN")
for data, target in test_bar:
    data, target = data.cuda(non_blocking=True), target.cuda(non_blocking=True)
    # print("data2", data.shape, "target2", target.shape)   # 512.3,32,32, 512

    feature = net(data)
    feature = F.normalize(feature, dim=1)
    # print("feature2", feature.shape)           # 512,128
    
    pred_labels = knn_predict(feature, feature_bank, feature_labels, classes, args.knn_k, args.knn_t)
    # print("pred_labels", pred_labels.shape)    # 512*10

    total_num += data.size(0)
    # print("total_num", total_num)              # 512

    total_top1 += (pred_labels[:, 0] == target).float().sum().item()
    test_bar.set_description('Test Epoch: [{}/{}] Acc@1:{:.2f}%'.format(epoch, args.epochs, total_top1 / total_num * 100))

下面对应这个代码。最重要的是knn_predict。对应图片的下半部分。knn_predict 函数为:

# knn monitor as in InstDisc https://arxiv.org/abs/1805.01978
# implementation follows http://github.com/zhirongw/lemniscate.pytorch and https://github.com/leftthomas/SimCLR
def knn_predict(feature, feature_bank, feature_labels, classes, knn_k, knn_t):
    # compute cos similarity between each feature vector and feature bank ---> [B, N]
    sim_matrix = torch.mm(feature, feature_bank)
    # print(feature.shape, feature_bank.shape, "sim_matrix", sim_matrix.shape)      # [512, 50000]

    # [B, K], 从 50000 中选择 200 个最大的?
    sim_weight, sim_indices = sim_matrix.topk(k=knn_k, dim=-1)
    # torch.Size([512, 200]) torch.Size([512, 200]), 200
    # print("sim_weight.shape, sim_indices.shape", sim_weight.shape, sim_indices.shape, knn_k)

    # [B, K]
    # feature_labels 50000 --> 512, 50000
    # print("inter", feature_labels.expand(feature.size(0), -1).shape)     # [512, 50000]

    sim_labels = torch.gather(feature_labels.expand(feature.size(0), -1), dim=-1, index=sim_indices)
    # print("feature", feature.size, "sim_labels", sim_labels.shape)           # 512,128; 512,200

    # weight 扩大了 10 倍?
    sim_weight = (sim_weight / knn_t).exp()
    # print("sim_weight", sim_weight.shape)           # [512, 200]

    # counts for each class, 这里为什么搞这么长?
    one_hot_label = torch.zeros(feature.size(0) * knn_k, classes, device=sim_labels.device)
    # print("one_hot_label", one_hot_label.shape)       # [102400, 10]
    # print("one_hot_label", one_hot_label)  

    # [B*K, C]
    one_hot_label = one_hot_label.scatter(dim=-1, index=sim_labels.view(-1, 1), value=1.0)
    # print("one_hot_label", one_hot_label.shape)       # [102400, 10]
    # print("one_hot_label", one_hot_label) 

    # weighted score ---> [B, C]
    # 512*200*10 * 512*200*1  ---->   512*200*10  --->   512*10
    pred_scores = torch.sum(one_hot_label.view(feature.size(0), -1, classes) * sim_weight.unsqueeze(dim=-1), dim=1)
    # print("pred_scores", pred_scores.shape)           # [512, 10]

    pred_labels = pred_scores.argsort(dim=-1, descending=True)
    # print("pred_labels", pred_labels.shape)           # [512, 10]
    
    return pred_labels

这个knn的计算过程可以说是个难点,但不是重点。这里极力推荐这个博客:术之多。我就不重复造轮子了。简单的说,就是计算每个预测值和这50000个样本的 相似度,选择最多相似度的。只不过这里还加了一个相似度系数,越相似的样本,他的权重越大,用 knn_k 控制,细节可以看上面的博客。

6.2 Linear eval

这个代码没有线性预测,这里给出几个有线性预测的代码。
博客:术之多
BYOL
SimSiam

7 总结

学习无监督学习断断续续几个月了,看了几篇论文,又看了几个代码,终于做了一个阶段性总结,对于于无监督对比学习有了比一个不再模糊的认识。但是也只能说刚入门。学习过程中,对于pytorch,python的理解加深了不少,继续努力。

1.代码来源
MoCo 官方
2.MoCo 代码解读
3.Loss function
4.Knn monitor
4.SimSiam 代码解读
5.MoCo 代码解读
6.BYOL 代码
7.SimSiam 代码。注:这里面的BYOL的代码没写全,只看SimSiam 和SimCLR即可,也可以根据上一个代码自己更改,推荐。

### MoCo 方法详解与原理 MoCo(Momentum Contrast)是一种自监督对比学习框架,其核心思想在于通过动态维护一个负样本字典,为模型提供更丰富的对比样本。这种方法在多个基准数据集上表现出色,成为对比学习领域的重要进展之一。 #### 1. 核心概念 MoCo 的核心在于其独特的负样本字典机制。这个字典是一个动态更新的集合,包含了之前训练步骤中生成的负样本特征。通过这种方式,MoCo 能够在每个训练步骤中使用大量的负样本来提高模型的判别能力。 #### 2. 动量编码器 MoCo 引入了动量编码器的概念,用于逐步更新负样本的表示。动量编码器的参数更新不是直接通过梯度下降,而是通过一个动量项来缓慢调整。这种更新方式确保了负样本特征的稳定性,同时允许模型逐渐适应新的特征表示。 #### 3. 训练过程 在训练过程中,MoCo 通过以下步骤进行: - **查询编码**:当前输入图像通过查询编码器(query encoder)生成查询特征。 - **键编码**:同一图像的不同增强版本通过键编码器(key encoder)生成键特征。 - **负样本采样**:从动态字典中随机采样一组负样本特征。 - **对比损失计算**:计算查询特征与正样本(键特征)以及负样本之间的对比损失。通常使用 InfoNCE 损失函数,最大化正样本对的相似度,同时最小化负样本对的相似度。 #### 4. 优势与特点 MoCo 的主要优势在于其动态负样本字典的设计,这使得模型能够在训练过程中利用大量的负样本,从而提高学习效率和模型性能。此外,动量编码器的引入保证了负样本特征的稳定性,避免了因快速变化的特征表示而导致的学习不稳定。 #### 5. 应用场景 MoCo 在计算机视觉领域有着广泛的应用,特别是在图像分类、目标检测和语义分割等任务中。由于其高效的自监督学习能力,MoCo 可以在没有大量标注数据的情况下,依然保持较高的模型性能。 ```python # 示例代码MoCo 的基本结构 import torch import torch.nn as nn class MoCo(nn.Module): def __init__(self, base_encoder, dim=128, K=65536, m=0.999, T=0.07): super(MoCo, self).__init__() self.K = K self.m = m self.T = T # 创建查询编码器和键编码器 self.encoder_q = base_encoder(num_classes=dim) self.encoder_k = base_encoder(num_classes=dim) # 初始化动量编码器的参数 for param_q, param_k in zip(self.encoder_q.parameters(), self.encoder_k.parameters()): param_k.data.copy_(param_q.data) param_k.requires_grad = False # 创建负样本字典 self.register_buffer("queue", torch.randn(dim, K)) self.queue = nn.functional.normalize(self.queue, dim=0) self.register_buffer("queue_ptr", torch.zeros(1, dtype=torch.long)) @torch.no_grad() def _momentum_update_key_encoder(self): # 动量更新键编码器 for param_q, param_k in zip(self.encoder_q.parameters(), self.encoder_k.parameters()): param_k.data = param_k.data * self.m + param_q.data * (1. - self.m) def forward(self, im_q, im_k): # 查询编码 q = self.encoder_q(im_q) q = nn.functional.normalize(q, dim=1) # 键编码 with torch.no_grad(): self._momentum_update_key_encoder() k = self.encoder_k(im_k) k = nn.functional.normalize(k, dim=1) # 计算对比损失 l_pos = torch.einsum('nc,nc->n', [q, k]).unsqueeze(-1) l_neg = torch.einsum('nc,ck->nk', [q, self.queue.clone().detach()]) logits = torch.cat([l_pos, l_neg], dim=1) logits /= self.T labels = torch.zeros(logits.shape[0], dtype=torch.long).cuda() # 更新负样本字典 self._dequeue_enqueue(k) return logits, labels @torch.no_grad() def _dequeue_enqueue(self, keys): batch_size = keys.shape[0] ptr = int(self.queue_ptr) assert self.K % batch_size == 0 # 替换字典中的旧样本 self.queue[:, ptr:ptr + batch_size] = keys.T ptr = (ptr + batch_size) % self.K self.queue_ptr[0] = ptr ```
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值