๐ŸŽ“ A1Learner
๐Ÿ”

PyTorch Focal Loss

Focal loss is a loss that shrinks the score of examples the model already gets right. Hard examples keep their full score, so they count more in training.

The shrinking factor

An easy example has pt near 1. A hard example has pt far below 1.

import torch

p_easy = torch.tensor(0.95)
p_hard = torch.tensor(0.30)
print(f"easy factor {(1 - p_easy) ** 2:.4f}")
print(f"hard factor {(1 - p_hard) ** 2:.4f}")

Two examples under both losses

The first logit is confident and correct. The second logit is wrong. BCE is binary cross-entropy, the per-example loss.

import torch
import torch.nn.functional as F

logits = torch.tensor([2.9444, -0.8473])
targets = torch.tensor([1.0, 1.0])
bce = F.binary_cross_entropy_with_logits(logits, targets, reduction="none")
p = torch.sigmoid(logits)
focal = (1 - p) ** 2 * bce
print(f"bce: {bce[0].item():.4f} {bce[1].item():.4f}")
print(f"focal: {focal[0].item():.4f} {focal[1].item():.4f}")

Gamma sets the strength

The same easy example shrinks more as gamma grows. At gamma 0 nothing shrinks.

import torch

pt = torch.tensor(0.9)
for gamma in [0, 1, 2]:
    print(f"gamma {gamma}: factor {(1 - pt) ** gamma:.2f}")

Example

import torch

torch.manual_seed(0)

# Class 0 is common and tight. Class 1 is rare and spread out.
n0, n1 = 380, 20
X0 = torch.randn(n0, 2) * 0.7 + torch.tensor([-2.0, -2.0])
X1 = torch.randn(n1, 2) * 1.6 + torch.tensor([0.5, 0.5])
X = torch.cat([X0, X1])
y = torch.cat([torch.zeros(n0), torch.ones(n1)]).reshape(-1, 1)


def focal_loss(logits, targets, gamma=2.0):
    bce = torch.nn.functional.binary_cross_entropy_with_logits(
        logits, targets, reduction="none"
    )
    p = torch.sigmoid(logits)
    pt = torch.where(targets == 1, p, 1 - p)
    return (((1 - pt) ** gamma) * bce).mean()


def train(use_focal):
    torch.manual_seed(1)
    model = torch.nn.Linear(2, 1)
    optimizer = torch.optim.Adam(model.parameters(), lr=0.05)
    for step in range(100):
        logits = model(X)
        if use_focal:
            loss = focal_loss(logits, y)
        else:
            loss = torch.nn.functional.binary_cross_entropy_with_logits(logits, y)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
    with torch.no_grad():
        pred = (torch.sigmoid(model(X)) >= 0.5).float()
        hard = y == 1
        recall = (pred[hard] == 1).float().mean().item()
    return recall


print(f"Examples: {len(X)} (class 0: {n0}, class 1: {n1})")
print(f"Cross-entropy hard-class recall: {train(False):.2f}")
print(f"Focal hard-class recall: {train(True):.2f}")

Key points

  • Imbalanced data: one class appears far more often than another
  • Easy example: an example the model already predicts with high confidence
  • pt: the model's probability for the correct class on one example
  • Use the factor (1 - pt) ** gamma to shrink the loss of easy examples
  • Gamma: the number that sets how hard easy examples are shrunk, often 2
  • Recall: the share of a class's examples that the model finds
  • Focal loss leaves hard examples at full strength and turns easy examples down

In this example, the data holds 400 points. Class 0 has 380 points in a tight cluster. Class 1 has 20 points spread wide, so its points are the hard ones. Two models start from the same seed and train for 100 steps. The cross-entropy model finds 45 percent of the hard class, a recall of 0.45. The focal model finds 55 percent, a recall of 0.55. The easy class-0 points stop dominating the focal loss, so the rare class gets more of the training signal.

Note

With gamma set to 0, focal loss is exactly ordinary cross-entropy

Try it Yourself

Edit the code, then press Run.

Running is simulated ยท Python Playground

Exercise

An example has pt = 0.99 under gamma 2. What happens to its loss under focal loss?