小小的科研总结

本文最后更新于 2025年4月25日 下午

Warning:个人实践理解 + AI解释 + 二手博客信息汇总, 如有错误,请于底部评论区批评指正,全文仅供参考

在此感谢师兄对我的指导和老师提供的实验经费!!

git记录从2月25日开始,那天pull下来了 facebookresearch/DomainBed框架,到现在做了一个多月的实验,清明假期犯懒不想做正事,特回顾总结并记录这一个多月的实验问题以及解决方法

记录顺序以执行的代码顺序以及时间顺序记录

First Stage

这一阶段主要任务是:使用未经训练的CLIP预训练模型提取一个数据集的特征,画出关于域的t-SNE图

师兄是在HuggingFace上翻的 doc 给我看,指出用CLIP Vision Model / ViT等预训练视觉模型来做。

比较简单,半天完成,暂时没用到DomainBed,毕竟不需要各种算法。在网上下载了PACS数据集,上传到AutoDL上。

什么是CLIP?

论文原文: Learning Transferable Visual Models From Natural Language Supervision(只看了摘要)

以看完摘要的理解来说就是:利用了4亿图文对训练,对比学习,取消了以前依赖label的监督方式,转而利用图片的文本描述,这样监督更broader。图文结合。

具有强大的泛化能力和零样本能力

啥是监督(supervise)

我也似懂非懂,大概就是用没用标签的意思。贴出Grok 3的回答,括号内是我的浅显理解:

在深度学习中,“监督”(supervise)是指在模型训练过程中是否使用了标注数据来指导学习。根据是否需要标注数据以及标注数据的多少,可以将学习方式分为 以下几类:监督学习(明确标签)、自监督学习(一开始没标签,自己生成标签)、半监督学习(少部分有标签,大部分没标签)和无监督学习(完全没标签)。

学习类型 是否需要标注数据 训练方式 典型应用
supervised 全部 优化损失函数 分类、回归
semi-supervised 少量 伪标签迭代 数据稀缺场景分类
unsupervised 发现数据结构,无明确输出目标 聚类、降维、生成

然后看了看下面的方法总览图和描述,意思感觉差不多,传统是先训练vision model来extract feature,然后投进linear classifier里面分类。CLIP同时有图像和文本encoder。

看到两个encoder组合,突然想到了之前自己想的的水大创,也是图像结合文本信息,虽然草陋得多(雾)

什么叫提取特征

feature、representation,在论文中指“特征“、“表示”

我目前的理解是:深度神经网络可以输入图像,输出图像的“特征”,也就是经过各种层、各种卷积计算之类的,最后输出一个多少维 \(\times\) 多少维的向量,这个向量就是图像的feature或者representation。比如CLIP是768维向量,ResNet是2048维向量。

可以通过查看输出的shape来看是否正确使用了CLIP或者ResNet

(shape就是输出的样子、格式之类的,调试时变量真正是什么样和应该是什么样)

这两个词似乎可以混用,DG领域的综述《Generalizing to Unseen Domains: A Survey on Domain Generalization》用的是representation

周志华老师的《机器学习》(2016)中也写到:“可将深度学习理解为进行‘特征学习’(feature learning)或‘表示学习’(representation learning)。”

这里的提取特征就是,准备好images,然后调整好输入方式,喂给CLIP预训练模型,得到一个特征或表示(768维向量)

什么是t-SNE图

最开始接触到是在组会和各种论文里,有那种scatter plot(散点图),各种颜色。

基本都是一个颜色表示一个域,然后看各个域的重合情况,重合越多表示域不变特征(domain-invariant feature)越强,各个域共有的特征多,如果学到这个特征,就能通吃各个域,泛化能力强。

下文有3张t-SNE图,一直以来都只是知道怎么看,以及怎么调库生成,以及知道是可视化相邻情况的。下面较详细地记录下数学原理和调库原理:

来自t-SNE算法解析和AI回答

t-SNE

t-distributed Stochastic Neighbor Embedding,t分布随机邻域嵌入,一种非线性降维技术,用于高维数据可视化。(好像是Hinton提出的😮)

核心就是降维,要可视化一般降到2或3维,降维之后,在高维里面相邻的,在低维也相邻

t-SNE数学原理简述

高维空间的相似性建模

