Entropy vs Margin Sampling for Segmentation Masks

For per-pixel segmentation masks, the choice between entropy and margin sampling comes down to how many classes carry real probability mass. Entropy uses the full class distribution and is the better signal for many-class land cover masks, where three- and four-way confusion is common and only a full-distribution measure captures it. Margin sampling — and its close relative least-confidence — is cheaper to compute and produces a more stable ranking for binary or few-class masks such as building, water, or road extraction. In both cases the critical move is aggregation: convert the per-pixel uncertainty map to a single tile score using the mean of the top-k most uncertain pixels, never the global mean, so a small ambiguous region is not averaged into irrelevance by a large confident background. That tile score is what feeds the ranking that drives your active learning loop.

Why This Matters in Geospatial Pipelines

Aerial and satellite tiles are dominated by confident background — vegetation, water, bare soil — so a segmentation model is genuinely uncertain over only a thin fraction of each tile, usually class boundaries and rare objects. If you rank tiles by average uncertainty, those informative fractions vanish and annotators are handed confident tiles that teach the model nothing. Choosing the right per-pixel measure and aggregating it with a top-k mean concentrates scarce annotation budget on the tiles whose ambiguous regions will actually move the metric.

Entropy vs Margin on a Single Pixel

Both measures start from the same input: a per-pixel probability vector over C classes, produced by a softmax over the segmentation head’s logits. They disagree on what “uncertain” means. Entropy is maximised when probability is spread evenly across all classes; margin looks only at the gap between the top two. The SVG below shows the same two softmax vectors scored both ways — a three-way-confused pixel that entropy flags strongly but margin rates as only moderately uncertain.

Entropy versus margin on two example softmax vectors Two grouped bar charts of class probabilities. Pixel A has three near-equal classes and one small class, giving high entropy but only moderate margin uncertainty. Pixel B has one dominant class and one runner-up, giving lower entropy but a small top-two gap. Score readouts appear beneath each chart. Pixel A — 3-way confusion 0.34 0.31 0.28 0.07 entropy = 1.09 (high) margin = 1 − (0.34−0.31) = 0.97 both flag it — top two nearly tied Pixel B — dominant + runner-up 0.55 0.34 0.07 0.04 entropy = 0.99 (lower) margin = 1 − (0.55−0.34) = 0.79 margin ranks it clearly below A Entropy rewards spread across many classes; margin cares only about the top-two gap. Both scores rise as the mask becomes more ambiguous; scale each map before aggregating.

Step-by-Step Implementation

Install the two libraries used throughout. Everything below runs on NumPy alone — no model or GPU required — so you can validate the scoring logic on synthetic logits before wiring it to a real segmenter.

bash
pip install numpy==1.26.4 scipy==1.13.1

Assume your model returns raw logits of shape (C, H, W) per tile, where C is the number of segmentation classes and H, W are the tile height and width in pixels.

Step 1 — Softmax and Per-Pixel Entropy

Entropy integrates over the whole class axis, so it is the natural default for multi-class land cover masks. Normalise the logits with a numerically stable softmax first, then evaluate -sum(p * log p) along the class axis. Dividing by log(C) rescales the map to [0, 1], which makes score thresholds portable across models with different class counts.

python
from __future__ import annotations
import numpy as np

def softmax(logits: np.ndarray, axis: int = 0) -> np.ndarray:
    """Numerically stable softmax over `axis` of a (C, H, W) logit array."""
    shifted = logits - np.max(logits, axis=axis, keepdims=True)
    exp = np.exp(shifted)
    return exp / np.sum(exp, axis=axis, keepdims=True)

def pixel_entropy(probs: np.ndarray, axis: int = 0) -> np.ndarray:
    """
    Normalised Shannon entropy per pixel.

    Args:
        probs: (C, H, W) class-probability array (sums to 1 over `axis`).
    Returns:
        (H, W) array in [0, 1]; 1.0 means a uniform distribution over all classes.
    """
    eps = 1e-12
    num_classes = probs.shape[axis]
    raw = -np.sum(probs * np.log(probs + eps), axis=axis)
    return raw / np.log(num_classes)

Step 2 — Per-Pixel Margin (and Least-Confidence)

Margin looks only at the top two classes, so it is cheaper and far more stable for binary or few-class masks where a broad spread of near-zero probabilities would otherwise perturb entropy. Sort the probabilities descending along the class axis and take 1 - (p_top1 - p_top2); a value near 1.0 means the two leading classes are nearly tied. The same helper returns least-confidence (1 - p_top1) for callers that prefer it.

python
from __future__ import annotations
import numpy as np

def pixel_margin(probs: np.ndarray, axis: int = 0) -> np.ndarray:
    """
    Per-pixel margin uncertainty: 1 - (top1 - top2) probability gap.

    Args:
        probs: (C, H, W) class-probability array.
    Returns:
        (H, W) array in [0, 1]; higher means the top two classes are closer.
    """
    top2 = np.sort(probs, axis=axis)[-2:, ...]   # ascending -> last two are top2, top1
    gap = top2[-1] - top2[-2]
    return 1.0 - gap

def pixel_least_confidence(probs: np.ndarray, axis: int = 0) -> np.ndarray:
    """Per-pixel least-confidence uncertainty: 1 - max class probability."""
    return 1.0 - np.max(probs, axis=axis)

Step 3 — Aggregate to a Tile Score with a Top-k Mean

This is the step that makes or breaks geospatial uncertainty sampling. Flatten the per-pixel map, take the highest-scoring k percent of pixels, and average only those. A background-dominated tile with a genuinely ambiguous boundary now scores on the strength of that boundary instead of being diluted to near zero by a confident interior. Keep k small for sparse targets and larger for area classes; the table in the next section gives starting values.

