☰
PyTorch实现CLIP模型:从双塔结构到零样本图像分类
2026/10/2 3:24:24 网站建设 项目流程

简介:基于PyTorch实现的CLIP模型完整项目,面向深度学习开发者、CV/NLP领域研究者及入门学习者,解决图像与文本跨模态语义对齐与理解问题,涵盖从数据准备到模型推理的完整链路。资源包共13个文件,包含7个Python脚本(覆盖模型架构、数据加载、训练与推理)、1个Jupyter Notebook流程教程、1份README说明文档及3张示例图片,整体仅3.11MB,轻量易部署。目前已有817人学习下载。源码按PyTorch标准工程组织,模块划分清晰,涵盖模型定义、数据处理、训练、验证与推理等环节,配套Notebook和Markdown教程深入讲解对比学习预训练原理、数据配对策略与训练参数调优方法,可帮助用户快速复现图像分类、图文检索、视觉问答等任务。项目强调实战,代码注释清晰,易于修改与扩展,也适合作为大模型相关课程设计与毕业设计的起点。

1. 一个例子看清CLIP在解决什么:没有标注数据,也能给图片分类

接到一个活:要把几千张汽车图按“轿车、SUV、皮卡”区分开,但库里连一张标签都没有。传统做法是先花一周人工标注、训练分类器、调参上线,周期长且换一批车型就得重来。CLIP(Contrastive Language-Image Pre-training)的思路是让模型提前学会“图片和文字在同一个语义空间里对齐”,到了新任务上直接拿类别名去匹配图片,一个样本都不用标。

这个标题给的是一个“简洁明了的CLIP模型”落地包:Pytorch实现、双塔结构、对比损失、附带项目源码和流程教程。定位很实在——不是把OpenAI原版几十亿数据训练的流程搬过来,而是把结构讲清楚、让普通机器能跑通、让新手能照着复现。下面我按自己做过的方案,从原理、代码、训练参数到踩坑记录,完整讲一遍怎么把这个项目在本地落地。

2. 双塔结构:图像侧和文本侧各自提取特征,再对对齐

2.1 对比学习:让图片和文本在同一个向量空间里相遇

CLIP的核心不是分类,而是度量学习。训练数据是一批(图片, 文本)配对,比如一张狗的照片配一句“a photo of a dog”。训练时模型把图片编码成一个向量,把文本也编码成一个向量,目标很直观:配对的图文向量要靠近,同一batch里其他不配对的要拉开。

这个batch内拉近推远的做法叫InfoNCE,也叫对比学习。它和分类的本质区别在于:分类是模型学会“这张图是狗”,CLIP是模型学会“狗这个概念的图片表达”和“狗这个概念的文本表达”落在同一个向量区域里。所以换到新任务时,不需要重新训练,只要把新类别的名字写成句子,跟图片向量做相似度排序就能出分类结果,这就是zero-shot分类。

训练样本的组织方式也值得注意。CLIP没有人为造负样本,它直接把当前batch里的其他图文对当作负样本。这意味着batch size越大,负样本越丰富,学出来的特征越有区分度。原论文用了几万的大batch,普通机器做不到,但结构不变、损失函数不变,小batch也能学到可用的特征,只是效果上限有差距。

损失函数长这样:对一个batch的图片特征和文本特征做矩阵乘法,得到B×B的相似度矩阵,对角线是正样本,其他位置全是负样本。然后分别按行、按列做交叉熵,两个方向平均。这个对称形式让模型既要“图片找文本”,也要“文本找图片”,两边都学。

相似度计算前有一个关键操作:特征向量要做L2归一化。不归一化,向量的模长会干扰相似度排序,训练容易震荡。归一化之后,点积的范围被限制在[-1, 1],再乘一个可学习的温度因子控制分布的锐度。温度越小,softmax越尖锐,模型越敢下判断。

2.2 为什么用Pytorch自己实现,而不是直接调open_clip