SNE使用条件概率来描述两个数据之间的相似性,假设\(x_i, x_j\)是高维空间中的两个点,那么以点\(x_i\)为中心构建方差为\(\sigma_i^2\)的高斯分布,使用\(p_{j|i}\)表示\(x_j\)\(x_i\)邻域的概率,如果\(x_j\)\(x_i\)很近,那么\(p_{j|i}\)很大,反之,\(p_{j|i}\)很小,\(p_{j|i}\)定义如下: \[ p_{j|i}=\frac{\exp(-||x_i-x_j||^2/(2\sigma_i^2))}{\sum_{k\ne i}{\exp(-||x_i-x_j||^2/(2\sigma_i^2))}} \] > 看了SVM的核方法,之前不知道为什么要这样构造,现在大概知道了,\(\exp(-||x_i-x_j||^2/(2\sigma_i^2))\) 是高斯核,之后是个人猜测:高斯核在SVM中是为了避免高维的复杂内积运算,将低维映射到高维,搜索了一下,高斯核可以映射到无穷维,所以这里是为了升维。为什么要升维呢?

设定\(p_{i|i}=0\),因为只想要不同点的相似度。

对称化:将条件概率对称化,定义联合概率: \[ p_{ij}=\frac{p_{j|i}+p_{i|j}}{2n} \] \(n\)是数据点总数。

低维空间的相似性建模

假设\(x_i, x_j\)映射到低维变成了\(y_i,y_j\),每个\(y\)\(d\)维向量(\(d=2、3\))

\(y_j\)\(y_i\)邻域的条件概率为: \[ q_{j|i}=\frac{(1+||y_i-y_j)||^2)^{-1}}{\sum_{k\ne l }{(1+||y_k-y_l)||^2)^{-1}}} \]

目标函数

在高维空间中,如果考虑\(x_i\)与其他所有点之间的条件概率,那么会构成一个条件概率分布\(P_i\) ,同样在地位空间也会有与之对应的条件概率分布 \(Q_i\),如果降维之后的数据分布与原始高维空间中的数据分布是一样的,那么理论上这两个条件概率分布式是一致的。那么如何衡量两个条件概率分布之间的差异呢?经典问题,使用 K-L 散度(也叫做相对熵),于是,目标函数为: \[ C=\sum_{i}{KL(P_i||Q_i)}=\sum_{i\ne j}\log{\frac{p_{j|i}}{q_{j|i}}} \] 通过梯度下降调整\(y_i\)的位置,最小化\(C\)

其他

更底层的就不写了,可以去问AI,这里写个大概:

得到梯度公式,初始的低维空间点随机生成,然后迭代更新\(y_i\)

困惑度perplexity控制高斯分布的\(\sigma_i\),影响邻域大小;t分布的长尾特性缓解了高维数据在低维空间的过度压缩。

在Python中一般使用from sklearn.manifold import TSNE导入库使用:

1
2
tsne = TSNE(n_components=2, perplexity=30, n_iter=1000, random_state=42)
features_2d = tsne.fit_transform(features)

first summary

整体的代码流程是:

  • load_data
    • 使用from torchvision.datasets import ImageFolder,设定好backbone需要的transform(图像大小尺寸等),得到ImageFolder对象
    • 利用from torch.utils.data import DataLoaderImageFolder对象变成Dataloader对象
  • load_model
    • 利用from transformers import CLIPProcessor, CLIPModel+from_pretrained方法直接从huggingface下载预训练模型openai/clip-vit-base-patch32
  • extract_feature
    • 在torch.no_grad()条件下(任务不需要梯度下降,所以关闭梯度下降,简化计算流程),遍历loader中的images,调各种库函数提取特征,拼接在一起,最后返回。 按照HuggingFace的doc上来说,应该得到CLIP的pooler_output这个输出。
  • plot_tsne
    • 调库,特征降维,得到二维特征,用matplotlib画图

second stage

找个图像域泛化方法,用CLIP当backbone,训练前后都用backbone把数据encode成feature,画出t-SNE。

用意是:

看相关的图像域泛化方法的t-SNE是否有分开的现象。因为最初的研究动机是,一些文本的域泛化方法使用后反而使域不变特征减少了,也就是t-SNE的各个颜色点变得各自分离团簇而不互相交杂

这一步就开始用到DomainBed了,不过只需要简单使用,训练出一两个模型。

什么是backbone?

以前用YOLO的时候看到过这个概念,记得YOLO是head、neck、backbone的结构,但不知道是做什么的。

目前的理解是:backbone是用来提取特征的,我们常说的各种网络(CLIP、ResNet、VGG、AlexNet、LeNet……)一般都用来当做backbone。

输入的是图片(以及其他raw数据),输出的是特征,也就是用来提取特征的(见上文:什么叫提取特征)。

