项目经验分享|OpenMMLab 浦视 瞿博文:始于兴趣,行于目标方向

开源之夏个人专访与项目经验分享持续开放中,欢迎已从开源之夏毕业或正在参与开源之夏活动的学生、导师一同加入专访行动,扫描文末二维码填写专访问卷,与大家分享你眼中的开源之夏!
本期项目经验分享来自OpenMMLab 浦视社区中选学生——瞿博文,在开源之夏2023中承担的项目是基于MMPreTrain实现Prompt-base分类器。

# 关于 OpenMMLab 浦视
OpenMMLab 诞生于 2018 年,是深度学习时代计算机视觉领域最全面、最具影响力的开源算法体系。旨在为学术和产业界提供一个可跨方向、结构精良、跨站性强、易复现的统一算法工具库。在 2021 年上海人工智能大会上,发布了新一代 OpenMMLab,其升级后涵盖了更广泛的算法领域和应用场景,实现了从训练到部署的全链条价值。目前,OpenMMLab 已经累计开源了超过 30 个算法库,涵盖分类、检测、分割、视频理解等众多算法领域,有超过 300 种算法实现、2,400 多个预训练模型。在 GitHub 上获得超过 75,000 个标星,同时吸引了超过 1,600 名社区开发者参与项目贡献,用户遍及超过 110 个国家和地区,覆盖全国全球顶尖高校、研究机构和企业。
官网:https://openmmlab.com/
# 项目基本信息
项目名称:基于 MMPreTrain 实现 Prompt-base 分类器
项目导师:马泽润
项目需求:本题目的任务是实现一个 prompt-base 的分类器,它的权重是固定的,提供简单的接口, 给出以下参数就可以对任意图片进行分类:
类别名(category)
图像描述(optional)
图片样例(optional)
项目链接:https://summer-ospp.ac.cn/org/prodetail/232bc0112
# 项目开发经验分享
项目背景与大致流程
项目背景
传统图像分类通常遵循预训练加微调(pretrain + finetune)的模式,并依赖一个预设的固定类别表。然而,随着多种视觉-语言多模态模型(Vision-Language Models, VLMs)的兴起,这种多模态方法使得模型能够无需微调,仅通过预设提示(prompts)即可直接产出卓越的分类结果。这种做法颠覆了传统的预训练模型在图像分类下游任务中的微调方法,标志着从经典微调过渡到一种新的多模态范式——在这种范式中,模型不需要在下游任务上进行额外训练,而是直接依据具体任务构建相关的文本模板(prompt),通过多模态推理来得到分类结果。
大致流程
1. 基于 OpenAI 的 CLIP 模型,利用其强大的 zero-shot 能力,实现 Open-Vocabulary 的图像分类(主要针对单目标分类,即仅有一个输出结果)
2. 基于 RAM(Recognize Anything Model),实现 Open-Vocabulary 的多分类任务,可以将图像中所有物体进行识别并输出(即支持多目标分类)
关键概念
Registry 机制:
MM 系列库的核心,这一机制最初由 MMEngine 库定义。该机制为模型、数据集、优化器、学习率调度器、数据预处理转换、分词器等组件提供了一个注册表,注册表实现了字符串到具体类的映射。这意味着用户可以避免复杂的 import 语句,直接通过注册表快速访问并实例化所需的类。此外,Registry 机制还简化了配置文件(Config 文件)的编写过程,使得用户配置模型和实验变得更加高效和灵活。同时,也为模块测试提供了便利,对仓库的开发者和维护者来说是一个福音。
Hook 机制:
MM 系列库的又一个核心,可以在整个 pipeline 的某个部分,如:模型的 forward 途中,定义 Hook,从而为输出模型中间层特征,特征可视化等操作提供了便利。
各种基类:
MM 系列算法库提供了一系列的基类,例如 BaseModel、BaseDataProcessor 等。这些基类不仅明确规定了派生子类必须实现的方法,而且也便于子类继承和定制化重写。通过这种设计,MM 系列算法库的一致性和模块化得到了显著提升,同时也简化了新算法的集成和开发过程。
项目实现细节
基于 MMPreTrain 实现 CLIP
Step1:将 CLIP 的 ViT 转换成 MMPreTrain 中的 VisionTransformer 的实现
需要完成以下内容:
完成 ViT 的 checkpoint 中的 state_dict 的转换
实现 ViT-B/16 和 ViT-L/14 两种 setting 的转换
关键函数:
from collections import OrderedDict
def convert_clip(ckpt):
new_ckpt = OrderedDict()
for k, v in list(ckpt.items()):
new_v = v
if k.startswith('visual.conv1'):
new_k = k.replace('conv1', 'patch_embed.projection')
elif k.startswith('visual.positional_embedding'):
new_k = k.replace('positional_embedding', 'pos_embed')
new_v = v.unsqueeze(dim=0)
elif k.startswith('visual.class_embedding'):
new_k = k.replace('class_embedding', 'cls_token')
new_v = v.unsqueeze(dim=0).unsqueeze(dim=0)
elif k.startswith('visual.ln_pre'):
new_k = k.replace('ln_pre', 'pre_norm')
elif k.startswith('visual.transformer.resblocks'):
new_k = k.replace('transformer.resblocks', 'layers')
if 'ln_1' in k:
new_k = new_k.replace('ln_1', 'ln1')
elif 'ln_2' in k:
new_k = new_k.replace('ln_2', 'ln2')
elif 'mlp.c_fc' in k:
new_k = new_k.replace('mlp.c_fc', 'ffn.layers.0.0')
elif 'mlp.c_proj' in k:
new_k = new_k.replace('mlp.c_proj', 'ffn.layers.1')
elif 'attn.in_proj_weight' in k:
new_k = new_k.replace('in_proj_weight', 'qkv.weight')
elif 'attn.in_proj_bias' in k:
new_k = new_k.replace('in_proj_bias', 'qkv.bias')
elif 'attn.out_proj' in k:
new_k = new_k.replace('out_proj', 'proj')
elif k.startswith('visual.ln_post'):
new_k = k.replace('ln_post', 'ln1')
elif k.startswith('visual.proj'):
new_k = k.replace('visual.proj', 'visual_proj.proj')
else:
new_k = k
new_ckpt[new_k] = new_v
return new_ckpt如此即可将 OpenAI 的 Vision Transformer 的权重转换到 MMPreTrain 内置实现的 Vision Transformer 的格式,方便我们在 MMPreTrain 框架下也可以加载 OpenAI 的 Vision Transformer 权重。
Step2:实现一个 CLIP 基类
其中需要完成以下功能:
1. 模型结构组件的定义
2. 实现图像处理、文本的处理以及 BBPE(Byte-level Byte Pair Encoding) 分词
3. 实现图像特征的提取,以及文本特征的提取
核心代码:
class CLIP(BaseModel):
def __init__(self,
vision_backbone: dict,
projection: dict,
text_backbone: dict,
tokenizer: dict,
vocab_size: int,
transformer_width: int,
proj_dim: int,
context_length: int = 77,
data_preprocessor: Optional[dict] = None,
init_cfg: Optional[dict] = None):
# 定义模型组件,包括图像、文本编码器,对齐所用的projection层、分词器tokenizer、
# 对输出的logits进行scale的一个可训练常数logit_scale等
def forward(
self,
images: torch.Tensor,
data_samples: Optional[list] = None,
mode: str = 'predict',
**kwargs,
):
# 仅支持推理,不支持训练
if mode == 'predict':
return self.predict(images, data_samples, **kwargs)
else:
raise RuntimeError(f'Invalid mode "{mode}".')
def extract_image_feat(self, images: torch.Tensor) -> torch.Tensor:
"""The function to extract image latent features."""
def extract_text_feat(self, texts: torch.Tensor) -> torch.Tensor:
"""The function to extract text latent features."""
def extract_feat(
self, images: torch.Tensor,
texts: torch.Tensor) -> Union[torch.Tensor, Tuple[torch.Tensor]]:
def compute_similarity(self, images, texts):
"""Extract images and texts features and compute cosine similarity."""
@abstractmethod
def predict(self,
images: torch.Tensor,
data_samples: DataSample = None) -> DataSample:
raise NotImplementedError
def tokenize(self, texts: Union[str, List[str]]) -> torch.LongTensor:
"""Returns the tokenized representation of given input string(s)
Args:
texts (Union[str, List[str]]): An input string or a list of input
strings to tokenize
context_length (int): The context length to use. Defaults to 52.
Returns:
torch.Tensor: Resulting tokens.
"""可以看到,predict 方法暂未实现,需要在其子类中进行实现。
Step3:实现一个 CLIPZeroShot 类
它继承自 CLIP 基类,并实现额外的 zero-shot 推理功能,即任意给定一个 category,可以在这个 category 下实现 open-vocabulary 的分类。
具体而言,需要重写 CLIP 基类没有定义的 predict 方法,大致代码如下:
@MODELS.register_module()
class CLIPZeroShot(CLIP):
def predict(self,
images: torch.Tensor,
data_samples: DataSample = None) -> DataSample:
if self.text_prototype_embeds is None:
self.prepare_text_prototype(device=images.device)
image_features = self.extract_image_feat(images=images)
image_features /= image_features.norm(dim=-1, keepdim=True)
# cosine similarity as logits
logits_per_image = image_features @ self.text_prototype_embeds.to(
image_features.device) * self.logit_scale.exp()
pred_scores = F.softmax(logits_per_image, dim=1)
pred_labels = pred_scores.argmax(dim=1, keepdim=True).detach()
out_data_samples = []
if data_samples is None:
data_samples = [None for _ in range(pred_scores.size(0))]
for data_sample, score, label in zip(data_samples, pred_scores,
pred_labels):
if data_sample is None:
data_sample = DataSample()
data_sample.set_pred_score(score).set_pred_label(label)
out_data_samples.append(data_sample)
return out_data_samples
def prepare_text_prototype(self, device) -> None:
"""The function to prepare text prototypes with prompt."""
class_embeddings = []
for classname in track_on_main_process(self.prototype,
'Prepare text prototype...'):
# format with class
texts = [prompt(classname) for prompt in self.prompt]
tokenized_texts = self.tokenize(texts)
class_features = self.extract_text_feat(tokenized_texts.to(device))
class_features /= class_features.norm(dim=-1, keepdim=True)
class_feature = class_features.mean(dim=0)
class_feature /= class_feature.norm()
class_embeddings.append(class_feature)
self.text_prototype_embeds = torch.stack(
class_embeddings, dim=1).to(device)简单来说,即:CLIPZeroShot 类继承自 CLIP 基类,并在 predict 方法中实现了图像的 open-vocabulary 分类。
基于 MMPreTrain 实现 RAM
RAM 中需要使用 CLIP 模型的文本编码器提取文本特征,所以 RAM 的实现是基于上述的 MMPreTrain 中 CLIP 实现的。
Step1:将 RAM 的 SwinTranformer 转换成 MMPreTrain 中的实现
需要完成的功能:
SwinTransformer 的 checkpoint 中的 state_dict 的转换
在此过程中,我也遇到了一个困扰我很久的问题,即:
MMPetrain 中采用最新版本的 swin-transformer 实现,其中 PatchMerging 模块采用 nn.Unfold 实现,而其他 SwinTransformer 实现大多采用 Slice 再 Concat 的实现方式,所以在对应的 state_dict 的权重的通道顺序上也需要进行转换。
最初,我一直在硬磕这个问题,死磕了很久才得以解决,在和导师交流后,他很快地就在 MMSegmentation 库的 Issue 和 PR 中找到了几乎一模一样的问题和解决方案,这就是开源社区的好处,如果我早点意识到,也就会减少很多重复的工作量了~
关键函数代码:
from collections import OrderedDict
def convert_swin(ckpt):
new_ckpt = OrderedDict()
convert_mapping = dict()
def correct_unfold_reduction_order(x):
out_channel, in_channel = x.shape
x = x.reshape(out_channel, 4, in_channel // 4)
x = x[:, [0, 2, 1, 3], :].transpose(1,
2).reshape(out_channel, in_channel)
return x
def correct_unfold_norm_order(x):
in_channel = x.shape[0]
x = x.reshape(4, in_channel // 4)
x = x[[0, 2, 1, 3], :].transpose(0, 1).reshape(in_channel)
return x
for k, v in ckpt.items():
if 'attn_mask' in k:
continue
if k.startswith('head'):
continue
elif k.startswith('layers'):
new_v = v
if 'attn.' in k:
new_k = k.replace('attn.', 'attn.w_msa.')
elif 'mlp.' in k:
if 'mlp.fc1.' in k:
new_k = k.replace('mlp.fc1.', 'ffn.layers.0.0.')
elif 'mlp.fc2.' in k:
new_k = k.replace('mlp.fc2.', 'ffn.layers.1.')
else:
new_k = k.replace('mlp.', 'ffn.')
elif 'downsample' in k:
new_k = k
if 'reduction.' in k:
new_v = correct_unfold_reduction_order(v)
elif 'norm.' in k:
new_v = correct_unfold_norm_order(v)
else:
new_k = k
new_k = new_k.replace('layers', 'stages', 1)
elif k.startswith('patch_embed'):
new_v = v
if 'proj' in k:
new_k = k.replace('proj', 'projection')
else:
new_k = k
elif k.startswith('norm'):
new_v = v
new_k = k.replace('norm', 'norm3')
else:
new_v = v
new_k = k
new_ckpt[new_k] = new_v
convert_mapping[k] = new_k
return new_ckpt, convert_mappingStep2:实现 RAM 基类,并基于此实现默认词表的 RAMNormal 类和支持用户自定义词表的 RAMOpenset 类
此处的程序设计理念和 CLIPZeroShot 与 CLIP 基类类似,即 RAM 基类实现一些基本的模型推理和特征提取,而子类的 RAMNormal 和 RAMOpenset 更改其 predict 方法,以完成个性化的设计,大致的伪代码框架如下:
class RAM(BaseModel):
"""The implementation of `RAM <https://arxiv.org/abs/2306.03514>`_."""
def __init__(self,
tokenizer: dict,
vision_backbone: dict,
tag_encoder: dict,
tagging_head: dict,
text_decoder: dict,
device: str = 'cpu',
vision_width: int = 1536,
prompt='a picture of ',
threshold=0.68,
delete_tag_index=[],
tag_list='./data/ram_tag_list.pickle',
tag_list_chinese='./data/ram_tag_list_chinese.pickle',
data_preprocessor: Optional[dict] = None,
init_cfg: Optional[dict] = None):
# 定义各组件
def load_tag_list(self, tag_list_file):
# 从文件中得到词表
def get_label_embed(self):
# 得到词表中每个词的嵌入特征
def extract_visual_feature(self, images):
# 提取视觉特征
def image2tag(self, label_embed, image_embeds, image_atts):
# image2tag推理
def forward(
self,
images: torch.Tensor,
data_samples: Optional[list] = None,
mode: str = 'predict',
**kwargs,
):
if mode == 'predict':
return self.predict(images, data_samples, **kwargs)
else:
raise RuntimeError(f'Invalid mode "{mode}".')
@abstractmethod
def predict(self,
images: torch.Tensor,
data_samples: DataSample = None) -> DataSample:
raise NotImplementedError
@MODELS.register_module()
class RAMNormal(RAM):
def tag_process(self, logits):
# 处理词表
def predict(self,
images: torch.Tensor,
data_samples: DataSample = None) -> DataSample:
# 定义直接加载词表情况下的predict行为
@MODELS.register_module()
class RAMOpenset(RAMNormal): # 继承RAMNormal类
def set_openset(self,
categories: List[str] = None,
clip_ckpt: str = '',
threshold: float = 0.68):
# openset的相关设置和embedding提取
def tag_process(self, logits):
# 重写tag_process函数Step3:基于 gradio 实现一个 webui,能够让用户更便捷的使用 RAM
构建一 个WebUI,可以让用户更加方便地使用 RAM,测试其性能,并且近乎实时地看到输出结果,体感极强!
项目结果呈现
CLIP在CIFAR100和ImageNet1k上的zero-shot性能对齐
如下表展示的数据所示,基于 MMPreTrain 实现的 CLIP 模型在 CIFAR100 和 ImageNet1k 这两个数据集上的 zero-shot 分类性能,可以与 OpenAI 的 CLIP 模型相媲美。

RAM 的 Gradio WebUI demo 展示
加载预设词表(Normal 模式):


使用自定义词表(Openset 模式,暂未支持中文输出):

PR 链接
CLIP:https://github.com/open-mmlab/mmpretrain/pull/1737
RAM:https://github.com/open-mmlab/mmpretrain/pull/1802
后续工作安排
由于我个人研究方向是多模态学习,且现阶段对多模态大语言模型非常感兴趣,在参加 OSPP 后,我也有在持续关注 MMPreTrain 和 OpenMMLab 其他仓库(如:MMGPT 等)的最新进展和开发者活动,我也会通过开发者活动和日常贡献等方式继续参与 OpenMMLab 社区的开源建设!
# 开源之夏个人随访
--自我介绍--
OSPP:请简单介绍一下自己,并说一下自己的开源经历吧。
瞿博文:大家好,我是瞿博文,本科毕业于华中科技大学电子信息工程专业,现在在北京大学信息工程学院计算机应用技术专业就读,是一名研二学生。在参加开源之夏活动之前,我主要是在计算机保研群的 GitHub 仓库中提交了一篇经验分享文章;还在实习期间为一个视频动作识别的开源项目提交了代码(相对来说可能,比较自娱自乐地分享一下,这个贡献现在似乎还没有被合并呢,哈哈)
OSPP:你有几段实习和项目经历,这些经历和你在开源社区中的经历有什么异同之处呢?
瞿博文:算是比较正式的算是一段实习和一段比赛吧,我大四时在科大讯飞有一段实习经历,当时主要做的是图像目标检测和视频动作识别相关的项目,后来参加过Kaggle的一个有关视觉语言多模态的比赛。这些经历的话,可能kaggle的这段和开源社区的经历更加相似吧,因为kaggle也是一个偏社区性质的竞赛平台,有非常多的大佬分享自己的经验、方法和代码,当时能获得银牌多亏了kaggle多方大佬的讨论和分享。然而,这与开源社区还是有不小区别的,如果说kaggle的分享对我来说是一种“传授”的话,我认为开源社区的分享应该是一种“互通”,是社区、贡献者、使用者们互相交流,在过程中共同发现新坑新问题。同时,开源社区的话涉及更长期的“承诺”,社区以及贡献者们需要更长期、广泛地合作和交流,以提供一个稳定而又不断创新的框架。
OSPP:参与(例如开源之夏)开源项目开发实践与参与竞赛的体验有什么不同?
瞿博文:排除上面所说的“分享”层面的不同,我作为一个初学者参加kaggle竞赛时,可能更关注于我在竞赛榜单上的位置,关注他人分享的经验和方法是否能、如何能用到我的方案上,以获得一个更高的排名,而没有去分享自己的经验的意识(同时也水平有限),这会导致在进行项目编程和总结的时候,忽略掉一些规范和整体性。从完整项目的角度来看,似乎是处在一个toy级别。
而参加开源项目开发实践的话,会在编程时就对自己有一些代码风格、质量、规范上的高要求;在提交PR时会更整体的去介绍和总结自己的贡献;在进行review交互时,会不断地思考如何迭代得更好。这些过程让我对于更加高质量项目的建立、提交、迭代有了一些更深入的认识。
--参与开源之夏--
OSPP:在开源之夏的项目开发过程中,你有遇到什么困难或挑战么?你是如何克服的呢?导师和社区在这一过程中又给你提供了什么帮助?最大的收获是什么?
瞿博文:是有遇到一些困难的,就像经验贴里所说的,在进行swin transformer的state_dict转换时,一个模块中算子的实现有所差异,需要重新去计算,当时花了我很长时间去解决,在后来与导师交流时,导师发现OpenMMLab社区的另一个MMSegmentation库中有一个类似的Issue和相关PR,能够很好的解决我的问题。当时让我意识到了开源社区的好处,对于开发时遇到的问题,可以先在开源社区进行检索,一些相关分享是可以很好的避免我们重复造轮子的。
OSPP:对于在活动中选择社区、挑选项目、与导师沟通、撰写申请书方面,有什么经验或建议可以分享给大家么?
瞿博文:以我个人的经历而言,我比较推荐去选择与自己的兴趣和背景更加相关的社区和项目,这大概率可以让我们写出有背景、有方案、有一定深度的申请书,在进行项目的时候也可以更加有把握、有方向。至于与导师沟通的话,我觉得可以尽可能地提前进行,这样可能可以更早的进入社区,参与一些Issue和PR,增进自己对于社区的了解吧,这方面我是个反例,ddl战士太挑战心跳了一点。
--寄语--
OSPP:谈一谈你对高校学生参与开源的看法吧
瞿博文:作为高校学生,我们平时接触到的项目应该更多是大作业和课程设计,或者自发地进行一些个人项目和竞赛。做这些项目的目的也主要是了解、学习一个新领域的知识。参与开源可以让我们更早地接触到工业级的、更加完整的、更加高质量的项目,而PR和review就像是初次验收和不断迭代,这可以让我们了解到一个好的项目的完整流程大概是什么样的,增进我们对于项目的品味和感知,后续可能就可以做出一些更好地项目了!
END
专栏编辑:大梦
校对:校大山、瞿博文
制图:GoodWhite

专栏投稿请联系开源小助手:kaiyuanzhixia 或专栏编辑:Hungryfish34(备注“专栏投稿”加速通过),或填写下方专访信息收集问卷。