这个决定不少人犹豫过。open_clip有现成权重、有训练好的模型,pip装完就能用。那为什么还要自己用Pytorch写一遍?我列出三条实际考虑。

第一,黑匣子问题。open_clip的代码封装层级很多,想改结构、想换backbone、想只看一个batch的中间特征,都要翻很久源码。自己做项目时,我经常需要打印中间层的shape、检查某一层的梯度,自己写的结构一眼就能定位。标题里“简洁明了”这四个字,对应的就是这种可控性。

第二,微调和部署需要改结构。比如要把图像编码器从ResNet换成ViT,或者要在文本编码器后面接一个自己的分类头,open_clip的接口不一定支持直接改。自己维护的代码,改动成本最低。后面章节我会讲到微调时的冻结策略,自己写的模型改起来非常顺手。

第三,Pytorch的生态配套太省事。torchvision自带预训练ResNet和ViT,torch.cuda.amp一行开混合精度,torch.utils.data.DataLoader的num_workers、pin_memory直接加速,这些在训练流程里都是刚需。换TensorFlow或JAX,同等功能的代码量至少翻一倍。

另外说一个反向理由:自己实现不代表不能用别人的预训练权重。图像编码器直接用torchvision的ResNet50预训练权重,文本编码器从头训练,这种组合在小规模数据上效果不错。CLIP学的是跨模态对齐,视觉特征已经有了,模型只需要学会把文本也映射到同一个空间。

3. 用Pytorch写一个极简CLIP:模型定义与对比损失

3.1 图像编码器:用预训练ResNet做backbone,丢掉分类头

图像侧最常见的做法是拿torchvision里的ResNet50当backbone,去掉最后一层全局池化和分类头。为什么选ResNet50而不是更大模型?因为CLIP是双塔结构,文本侧也要训练,两个塔一起算梯度,视觉backbone太大显存扛不住。先跑通流程,再考虑换ViT。

import torch import torch.nn as nn import torch.nn.functional as F from torchvision import models class ImageEncoder(nn.Module): def __init__(self, out_dim=512): super().__init__() # 加载IMAGENET1K_V2的预训练权重 backbone = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) # 丢掉ResNet最后的全局平均池化和全连接分类头 self.backbone = nn.Sequential(*list(backbone.children())[:-1]) # ResNet50 conv5输出2048维,投影到统一的语义维度 self.proj = nn.Linear(2048, out_dim) def forward(self, images): # images: [B, 3, H, W],输入尺寸建议224x224 features = self.backbone(images) # [B, 2048, 1, 1] features = features.flatten(1) # [B, 2048] features = self.proj(features) # [B, out_dim] return F.normalize(features, dim=-1) # 归一化,关键不能省

这里有个细节值得说:nn.Sequential(*list(backbone.children())[:-1])把ResNet最后一个stage的输出保留下来,没有走全局平均池化,直接flatten。好处是保留了空间信息,虽然CLIP最后只用全局特征,但如果你想在中间层接自己的池化策略,这个结构更好改。

投影层self.proj把2048维压到统一维度。out_dim在整个双塔里必须一致,图像侧和文本侧都输出这个维度,才能做点积。我用512,显存紧张可以降到256,特征区分度会略降。注意F.normalize在forward里做了,后续计算相似度时不要再归一化一次,否则等于白做。

3.2 文本编码器:一个能处理句子的TransformerEncoder

文本侧比图像侧更讲究。原版CLIP用的是GPT-2风格的Transformer,输入是BPE编码的token序列,句子前加[SOS]、末尾加[EOS],取EOS位置的输出作为句向量。简洁版可以省掉[SOS],保留EOS位置逻辑,用Pytorch自带的nn.TransformerEncoder搭一个。

