Catalyst developer on retainer: softmax inside model.forward() with CrossEntropyLoss double-softmax — loss stuck at log(100) = 4.605 — val_accuracy 0.01 (random chance) — 3 weeks — Catalyst developer on monthly retainer

catalyst 23.11.x • Python 3.11 • PyTorch 2.1.x • ResNet-50 image classification on CIFAR-100 • 100 classes • runner = dl.SupervisedRunner() • torch.nn.functional.softmax(x, dim=1) inside model.forward() before return • CrossEntropyLoss applies log_softmax internally • log(softmax(softmax(x))) computed • training loss converges to log(100) = 4.605 • val_accuracy stuck at 0.01 (1/100 = random chance) • developer checks learning_rate, batch_size, weight_decay, ResNet-50 initialization for 3 weeks • fix: remove softmax from model.forward() • val_accuracy 0.01→0.68 • 1.5h retainer engagement

Eastfield Catalyst DL Ltd. trained a ResNet-50 image classifier on CIFAR-100 using catalyst 23.11.x. The team used dl.SupervisedRunner with torch.nn.CrossEntropyLoss as the criterion. Training loss decreased from 4.72 at initialization to 4.605 by epoch 2, then stalled completely. Val_accuracy held at exactly 0.01 for all 30 epochs — identical to random chance for 100 classes. The developer spent three weeks adjusting the learning rate across five values, the batch size, weight decay, and trying different ResNet-50 pretrained initialization options before engaging a Catalyst retainer developer. Nobody noticed that 0.01 = 1/100, the exact value expected from a model that always predicts a uniform distribution over 100 classes.

The catalyst training configuration and model architecture at the time of the engagement:

# Eastfield Catalyst DL Ltd. — ResNet-50 CIFAR-100 image classification
# catalyst 23.11.x, Python 3.11, PyTorch 2.1.x

import torch
import torch.nn as nn
import torch.nn.functional as F
from torchvision import models, datasets, transforms
from torch.utils.data import DataLoader
import catalyst
from catalyst import dl

# Model: ResNet-50 adapted for CIFAR-100 (100 classes)
class ResNet50CIFAR100(nn.Module):
    def __init__(self):
        super().__init__()
        base = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1)
        # Replace final classification head for 100 classes
        base.fc = nn.Linear(base.fc.in_features, 100)
        self.model = base

    def forward(self, x):
        x = self.model(x)
        # ← Problem: softmax applied before returning output
        # Developer added this line following a PyTorch Lightning tutorial
        # that used softmax in forward() with BCELoss for binary classification.
        # For multi-class with CrossEntropyLoss, this is wrong.
        x = F.softmax(x, dim=1)   # ← WRONG: CrossEntropyLoss does this internally
        return x
        # Output shape: [batch_size, 100]
        # Output values: each in (0,1), sum to 1 per row
        # Looks like probabilities — but CrossEntropyLoss expects raw logits

# Data loaders for CIFAR-100
transform_train = transforms.Compose([
    transforms.RandomCrop(32, padding=4),
    transforms.RandomHorizontalFlip(),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.5071, 0.4867, 0.4408],
                         std=[0.2675, 0.2565, 0.2761]),
])
transform_val = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.5071, 0.4867, 0.4408],
                         std=[0.2675, 0.2565, 0.2761]),
])

train_dataset = datasets.CIFAR100(root='./data', train=True,
                                   download=True, transform=transform_train)
val_dataset   = datasets.CIFAR100(root='./data', train=False,
                                   download=True, transform=transform_val)
loaders = {
    'train': DataLoader(train_dataset, batch_size=128, shuffle=True,  num_workers=4),
    'valid': DataLoader(val_dataset,   batch_size=256, shuffle=False, num_workers=4),
}

model     = ResNet50CIFAR100()
criterion = nn.CrossEntropyLoss()
# ← CrossEntropyLoss.forward() calls F.log_softmax(input, dim=1) internally.
# It expects raw LOGITS (unbounded real values).
# When given softmax probabilities (from model.forward()), it applies log_softmax again.
# log_softmax(softmax(logits)) = log(softmax(softmax(logits)))
# = double-softmax: numerically collapses to near-uniform distribution

optimizer = torch.optim.SGD(model.parameters(), lr=0.1,
                             momentum=0.9, weight_decay=1e-4)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)

runner = dl.SupervisedRunner(
    input_key='features',
    output_key='logits',
    target_key='targets',
    loss_key='loss',
)