由于对各种权威专著毫无了解,所以贴AI的回答:

在深度学习中,backbone 通常指模型中用于提取输入特征的核心网络结构。 比如在图像任务中,ResNet、VGG、MobileNet 等 CNN 架构常用作图像的 backbone;

而在自然语言处理任务中,Transformer、BERT、RoBERTa 等模型常作为文本的 backbone。

Backbone 的主要职责是将原始输入转化为高维、抽象的特征表示,便于后续的分类器或其他模块进行处理。

通常在领域泛化、迁移学习等任务中,也会关注 backbone 的特征表达能力以及是否固定其参数。

再简略写一句关于head、neck的解释:

  • Backbone:主干网络,从原始输入中提取高级通用特征;

  • Neck:连接Backbone和Head,再目标检测任务中很重要,用于融合不同尺度的特征 或者 加强/重组backbone提取的特征,常见结构有FPN、BiFPN、PAN;

  • Head:输出头,根据任务需求输出结果,比如分类、检测、分割

不同backbone提取同一数据集的特征会不同吗?代表什么?

会,如上面所说,CV和NLP领域的模型的backbone都会不同,说明backbone之间差异很大,对于同一数据集,提取出的特征可能也会大相径庭。

这可以解释为不同的backbone对于特征的理解不同提取的特征也不同,比如:

  • ResNet特征偏向低层纹理、边缘等视觉细节
  • ViT偏向全局语义和高层概念

以容易理解的方式来说就比如,ResNet提取的狗照片的特征是有毛发,而CLIP提取的狗照片的特征是四条腿,DINO可能提取了狗的姿态……

同样,不同backbone提取出的特征,作t-SNE图的结果也可能会大相径庭,聚类表现完全不同。

DINO简介

Distillation with NO labels,meta在21年提出的自监督学习方法,全称是Self-Distillation with NO Labels。

解决的问题

标注数据太费,DINO希望利用大量未标注数据,也能训练好;

ViT在自监督领域不稳定,DINO通过teacher-student框架稳定训练;

核心思想
  • 不使用标签的知识蒸馏(Self-distilation)

    • 使用一个student网络和一个teacher网络

    • 输入图像的多个增强版本,分别送入teacher和student,student要尽可能接近teacher。

  • teacher不参与BP,而是使用student的指数滑动平均更新参数

  • ……

总结

DINO是让ViT在没有标签的情况下,能学到结构化语义特征的自监督方法。

DINOv2,无需下游微调,直接表现良好。

知识蒸馏简介

在DeepSeek下面看到过,一直都是知道,但从未了解,DINO也出现了,所以了解一下:

Knowledge Distillation用一个复杂的大模型Teacher来指导一个简单的小模型Student学习,从而提升小模型的性能

传统模型训练使用硬标签,而KD训练使用Teacher输出的软标签

硬标签:如one-hot,直接表明哪个类是正确的

软标签:给出一系列softmax后的概率分布

用于:模型压缩、加速推理、多模型融合、自监督、多模态

蒸馏需要温度,在知识蒸馏中,温度T在softmax中显示,在exp中除以T,让两极分化程度变小,输出的概率变得更相近,暴露出类别之间的关系。

温度升高,负标签的关注度就会上升,学到的也会变多。

backbone还能训练?什么意思?

训练,也可以叫迭代优化,一般“训练模型”说人话就是一遍遍试错,然后调整各种可学习的“参数权重”,来让表现更好。

backbone是可以训练的,也可以选择“冻结”,也就是不训练backbone或者训练backbone的一部分。

那么backbone有什么“参数权重”需要训练来调整呢?

很多,backbone平时视作黑盒,但内部复杂,以ResNet为例,可学习的有每一层卷积核的权重、每个BatchNorm的缩放系数、残差连接的参数……

方法和模型的关系

这个问题比较长,不好放在标题,在下面写:

假设,有一篇论文,提出了个方法,叫MetHod(吐槽:经典的,以奇怪的大小写方式、强行拼凑出的一个单词当方法名字),有效提高了关于图像的域泛化能力。

现在我要训练一个以CLIP为backbone的、使用MetHod方法的模型,那CLIP和MetHod的关系是什么?

概念 类型 作用 层级
CLIP 模型结构(Backbone) 提取特征 属于内部网络的模块
MetHod 训练/优化算法 最小化训练集上的平均损失,引导模型学习 属于训练策略