class TextEncoder(nn.Module): def __init__(self, vocab_size, max_len=64, embed_dim=512, out_dim=512, nhead=8, num_layers=3): super().__init__() self.token_emb = nn.Embedding(vocab_size, embed_dim) # 可学习位置编码,比正弦编码好在不用处理长度外推 self.pos_emb = nn.Parameter(torch.zeros(1, max_len, embed_dim)) layer = nn.TransformerEncoderLayer( d_model=embed_dim, nhead=nhead, dim_feedforward=2048, dropout=0.1, batch_first=True, activation="gelu" ) self.encoder = nn.TransformerEncoder(layer, num_layers=num_layers) self.proj = nn.Linear(embed_dim, out_dim) def forward(self, tokens, mask): # tokens: [B, L],mask为True的位置是padding,不参与attention B, L = tokens.shape x = self.token_emb(tokens) + self.pos_emb[:, :L, :] x = self.encoder(x, src_key_padding_mask=mask) # 取每个样本最后一个有效字符位置的输出作为句向量 lengths = mask.sum(dim=1, keepdim=True) # [B, 1] last = lengths.clamp(min=1) - 1 x = x[torch.arange(B, device=x.device), last.squeeze(1)] x = self.proj(x) # [B, out_dim] return F.normalize(x, dim=-1)

两个参数值得细说。max_len控制句子长度上限,原版CLIP是77,实际训练里大部分标题不到20个token,设64足够,太长浪费显存。num_layers我选3,TransformerEncoderLayer内部已经有自注意力+前馈+层归一化,深度太大在几千条图文对的小数据上容易过拟合。

句向量的取法我做了简化:原版取EOS位置的输出,我这里取最后一个非padding位置的输出。只要tokenize时在句子末尾统一追加一个EOS token,这个位置就是EOS。省去了记录EOS索引的麻烦,训练效果几乎一样。注意mask.sum(dim=1)统计的是padding数量,最后一个有效字符的索引是总长度 - padding数 - 1,代码里clamp(min=1)是为了防止空句子。

3.3 对比损失:对称的InfoNCE,温度参数怎么设

模型主体由两个塔组成,损失函数是CLIP的灵魂。把两个塔的输出做矩阵乘法,对角线是正样本对,其余位置全是负样本。用交叉熵分别按行按列算一遍再平均,这个对称损失让两个塔同步更新。

class CLIPModel(nn.Module): def __init__(self, image_encoder, text_encoder, logit_scale_init=2.659): super().__init__() self.image_encoder = image_encoder self.text_encoder = text_encoder # 温度参数的倒数,初始化为 ln(1/0.07) ≈ 2.659 self.logit_scale = nn.Parameter(torch.tensor(logit_scale_init)) def forward(self, images, tokens, mask): image_features = self.image_encoder(images) # [B, D],已归一化 text_features = self.text_encoder(tokens, mask) # [B, D] logits = self.logit_scale * (image_features @ text_features.t()) labels = torch.arange(logits.size(0), device=logits.device) loss_img = F.cross_entropy(logits, labels) # 图片去匹配文本 loss_txt = F.cross_entropy(logits.t(), labels) # 文本去匹配图片 return (loss_img + loss_txt) / 2

温度参数这里我用的是logit_scale,它是1/temperature。原论文初始化temperature=0.07,对应logit_scale约2.659。为什么不用temperature本身?因为Pytorch里直接优化temperature会出现除零和梯度爆炸,优化它的对数形式数值更稳定。

logit_scale在训练中是可学习参数,会自动调整相似度分布的锐度。训练初期logits普遍很小,交叉熵loss高,模型被迫拉大scale;后期特征区分度好了,scale会稳定在一个值附近。如果你发现训练结束后logit_scale异常大(比如超过10),说明特征没有学好,模型在用温度硬撑,这时候要先检查特征质量而不是调温度。

4. 跑通训练和zero-shot推理:数据处理、参数与评估

4.1 数据组织:一张CSV表同时管理图片和文本

