Skip to content

Commit a246a6a

Browse files
Fix MetricGroup requiring (y_pred, y) keys on mapping outputs
MetricGroup.update() already applies each child metric's own output_transform to pull what it needs out of the group's output, per the documented contract ("output_transform of each metric in the group is also called upon its update"). But MetricGroup inherited Metric.iteration_completed unchanged, which enforces required_output_keys == ("y_pred", "y") whenever the (group-transformed) engine output is a mapping -- and unconditionally raises if the group's own required_output_keys is None. That check only makes sense for a single "leaf" metric consuming (y_pred, y) directly; a MetricGroup wrapping heterogeneous child metrics with their own output_transforms has no business requiring specific keys on the raw output. As a result, any engine returning a dict with keys other than 'y_pred'/'y' (e.g. a multi-output model returning {"outputs_1": ..., "masks": ...}) broke as soon as it was wrapped in a MetricGroup, even though attaching the same child metric directly worked fine. Manually setting required_output_keys on the group didn't help either: it triggered MetricGroup's inherited multi-output "unrolling" logic, which reinterpreted the mapping as a (y_pred, y)-shaped tuple and unrolled it into mismatched per-metric updates. Fixes #3806. MetricGroup.iteration_completed is now overridden to pass a mapping output straight through to update() without the required_output_keys check, while preserving the existing multi-output unrolling behavior (skip_unrolling) for non-mapping outputs. Added a regression test that attaches the same Loss metric directly and via a MetricGroup and asserts they produce identical results, which fails with the original ValueError on the pre-fix code.
1 parent 563adbe commit a246a6a

2 files changed

Lines changed: 63 additions & 3 deletions

File tree

ignite/metrics/metric_group.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
from collections.abc import Callable, Sequence
1+
from collections.abc import Callable, Mapping, Sequence
22
from typing import Any
33

44
import torch
55

6+
from ignite.engine import Engine
67
from ignite.metrics import Metric
8+
from ignite.metrics.metric import _is_list_of_tensors_or_numbers, _to_batched_tensor
79

810

911
class MetricGroup(Metric):
@@ -57,7 +59,37 @@ def reset(self) -> None:
5759
for m in self.metrics.values():
5860
m.reset()
5961

60-
def update(self, output: Sequence[torch.Tensor]) -> None:
62+
def iteration_completed(self, engine: Engine) -> None:
63+
# Overridden because, unlike a "leaf" metric, a MetricGroup does not itself consume a
64+
# ``(y_pred, y)``-shaped output: each metric in the group applies its own
65+
# ``output_transform`` in ``update`` to pull whatever it needs out of the group's
66+
# (transformed) output. So, unlike ``Metric.iteration_completed``, a mapping output is
67+
# passed straight through to ``update`` rather than being validated/unpacked against
68+
# ``required_output_keys``, which only makes sense for a single metric's ``(y_pred, y)``.
69+
output = self._output_transform(engine.state.output)
70+
if isinstance(output, Mapping):
71+
self.update(output)
72+
return
73+
74+
if (
75+
(not self._skip_unrolling)
76+
and isinstance(output, Sequence)
77+
and all(_is_list_of_tensors_or_numbers(o) for o in output)
78+
):
79+
if not (len(output) == 2 and len(output[0]) == len(output[1])):
80+
raise ValueError(
81+
f"Output should have 2 items of the same length, "
82+
f"got {len(output)} and {len(output[0])}, {len(output[1])}"
83+
)
84+
for o1, o2 in zip(output[0], output[1]):
85+
# o1 and o2 are list of tensors or numbers
86+
tensor_o1 = _to_batched_tensor(o1)
87+
tensor_o2 = _to_batched_tensor(o2, device=tensor_o1.device)
88+
self.update((tensor_o1, tensor_o2))
89+
else:
90+
self.update(output)
91+
92+
def update(self, output: Sequence[torch.Tensor] | Mapping[Any, Any]) -> None:
6193
for m in self.metrics.values():
6294
m.update(m._output_transform(output))
6395

tests/ignite/metrics/test_metric_group.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from ignite import distributed as idist
55
from ignite.engine import Engine
6-
from ignite.metrics import Accuracy, MetricGroup, Precision
6+
from ignite.metrics import Accuracy, Loss, MetricGroup, Precision
77

88
torch.manual_seed(41)
99

@@ -48,6 +48,34 @@ def drop_first(output):
4848
assert accuracy.state_dict() == group.metrics["accuracy"].state_dict()
4949

5050

51+
def test_mapping_output_with_custom_keys():
52+
# Regression test for https://github.com/pytorch/ignite/issues/3806 :
53+
# a MetricGroup should not require the engine's output mapping to contain
54+
# ('y_pred', 'y'); each metric in the group applies its own output_transform
55+
# to pull what it needs from the mapping, same as attaching it directly.
56+
def step(engine, batch):
57+
return {
58+
"outputs_1": (torch.rand(4, 3), torch.rand(4, 3)),
59+
"masks": (torch.rand(4, 3), torch.rand(4, 3)),
60+
}
61+
62+
loss_fn = torch.nn.MSELoss()
63+
64+
direct_engine = Engine(step)
65+
Loss(loss_fn, output_transform=lambda o: o["outputs_1"]).attach(direct_engine, "loss")
66+
67+
group_engine = Engine(step)
68+
group = MetricGroup({"loss": Loss(loss_fn, output_transform=lambda o: o["outputs_1"])})
69+
group.attach(group_engine, "metrics")
70+
71+
torch.manual_seed(0)
72+
direct_engine.run([0])
73+
torch.manual_seed(0)
74+
group_engine.run([0])
75+
76+
assert group_engine.state.metrics["metrics"] == {"loss": direct_engine.state.metrics["loss"]}
77+
78+
5179
def test_compute():
5280
precision = Precision()
5381
accuracy = Accuracy()

0 commit comments

Comments
 (0)