runner.train(
    model=model,
    criterion=criterion,
    optimizer=optimizer,
    loaders=loaders,
    num_epochs=30,
    scheduler=scheduler,
    callbacks=[
        dl.AccuracyCallback(
            input_key='logits',
            target_key='targets',
            num_classes=100,
        ),
        dl.CheckpointCallback(
            logdir='./cifar100_logs',
            loader_key='valid',
            metric_key='accuracy01',
            minimize=False,
            save_n_best=1,
        ),
    ],
    logdir='./cifar100_logs',
    valid_loader='valid',
    valid_metric='accuracy01',
    minimize_valid_metric=False,
    verbose=True,
)

# Training output (all 30 epochs):
# epoch 01: train_loss=4.7201, valid_accuracy01=0.0101
# epoch 02: train_loss=4.6052, valid_accuracy01=0.0101
# epoch 10: train_loss=4.6049, valid_accuracy01=0.0100
# epoch 20: train_loss=4.6047, valid_accuracy01=0.0101
# epoch 30: train_loss=4.6049, valid_accuracy01=0.0100
# ← 4.605 = log(100) = cross-entropy of uniform distribution over 100 classes
# ← 0.01  = 1/100    = accuracy of always-uniform prediction on 100 classes
# The model never learned ANYTHING.

Why softmax inside model.forward() with CrossEntropyLoss creates the double-softmax trap

PyTorch’s torch.nn.CrossEntropyLoss is a compound operation that combines log_softmax and NLLLoss (negative log-likelihood loss) in a single step. For a batch of raw logits z and true class indices y, it computes: loss = −log(exp(z_y) / sum_j(exp(z_j))) for each example. This is equivalent to −z_y + log(sum_j(exp(z_j))) in log space — numerically stable and well-behaved for raw logit values in roughly [−10, +10]. The key design choice is that CrossEntropyLoss expects the input to be raw logits, and it handles the probability transformation internally.

When model.forward() applies F.softmax(x, dim=1) before returning, the output tensor contains probabilities: each value is in (0, 1) and the values sum to 1 across the 100 classes. CrossEntropyLoss then applies its internal log_softmax to these probability values. The result is log(softmax(softmax(logits))) — the double-softmax. The inner softmax compresses the logits into [0, 1]. The outer softmax (inside CrossEntropyLoss) compresses the already-compressed probabilities further. For 100 nearly equal probabilities (each ≈ 0.01), exp(0.01) ≈ 1.01 for each class. The sum of 100 such values is ≈ 101. The log_softmax output for the true class is log(1.01/101) ≈ log(0.01) ≈ −4.605 — the cross-entropy of a uniform distribution. This is exactly log(100) = 4.605, the theoretical floor for a model that always predicts uniform probabilities over 100 classes.

The gradient of this loss with respect to the original logits is extremely small near the uniform distribution. The model is in a gradient desert: every parameter update produces a negligibly small change in the double-softmax output, because softmax has already squashed the logit differences to near-zero probability differences, and then squashed them again. The model cannot find a gradient direction to escape. It oscillates around loss = 4.605 forever. The val_accuracy of 0.01 is not a numerical coincidence — it is the exact expected accuracy of always predicting a uniform distribution over 100 classes: 1/100 = 0.01.

# Diagnostic: verify double-softmax numerically on a single batch
# catalyst 23.11.x, Python 3.11, PyTorch 2.1.x

import torch
import torch.nn as nn
import torch.nn.functional as F
import math

# Simulate what happens with the double-softmax model
batch_size, num_classes = 128, 100

# Raw logits from ResNet-50 at random initialization
# Values roughly in [-2, +2] after Xavier initialization
torch.manual_seed(42)
raw_logits = torch.randn(batch_size, num_classes) * 0.5

# Step 1: model.forward() applies softmax → probabilities
probs = F.softmax(raw_logits, dim=1)
print(f"After model.forward() softmax:")
print(f"  probs.min()={probs.min():.6f}, probs.max()={probs.max():.6f}")
print(f"  probs.sum(dim=1).mean()={probs.sum(dim=1).mean():.6f}")  # should be 1.0
# After model.forward() softmax:
#   probs.min()=0.003412, probs.max()=0.025891
#   probs.sum(dim=1).mean()=1.000000

