Metrics¶
Metrics and losses.
A metric measures how good an output is. A loss is a metric you
train on: the same class, with a weight that decides whether it drives
the gradient. Modules are grouped by what a metric assumes about the data:
- :mod:
~srforge.metrics.regression— any tensor: L1, MSE, Charbonnier, PSNR - :mod:
~srforge.metrics.classification— yes/no per element: F1, IoU, MCC, counts; Dice / Jaccard / Tversky losses, binary and multi-class cross-entropy - :mod:
~srforge.metrics.image— images: SSIM, MGE, TotalVariation, TBE, LPIPS, SAM - :mod:
~srforge.metrics.wrappers— change how another metric is evaluated - :mod:
~srforge.metrics.regularization— penalties on a single tensor - :mod:
~srforge.metrics.adversarial— GAN objectives - :mod:
~srforge.metrics.adapters— torchmetrics and pyiqa
The core API is importable from here directly.
Metric
¶
Bases: Module, IOModule, ABC
Base class for metrics — and so for losses, which are metrics you train on.
Subclasses pick ONE of two implementation levels:
-
Level 2 (compositional, preferred for new metrics). Override :meth:
pointwiseonly — return a per-element score map with the same spatial shape as the inputs. The default :meth:calculate_scorecomposes :meth:pointwise→ :meth:reduce(with optional masking applied inside reduce). Free for wrapping by modifiers like :class:UncertaintyLoss. -
Level 1 (structural, full control). Override :meth:
calculate_scoredirectly to return a[B]tensor. Use this when the metric has no per-pixel value (SSIM windows, MGE gradients), or when something non-linear follows the average (PSNR's logarithm). Skip :meth:pointwise— the default raises a clear :class:NotImplementedError, which is exactly what should happen if someone tries to wrap a structural metric with :class:UncertaintyLoss.
Subclasses with non-canonical signatures (e.g.
:class:UncertaintyLoss takes (x, y, sigma)) must override
:meth:calculate_score as well to route their extra inputs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
weight
|
float
|
Scalar weight applied during |
1.0
|
name
|
str
|
Human-readable name for logging. Defaults to the class name. |
None
|
reduction
|
str
|
How
Only a metric whose score goes through :meth: |
'mean'
|
aggregate
|
Optional[str]
|
How per-image values combine — over a training batch
for the value you backpropagate, and over the epoch for the
value you report. Each class states what it supports; the first
is its default, used when this is left
Anything a class does not support is refused here, naming what it does support. |
None
|
reductions
property
¶
How each part of this metric's value combines over the epoch.
None (the default) keeps every per-sample value, which is what
every metric that predates this does. Declare otherwise when a part
is not something you average::
@property
def reductions(self):
return {"tp": "sum", "fp": "sum", "fn": "sum"}
Valid rules are sum, mean, min, max and cat.
Either a mapping (for a metric returning a dict of parts) or a single
string (for one returning a plain tensor).
This exists so :meth:finalize receives values already combined
according to what they are, instead of having to survive an
aggregation it did not choose. Without it, a total could not be
reported at all — a distributed run averages every key, so a sum
comes back divided by the number of images — and neither could a
worst case. Even a ratio was fragile: adding the +1 smoothing
that segmentation code uses everywhere silently changed the answer.
__init_subclass__(**kwargs)
¶
Install the Metric-argument check on any subclass that needs it, and check what the class says about aggregation.
Preserved and invoked by audit_subclasses, which wraps the base
and calls whatever __init_subclass__ the class already defines.
set_io(io_cfg, *, strict=True, require_all=True)
¶
Set IO binding using the unified format: {"inputs": {param: field}}.
Metrics only read from Entry (no outputs). If outputs is provided,
a :class:TypeError is raised.
finalize(raw: Any) -> torch.Tensor
¶
Turn this metric's accumulated values into the number to report.
The third and last stage, after :meth:pointwise (per pixel) and
:meth:calculate_score (per image). The default is what every metric
in SR-Forge does today: average the per-image scores collected over
the epoch.
Override it when that average is the wrong answer. A micro-averaged
F1 is 2*ΣTP / (2*ΣTP + ΣFP + ΣFN) over every pixel seen, which is
not the mean of per-image F1 values — on change-detection data
the two differ by tens of percent, because images containing no
change score 0 and drag a mean down. A pooled PSNR is the log of the
pooled MSE, not the mean of per-image PSNRs; measured on SR data the
gap is over 10 dB.
A metric that overrides this returns its ingredients rather than a
finished score — {"tp": [B], "fp": [B], "fn": [B]}, say — and
combines them here. The aggregation then travels with the metric that
owns it, instead of living somewhere that has to find it again later.
Store what adds up. Counts and sums, never means or ratios: a
mean has already discarded the weight it was taken over, so averaging
means silently mis-weights images of different sizes. If the quantity
has a denominator, carry the numerator and denominator separately.
A :meth:pointwise metric gets this for free — see below.
That rule also makes the result correct under DDP, where
reduce_scores collapses each key to a global mean before this
runs: a ratio of sums is unchanged by that, a bare sum() is not.
An order statistic (median, percentile) cannot be expressed here at
all, because the individual values no longer exist.
Under aggregate="micro" on a pointwise metric the default does the
pooling itself: raw is {"sum", "count"} already totalled, and
the value is their ratio — the mean over every valid pixel seen.
calculate_score(*args, **kwargs) -> torch.Tensor
¶
Compute the per-sample value.
The base method is declared as *args, **kwargs so subclasses
can override it with whatever inputs they need (IO binding routes
Entry fields to the declared params — e.g.
ShiftNetRegisLoss.calculate_score(shifts)) without tripping
static-analysis Liskov checks. The polymorphic arity is
intentional.
Subclasses that don't override this method inherit the default
Level-2 path (:meth:pointwise → mask → :meth:reduce),
implemented in :meth:_default_calculate_score. Their inputs are
:meth:pointwise's parameters plus y_mask.
Override directly for Level-1 (structural) metrics that aren't decomposable into a per-element operation (SSIM, MGE), whose per-image score is a non-linear function of a mean (PSNR), or with a non-canonical input signature.
pointwise(*args, **kwargs) -> torch.Tensor
¶
Per-pixel score map — no masking, no reduction.
Its parameters are the metric's inputs (plus y_mask, which
the framework applies): pointwise(self, x) for a no-reference
metric, pointwise(self, x, y) for the usual comparison,
UncertaintyLoss.pointwise(self, x, y, log_var) for more. Name
and annotate them like calculate_score parameters; y_mask
itself is reserved. The base method is declared as *args,
**kwargs so subclasses can choose freely without tripping
static-analysis Liskov checks.
Override in subclasses that decompose into a per-element
comparison (MSE, L1, Charbonnier, CrossEntropy, SAM, …). The
default raises so wrapping a non-pointwise metric with a
Level-2 modifier (e.g. :class:UncertaintyLoss) fails loudly
instead of silently producing garbage.
Contract:
- Batch dim first; the output's first axis is
[B]. -
Non-batch dims may differ from the inputs, as long as :meth:
reducecan collapse them to a single per-sample scalar (it doesview(B, -1)internally). Typical shapes: -
[B, C, H, W]— element-wise comparison preserving all input dims (MSE, L1, Charbonnier). [B, 1, H, W]— comparison that collapses the channel axis (SAM's per-pixel spectral angle).
What does NOT fit cleanly: shapes that drop spatial pixels
(windowed reductions like SSIM, forward-difference like
TotalVariation) — those stay Level-1 (override
:meth:calculate_score directly).
* No masking — the default :meth:calculate_score
dispatches masking to :meth:reduce (which zeros the
score map at masked positions and normalises the
denominator). Pointwise sees raw inputs and may produce
arbitrary finite values at "masked" positions — they're
discarded by reduce. If your pointwise is convolutional
/ windowed (SSIM, MGE) and needs input neutralisation
so windows never reach masked pixels, use :meth:erode_mask in your
own :meth:calculate_score override.
* No reduction — :meth:reduce does that, separately
and configurably.
No :meth:calculate_score override is needed for extra inputs:
the inherited one passes every input through to pointwise.
reduce(score_map: torch.Tensor, mask: torch.Tensor = None) -> torch.Tensor
¶
Reduce a [B, ...] per-element score map to [B].
With a mask: brings it to the map's shape (see :meth:_match_mask),
zeros masked positions, sums the remaining, and normalises by the
number of valid elements (clamped to ≥ 1 so all-masked samples
return 0, not NaN). Without a mask:
averages or sums over all non-batch dims, depending on
:attr:_reduction.
Returns the score map unchanged when reduction == "none".
__check_annotated_signatures()
¶
Ensures that the calculate_score method has annotated signatures for all parameters.
erode_mask(mask: torch.Tensor, window_size: int) -> torch.Tensor
staticmethod
¶
Keep only the positions whose whole window_size window is valid.
For windowed metrics (SSIM, gradient metrics, anything built on a convolution). The value such a metric computes at a pixel depends on every pixel in the window around it, so a window that reaches into a masked area compares values nobody vouches for. Zeroing the masked inputs first does not help — the zeros become part of the windows next to them: SSIM then sees a perfect match, a gradient filter sees an edge that is not in the image.
Compute the metric's map on the real inputs, then average it over this eroded mask. Each value is the smallest mask value in its window (a min-filter), so soft masks stay soft. Positions outside the image are treated as valid: image borders are the metric's own padding business, not the mask's, and an all-ones mask therefore changes nothing.
mask_pixels(sr: torch.Tensor, hr: torch.Tensor, hr_mask=None)
staticmethod
¶
Neutralise inputs at masked positions: returns
(sr*mask, hr*mask, unmasked_count_clamped_to_one).
Not for windowed metrics. Zeroing masked positions before a
window runs puts those zeros into the neighbouring windows — SSIM
and the built-in gradient metric both scored masked areas wrongly
this way. Use :meth:erode_mask on the metric's map instead. Pointwise
metrics don't need this either — :meth:reduce already zeros the
score map at masked positions and normalises the denominator.
The unmasked_count is clamped to ≥1 so callers that
divide by it don't hit div-by-zero on samples whose mask is
entirely zero. With sr*mask = hr*mask = 0 in that case,
the resulting per-sample value is exactly 0 instead of NaN —
what we want for label-less / placeholder HR samples
(challenge_dev mixed into training, etc.). Pixel counts are
integers ≥ 1 in the normal partial-mask case so the clamp
is a no-op there.
MetricEntry
dataclass
¶
Holds all variants of a single metric
- raw: output of calculate_score (no sign, no weight)
- best_min: True if smaller is better; False if larger is better
- weight: optimization weight (can be 0)
- finalize: optional rule for turning accumulated
rawinto one number, replacing the default "average the per-sample values"
finalize exists because some metrics are not averages of per-sample
scores. A micro-averaged F1 is 2*ΣTP / (2*ΣTP + ΣFP + ΣFN) over every
pixel in the epoch, which is not the mean of per-image F1 — on
change-detection data the two differ by tens of percent, because images
containing no change score 0 and drag a mean down. Such a metric reports
its parts ({"tp": [B], "fp": [B], "fn": [B]}) and supplies the rule
that combines them, so the aggregation travels with the value instead of
living in something that has to find it again by name later.
A finalized entry is not a per-sample score, so it never appears in
the per-sample views. What it contributes to total_raw and
total_weighted depends on aggregate:
"micro"— its pooled value over whatever the entry currently holds: one training batch when the runner backpropagates, the whole epoch when a checkpoint compares totals. That is batch-level micro for training, and it is differentiable wherever the metric is."custom"— nothing. A count or a worst case is not a loss term.
The rule must be a function of per-key means. In a single process
raw holds every per-sample value; in a distributed run
TrainingStrategy.reduce_scores has already collapsed each key to one
global mean. A rule written with .mean(), or as a ratio of sums
(where the sample count cancels), gives the same answer in both cases.
F1, precision, recall, IoU, MCC and a pooled PSNR all satisfy this.
A rule that does not — a bare .sum(), or an order statistic such as a
median or a 95th percentile — is correct on one process and silently
wrong on N, because the values it needs no longer exist by the time it
runs. Such a metric does not belong here.
is_finalized: bool
property
¶
Whether this entry carries parts + a rule rather than scores.
counts_toward_total: bool
property
¶
Whether this entry is a loss term. Per-image scores are; a pooled (micro) value is the same quantity weighted differently, so it is too; a custom epoch rule (a total, a worst case) is not.
total_batch(weighted: bool) -> torch.Tensor
¶
This entry's [B] contribution to a loss total.
A pooled entry has one value for the whole batch rather than one per
image, so it is repeated across the batch: the runner's .mean()
then recovers exactly that value, and its gradient, with no rescaling.
reduction_for(key: str = None) -> str
¶
The declared reduction for key, defaulting to "cat".
reduced() -> TensorOrDict
¶
raw with each key collapsed as the metric declared.
This is what :meth:finalize receives: values already totalled,
minimised or averaged according to what they are, so the formula
does not have to compensate for an aggregation it did not choose.
Without a declaration the raw values pass through unchanged, which
is the behaviour of every metric that predates this. With bands,
each band is collapsed on its own: {band: reduced value}.
band_values(weighted: bool = False) -> Dict[str, torch.Tensor]
¶
Each band's own value, by the metric's own rule.
For a finalized entry with bands: the metric's :meth:finalize run
on each band's totals, so F1.red is the micro F1 of the red band
alone. weighted signs and weights micro values the way
:meth:final_weighted does.
final_value() -> torch.Tensor
¶
The single number this entry represents.
With bands, the bands combine the way the metric's images do:
- macro and micro — the average of the band values, each band counting equally (bands often differ in resolution, and pooling would let the finest one dominate);
- custom — the band totals combined by the metric's own declared
rules, then its
finalize(a count sums, a worst case takes the minimum). Where a rule cannot run across bands, the average of the band values.
final_weighted() -> torch.Tensor
¶
:meth:final_value as it enters the weighted total.
Sign and weight apply to a pooled (micro) value exactly as to a
per-image score, so the weighted numbers of a MetricScores add up to
its total_weighted(). A custom rule's value — a count, a worst
case — is not a loss term and has no weighted form, so it is
returned as is.
MetricScores
dataclass
¶
__setitem__(name: str, entry: MetricEntry) -> None
¶
In-place add or replace a metric. Used by hooks that inject
auxiliary loss terms from on_post_forward — drop a
:class:MetricEntry here and the runner's total_weighted()
picks it up before backward, and LossLogger emits it as a
per-name series alongside the criterion's metrics.
add_scores(other: MetricScores) -> None
¶
In-place accumulation: concatenates batch dimension when names match.
LossScheduler
¶
Epoch-aware loss wrapper that swaps the active Metric at configured milestones.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
schedule
|
Mapping[int, Metric]
|
Mapping of |
required |
start_epoch
|
int
|
Current/resume epoch (defaults to 0). |
0
|
adapt(obj: Any, **options: Any) -> Any
¶
Return obj as a :class:Metric, wrapping it if it comes from a
library SR-Forge knows how to adapt.
Anything that is already a Metric — and anything no adapter claims —
is returned unchanged and identical, so this is safe to call on
every object the resolver builds.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
obj
|
Any
|
The object to adapt. |
required |
**options
|
Any
|
Forwarded to the adapter's constructor ( |
{}
|