python
from __future__ import annotations
import numpy as np

def topk_mean(uncertainty_map: np.ndarray, k_percent: float = 5.0) -> float:
    """
    Aggregate a (H, W) per-pixel uncertainty map into one tile score.

    Averages only the top `k_percent` most uncertain pixels so small
    ambiguous regions are not washed out by a confident background.

    Args:
        uncertainty_map: (H, W) array of per-pixel scores in [0, 1].
        k_percent: fraction of pixels to keep, e.g. 5.0 for the top 5%.
    Returns:
        Scalar tile score in [0, 1].
    """
    flat = uncertainty_map.reshape(-1)
    n_keep = max(1, int(round(flat.size * k_percent / 100.0)))
    # np.partition puts the n_keep largest values at the end, unsorted (O(n)).
    top_values = np.partition(flat, flat.size - n_keep)[-n_keep:]
    return float(np.mean(top_values))

Step 4 — Rank Candidate Tiles for the Next Labeling Round

Score every unlabeled tile, then sort descending. The scorer is selectable so you can run entropy for a land-cover model and margin for a binary extractor without changing the ranking code. The output is a queue of tile IDs handed to annotators — the highest-uncertainty tiles first.

python
from __future__ import annotations
import numpy as np
from typing import Callable

Scorer = Callable[[np.ndarray], np.ndarray]  # (C,H,W) probs -> (H,W) uncertainty

def rank_tiles(
    tile_logits: dict[str, np.ndarray],
    scorer: Scorer = pixel_entropy,
    k_percent: float = 5.0,
) -> list[tuple[str, float]]:
    """
    Rank tiles by aggregated top-k uncertainty, highest first.

    Args:
        tile_logits: mapping of tile_id -> (C, H, W) raw logits.
        scorer: pixel_entropy (many-class) or pixel_margin (few-class).
        k_percent: top-k percentage passed to topk_mean.
    Returns:
        List of (tile_id, score) sorted by descending score.
    """
    scored: list[tuple[str, float]] = []
    for tile_id, logits in tile_logits.items():
        probs = softmax(logits, axis=0)
        umap = scorer(probs)
        scored.append((tile_id, topk_mean(umap, k_percent)))
    scored.sort(key=lambda item: item[1], reverse=True)
    return scored

# --- self-contained demo on synthetic logits ---
if __name__ == "__main__":
    rng = np.random.default_rng(42)
    tiles: dict[str, np.ndarray] = {
        "tile_confident":  rng.normal(0, 1, (4, 64, 64)) + np.array([6, 0, 0, 0])[:, None, None],
        "tile_ambiguous":  rng.normal(0, 1, (4, 64, 64)),  # near-uniform logits
    }
    for tid, score in rank_tiles(tiles, scorer=pixel_entropy, k_percent=5.0):
        print(f"{tid:16s} entropy_top5 = {score:.3f}")

Parameters and Score Ranges

The two measures live on different numeric scales even after normalisation, so pick thresholds per measure and per target type rather than reusing one global cutoff. Because these scores drive annotation priority the same way confidence scores drive review triage, keep them calibrated: over-confident softmax output compresses both measures toward zero and flattens the ranking.

Parameter Entropy Margin / least-confidence Notes
Per-pixel range (normalised) 0.0 – 1.0 0.0 – 1.0 1.0 = maximally uncertain pixel
Best fit Many-class land cover Binary / few-class masks Match the measure to class count
Cost per tile Higher (full C sum) Lower (top-2 sort) Margin scales better on large tiles
Top-k for sparse targets k = 1 – 5% k = 1 – 5% Vehicles, panels, small buildings
Top-k for area classes k = 10 – 20% k = 10 – 20% Vegetation, water, land cover
“Send to annotator” tile score ≳ 0.55 ≳ 0.45 Tune on a held-out set per model
Stability under noisy tails Sensitive Robust Heavy probability tails perturb entropy more

Treat the “send to annotator” rows as ranking anchors, not hard gates — in most loops you take a fixed batch size off the top of the ranked queue each round rather than everything above a score. When ambiguity clusters spatially, deduplicate the batch so you are not labeling ten adjacent tiles of the same confused field; that is the subject of prioritizing tiles by model disagreement.

Common Errors and Fixes

Every tile scores near zero and the ranking looks random. Root cause: you aggregated with a global mean() over all pixels, so confident background dominated and drowned the small uncertain regions. Fix: aggregate with topk_mean() and start at k_percent = 5; the informative fraction of a geospatial tile is small by nature.

entropy returns nan for some pixels. Root cause: log(0) where a class probability underflowed to exactly zero after softmax. Fix: add a small epsilon inside the log (np.log(probs + 1e-12)) as shown, and confirm the softmax was applied before scoring.

Margin scores are stuck at a constant value across every tile. Root cause: you passed raw logits instead of probabilities, so the top-two gap is on an unbounded scale and the 1 - gap result saturates or goes negative. Fix: always run softmax() first; margin and entropy are defined on the probability simplex, not on logits.

Entropy and margin disagree wildly on which tiles to label. Root cause: the mask has many classes with a heavy tail of tiny probabilities; entropy responds to that spread while margin ignores it. Fix: this is expected — pick the measure that matches your class count (entropy for many-class, margin for few-class) rather than averaging the two rankings.

The queue keeps surfacing the same over-confident tiles as “certain” that annotators find obviously wrong. Root cause: uncalibrated logits make the model over-confident, compressing uncertainty toward zero. Fix: apply temperature scaling to the logits before softmax() so the score spread between tiles is restored.

This guide is one specialised technique within Uncertainty Sampling for Geospatial Active Learning, which is itself part of Active Learning & Model Feedback Loops for Geospatial Annotation.