Skip to content

Commit bbd363c

Browse files
dakshjotwanifmassa
authored andcommitted
Similarity learning reference code (#1101)
* Add loss, sampler, and train script * Fix train script * Add argparse * Fix lint * Change f strings to .format() * Remove unused imports * Change TripletMarginLoss to extend nn.Module * Load eye uint8 tensors directly on device * Refactor model.py to backbone=None * Add docstring for PKSampler * Refactor evaluate() to take loader as arg instead * Change eval method to cat embeddings all at once * Add dataset comments * Add README.md * Add tests for sampler * Refactor threshold finder to helper method * Refactor targets comment * Fix lint * Rename embedding to similarity (More consistent with existing literature)
1 parent 8837e0e commit bbd363c

6 files changed

Lines changed: 462 additions & 0 deletions

File tree

‎references/similarity/README.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Similarity Learning Using Triplet Loss #
2+
3+
In this reference, we use triplet loss to learn embeddings which can be used to differentiate images. This learning technique was popularized by [FaceNet: A Unified Embedding for Face Recognition and Clustering](https://arxiv.org/abs/1503.03832) and has been quite effective in learning embeddings to differentiate between faces.
4+
5+
This reference can be directly applied to the following use cases:
6+
7+
* You have an unknown number of classes and would like to train a model to learn how to differentiate between them.
8+
* You want to train a model to learn a distance-based metric between samples. For example, learning a distance-based similarity measure between faces.
9+
10+
### Training ###
11+
By default, the training script trains ResNet50 on the FashionMNIST Dataset to learn image embeddings which can be used to differentiate between images by measuring the euclidean distance between embeddings. This can be changed as per your requirements.
12+
13+
Image embeddings of the same class should be 'close' to each other, while image embeddings between different classes should be 'far' away.
14+
15+
To run the training script:
16+
17+
```bash
18+
python train.py -h # Lists all optional arguments
19+
python train.py # Runs training script with default args
20+
```
21+
22+
Running the training script as is should yield 97% accuracy on the FMNIST test set within 10 epochs.
23+
24+
### Loss ###
25+
`TripletMarginLoss` is a loss function which takes in a triplet of samples. A valid triplet has an:
26+
27+
1. Anchor: a sample from the dataset
28+
2. Positive: another sample with the same label/group as the anchor (Generally, positive != anchor)
29+
3. Negative: a sample with a different label/group from the anchor
30+
31+
`TripletMarginLoss` (refer to `loss.py`) does the following:
32+
33+
```python
34+
loss = max(dist(anchor, positive) - dist(anchor, negative) + margin, 0)
35+
```
36+
Where `dist` is a distance function. Minimizing this function effectively leads to minimizing `dist(anchor, positive)` and maximizing `dist(anchor, negative)`.
37+
38+
The FaceNet paper describe this loss in more detail.
39+
40+
### Sampler ###
41+
42+
In order to generate valid triplets from a batch of samples, we need to make sure that each batch has multiple samples with the same label. We do this using `PKSampler` (refer to `sampler.py`), which ensures that each batch of size `p * k` will have samples from exactly `p` classes and `k` samples per class.
43+
44+
### Triplet Mining ###
45+
46+
`TripletMarginLoss` currently supports the following mining techniques:
47+
48+
* `batch_all`: Generates all possible triplets from a batch and excludes the triplets which are 'easy' (which have `loss = 0`) before passing it through the loss function.
49+
* `batch_hard`: For every anchor, `batch_hard` creates a triplet with the 'hardest' positive (farthest positive) and negative (closest negative).
50+
51+
These mining strategies usually speed up training.
52+
53+
This [webpage](https://omoindrot.github.io/triplet-loss) describes the sampling and mining strategies in more detail.

‎references/similarity/loss.py‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
'''
2+
Pytorch adaptation of https://omoindrot.github.io/triplet-loss
3+
https://github.com/omoindrot/tensorflow-triplet-loss
4+
'''
5+
import torch
6+
import torch.nn as nn
7+
8+
9+
class TripletMarginLoss(nn.Module):
10+
def __init__(self, margin=1.0, p=2., mining='batch_all'):
11+
super(TripletMarginLoss, self).__init__()
12+
self.margin = margin
13+
self.p = p
14+
self.mining = mining
15+
16+
if mining == 'batch_all':
17+
self.loss_fn = batch_all_triplet_loss
18+
if mining == 'batch_hard':
19+
self.loss_fn = batch_hard_triplet_loss
20+
21+
def forward(self, embeddings, labels):
22+
return self.loss_fn(labels, embeddings, self.margin, self.p)
23+
24+
25+
def batch_hard_triplet_loss(labels, embeddings, margin, p):
26+
pairwise_dist = torch.cdist(embeddings, embeddings, p=p)
27+
28+
mask_anchor_positive = _get_anchor_positive_triplet_mask(labels).float()
29+
anchor_positive_dist = mask_anchor_positive * pairwise_dist
30+
31+
# hardest positive for every anchor
32+
hardest_positive_dist, _ = anchor_positive_dist.max(1, keepdim=True)
33+
34+
mask_anchor_negative = _get_anchor_negative_triplet_mask(labels).float()
35+
36+
# Add max value in each row to invalid negatives
37+
max_anchor_negative_dist, _ = pairwise_dist.max(1, keepdim=True)
38+
anchor_negative_dist = pairwise_dist + max_anchor_negative_dist * (1.0 - mask_anchor_negative)
39+
40+
# hardest negative for every anchor
41+
hardest_negative_dist, _ = anchor_negative_dist.min(1, keepdim=True)
42+
43+
triplet_loss = hardest_positive_dist - hardest_negative_dist + margin
44+
triplet_loss[triplet_loss < 0] = 0
45+
46+
triplet_loss = triplet_loss.mean()
47+
48+
return triplet_loss, -1
49+
50+
51+
def batch_all_triplet_loss(labels, embeddings, margin, p):
52+
pairwise_dist = torch.cdist(embeddings, embeddings, p=p)
53+
54+
anchor_positive_dist = pairwise_dist.unsqueeze(2)
55+
anchor_negative_dist = pairwise_dist.unsqueeze(1)
56+
57+
triplet_loss = anchor_positive_dist - anchor_negative_dist + margin
58+
59+
mask = _get_triplet_mask(labels)
60+
triplet_loss = mask.float() * triplet_loss
61+
62+
# Remove negative losses (easy triplets)
63+
triplet_loss[triplet_loss < 0] = 0
64+
65+
# Count number of positive triplets (where triplet_loss > 0)
66+
valid_triplets = triplet_loss[triplet_loss > 1e-16]
67+
num_positive_triplets = valid_triplets.size(0)
68+
num_valid_triplets = mask.sum()
69+
70+
fraction_positive_triplets = num_positive_triplets / (num_valid_triplets.float() + 1e-16)
71+
72+
# Get final mean triplet loss over the positive valid triplets
73+
triplet_loss = triplet_loss.sum() / (num_positive_triplets + 1e-16)
74+
75+
return triplet_loss, fraction_positive_triplets
76+
77+
78+
def _get_triplet_mask(labels):
79+
# Check that i, j and k are distinct
80+
indices_equal = torch.eye(labels.size(0), dtype=torch.uint8, device=labels.device)
81+
indices_not_equal = ~indices_equal
82+
i_not_equal_j = indices_not_equal.unsqueeze(2)
83+
i_not_equal_k = indices_not_equal.unsqueeze(1)
84+
j_not_equal_k = indices_not_equal.unsqueeze(0)
85+
86+
distinct_indices = (i_not_equal_j & i_not_equal_k) & j_not_equal_k
87+
88+
label_equal = labels.unsqueeze(0) == labels.unsqueeze(1)
89+
i_equal_j = label_equal.unsqueeze(2)
90+
i_equal_k = label_equal.unsqueeze(1)
91+
92+
valid_labels = ~i_equal_k & i_equal_j
93+
94+
return valid_labels & distinct_indices
95+
96+
97+
def _get_anchor_positive_triplet_mask(labels):
98+
# Check that i and j are distinct
99+
indices_equal = torch.eye(labels.size(0), dtype=torch.uint8, device=labels.device)
100+
indices_not_equal = ~indices_equal
101+
102+
# Check if labels[i] == labels[j]
103+
labels_equal = labels.unsqueeze(0) == labels.unsqueeze(1)
104+
105+
return labels_equal & indices_not_equal
106+
107+
108+
def _get_anchor_negative_triplet_mask(labels):
109+
return labels.unsqueeze(0) != labels.unsqueeze(1)

‎references/similarity/model.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import torch
2+
import torch.nn as nn
3+
import torchvision.models as models
4+
5+
6+
class EmbeddingNet(nn.Module):
7+
def __init__(self, backbone=None):
8+
super(EmbeddingNet, self).__init__()
9+
if backbone is None:
10+
backbone = models.resnet50(num_classes=128)
11+
12+
self.backbone = backbone
13+
14+
def forward(self, x):
15+
x = self.backbone(x)
16+
x = nn.functional.normalize(x, dim=1)
17+
return x

‎references/similarity/sampler.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import torch
2+
from torch.utils.data.sampler import Sampler
3+
from collections import defaultdict
4+
import random
5+
6+
7+
def create_groups(groups, k):
8+
"""Bins sample indices with respect to groups, remove bins with less than k samples
9+
10+
Args:
11+
groups (list[int]): where ith index stores ith sample's group id
12+
13+
Returns:
14+
defaultdict[list]: Bins of sample indices, binned by group_idx
15+
"""
16+
group_samples = defaultdict(list)
17+
for sample_idx, group_idx in enumerate(groups):
18+
group_samples[group_idx].append(sample_idx)
19+
20+
keys_to_remove = []
21+
for key in group_samples:
22+
if len(group_samples[key]) < k:
23+
keys_to_remove.append(key)
24+
continue
25+
26+
for key in keys_to_remove:
27+
group_samples.pop(key)
28+
29+
return group_samples
30+
31+
32+
class PKSampler(Sampler):
33+
"""
34+
Randomly samples from a dataset while ensuring that each batch (of size p * k)
35+
includes samples from exactly p labels, with k samples for each label.
36+
37+
Args:
38+
groups (list[int]): List where the ith entry is the group_id/label of the ith sample in the dataset.
39+
p (int): Number of labels/groups to be sampled from in a batch
40+
k (int): Number of samples for each label/group in a batch
41+
"""
42+
43+
def __init__(self, groups, p, k):
44+
self.p = p
45+
self.k = k
46+
self.groups = create_groups(groups, self.k)
47+
48+
# Ensures there are enough classes to sample from
49+
assert len(self.groups) >= p
50+
51+
def __iter__(self):
52+
# Shuffle samples within groups
53+
for key in self.groups:
54+
random.shuffle(self.groups[key])
55+
56+
# Keep track of the number of samples left for each group
57+
group_samples_remaining = {}
58+
for key in self.groups:
59+
group_samples_remaining[key] = len(self.groups[key])
60+
61+
while len(group_samples_remaining) > self.p:
62+
# Select p groups at random from valid/remaining groups
63+
group_ids = list(group_samples_remaining.keys())
64+
selected_group_idxs = torch.multinomial(torch.ones(len(group_ids)), self.p).tolist()
65+
for i in selected_group_idxs:
66+
group_id = group_ids[i]
67+
group = self.groups[group_id]
68+
for _ in range(self.k):
69+
# No need to pick samples at random since group samples are shuffled
70+
sample_idx = len(group) - group_samples_remaining[group_id]
71+
yield group[sample_idx]
72+
group_samples_remaining[group_id] -= 1
73+
74+
# Don't sample from group if it has less than k samples remaining
75+
if group_samples_remaining[group_id] < self.k:
76+
group_samples_remaining.pop(group_id)

‎references/similarity/test.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import unittest
2+
from collections import defaultdict
3+
4+
from torch.utils.data import DataLoader
5+
from torchvision.datasets import FakeData
6+
import torchvision.transforms as transforms
7+
8+
from sampler import PKSampler
9+
10+
11+
class Tester(unittest.TestCase):
12+
13+
def test_pksampler(self):
14+
p, k = 16, 4
15+
16+
# Ensure sampler does not allow p to be greater than num_classes
17+
dataset = FakeData(size=100, num_classes=10, image_size=(3, 1, 1))
18+
targets = [target.item() for _, target in dataset]
19+
self.assertRaises(AssertionError, PKSampler, targets, p, k)
20+
21+
# Ensure p, k constraints on batch
22+
dataset = FakeData(size=1000, num_classes=100, image_size=(3, 1, 1),
23+
transform=transforms.ToTensor())
24+
targets = [target.item() for _, target in dataset]
25+
sampler = PKSampler(targets, p, k)
26+
loader = DataLoader(dataset, batch_size=p * k, sampler=sampler)
27+
28+
for _, labels in loader:
29+
bins = defaultdict(int)
30+
for l in labels.tolist():
31+
bins[l] += 1
32+
33+
# Ensure that each batch has samples from exactly p classes
34+
self.assertEqual(len(bins), p)
35+
36+
# Ensure that there are k samples from each class
37+
for l in bins:
38+
self.assertEqual(bins[l], k)
39+
40+
41+
if __name__ == '__main__':
42+
unittest.main()

0 commit comments

Comments
 (0)