流程是:

  • 输入图像:ImageFolder -> DataLoader -> 喂给 CLIP
  • CLIP输出特征:[feature] 喂给分类器classifier
  • 输出分类概率:[dog:80%, chair:10%, ……],计算loss(例如交叉熵或者MetHod规定的loss计算方式)
  • 使用MetHod:最小化训练集上所有样本的平均loss
  • 反向传播更新CLIP和分类器的参数

所以,MetHod算作是损失函数+优化策略+训练目标的总称。

关系是:

MetHod和backbone相互影响,是两个不同的但相互影响的模块

训练结束后得到权重,利用这个权重new一个model出来,这就得到模型。

使用DomainBed时遇到的问题

开头一堆parser,add_argument是什么?

一种实现命令行选项的方式,例:

1
2
3
4
5
6
7
8
import argparse # 导库
parser = argparse.ArgumentParser() # 对象
parser.add_argument('--hparams', type=str, help='你的注释') # 加参数
# ...一大堆其他的参数设置

args = parser.parse_args() # 等输入,并解析
# 命令行使用python -m train.py --hparams '{"backbone": "clip"}'
# args里面就会有hparams : '{"backbone": "clip"}'这个哈希表项

然后就更能理解DomainBed的README中的用法:

python -m domainbed.scripts.train 跟一堆参数,就能一键开始训练。

怎么换backbone

domainbed包里主要的文件有:

  • lib.fast_data_loader.py:定义了FastDataLoaderInfiniteDataLoader,在train.py中用到
  • lib.misc.pyMiscellaneous,杂项的意思,目前主要关注到accuracy方法,是checkpoint计算预测准确率(correct / total)的(因为这个方法比较慢,数据集大了就是瓶颈之一)
  • scripts.train.py主要的训练流程代码,解析参数、设置hp、分割数据集、加载数据、迭代、保存模型
  • scripts.download.py下载数据集用的。在最后的if __name__ == "__main__"下保留要下载的数据集的方法,执行带上data_dir参数
  • algorithms.py:记录了DomainBed支持的所有算法的类(继承torch.nn.Module),也就是各种算法的实现。有一个总的抽象父类供所有类实现,有updatepredict两个方法
  • datasets.py:记录了DomainBed支持的各种数据集的域、设置了checkpoint的频率,以及子文件夹的拼接路径
  • hparams_registry.py:记录了对各种算法、数据集的默认超参数设置(batch size、lr等)
  • networks.py实现了各种网络,ResNet、DinoV2、MNIST_CNN等

显然,我需要在networks.py中实现CLIP,然后打包成跟ResNet差不多的样子,最后找到分配backbone的“工厂”部分,加上CLIP的选项。

1
2
3
4
5
6
7
8
9
10
# 分配backbone的“工厂”:
def Featurizer(input_shape, hparams):
if hparams.get("backbone", "resnet50") == "clip": # 添加CLIP模型
return CLIPBackbone(input_shape, hparams)
elif hparams["vit"]:
if hparams["dinov2"]:
return DinoV2(input_shape, hparams)
else:
raise NotImplementedError
return ResNet(input_shape, hparams) # 默认 ResNet

训练完怎么利用得到的model.pkl?

首先看到train.py是怎么保存的:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 保存模型的方法
def save_checkpoint(filename):
if args.skip_model_save:
return
save_dict = {
"args": vars(args),
"model_input_shape": dataset.input_shape,
"model_num_classes": dataset.num_classes,
"model_num_domains": len(dataset) - len(args.test_envs),
"model_hparams": hparams,
"model_dict": algorithm.state_dict()
}
torch.save(save_dict, os.path.join(args.output_dir, filename))

save_checkpoint在最后被调用,保存为了model.pkl,主要关注方法中的kv关系:

1
2
3
4
5
6
7
# model.pkl中的哈希关系
"args": vars(args),
"model_input_shape": dataset.input_shape,
"model_num_classes": dataset.num_classes,
"model_num_domains": len(dataset) - len(args.test_envs),
"model_hparams": hparams,
"model_dict": algorithm.state_dict()

这说明model.pkl就是个哈希表,而不是exe那种执行一下、喂数据、出结果 的黑盒,需要用这些数据重新构建出model。

所以看看algorithms.py是怎么定义各种模型的类的,以便复原出模型,这里以ERM举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# ERM的部分实现代码
class ERM(Algorithm):
"""
Empirical Risk Minimization (ERM)
"""
def __init__(self, input_shape, num_classes, num_domains, hparams):
super(ERM, self).__init__(input_shape, num_classes, num_domains, hparams)
self.featurizer = networks.Featurizer(input_shape, self.hparams)
self.classifier = networks.Classifier(
self.featurizer.n_outputs,
num_classes,
self.hparams['nonlinear_classifier'])