训练CLIP需要图文对数据。最省事的组织方式是一张CSV,两列:image_path和text。没有现成数据时,常用起点是COCO的train2017图片加caption标注,抽几千对就能跑通流程。关键是数据清洗:一句文本对应一张图,文本不能太长,长句截断即可。

import os import torch import pandas as pd from PIL import Image from torch.utils.data import Dataset class ImageTextDataset(Dataset): def __init__(self, csv_path, img_dir, transform, max_len=64): self.df = pd.read_csv(csv_path) self.img_dir = img_dir self.transform = transform self.max_len = max_len def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] image = Image.open(os.path.join(self.img_dir, row["image_path"])).convert("RGB") image = self.transform(image) tokens, mask = self.encode_text(row["text"]) return image, tokens, mask def encode_text(self, text): # 用tiktoken的cl100k_base做BPE编码,效果接近原版CLIP的tokenizer import tiktoken enc = tiktoken.get_encoding("cl100k_base") ids = enc.encode(text)[:self.max_len - 1] + [enc.eot_token] length = len(ids) tokens = torch.zeros(self.max_len, dtype=torch.long) mask = torch.ones(self.max_len, dtype=torch.bool) tokens[:length] = torch.tensor(ids) mask[:length] = False # False表示有效字符,True表示padding return tokens, mask

encode_text里的逻辑要和TextEncoder的forward对得上:句子末尾追加eot_token,mask里有效位置置False。这样做有两个好处,一是Transformer的attention不会看到padding位置,二是TextEncoder取“最后一个有效位置”时,取到的正好是EOS的输出。

tiktoken是OpenAI开源的BPE编码器,cl100k_base词表约10万token。你也可以用HuggingFace的CLIPProcessor里的tokenizer,效果等同。自己写tokenizer的坑在于词表不一致会导致Embedding维度对不上,最省心的是训练和推理永远用同一个编码函数。

transform部分,图像侧缩放到224x224,用torchvision的标准流程。注意图像增强不要加RandomCrop,因为配对的文本描述的是整张图,不是某个局部。可以用RandomResizedCrop配合小比例缩放,但不要裁掉太多主体。

from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])

4.2 训练循环:混合精度、学习率与日志

训练配置上,CLIP和普通分类模型的差别主要在两点:一是batch size尽量大,二是温度参数跟着学。先看训练循环的完整代码。

device = "cuda" if torch.cuda.is_available() else "cpu" enc = tiktoken.get_encoding("cl100k_base") model = CLIPModel( ImageEncoder(out_dim=512), TextEncoder(vocab_size=enc.n_vocab, max_len=64, out_dim=512) ).to(device) optimizer = torch.optim.AdamW(model.parameters(), lr=5e-5, weight_decay=0.2) scaler = torch.cuda.amp.GradScaler() model.train() for epoch in range(30): total_loss = 0 for images, tokens, mask in dataloader: images = images.to(device) tokens = tokens.to(device) mask = mask.to(device) optimizer.zero_grad() with torch.cuda.amp.autocast(): loss = model(images, tokens, mask) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() total_loss += loss.item() print(f"epoch {epoch} loss {total_loss / len(dataloader):.4f}")

学习率5e-5配合AdamW是CLIP类模型的安全起点。weight_decay设0.2看起来偏高,但对比学习任务里正则化能防止特征空间过度膨胀。如果你的数据只有几千对,每个epoch不到100步,30个epoch可能不够,我习惯看到loss平原后把学习率降到1e-5再跑10个epoch。

batch size如果上不了64,用梯度累积模拟大batch,这是对比学习任务里性价比最高的技巧。

accum_steps = 4 for step, (images, tokens, mask) in enumerate(dataloader): images = images.to(device); tokens = tokens.to(device); mask = mask.to(device) with torch.cuda.amp.autocast(): loss = model(images, tokens, mask) / accum_steps scaler.scale(loss).backward() if (step + 1) % accum_steps == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()