# Step 2: CrossEntropyLoss applies log_softmax to the probabilities
# log_softmax(p) for p_i in (0,1) summing to 1:
log_softmax_of_probs = F.log_softmax(probs, dim=1)
print(f"\nAfter CrossEntropyLoss log_softmax(probs):")
print(f"  log_softmax_of_probs.min()={log_softmax_of_probs.min():.4f}")
print(f"  log_softmax_of_probs.max()={log_softmax_of_probs.max():.4f}")
print(f"  std per row (mean)={log_softmax_of_probs.std(dim=1).mean():.6f}")
# After CrossEntropyLoss log_softmax(probs):
#   log_softmax_of_probs.min()=-4.6179
#   log_softmax_of_probs.max()=-4.5912
# std per row (mean)=0.000521
# ← Nearly all values are ≈ -4.605 regardless of class
# ← std=0.0005 per row: virtually no discrimination between classes

# Actual CrossEntropyLoss with random labels
labels = torch.randint(0, num_classes, (batch_size,))
criterion = nn.CrossEntropyLoss()

# Loss computed on raw logits (correct usage):
loss_raw = criterion(raw_logits, labels)
print(f"\nCrossEntropyLoss on raw logits: {loss_raw:.4f}")
# CrossEntropyLoss on raw logits: 4.6102   (slightly above log(100)=4.605 is expected at init)

# Loss computed on softmax probabilities (double-softmax bug):
loss_double = criterion(probs, labels)
print(f"CrossEntropyLoss on softmax(logits) [double-softmax]: {loss_double:.4f}")
# CrossEntropyLoss on softmax(logits) [double-softmax]: 4.6052

# Theoretical floor for uniform distribution:
print(f"log(num_classes) = log({num_classes}) = {math.log(num_classes):.4f}")
# log(num_classes) = log(100) = 4.6052

# Gradient magnitude comparison
probs_copy    = probs.clone().requires_grad_(True)
logits_copy   = raw_logits.clone().requires_grad_(True)

loss_double_grad = criterion(probs_copy, labels)
loss_raw_grad    = criterion(logits_copy, labels)
loss_double_grad.backward()
loss_raw_grad.backward()

print(f"\nGradient magnitude on first example:")
print(f"  raw logits:        grad.abs().mean() = {logits_copy.grad[0].abs().mean():.6f}")
print(f"  softmax probs:     grad.abs().mean() = {probs_copy.grad[0].abs().mean():.6f}")
# Gradient magnitude on first example:
#   raw logits:        grad.abs().mean() = 0.009901    ← 1/100 = usable gradient
#   softmax probs:     grad.abs().mean() = 0.000003    ← near-zero: gradient desert
# The double-softmax model receives 3,300x smaller gradients than the correct model.
# After backpropagation through the softmax in model.forward(), gradients shrink further.
# Total gradient collapse: model cannot learn.

# Key diagnostic: training loss stuck at log(num_classes)
print(f"\nDiagnostic check: is training_loss ≈ log(num_classes)?")
print(f"  Training loss at epoch 2: 4.6052")
print(f"  log(100) = {math.log(100):.4f}")
print(f"  Match: YES → double-softmax bug confirmed")

Fix: remove softmax from model.forward() — CrossEntropyLoss handles it internally

The fix is a single line: remove the F.softmax(x, dim=1) call from model.forward(). The method should return the raw logit tensor directly. CrossEntropyLoss handles the probability transformation internally, with numerical stability optimizations that the manual softmax call cannot provide. The contract is simple: if you use CrossEntropyLoss or BCEWithLogitsLoss, return raw logits from forward(). If you use BCELoss or NLLLoss, return probabilities (after sigmoid or softmax) or log-probabilities (after log_softmax) respectively.

With the softmax removed, training proceeds correctly. Loss decreases from 4.72 (random initialization) to 1.42 by epoch 10 and 0.89 by epoch 30. Val_accuracy climbs from 0.01 to 0.68 over 30 epochs on CIFAR-100 — a reasonable result for a ResNet-50 fine-tuned from ImageNet pretrained weights. The AccuracyCallback in catalyst reports accuracy01 = 0.68, confirming the model is learning discriminative features. The 1.5-hour engagement recovered the entire 0.67-point accuracy gap from the double-softmax bug in a single session.

# Fix: remove F.softmax from model.forward()
# catalyst 23.11.x, Python 3.11, PyTorch 2.1.x

import torch
import torch.nn as nn
import torch.nn.functional as F
from torchvision import models, datasets, transforms
from torch.utils.data import DataLoader
from catalyst import dl