self.network = nn.Sequential(self.featurizer, self.classifier)
self.optimizer = torch.optim.Adam(
self.network.parameters(),
lr=self.hparams["lr"],
weight_decay=self.hparams['weight_decay']
)
# ...others...

可以看到__init__方法需要input_shape, num_classes, num_domains, hparams四个参数,正好是model.pkl里面保存的,所以读取出来传进去就行了。

注意调用栈里面会用到的自己写的CLIP类,也要保持与原有的ResNet、DinoV2相同的输入结构,才好复用。

现在只需要把之前用纯CLIP画图的代码中的load_model方法里用from_pretrained下载的模型改成使用model.pkl重新构建的 model 即可。

second summary

这部分也比较简单,主要难点在看懂DomainBed的结构

其实现在都没完全看懂 (lll¬ω¬)

最后的结果是,太理想了!t-SNE里的各域变得团簇了!,结果很令人迷惑,感觉前后都差不多,纯CLIP的比较团簇,使用方法后反而混合错杂了一点😵‍💫

回顾下这一阶段的目的:

看相关的图像域泛化方法的t-SNE是否有分开的现象。因为最初的研究动机是,一些文本的域泛化方法使用后反而使域不变特征减少了,也就是t-SNE变得团簇而不错杂

纯CLIP
ERM
SagNet

third stage

这一阶段开始使用咱们(全是师兄提出的,但不妨碍,我们两个真是太厉害了😋☝️)提出的特征对齐方法了:

利用的特征对齐方法:

计算源域和目标域的平均向量,求differ=源域平均向量-目标域平均向量,在分类前给样本特征加上differ,再投入分类器分类

实验目标:

  • 不用该方法获得的准确率/F1分数
  • 使用该方法获得的准确率/F1分数
  • CLIP参数固定与不固定
  • 每个方法跑3次求平均

用伪代码描述:

1
2
3
4
5
for d in [None, differ]: # 是否用differ
for clip in [freeze, unfreeze]: # 是否固定CLIP
for i in range(3): # 不同随机种子重复实验3次
seed = random
train(d, clip, seed)

首先要训练3n个模型,然后改一下测准确率的方法,向里面加入使用特征对齐的分类方式。在训练后算出differ并保存。

写代码过程中的问题

训练结束后如何调用方法计算平均向量并保存?

首先要拼接(torch.utils.data.ConcatDataset)所有源域数据,才能计算源域的平均向量。

这里没有使用ImageFolder->DataLoader的常规load方法,而是使用了DomainBed自己的FastDataLoader,用的时候几乎都是ChatGPT写代码,没怎么看FastDataLoader是如何工作和使用的,下面简要记录:

FastDataLoader类(在我修改后)支持DDP(DistributedDataParallel,分布式数据并行,见下文),传入dataset、batch_size、num_workers、device参数,

成员loaderDataLoader对象,使用传入的参数定义,另有__iter__方法和__len__方法,分别提供迭代和长度。

看起来似乎就是对DataLoader的简单封装,大概是为了统一实验设置,毕竟DomainBed为的是提供标准统一实验框架


说完Loader。计算平均向量的代码是师兄提供的,输入backbone、loader、device即可。

得到平均向量后简单相减,然后torch.save保存为pt文件。

如何利用differ?

differ要在投入分类器前加到样本的特征上,所以流程应该是:

加载测试数据和模型->遍历样本->模型backbone提取样本的特征->加上或不加上differ->喂给分类器->统计得到准确率和F1

上文所说,lib.misc.py中有一个测分类准确率的方法,似乎只需要略加修改即可。

但是我当时并没有意识到,于是自己写了一个evaluate方法😞,包含load_data、extract_features等操作。

总之,核心代码如下:

1
2
3
4
5
6
7
8
features = model.featurizer(images) # 提取样本特征
# 如果传入了differ,就加上
if differ is not None and differ.shape == features.shape[1:]:
# 喂给分类器
logits = model.classifier(features)
# 取最可能的一个预测值
predictions = torch.argmax(logits, dim=1)
# ...correct/total...

如何在DomainBed中提取特征(stupid版问题)

问出这个问题是因为,我自己写evaluate时,不知道怎么调出模型的backbone,不知道该怎么把image喂给backbone。

上文所说,DomainBed中的各种算法类都是实现了一个抽象父类,需要实现updatepredict两个方法。

而下面的各种方法,用来表示backbone和分类器的成员名称都不太一样,导致了不能直接network(image)表示backbone、model(feature)表示分类。