梯度累积的副作用是BN的统计量更新频率变低,不过我们的图像编码器用的是预训练ResNet,backbone部分BN已经学好了,影响不大。训练时监控三个指标:loss是否下降、logit_scale是否上升后趋稳、每个epoch结束时在验证集上跑一次zero-shot精度。第三个指标才是CLIP有没有学好的最终标准。

4.3 zero-shot推理:把你的类别列表变成一句句模板

训练完的CLIP怎么用?不需要任何训练样本,把类别名写成自然语言模板,让文本编码器编码,再和图片特征算相似度。CIFAR-10的类别模板就是“a photo of a {class}”,class换成plane、car、bird等。

def zeroshot_classify(model, images, class_names, enc, device): model.eval() texts = [f"a photo of a {c}" for c in class_names] token_list, mask_list = [], [] for t in texts: tokens, mask = encode_text(t, enc) token_list.append(tokens); mask_list.append(mask) tokens = torch.stack(token_list).to(device) masks = torch.stack(mask_list).to(device) with torch.no_grad(): text_features = model.text_encoder(tokens, masks) # [C, D] image_features = model.image_encoder(images) # [B, D] similarity = image_features @ text_features.t() # [B, C] preds = similarity.argmax(dim=-1) return preds

模板的选择直接影响精度。直接写“car”效果差,因为训练数据里文本基本都是完整句子,模型没见过裸词。加“a photo of a”前缀是原论文验证过的最稳模板。特定数据集上可以更细化,比如医疗图像用“a chest X-ray of {class}”,这个细节后面微调章节还会提到。

zero-shot推理时注意model.eval()会关掉Dropout,影响不大,但BN的running_mean在单卡推理时不更新,这是预期行为。多次调用zeroshot_classify时,相同类别文本会被重复编码,可以在类外缓存text_features,推理效率提升明显。

5. 训练CLIP最容易踩的6个坑:现象、原因与解法

5.1 Loss变成nan,训练直接中断

现象:训练到某一步,loss变成nan,之后无法恢复。原因有三个高发点:logit_scale在AMP混合精度下溢出、文本编码器位置编码初始化过大、图像侧某个batch出现损坏数据。

解决:logit_scale初始化后加一行self.logit_scale.data.clamp_(max=4.6),上限相当于温度0.217,训练中不会超过这个值。位置编码用nn.init.normal_(self.pos_emb, std=0.02)初始化。数据侧在Dataset的__getitem__里包一层try-except,图片解码失败时返回同batch的上一张图,避免PIL丢异常。

5.2 loss在下降,但zero-shot精度纹丝不动

现象:训练loss从8降到4,换到分类任务上精度还是随机水平。原因:图像侧用的是预训练ResNet,文本侧从零开始,模型发现靠图像特征已经能区分batch内的样本,文本侧成了摆设,根本没有学到语义对齐。

解决:前5个epoch冻结图像编码器,只训练文本侧和投影层。具体做法是把image_encoder参数的requires_grad置False,等loss明显下降后再解冻一起训练。这个技巧在图文数据量少于5万对时几乎是必须的。

5.3 batch内图片和文本数量对不上

现象:DataLoader报错,或者模型forward里矩阵乘法维度不匹配。原因:文本长度不同,虽然mask了padding,但tensor的shape必须完全一致。我遇到过一个隐蔽版本:encode_text里截断长度max_len设了64,但tokens初始化用了torch.zeros(self.max_len),mask用了torch.ones(self.max_len),两个都是64没问题;换了一个数据集重新实例化Dataset时max_len传了128,DataLoader里旧数据的缓存没清,报错信息指向模型内部,排查了半天。

解决:训练前打印一个batch的shape:for batch in dataloader: print([x.shape for x in batch]); break。确认image是[B,3,224,224]、tokens和mask都是[B,64]再开始训练。

5.4 GPU利用率低,显存没满但训练很慢

现象:loss在降,但nvidia-smi显示GPU利用率只有20%上下。原因:DataLoader的num_workers默认是0,每步都在主进程里等图片读取和解码。CLIP的数据加载比分类任务更重,因为每次还要做BPE编码。

