Approach: compute per-class weight using the "effective number" formula w_c = (1 - beta) / (1 - beta^n_c), normalize or scale optional; compute standard focal loss but operate on logits via log-softmax for numerical stability. Multiply per-example loss by class weight. Support multi-class (C-way) classification with integer class labels.python
import torch
import torch.nn.functional as F
from torch import nn
class ClassBalancedFocalLoss(nn.Module):
def __init__(self, class_counts, beta=0.9999, gamma=2.0, reduction='mean', eps=1e-8):
"""
class_counts: iterable of length C with class sample counts (ints or floats)
beta: hyperparameter for effective number (near 1)
gamma: focal loss gamma
reduction: 'mean', 'sum', or 'none'
eps: small constant for numerical stability
"""
super().__init__()
counts = torch.tensor(class_counts, dtype=torch.float32)
if (counts <= 0).any():
raise ValueError("class_counts must be positive")
effective_num = 1.0 - torch.pow(beta, counts)
weights = (1.0 - beta) / (effective_num + eps)
# Normalize weights to sum to C (optional but often helpful)
weights = weights / weights.sum() * counts.numel()
self.register_buffer('class_weights', weights) # shape (C,)
self.gamma = float(gamma)
self.reduction = reduction
self.eps = eps
def forward(self, logits, targets):
"""
logits: (N, C) raw model outputs
targets: (N,) long with class indices in [0, C-1]
"""
if logits.dim() != 2:
raise ValueError("logits must be shape (N, C)")
N, C = logits.shape
if targets.dim() != 1 or targets.shape[0] != N:
raise ValueError("targets must be shape (N,)")
# log-probs for numerical stability
log_probs = F.log_softmax(logits, dim=1) # (N, C)
probs = torch.exp(log_probs) # (N, C)
# Gather per-sample log_prob and prob of the true class
t = targets.view(-1, 1)
pt = probs.gather(1, t).clamp(min=self.eps) # (N,1)
log_pt = log_probs.gather(1, t) # (N,1)
# focal modulation: (1 - p_t)^gamma
focal_factor = (1.0 - pt) ** self.gamma
# per-class weight for each sample
cw = self.class_weights.view(1, -1).gather(1, t) # (N,1)
# loss per sample
loss = -cw * focal_factor * log_pt # (N,1)
loss = loss.view(-1)
if self.reduction == 'mean':
return loss.mean()
elif self.reduction == 'sum':
return loss.sum()
else:
return loss # shape (N,)
Key points:- Uses log_softmax + gather for numerical stability; avoids computing (1 - softmax) from logits directly.- Class weights computed with effective number; optional normalization prevents extremely large/small magnitudes.- Complexity: O(N*C) time and O(C) extra memory for weights. If C is large, compute log_softmax still costs O(N*C). For sparse targets or very large C, consider sampled softmax or hierarchical softmax.- Edge cases: zero counts disallowed; beta close to 1 can cause numerical cancellation so eps added; supports reduction modes.