例如,ERM的是featurizer、classifier,SagNet的是network_f、network_c,于是有了下面的if-else🤡:

1
2
3
4
5
6
7
8
9
10
11
if hasattr(algorithm, "featurizer"):
feature_extractor = algorithm.featurizer
elif hasattr(algorithm, "network_f"):
feature_extractor = algorithm.network_f
elif hasattr(algorithm, "network"):
feature_extractor = algorithm.network
elif hasattr(algorithm, "predict"):
def feature_extractor(x):
return algorithm.predict(x)
else:
raise ValueError(f"{args.algorithm} does not support feature extraction!")

如何固定CLIP参数

这里的“固定CLIP参数”就是冻结backbone的意思(见上文

在初始化CLIP时加上

1
2
3
if hparams.get("freeze_clip", False):  # 是否冻结 CLIP
for param in self.network.parameters():
param.requires_grad = False

取消CLIP中所有参数的requires_grad就好。

如何一键训练多个模型

这一阶段需要训练多个模型,如上文所说,每次训练需要用python -m domainbed.scripts.train --hparapms '...' --dataset "..." --algorithm "..." --output_dir "/..." --test_envs "..." 这一长串命令来启动,对于目前这种需要训练多个模型的场景:

  • 模型在服务器上训练,时间就是金钱,最好自动无缝衔接训练
  • 命令这么长,手滑敲错了很难发现,还浪费了时间

很不友好

其实,DomainBed有Sweep功能可以提供大型实验管理,也就是这里的一键训练多个模型,但由于我觉得英文的README太难读并且README里没写sweep是啥,就没了解,很久之后才知道,DomainBed自带有这样的功能。

这时候就要掏出计算机教育中缺失的一课(虽然我只看了一点点)中的shell🤓。需要强调的是,这并不是AI出的主意,而是从我的辣鸡大脑中蹦出来的(自豪)

回想之前的伪代码,这就是良好的草稿,稍作修改,将其中的train改为这一长串命令带参数就好了。简单不知天高地厚地贴个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#!/bin/bash
DATASETS=("PACS")
ALGORITHMS=("ERMPlusPlus" "SagNet" "ERM")
SEEDS=(17 373 3403)
TEST_ENVS=(0)
OUTPUT_BASE="/root/autodl-tmp/results"
HPARAMS=('{"backbone": "clip", "freeze_clip": true}')

for DATASET in "${DATASETS[@]}"; do
for TEST_ENV in "${TEST_ENVS[@]}"; do
for ALGORITHM in "${ALGORITHMS[@]}"; do
COUNT_NOFREEZE=1 # 计数器(不冻结 CLIP)
COUNT_FREEZE=1 # 计数器(冻结 CLIP)

for SEED in "${SEEDS[@]}"; do
for HPARAM in "${HPARAMS[@]}"; do
# 判断是否 freeze CLIP
if [[ "$HPARAM" == *"freeze_clip\": true"* ]]; then
FREEZE_STATUS="freeze"
OUTPUT_DIR="${OUTPUT_BASE}/${TEST_ENV}/${ALGORITHM}_${FREEZE_STATUS}_${COUNT_FREEZE}"
# 检查文件夹是否已存在且包含训练好的 model.pkl
if [ -f "${OUTPUT_DIR}/model.pkl" ]; then
echo "已存在: ${OUTPUT_DIR}/model.pkl,跳过训练。"
((COUNT_FREEZE++))
continue
fi
((COUNT_FREEZE++))
else
OUTPUT_DIR="${OUTPUT_BASE}/${TEST_ENV}/${ALGORITHM}_${COUNT_NOFREEZE}"
# 检查文件夹是否已存在且包含训练好的 model.pkl
if [ -f "${OUTPUT_DIR}/model.pkl" ]; then
echo "已存在: ${OUTPUT_DIR}/model.pkl,跳过训练。"
((COUNT_NOFREEZE++))
continue
fi

((COUNT_NOFREEZE++))
fi
mkdir -p "${OUTPUT_DIR}"
python -m domainbed.scripts.train \
--dataset "${DATASET}" \
--algorithm "${ALGORITHM}" \
--data_dir "/root/autodl-tmp/data/${DATASET}" \
--hparams "${HPARAM}" \
--output_dir "${OUTPUT_DIR}" \
--test_envs "${TEST_ENV}" \
--seed "${SEED}"
done
done
done
done
done
# 训练结束,自动关机省钱
/usr/bin/shutdown

只需修改数据集名称,打开screen守护进程,./run.sh一键运行,然后关闭ssh连接放手不管😋☝️

fourth stage

这一阶段的任务是扩大实验,包括:

  • 多实验几个数据集
  • 换用更新的方法(24年及以后)
  • 用resnet当backbone实验
  • 用t-SNE可视化特征对齐之后的域差异
    • Backbone不做训练,encode得到的分布
    • Backbone在某个/些域上训练,w/ w/o differ,encode得到的分布
  • 用MMD衡量特征对齐后的域差异
  • 设计实验展示该方法的低成本且无参特性

目前就卡在这一阶段,自我批评一番:

  • 代码能力较弱(没总结整体思路,写简单的可视化代码与实现多卡训练花了半个月)

  • 实验策略不合理(选用了过大的数据集DomainNet,白干一周)

  • 代码有bug(导致训练缓慢,浪费钱和时间不说,结果还是错的)

  • shell使用不熟练(本想用脚本无缝衔接,结果写错了没检查,得不偿失)

  • 实验结果组织不合理(刚开始自动生成latex表格代码,结果布局不好,又花时间手动复制到excel,excel的布局又不合理,又手动修改……花了很多时间)

  • 懒(遇到困难常常退缩拖延,总结也拖,整体思路混乱,没有自己的思考和记录)

下面记录这一阶段的问题:

如何实现多卡训练

动机是:无知地选了个超大的数据集DomainNet,60w图片,之前用的PACS、VLCS、OfficeHome最多的才1w图片。

由于太大,以DomainBed的默认超参数,batch_size=16,在4090D上都会OOM,减小到4才成功运行,但extremely slow,一个小时计算刚开始的accuracy都没算出来,把checkpoint全ban掉,测出来一分半计算一个样本,好像一共有几十万个样本待测……,总之非常的慢,想到试试多卡,也许可行。

不动脑子的后果,一张卡慢成这样,难道8张卡就能快了?照样费钱,拿不下来,就不该尝试这个数据集

首先搜索了以前听说的DataParallel,全程问AI+搜博客写代码。

多卡训练的基础概念和常用库

基本概念

多卡训练应该叫数据并行(Data Parallelism)。核心思想是将训练数据分成多个子集,分配到多个计算设备(如GPU)上并行处理,从而减少单次迭代的时间。

一般的流程是:

  • 分割batch(解决batch size过大导致的OOM)为多个sub-batch
  • 每个GPU对自己的sub-batch前向计算反向传播来更新参数
  • 将所有GPU的梯度汇总(求和或者平均),在主进程上更新全局参数
  • 再将更新后的参数广播到所有GPU上

好处是batch可以更大,坏处是要通信,可能成为瓶颈;实现复杂

Pytorch的实现

pytorch对于多卡训练有两个库能用,DataParallelDistributedDataParallel,官方文档:DataParallel — PyTorch 2.6 documentationDistributedDataParallel — PyTorch 2.6 documentation

官方强烈建议不使用DataParallel,而使用DistributedDataParallel。理由是:DDP给每个GPU建一个进程(multiprocessing),而DP用的是多线程(multithreading)

多线程避免了Python解释器的全局解释器锁GIL带来的性能开销GIL在同一时刻只允许一个线程访问CPU,执行字节码,而多进程就可以同时执行多个Python解释器。

DataParallel

适用于单机多卡训练,使用简单,把代码里表示模型的变量用DP包装一下就好。

原理:

DP有一个主GPU,上面维护了一个主模型副本,复制到其他GPU上。每次迭代,主GPU将batch分割,分发到各个GPU,各GPU算完汇总梯度到主GPU,主GPU更新模型参数,再广播

相当于计网的星状拓扑

DistribuedDataParallel

单机多卡和多机多卡都适用。

原理:

每个GPU都有一个进程独立拥有完整的模型副本,通过DistributedSampler,每个进程从数据集中获取不同的子集,不需要主GPU分发。每个GPU各自算完,所有GPU直接交换梯度保持同步,并独立更新参数。没有主GPU,通信使用NCCL分布式集体通信

这相当于环状拓扑

我的实现过程

DataParallel尝试过程

DataParallel比较简单,algorithm=DataParallel(algorithm)就够了,注意在调用原有algorithm的方法时,不能直接调,要用algorithm.module调用。

当然,这没成功,改为DataParallel后,训练时使用nvidia-smi查看显卡状态,一共2张卡,只有在load_data完成后的极短时间内,两个GPU的使用率都达到了80%+,且状态都是P2。其他时候都是cuda0打满,另一个0%利用率。

查了问题,虽然最后还是没成功,换用了DDP,但是大概率问题在于模型的实现上,如上文所说,algorithms.py中的抽象父类只有有updatepredict两个方法,DomainBed没有显式定义forward方法Module抽象基类不强制实现forward),而DataParallel在前向计算时只会自动调用模型的forward方法。

