PMC-CLIP概述
# CLIP模型简介 CLIP(Contrastive Language-Image Pre-training)是OpenAI提出的多模态预训练模型,通过对比学习实现图像和文本的对齐。 ### 核心思想 将图像和文本映射到同一特征空间,通过对比学习使匹配的图像-文本对在特征空间中更接近。 ### 架构组成 ``` 输入图像 ─→ 图像编码器 ─→ 图像特征向量 (768维) ↓ 共享特征空间 ↓ 输入文本 ─→ 文本编码器 ─→ 文本特征向量 (768维) ↓ 相似度计算 ``` ### 依赖库 ```python import torch import torch.nn as nn from transformers import AutoTokenizer, AutoModel from PIL import Image ``` ### 环境要求 - torch >= 2.0.0 - transformers >= 4.54.0 - CUDA支持(推荐)
# PMC-CLIP特点 ### 医学领域专用 PMC-CLIP是专门针对医学图像-文本匹配的CLIP模型变体: | 特点 | 说明 | |------|------| | **医学文本编码器** | 使用BiomedBERT而非通用BERT | | **医学图像理解** | 针对X光、MRI等医学图像优化 | | **开放获取数据** | 在PMC(PubMed Central)开放获取数据上训练 | ### 模型权重 可从Hugging Face下载预训练权重: ```bash # 文本编码器(约418MB) wget https://huggingface.co/datasets/axiong/pmc_oa/resolve/main/text_encoder.pth # 图像编码器 ResNet50(约144MB) wget https://huggingface.co/datasets/axiong/pmc_oa/resolve/main/image_encoder(resnet50).pth # 文本投影层(约2.3MB) wget https://huggingface.co/datasets/axiong/pmc_oa/resolve/main/text_projection_layer.pth ``` ### 特征维度 - 图像特征:768维 - 文本特征:768维 - 对比温度参数(logit_scale):4.4292
# 应用场景 ### 医学图像-文本检索 | 场景 | 说明 | |------|------| | **报告生成** | 根据医学图像生成诊断报告 | | **图像检索** | 通过文本描述检索相关医学图像 | | **多模态检索** | 图像查询文本或文本查询图像 | | **诊断辅助** | 辅助医生快速定位相似病例 | ### 典型任务 1. **零样本分类**:无需训练即可分类医学图像 2. **跨模态检索**:文本描述查找相应图像 3. **报告生成**:图像自动生成医学报告 4. **质量评估**:评估医学图像与报告的一致性 ### 示例数据 - 胸部X光片(Chest X-ray) - 脑部MRI(Brain MRI) - CT扫描图像 - 病理切片图像
图像编码器
# Modified ResNet架构 ### 核心改进 PMC-CLIP使用的Modified ResNet相比标准ResNet有以下改进: 1. **三层Stem**:替换原来的单层卷积 2. **抗混叠卷积**:在步长>1的卷积前添加平均池化 3. **注意力池化**:替换最后的全局平均池化 ### 网络结构 ``` 输入图像 (224×224×3) ↓ Stem (3层卷积 + AvgPool) ↓ Layer1 (3个Bottleneck, 64维) ↓ Layer2 (4个Bottleneck, 128维, stride=2) ↓ Layer3 (6个Bottleneck, 256维, stride=2) ↓ Layer4 (3个Bottleneck, 512维, stride=2) ↓ AttentionPool2d (多头注意力) ↓ 图像特征 (768维) ``` ### 初始化参数 ```python layers = [3, 4, 6, 3] # ResNet50 output_dim = 768 heads = 8 image_size = 224 width = 64 ```
# Bottleneck模块 ### 结构组成 每个Bottleneck包含三个卷积层: ``` 输入 (inplanes) ↓ 1×1 Conv + BN + ReLU → planes ↓ 3×3 Conv + BN + ReLU → planes ↓ AvgPool (stride>1时) ↓ 1×1 Conv + BN → planes×4 ↓ 残差连接 (如果需要) → 输出 ``` ### 下采样处理 当stride>1或维度不匹配时: ```python downsample = nn.Sequential([ nn.AvgPool2d(stride), nn.Conv2d(inplanes, planes*4, 1, stride=1), nn.BatchNorm2d(planes*4) ]) ``` ### 前向传播 ```python def forward(self, x): identity = x out = self.relu1(self.bn1(self.conv1(x))) out = self.relu2(self.bn2(self.conv2(out))) out = self.avgpool(out) out = self.bn3(self.conv3(out)) if self.downsample: identity = self.downsample(x) out += identity # 残差连接 return self.relu3(out) ```
# 注意力池化 ### AttentionPool2d 替换传统的全局平均池化,使用多头自注意力机制: ```python class AttentionPool2d(nn.Module): def __init__(self, spacial_dim, embed_dim, num_heads, output_dim=None): super().__init__() # 位置编码 self.positional_embedding = nn.Parameter( torch.randn(spacial_dim**2 + 1, embed_dim) / embed_dim**0.5 ) # QKV投影 self.k_proj = nn.Linear(embed_dim, embed_dim) self.q_proj = nn.Linear(embed_dim, embed_dim) self.v_proj = nn.Linear(embed_dim, embed_dim) self.c_proj = nn.Linear(embed_dim, output_dim or embed_dim) self.num_heads = num_heads ``` ### 计算流程 ``` 特征图 (N, C, H, W) ↓ 展平为 (HW, N, C) ↓ 添加CLS token (HW+1, N, C) ↓ 添加位置编码 ↓ 多头注意力 (QKV投影) ↓ 取CLS token输出 (N, output_dim) ``` ### 参数 - spacial_dim:图像尺寸/32(224→7) - embed_dim:ResNet特征维度(width×32=2048) - num_heads:注意力头数(8) - output_dim:输出维度(768)
# 图像预处理 ### 预处理流程 ```python def image_transform(image_size=224, mean=None, std=None): mean = mean or (0.48145466, 0.4578275, 0.40821073) std = std or (0.26862954, 0.26130258, 0.27577711) transforms = [ Resize(image_size, interpolation=BICUBIC), CenterCrop(image_size), _convert_to_rgb, # 转换为RGB ToTensor(), Normalize(mean=mean, std=std) ] return Compose(transforms) ``` ### RGB转换 ```python def _convert_to_rgb(image): return image.convert('RGB') ``` ### 加载图像 ```python from PIL import Image image_path_ls = ['chest_X-ray.jpg', 'brain_MRI.jpg'] images = [] image_tensor = [] for path in image_path_ls: image = Image.open(path).convert('RGB') images.append(image) image_tensor.append(preprocess_val(image)) image_tensor = torch.stack(image_tensor, dim=0).to(device) # 输出形状: [batch_size, 3, 224, 224] ``` ### 归一化参数 使用OpenAI数据集的均值和标准差: - mean: (0.48145466, 0.4578275, 0.40821073) - std: (0.26862954, 0.26130258, 0.27577711)
文本编码器
# BiomedBERT文本编码器 ### 模型介绍 PMC-CLIP使用微软的BiomedBERT作为文本编码器: - **模型**:microsoft/BiomedNLP-BiomedBERT-base-uncased-abstract - **用途**:医学文献摘要理解 - **优势**:在生物医学文本上预训练 ### 加载模型 ```python from transformers import AutoTokenizer, AutoModel # 加载Tokenizer tokenizer = AutoTokenizer.from_pretrained( 'microsoft/BiomedNLP-BiomedBERT-base-uncased-abstract' ) # 加载模型 text_encoder = AutoModel.from_pretrained( 'microsoft/BiomedNLP-BiomedBERT-base-uncased-abstract' ) # 加载PMC-CLIP微调权重 text_encoder.load_state_dict(torch.load('text_encoder.pth')) ``` ### Tokenization ```python bert_input = ['chest X-ray', 'brain MRI'] encoded_input = tokenizer( bert_input, padding='max_length', truncation=True, max_length=77, return_tensors='pt' ) input_ids = encoded_input['input_ids'].to(device) # 输出形状: [batch_size, max_length] ``` ### 模型输出 ```python text_feature = text_encoder(input_ids) last_hidden_state = text_feature.last_hidden_state # [batch, seq_len, 768] pooler_output = text_feature.pooler_output # [batch, 768] ```
# 文本投影层 ### 作用 将BERT的768维输出投影到与图像特征相同的共享空间: ```python # 加载投影层权重 text_projection_layer = torch.load('text_projection_layer.pth') text_projection_layer = nn.Parameter(text_projection_layer) # 应用投影 text_feature = pooler_output @ text_projection_layer # 输出: [batch_size, 768] ``` ### 投影矩阵 - 输入维度:768(BERT输出) - 输出维度:768(共享特征空间) - 参数量:768 × 768 = 589,824 ### 模型加载到设备 ```python device = 'cuda' if torch.cuda.is_available() else 'cpu' text_encoder = text_encoder.to(device) text_projection_layer = text_projection_layer.to(device) ```
# 文本预处理 ### 分词参数 | 参数 | 值 | 说明 | |------|-----|------| | padding | max_length | 填充到固定长度 | | truncation | True | 超长截断 | | max_length | 77 | 最大序列长度 | | return_tensors | pt | 返回PyTorch张量 | ### 医学文本示例 ```python # 医学图像描述 medical_texts = [ 'chest X-ray', # 胸部X光 'brain MRI', # 脑部MRI 'CT scan of abdomen', # 腹部CT扫描 'histopathology slide' # 组织病理切片 ] ``` ### 注意事项 - 医学术语较多,需要专门的医学BERT - 最大长度77足够覆盖大多数描述 - 建议添加attention_mask避免填充token干扰
特征提取
# 图像特征提取 ### 编码器输出 ```python image_encoder = ModifiedResNet( layers=[3,4,6,3], output_dim=768, heads=8, image_size=224, width=64 ) # 加载预训练权重 image_encoder.load_state_dict(torch.load('image_encoder(resnet50).pth')) image_encoder = image_encoder.to(device) ``` ### 提取特征 ```python image_feature = image_encoder(image_tensor) if isinstance(image_feature, dict): image_feature = image_feature['image_features'] print(f'image size: {image_tensor.shape}') print(f'feature size: {image_feature.shape}') # 输出: image size: torch.Size([2, 3, 224, 224]) # feature size: torch.Size([2, 768]) ``` ### 输出说明 - 输入:[batch_size, 3, 224, 224] - 输出:[batch_size, 768] - 每个图像对应一个768维特征向量
# 文本特征提取 ### 编码器输出 ```python text_feature = text_encoder(input_ids) last_hidden_state = text_feature.last_hidden_state # [2, 77, 768] pooler_output = text_feature.pooler_output # [2, 768] ``` ### 应用投影层 ```python # 应用投影层 text_feature = pooler_output @ text_projection_layer print(f'text_feature.shape: {text_feature.shape}') # 输出: text_feature.shape: torch.Size([2, 768]) ``` ### 特征说明 | 输出 | 维度 | 说明 | |------|------|------| | last_hidden_state | [batch, seq_len, 768] | 每个token的特征 | | pooler_output | [batch, 768] | [CLS] token的池化输出 | | text_feature | [batch, 768] | 投影后的共享空间特征 |
# 特征归一化 ### L2归一化 在计算相似度之前,需要对特征进行L2归一化: ```python # 图像特征归一化 image_feature = image_feature / image_feature.norm(dim=-1, keepdim=True) # 文本特征归一化 text_feature = text_feature / text_feature.norm(dim=-1, keepdim=True) ``` ### 为什么需要归一化? 归一化后: - 特征向量长度为1 - 相似度计算变为余弦相似度 - 相似度范围:[-1, 1] - 内积 = cos(θ) ### 验证归一化 ```python # 验证归一化后的模长为1 print(f'Norm check: {image_feature[0].norm().item():.4f}') # 应该输出: Norm check: 1.0000 ```
相似度计算
# 相似度矩阵计算 ### 计算过程 ```python # 温度参数 logit_scale = 4.4292 # 计算相似度矩阵 similarity = (math.exp(logit_scale) * (image_feature @ text_feature.T)).softmax(dim=-1) ``` ### 计算步骤 1. **内积计算**:image_feature @ text_feature.T - 输入:[2, 768] @ [768, 2] - 输出:[2, 2] 相似度矩阵 2. **温度缩放**:math.exp(logit_scale) × 内积 - exp(4.4292) ≈ 83.87 - 放大相似度差异 3. **Softmax**:归一化为概率分布 - 每行和为1 - 表示匹配概率 ### 相似度矩阵示例 ``` chest X-ray brain MRI chest_X-ray.jpg 0.98 0.02 brain_MRI.jpg 0.01 0.99 ``` 对角线高值表示正确的图像-文本匹配
# 温度缩放 ### 温度参数 ```python logit_scale = 4.4292 temperature = math.exp(logit_scale) # ≈ 83.87 ``` ### 作用 温度缩放的作用是调整相似度分布的"锐度": - **高温度(大logit_scale)**: - 相似度分布更陡峭 - 正确匹配的概率更高 - 训练更稳定 - **低温度(小logit_scale)**: - 相似度分布更平缓 - 模糊度增加 ### 对比 ``` 无温度缩放: [0.85, 0.15] 有温度缩放: [0.98, 0.02] # 更明显的区分 ``` ### 训练vs推理 - 训练时:温度参数可学习 - 推理时:使用固定的logit_scale
# 结果可视化 ### 显示结果 ```python import matplotlib.pyplot as plt for i, (image_path, image) in enumerate(zip(image_path_ls, images)): print(image_path) plt.imshow(image) plt.show() for j in range(len(bert_input)): score = similarity[i, j].item() print(f'{bert_input[j]}: {score:.4f}') print() ``` ### 输出示例 ``` chest_X-ray.jpg [显示胸部X光图像] chest X-ray: 0.9834 brain MRI: 0.0166 brain_MRI.jpg [显示脑部MRI图像] chest X-ray: 0.0123 brain MRI: 0.9877 ``` ### 评估指标 | 指标 | 说明 | |------|------| | **Top-1准确率** | 最高分匹配是否正确 | | **召回率@K** | 前K个结果中是否包含正确匹配 | | **Mean Rank** | 正确匹配的平均排名 |
CLIP对应原理
# CLIP核心原理:为什么图像和文本能对应? ## 关键问题 **图像预训练模型(ResNet)** 和 **文本预训练模型(BERT)** 是在完全不同的数据和任务上训练的,为什么在CLIP中能对应上? ## 答案:对比学习 + 联合训练 ### 1. 共享特征空间 CLIP的核心思想是创建一个**共享的特征空间**: ``` 训练前:图像空间 ───???─── 文本空间 (完全不同,无法比较) 训练后: 图像空间 ───→ 共享空间 ←── 文本空间 (768维对齐) ↑ 对比学习训练 ``` ### 2. 联合训练机制 CLIP不是分别训练两个模型,而是**同时训练**: ```python # 简化的训练伪代码 for batch in dataloader: images, texts = batch # 配对的图像-文本数据 # 两个编码器同时前向传播 image_features = image_encoder(images) # [N, 768] text_features = text_encoder(texts) # [N, 768] # 计算相似度矩阵 N×N similarity = image_features @ text_features.T # 对比损失:让对角线(正确配对)最大 loss = contrastive_loss(similarity) # 同时更新两个编码器 loss.backward() optimizer.step() ``` ### 3. 配对数据的作用 使用**图像-文本配对数据**进行训练: ``` 训练数据示例: ┌─────────────────┬──────────────────────┐ │ 图像 │ 文本描述 │ ├─────────────────┼──────────────────────┤ │ 胸部X光图像 │ "chest X-ray" │ ← 配对✓ │ 脑部MRI图像 │ "brain MRI" │ ← 配对✓ │ 胸部X光图像 │ "brain MRI" │ ← 不配对✗ └─────────────────┴──────────────────────┘ ``` 训练目标:配对的图像-文本特征接近,不配对的远离
# 对比学习详解 ## 相似度矩阵目标 对于N个图像-文本配对,计算N×N相似度矩阵: ``` N×N 相似度矩阵示例: 正确文本 错误文本1 错误文本2 正确图像 [0.95 0.02 0.03] ← 想要最大 错误图像1 [0.01 0.96 0.03] ← 想要最小 错误图像2 [0.02 0.01 0.97] ← 想要最小 ↑ ↑ ↑ 拉近 推开 推开 ``` ## 对比损失函数 CLIP使用对称的对比损失: ```python # 图像到文本的损失 loss_i2t = -torch.log( torch.exp(similarity[i, i]) / torch.exp(similarity[i]).sum() ) # 文本到图像的损失 loss_t2i = -torch.log( torch.exp(similarity[j, j]) / torch.exp(similarity[:, j]).sum() ) # 总损失 total_loss = (loss_i2t + loss_t2i) / 2 ``` ## 直观理解 - **拉近**:正确配对的图像和文本在特征空间中靠近 - **推远**:错误配对的图像和文本在特征空间中远离 - **结果**:模型学会将相关的图像和文本映射到相近的位置
# 投影层对齐 ## 为什么需要投影层? ResNet50的原始输出是2048维,BERT输出是768维,需要**投影层**对齐到相同维度: ``` ResNet50 输出: 2048维 ─→ projection ─→ 768维 ↓ 共享特征空间 ↑ BERT 输出: 768维 ───→ projection ─→ 768维 ``` ## 投影层的作用 1. **维度对齐**:将不同维度的特征映射到统一维度 2. **语义对齐**:学习跨模态的语义映射关系 3. **可学习变换**:通过训练自动学习最优映射 ## 投影层实现 ```python # 图像投影层(在AttentionPool2d中) self.c_proj = nn.Linear(embed_dim, 768) # 2048 → 768 # 文本投影层(独立参数) text_projection_layer = nn.Parameter(torch.randn(768, 768)) text_feature = pooler_output @ text_projection_layer # 768 → 768 ``` ## 投影层的训练 - 随机初始化(不使用预训练权重) - 在CLIP训练过程中从头学习 - 学习最优的跨模态映射关系
# CLIP训练流程详解 ## 完整训练流程 ``` ┌─────────────────────────────────────────────────┐ │ CLIP训练过程 │ └─────────────────────────────────────────────────┘ 1. 准备配对数据 ┌─────────────┐ ┌─────────────┐ │ Image 1 │ │ "chest X-ray" │ │ Image 2 │ │ "brain MRI" │ │ ... │ │ ... │ │ Image N │ │ "CT scan" │ └──────┬──────┘ └──────┬───────┘ 2. 前向传播 │ │ ▼ ▼ ┌─────────┐ ┌─────────┐ │Image │ │Text │ │Encoder │ │Encoder │ │(预训练 │ │(预训练 │ │ ResNet) │ │ BERT) │ └────┬────┘ └────┬────┘ │ │ ▼ ▼ ┌─────────┐ ┌─────────┐ │Projection│ │Projection│ │ Layer │ │ Layer │ └────┬────┘ └────┬────┘ │ │ ▼ ▼ ┌────────────────────────────────┐ │ 共享特征空间 (768维) │ │ Image1: [0.2, 0.5, ..., 0.8] │ │ Text1: [0.2, 0.5, ..., 0.8] │ ← 越接近越好 │ │ │ Image2: [0.1, 0.9, ..., 0.3] │ │ Text1: [0.2, 0.5, ..., 0.8] │ ← 越远越好 └───────────────┬────────────────┘ │ ▼ ┌─────────────┐ │ 计算相似度 │ │ (N×N矩阵) │ └──────┬──────┘ │ ▼ ┌─────────────┐ │ 对比损失 │ │ 最大化对角线 │ └──────┬──────┘ │ ▼ ┌─────────────┐ │ 反向传播更新 │ │ 两个编码器+ │ │ 投影层 │ └─────────────┘ ``` ## 预训练模型的作用 | 编码器 | 预训练任务 | 对CLIP的贡献 | |--------|-----------|-------------| | ResNet | ImageNet分类 | 已学会提取图像特征 | | BERT | 语言模型理解 | 已学会理解文本语义 | **然后CLIP微调**: - 保留预训练的知识 - 学习跨模态对齐 - 比从头训练快且效果好
对比学习详解
# 为什么图像和文本能对应? ## 核心问题 **图像预训练模型(如ResNet)**和**文本预训练模型(如BERT)**是在完全不同的数据和任务上训练的,为什么它们还能对应上? ## 核心答案:对比学习(Contrastive Learning) ### 将图像和文本映射到共享特征空间 ``` 训练前:图像空间 ───???─── 文本空间 (完全不同,无法比较) 训练后: 图像空间 ───→ 共享空间 ←── 文本空间 (768维对齐) ↑ 对比学习训练 ``` ### 为什么这不是"神奇"的? - ✗ 不是因为模型天生理解跨模态关系 - ✓ 是通过**大量配对数据 + 对比学习训练**出来的 - ✓ 共享空间是**学习出来的**,不是预定义的 - ✓ 投影层将两个模态映射到同一维度空间 - ✓ 对比损失让正确配对接近,错误配对远离 ### 共享特征空间的概念 在768维的共享空间中: ``` 配对的图像-文本 → 相近的位置 ↓ 图像1特征: [0.2, 0.5, ..., 0.8] 文本1特征: [0.2, 0.5, ..., 0.8] ← 距离近 不配对的图像-文本 → 较远的位置 ↓ 图像2特征: [0.1, 0.9, ..., 0.3] 文本1特征: [0.2, 0.5, ..., 0.8] ← 距离远 ```
# 联合训练机制 ## CLIP不是分别训练,而是同时训练 ### 训练伪代码 ```python # CLIP训练过程 for batch in dataloader: images, texts = batch # 配对的图像-文本数据 # 两个编码器同时前向传播 image_features = image_encoder(images) # [N, 768] text_features = text_encoder(texts) # [N, 768] # 计算相似度矩阵 N×N similarity = image_features @ text_features.T # 对比损失:让对角线(正确配对)最大,非对角线最小 loss = contrastive_loss(similarity) # 同时更新两个编码器 loss.backward() optimizer.step() ``` ### 为什么是联合训练? | 训练方式 | 说明 | 效果 | |---------|------|------| | **分别训练** | 图像编码器和文本编码器独立优化 | 无法学习跨模态关系 | | **联合训练** | 两个编码器同时优化,共享损失 | 学习到跨模态对齐 | ### 关键点 1. **同时前向传播**:图像和文本同时编码 2. **共享损失函数**:对比损失同时作用于两个模态 3. **联合反向传播**:梯度同时更新两个编码器 4. **端到端优化**:整个系统作为一个整体优化 ### 与传统方法的区别 ``` 传统方法:图像 → 特征 → 分类器 → 类别 文本 → 特征 → 分类器 → 类别 (各自独立) CLIP方法:图像 → 特征 → 共享空间 → 相似度 → 文本 (联合优化) ```
# 投影层对齐 ## 两个预训练模型输出的维度可能不同 ``` ResNet50 输出: 2048维 ─→ projection ─→ 768维 ↓ 共享特征空间 ↑ BERT 输出: 768维 ───→ projection ─→ 768维 ``` ## 在代码中的实现 ### 文本投影层 ```python # 加载文本投影层权重 text_projection_layer = torch.load('text_projection_layer.pth') text_projection_layer = nn.Parameter(text_projection_layer) # 应用投影层对齐到共享空间 pooler_output = text_encoder(input_ids).pooler_output # [N, 768] text_feature = pooler_output @ text_projection_layer # [N, 768] ``` ### 图像投影层 ```python # 在AttentionPool2d中实现 self.c_proj = nn.Linear(embed_dim, output_dim) # 2048 → 768 # 图像特征通过投影层 image_feature = self.attnpool(x) # 输出768维 ``` ## 投影层的三个作用 | 作用 | 说明 | |------|------| | **维度对齐** | 将不同维度的特征映射到统一维度 | | **语义对齐** | 学习跨模态的语义映射关系 | | **可学习变换** | 通过训练自动学习最优映射 | ## 投影层如何训练? - **随机初始化**:不使用预训练权重 - **从头学习**:在CLIP训练过程中学习 - **学习目标**:找到最优的跨模态映射关系 - **结果**:将图像特征和文本特征投影到可比的空间 ## 为什么投影层是关键? 没有投影层: - ResNet输出2048维,BERT输出768维 - 无法计算相似度 - 特征空间不对齐 有了投影层: - 统一到768维共享空间 - 可以计算相似度 - 学习跨模态语义关系
# 对比学习损失函数 ## N×N 相似度矩阵示例 假设有3个图像-文本配对: ``` 正确文本 错误文本1 错误文本2 正确图像 [0.95 0.02 0.03] ← 想要最大 错误图像1 [0.01 0.96 0.03] ← 想要最小 错误图像2 [0.02 0.01 0.97] ← 想要最小 ↑ ↑ ↑ 拉近 推开 推开 ``` ## 损失目标 ### 对角线(正确配对) - 目标:相似度 → 1 - 操作:拉近正样本对 ### 非对角线(错误配对) - 目标:相似度 → 0 - 操作:推开负样本对 ## 对比损失实现 ```python import torch.nn.functional as F def contrastive_loss(similarity, temperature=1.0): """ similarity: [N, N] 相似度矩阵 temperature: 温度参数 """ # 温度缩放 logits = similarity / temperature # 图像到文本的损失 labels = torch.arange(N).to(device) loss_i2t = F.cross_entropy(logits, labels) # 文本到图像的损失 loss_t2i = F.cross_entropy(logits.t(), labels) # 对称损失 return (loss_i2t + loss_t2i) / 2 ``` ## 温度参数的作用 ```python logit_scale = 4.4292 temperature = math.exp(logit_scale) # ≈ 83.87 ``` - **高温度**:相似度分布更陡峭,区分度更高 - **低温度**:相似度分布更平缓 - **可学习**:训练时作为可学习参数优化 ## 直观理解 ``` 训练前(随机初始化): 文本A 文本B 文本C 图像A [0.33 0.33 0.34] ← 随机分布 训练后(对比学习): 文本A 文本B 文本C 图像A [0.95 0.02 0.03] ← 正确配对突出 ```
# 训练数据 ## PMC-CLIP使用的训练数据 ### 数据来源:PMC(PubMed Central) PMC是生物医学和生命科学领域的开放获取数据库。 ### 数据类型:医学图像-摘要配对 | 图像类型 | 文本描述示例 | |---------|-------------| | 胸部X光 | "chest X-ray showing pneumonia" | | 脑部MRI | "brain MRI scan with contrast" | | CT扫描 | "CT scan of abdomen showing lesions" | | 病理切片 | "histopathology of tumor tissue" | ### 配对关系 ``` 配对数据示例: ┌─────────────────┬──────────────────────┐ │ 图像 │ 文本描述 │ ├─────────────────┼──────────────────────┤ │ 胸部X光图像 │ "chest X-ray showing │ │ │ pneumonia" │ ← 配对✓ │ │ │ │ 脑部MRI图像 │ "brain MRI scan" │ ← 配对✓ │ │ │ │ 胸部X光图像 │ "brain MRI scan" │ ← 不配对✗ └─────────────────┴──────────────────────┘ ``` ### 训练数据的作用 1. **提供监督信号**:图像-文本配对告诉模型什么是正确的对应 2. **学习跨模态关系**:模型从大量配对中学习图像和文本的关联 3. **医学领域知识**:PMC数据确保模型理解医学图像和术语 ### 数据规模 - CLIP原版:4亿图像-文本对 - PMC-CLIP:百万级医学图像-摘要对 - 数据质量:经过同行评审的医学文献 ### 为什么配对数据重要? 没有配对数据: - 模型不知道图像和文本应该如何对应 - 无法计算对比损失 - 无法学习跨模态对齐 有配对数据: - 提供明确的监督信号 - 可以计算对比损失 - 可以学习跨模态语义关系
# 完整训练流程图 ## CLIP训练过程详解 ``` ┌─────────────────────────────────────────────────────┐ │ CLIP训练过程 │ └─────────────────────────────────────────────────────┘ 第1步:准备配对数据 ┌─────────────┐ ┌─────────────┐ │ Image 1 │ │ "chest X-ray"│ │ Image 2 │ │ "brain MRI" │ │ ... │ │ ... │ │ Image N │ │ "CT scan" │ └──────┬──────┘ └──────┬───────┘ │ │ 第2步:编码器前向传播 ▼ ▼ ┌─────────────┐ ┌─────────────┐ │ Image │ │ Text │ │ Encoder │ │ Encoder │ │ (预训练ResNet)│ │ (预训练BERT) │ └──────┬──────┘ └──────┬───────┘ │ │ ▼ ▼ ┌─────────────┐ ┌─────────────┐ │ Projection │ │ Projection │ │ Layer │ │ Layer │ │ (2048→768) │ │ (768→768) │ └──────┬──────┘ └──────┬───────┘ │ │ 第3步:共享特征空间 ▼ ▼ ┌───────────────────────────────────────┐ │ 共享特征空间 (768维) │ │ Image1: [0.2, 0.5, ..., 0.8] │ │ Text1: [0.2, 0.5, ..., 0.8] ← 越接近越好│ │ │ │ Image2: [0.1, 0.9, ..., 0.3] │ │ Text1: [0.2, 0.5, ..., 0.8] ← 越远越好 │ └───────────────────┬─────────────────┘ │ 第4步:计算相似度 ▼ ┌─────────────┐ │ 计算相似度 │ │ (N×N矩阵) │ └──────┬──────┘ │ 第5步:计算对比损失 ▼ ┌─────────────┐ │ 对比损失 │ │ 最大化对角线 │ │ 最小化非对角 │ └──────┬──────┘ │ 第6步:反向传播更新 ▼ ┌─────────────┐ │ 反向传播更新 │ │ 两个编码器 │ │ + 投影层 │ └─────────────┘ ``` ## 各步骤详解 ### 步骤1:数据准备 - 收集图像-文本配对数据 - 确保配对质量 - 构建训练批次 ### 步骤2:编码器前向传播 - 图像通过ResNet提取特征 - 文本通过BERT提取特征 - 保持预训练知识 ### 步骤3:投影到共享空间 - 图像特征通过投影层(2048→768) - 文本特征通过投影层(768→768) - 统一到可比的维度 ### 步骤4:计算相似度 - 计算N×N相似度矩阵 - 每个图像与所有文本比较 - 每个文本与所有图像比较 ### 步骤5:计算对比损失 - 对角线(正确配对)最大化 - 非对角线(错误配对)最小化 - 对称计算(图像→文本,文本→图像) ### 步骤6:反向传播更新 - 同时更新两个编码器 - 同时更新投影层 - 保持预训练知识的同时学习跨模态对齐
# 为什么预训练模型还能用? ## 预训练模型提供了良好的初始化 ### 预训练模型对CLIP的贡献 | 编码器 | 预训练任务 | 对CLIP的贡献 | |--------|-----------|-------------| | **ResNet** | ImageNet图像分类 | 已学会提取图像特征(边缘→纹理→物体) | | **BERT** | 语言模型理解 | 已学会理解文本语义 | ### 预训练学到什么? #### ResNet学到的图像特征 ``` 浅层:边缘、颜色、纹理 中层:局部模式、部件 深层:整体物体、场景 ``` #### BERT学到的文本特征 ``` 浅层:语法、词性 中层:短语、句法 深层:语义、上下文理解 ``` ## CLIP的微调过程 ### 训练策略 1. **加载预训练权重**:初始化编码器 2. **随机初始化投影层**:从头学习跨模态映射 3. **联合训练**:同时优化两个编码器 4. **学习率调整**:编码器用较小学习率,投影层用较大学习率 ### 微调效果 ``` 预训练模型(如ResNet) ↓ 已具备特征提取能力 ↓ CLIP微调 ↓ 保持特征提取能力 + 学习跨模态对齐 ↓ 比从头训练快得多、效果好得多 ``` ## 为什么不用从头训练? | 对比 | 从头训练 | 基于预训练微调 | |------|---------|--------------| | **训练数据** | 需要更多数据 | 需要较少数据 | | **训练时间** | 更长 | 更短 | | **收敛速度** | 更慢 | 更快 | | **最终效果** | 可能更差 | 通常更好 | ## 预训练+微调的优势 1. **特征提取能力**:预训练模型已经学会提取高质量特征 2. **知识迁移**:将大规模预训练的知识迁移到CLIP任务 3. **快速收敛**:好的初始化使训练更快收敛 4. **更好效果**:预训练的知识提供更强的泛化能力 ## 总结 **不是神奇**:是大量配对数据 + 对比学习训练出来的 **共享空间**:投影层将两个模态映射到同一维度空间 **对比损失**:让正确配对接近,错误配对远离 **预训练基础**:利用了ResNet和BERT已有的特征提取能力 **联合训练**:同时优化两个编码器学习跨模态对齐
代码实现
# 环境配置 ## 导入依赖库 ```python # 基础库 from collections import OrderedDict from typing import Optional, Sequence, Tuple import math # PyTorch相关 import torch import torch.nn.functional as F from torch import nn # 图像处理 from torchvision.transforms import ( Normalize, Compose, RandomResizedCrop, InterpolationMode, ToTensor, Resize, CenterCrop ) # Transformers库 from transformers import AutoTokenizer, AutoModel # 图像显示 from PIL import Image import matplotlib.pyplot as plt ``` ## 检查环境 ```bash # 检查PyTorch版本 !pip show torch # Name: torch # Version: 2.6.0+cu124 # 检查Transformers版本 !pip show transformers # Name: transformers # Version: 4.54.0 ``` ## 环境要求 | 组件 | 版本要求 | 说明 | |------|---------|------| | Python | >= 3.8 | 推荐使用3.11 | | PyTorch | >= 2.0.0 | 支持CUDA加速 | | Transformers | >= 4.54.0 | 用于加载BERT | | CUDA | 推荐有 | GPU加速推理 | ## 设备配置 ```python # 自动检测设备 device = 'cuda' if torch.cuda.is_available() else 'cpu' print(f'Using device: {device}') ```
# ResNet架构实现 ## Bottleneck模块 ```python class Bottleneck(nn.Module): expansion = 4 def __init__(self, inplanes, planes, stride=1): super().__init__() # 1x1卷积降维 self.conv1 = nn.Conv2d(inplanes, planes, 1, bias=False) self.bn1 = nn.BatchNorm2d(planes) self.relu1 = nn.ReLU(inplace=True) # 3x3卷积 self.conv2 = nn.Conv2d(planes, planes, 3, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(planes) self.relu2 = nn.ReLU(inplace=True) # 步长>1时的平均池化 self.avgpool = nn.AvgPool2d(stride) if stride > 1 else nn.Identity() # 1x1卷积升维 self.conv3 = nn.Conv2d(planes, planes * self.expansion, 1, bias=False) self.bn3 = nn.BatchNorm2d(planes * self.expansion) self.relu3 = nn.ReLU(inplace=True) # 下采样(残差连接) self.downsample = None self.stride = stride if stride > 1 or inplanes != planes * Bottleneck.expansion: self.downsample = nn.Sequential(OrderedDict([ ("-1", nn.AvgPool2d(stride)), ("0", nn.Conv2d(inplanes, planes * self.expansion, 1, stride=1, bias=False)), ("1", nn.BatchNorm2d(planes * self.expansion)) ])) def forward(self, x: torch.Tensor): identity = x out = self.relu1(self.bn1(self.conv1(x))) out = self.relu2(self.bn2(self.conv2(out))) out = self.avgpool(out) out = self.bn3(self.conv3(out)) if self.downsample is not None: identity = self.downsample(x) out += identity # 残差连接 out = self.relu3(out) return out ``` ## 注意力池化层 ```python class AttentionPool2d(nn.Module): def __init__(self, spacial_dim: int, embed_dim: int, num_heads: int, output_dim: int = None): super().__init__() # 位置编码 self.positional_embedding = nn.Parameter( torch.randn(spacial_dim ** 2 + 1, embed_dim) / embed_dim ** 0.5 ) # QKV投影 self.k_proj = nn.Linear(embed_dim, embed_dim) self.q_proj = nn.Linear(embed_dim, embed_dim) self.v_proj = nn.Linear(embed_dim, embed_dim) self.c_proj = nn.Linear(embed_dim, output_dim or embed_dim) self.num_heads = num_heads def forward(self, x): # NCHW -> (HW)NC x = x.reshape(x.shape[0], x.shape[1], x.shape[2] * x.shape[3]).permute(2, 0, 1) # 添加CLS token x = torch.cat([x.mean(dim=0, keepdim=True), x], dim=0) # 添加位置编码 x = x + self.positional_embedding[:, None, :].to(x.dtype) # 多头注意力 x, _ = F.multi_head_attention_forward( query=x, key=x, value=x, embed_dim_to_check=x.shape[-1], num_heads=self.num_heads, q_proj_weight=self.q_proj.weight, k_proj_weight=self.k_proj.weight, v_proj_weight=self.v_proj.weight, in_proj_weight=None, in_proj_bias=torch.cat([self.q_proj.bias, self.k_proj.bias, self.v_proj.bias]), bias_k=None, bias_v=None, add_zero_attn=False, dropout_p=0, out_proj_weight=self.c_proj.weight, out_proj_bias=self.c_proj.bias, use_separate_proj_weight=True, training=self.training, need_weights=False ) return x[0] # 返回CLS token ``` ## ModifiedResNet完整实现 ```python class ModifiedResNet(nn.Module): def __init__(self, layers, output_dim, heads, image_size=224, width=64): super().__init__() self.output_dim = output_dim self.image_size = image_size # 3层Stem self.conv1 = nn.Conv2d(3, width // 2, kernel_size=3, stride=2, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(width // 2) self.relu1 = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(width // 2, width // 2, kernel_size=3, stride=2, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(width // 2) self.relu2 = nn.ReLU(inplace=True) self.conv3 = nn.Conv2d(width // 2, width, kernel_size=3, stride=2, padding=1, bias=False) self.bn3 = nn.BatchNorm2d(width) self.relu3 = nn.ReLU(inplace=True) self.avgpool = nn.AvgPool2d(2) # 残差层 self._inplanes = width self.layer1 = self._make_layer(width, layers[0]) self.layer2 = self._make_layer(width * 2, layers[1], stride=2) self.layer3 = self._make_layer(width * 4, layers[2], stride=2) self.layer4 = self._make_layer(width * 8, layers[3], stride=2) # 注意力池化 embed_dim = width * 32 # ResNet特征维度 self.attnpool = AttentionPool2d(image_size // 32, embed_dim, heads, output_dim) def _make_layer(self, planes, blocks, stride=1): layers = [Bottleneck(self._inplanes, planes, stride)] self._inplanes = planes * Bottleneck.expansion for _ in range(1, blocks): layers.append(Bottleneck(self._inplanes, planes)) return nn.Sequential(*layers) def forward(self, x): x = self.stem(x) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) x = self.layer4(x) x = self.attnpool(x) visual_output = dict.fromkeys(["image_features", "mim_loss"], None) visual_output.update({ 'image_features': x, }) return visual_output def stem(self, x): x = self.relu1(self.bn1(self.conv1(x))) x = self.relu2(self.bn2(self.conv2(x))) x = self.relu3(self.bn3(self.conv3(x))) x = self.avgpool(x) return x ```
# 图像预处理 ## RGB转换函数 ```python def _convert_to_rgb(image): """将图像转换为RGB格式""" return image.convert('RGB') ``` ## 图像变换流程 ```python def image_transform( image_size: int, mean: Optional[Tuple[float, ...]] = None, std: Optional[Tuple[float, ...]] = None, fill_color: int = 0, ): """ 图像预处理变换 Args: image_size: 目标图像尺寸 mean: 归一化均值 std: 归一化标准差 fill_color: 填充颜色 """ # 处理方形尺寸 if isinstance(image_size, (list, tuple)) and image_size[0] == image_size[1]: image_size = image_size[0] # 使用OpenAI数据集的统计值 mean = mean or (0.48145466, 0.4578275, 0.40821073) std = std or (0.26862954, 0.26130258, 0.27577711) normalize = Normalize(mean=mean, std=std) # 构建变换序列 transforms = [ Resize(image_size, interpolation=InterpolationMode.BICUBIC), CenterCrop(image_size), _convert_to_rgb, ToTensor(), normalize, ] return Compose(transforms) ``` ## 加载图像 ```python from PIL import Image # 定义图像路径 image_path_ls = [ 'chest_X-ray.jpg', 'brain_MRI.jpg' ] # 创建预处理器 preprocess_val = image_transform(image_size=224) # 加载图像 images = [] image_tensor = [] for image_path in image_path_ls: image = Image.open(image_path).convert('RGB') images.append(image) image_tensor.append(preprocess_val(image)) # 堆叠为batch image_tensor = torch.stack(image_tensor, dim=0).to(device) print(f'Image tensor shape: {image_tensor.shape}') # 输出: Image tensor shape: torch.Size([2, 3, 224, 224]) ``` ## 下载测试图像 ```bash # 下载胸部X光图像 !wget https://huggingface.co/datasets/axiong/pmc_oa/resolve/main/chest_X-ray.jpg # 下载脑部MRI图像 !wget https://huggingface.co/datasets/axiong/pmc_oa/resolve/main/brain_MRI.jpg ```
# 加载模型 ## 下载模型权重 ```bash # 下载文本编码器(约418MB) !wget https://huggingface.co/datasets/axiong/pmc_oa/resolve/main/text_encoder.pth # 下载图像编码器 ResNet50(约144MB) !wget https://huggingface.co/datasets/axiong/pmc_oa/resolve/main/image_encoder(resnet50).pth # 下载文本投影层(约2.3MB) !wget https://huggingface.co/datasets/axiong/pmc_oa/resolve/main/text_projection_layer.pth ``` ## 初始化图像编码器 ```python # 创建ModifiedResNet模型 image_encoder = ModifiedResNet( layers=[3, 4, 6, 3], # ResNet50结构 output_dim=768, # 输出维度 heads=8, # 注意力头数 image_size=224, # 输入图像尺寸 width=64 # 基础宽度 ) # 加载预训练权重 image_encoder.load_state_dict(torch.load('image_encoder(resnet50).pth')) # 移到设备 image_encoder = image_encoder.to(device) ``` ## 初始化文本编码器 ```python # 加载BiomedBERT分词器 tokenizer = AutoTokenizer.from_pretrained( 'microsoft/BiomedNLP-BiomedBERT-base-uncased-abstract' ) # 加载BiomedBERT模型 text_encoder = AutoModel.from_pretrained( 'microsoft/BiomedNLP-BiomedBERT-base-uncased-abstract' ) # 加载PMC-CLIP微调权重 text_encoder.load_state_dict(torch.load('text_encoder.pth')) # 移到设备 text_encoder = text_encoder.to(device) ``` ## 加载投影层和温度参数 ```python # 加载文本投影层 text_projection_layer = torch.load('text_projection_layer.pth') text_projection_layer = nn.Parameter(text_projection_layer) text_projection_layer = text_projection_layer.to(device) # 设置温度参数 logit_scale = 4.4292 ``` ## 模型参数汇总 | 组件 | 参数量 | 文件大小 | |------|-------|---------| | 图像编码器 (ResNet50) | ~25M | 144MB | | 文本编码器 (BiomedBERT) | ~110M | 418MB | | 文本投影层 | 0.59M | 2.3MB | | **总计** | **~135M** | **~564MB** |
# 特征提取 ## 图像特征提取 ```python # 提取图像特征 image_feature = image_encoder(image_tensor) # 处理字典输出 if isinstance(image_feature, dict): image_feature = image_feature['image_features'] print(f'Image size: {image_tensor.shape}') print(f'Feature size: {image_feature.shape}') # 输出: Image size: torch.Size([2, 3, 224, 224]) # Feature size: torch.Size([2, 768]) ``` ## 文本特征提取 ```python # 准备文本输入 bert_input = [ 'chest X-ray', 'brain MRI', ] # 分词 encoded_input = tokenizer( bert_input, padding='max_length', truncation=True, max_length=77, return_tensors='pt' ) input_ids = encoded_input['input_ids'].to(device) # 文本编码 text_feature = text_encoder(input_ids) last_hidden_state = text_feature.last_hidden_state # [2, 77, 768] pooler_output = text_feature.pooler_output # [2, 768] # 应用投影层 text_feature = pooler_output @ text_projection_layer print(f'Text feature shape: {text_feature.shape}') # 输出: Text feature shape: torch.Size([2, 768]) ``` ## 特征归一化 ```python # L2归一化(重要!) image_feature = image_feature / image_feature.norm(dim=-1, keepdim=True) text_feature = text_feature / text_feature.norm(dim=-1, keepdim=True) # 验证归一化 print(f'Image norm: {image_feature[0].norm().item():.4f}') print(f'Text norm: {text_feature[0].norm().item():.4f}') # 输出: Image norm: 1.0000 # Text norm: 1.0000 ``` ## 特征提取流程图 ``` 图像输入 [2, 3, 224, 224] ↓ ResNet编码器 ↓ 图像特征 [2, 768] ↓ L2归一化 ↓ 归一化图像特征 [2, 768] 文本输入 ["chest X-ray", "brain MRI"] ↓ Tokenizer ↓ input_ids [2, 77] ↓ BERT编码器 ↓ pooler_output [2, 768] ↓ 投影层 ↓ 文本特征 [2, 768] ↓ L2归一化 ↓ 归一化文本特征 [2, 768] ```
# 相似度计算与可视化 ## 计算相似度矩阵 ```python import math # 温度缩放并计算相似度 similarity = (math.exp(logit_scale) * image_feature @ text_feature.T).softmax(dim=-1) print(f'Similarity matrix shape: {similarity.shape}') print(f'Temperature: {math.exp(logit_scale):.2f}') # 输出: Similarity matrix shape: torch.Size([2, 2]) # Temperature: 83.87 ``` ## 显示相似度矩阵 ```python # 打印相似度矩阵 print("\n相似度矩阵:") print(similarity.detach().cpu().numpy()) # 示例输出: # [[0.9834 0.0166] # [0.0123 0.9877]] ``` ## 结果可视化 ```python import matplotlib.pyplot as plt # 遍历每个图像 for i, (image_path, image) in enumerate(zip(image_path_ls, images)): print(f'\n{'='*50}') print(f'图像: {image_path}') print(f'{'='*50}') # 显示图像 plt.figure(figsize=(6, 6)) plt.imshow(image) plt.axis('off') plt.title(image_path) plt.show() # 打印相似度分数 print(f'\n相似度分数:') for j in range(len(bert_input)): score = similarity[i, j].item() # 添加颜色标记 if i == j: # 正确配对 print(f' ✓ {bert_input[j]}: {score:.4f}') else: # 错误配对 print(f' ✗ {bert_input[j]}: {score:.4f}') ``` ## 输出示例 ``` ================================================== 图像: chest_X-ray.jpg ================================================== [显示胸部X光图像] 相似度分数: ✓ chest X-ray: 0.9834 ✗ brain MRI: 0.0166 ================================================== 图像: brain_MRI.jpg ================================================== [显示脑部MRI图像] 相似度分数: ✗ chest X-ray: 0.0123 ✓ brain MRI: 0.9877 ``` ## 完整推理代码 ```python def compute_similarity(image_paths, text_inputs): """ 计算图像-文本相似度 Args: image_paths: 图像路径列表 text_inputs: 文本描述列表 Returns: similarity: 相似度矩阵 """ # 加载图像 images = [] image_tensors = [] for path in image_paths: img = Image.open(path).convert('RGB') images.append(img) image_tensors.append(preprocess_val(img)) image_tensor = torch.stack(image_tensors, dim=0).to(device) # 提取特征 with torch.no_grad(): image_feat = image_encoder(image_tensor) if isinstance(image_feat, dict): image_feat = image_feat['image_features'] # 文本编码 encoded = tokenizer(text_inputs, padding='max_length', truncation=True, max_length=77, return_tensors='pt') input_ids = encoded['input_ids'].to(device) text_feat = text_encoder(input_ids).pooler_output text_feat = text_feat @ text_projection_layer # 归一化 image_feat = image_feat / image_feat.norm(dim=-1, keepdim=True) text_feat = text_feat / text_feat.norm(dim=-1, keepdim=True) # 计算相似度 similarity = (math.exp(logit_scale) * image_feat @ text_feat.T).softmax(dim=-1) return similarity, images ```
OpenAI CLIP
# OpenAI CLIP 概述 ## 背景 2021年1月,OpenAI宣布了两个多模态模型:**DALL-E**和**CLIP**,两者都以某种方式连接文本和图像。本文我们将从头实现CLIP模型。 ## 什么是CLIP? **CLIP**全称**Contrastive Language-Image Pre-training**(对比语言-图像预训练)。 ### 核心功能 CLIP学习**整句句子**与其描述的**图像**之间的关系。训练完成后: - 输入一个句子,模型能够检索最相关的图像 - 输入一张图像,模型能够找到最相关的描述 ### 与传统方法的区别 | 传统图像分类 | CLIP方法 | |-------------|----------| | 单个类别标签(car, dog等) | 完整的自然语言描述 | | 在ImageNet上训练优化分类 | 在图像-文本对上学习 | | 需要重新训练才能识别新类别 | 零样本即可识别新类别 | ## 为什么CLIP有趣? ### 零样本能力 CLIP在大规模数据集上训练后,可以无需额外训练就作为分类器使用。甚至比专门在ImageNet上优化的SOTA模型表现更好! ### 学习更丰富的表示 在完整句子上训练,模型可以学到更多东西,发现图像和文本之间的模式。 ## 示例效果 输入查询文本: - "a boy jumping with skateboard" - "a girl jumping from swing" 模型可以检索出最相关的图像。 ## 论文信息 **标题**: Learning Transferable Visual Models From Natural Language Supervision **作者**: OpenAI (Alec Radford et al.) **年份**: 2021 **亮点**: 大规模对比学习、零样本能力、跨模态迁移
# CLIP核心思想 ## 学习目标 **我们希望模型学习:为给定图像和描述它的文本,生成"相似的表示(向量)"。** 也就是说,无论我们给它图像还是描述它的文本,我们都希望它为两者产生相同大小的向量。 ## 直观理解 ``` 输入: 图像A + 描述文本T ↓ 图像编码器 → 图像特征向量 [256维] 文本编码器 → 文本特征向量 [256维] ↓ 两个向量应该非常相似(接近) ``` ## 如何判断两个向量相似? 在线性代数中,衡量两个向量是否具有相似特征的常用方法是计算它们的**点积**(内积): - 如果点积值大 → 它们相似 - 如果点积值小 → 它们不相似 ## CLIP的工作原理 ### 1. 编码阶段 ``` 图像 → 图像编码器 → 2048维特征 → 投影层 → 256维嵌入 文本 → 文本编码器 → 768维特征 → 投影层 → 256维嵌入 ``` ### 2. 对比学习 - **拉近**: 匹配的图像-文本对的嵌入应该接近 - **推远**: 不匹配的图像-文本对的嵌入应该远离 ### 3. 相似度计算 ``` 相似度矩阵 = text_embeddings @ image_embeddings.T = (batch_size, 256) @ (256, batch_size) = (batch_size, batch_size) ``` ## 训练策略 ### 数据规模 - CLIP原版在**4亿**图像-文本对上训练 - 使用来自互联网的自然图像和文本 ### 为什么大规模重要? - 更多样化的视觉概念 - 更丰富的语言描述 - 更好的泛化能力 ## 理想情况下的相似度矩阵 如果模型训练完美,相似度矩阵的softmax结果应该是**单位矩阵**: ``` [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]] ``` 对角线为1.0(正确匹配),其余为0.0(错误匹配)。
# CLIP模型架构 ## 整体架构 ``` ┌─────────────────────────────────────────────────┐ │ CLIP模型架构 │ └─────────────────────────────────────────────────┘ 图像输入 文本输入 (224×224×3) ("a dog on grass") ↓ ↓ ┌───────────┐ ┌───────────┐ │Image │ │Text │ │Encoder │ │Encoder │ │(ResNet50) │ │(DistilBERT)│ └─────┬─────┘ └─────┬─────┘ │ │ ↓ ↓ 图像特征 文本特征 [batch, 2048] [batch, 768] │ │ ↓ ↓ ┌───────────┐ ┌───────────┐ │Image │ │Text │ │Projection │ │Projection │ │Head │ │Head │ └─────┬─────┘ └─────┬─────┘ │ │ ↓ ↓ 图像嵌入 文本嵌入 [batch, 256] [batch, 256] │ │ └──────┬───────────────────┘ ↓ 计算相似度矩阵 [batch, batch] ↓ 对比损失 ``` ## 图像编码器 ### 使用timm库创建ResNet50 ```python import timm import torch.nn as nn class ImageEncoder(nn.Module): def __init__(self, model_name='resnet50', pretrained=True, trainable=True): super().__init__() self.model = timm.create_model( model_name, pretrained, num_classes=0, # 移除分类头 global_pool="avg" # 全局平均池化 ) for p in self.model.parameters(): p.requires_grad = trainable def forward(self, x): return self.model(x) # 输出: [batch, 2048] ``` ### 图像编码器特点 | 特性 | 说明 | |------|------| | **模型** | ResNet50 | | **预训练** | ImageNet | | **输出维度** | 2048 | | **可训练** | 可配置 | ## 文本编码器 ### 使用DistilBERT ```python from transformers import DistilBertModel class TextEncoder(nn.Module): def __init__(self, model_name="distilbert-base-uncased", pretrained=True, trainable=True): super().__init__() if pretrained: self.model = DistilBertModel.from_pretrained(model_name) else: self.model = DistilBertModel(config=DistilBertConfig()) for p in self.model.parameters(): p.requires_grad = trainable # 使用CLS token的表示作为句子嵌入 self.target_token_idx = 0 def forward(self, input_ids, attention_mask): output = self.model(input_ids=input_ids, attention_mask=attention_mask) last_hidden_state = output.last_hidden_state return last_hidden_state[:, self.target_token_idx, :] # 输出: [batch, 768] ``` ### 文本编码器特点 | 特性 | 说明 | |------|------| | **模型** | DistilBERT | | **输出维度** | 768 | | **使用Token** | CLS token | | **原因** | CLS token捕捉整个句子的表示 | ## 投影头 ### 为什么要投影? 图像编码器输出2048维,文本编码器输出768维,维度不同无法直接比较。投影头将它们映射到相同的256维空间。 ### 投影头实现 ```python class ProjectionHead(nn.Module): def __init__(self, embedding_dim, projection_dim=256, dropout=0.1): super().__init__() self.projection = nn.Linear(embedding_dim, projection_dim) self.gelu = nn.GELU() self.fc = nn.Linear(projection_dim, projection_dim) self.dropout = nn.Dropout(dropout) self.layer_norm = nn.LayerNorm(projection_dim) def forward(self, x): projected = self.projection(x) x = self.gelu(projected) x = self.fc(x) x = self.dropout(x) x = x + projected # 残差连接 x = self.layer_norm(x) return x # 输出: [batch, 256] ``` ### 投影头结构 ``` 输入 [embedding_dim] ↓ Linear → [projection_dim] ↓ GELU激活 ↓ Linear → [projection_dim] ↓ Dropout ↓ 残差连接 (x + projected) ↓ LayerNorm ↓ 输出 [projection_dim] ``` ## 完整CLIP模型 ```python class CLIPModel(nn.Module): def __init__(self, temperature=1.0, image_embedding=2048, text_embedding=768): super().__init__() self.image_encoder = ImageEncoder() self.text_encoder = TextEncoder() self.image_projection = ProjectionHead(embedding_dim=image_embedding) self.text_projection = ProjectionHead(embedding_dim=text_embedding) self.temperature = temperature def forward(self, batch): # 获取图像和文本特征 image_features = self.image_encoder(batch["image"]) text_features = self.text_encoder( input_ids=batch["input_ids"], attention_mask=batch["attention_mask"] ) # 获取图像和文本嵌入(相同维度) image_embeddings = self.image_projection(image_features) text_embeddings = self.text_projection(text_features) # 计算损失 logits = (text_embeddings @ image_embeddings.T) / self.temperature # 计算相似度矩阵作为目标 images_similarity = image_embeddings @ image_embeddings.T texts_similarity = text_embeddings @ text_embeddings.T targets = F.softmax( (images_similarity + texts_similarity) / 2 * self.temperature, dim=-1 ) # 计算交叉熵损失 texts_loss = cross_entropy(logits, targets, reduction='none') images_loss = cross_entropy(logits.T, targets.T, reduction='none') loss = (images_loss + texts_loss) / 2.0 return loss.mean() ```
# CLIP对比损失 ## 损失函数原理 ### 目标矩阵 理想情况下,相似度矩阵的softmax应该是单位矩阵: ```python # 示例:batch_size=4, dim=256 batch_size = 4 dim = 256 embeddings = torch.randn(batch_size, dim) similarity_matrix = embeddings @ embeddings.T # softmax后 print(F.softmax(similarity_matrix, dim=-1)) # 输出接近单位矩阵 ``` ### 为什么这样设计? - **对角线元素=1**: 正确配对的图像-文本完全匹配 - **非对角线元素=0**: 错误配对完全不匹配 ## 损失计算步骤 ### 1. 计算相似度矩阵 ```python # logits形状: [batch_size, batch_size] logits = (text_embeddings @ image_embeddings.T) / temperature ``` ### 2. 计算目标矩阵 ```python # 图像之间的相似度 images_similarity = image_embeddings @ image_embeddings.T # 文本之间的相似度 texts_similarity = text_embeddings @ text_embeddings.T # 合并作为目标 targets = F.softmax( (images_similarity + texts_similarity) / 2 * temperature, dim=-1 ) ``` ### 3. 交叉熵损失 ```python def cross_entropy(preds, targets, reduction='none'): log_softmax = nn.LogSoftmax(dim=-1) loss = (-targets * log_softmax(preds)).sum(1) if reduction == "none": return loss elif reduction == "mean": return loss.mean() # 文本到图像的损失 texts_loss = cross_entropy(logits, targets, reduction='none') # 图像到文本的损失 images_loss = cross_entropy(logits.T, targets.T, reduction='none') # 对称损失 loss = (images_loss + texts_loss) / 2.0 ``` ## 温度参数 ### 温度的作用 ```python temperature = 1.0 # 可学习参数 logits = (text_embeddings @ image_embeddings.T) / temperature ``` | 温度值 | 效果 | |--------|------| | **高温度** | 相似度分布更平缓,训练更稳定 | | **低温度** | 相似度分布更陡峭,区分度更高 | | **temperature=1.0** | 无缩放效果 | ## 为什么不简化为torch.arange? 更简单的做法是: ```python # 简单版本(不适用于多标签情况) nn.CrossEntropyLoss()(logits, torch.arange(batch_size)) ``` ### 为什么不使用简化版本? 1. **多标签数据集**: 一张图像可能有多条描述 2. **批内重复**: 同一图像可能多次出现在同一batch 3. **正确处理**: 完整目标矩阵可以正确处理这些情况 ## 损失函数可视化 ``` 图像1 图像2 图像3 文本1 [0.95 0.03 0.02] ← 高(正确) 文本2 [0.02 0.96 0.02] ← 高(正确) 文本3 [0.03 0.01 0.96] ← 高(正确) ``` 训练目标:让对角线接近1,非对角线接近0。
# CLIP训练实现 ## 配置参数 ```python class CFG: # 训练参数 batch_size = 32 epochs = 4 device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 学习率 head_lr = 1e-3 image_encoder_lr = 1e-4 text_encoder_lr = 1e-5 weight_decay = 1e-3 # 模型参数 model_name = 'resnet50' image_embedding = 2048 text_encoder_model = "distilbert-base-uncased" text_embedding = 768 max_length = 200 # 投影头参数 num_projection_layers = 1 projection_dim = 256 dropout = 0.1 temperature = 1.0 # 图像大小 size = 224 ``` ## 数据集实现 ```python class CLIPDataset(torch.utils.data.Dataset): def __init__(self, image_filenames, captions, tokenizer, transforms): self.image_filenames = image_filenames self.captions = list(captions) self.encoded_captions = tokenizer( list(captions), padding=True, truncation=True, max_length=CFG.max_length ) self.transforms = transforms def __getitem__(self, idx): item = { key: torch.tensor(values[idx]) for key, values in self.encoded_captions.items() } # 加载并处理图像 image = cv2.imread(f"{CFG.image_path}/{self.image_filenames[idx]}") image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) image = self.transforms(image=image)['image'] item['image'] = torch.tensor(image).permute(2, 0, 1).float() item['caption'] = self.captions[idx] return item def __len__(self): return len(self.captions) ``` ## 图像增强 ```python import albumentations as A def get_transforms(mode="train"): return A.Compose([ A.Resize(CFG.size, CFG.size, always_apply=True), A.Normalize(max_pixel_value=255.0, always_apply=True), ]) ``` ## 训练循环 ```python def train_epoch(model, train_loader, optimizer, lr_scheduler, step): loss_meter = AvgMeter() tqdm_object = tqdm(train_loader, total=len(train_loader)) for batch in tqdm_object: batch = {k: v.to(CFG.device) for k, v in batch.items() if k != "caption"} # 前向传播 loss = model(batch) # 反向传播 optimizer.zero_grad() loss.backward() optimizer.step() if step == "batch": lr_scheduler.step() # 更新损失 count = batch["image"].size(0) loss_meter.update(loss.item(), count) tqdm_object.set_postfix( train_loss=loss_meter.avg, lr=get_lr(optimizer) ) return loss_meter ``` ## 优化器配置 ```python import itertools def main(): # 创建数据加载器 train_df, valid_df = make_train_valid_dfs() tokenizer = DistilBertTokenizer.from_pretrained(CFG.text_tokenizer) train_loader = build_loaders(train_df, tokenizer, mode="train") valid_loader = build_loaders(valid_df, tokenizer, mode="valid") # 创建模型 model = CLIPModel().to(CFG.device) # 配置不同层的学习率 params = [ { "params": model.image_encoder.parameters(), "lr": CFG.image_encoder_lr }, { "params": model.text_encoder.parameters(), "lr": CFG.text_encoder_lr }, { "params": itertools.chain( model.image_projection.parameters(), model.text_projection.parameters() ), "lr": CFG.head_lr, "weight_decay": CFG.weight_decay } ] # 优化器和调度器 optimizer = torch.optim.AdamW(params, weight_decay=0.) lr_scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="min", patience=1, factor=0.8 ) # 训练循环 best_loss = float('inf') for epoch in range(CFG.epochs): print(f"Epoch: {epoch + 1}") model.train() train_loss = train_epoch(model, train_loader, optimizer, lr_scheduler, "epoch") model.eval() with torch.no_grad(): valid_loss = valid_epoch(model, valid_loader) # 保存最佳模型 if valid_loss.avg < best_loss: best_loss = valid_loss.avg torch.save(model.state_dict(), "best.pt") print("Saved Best Model!") lr_scheduler.step(valid_loss.avg) ``` ## 训练技巧 | 技巧 | 说明 | |------|------| | **不同学习率** | 编码器用小学习率,投影头用大学习率 | | **权重衰减** | 防止过拟合 | | **学习率调度** | ReduceLROnPlateau自适应调整 | | **早停** | 保存验证损失最低的模型 |
# CLIP推理应用 ## 获取图像嵌入 ```python def get_image_embeddings(valid_df, model_path): tokenizer = DistilBertTokenizer.from_pretrained(CFG.text_tokenizer) valid_loader = build_loaders(valid_df, tokenizer, mode="valid") model = CLIPModel().to(CFG.device) model.load_state_dict(torch.load(model_path, map_location=CFG.device)) model.eval() valid_image_embeddings = [] with torch.no_grad(): for batch in tqdm(valid_loader): image_features = model.image_encoder(batch["image"].to(CFG.device)) image_embeddings = model.image_projection(image_features) valid_image_embeddings.append(image_embeddings) return model, torch.cat(valid_image_embeddings) ``` ## 查找匹配图像 ```python def find_matches(model, image_embeddings, query, image_filenames, n=9): tokenizer = DistilBertTokenizer.from_pretrained(CFG.text_tokenizer) # 编码查询文本 encoded_query = tokenizer([query]) batch = { key: torch.tensor(values).to(CFG.device) for key, values in encoded_query.items() } with torch.no_grad(): text_features = model.text_encoder( input_ids=batch["input_ids"], attention_mask=batch["attention_mask"] ) text_embeddings = model.text_projection(text_features) # 归一化 image_embeddings_n = F.normalize(image_embeddings, p=2, dim=-1) text_embeddings_n = F.normalize(text_embeddings, p=2, dim=-1) # 计算相似度 dot_similarity = text_embeddings_n @ image_embeddings_n.T # 获取top-k values, indices = torch.topk(dot_similarity.squeeze(0), n * 5) # 显示结果 matches = [image_filenames[idx] for idx in indices[::5]] _, axes = plt.subplots(3, 3, figsize=(10, 10)) for match, ax in zip(matches, axes.flatten()): image = cv2.imread(f"{CFG.image_path}/{match}") image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) ax.imshow(image) ax.axis("off") plt.show() ``` ## 使用示例 ```python # 获取验证集图像嵌入 _, valid_df = make_train_valid_dfs() model, image_embeddings = get_image_embeddings(valid_df, "best.pt") # 查询 find_matches( model, image_embeddings, query="dogs on the grass", image_filenames=valid_df['image'].values, n=9 ) ``` ## 应用场景 ### 1. 图像检索 输入文本描述,检索相关图像: - "dogs on the grass" → 找到草地上的狗的图片 - "a boy jumping with skateboard" → 找到男孩玩滑板的图片 ### 2. 零样本分类 ```python # 定义类别文本提示 class_prompts = [ "a photo of a dog", "a photo of a cat", "a photo of a bird", "a photo of a car" ] # 编码类别文本 text_features = encode_text(class_prompts) # 编码测试图像 image_features = encode_image(test_image) # 找到最匹配的类别 similarity = image_features @ text_features.T predicted_class = class_prompts[similarity.argmax()] ``` ### 3. 图像-文本匹配 验证图像和描述是否匹配: - 图像检索系统 - 自动标注 - 内容审核 ## 性能优化 | 技术 | 说明 | |------|------| | **批处理** | 一次处理多个查询 | | **归一化** | 使用余弦相似度提高稳定性 | | **缓存嵌入** | 预先计算并存储图像嵌入 | | **近似搜索** | 使用FAISS等加速大规模检索 | ## CLIP原版vs实现版本 | 特性 | OpenAI CLIP | 实现版本 | |------|-------------|----------| | **图像编码器** | Vision Transformer | ResNet50 | | **文本编码器** | Custom Transformer | DistilBERT | | **训练数据** | 4亿对 | Flickr 8k/30k | | **投影维度** | 512 | 256 | | **核心思想** | 相同 | 相同 |
CLIP代码详解
# 环境配置与导入 ## 安装依赖 ```bash pip install timm pip install transformers ``` ## 导入库 ```python # 基础库 import os import gc import cv2 import numpy as np import pandas as pd import itertools from tqdm.autonotebook import tqdm import matplotlib.pyplot as plt # PyTorch相关 import torch import torch.nn as nn import torch.nn.functional as F # timm库 (PyTorch Image Models) import timm # Transformers库 from transformers import ( DistilBertModel, DistilBertConfig, DistilBertTokenizer ) # 图像增强 import albumentations as A ``` ## Kaggle数据下载 ```python # 设置Kaggle凭证 os.environ['KAGGLE_USERNAME'] = "your_username" os.environ['KAGGLE_KEY'] = "your_api_key" # 下载Flickr 8k数据集 !kaggle datasets download -d adityajn105/flickr8k !unzip flickr8k.zip dataset = "8k" # 或下载Flickr 30k数据集 # !kaggle datasets download -d hsankesara/flickr-image-dataset # !unzip flickr-image-dataset.zip # dataset = "30k" ``` ## 数据预处理 ```python if dataset == "8k": df = pd.read_csv("captions.txt") df['id'] = [id_ for id_ in range(df.shape[0] // 5) for _ in range(5)] df.to_csv("captions.csv", index=False) df = pd.read_csv("captions.csv") image_path = "/content/Images" captions_path = "/content" elif dataset == "30k": df = pd.read_csv("/content/flickr30k_images/results.csv", delimiter="|") df.columns = ['image', 'caption_number', 'caption'] df['caption'] = df['caption'].str.lstrip() df['caption_number'] = df['caption_number'].str.lstrip() df.loc[19999, 'caption_number'] = "4" df.loc[19999, 'caption'] = "A dog runs across the grass." ids = [id_ for id_ in range(len(df) // 5) for _ in range(5)] df['id'] = ids df.to_csv("captions.csv", index=False) image_path = "/content/flickr30k_images/flickr30k_images" captions_path = "/content" # 查看数据 df.head() ``` ## 输出示例 ``` image caption id 0 1000268201_693b08cb0e.jpg A child in a pink dress is climbing up a set o... 0 1 1000268201_693b08cb0e.jpg A girl going into a wooden building . 0 2 1000268201_693b08cb0e.jpg A little girl climbing into a wooden playhouse . 0 3 1000268201_693b08cb0e.jpg A little girl climbing the stairs to her playh... 0 4 1000268201_693b08cb0e.jpg A little girl in a pink dress going into a woo... 0 ``` ## 工具函数 ```python class AvgMeter: """用于跟踪和计算平均指标""" def __init__(self, name="Metric"): self.name = name self.reset() def reset(self): self.avg, self.sum, self.count = [0] * 3 def update(self, val, count=1): self.count += count self.sum += val * count self.avg = self.sum / self.count def __repr__(self): text = f"{self.name}: {self.avg:.4f}" return text def get_lr(optimizer): """获取当前学习率""" for param_group in optimizer.param_groups: return param_group["lr"] ```
# 配置参数 ## CFG配置类 ```python class CFG: # 调试模式 debug = False # 路径配置 image_path = image_path captions_path = captions_path # 训练参数 batch_size = 32 num_workers = 2 epochs = 4 # 学习率配置 head_lr = 1e-3 # 投影头学习率 image_encoder_lr = 1e-4 # 图像编码器学习率 text_encoder_lr = 1e-5 # 文本编码器学习率 weight_decay = 1e-3 # 学习率调度器 patience = 1 factor = 0.8 # 设备配置 device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 图像编码器配置 model_name = 'resnet50' image_embedding = 2048 # 文本编码器配置 text_encoder_model = "distilbert-base-uncased" text_embedding = 768 text_tokenizer = "distilbert-base-uncased" max_length = 200 # 预训练配置 pretrained = True # 对图像和文本编码器都启用 trainable = True # 对图像和文本编码器都启用 # 温度参数 temperature = 1.0 # 图像尺寸 size = 224 # 投影头配置 num_projection_layers = 1 projection_dim = 256 # 共享嵌入空间维度 dropout = 0.1 ``` ## 配置说明 ### 学习率策略 | 组件 | 学习率 | 说明 | |------|-------|------| | 投影头 | 1e-3 | 从头学习,用较大学习率 | | 图像编码器 | 1e-4 | 微调预训练模型 | | 文本编码器 | 1e-5 | 微调预训练模型 | ### 为什么分层学习率? - **投影头**: 随机初始化,需要快速学习 - **编码器**: 已有预训练权重,需要微调而非破坏 ### 嵌入维度 ``` 图像特征: 2048维 (ResNet50输出) ↓ 投影 共享嵌入: 256维 ↑ 投影 文本特征: 768维 (DistilBERT输出) ``` ### 温度参数 - 温度=1.0: 无缩放 - 温度>1.0: 使分布更平缓 - 温度<1.0: 使分布更陡峭 ## 设备检查 ```python print(f"Using device: {CFG.device}") print(f"Image size: {CFG.size}x{CFG.size}") print(f"Batch size: {CFG.batch_size}") print(f"Projection dim: {CFG.projection_dim}") ```
# 数据集实现 ## CLIPDataset类 ```python class CLIPDataset(torch.utils.data.Dataset): def __init__(self, image_filenames, captions, tokenizer, transforms): """ 参数: image_filenames: 图像文件名列表 captions: 对应的标题列表 tokenizer: HuggingFace分词器 transforms: 图像变换 """ self.image_filenames = image_filenames self.captions = list(captions) # 预先编码所有标题 self.encoded_captions = tokenizer( list(captions), padding=True, truncation=True, max_length=CFG.max_length ) self.transforms = transforms def __getitem__(self, idx): # 获取预编码的标题 item = { key: torch.tensor(values[idx]) for key, values in self.encoded_captions.items() } # 加载和处理图像 image = cv2.imread(f"{CFG.image_path}/{self.image_filenames[idx]}") image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) image = self.transforms(image=image)['image'] item['image'] = torch.tensor(image).permute(2, 0, 1).float() item['caption'] = self.captions[idx] # 仅用于可视化 return item def __len__(self): return len(self.captions) ``` ## 图像变换 ```python def get_transforms(mode="train"): """获取图像变换""" if mode == "train": return A.Compose([ A.Resize(CFG.size, CFG.size, always_apply=True), A.Normalize(max_pixel_value=255.0, always_apply=True), ]) else: return A.Compose([ A.Resize(CFG.size, CFG.size, always_apply=True), A.Normalize(max_pixel_value=255.0, always_apply=True), ]) ``` ## 数据加载器 ```python def build_loaders(dataframe, tokenizer, mode): """构建数据加载器""" transforms = get_transforms(mode=mode) dataset = CLIPDataset( dataframe["image"].values, dataframe["caption"].values, tokenizer=tokenizer, transforms=transforms, ) dataloader = torch.utils.data.DataLoader( dataset, batch_size=CFG.batch_size, num_workers=CFG.num_workers, shuffle=True if mode == "train" else False, ) return dataloader ``` ## 划分训练集和验证集 ```python def make_train_valid_dfs(): """划分训练集和验证集""" dataframe = pd.read_csv(f"{CFG.captions_path}/captions.csv") max_id = dataframe["id"].max() + 1 if not CFG.debug else 100 # 获取所有图像ID image_ids = np.arange(0, max_id) # 随机选择20%作为验证集 np.random.seed(42) valid_ids = np.random.choice( image_ids, size=int(0.2 * len(image_ids)), replace=False ) # 划分数据集 train_ids = [id_ for id_ in image_ids if id_ not in valid_ids] train_dataframe = dataframe[dataframe["id"].isin(train_ids)].reset_index(drop=True) valid_dataframe = dataframe[dataframe["id"].isin(valid_ids)].reset_index(drop=True) return train_dataframe, valid_dataframe ``` ## 数据集统计 ```python # 使用示例 train_df, valid_df = make_train_valid_dfs() print(f"训练集大小: {len(train_df)}") print(f"验证集大小: {len(valid_df)}") print(f"图像数量: {train_df['id'].nunique()}") ``` ## 数据流程图 ``` 原始CSV文件 ↓ 读取为DataFrame ↓ 划分train/valid ↓ 创建CLIPDataset ↓ ├─ 预编码文本标题 (Tokenizer) └─ 加载图像路径 ↓ DataLoader ↓ ├─ 批量文本 (input_ids, attention_mask) └─ 批量图像 [batch, 3, 224, 224] ```
# 模型架构 ## 图像编码器 ```python class ImageEncoder(nn.Module): """ 将图像编码为固定大小的向量 """ def __init__( self, model_name=CFG.model_name, pretrained=CFG.pretrained, trainable=CFG.trainable ): super().__init__() # 使用timm创建ResNet50 self.model = timm.create_model( model_name, pretrained, num_classes=0, # 移除分类头 global_pool="avg" # 全局平均池化 ) # 设置是否可训练 for p in self.model.parameters(): p.requires_grad = trainable def forward(self, x): return self.model(x) # [batch, 2048] ``` ## 文本编码器 ```python class TextEncoder(nn.Module): def __init__( self, model_name=CFG.text_encoder_model, pretrained=CFG.pretrained, trainable=CFG.trainable ): super().__init__() if pretrained: self.model = DistilBertModel.from_pretrained(model_name) else: self.model = DistilBertModel(config=DistilBertConfig()) # 设置是否可训练 for p in self.model.parameters(): p.requires_grad = trainable # 使用CLS token作为句子表示 self.target_token_idx = 0 def forward(self, input_ids, attention_mask): output = self.model( input_ids=input_ids, attention_mask=attention_mask ) last_hidden_state = output.last_hidden_state # 返回CLS token的隐藏状态 return last_hidden_state[:, self.target_token_idx, :] # [batch, 768] ``` ## 投影头 ```python class ProjectionHead(nn.Module): def __init__( self, embedding_dim, projection_dim=CFG.projection_dim, dropout=CFG.dropout ): super().__init__() self.projection = nn.Linear(embedding_dim, projection_dim) self.gelu = nn.GELU() self.fc = nn.Linear(projection_dim, projection_dim) self.dropout = nn.Dropout(dropout) self.layer_norm = nn.LayerNorm(projection_dim) def forward(self, x): projected = self.projection(x) x = self.gelu(projected) x = self.fc(x) x = self.dropout(x) x = x + projected # 残差连接 x = self.layer_norm(x) return x # [batch, 256] ``` ## 完整CLIP模型 ```python class CLIPModel(nn.Module): def __init__( self, temperature=CFG.temperature, image_embedding=CFG.image_embedding, text_embedding=CFG.text_embedding, ): super().__init__() self.image_encoder = ImageEncoder() self.text_encoder = TextEncoder() self.image_projection = ProjectionHead(embedding_dim=image_embedding) self.text_projection = ProjectionHead(embedding_dim=text_embedding) self.temperature = temperature def forward(self, batch): # 获取图像和文本特征 image_features = self.image_encoder(batch["image"]) text_features = self.text_encoder( input_ids=batch["input_ids"], attention_mask=batch["attention_mask"] ) # 获取图像和文本嵌入(相同维度) image_embeddings = self.image_projection(image_features) text_embeddings = self.text_projection(text_features) # 计算损失 logits = (text_embeddings @ image_embeddings.T) / self.temperature # 计算相似度矩阵作为目标 images_similarity = image_embeddings @ image_embeddings.T texts_similarity = text_embeddings @ text_embeddings.T targets = F.softmax( (images_similarity + texts_similarity) / 2 * self.temperature, dim=-1 ) # 计算交叉熵损失 texts_loss = cross_entropy(logits, targets, reduction='none') images_loss = cross_entropy(logits.T, targets.T, reduction='none') loss = (images_loss + texts_loss) / 2.0 return loss.mean() def cross_entropy(preds, targets, reduction='none'): """自定义交叉熵损失""" log_softmax = nn.LogSoftmax(dim=-1) loss = (-targets * log_softmax(preds)).sum(1) if reduction == "none": return loss elif reduction == "mean": return loss.mean() ``` ## 模型架构可视化 ``` ┌────────────────────────────────────────────────┐ │ CLIP模型完整架构 │ └────────────────────────────────────────────────┘ 输入图像 输入文本 [batch, 3, 224, 224] [batch, max_length] ↓ ↓ ┌──────────────┐ ┌──────────────┐ │Image Encoder │ │Text Encoder │ │ (ResNet50) │ │(DistilBERT) │ │ timm │ │ Transformers│ └──────┬───────┘ └──────┬───────┘ │ │ ↓ ↓ 图像特征 文本特征 [batch, 2048] [batch, 768] │ │ ↓ ↓ ┌──────────────┐ ┌──────────────┐ │ Projection │ │ Projection │ │ Head │ │ Head │ │ (2048→256) │ │ (768→256) │ └──────┬───────┘ └──────┬───────┘ │ │ ↓ ↓ 图像嵌入 文本嵌入 [batch, 256] [batch, 256] │ │ └──────────┬───────────────┘ ↓ 计算相似度矩阵 / 对比损失 ```
# 训练流程 ## 训练一个Epoch ```python def train_epoch(model, train_loader, optimizer, lr_scheduler, step): """训练一个epoch""" loss_meter = AvgMeter() tqdm_object = tqdm(train_loader, total=len(train_loader)) for batch in tqdm_object: # 将数据移到设备 batch = {k: v.to(CFG.device) for k, v in batch.items() if k != "caption"} # 前向传播 loss = model(batch) # 反向传播 optimizer.zero_grad() loss.backward() optimizer.step() # 按batch更新学习率 if step == "batch": lr_scheduler.step() # 更新损失 count = batch["image"].size(0) loss_meter.update(loss.item(), count) tqdm_object.set_postfix( train_loss=loss_meter.avg, lr=get_lr(optimizer) ) return loss_meter ``` ## 验证一个Epoch ```python def valid_epoch(model, valid_loader): """验证一个epoch""" loss_meter = AvgMeter() tqdm_object = tqdm(valid_loader, total=len(valid_loader)) for batch in tqdm_object: batch = {k: v.to(CFG.device) for k, v in batch.items() if k != "caption"} loss = model(batch) count = batch["image"].size(0) loss_meter.update(loss.item(), count) tqdm_object.set_postfix(valid_loss=loss_meter.avg) return loss_meter ``` ## 主训练函数 ```python def main(): # 准备数据 train_df, valid_df = make_train_valid_dfs() tokenizer = DistilBertTokenizer.from_pretrained(CFG.text_tokenizer) train_loader = build_loaders(train_df, tokenizer, mode="train") valid_loader = build_loaders(valid_df, tokenizer, mode="valid") # 创建模型 model = CLIPModel().to(CFG.device) # 配置不同层的学习率 params = [ { "params": model.image_encoder.parameters(), "lr": CFG.image_encoder_lr }, { "params": model.text_encoder.parameters(), "lr": CFG.text_encoder_lr }, { "params": itertools.chain( model.image_projection.parameters(), model.text_projection.parameters() ), "lr": CFG.head_lr, "weight_decay": CFG.weight_decay } ] # 优化器 optimizer = torch.optim.AdamW(params, weight_decay=0.) # 学习率调度器 lr_scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="min", patience=CFG.patience, factor=CFG.factor ) step = "epoch" # 训练循环 best_loss = float('inf') for epoch in range(CFG.epochs): print(f"Epoch: {epoch + 1}") # 训练 model.train() train_loss = train_epoch( model, train_loader, optimizer, lr_scheduler, step ) # 验证 model.eval() with torch.no_grad(): valid_loss = valid_epoch(model, valid_loader) # 保存最佳模型 if valid_loss.avg < best_loss: best_loss = valid_loss.avg torch.save(model.state_dict(), "best.pt") print("Saved Best Model!") # 更新学习率 lr_scheduler.step(valid_loss.avg) ``` ## 运行训练 ```python # 开始训练 main() ``` ## 训练输出示例 ``` Epoch: 1 train_loss: 3.2456, lr: 0.0001: 100%|████████| 1012/1012 [08:23<00:00, 2.01it/s] valid_loss: 2.8765: 100%|████████| 253/253 [01:15<00:00, 3.36it/s] Saved Best Model! Epoch: 2 train_loss: 2.5432, lr: 0.0001: 100%|████████| 1012/1012 [08:21<00:00, 2.02it/s] valid_loss: 2.6543: 100%|████████| 253/253 [01:15<00:00, 3.36it/s] Saved Best Model! ... ``` ## 训练技巧 | 技巧 | 说明 | |------|------| | **分层学习率** | 编码器小学习率,投影头大学习率 | | **权重衰减** | 防止过拟合 (1e-3) | | **学习率调度** | ReduceLROnPlateau自适应调整 | | **梯度裁剪** | 防止梯度爆炸 | | **混合精度** | 使用AMP加速训练 |
# 推理应用 ## 获取图像嵌入 ```python def get_image_embeddings(valid_df, model_path): """ 获取验证集所有图像的嵌入 返回: model: 加载的CLIP模型 image_embeddings: 所有图像的嵌入 [valid_set_size, 256] """ tokenizer = DistilBertTokenizer.from_pretrained(CFG.text_tokenizer) valid_loader = build_loaders(valid_df, tokenizer, mode="valid") model = CLIPModel().to(CFG.device) model.load_state_dict(torch.load(model_path, map_location=CFG.device)) model.eval() valid_image_embeddings = [] with torch.no_grad(): for batch in tqdm(valid_loader): image_features = model.image_encoder(batch["image"].to(CFG.device)) image_embeddings = model.image_projection(image_features) valid_image_embeddings.append(image_embeddings) return model, torch.cat(valid_image_embeddings) ``` ## 查找匹配图像 ```python import matplotlib.pyplot as plt def find_matches(model, image_embeddings, query, image_filenames, n=9): """ 根据文本查询查找匹配的图像 参数: model: CLIP模型 image_embeddings: 预计算的图像嵌入 query: 查询文本 image_filenames: 图像文件名列表 n: 显示的图像数量 """ tokenizer = DistilBertTokenizer.from_pretrained(CFG.text_tokenizer) # 编码查询文本 encoded_query = tokenizer([query]) batch = { key: torch.tensor(values).to(CFG.device) for key, values in encoded_query.items() } with torch.no_grad(): # 获取文本特征 text_features = model.text_encoder( input_ids=batch["input_ids"], attention_mask=batch["attention_mask"] ) text_embeddings = model.text_projection(text_features) # 归一化 image_embeddings_n = F.normalize(image_embeddings, p=2, dim=-1) text_embeddings_n = F.normalize(text_embeddings, p=2, dim=-1) # 计算点积相似度 dot_similarity = text_embeddings_n @ image_embeddings_n.T # 获取top-k匹配 values, indices = torch.topk(dot_similarity.squeeze(0), n * 5) matches = [image_filenames[idx] for idx in indices[::5]] # 显示结果 _, axes = plt.subplots(3, 3, figsize=(10, 10)) for match, ax in zip(matches, axes.flatten()): image = cv2.imread(f"{CFG.image_path}/{match}") image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) ax.imshow(image) ax.axis("off") plt.show() ``` ## 使用示例 ```python # 获取验证集图像嵌入 _, valid_df = make_train_valid_dfs() model, image_embeddings = get_image_embeddings(valid_df, "best.pt") # 查询1: 狗在草地上 find_matches( model, image_embeddings, query="dogs on the grass", image_filenames=valid_df['image'].values, n=9 ) # 查询2: 男孩玩滑板 find_matches( model, image_embeddings, query="a boy jumping with skateboard", image_filenames=valid_df['image'].values, n=9 ) ``` ## 零样本分类 ```python def zero_shot_classifier(model, image, class_prompts): """ 零样本图像分类 参数: model: CLIP模型 image: 输入图像 class_prompts: 类别提示列表 返回: predicted_class: 预测的类别 """ tokenizer = DistilBertTokenizer.from_pretrained(CFG.text_tokenizer) # 编码类别提示 encoded_prompts = tokenizer(class_prompts) batch = { key: torch.tensor(values).to(CFG.device) for key, values in encoded_prompts.items() } with torch.no_grad(): # 获取文本嵌入 text_features = model.text_encoder( input_ids=batch["input_ids"], attention_mask=batch["attention_mask"] ) text_embeddings = model.text_projection(text_features) # 获取图像嵌入 image_features = model.image_encoder(image) image_embedding = model.image_projection(image_features) # 归一化 text_embeddings_n = F.normalize(text_embeddings, p=2, dim=-1) image_embedding_n = F.normalize(image_embedding, p=2, dim=-1) # 计算相似度 similarity = image_embedding_n @ text_embeddings_n.T # 返回最匹配的类别 predicted_idx = similarity.argmax() return class_prompts[predicted_idx] # 使用示例 class_prompts = [ "a photo of a dog", "a photo of a cat", "a photo of a bird", "a photo of a car" ] # 预测 # predicted = zero_shot_classifier(model, test_image, class_prompts) ``` ## 应用场景总结 | 应用 | 说明 | |------|------| | **图像检索** | 根据文本描述检索图像 | | **零样本分类** | 无需训练即可分类新类别 | | **自动标注** | 为图像生成描述 | | **内容审核** | 检测不当内容 | | **推荐系统** | 基于视觉相似度推荐 |
CLIP源码分析
# CLIP源码目录结构 https://github.com/openai/CLIP/tree/main OpenAI CLIP代码库采用模块化设计,主要文件如下: ``` CLIP-main/ ├── clip/ │ ├── __init__.py # 包初始化,导出主要API │ ├── clip.py # 用户接口:模型加载、图像预处理、文本分词 │ ├── model.py # 核心模型定义:ResNet/ViT、Transformer │ └── simple_tokenizer.py # BPE文本分词器 ├── setup.py # 包安装配置 ├── hubconf.py # PyTorch Hub配置 └── tests/ # 测试文件 ``` ### 代码层次结构 ``` 用户层 (clip.py) ├── load() - 加载预训练模型 ├── tokenize() - 文本分词 └── _transform() - 图像预处理变换 ↓ 模型层 (model.py) ├── CLIP - 主模型类 ├── VisionTransformer / ModifiedResNet - 图像编码器 ├── Transformer - 文本编码器 ├── AttentionPool2d - 注意力池化 └── ResidualAttentionBlock - Transformer块 ↓ 工具层 (simple_tokenizer.py) ├── SimpleTokenizer - BPE分词器 ├── bpe() - 字节对编码 └── encode/decode - 编解码 ```
## clip.py 详解 ### 导入依赖 (第1-28行) ```python # 标准库 import hashlib # 用于SHA256校验 import os # 文件路径操作 import urllib # 模型下载 # 版本管理 from packaging import version from typing import Union, List # 深度学习框架 import torch from PIL import Image from torchvision.transforms import Compose, Resize, CenterCrop, ToTensor, Normalize from tqdm import tqdm # CLIP内部模块 from .model import build_model from .simple_tokenizer import SimpleTokenizer as _Tokenizer ``` **关键点说明:** - `hashlib`:下载模型后验证完整性,确保模型未被篡改 - `torchvision.transforms`:图像预处理管道 - `build_model`:从state_dict构建模型实例 ### 模型配置字典 (第30-40行) ```python _MODELS = { "RN50": "https://openaipublic.azureedge.net/clip/models/...", "RN101": "...", "RN50x4": "...", "RN50x16": "...", "RN50x64": "...", "ViT-B/32": "...", "ViT-B/16": "...", "ViT-L/14": "...", "ViT-L/14@336px": "...", } ``` **模型命名规则:** - `RNxx`:ResNet架构 - `RN50`:标准ResNet-50 - `RN50x4`:宽度放大4倍 - `RN50x16`:宽度放大16倍 - `RN50x64`:宽度放大64倍 - `ViT-B/32`:Vision Transformer,Base大小,32x32像素patch - `ViT-L/14`:Vision Transformer,Large大小,14x14像素patch ### 模型下载函数 (第43-72行) ```python def _download(url: str, root: str): # 1. 创建目录 os.makedirs(root, exist_ok=True) # 2. 从URL提取期望的SHA256 expected_sha256 = url.split("/")[-2] # 3. 检查文件是否已存在且校验通过 if os.path.isfile(download_target): if hashlib.sha256(...).hexdigest() == expected_sha256: return download_target # 4. 下载并显示进度条 with urllib.request.urlopen(url) as source: with tqdm(...) as loop: while True: buffer = source.read(8192) # 8KB块读取 if not buffer: break output.write(buffer) loop.update(len(buffer)) # 5. 验证下载的文件 if hashlib.sha256(...).hexdigest() != expected_sha256: raise RuntimeError("校验和不匹配") ``` **下载流程图:** ``` 开始 → 检查本地文件 → 校验SHA256 ↓ ↓ 已存在且有效 需下载 ↓ ↓ 直接返回 下载 → 校验 → 返回 ↓ 校验失败 → 报错 ``` ### 图像预处理 (第79-86行) ```python def _transform(n_px): return Compose([ Resize(n_px, interpolation=BICUBIC), # 双三次插值缩放 CenterCrop(n_px), # 中心裁剪 _convert_image_to_rgb, # 转RGB ToTensor(), # 转张量 [0,1] Normalize((0.48145466, 0.4578275, 0.40821073), # ImageNet均值 (0.26862954, 0.26130258, 0.27577711)) # ImageNet标准差 ]) ``` **预处理步骤详解:** | 步骤 | 输入 | 输出 | 说明 | |------|------|------|------| | Resize | 原图 | 短边=n_px | 保持宽高比 | | CenterCrop | 矩形 | n_px×n_px | 中心裁剪为正方形 | | ToTensor | PIL Image | Tensor[0,1] | 归一化到[0,1] | | Normalize | Tensor[0,1] | Tensor[-1,1] | 减均值除标准差 | ### 文本分词 (第205-245行) ```python def tokenize(texts: Union[str, List[str]], context_length: int = 77, truncate: bool = False): """ 将文本转换为CLIP输入token 输出格式: <|startoftext|> token1 token2 ... <|endoftext|> """ # 1. 统一转为列表 if isinstance(texts, str): texts = [texts] # 2. 添加特殊token sot_token = _tokenizer.encoder["<|startoftext|>"] # 49406 eot_token = _tokenizer.encoder["<|endoftext|>"] # 49407 # 3. 编码每个文本 all_tokens = [ [sot_token] + _tokenizer.encode(text) + [eot_token] for text in texts ] # 4. 填充到固定长度 result = torch.zeros(len(all_tokens), context_length, dtype=torch.int) for i, tokens in enumerate(all_tokens): result[i, :len(tokens)] = torch.tensor(tokens) return result ``` **tokenize示例:** ``` 输入: "a dog" 输出: [49406, 320, 1927, 49407, 0, 0, ..., 0] # 长度77 ↑ ↑ ↑ ↑ ↑ SOT "a" "dog" EOT padding ``` ### 模型加载函数 (第94-202行) ```python def load(name: str, device: Union[str, torch.device] = "cuda" if torch.cuda.is_available() else "cpu", jit: bool = False, download_root: str = None): """ 返回: (model, preprocess) - model: CLIP模型 - preprocess: 图像预处理函数 """ # 1. 获取模型路径 if name in _MODELS: model_path = _download(_MODELS[name], ...) elif os.path.isfile(name): model_path = name # 2. 加载权重 with open(model_path, 'rb') as f: try: # JIT格式(优化后的模型) model = torch.jit.load(f, map_location=device).eval() except RuntimeError: # state_dict格式(可修改的模型) state_dict = torch.load(f, map_location="cpu") # 3. 构建模型 if not jit: model = build_model(state_dict or model.state_dict()).to(device) # 4. 返回模型和预处理 return model, _transform(model.visual.input_resolution) ``` **加载流程:** ``` 用户指定模型名 → 查找本地/下载 → 加载权重 → 构建模型 → 返回(模型, 预处理) ```
## model.py 详解 ### 文件结构概览 ```python # 1. ResNet相关组件 class Bottleneck(nn.Module) # ResNet瓶颈块 class AttentionPool2d(nn.Module) # 注意力池化层 class ModifiedResNet(nn.Module) # 修改版ResNet编码器 # 2. Transformer相关组件 class LayerNorm(nn.LayerNorm) # LayerNorm(支持fp16) class QuickGELU(nn.Module) # 快速GELU激活 class ResidualAttentionBlock(nn.Module) # Transformer残差块 class Transformer(nn.Module) # Transformer编码器 # 3. Vision Transformer class VisionTransformer(nn.Module) # ViT图像编码器 # 4. CLIP主模型 class CLIP(nn.Module) # 完整CLIP模型 # 5. 工具函数 def convert_weights(model) # 转换为fp16 def build_model(state_dict) # 从权重构建模型 ``` ### Bottleneck类 (第10-55行) ```python class Bottleneck(nn.Module): """ResNet瓶颈块,扩展因子=4""" expansion = 4 def __init__(self, inplanes, planes, stride=1): super().__init__() # 1×1卷积降维 self.conv1 = nn.Conv2d(inplanes, planes, 1, bias=False) self.bn1 = nn.BatchNorm2d(planes) self.relu1 = nn.ReLU(inplace=True) # 3×3卷积(主操作) self.conv2 = nn.Conv2d(planes, planes, 3, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(planes) self.relu2 = nn.ReLU(inplace=True) # 步幅>1时使用avgpool下采样 self.avgpool = nn.AvgPool2d(stride) if stride > 1 else nn.Identity() # 1×1卷积升维(4倍扩展) self.conv3 = nn.Conv2d(planes, planes * 4, 1, bias=False) self.bn3 = nn.BatchNorm2d(planes * 4) self.relu3 = nn.ReLU(inplace=True) # 残差连接的降采样 self.downsample = None if stride > 1 or inplanes != planes * 4: self.downsample = nn.Sequential([ nn.AvgPool2d(stride), nn.Conv2d(inplanes, planes * 4, 1, bias=False), nn.BatchNorm2d(planes * 4) ]) def forward(self, x): identity = self.downsample(x) if self.downsample else x out = self.relu1(self.bn1(self.conv1(x))) out = self.relu2(self.bn2(self.conv2(out))) out = self.avgpool(out) out = self.bn3(self.conv3(out)) out += identity # 残差连接 return self.relu3(out) ``` **Bottleneck结构图:** ``` 输入 → 1×1conv → BN → ReLU → 3×3conv → BN → ReLU → [AvgPool] → 1×1conv → BN → + → ReLU ↑ ↑ (步幅>1时) 残差连接 ``` ### AttentionPool2d类 (第58-91行) ```python class AttentionPool2d(nn.Module): """用多头注意力代替传统平均池化""" def __init__(self, spacial_dim, embed_dim, num_heads, output_dim=None): super().__init__() # 位置编码:HW+1个位置(+1是CLS token) self.positional_embedding = nn.Parameter( torch.randn(spacial_dim ** 2 + 1, embed_dim) / embed_dim ** 0.5 ) # QKV投影 self.k_proj = nn.Linear(embed_dim, embed_dim) self.q_proj = nn.Linear(embed_dim, embed_dim) self.v_proj = nn.Linear(embed_dim, embed_dim) self.c_proj = nn.Linear(embed_dim, output_dim or embed_dim) self.num_heads = num_heads def forward(self, x): # NCHW → (HW)NC:展平空间维度 x = x.flatten(start_dim=2).permute(2, 0, 1) # 添加CLS token(空间平均) x = torch.cat([x.mean(dim=0, keepdim=True), x], dim=0) # 添加位置编码 x = x + self.positional_embedding[:, None, :] # 多头注意力:CLS作为query,所有位置作为key/value x, _ = F.multi_head_attention_forward( query=x[:1], # CLS token查询 key=x, # 所有位置作为key value=x, # 所有位置作为value ... ) return x.squeeze(0) ``` **AttentionPool原理:** ``` 特征图 → 展平 → 添加CLS → 加位置编码 → 多头注意力 → 输出 ↑ 全局平均池化作为CLS token ``` ### ModifiedResNet类 (第94-154行) ```python class ModifiedResNet(nn.Module): """ 与torchvision ResNet的区别: 1. 3层stem(而非1层) 2. 使用avgpool而非maxpool 3. 步幅>1的卷积前加avgpool(抗混叠) 4. 最终用注意力池化替代平均池化 """ def __init__(self, layers, output_dim, heads, input_resolution=224, width=64): super().__init__() # 3层stem:逐步下采样 self.conv1 = nn.Conv2d(3, width//2, 3, stride=2, padding=1) # 112 self.conv2 = nn.Conv2d(width//2, width//2, 3, padding=1) # 112 self.conv3 = nn.Conv2d(width//2, width, 3, padding=1) # 112 self.avgpool = nn.AvgPool2d(2) # 56 # 4个残差层 self.layer1 = self._make_layer(width, layers[0]) # 56 self.layer2 = self._make_layer(width*2, layers[1], 2) # 28 self.layer3 = self._make_layer(width*4, layers[2], 2) # 14 self.layer4 = self._make_layer(width*8, layers[3], 2) # 7 # 注意力池化:7×7→输出维度 self.attnpool = AttentionPool2d(7, width*32, heads, output_dim) ``` **ModifiedResNet结构:** ``` 输入(224×224) ↓ stem(3层conv + avgpool) → 56×56×width ↓ layer1 (×1) → 56×56×4×width ↓ layer2 (×2, stride=2) → 28×28×8×width ↓ layer3 (×4, stride=2) → 14×14×16×width ↓ layer4 (×8, stride=2) → 7×7×32×width ↓ AttentionPool2d → output_dim ``` ### VisionTransformer类 (第206-240行) ```python class VisionTransformer(nn.Module): """Vision Transformer图像编码器""" def __init__(self, input_resolution, patch_size, width, layers, heads, output_dim): super().__init__() # Patch嵌入:conv2d将patch展平为向量 self.conv1 = nn.Conv2d(3, width, kernel_size=patch_size, stride=patch_size) # CLS token和位置编码 scale = width ** -0.5 self.class_embedding = nn.Parameter(scale * torch.randn(width)) grid_size = input_resolution // patch_size self.positional_embedding = nn.Parameter( scale * torch.randn(grid_size ** 2 + 1, width) ) # Transformer self.transformer = Transformer(width, layers, heads) # 输出投影 self.ln_post = LayerNorm(width) self.proj = nn.Parameter(scale * torch.randn(width, output_dim)) def forward(self, x): # 1. 分patch:N×3×H×W → N×width×grid×grid x = self.conv1(x) # 2. 展平:N×width×grid×grid → N×width×grid² x = x.reshape(x.shape[0], x.shape[1], -1) # 3. 转置:N×width×grid² → N×grid²×width x = x.permute(0, 2, 1) # 4. 添加CLS token:N×grid²×width → N×(grid²+1)×width x = torch.cat([ self.class_embedding.to(x.dtype) + torch.zeros(x.shape[0], 1, x.shape[-1], ...), x ], dim=1) # 5. 加位置编码 x = x + self.positional_embedding.to(x.dtype) # 6. LayerNorm x = self.ln_pre(x) # 7. Transformer (需要LND格式) x = x.permute(1, 0, 2) # NLD → LND x = self.transformer(x) x = x.permute(1, 0, 2) # LND → NLD # 8. 取CLS token输出 x = self.ln_post(x[:, 0, :]) # 9. 投影到输出维度 return x @ self.proj ``` **ViT处理流程:** ``` 图像 → Patch嵌入 → 加CLS/位置编码 → Transformer → 取CLS → 投影 [N×3×224×224] [N×(7²+1)×768] [N×768] [N×512] ``` ### Transformer相关类 **QuickGELU (第166-168行):** ```python class QuickGELU(nn.Module): """快速GELU近似:x * sigmoid(1.702*x)""" def forward(self, x): return x * torch.sigmoid(1.702 * x) ``` **ResidualAttentionBlock (第171-192行):** ```python class ResidualAttentionBlock(nn.Module): """Transformer残差块:Pre-LN结构""" def __init__(self, d_model, n_head, attn_mask=None): super().__init__() self.attn = nn.MultiheadAttention(d_model, n_head) self.ln_1 = LayerNorm(d_model) # Pre-LN # MLP self.mlp = nn.Sequential([ nn.Linear(d_model, d_model * 4), QuickGELU(), nn.Linear(d_model * 4, d_model) ]) self.ln_2 = LayerNorm(d_model) # Pre-LN self.attn_mask = attn_mask def forward(self, x): # Pre-LN + 残差 x = x + self.attention(self.ln_1(x)) x = x + self.mlp(self.ln_2(x)) return x ``` **Transformer结构图:** ``` 输入 → LN → MultiHeadAttention → + → LN → MLP → + → 输出 ↑ ↑ ↑ ↑ Pre-LN 残差 Pre-LN 残差 ``` ### CLIP主类 (第243-372行) ```python class CLIP(nn.Module): def __init__(self, embed_dim, image_resolution, vision_layers, vision_width, vision_patch_size, context_length, vocab_size, transformer_width, transformer_heads, transformer_layers): super().__init__() # 图像编码器:ResNet或ViT if isinstance(vision_layers, tuple): self.visual = ModifiedResNet(...) else: self.visual = VisionTransformer(...) # 文本编码器:Transformer self.transformer = Transformer( width=transformer_width, layers=transformer_layers, heads=transformer_heads, attn_mask=self.build_attention_mask() # 因果mask ) # 文本嵌入 self.token_embedding = nn.Embedding(vocab_size, transformer_width) self.positional_embedding = nn.Parameter( torch.empty(context_length, transformer_width) ) self.ln_final = LayerNorm(transformer_width) # 投影到共享空间 self.text_projection = nn.Parameter( torch.empty(transformer_width, embed_dim) ) # 温度参数(初始化为1/0.07的log) self.logit_scale = nn.Parameter(torch.ones([]) * np.log(1 / 0.07)) def encode_image(self, image): """图像编码""" return self.visual(image.type(self.dtype)) def encode_text(self, text): """文本编码""" # 1. token嵌入 x = self.token_embedding(text).type(self.dtype) # 2. 加位置编码 x = x + self.positional_embedding.type(self.dtype) # 3. Transformer x = x.permute(1, 0, 2) # NLD → LND x = self.transformer(x) x = x.permute(1, 0, 2) # LND → NLD # 4. LayerNorm x = self.ln_final(x).type(self.dtype) # 5. 取EOT token的特征 x = x[torch.arange(x.shape[0]), text.argmax(dim=-1)] # 6. 投影到共享空间 return x @ self.text_projection def forward(self, image, text): """计算图像-文本相似度""" # 1. 编码 image_features = self.encode_image(image) text_features = self.encode_text(text) # 2. L2归一化 image_features = image_features / image_features.norm(dim=1, keepdim=True) text_features = text_features / text_features.norm(dim=1, keepdim=True) # 3. 余弦相似度(带温度缩放) logit_scale = self.logit_scale.exp() logits_per_image = logit_scale * (image_features @ text_features.T) logits_per_text = logits_per_image.T return logits_per_image, logits_per_text ``` **CLIP forward流程:** ``` 图像 + 文本 ↓ 图像编码器 → 图像特征(768) → 归一化 文本编码器 → 文本特征(768) → 归一化 ↓ 计算余弦相似度矩阵 (×温度参数) ↓ 返回 logits_per_image (N×N) logits_per_text (N×N) ``` ### build_model函数 (第399-436行) ```python def build_model(state_dict): """从state_dict构建模型""" # 1. 检测是ViT还是ResNet vit = "visual.proj" in state_dict if vit: # 解析ViT参数 vision_width = state_dict["visual.conv1.weight"].shape[0] vision_layers = len([k for k in state_dict.keys() if k.startswith("visual.") and k.endswith(".attn.in_proj_weight")]) vision_patch_size = state_dict["visual.conv1.weight"].shape[-1] grid_size = round((state_dict["visual.positional_embedding"].shape[0] - 1) ** 0.5) image_resolution = vision_patch_size * grid_size else: # 解析ResNet参数 counts = [len(set(k.split(".")[2] for k in state_dict if k.startswith(f"visual.layer{b}"))) for b in [1, 2, 3, 4]] vision_layers = tuple(counts) vision_width = state_dict["visual.layer1.0.conv1.weight"].shape[0] image_resolution = output_width * 32 # 2. 解析文本编码器参数 embed_dim = state_dict["text_projection"].shape[1] context_length = state_dict["positional_embedding"].shape[0] vocab_size = state_dict["token_embedding.weight"].shape[0] transformer_width = state_dict["ln_final.weight"].shape[0] transformer_heads = transformer_width // 64 transformer_layers = len(set(k.split(".")[2] for k in state_dict if k.startswith("transformer.resblocks"))) # 3. 构建模型 model = CLIP(embed_dim, image_resolution, vision_layers, vision_width, vision_patch_size, context_length, vocab_size, transformer_width, transformer_heads, transformer_layers) # 4. 转换权重并加载 convert_weights(model) model.load_state_dict(state_dict) return model.eval() ```
## simple_tokenizer.py 详解 ### bytes_to_unicode函数 (第16-35行) ```python @lru_cache() def bytes_to_unicode(): """ 字节到Unicode的映射表 目的:BPE在Unicode上工作,需要避免大量UNK token 将256个字节映射到可Unicode字符范围 """ # 1. 可打印ASCII字符 bs = list(range(ord("!"), ord("~")+1)) + \ list(range(ord("¡"), ord("¬")+1)) + \ list(range(ord("®"), ord("ÿ")+1)) # 2. 其他字节映射到高Unicode范围 cs = bs[:] n = 0 for b in range(2**8): if b not in bs: bs.append(b) cs.append(2**8 + n) n += 1 cs = [chr(n) for n in cs] return dict(zip(bs, cs)) ``` **字节映射示例:** ``` 字节值 → Unicode字符 65-126 → 直接可见字符(A-Z, a-z, 0-9, 标点) 其他 → 高Unicode字符(Ā, ā, Ă...) ``` ### SimpleTokenizer类 (第62-132行) ```python class SimpleTokenizer: def __init__(self, bpe_path=default_bpe()): # 1. 字节编解码器 self.byte_encoder = bytes_to_unicode() self.byte_decoder = {v: k for k, v in self.byte_encoder.items()} # 2. 加载BPE合并规则 merges = gzip.open(bpe_path).read().decode("utf-8").split('\n') merges = merges[1:49152-256-2+1] # 跳过首行和特殊token merges = [tuple(merge.split()) for merge in merges] # 3. 构建词汇表 vocab = list(bytes_to_unicode().values()) vocab = vocab + [v+'' for v in vocab] # 词尾标记 for merge in merges: vocab.append(''.join(merge)) vocab.extend(['<|startoftext|>', '<|endoftext|>']) # 4. 编解码映射 self.encoder = dict(zip(vocab, range(len(vocab)))) self.decoder = {v: k for k, v in self.encoder.items()} self.bpe_ranks = dict(zip(merges, range(len(merges)))) # 5. 正则表达式(分词用) self.pat = re.compile( r"""<\|startoftext\|>|<\|endoftext\|>|'s|'t|'re|'ve|'m|'ll|'d| [\p{L}]+|[\p{N}]|[^\s\p{L}\p{N}]+""", re.IGNORECASE ) ``` **词汇表构成:** ``` 基础:256个Unicode字符 扩展:每个字符+词尾标记(256个) BPE:48888个合并规则 特殊:<|startoftext|>, <|endoftext|> 总计:49408个token ``` ### BPE编码 (第80-119行) ```python def bpe(self, token): """字节对编码:贪心合并高频字符对""" if token in self.cache: return self.cache[token] # 1. 添加词尾标记 word = tuple(token[:-1]) + (token[-1] + '',) # 2. 获取所有字符对 pairs = get_pairs(word) if not pairs: return token + '' # 3. 贪心合并 while True: # 找到最高优先级的字符对 bigram = min(pairs, key=lambda pair: self.bpe_ranks.get(pair, float('inf'))) if bigram not in self.bpe_ranks: break first, second = bigram new_word = [] i = 0 while i < len(word): try: j = word.index(first, i) new_word.extend(word[i:j]) i = j except: new_word.extend(word[i:]) break # 合并匹配的字符对 if word[i] == first and i < len(word)-1 and word[i+1] == second: new_word.append(first + second) i += 2 else: new_word.append(word[i]) i += 1 word = tuple(new_word) if len(word) == 1: break else: pairs = get_pairs(word) return ' '.join(word) ``` **BPE示例:** ``` 输入: "hug" 1. 初始: ['h', 'u', 'g'] 2. 字符对: ('h','u'), ('u','g') 3. 假设('u','g')优先级最高 4. 合并: ['h', 'ug'] 输出: "h ug" ``` ### encode函数 (第121-127行) ```python def encode(self, text): """文本→token ID序列""" bpe_tokens = [] # 1. 清理和转小写 text = whitespace_clean(basic_clean(text)).lower() # 2. 正则分词 for token in re.findall(self.pat, text): # 3. 字节→Unicode token = ''.join(self.byte_encoder[b] for b in token.encode('utf-8')) # 4. BPE编码 for bpe_token in self.bpe(token).split(' '): bpe_tokens.append(self.encoder[bpe_token]) return bpe_tokens ``` **encode完整示例:** ``` 输入: "Hello World!" 1. 清理: "hello world!" 2. 分词: ["hello", "world", "!"] 3. 字节映射: ["Ħėļļō", "ŵōřļđ", "!"] 4. BPE: ["Ħ", "ė", "ļļ", "ō", "ŵ", "ō", "ř", "ļ", "đ", "!"] 5. 编码: [66, 76, 399, 77, 95, 77, 96, 399, 97, 0] ``` ### decode函数 (第129-132行) ```python def decode(self, tokens): """token ID序列→文本""" # 1. ID→字符 text = ''.join([self.decoder[token] for token in tokens]) # 2. Unicode→字节 text = bytearray([self.byte_decoder[c] for c in text]) # 3. 字节→UTF-8字符串 text = text.decode('utf-8', errors="replace").replace('', ' ') return text ```
## CLIP核心工作流程 ### 完整推理流程 ```python import torch import clip from PIL import Image # 1. 加载模型 device = "cuda" if torch.cuda.is_available() else "cpu" model, preprocess = clip.load("ViT-B/32", device=device) # 2. 准备输入 image = preprocess(Image.open("dog.jpg")).unsqueeze(0).to(device) text = clip.tokenize(["a dog", "a cat", "a car"]).to(device) # 3. 前向传播 with torch.no_grad(): image_features = model.encode_image(image) # [1, 512] text_features = model.encode_text(text) # [3, 512] # 4. 计算相似度 logits_per_image, logits_per_text = model(image, text) probs = logits_per_image.softmax(dim=-1).cpu().numpy() print("Label probs:", probs) # [dog: 0.99, cat: 0.01, car: 0.00] ``` ### 数据流动图 ``` 用户输入 ↓ ┌─────────────┬─────────────┐ │ │ │ 图像 (224×224) 文本 ("a dog") │ │ │ ↓ ↓ │ preprocess() tokenize() │ │ │ │ ↓ ↓ │ Tensor[3,224,224] Tensor[1,77] │ │ │ │ ↓ ↓ │ ┌─────────────┴─────────────┐ │ │ │ CLIP模型 │ │ ┌──────────────────────┐ │ │ │ 图像编码器 │ │ │ │ (ViT/ResNet) │ │ │ │ → 512维向量 │ │ │ └──────────────────────┘ │ │ │ │ ┌──────────────────────┐ │ │ │ 文本编码器 │ │ │ │ (Transformer) │ │ │ │ → 512维向量 │ │ │ └──────────────────────┘ │ │ │ │ 余弦相似度 × 温度参数 │ └─────────────────────────────┘ ↓ 相似度矩阵 [1, 3] ↓ softmax → 概率分布 ``` ### 训练流程(对比学习) ```python # 批次数据 images = preprocess_batch([img1, img2, ..., imgN]) # [N, 3, 224, 224] texts = tokenize(["caption 1", "caption 2", ...]) # [N, 77] # 前向传播 logits_per_image, logits_per_text = model(images, texts) # 真实标签:对角线为匹配对 labels = torch.arange(N).to(device) # 损失:交叉熵 loss_i = F.cross_entropy(logits_per_image, labels) # 图像→文本 loss_t = F.cross_entropy(logits_per_text, labels) # 文本→图像 loss = (loss_i + loss_t) / 2 # 反向传播 loss.backward() optimizer.step() ``` ### 损失计算详解 ``` 相似度矩阵(batch_size=3): 文本1 文本2 文本3 图像1 [ 10.5 -5.2 -8.1 ] ← 真实匹配 图像2 [ -4.8 9.7 -6.3 ] 图像3 [ -7.2 -5.9 8.8 ] ← 真实匹配 对于图像1: - 正样本对:(图像1, 文本1) → logit=10.5 - 负样本对:(图像1, 文本2/3) → logit=-5.2, -8.1 - 损失:-log(exp(10.5) / (exp(10.5)+exp(-5.2)+exp(-8.1))) ``` ### 零样本分类 ```python # 图像分类提示模板 class_prompts = [ "a photo of a dog", "a photo of a cat", "a photo of a car", "a photo of a bird" ] # 编码文本 text_tokens = clip.tokenize(class_prompts).to(device) # 推理 with torch.no_grad(): image_features = model.encode_image(image) text_features = model.encode_text(text_tokens) # 计算相似度 similarity = (image_features @ text_features.T).squeeze(0) probs = similarity.softmax(dim=-1) # 获取预测类别 pred_idx = probs.argmax().item() print(f"Predicted: {class_prompts[pred_idx]}") ``` ### 关键参数说明 | 参数 | ViT-B/32 | ViT-B/16 | ViT-L/14 | |------|----------|----------|----------| | 图像分辨率 | 224 | 224 | 224/336 | | Patch大小 | 32×32 | 16×16 | 14×14 | | Embedding维度 | 768 | 768 | 1024 | | Transformer层数 | 12 | 12 | 24 | | Attention头数 | 12 | 12 | 16 | | 输出维度 | 512 | 512 | 768 | | 参数量 | 150M | 86M | 304M | ### 温度参数作用 ```python # 温度参数控制softmax的锐度 logit_scale = model.logit_scale.exp() # 默认约1/0.07 ≈ 14.29 # 高温度 → 较低的相似度 → 更均匀的分布 # 低温度 → 较高的相似度 → 更锐利的分布 ``` **温度缩放效果:** ``` 未缩放相似度: [2.0, 1.0, 0.5] 温度=1: softmax → [0.66, 0.24, 0.10] 温度=10: softmax → [0.45, 0.32, 0.23] ← 更均匀 温度=0.1: softmax → [0.92, 0.07, 0.01] ← 更锐利 ```
OpenCLIP源码分析
# OpenCLIP项目概述 https://github.com/mlfoundations/open_clip ## 项目简介 OpenCLIP是MLFoundations团队开源的CLIP实现,支持大规模对比语言-图像预训练。相比OpenAI原版CLIP,OpenCLIP提供了更丰富的模型变体、训练数据和配置选项。 ## 核心特性 ### 支持的模型类型 | 模型类型 | 描述 | 配置前缀 | |---------|------|---------| | ViT | Vision Transformer | `ViT-B-*`, `ViT-L-*`, `ViT-H-*`, `ViT-g-*` | | ConvNeXt | 现代CNN架构 | `convnext_*` | | ResNet | 经典残差网络 | `RN50*` | | SigLIP | Sigmoid Loss CLIP | `ViT-SO-*-SigLIP` | | CoCa | Contrastive Captioners | `coca_*` | | CLAP | Contrastive Language-Audio Pre-training | `clap_*` | | NaFlex | 灵活分辨率/长宽比 | `naflex*` | ### 支持的模态 ``` 图像-文本 (CLIP) ─→ 对比学习 音频-文本 (CLAP) ─→ 对比学习 生成式 (GenLIP/GenLAP) ─→ 前缀LM + 打包训练 ``` ## 目录结构 ``` open_clip-main/ ├── src/ │ ├── open_clip/ # 核心推理库 │ │ ├── __init__.py # 导出API │ │ ├── factory.py # 模型工厂(加载/创建) │ │ ├── model.py # CLIP模型定义 │ │ ├── modified_resnet.py # ResNet图像编码器 │ │ ├── transformer.py # Transformer组件 │ │ ├── timm_model.py # timm图像编码器封装 │ │ ├── hf_model.py # HuggingFace文本编码器 │ │ ├── tokenizer.py # 文本分词器 │ │ ├── transform.py # 图像预处理 │ │ ├── loss.py # 损失函数 │ │ ├── pretrained.py # 预训练权重管理 │ │ ├── task.py # 训练任务封装 │ │ ├── constants.py # 常量定义 │ │ ├── coca_model.py # CoCa模型 │ │ ├── clap_model.py # CLAP模型 │ │ └── model_configs/ # 模型配置JSON │ │ │ └── open_clip_train/ # 训练相关代码 │ ├── main.py # 训练入口 │ ├── train.py # 训练循环 │ ├── data.py # 数据加载 │ ├── distributed.py # 分布式训练 │ ├── optim.py # 优化器配置 │ ├── scheduler.py # 学习率调度 │ ├── params.py # 参数解析 │ ├── zero_shot.py # 零样本评估 │ ├── metrics.py # 评估指标 │ ├── profiler.py # 性能分析 │ └── legacy_*.py # 兼容旧版代码 │ ├── scripts/ # 辅助脚本 ├── tests/ # 单元测试 └── docs/ # 文档 ``` ## 代码架构层次 ``` ┌─────────────────────────────────────────────────────────────────┐ │ 用户API层 │ │ create_model() / create_model_and_transforms() / get_tokenizer()│ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 工厂层 (factory.py) │ │ 模型创建 | 权重加载 | 配置解析 | 分词器解析 │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 模型层 (model.py) │ │ CLIP | CustomTextCLIP | CoCa | CLAP | VisionTransformer │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 组件层 │ │ Transformer | ResNet | Attention | LayerNorm | 损失函数 │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 训练层 (train.py + task.py) │ │ TrainingTask | 前向传播 | 反向传播 | 优化器 | 调度器 │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 数据层 (data.py) │ │ WebDataset | CsvDataset | ImageFolder | 数据增强 │ └─────────────────────────────────────────────────────────────────┘ ``` ## 与OpenAI CLIP的对比 | 特性 | OpenAI CLIP | OpenCLIP | |------|-------------|----------| | 模型类型 | ViT + ResNet | ViT + ResNet + ConvNeXt + SigLIP + CoCa | | 训练数据 | WIT (400M) | LAION-2B, DataComp-1B等 | | 预训练权重 | OpenAI服务器 | 多个来源 + 本地路径 + HuggingFace | | 训练代码 | 简单脚本 | 完整训练框架 | | 分布式 | 基础DDP | DDP + FSDP2 | | 精度支持 | FP16/FP32 | FP16/FP32/BF16 + AMP | | 扩展性 | 有限 | 高度模块化 | ## 依赖关系 ``` torch >= 2.6 torchvision timm (可选,用于图像编码器) transformers (可选,用于文本编码器) webdataset (训练时) ftfy (文本清理) regex (分词器) ``` ## 模型配置系统 OpenCLIP使用JSON配置文件定义模型架构: ```json { "embed_dim": 512, "vision_cfg": { "image_size": 224, "layers": 12, "width": 768, "patch_size": 32 }, "text_cfg": { "context_length": 77, "vocab_size": 49408, "width": 512, "layers": 12, "heads": 8 } } ``` **支持的配置来源:** 1. 内置配置 (model_configs/*.json) 2. HuggingFace Hub (hf-hub:org/model) 3. 本地目录 (local-dir:/path) ## 预训练权重标签 ```python # 示例权重标签 'laion2b_s34b_b79k' # LAION-2B, 34B样本, 79k批次 'datacomp_xl_s13b_b90k' # DataComp-XL, 13B样本, 90k批次 'openai' # OpenAI原始权重 'redisclosed' # 重新发布的权重 ```
## factory.py 详解 ### 模型配置注册系统 (第46-72行) ```python _MODEL_CONFIG_PATHS = [Path(__file__).parent / "model_configs/"] _MODEL_CONFIGS = {} # 模型名 → 配置字典 def _rescan_model_configs(): """扫描model_configs目录,加载所有JSON配置""" global _MODEL_CONFIGS config_files = [] for config_path in _MODEL_CONFIG_PATHS: if config_path.is_file() and config_path.suffix == '.json': config_files.append(config_path) elif config_path.is_dir(): config_files.extend(config_path.glob('*.json')) for cf in config_files: with open(cf, 'r') as f: model_cfg = json.load(f) # 验证配置完整性 has_vision = all(k in model_cfg for k in ('embed_dim', 'vision_cfg', 'text_cfg')) has_audio = all(k in model_cfg for k in ('embed_dim', 'audio_cfg', 'text_cfg')) if has_vision or has_audio: _MODEL_CONFIGS[cf.stem] = model_cfg _rescan_model_configs() # 初始化时执行 ``` ### 模型名称解析 (第92-135行) ```python def parse_model_name(model_name: str) -> Tuple[Optional[str], str]: """ 解析模型名称,识别schema前缀 支持的schema: - 'hf-hub:org/model' → HuggingFace Hub - 'local-dir:/path' → 本地目录 - 'ViT-B-32' → 内置模型(无schema) """ # 1. 检查local-dir schema if model_name.startswith(LOCAL_DIR_PREFIX): identifier = model_name[len(LOCAL_DIR_PREFIX):] if not identifier: raise ValueError("Empty path after 'local-dir:'") return 'local-dir', identifier # 2. 检查hf-hub schema elif model_name.startswith(HF_HUB_PREFIX): identifier = model_name[len(HF_HUB_PREFIX):] if not identifier: raise ValueError("Empty identifier after 'hf-hub:'") return 'hf-hub', identifier # 3. 无schema → 内置模型名 else: return None, model_name ``` ### 配置获取函数 (第154-169行) ```python def get_model_config(model_name): """ 从指定位置获取模型配置 优先级:local-dir > hf-hub > 内置配置 """ loc, model_id = parse_model_name(model_name) if loc == 'local-dir': local_path = Path(model_id) / 'open_clip_config.json' with open(local_path, 'r', encoding='utf-8') as f: config = json.load(f) return config.get('model_cfg', config) elif loc == 'hf-hub': config = _get_hf_config(model_id) # 从HF下载 return config.get('model_cfg', config) elif model_name in _MODEL_CONFIGS: return deepcopy(_MODEL_CONFIGS[model_name]) else: return None ``` ### 权重加载 (第172-238行) ```python def load_checkpoint(model, checkpoint_path, strict=True, device='cpu'): """ 加载权重到模型,处理各种格式兼容性 """ # 1. 根据扩展名选择加载方式 if str(checkpoint_path).endswith(".safetensors"): from safetensors.torch import load_file checkpoint = load_file(checkpoint_path, device=device) else: checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=True) # 2. 提取state_dict if isinstance(checkpoint, dict) and 'state_dict' in checkpoint: state_dict = checkpoint['state_dict'] else: state_dict = checkpoint # 3. 处理DDP权重(移除'module.'前缀) if next(iter(state_dict.items()))[0].startswith('module'): state_dict = {k[7:]: v for k, v in state_dict.items()} # 4. 第三方权重转换(如OpenAI、SigLIP) state_dict = convert_state_dict(model, state_dict) # 5. 处理旧格式兼容性 if 'positional_embedding' in state_dict and not hasattr(model, 'positional_embedding'): state_dict = convert_to_custom_text_state_dict(state_dict) # 6. 处理logit_scale形状差异 if 'logit_scale' in state_dict and model.logit_scale.ndim != state_dict['logit_scale'].ndim: state_dict['logit_scale'] = state_dict['logit_scale'].reshape(model.logit_scale.shape) # 7. 调整位置编码大小 resize_pos_embed(state_dict, model) resize_text_pos_embed(state_dict, model) # 8. 加载权重 incompatible_keys = model.load_state_dict(state_dict, strict=strict) return incompatible_keys ``` ### 主创建函数 (第264-702行) ```python def create_model( model_name: str, pretrained: Optional[str] = None, precision: str = 'fp32', device: Union[str, torch.device] = 'cpu', force_quick_gelu: bool = False, force_custom_text: bool = False, force_patch_dropout: Optional[float] = None, force_image_size: Optional[Union[int, Tuple[int, int]]] = None, pretrained_image: bool = False, pretrained_text: bool = True, pretrained_image_path: Optional[str] = None, pretrained_text_path: Optional[str] = None, cache_dir: Optional[str] = None, require_pretrained: bool = False, **model_kwargs, ) -> torch.nn.Module: """ 创建CLIP模型的主工厂函数 参数说明: - model_name: 模型标识符(可含schema前缀) - pretrained: 预训练权重来源(仅在无schema时使用) - force_quick_gelu: 强制使用QuickGELU - force_custom_text: 强制使用自定义文本编码器 - pretrained_image/text: 是否加载基础塔权重 - pretrained_*_path: 单独加载指定塔的权重路径 - require_pretrained: 必须加载完整CLIP权重 """ # 1. 解析模型名称 schema, identifier = parse_model_name(model_name) # 2. 根据schema获取配置 model_cfg = None checkpoint_path = None preprocess_cfg = asdict(PreprocessCfg()) if schema == 'local-dir': # 从本地目录加载 model_cfg = _load_local_config(identifier) checkpoint_path = _find_checkpoint_in_dir(Path(identifier)) elif schema == 'hf-hub': # 从HuggingFace加载 hf_config = _get_hf_config(identifier) model_cfg = hf_config['model_cfg'] checkpoint_path = download_pretrained_from_hf(identifier) else: # 无schema → 内置模型 model_cfg = get_model_config(identifier) if pretrained: checkpoint_path = download_pretrained(pretrained) # 3. 应用配置覆盖 if force_quick_gelu: model_cfg["quick_gelu"] = True if force_patch_dropout is not None: model_cfg['vision_cfg']["patch_dropout"] = force_patch_dropout if force_image_size is not None: model_cfg['vision_cfg']["image_size"] = force_image_size # 4. 确定模型类型 if 'audio_cfg' in model_cfg: model_class = CLAP elif "multimodal_cfg" in model_cfg: model_class = CoCa elif force_custom_text or 'hf_model_name' in model_cfg.get('text_cfg', {}): model_class = CustomTextCLIP else: model_class = CLIP # 5. 创建模型实例 model = model_class(**model_cfg) # 6. 设置设备和精度 _set_model_device_and_precision(model, device, precision) # 7. 加载权重 if checkpoint_path and load_weights: load_checkpoint(model, checkpoint_path) # 8. 加载塔特定权重(可选) if pretrained_image_path: image_state_dict = load_state_dict(pretrained_image_path) model.visual.load_state_dict(image_state_dict, strict=False) if pretrained_text_path: text_state_dict = load_state_dict(pretrained_text_path) model.text.load_state_dict(text_state_dict, strict=False) # 9. 设置预处理配置 set_model_preprocess_cfg(model, preprocess_cfg) return model ``` ### 分词器获取 (第746-886行) ```python def get_tokenizer( model_name: str = '', context_length: Optional[int] = None, **kwargs, ): """ 根据模型配置获取对应的分词器 分词器类型选择逻辑: 1. tiktoken → TikTokenTokenizer (GenLIP) 2. hf_tokenizer_name → HFTokenizer 3. siglip命名 → SigLipTokenizer 4. 默认 → SimpleTokenizer """ # 1. 获取配置 config = get_model_config(model_name) or {} text_config = config.get('text_cfg', {}) # 2. 确定context_length if context_length is None: context_length = text_config.get('context_length', DEFAULT_CONTEXT_LENGTH) # 3. 选择分词器类型 tokenizer_type = text_config.get('tokenizer_type', '') hf_tokenizer_name = text_config.get('hf_tokenizer_name', '') if tokenizer_type == 'tiktoken': return TikTokenTokenizer( encoding_name=text_config.get('tiktoken_name', 'cl100k_base'), context_length=context_length, **kwargs ) elif hf_tokenizer_name: return HFTokenizer( tokenizer_source=hf_tokenizer_name, context_length=context_length, **kwargs ) elif 'siglip' in model_name.lower(): return SigLipTokenizer(context_length=context_length) else: return SimpleTokenizer(context_length=context_length, **kwargs) ``` ### 创建模型与变换 (第1081-1214行) ```python def create_model_and_transforms( model_name: str, pretrained: Optional[str] = None, precision: str = 'fp32', device: Union[str, torch.device] = 'cpu', aug_cfg: Optional[Union[Dict, AugmentationCfg]] = None, audio_aug_cfg: Optional[Any] = None, **kwargs, ): """ 同时创建模型和预处理变换 返回: (model, preprocess_train, preprocess_val) """ # 1. 创建模型 model = create_model( model_name, pretrained, precision=precision, device=device, **kwargs, ) # 2. 构建预处理变换 preprocess_train, preprocess_val = _build_preprocess( model, aug_cfg=aug_cfg, audio_aug_cfg=audio_aug_cfg ) return model, preprocess_train, preprocess_val ```
## model.py 详解 ### 配置数据类 (第37-151行) ```python @dataclass class CLIPVisionCfg: """视觉编码器配置""" layers: Union[Tuple[int, int, int, int], int] = 12 # ResNet层或ViT层数 width: int = 768 # ViT宽度 head_width: int = 64 # 注意力头宽度 mlp_ratio: float = 4.0 # MLP扩展比 patch_size: int = 16 # ViT patch大小 image_size: Union[Tuple[int, int], int] = 224 # 输入图像大小 patch_dropout: float = 0. # patch丢弃率 attentional_pool: bool = False # 是否使用注意力池化 pos_embed_type: str = 'learnable' # 位置编码类型 pool_type: str = 'tok' # 池化类型 # timm相关配置 timm_model_name: Optional[str] = None # timm模型名 timm_model_pretrained: bool = False # 是否使用预训练 timm_pool: str = 'avg' # timm池化方式 @dataclass class CLIPTextCfg: """文本编码器配置""" text_arch: str = "clip" # 架构类型 context_length: int = 77 # 上下文长度 vocab_size: int = 49408 # 词表大小 hf_tokenizer_name: Optional[str] = None # HF分词器名 tokenizer_type: str = '' # 分词器类型 width: int = 512 # Transformer宽度 heads: int = 8 # 注意力头数 layers: int = 12 # Transformer层数 mlp_ratio: float = 4.0 # MLP扩展比 pool_type: str = 'argmax' # 池化类型 proj_type: str = 'linear' # 投影类型 # HuggingFace相关 hf_model_name: Optional[str] = None # HF模型名 hf_model_pretrained: bool = True # 是否使用预训练 hf_pooler_type: str = 'mean_pooler' # HF池化器 ``` ### 视觉塔构建 (第171-248行) ```python def _build_vision_tower( embed_dim: int, vision_cfg: CLIPVisionCfg, quick_gelu: bool = False, cast_dtype: Optional[torch.dtype] = None ): """构建视觉编码器塔""" if isinstance(vision_cfg, dict): vision_cfg = CLIPVisionCfg(**vision_cfg) act_layer = QuickGELU if quick_gelu else nn.GELU # 分支1: timm模型 if vision_cfg.timm_model_name: visual = TimmModel( vision_cfg.timm_model_name, pretrained=vision_cfg.timm_model_pretrained, pool=vision_cfg.timm_pool, proj=vision_cfg.timm_proj, embed_dim=embed_dim, image_size=vision_cfg.image_size, ) # 分支2: ResNet elif isinstance(vision_cfg.layers, (tuple, list)): vision_heads = vision_cfg.width * 32 // vision_cfg.head_width visual = ModifiedResNet( layers=vision_cfg.layers, output_dim=embed_dim, heads=vision_heads, image_size=vision_cfg.image_size, width=vision_cfg.width, ) # 分支3: Vision Transformer else: vision_heads = vision_cfg.width // vision_cfg.head_width norm_layer = LayerNormFp32 if cast_dtype in (torch.float16, torch.bfloat16) else LayerNorm visual = VisionTransformer( image_size=vision_cfg.image_size, patch_size=vision_cfg.patch_size, width=vision_cfg.width, layers=vision_cfg.layers, heads=vision_heads, mlp_ratio=vision_cfg.mlp_ratio, patch_dropout=vision_cfg.patch_dropout, attentional_pool=vision_cfg.attentional_pool, pos_embed_type=vision_cfg.pos_embed_type, pool_type=vision_cfg.pool_type, output_dim=embed_dim, act_layer=act_layer, norm_layer=norm_layer, ) return visual ``` ### 文本塔构建 (第251-315行) ```python def _build_text_tower( embed_dim: int, text_cfg: CLIPTextCfg, quick_gelu: bool = False, cast_dtype: Optional[torch.dtype] = None, ): """构建文本编码器塔""" if isinstance(text_cfg, dict): text_cfg = CLIPTextCfg(**text_cfg) # 分支1: ModernTextTransformer if text_cfg.text_arch == "modern": text = ModernTextTransformer(text_cfg, output_dim=embed_dim) # 分支2: HuggingFace编码器 elif text_cfg.hf_model_name: text = HFTextEncoder( text_cfg.hf_model_name, output_dim=embed_dim, proj_type=text_cfg.hf_proj_type, pooler_type=text_cfg.hf_pooler_type, pretrained=text_cfg.hf_model_pretrained, ) # 分支3: 原生CLIP Transformer else: act_layer = QuickGELU if quick_gelu else nn.GELU norm_layer = LayerNormFp32 if cast_dtype in (torch.float16, torch.bfloat16) else LayerNorm text = TextTransformer( context_length=text_cfg.context_length, vocab_size=text_cfg.vocab_size, width=text_cfg.width, heads=text_cfg.heads, layers=text_cfg.layers, mlp_ratio=text_cfg.mlp_ratio, output_dim=embed_dim, pool_type=text_cfg.pool_type, proj_type=text_cfg.proj_type, act_layer=act_layer, norm_layer=norm_layer, ) text.variable_text = bool(text_cfg.variable_text) return text ``` ### CLIP主类 (第318-548行) ```python class CLIP(nn.Module): """ CLIP主模型类 - 使用原生CLIP文本塔 结构: - visual: 视觉编码器(ResNet/ViT/timm) - transformer: 文本Transformer - logit_scale: 可学习温度参数 """ def __init__( self, embed_dim: int, vision_cfg: CLIPVisionCfg, text_cfg: CLIPTextCfg, quick_gelu: bool = False, init_logit_scale: float = np.log(1 / 0.07), # ≈ 2.659 init_logit_bias: Optional[float] = None, cast_dtype: Optional[torch.dtype] = None, output_dict: bool = False, ): super().__init__() self.output_dict = output_dict self.embed_dim = embed_dim # 构建视觉塔 self.visual = _build_vision_tower(embed_dim, vision_cfg, quick_gelu, cast_dtype) # 构建文本塔(内联到CLIP中) text = _build_text_tower(embed_dim, text_cfg, quick_gelu, cast_dtype) self.transformer = text.transformer self.context_length = text.context_length self.vocab_size = text.vocab_size self.token_embedding = text.token_embedding self.positional_embedding = text.positional_embedding self.ln_final = text.ln_final self.text_projection = text.text_projection self.text_pool_type = text.pool_type self.attn_mask = text.attn_mask # 可学习参数 self.logit_scale = nn.Parameter(torch.ones([]) * init_logit_scale) if init_logit_bias is not None: self.logit_bias = nn.Parameter(torch.ones([]) * init_logit_bias) else: self.logit_bias = None def _encode_image(self, image, normalize: bool = False): """编码图像""" features = self.visual(image) return F.normalize(features, dim=-1) if normalize else features def _encode_text(self, text, normalize: bool = False): """编码文本""" cast_dtype = self.transformer.get_cast_dtype() # Token嵌入 x = self.token_embedding(text).to(cast_dtype) # 加位置编码 x = x + self.positional_embedding.to(cast_dtype) # Transformer编码 x = self.transformer(x, attn_mask=self.attn_mask) # 最终LayerNorm x = self.ln_final(x) # 全局池化 x = text_global_pool(x, text, self.text_pool_type) # 投影到共享空间 if self.text_projection is not None: if isinstance(self.text_projection, nn.Linear): x = self.text_projection(x) else: x = x @ self.text_projection return F.normalize(x, dim=-1) if normalize else x def get_logits(self, image, text): """计算图像-文本logits""" image_features = self._encode_image(image, normalize=True) text_features = self._encode_text(text, normalize=True) # 余弦相似度 × 温度 image_logits = self.logit_scale.exp() * (image_features @ text_features.T) if self.logit_bias is not None: image_logits += self.logit_bias text_logits = image_logits.T return image_logits, text_logits def forward(self, image=None, text=None): """前向传播""" image_features = self._encode_image(image, normalize=True) if image is not None else None text_features = self._encode_text(text, normalize=True) if text is not None else None if self.output_dict: return { "image_features": image_features, "text_features": text_features, "logit_scale": self.logit_scale.exp() } if self.logit_bias is not None: return image_features, text_features, self.logit_scale.exp(), self.logit_bias.clone() return image_features, text_features, self.logit_scale.exp() ``` ### CustomTextCLIP类 (第551-735行) ```python class CustomTextCLIP(nn.Module): """ 使用独立文本塔的CLIP变体 与CLIP的区别: - 文本塔作为self.text独立存在 - 支持HuggingFace预训练模型 - 支持可变长度文本 """ def __init__( self, embed_dim: int, vision_cfg: CLIPVisionCfg, text_cfg: CLIPTextCfg, quick_gelu: bool = False, init_logit_scale: float = np.log(1 / 0.07), **kwargs, ): super().__init__() self.embed_dim = embed_dim # 构建双塔 self.visual = _build_vision_tower(embed_dim, vision_cfg, quick_gelu) self.text = _build_text_tower(embed_dim, text_cfg, quick_gelu) self.context_length = self.text.context_length self.vocab_size = self.text.vocab_size # 可学习参数 self.logit_scale = nn.Parameter(torch.ones([]) * init_logit_scale) self.logit_bias = nn.Parameter(torch.ones([]) * init_logit_bias) if init_logit_bias else None def _encode_text(self, text, normalize: bool = False): """编码文本(委托给self.text)""" features = self.text(text) return F.normalize(features, dim=-1) if normalize else features ``` ### 权重转换函数 (第738-767行) ```python def convert_weights_to_lp(model: nn.Module, dtype=torch.float16): """将模型权重转换为低精度(fp16/bf16)""" def _convert_weights(l): # 卷积和线性层 if isinstance(l, (nn.Conv1d, nn.Conv2d, nn.Linear)): l.weight.data = l.weight.data.to(dtype) if l.bias is not None: l.bias.data = l.bias.data.to(dtype) # 注意力层 if isinstance(l, Attention): for attr in ['in_proj_weight', 'q_proj_weight', 'k_proj_weight', 'v_proj_weight']: tensor = getattr(l, attr, None) if tensor is not None: tensor.data = tensor.data.to(dtype) # 文本投影 if isinstance(l, (CLIP, TextTransformer)): attr = getattr(l, "text_projection", None) if attr is not None: attr.data = attr.data.to(dtype) # 视觉投影 if isinstance(l, VisionTransformer): attr = getattr(l, "proj", None) if attr is not None: attr.data = attr.data.to(dtype) model.apply(_convert_weights) ``` ### 位置编码调整 (第790-854行) ```python def resize_pos_embed(state_dict, model, interpolation='bicubic', antialias=True): """ 调整位置编码大小 当加载不同分辨率的权重时使用 """ old_pos_embed = state_dict.get('visual.positional_embedding', None) if old_pos_embed is None or not hasattr(model.visual, 'grid_size'): return grid_size = to_2tuple(model.visual.grid_size) extra_tokens = 1 # CLS token new_seq_len = grid_size[0] * grid_size[1] + extra_tokens if new_seq_len == old_pos_embed.shape[0]: return # 无需调整 # 分离CLS token和位置编码 pos_emb_tok, pos_emb_img = old_pos_embed[:extra_tokens], old_pos_embed[extra_tokens:] # 旧grid大小 old_grid_size = to_2tuple(int(math.sqrt(len(pos_emb_img)))) # 双三次插值调整 pos_emb_img = pos_emb_img.reshape(1, old_grid_size[0], old_grid_size[1], -1).permute(0, 3, 1, 2) pos_emb_img = F.interpolate( pos_emb_img, size=grid_size, mode=interpolation, antialias=antialias, ) pos_emb_img = pos_emb_img.permute(0, 2, 3, 1).reshape(1, grid_size[0] * grid_size[1], -1)[0] # 拼接CLS token new_pos_embed = torch.cat([pos_emb_tok, pos_emb_img], dim=0) state_dict['visual.positional_embedding'] = new_pos_embed ```
## loss.py 详解 ### 分布式特征收集 (第29-54行) ```python def gather_features( image_features, text_features, local_loss=False, gather_with_grad=False, rank=0, world_size=1, ): """ 在多GPU训练中收集所有进程的特征 行为: - gather_with_grad=True: 保留梯度收集(内存开销大) - gather_with_grad=False: 收集后为本地rank添加梯度 """ if gather_with_grad: all_image_features = _all_gather_with_grad(image_features) all_text_features = _all_gather_with_grad(text_features) else: # 创建收集容器 gathered_image_features = [torch.zeros_like(image_features) for _ in range(world_size)] gathered_text_features = [torch.zeros_like(text_features) for _ in range(world_size)] # all_gather收集 dist.all_gather(gathered_image_features, image_features) dist.all_gather(gathered_text_features, text_features) if not local_loss: # 确保本地rank有梯度 gathered_image_features[rank] = image_features gathered_text_features[rank] = text_features all_image_features = torch.cat(gathered_image_features, dim=0) all_text_features = torch.cat(gathered_text_features, dim=0) return all_image_features, all_text_features ``` ### ClipLoss类 (第57-141行) ```python class ClipLoss(nn.Module): """ 标准CLIP对比损失 损失 = (CrossEntropy(image_logits, labels) + CrossEntropy(text_logits, labels)) / 2 """ def __init__( self, local_loss=False, # 是否使用局部损失 gather_with_grad=False, # 收集时是否保留梯度 cache_labels=False, # 是否缓存标签 rank=0, # 进程rank world_size=1, # 总进程数 ): super().__init__() self.local_loss = local_loss self.gather_with_grad = gather_with_grad self.cache_labels = cache_labels self.rank = rank self.world_size = world_size def get_ground_truth(self, device, num_logits) -> torch.Tensor: """获取真实标签(对角线)""" if self.prev_num_logits != num_logits or device not in self.labels: labels = torch.arange(num_logits, device=device, dtype=torch.long) if self.world_size > 1 and self.local_loss: labels = labels + num_logits * self.rank if self.cache_labels: self.labels[device] = labels self.prev_num_logits = num_logits else: labels = self.labels[device] return labels def get_logits(self, image_features, text_features, logit_scale, logit_bias=None): """计算logits""" if self.world_size > 1: all_image_features, all_text_features = gather_features(...) if self.local_loss: # 局部损失:本地图像 × 全局文本 logits_per_image = logit_scale * image_features @ all_text_features.T logits_per_text = logit_scale * text_features @ all_image_features.T else: # 全局损失:全局图像 × 全局文本 logits_per_image = logit_scale * all_image_features @ all_text_features.T logits_per_text = logits_per_image.T else: # 单GPU logits_per_image = logit_scale * image_features @ text_features.T logits_per_text = logit_scale * text_features @ image_features.T if logit_bias is not None: logits_per_image += logit_bias logits_per_text += logit_bias return logits_per_image, logits_per_text def forward(self, image_features, text_features, logit_scale, logit_bias=None): """ 计算CLIP损失 返回: 总损失 = (image_to_text_loss + text_to_image_loss) / 2 """ # 1. 计算logits logits_per_image, logits_per_text = self.get_logits( image_features, text_features, logit_scale, logit_bias ) # 2. 获取标签(对角线) labels = self.get_ground_truth(image_features.device, logits_per_image.shape[0]) # 3. 计算双向交叉熵 total_loss = ( F.cross_entropy(logits_per_image, labels) + # 图像→文本 F.cross_entropy(logits_per_text, labels) # 文本→图像 ) / 2 return total_loss ``` **损失计算示意图:** ``` 相似度矩阵 (batch_size=4): 文本1 文本2 文本3 文本4 图像1 [ 9.2 -3.1 -4.5 -5.2 ] ← 匹配 图像2 [ -2.8 8.7 -3.9 -4.1 ] ← 匹配 图像3 [ -4.2 -3.5 9.1 -3.8 ] ← 匹配 图像4 [ -5.1 -4.8 -3.7 8.9 ] ← 匹配 标签: [0, 1, 2, 3] (对角线索引) 图像→文本损失: -log(exp(9.2) / (exp(9.2)+exp(-3.1)+exp(-4.5)+exp(-5.2))) 文本→图像损失: -log(exp(9.2) / (exp(9.2)+exp(-2.8)+exp(-4.2)+exp(-5.1))) 总损失 = (图像损失 + 文本损失) / 2 ``` ### CoCaLoss类 (第144-184行) ```python class CoCaLoss(ClipLoss): """ CoCa (Contrastive Captioners) 损失 组合损失 = λ₁ × ClipLoss + λ₂ × CaptionLoss """ def __init__( self, caption_loss_weight, clip_loss_weight, pad_id=0, local_loss=False, gather_with_grad=False, **kwargs, ): super().__init__(local_loss, gather_with_grad, **kwargs) self.clip_loss_weight = clip_loss_weight self.caption_loss_weight = caption_loss_weight self.caption_loss = nn.CrossEntropyLoss(ignore_index=pad_id) def forward(self, image_features, text_features, logits, labels, logit_scale): """ 参数: - logits: 生成式logits [batch, seq_len, vocab_size] - labels: 文本标签 [batch, seq_len] """ # 1. 对比损失 if self.clip_loss_weight: clip_loss = super().forward(image_features, text_features, logit_scale) clip_loss = self.clip_loss_weight * clip_loss # 2. 生成式损失(next token prediction) caption_loss = self.caption_loss( logits.permute(0, 2, 1), # [batch, vocab, seq_len] labels, ) caption_loss = self.caption_loss_weight * caption_loss return clip_loss, caption_loss ``` ### SigLipLoss类 (第314-489行) ```python class SigLipLoss(nn.Module): """ Sigmoid Loss for Language Image Pre-training 与CLIP损失的区别: - 使用Sigmoid代替Softmax - 不需要全局归一化 - 更适合大规模分布式训练 """ def __init__( self, cache_labels: bool = False, rank: int = 0, world_size: int = 1, dist_impl: Optional[str] = None, # 'bidir', 'shift', 'reduce', 'gather' chunk_size: int = 0, ): super().__init__() self.rank = rank self.world_size = world_size self.dist_impl = dist_impl or 'bidir' self.chunk_size = chunk_size def get_ground_truth(self, device, dtype, num_logits, negative_only=False): """生成标签矩阵 (+1对角线, -1其他)""" labels = -torch.ones((num_logits, num_logits), device=device, dtype=dtype) if not negative_only: labels = 2 * torch.eye(num_logits, device=device, dtype=dtype) + labels return labels def _loss(self, image_features, text_features, logit_scale, logit_bias=None, negative_only=False): """计算sigmoid loss""" logits = logit_scale * image_features @ text_features.T if logit_bias is not None: logits += logit_bias labels = self.get_ground_truth( image_features.device, image_features.dtype, image_features.shape[0], negative_only=negative_only, ) # -logsigmoid(labels * logits) 当labels=+1时为正样本,=-1时为负样本 loss = -F.logsigmoid(labels * logits).sum() / image_features.shape[0] return loss def forward(self, image_features, text_features, logit_scale, logit_bias): """前向计算(含分布式)""" loss = self._loss(image_features, text_features, logit_scale, logit_bias) # 分布式邻居交换 if self.world_size > 1: if self.dist_impl == 'bidir': # 双向交换 right_rank = (self.rank + 1) % self.world_size left_rank = (self.rank - 1 + self.world_size) % self.world_size for i in range((self.world_size - 1) // 2): text_features_recv = neighbour_exchange_bidir_with_grad(...) for f in text_features_recv: loss += self._loss(image_features, f, logit_scale, negative_only=True) elif self.dist_impl == 'shift': # 环形移位 for i in range(self.world_size - 1): text_features_from_left = neighbour_exchange_with_grad(...) loss += self._loss(image_features, text_features_from_left, ...) return loss ``` ### GenLipLoss类 (第542-561行) ```python class GenLipLoss(nn.Module): """ 生成式CLIP损失(用于GenLIP模型) 纯自回归语言建模损失 """ def __init__(self, ignore_index: int = -100): super().__init__() self.ignore_index = ignore_index self.caption_loss = nn.CrossEntropyLoss(ignore_index=ignore_index) def forward(self, logits, labels): """ 参数: - logits: [batch, seq_len, vocab_size] - labels: [batch, seq_len] 损失在展平的token上计算 """ loss = self.caption_loss( logits.reshape(-1, logits.shape[-1]), # [batch*seq_len, vocab_size] labels.reshape(-1), # [batch*seq_len] ) return loss ```
## 训练全流程 ### 训练入口 (main.py) ```python def main(args): """OpenCLIP训练主函数""" # 1. 初始化分布式环境 device = init_distributed_device(args) # 2. 设置实验名称和日志 args.name = f"{date_str}-model_{model_name}-lr_{lr}-b_{batch_size}" setup_logging(args.log_path, args.log_level) # 3. 创建模型 model, preprocess_train, preprocess_val = create_model_and_transforms( args.model, args.pretrained, precision=args.precision, device=device, aug_cfg=args.aug_cfg, ) # 4. 创建训练任务(封装模型+损失) task = create_task(args, model=model) # 5. 创建数据加载器 data = get_data(args, preprocess_train, preprocess_val) # 6. 创建优化器 optimizer = create_optimizer(args, model) # 7. 创建学习率调度器 scheduler = cosine_lr(optimizer, args.lr, args.warmup, args.epochs) # 8. 创建训练状态 state = TrainState( task=task, optimizer=optimizer, scheduler=scheduler, scaler=scaler, epoch=start_epoch, ) # 9. 训练循环 for epoch in range(start_epoch, args.epochs): train_one_epoch(state, data, args) evaluate(task, data, epoch, args) # 10. 保存检查点 save_checkpoint(state, checkpoint_path) ``` ### 训练状态 (train.py) ```python @dataclass class TrainState: """训练运行时状态""" task: Any # 训练任务 optimizer: Optional[torch.optim.Optimizer] = None scaler: Optional[Any] = None # AMP GradScaler scheduler: Optional[Callable] = None # 学习率调度器 epoch: int = 0 # 当前epoch global_step: int = 0 # 全局步数 samples_seen: int = 0 # 已处理样本数 compiled_train_step: Optional[Callable] = None # 编译后的训练步 losses_ema: dict = field(default_factory=dict) # 损失EMA ``` ### 单epoch训练 (train.py 第337-520行) ```python def train_one_epoch(state: TrainState, data, args, tb_writer=None): """训练一个epoch""" task = state.task optimizer = state.optimizer scheduler = state.scheduler epoch = state.epoch # 1. 设置模型为训练模式 task.train() # 2. 初始化数据加载器 dataloader = data['train'].dataloader data['train'].set_epoch(epoch) # 3. 确定训练步函数 if args.torchcompile and args.torchcompile_strategy == 'step': train_step = _get_compiled_train_step(state, autocast, args) elif args.accum_freq > 1: train_step = None # 使用累积 else: train_step = _make_train_step_no_accum_no_scaler(...) # 4. 训练循环 for i, batch in enumerate(dataloader): # 4.1 更新学习率 step = num_batches_per_epoch * epoch + i // args.accum_freq scheduler(step) # 4.2 准备批次 batch = task.prepare_batch(batch, device=device, input_dtype=input_dtype) # 4.3 训练步 if train_step is not None: optimizer.zero_grad() losses, report = train_step(batch) task.clamp_logit_scale() else: # 使用梯度累积或混合精度 result = _train_step_eager(task, batch, accum_state, optimizer, scaler, autocast, args) losses, report = result # 4.4 更新计数器 state.global_step = step + 1 state.samples_seen += batch_size * args.world_size # 4.5 日志记录 if is_master(args) and i % args.log_every_n_steps == 0: _logger.info( f"Epoch: {epoch} " f"Loss: {losses['loss'].item():.4f} " f"LR: {get_learning_rate(optimizer):.5f} " f"Logit Scale: {report['logit_scale'].item():.3f}" ) # TensorBoard记录 if tb_writer is not None: for name, val in losses.items(): tb_writer.add_scalar(f"train/{name}", val.item(), step) # 5. Epoch结束日志 _logger.info(f"End epoch {epoch} | Avg Loss: {loss_avg:.4f}") ``` ### 训练步函数 (train.py 第163-216行) ```python def _make_train_step_no_accum_no_scaler(task, optimizer, autocast, args): """ 构建单步训练函数(无累积、无scaler) 这是torch.compile的目标函数 """ grad_clip_norm = getattr(args, "grad_clip_norm", None) clip_params = tuple(task.trainable_module.parameters()) if grad_clip_norm else () def train_step(batch): # 1. 获取损失缩放(NaFlex) loss_scale = get_naflex_loss_scale(batch, args, task) # 2. 前向传播 with autocast(): losses, report = task(batch) total_loss = losses["loss"] if loss_scale != 1.0: total_loss = total_loss * loss_scale # 3. 反向传播 total_loss.backward() # 4. 梯度裁剪 if grad_clip_norm is not None: torch.nn.utils.clip_grad_norm_(clip_params, grad_clip_norm) # 5. 优化器步 optimizer.step() return losses, report return train_step ``` ### 急切模式训练步 (train.py 第221-311行) ```python def _train_step_eager(task, batch, accum_state, optimizer, scaler, autocast, args): """ 急切模式训练步(支持梯度累积和AMP) 返回: (losses, report, batch_size, accum_state) """ # === 无梯度累积 === if args.accum_freq == 1: optimizer.zero_grad() with autocast(): losses, report = task(batch) total_loss = losses["loss"] loss_scale = get_naflex_loss_scale(batch, args, task) if loss_scale != 1.0: total_loss = total_loss * loss_scale backward(total_loss, scaler) _finish_eager_train_step(task, optimizer, scaler, args) return losses, report, batch_size, accum_state # === 有梯度累积 === accum_batches, accum_features = accum_state # 1. 缓存特征(无梯度) with torch.no_grad(): with autocast(): model_out = task.trainable_module(**batch) model_out.pop("logit_scale", None) model_out.pop("logit_bias", None) for key, val in model_out.items(): if key in accum_features: accum_features[key].append(val) else: accum_features[key] = [val] accum_batches.append(batch) # 2. 等待累积完成 if len(accum_batches) < args.accum_freq: return None # 3. 计算累积梯度 optimizer.zero_grad() for j in range(args.accum_freq): batch_j = accum_batches[j] is_last_step = (j == args.accum_freq - 1) # 禁用梯度同步(FSDP/DDP) use_no_sync = not is_last_step and (isinstance(task.trainable_module, DistributedDataParallel)) ddp_context = task.trainable_module.no_sync() if use_no_sync else nullcontext() with ddp_context: with autocast(): model_out = task.trainable_module(**batch_j) # 重新组装输入(使用累积特征) inputs_no_accum = {"logit_scale": model_out.pop("logit_scale")} inputs = {} for key, val in accum_features.items(): accumulated = accum_features[key] inputs[key] = torch.cat(accumulated[:j] + [model_out[key]] + accumulated[j + 1:]) losses, report = task.compute_accum_loss(inputs, inputs_no_accum, accum_batches) total_loss = sum(v for k, v in losses.items() if k.endswith('_loss')) backward(total_loss, scaler) # 4. 优化器步 _finish_eager_train_step(task, optimizer, scaler, args) return losses, report, step_batch_size, ([], {}) ``` ### 学习率调度 (scheduler.py) ```python def cosine_lr(optimizer, base_lr, warmup_lr, warmup_steps, total_steps): """ 余弦退火学习率调度器 公式: - warmup阶段: lr = warmup_lr + (base_lr - warmup_lr) * step / warmup_steps - decay阶段: lr = 0.5 * (1 + cos(π * (step - warmup_steps) / (total_steps - warmup_steps))) """ def step_fn(step): if step < warmup_steps: # 线性warmup lr = warmup_lr + (base_lr - warmup_lr) * step / warmup_steps else: # 余弦衰减 lr = 0.5 * base_lr * (1 + math.cos(math.pi * (step - warmup_steps) / (total_steps - warmup_steps))) for param_group in optimizer.param_groups: param_group['lr'] = lr return lr return step_fn ``` ### 优化器创建 (optim.py) ```python def create_optimizer(args, model): """ 创建优化器 支持: - AdamW (默认) - SGD - AdaW (带层衰减) """ # 跳过不需要衰减的参数 skip = {} if hasattr(model, 'no_weight_decay'): for n, p in model.named_parameters(): if n in model.no_weight_decay(): skip[p] = True # 参数分组(不同学习率) decay_params = [] no_decay_params = [] for n, p in model.named_parameters(): if p not in skip: if 'bias' in n or 'norm' in n or 'ln_' in n: no_decay_params.append(p) else: decay_params.append(p) optimizer_args = dict( params=[ dict(params=decay_params, weight_decay=args.wd), dict(params=no_decay_params, weight_decay=0) ], lr=args.lr, betas=(args.beta1, args.beta2), eps=args.eps, ) if args.optim == 'adamw': return torch.optim.AdamW(**optimizer_args) elif args.optim == 'sgd': return torch.optim.SGD(**optimizer_args) else: raise ValueError(f"Unknown optimizer: {args.optim}") ``` ### 训练命令示例 ```bash # 基础训练 python -m open_clip_train.main \ --train-data "pipe:curl -s -L {url} | tar xf -" \ --train-num-samples 1000000 \ --dataset-type webdataset \ --batch-size 256 \ --epochs 32 \ --lr 1e-3 \ --warmup 2000 \ --model ViT-B-32 \ --name my_experiment # 带验证的训练 python -m open_clip_train.main \ --train-data "/path/to/train shard-{0000..0999}.tar" \ --val-data "/path/to/val" \ --dataset-type webdataset \ --imagenet-val "/path/to/imagenet/val" \ --batch-size 128 \ --workers 8 \ --epochs 64 \ --lr 5e-4 \ --model ViT-L-14 \ --pretrained laion2b_s34b_b79k \ --report-to wandb tensorboard # FSDP2分布式训练 python -m open_clip_train.main \ --train-data "pipe:aws s3 cp ..." \ --batch-size 2048 \ --accum-freq 1 \ --fsdp \ --fsdp-shard-strategy FULL_SHARD \ --model ViT-H-14 \ --precision amp_bf16 \ --torchcompile-strategy step \ --grad-checkpointing ``` ### 训练数据加载 (data.py) ```python def get_data(args, preprocess_train, preprocess_val): """ 创建训练和验证数据加载器 支持的数据源: - WebDataset (.tar文件) - CSV文件 - ImageFolder - TFDS (部分) """ data = {} # === 训练数据 === if args.dataset_type == 'webdataset': # WebDataset管道 pipeline = [ wds.SimpleShardList(args.train_data), wds.split_by_node, wds.split_by_worker, wds.tarfile_to_samples, wds.decode("pil"), wds.rename(image="jpg;png;jpeg;webp", text="txt"), wds.map(filter_no_caption_or_no_image), wds.map_dict(text=TokenizeText(tokenizer)), wds.map_dict(image=preprocess_train, text=None), wds.batched(args.batch_size, partial=False), ] dataloader = wds.WebLoader(pipeline, num_workers=args.workers) elif args.dataset_type == 'csv': # CSV数据集 dataset = CsvDataset( args.train_data, transforms=preprocess_train, img_key=args.csv_img_key, caption_key=args.csv_caption_key, tokenizer=tokenizer, ) sampler = DistributedSampler(dataset, shuffle=True) dataloader = DataLoader(dataset, batch_size=args.batch_size, sampler=sampler) data['train'] = DataInfo(dataloader=dataloader, sampler=sampler) # === 验证数据 === if args.val_dataset: val_dataset = ImageFolder(args.val_dataset, transform=preprocess_val) val_loader = DataLoader(val_dataset, batch_size=args.batch_size) data['val'] = DataInfo(dataloader=val_loader) # === ImageNet验证 (零样本) === if args.imagenet_val: data['imagenet-val'] = get_imagenet(args, (preprocess_train, preprocess_val), 'val') return data ```
## 推理与评估 ### 基础推理示例 ```python import torch import open_clip from PIL import Image # 1. 加载模型 model, preprocess = open_clip.create_model_and_transforms('ViT-B-32', pretrained='laion2b_s34b_b79k') model.eval() tokenizer = open_clip.get_tokenizer('ViT-B-32') # 2. 准备输入 image = preprocess(Image.open("image.jpg")).unsqueeze(0) text = tokenizer(["a dog", "a cat", "a car"]) # 3. 推理 with torch.no_grad(), torch.cuda.amp.autocast(): image_features = model.encode_image(image) text_features = model.encode_text(text) # 4. 计算相似度 image_features /= image_features.norm(dim=-1, keepdim=True) text_features /= text_features.norm(dim=-1, keepdim=True) text_probs = (100.0 * image_features @ text_features.T).softmax(dim=-1) print("标签概率:", text_probs) ``` ### 零样本分类 (zero_shot.py) ```python def zero_shot_eval(task, data, epoch, args, tokenizer=None): """ImageNet零样本评估""" if "imagenet-val" not in data: return {} # 1. 获取ImageNet类别名 classnames = open_clip.IMAGENET_CLASSNAMES templates = open_cipher.OPENAI_IMAGENET_TEMPLATES # 2. 构建分类器 classifier = build_zero_shot_classifier( model, tokenizer=tokenizer, classnames=classnames, templates=templates, device=args.device, ) # 3. 评估 metrics = {} dataloader = data['imagenet-val'].dataloader with torch.inference_mode(): top1, top5 = 0.0, 0.0 for images, labels in dataloader: images = images.to(args.device) labels = labels.to(args.device) # 前向传播 with torch.cuda.amp.autocast(): logits = 100.0 * model.logit_scale.exp() * (model.encode_image(images) @ classifier.T) # 计算准确率 acc1, acc5 = accuracy(logits, labels, topk=(1, 5)) top1 += acc1 top5 += acc5 metrics['imagenet_val_top1'] = top1 / len(dataloader) metrics['imagenet_val_top5'] = top5 / len(dataloader) return metrics ``` ### 检索评估 (metrics.py) ```python def get_clip_metrics( image_features, text_features, logit_scale, image_key="image", text_key="text", retrieval_chunk_size=DEFAULT_RETRIEVAL_CHUNK_SIZE, ): """ 计算图像-文本检索指标 指标: - Image→Text Recall@1,5,10 - Text→Image Recall@1,5,10 - Mean Reciprocal Rank (MRR) - Median Rank (MedR) """ # 计算相似度矩阵(分块以节省内存) logits = logit_scale * (image_features @ text_features.T) # 图像→文本检索 image2text_metrics = compute_recall(logits, topk=(1, 5, 10)) # 文本→图像检索 text2image_metrics = compute_recall(logits.T, topk=(1, 5, 10)) return { "image2text_recall@1": image2text_metrics[0], "image2text_recall@5": image2text_metrics[1], "image2text_recall@10": image2text_metrics[2], "text2image_recall@1": text2image_metrics[0], "text2image_recall@5": text2image_metrics[1], "text2image_recall@10": text2image_metrics[2], } ``` ### 完整评估函数 (train.py 第536-713行) ```python def evaluate(task, data, epoch, args, tb_writer=None, tokenizer=None): """运行验证和零样本评估""" metrics = {} device = torch.device(args.device) # 1. 零样本评估 zero_shot_metrics = zero_shot_eval_all(task, data, epoch, args, tokenizer) metrics.update(zero_shot_metrics) # 2. 验证集评估 if 'val' in data and (epoch % args.val_frequency == 0 or epoch == args.epochs): task.eval() dataloader = data['val'].dataloader all_image_features = [] all_text_features = [] cumulative_loss = 0.0 with torch.inference_mode(): for batch in dataloader: batch = task.prepare_batch(batch, device) # 前向传播 model_out = task(batch) # 收集特征 image_features = model_out["image_features"] text_features = model_out["text_features"] logit_scale = model_out["logit_scale"].mean() all_image_features.append(image_features.cpu()) all_text_features.append(text_features.cpu()) # 计算损失 logits_per_image = logit_scale * (image_features @ text_features.T) logits_per_text = logits_per_image.T labels = torch.arange(batch_size, device=device) loss = (F.cross_entropy(logits_per_image, labels) + F.cross_entropy(logits_per_text, labels)) / 2 cumulative_loss += loss * batch_size # 计算检索指标 val_metrics = get_clip_metrics( image_features=all_image_features, text_features=all_text_features, logit_scale=logit_scale, ) metrics.update(val_metrics) # 3. 日志记录 _logger.info(f"Eval Epoch {epoch}: " + "\t".join([f"{k}: {v:.4f}" for k, v in metrics.items()])) return metrics ``` ### 图像检索示例 ```python def image_retrieval(query_image, database_images, model, preprocess, top_k=10): """ 图像检索:从数据库中找到与查询最相似的图像 返回: (indices, similarities) """ # 1. 编码查询图像 query_feat = model.encode_image(preprocess(query_image).unsqueeze(0)) query_feat /= query_feat.norm(dim=-1, keepdim=True) # 2. 编码数据库图像(批量) db_feats = [] for img in database_images: feat = model.encode_image(preprocess(img).unsqueeze(0)) feat /= feat.norm(dim=-1, keepdim=True) db_feats.append(feat) db_feats = torch.cat(db_feats, dim=0) # 3. 计算相似度 similarities = (query_feat @ db_feats.T).squeeze(0) # 4. Top-K top_indices = similarities.topk(top_k).indices top_similarities = similarities[top_indices] return top_indices, top_similarities ``` ### 文本-图像检索示例 ```python def text_image_search(query_text, image_dataset, model, tokenizer, preprocess, top_k=5): """使用文本查询搜索图像""" # 1. 编码文本 text_tokens = tokenizer([query_text]) text_feat = model.encode_text(text_tokens) text_feat /= text_feat.norm(dim=-1, keepdim=True) # 2. 编码所有图像 image_feats = [] for img in image_dataset: img_feat = model.encode_image(preprocess(img).unsqueeze(0)) img_feat /= img_feat.norm(dim=-1, keepdim=True) image_feats.append(img_feat) image_feats = torch.cat(image_feats, dim=0) # 3. 计算相似度并排序 similarities = (text_feat @ image_feats.T).squeeze(0) top_k_indices = similarities.topk(top_k).indices return [(image_dataset[i], similarities[i].item()) for i in top_k_indices] ``` ### 批量特征提取 ```python def extract_features_for_dataset(images, model, preprocess, batch_size=256, device='cuda'): """为整个数据集提取特征""" model.eval() model.to(device) all_features = [] dataloader = DataLoader(images, batch_size=batch_size) with torch.inference_mode(): for batch in dataloader: batch = batch.to(device) # 预处理 batch = torch.stack([preprocess(img) for img in batch]) # 编码 features = model.encode_image(batch) features /= features.norm(dim=-1, keepdim=True) all_features.append(features.cpu()) return torch.cat(all_features, dim=0) ``` ### OpenCLIP vs OpenAI CLIP API对比 ```python # ===== OpenAI CLIP ===== import clip model, preprocess = clip.load("ViT-B/32") image_input = preprocess(Image.open("image.jpg")).unsqueeze(0) text_input = clip.tokenize(["a dog", "a cat"]) image_features = model.encode_image(image_input) text_features = model.encode_text(text_input) logits_per_image, logits_per_text = model(image_input, text_input) # ===== OpenCLIP ===== import open_clip model, preprocess = open_clip.create_model_and_transforms('ViT-B-32', pretrained='laion2b_s34b_b79k') tokenizer = open_clip.get_tokenizer('ViT-B-32') image_input = preprocess(Image.open("image.jpg")).unsqueeze(0) text_input = tokenizer(["a dog", "a cat"]) image_features = model.encode_image(image_input) text_features = model.encode_text(text_input) logits_per_image, logits_per_text = model.get_logits(image_input, text_input) # 或使用字典输出 model.output_dict = True output = model(image=image_input, text=text_input) # output = {"image_features": ..., "text_features": ..., "logit_scale": ...} ``` ### 预训练模型列表 ```python >>> import open_clip >>> open_clip.list_pretrained()[:10] # 显示前10个 [ ('ViT-B-32', 'laion2b_s34b_b79k'), ('ViT-B-32', 'laion2b_s34b_b88k'), ('ViT-B-32', 'laion1b_s13b_b51k'), ('ViT-B-16', 'laion2b_s34b_b88k'), ('ViT-L-14', 'laion2b_s32b_b82k'), ('ViT-L-14', 'datacomp_xl_s13b_b90k'), ('ViT-H-14', 'laion2b_s32b_b79k'), ('convnext_base', 'laion2b_s34b_b88k'), ('convnext_large', 'laion2b_s29b_b131k'), ] ```
参考
## 相关论文 ### CLIP原论文 **Learning Transferable Visual Models From Natural Language Supervision** - 作者:Alec Radford et al. (OpenAI) - 年份:2021 - 亮点:大规模对比学习、零样本能力 ### PMC-CLIP **PMC-CLIP: Contrastive Learning of Medical Visual-Language Representation on PubMed Central** - 针对医学图像-文本理解 - 在PMC开放数据上训练 - 使用BiomedBERT作为文本编码器 ### 医学AI相关 - Medical image-text matching - Clinical report generation - Multi-modal medical AI
## 代码资源 ### Hugging Face模型 - **PMC-CLIP权重**:https://huggingface.co/datasets/axiong/pmc_oa - **BiomedBERT**:https://huggingface.co/microsoft/BiomedNLP-BiomedBERT-base-uncased-abstract ### 示例代码 ```python # 完整推理示例 from transformers import AutoTokenizer, AutoModel import torch from PIL import Image # 加载模型 tokenizer = AutoTokenizer.from_pretrained('microsoft/BiomedNLP-BiomedBERT-base-uncased-abstract') text_encoder = AutoModel.from_pretrained('microsoft/BiomedNLP-BiomedBERT-base-uncased-abstract') # 加载图像 image = Image.open('medical_image.jpg') # 预处理 text_input = tokenizer(['chest X-ray'], return_tensors='pt') image_input = preprocess(image).unsqueeze(0) # 提取特征 with torch.no_grad(): image_feat = image_encoder(image_input) text_feat = text_encoder(**text_input).pooler_output # 计算相似度 similarity = (image_feat @ text_feat.T).item() ```