0. 前言 钟准团队的CCPR2019文章,牛逼。思想有一丢丢类似聚类,但是比聚类强的是,赋予了id。与HHL文章有很大的不同。什也不说了,虚心学习。
1. Introduction 作者的注意力还是集中在如何区分 target domain 中的图片。考虑了三个因素,exemplar-invariance, camera-invariance and neighborhood-invariance. 可以简单地理解成每个人是一类,StarGAN生成的图片是一类,特征相似的是一类,并且为了实现上述目标,需要存储 target domain 中 train 数据集的所有特征,这一点有点疑问,需要的存储空间和计算时间消耗大吗?作者在第一节的最后有解答,消耗的空间和时间都很小。等自己实验的时候就知道了。它是用了两个分类器,一个用于训练 source 数据集, label 是 source 的label,一个用于训练 target 数据集, label 是 target 的label,两个label不一样。网络模型与HHL略有不同。
第一,通过分类损失使 target domain 的图片全部可分。分类模型学到的更可能是图片视觉上的相似性而不是语义相似性,并且数据集中同一id的图片在视觉上变化很大,所以,索性将target数据集上的每张图片作为一类,迫使不同id的相似图片变得不相似。行吧,这个理由,(⊙o⊙)…,怎么有点牵强吧,因为和后面的特征相似的作为一类有一丢丢矛盾。不对,有点类似既然不知道你们的id,那就把让你们之间先全部可分,这个思路在哪篇论文见过,想不起来了。
第二,同一 content 不同 camera style 的图片视为一类。这个和HHL的数据集处理方法一样,用 StarGAN 生成同一图片在不同 camera 下的图片,并视为一类,因为刚刚对图片使用了分类损失,所以这时候可以直接用到分类损失上,而不用再考虑三元组损失,(我觉得可能是基于分类损失的作用一直大于二元组损失,三元组损失),这时可以保证同一 content 不同 camera style 的图片视为一类。这时 StarGAN 生成图片中的人的形态等基本保留,感觉更多是亮度上的变化(肉眼可见),偶尔会有衣服颜色的变换,但是不能变换这个人的姿势,比如正面走变成侧面走这种,HHL 已经证明了 camera style 的影响,但是我觉得姿势的变换也是一个重要的因素才对,因为分类模型的起家就是识别同一类物体不同形态的图片。好像 CVPR 2019 这个团队有做这方面的内容,抽空膜拜一波。
第三,k近邻的图片视为一类。这应该像是之间看到的一些关于聚类的或者k近邻的论文,总体思想是因为不知道图片的真id,那就可能赋予一个比较接近真实的id,又因为同一id的图片的特征应该是临近的,所以把临近的图片作为同一id也不过分,其实有的论文用的是聚类,但本质是一样的。
我觉得钟准厉害的地方不仅仅是能想到这三个创新点,更重要的是能把这三个创新点融合到一起,因为这三个创新点在别的论文里或多或少都可以看到影子,但是因为实现的的方法、思路、具体过程都不一样,很难看见一个创新点就把融到一起,看到一个想法就放到自己的模型里,或者看到一个idea就觉得有用并且一试还真有用,并能给出属于自己的解释,而不是明显的生搬硬造,比如这里的 memory module 很有想法。
Unsupervised domain adaptation. 一种方法是对齐两个域的特征,其基本假设是源域和目标域的类别一样。另一种方法是丢弃目标域的未知类别样本,学习源域到目标域的映射。
Unsupervised person re-identification. ECCV2018_HHL 可能忽略了 target domain 中其他因素对 id 的影响,比如姿势等。与 BMVC2018_DAL 不同的是,作者设计了软分类损失。
3. The Proposed Method 简单定义下数学符号,直接用英文吧,能知道就行。
数学符号
含义
$\lbrace X_s, Y_s \rbrace, N_s$
source domain, $Ns$ person images, $(x {si}, y_{s,i})$, $M$ person identities
$X_t, N_t$
target domain, $N_t$ person images
3.1 Overview of Framework
其实模型没有画的这么复杂,基本还是保留ResNet-50的基本网络,后面加个FC-4049,FC-#id/ exemplar memory,其中FC-#id用于 source domain 的数据, exemplar memory 用于计算 target domain 的数据,等下直接看代码一目了然。
3.2 Supervised Learning for Source Domain 这个容易理解, source domain 直接用个分类损失。
3.3 Exemplar Memory Exemplar Memory 借鉴的是 Joint Detection and Identification Feature Learning for Person Search ,(已经有两篇论文没有看过了,自己菜不是没有原因的),采用的是 key-value 架构 $K,V$,其中在 key-memory 中存储每张图片的 FC-4096 特征, value-memory 中存储图片的lable,其中视每张图片为一类,index 即为 label。key-memory 中的特征初始化成0,value-memory 初始化成 $V[i]=i$,并且在整个训练过程中保持不变。key-memory 的更新策略是:
其中$\alpha\in [0,1]$,$K[i]\gets \parallel K[i] \parallel_2$,也就是每次都会进行一次归一化。这里的 $\alpha$ 是线性变化的,初始化为 0.01, 后续变化为 $\alpha = 0.01*epoch $。其中每次更新都是发生在反向传播时,顺便进行更新的。写的很巧妙。
3.4 Invariance Learning for Target Domain 开始祭出三板斧了。
Exemplar-invariance : 因为同一id的图片在外观上也会有很大的变化,即每一张图片都应该只与自己相似,与其他图片差别很大,因此,可以视每一张图片为一类。具体过程是,对于给定图片$x{t,i}$,先计算 $x {t,i}$ 的特征与其他图片在 key-memory 存储的特征的 cosine 距离,距离越大越相似。然后用这个距离去预测 $x_{t,i}$ 属于第 i 类的概率。这种预测与我见过的分类不太一样,因为分类模型一般都是直接用特征去预测类别,但这里是用距离去预测类别,或者说用距离当预测概率,还是第一次见。
其中 $\beta\in (0,1]$ ,用于平衡分布的趋势,记为 temperature,$\beta=0.05$,根据代码来看,$K[i]$ 表示当前 model 运行之前存储的, $f(x_{t,i})$表示当前 model 的结果,也就是说两者不一样。下面求概率的过程同理。
这个公式和 distillation 很像。
其中,$z_i$是类别预测概率。
所以分类损失可以写成:
Camera-invariance :视经过 StarGAN 转换的图片$\hat{x}_{t,i}$是同一类,与HHL一样,但是与HHL不同的是,HHL用于三元组损失,ECN用于分类损失,类似 Exemplar-invariance 的概率求解和loss损失,得:
其中,$\hat{x} {t,i}$是在生成的C张图片中$\lbrace \hat{x} {t,i,1},…, \hat{x}_{t,i,C} \rbrace$随机选了一张。
这里有个问题,Exemplar Memory 中是否包含 CamStyle 生成的图片。等看代码就知道了。
Neighborhood-invariance : 对每一张图片,在数据集中一定至少存在一张与之相同id的图片,如果能得到这些图片,那就更好了,这样就可以获得同一id不同姿态的图片。具体方法是在 exemplar memory 中计算 cos 距离,用k近邻方法得到最近的k张图片的index,记为 $M(x_{t,i},k)$,显然最近的是i. $k=6$
基于假设k近邻得到的图片$M(x{t,i},k)$属于同一类,因此可以得到 $x {t,i}$ 属于第 j 类的概率的权重是:
这种赋予权重的方法是把 $x{t,i}$ 属于另一张图片类别的概率,而不是我之前以为的 $M(x {t,i},k)$ 中的图片属于 $x_{t,i}$ 类别的概率。
因此损失可以写成:
其中,为了区分 Exemplar-invariance 和 Neighborhood-invariance ,这里没有再次计算 $x_{t,i}$ 属于第 i 类的损失。
Overall loss of invariance learning : invariance learning 的总损失可以表示成:
其中,$x^* {t,i}$ 表示随机从$\lbrace x {t,i}, \hat{x} {t,i,1},…, \hat{x} {t,i,C}, \rbrace$ 选一张。
看看代码再说这个损失的具体实现吧。
3.5 Final Loss for Network 其中,$\lambda \in (0, 1]$, $\lambda=0.3$
这里的损失没有用常见的加法,而是一个线性组合?
4. Experiment 4.1 Experiment Setting 前5个 epoch 只训练 exemplar-invariance and camera-invariance,也就是通用的交叉熵损失,5个epoch之后再加入 neighborhood-invariance。
4.2 Parameter Analysis temperature fact $\beta$, weight of loss $\lambda$, number of candidate positive samples $k$.
Temperature fact $\beta$
当 $\beta$ 比较小的时候,分布越陡,值越大,损失越小,结果也越好。最好的结果是 $\beta$ 在0.05 左右。同时通过表格可以看出,当 $\beta$ 变化从0.05到0.5时,rank-1下降了17。但同时,在最优解的附近也接近最优解。
1 2 3 4 5 6 7 8 9 10 from torch.nn import functional as Fimport torchfun1 = F.softmax aa = torch.tensor([0.2 , 0.8 , 0.2 ]) fun1(aa, dim=0 ) tensor([0.2616 , 0.4767 , 0.2616 ]) bb = torch.tensor([0.2 , 0.8 , 0.2 ])/0.1 fun1(bb, dim=0 ) tensor([0.0025 , 0.9951 , 0.0025 ])
The weight of source and target losses $\lambda$ :
可以看出即便只有 target loss, 性能也超过 baseline, 当取其他值的时候,性能几乎不变。
Number of positive samples $k$ :
可以看出,性能对k挺敏感的。
4.4 Evaluation
通过表格中的数据集可以计算出,C+N>C,N,C和N共同使用互相还有促进作用。
作者也在 MSMT 数据集上做了训练测试。
感觉大家都开始在 MSMT17 上做实验了,不怎么管 CUHK 了。
5. code 代码和HHL的差不多
5.1 model 5.1.1 baseline 有以下几个改变:
conv1 ~ layer2 的参数不再更新
source: layer4-avgpool-Linear(4096)-bn-relu-drop(0.5)-Linear(num_class)
target: layer4-avgpool-Linear(4096)-bn-F.normalize-drop(0.5)
Question:先normalize后drop的话就不满足归一化的定义了
Question: conv1~layer2 固定参数是有什么含义吗?
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 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 class ResNet (nn.Module): __factory = { 18 : torchvision.models.resnet18, 34 : torchvision.models.resnet34, 50 : torchvision.models.resnet50, 101 : torchvision.models.resnet101, 152 : torchvision.models.resnet152, } def __init__ (self, depth, pretrained=True , cut_at_pooling=False , num_features=0 , norm=False , dropout=0 , num_classes=0 , num_triplet_features=0 ): super (ResNet, self).__init__() self.depth = depth self.pretrained = pretrained self.cut_at_pooling = cut_at_pooling if depth not in ResNet.__factory: raise KeyError("Unsupported depth:" , depth) self.base = ResNet.__factory[depth](pretrained=pretrained) fixed_names = [] for name, module in self.base._modules.items(): if name == "layer3" : break fixed_names.append(name) for param in module.parameters(): param.requires_grad = False if not self.cut_at_pooling: self.num_features = num_features self.norm = norm self.dropout = dropout self.has_embedding = num_features > 0 self.num_classes = num_classes self.num_triplet_features = num_triplet_features self.l2norm = Normalize(2 ) out_planes = self.base.fc.in_features if self.has_embedding: self.feat = nn.Linear(out_planes, self.num_features) self.feat_bn = nn.BatchNorm1d(self.num_features) init.kaiming_normal_(self.feat.weight, mode='fan_out' ) init.constant_(self.feat.bias, 0 ) init.constant_(self.feat_bn.weight, 1 ) init.constant_(self.feat_bn.bias, 0 ) else : self.num_features = out_planes if self.dropout >= 0 : self.drop = nn.Dropout(self.dropout) if self.num_classes > 0 : self.classifier = nn.Linear(self.num_features, self.num_classes) init.normal_(self.classifier.weight, std=0.001 ) init.constant_(self.classifier.bias, 0 ) if not self.pretrained: self.reset_params() def forward (self, x, output_feature=None ): for name, module in self.base._modules.items(): if name == 'avgpool' : break else : x = module(x) if self.cut_at_pooling: return x x = F.avg_pool2d(x, x.size()[2 :]) x = x.view(x.size(0 ), -1 ) if output_feature == 'pool5' : x = F.normalize(x) return x if self.has_embedding: x = self.feat(x) x = self.feat_bn(x) tgt_feat = F.normalize(x) tgt_feat = self.drop(tgt_feat) if output_feature == 'tgt_feat' : return tgt_feat if self.norm: x = F.normalize(x) elif self.has_embedding: x = F.relu(x) if self.dropout > 0 : x = self.drop(x) if self.num_classes > 0 : x = self.classifier(x) return x def reset_params (self ): for m in self.modules(): if isinstance (m, nn.Conv2d): init.kaiming_normal(m.weight, mode='fan_out' ) if m.bias is not None : init.constant(m.bias, 0 ) elif isinstance (m, nn.BatchNorm2d): init.constant(m.weight, 1 ) init.constant(m.bias, 0 ) elif isinstance (m, nn.Linear): init.normal(m.weight, std=0.001 ) if m.bias is not None : init.constant(m.bias, 0 )
L2 正则化定义了专门的层,但没有使用,可能是嫌慢吧。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import torchfrom torch.autograd import Variablefrom torch import nnclass Normalize (nn.Module): def __init__ (self, power=2 ): super (Normalize, self).__init__() self.power = power def forward (self, x ): norm = x.pow (self.power).sum (1 , keepdim=True ).pow (1. /self.power) out = x.div(norm) return out
5.1.2 InvNet 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 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 import torchimport torch.nn.functional as Ffrom torch import nn, autogradfrom torch.autograd import Variable, Functionimport numpy as npimport mathclass ExemplarMemory (Function ): def __init__ (self, em, alpha=0.01 ): super (ExemplarMemory, self).__init__() self.em = em self.alpha = alpha def forward (self, inputs, targets ): self.save_for_backward(inputs, targets) outputs = inputs.mm(self.em.t()) return outputs def backward (self, grad_outputs ): inputs, targets = self.saved_tensors grad_inputs = None if self.needs_input_grad[0 ]: grad_inputs = grad_outputs.mm(self.em) for x, y in zip (inputs, targets): self.em[y] = self.alpha * self.em[y] + (1. - self.alpha) * x self.em[y] /= self.em[y].norm() return grad_inputs, None class InvNet (nn.Module): def __init__ (self, num_features, num_classes, beta=0.05 , knn=6 , alpha=0.01 ): super (InvNet, self).__init__() self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu' ) self.num_features = num_features self.num_classes = num_classes self.alpha = alpha self.beta = beta self.knn = knn self.em = nn.Parameter(torch.zeros(num_classes, num_features)) def forward (self, inputs, targets, epoch=None ): alpha = self.alpha * epoch inputs = ExemplarMemory(self.em, alpha=alpha)(inputs, targets) inputs /= self.beta if self.knn > 0 and epoch > 4 : loss = self.smooth_loss(inputs, targets) else : loss = F.cross_entropy(inputs, targets) return loss def smooth_loss (self, inputs, targets ): targets = self.smooth_hot(inputs.detach().clone(), targets.detach().clone(), self.knn) outputs = F.log_softmax(inputs, dim=1 ) loss = - (targets * outputs) loss = loss.sum (dim=1 ) loss = loss.mean(dim=0 ) return loss def smooth_hot (self, inputs, targets, k=6 ): _, index_sorted = torch.sort(inputs, dim=1 , descending=True ) ones_mat = torch.ones(targets.size(0 ), k).to(self.device) targets = torch.unsqueeze(targets, 1 ) targets_onehot = torch.zeros(inputs.size()).to(self.device) weights = F.softmax(ones_mat, dim=1 ) targets_onehot.scatter_(1 , index_sorted[:, 0 :k], ones_mat * weights) targets_onehot.scatter_(1 , targets, float (1 )) return targets_onehot
补充 gather 和 scatter 的用法
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 55 56 57 58 59 60 61 62 63 64 65 import torchinput = torch.Tensor([[1 ,2 ],[3 ,4 ]])tensor([[1. , 2. ], [3. , 4. ]]) torch.gather(input , 0 , torch.LongTensor([[0 ,0 ]]) ) tensor([[1. , 2. ]]) torch.gather(input , 0 , torch.LongTensor([[0 ,1 ]]) ) tensor([[1. , 4. ]]) torch.gather(input , 0 , torch.LongTensor([[0 ,1 ], [0 ,1 ], [0 ,1 ]]) ) tensor([[1. , 4. ], [1. , 4. ], [1. , 4. ]]) a = torch.arange(0 ,27 ).view(3 ,3 ,3 ) tensor([[[ 0 , 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 ]]]) c = [[[0 ,1 ,2 ],[0 ,1 ,2 ],[0 ,1 ,2 ]]] index = torch.LongTensor(c) a.gather(0 , index) tensor([[[ 0 , 10 , 20 ], [ 3 , 13 , 23 ], [ 6 , 16 , 26 ]]]) c = [[[0 ,1 ,2 ],[0 ,1 ,2 ],[0 ,1 ,2 ]], [[0 ,1 ,0 ],[0 ,1 ,0 ],[0 ,1 ,0 ]]] index = torch.LongTensor(c) a.gather(0 , index) tensor([[[ 0 , 10 , 20 ], [ 3 , 13 , 23 ], [ 6 , 16 , 26 ]], [[ 0 , 10 , 2 ], [ 3 , 13 , 5 ], [ 6 , 16 , 8 ]]])
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 x = torch.rand(2 , 5 ) 0.4319 0.6500 0.4080 0.8760 0.2355 0.2609 0.4711 0.8486 0.8573 0.1029 [torch.FloatTensor of size 2x5] torch.zeros(3 , 5 ).scatter_(0 , torch.LongTensor([[0 , 1 , 2 , 0 , 0 ], [2 , 0 , 0 , 1 , 2 ]]), x) 0.4319 0.4711 0.8486 0.8760 0.2355 0.0000 0.6500 0.0000 0.8573 0.0000 0.2609 0.0000 0.4080 0.0000 0.1029 [torch.FloatTensor of size 3x5]
5.2 data 对于 target domain 中的图片,每次都是从原始图片和生成图片组成的集合中随机取一张图片,当选取的 camid 是原始图片的 camid 时,取原始图片,当取到的 camid 不是原始图片的 camid 时,取生成图片,实际只使用了 C-1 张生成图片和 1 张原始图片。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 def _get_single_item (self, index ): fname, pid, camid = self.dataset[index] sel_cam = torch.randperm(self.num_cam)[0 ] if sel_cam == camid: fpath = osp.join(self.root, fname) img = Image.open (fpath).convert('RGB' ) else : if 'msmt' in self.root: fname = fname[:-4 ] + '_fake_' + str (sel_cam.numpy() + 1 ) + '.jpg' else : fname = fname[:-4 ] + '_fake_' + str (camid + 1 ) + 'to' + str (sel_cam.numpy() + 1 ) + '.jpg' fpath = osp.join(self.camstyle_root, fname) img = Image.open (fpath).convert('RGB' ) if self.transform is not None : img = self.transform(img) return img, fname, pid, index
5.3 optimizer 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 base_param_ids = set (map (id , model.module.base.parameters())) base_params_need_for_grad = filter (lambda p: p.requires_grad, model.module.base.parameters()) new_params = [p for p in model.parameters() if id (p) not in base_param_ids] param_groups = [ {'params' : base_params_need_for_grad, 'lr_mult' : 0.1 }, {'params' : new_params, 'lr_mult' : 1.0 }] optimizer = torch.optim.SGD(param_groups, lr=args.lr, momentum=args.momentum, weight_decay=args.weight_decay, nesterov=True )
5.4 train 1 2 3 4 5 6 7 8 9 10 11 12 13 inputs, pids = self._parse_data(inputs) outputs = self.model(inputs) source_pid_loss = self.pid_criterion(outputs, pids) outputs = self.model(inputs_target, 'tgt_feat' ) loss_un = self.model_inv(outputs, index_target, epoch=epoch) loss = (1 - self.lmd) * source_pid_loss + self.lmd * loss_un
知识点补充:因为 ResNet-50 的 conv1~layer2 的参数已经设置不参与更新,自然这些层的 bn 层也应该是 eval 状态。
Question: 上面的知识点补充是否正确?
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 def set_model_train (self ): self.model.train() fixed_bns = [] for idx, (name, module) in enumerate (self.model.module.named_modules()): if name.find("layer3" ) != -1 : break if name.find("bn" ) != -1 : fixed_bns.append(name) module.eval () len (fixed_bns)22 ['bn1' , 'layer1.0.bn1' , 'layer1.0.bn2' , 'layer1.0.bn3' , 'layer1.1.bn1' , 'layer1.1.bn2' , 'layer1.1.bn3' , 'layer1.2.bn1' , 'layer1.2.bn2' , 'layer1.2.bn3' , 'layer2.0.bn1' , 'layer2.0.bn2' , 'layer2.0.bn3' , 'layer2.1.bn1' , 'layer2.1.bn2' , 'layer2.1.bn3' , 'layer2.2.bn1' , 'layer2.2.bn2' , 'layer2.2.bn3' , 'layer2.3.bn1' , 'layer2.3.bn2' , 'layer2.3.bn3' ]
到此应该代码全部看完了。