这就出问题了,模型里面就network有forward,用model()没定义__call__,压根就调不到forward。那么我自己加一个forward不就好了?于是我加了一个,不知道要写什么,所以直接调CLIP的forward,报错没了,但是GPU使用情况还是单卡背负所有,只是第二张卡上出现了一个Python进程,调试打印信息也显示数据全给了cuda0。

最后还是没解决,然后我气急败坏地改用DDP了

DistributedDataParallel实现过程

如前文,DDP是多进程,且有一个DistributedSampler,所以实现大致分为两部分:实现多进程,添加分布采样器。

多进程
1
2
3
4
5
def init_distributed(): # 初始化进程组
dist.init_process_group(backend='nccl')
torch.cuda.set_device(int(os.environ["LOCAL_RANK"])) # 设置当前 GPU
def cleanup(): # 释放进程组
dist.destroy_process_group()
DistributedSampler

Sampler实际是DataLoader的一个参数,之前一直缺省,现在需要用到。

DistributedSampler 需要在每次 epoch 前调用 .set_epoch() 来重置索引,否则容易出现 StopIteration 错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 提前过滤出符合 args.test_envs 和 args.src_envs 条件的 in_splits 列表,并保留索引 i。
filtered_in_splits = [(i, env, env_weights) for i, (env, env_weights) in enumerate(in_splits)
if i not in args.test_envs and i in args.src_envs]
if num_gpus > 1: # 多卡时使用 DistributedSampler
train_samplers = [
DistributedSampler(env, num_replicas=num_gpus, rank=int(os.environ["LOCAL_RANK"]), shuffle=True)
for i, env, env_weights in filtered_in_splits
]
else:
train_samplers = [None for _ in filtered_in_splits] # 单卡,不使用 DistributedSampler

