03 · Learning by Comparison
Triplet Loss
Learning embeddings where distance means similarity.
Distance should mean similarity
"Our method is based on learning a Euclidean embedding per image using a deep convolutional network. The network is trained such that the squared L2 distances in the embedding space directly correspond to face similarity: faces of the same person have small distances and faces of distinct people have large distances."
Schroff et al. (2015) introduced a method called FaceNet for learning face embeddings using a deep convolutional network. The core idea is simple: map each image to a point in Euclidean space such that distance reflects similarity. Images of the same person should be close together; images of different people should be far apart.
While the original application was face recognition, the triplet loss framework is far more general. It applies to any setting where we want to learn an embedding space that preserves some notion of similarity, whether for images, text, audio, or other modalities.
The embedding
We have a deep network
This normalisation ensures that all embeddings live on the surface of a
When this distance is small, the network considers the two inputs similar. When it is large, the network considers them different. The question then becomes: how do we train
Anchor, positive, and negative
The training signal comes from triplets of examples. Each triplet consists of:
- An anchor
: the reference example. - A positive
: an example that should be similar to the anchor (e.g. a different photo of the same person). - A negative
: an example that should be dissimilar (e.g. a photo of a different person).
The goal is to ensure that the anchor is closer to the positive than to the negative in the embedding space, by at least a margin
The margin
The triplet loss
Turning the constraint above into a loss function that we can minimise with gradient descent:
where
In words, for each triplet:
- Pull the anchor and positive closer together (decrease
). - Push the anchor and negative further apart (increase
). - Stop once the gap exceeds the margin
(the hinge clips the loss to zero).
The hinge function
The hinge function (also called the positive part or ReLU) is defined as
As we can see, it basically acts like a gate: when its argument is negative (the constraint is already satisfied), the loss contributes nothing and no gradient flows. When its argument is positive (the constraint is violated), the loss equals the amount of violation.
Why not just use the raw difference?
Without the hinge, the loss would keep decreasing even after the constraint is satisfied, pushing the positive and negative distances apart indefinitely. The way I see it is that the hinge is telling the network something like "this triplet is good enough, move on to harder ones."
Overall, this is important for training efficiency: once a triplet is correctly separated by margin
Triplet Loss in PyTorch
Here is a minimal PyTorch implementation. We train a small embedding network on synthetic 2D data with three classes, using the triplet loss to learn an embedding where same-class points cluster together.
# triplet_loss_example.py
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
# ── Synthetic data: 3 clusters in 2D ─────────────────────────────
def sample_triplet(n):
"""Sample n triplets (anchor, positive, negative) from 5 overlapping clusters."""
centres = torch.tensor([
[-1.0, 0.0], [1.0, 0.0], [0.0, 1.0], [0.7, -0.8], [-0.7, -0.8]
])
n_cls = centres.shape[0]
# Pick anchor class, then sample anchor + positive from same class
cls = torch.randint(0, n_cls, (n,))
anchor = centres[cls] + 0.8 * torch.randn(n, 2)
positive = centres[cls] + 0.8 * torch.randn(n, 2)
# Pick a different class for the negative
neg_cls = (cls + torch.randint(1, n_cls, (n,))) % n_cls
negative = centres[neg_cls] + 0.8 * torch.randn(n, 2)
return anchor, positive, negative
# ── Embedding network ─────────────────────────────────────────────
class EmbeddingNet(nn.Module):
def __init__(self, embed_dim=16):
super().__init__()
self.net = nn.Sequential(
nn.Linear(2, 64),
nn.ReLU(),
nn.Linear(64, embed_dim),
)
def forward(self, x):
"""Map input to unit-normalised embedding."""
e = self.net(x)
return F.normalize(e, p=2, dim=-1) # ||f(x)||_2 = 1
# ── Triplet loss ──────────────────────────────────────────────────
def triplet_loss(anchor, positive, negative, alpha=0.2):
"""
L = sum[ ||f(a) - f(p)||^2 - ||f(a) - f(n)||^2 + alpha ]+
"""
d_pos = (anchor - positive).pow(2).sum(dim=-1) # ||f(a) - f(p)||^2
d_neg = (anchor - negative).pow(2).sum(dim=-1) # ||f(a) - f(n)||^2
losses = torch.clamp(d_pos - d_neg + alpha, min=0) # hinge [·]+
return losses.mean()
# ── Training loop ─────────────────────────────────────────────────
model = EmbeddingNet()
optimizer = optim.Adam(model.parameters(), lr=1e-3)
alpha = 0.2 # margin
for step in range(3000):
a, p, n = sample_triplet(256)
# Embed all three
e_a = model(a)
e_p = model(p)
e_n = model(n)
loss = triplet_loss(e_a, e_p, e_n, alpha)
optimizer.zero_grad()
loss.backward()
optimizer.step()
if (step + 1) % 500 == 0:
# Fraction of triplets where constraint is satisfied
with torch.no_grad():
d_p = (e_a - e_p).pow(2).sum(-1)
d_n = (e_a - e_n).pow(2).sum(-1)
acc = (d_p + alpha < d_n).float().mean()
print(f"Step {step+1:4d} | loss = {loss.item():.4f} | triplet acc = {acc.item():.2%}")
A few things to notice:
F.normalize(e, p=2, dim=-1)projects embeddings onto the unit sphere, enforcing .torch.clamp(..., min=0)implements the hinge . Triplets that already satisfy the margin contribute zero loss and zero gradient.- The margin
alpha = 0.2is a hyperparameter. Larger values demand more separation between positives and negatives; smaller values are easier to satisfy but produce less discriminative embeddings. - We track triplet accuracy (fraction of triplets where
) as a training diagnostic.