解决:DataLoader里设num_workers=4, pin_memory=True, persistent_workers=True。如果图片是原始大图,先在预处理脚本里统一resize到256x256存成png,Dataset里只做ToTensor和Normalize,加载速度能快3倍。BPE编码不要在__getitem__里现算,提前把所有文本编码成tokens和mask存成npy文件,训练时直接查表。

5.5 微调时发现图像编码器根本没更新

现象:loss下降极慢,打印参数发现backbone的梯度全是None。原因:torchvision的预训练模型加载后,有人习惯性调了for param in backbone.parameters(): param.requires_grad = False,后面忘了解冻。另一个常见误操作是backbone子模块里的BN层处于eval模式,导致running_mean不更新。

解决:训练前加一段检查代码:

total = sum(1 for p in model.parameters() if p.requires_grad) print(f"trainable params: {total}")

再确认model.train()被调用。如果只想解冻部分层,比如ResNet最后两个stage,就只把那部分requires_grad置True,前面保持False。

5.6 单卡能跑通,换多卡后loss明显变高

现象:同一份代码从单卡改DDP,loss比单卡高而且不稳定。原因:对比学习的负样本来自batch内部,DDP下每个进程只看到自己的本地batch,负样本数量变少,学出来的特征区分度下降。这是在CLIP任务里多卡训练最常见的坑之一。

解决:小数据阶段用梯度累积替代多卡。如果一定要多卡,需要在每个step用torch.distributed.all_gather把所有进程的图像特征和文本特征收集起来再算loss,代码量增加不少。我的建议是先单卡把流程跑通,数据量大了再投入精力做分布式。

6. 进阶:微调自己的CLIP并在CIFAR-10上做验证

6.1 微调策略:先冻结再解冻,按数据分布决定解冻深度

CLIP模型微调是热词,实际场景里拿到一个预训练或自己训练的CLIP,要在特定领域用,常见做法是先冻结再解冻。数据分布跟自然图像接近(比如电商商品图、社交媒体图片),冻结backbone只训练投影层和文本侧就够,学习率可以提到1e-4,5个epoch就能看到效果。数据分布差异大(医学影像、卫星图、工业质检图),至少解冻图像编码器最后两个stage,让高层特征适配新域。

解冻操作很简单:找到model.image_encoder.backbone,把最后两个BasicBlock或Bottleneck的requires_grad置True,前面的保持False。优化器要用filter(lambda p: p.requires_grad, model.parameters())重新构造,否则冻结层的梯度为None会报错。

6.2 用CIFAR-10验证zero-shot效果

跑通训练后,第一步验证永远是zero-shot分类。CIFAR-10是标准基准,类别是10个常见物体,模板用“a photo of a {class}”。用torchvision加载测试集,跑一遍zeroshot_classify,记录准确率。

检查项预期结果不达标时先查什么
训练loss降到3.5以下检查梯度累积、学习率
logit_scale稳定在4~6之间特征质量差,检查数据清洗
CIFAR-10 zero-shot55%~70%区间先换更强的文本模板
单类准确率不要出现某类全错类别名和模板是否匹配

ResNet50图像侧配合小规模图文对训练的CLIP,CIFAR-10 zero-shot精度正常在55%~70%。原论文ViT-B/32能到76%,差距主要在训练数据规模,我们几千对数据学出来的特征区分度必然有限,这个数值是健康的。

我习惯在每个epoch训练结束后跑一次CIFAR-10验证,不只为了看精度,更是为了观察学的方向对不对。如果loss降了但精度没动,说明两个塔在对齐一个错误的空间,这时候先去看数据清洗和文本质量;如果精度在涨但涨幅很慢,优先检查温度参数和负样本数量。CLIP这个模型,训练曲线的诡异程度比分类模型高得多,不盯验证集很容易白跑十几个小时。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询