train_loaders = [
InfiniteDataLoader(
dataset=env,
weights=env_weights,
batch_size=hparams['batch_size'],
num_workers=dataset.N_WORKERS,
sampler=train_samplers[j] # loader中传入参数,指定sampler
)
for j, (i, env, env_weights) in enumerate(filtered_in_splits)
]

再记得每个epoch或step重置采样器:

1
2
3
if num_gpus > 1:
for sampler in train_samplers:
sampler.set_epoch(step)

最后,在fast_data_loader.py中加上适配DDP的一些if-else小逻辑即可,主要是指定sampler的部分。

到这里就成功了,训练时多个GPU都是100%用到,DomainNet数据即使开16的batch size也不会OOM了。

不过,还是非常慢,在程序各处都添加了print,打印各段的耗时,并与之前的小数据集对比,发现就是单纯地数据集太大,单轮迭代的耗时差不多,甚至DomainNet更短一点点,但DomainNet太大,已经是PACS、VLCS等小数据集的60倍以上,难以使用。主要是没那么多卡和钱给我烧,测小数据集就行了。

在多卡上取消所有checkpoint训练DomainNet,每step也需要较长时间,最终放弃。

小插曲,斗胆向DomainBed提出了一个PR,视作无成本的尝试,虽然目前对multi-GPU的支持显得十分cheap

针对多服务器,如何提高evaluate和可视化代码的可移植性

提出这个问题主要是因为实验使用AutoDL进行,每个实例的内存有限,所以我每个服务器只能装一两个数据集,因为每个数据集上需要训练出三十多个模型,都要保存,每个模型大概800M,两个数据集差不多打爆AutoDL的60G数据盘了。

面对这种场景,一份能够尽可能少改动甚至不改动就能直接在各个服务器的数据集上直接评估数据集各个域各个方法各个模型的准确率和可视化域差异的代码显得很有必要。(4重循环变量)

可移植性指软件能够在不同的环境(如操作系统、硬件、文件系统)中运行,而无需或只需最小的修改。强调代码在不同环境下的适应能力,通常通过抽象环境依赖(如路径、配置)来实现。

所以主要从路径上下手,也就是各种路径读取解析,然后拼接

本质是繁琐但不复杂的字符串操作和文件结构命名规范。

部分域实验


小小的科研总结
http://43.143.57.238/2025/04/04/小小的科研总结/
作者
Leoo Yann
发布于
2025年4月4日
更新于
2025年4月25日
许可协议