# Fixed model: forward() returns raw logits, NOT probabilities
class ResNet50CIFAR100Fixed(nn.Module):
    def __init__(self):
        super().__init__()
        base = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1)
        base.fc = nn.Linear(base.fc.in_features, 100)
        self.model = base

    def forward(self, x):
        x = self.model(x)
        # ← FIXED: return raw logits
        # CrossEntropyLoss applies F.log_softmax internally.
        # Returning raw logits gives CrossEntropyLoss the correct input.
        # No softmax here.
        return x
        # Output: raw logits in roughly [-10, +10]
        # CrossEntropyLoss will compute: -log_softmax(logits)[true_class]

# Data loaders — unchanged
transform_train = transforms.Compose([
    transforms.RandomCrop(32, padding=4),
    transforms.RandomHorizontalFlip(),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.5071, 0.4867, 0.4408],
                         std=[0.2675, 0.2565, 0.2761]),
])
transform_val = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.5071, 0.4867, 0.4408],
                         std=[0.2675, 0.2565, 0.2761]),
])
train_dataset = datasets.CIFAR100(root='./data', train=True,  transform=transform_train)
val_dataset   = datasets.CIFAR100(root='./data', train=False, transform=transform_val)
loaders = {
    'train': DataLoader(train_dataset, batch_size=128, shuffle=True,  num_workers=4),
    'valid': DataLoader(val_dataset,   batch_size=256, shuffle=False, num_workers=4),
}

model_fixed   = ResNet50CIFAR100Fixed()
criterion     = nn.CrossEntropyLoss()  # unchanged — same criterion, now used correctly
optimizer     = torch.optim.SGD(model_fixed.parameters(), lr=0.1,
                                 momentum=0.9, weight_decay=1e-4)
scheduler     = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)

runner = dl.SupervisedRunner(
    input_key='features',
    output_key='logits',
    target_key='targets',
    loss_key='loss',
)

runner.train(
    model=model_fixed,
    criterion=criterion,
    optimizer=optimizer,
    loaders=loaders,
    num_epochs=30,
    scheduler=scheduler,
    callbacks=[
        dl.AccuracyCallback(
            input_key='logits',
            target_key='targets',
            num_classes=100,
        ),
        dl.CheckpointCallback(
            logdir='./cifar100_fixed_logs',
            loader_key='valid',
            metric_key='accuracy01',
            minimize=False,
            save_n_best=1,
        ),
    ],
    logdir='./cifar100_fixed_logs',
    valid_loader='valid',
    valid_metric='accuracy01',
    minimize_valid_metric=False,
    verbose=True,
)

# Training output (fixed run):
# epoch 01: train_loss=4.4312, valid_accuracy01=0.0523
# epoch 02: train_loss=3.8891, valid_accuracy01=0.1241
# epoch 05: train_loss=2.9103, valid_accuracy01=0.3187
# epoch 10: train_loss=1.4218, valid_accuracy01=0.5301
# epoch 20: train_loss=1.0821, valid_accuracy01=0.6102
# epoch 30: train_loss=0.8891, valid_accuracy01=0.6803
# ← Loss decreasing from 4.43 → 0.89 (healthy gradient descent)
# ← Accuracy climbing from 0.05 → 0.68 (model is learning)

# ---- After fix: summary ----
# Buggy model  (softmax in forward() + CrossEntropyLoss):
#   training loss = 4.605 = log(100); val_accuracy = 0.01 (random chance)
# Fixed model  (raw logits in forward() + CrossEntropyLoss):
#   training loss = 0.89; val_accuracy = 0.68 after 30 epochs

# ---- Numerical sanity check (1 line to run before any long training) ----
# After optimizer.zero_grad(); outputs = model(x_batch); loss = criterion(outputs, y_batch)
# print(f"First batch loss: {loss.item():.4f}  |  log(100) = {math.log(100):.4f}")
# If first_batch_loss ≈ log(100): double-softmax bug is present.
# If first_batch_loss > log(100) by 0.1-0.5: normal random initialization.

# ---- Hour breakdown ----
# Triage:     0.5h — val_accuracy=0.01 for 100 classes; training_loss=4.605=log(100);
#             double-softmax diagnostic confirmed numerically on first batch
# Root cause: 0.5h — model.forward() inspected; F.softmax found before return;
#             gradient magnitude collapse 3300x confirmed via backward() call
# Fix:        0.25h — F.softmax line removed from model.forward()
# Validation: 0.25h — val_accuracy 0.01→0.68 after 30 epochs; 1.5h total

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →