Skip to content

Commit 2d8c83a

Browse files
remove sklearn dependency from cohenkappa score calculation logic and applied custom calculation and updated tests (#3731)
Fixes #3701 ## Description: Remove scikit-learn dependency from `CohenKappa` by implementing a native PyTorch version. - Replaced `sklearn.metrics.cohen_kappa_score` with a pure PyTorch implementation - Removed forced GPU→CPU transfer (`.cpu().numpy()`) — metric now runs fully on the configured device - Built confusion matrix with `torch.bincount` (single vectorised kernel) instead of a Python loop - Used `float64` throughout to match sklearn's numerical precision - Added explicit multilabel input validation (previously handled implicitly by sklearn Check list: - [x] New tests are added (if a new feature is added) - [x] New doc strings: description and/or example code are in RST format - [x] Documentation is updated (if required) --------- Co-authored-by: vfdev <vfdev.5@gmail.com>
1 parent f714577 commit 2d8c83a

9 files changed

Lines changed: 277 additions & 57 deletions

ignite/metrics/cohen_kappa.py

Lines changed: 186 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,156 @@
22
from typing import Literal
33

44
import torch
5+
import torch.nn.functional as F
56

7+
from ignite.exceptions import NotComputableError
8+
from ignite.metrics.confusion_matrix import ConfusionMatrix
69
from ignite.metrics.epoch_metric import EpochMetric
10+
from ignite.metrics.metric import Metric, reinit__is_reduced
711

812

9-
class CohenKappa(EpochMetric):
10-
"""Compute different types of Cohen's Kappa: Non-Wieghted, Linear, Quadratic.
11-
Accumulating predictions and the ground-truth during an epoch and applying
12-
`sklearn.metrics.cohen_kappa_score <https://scikit-learn.org/stable/modules/
13-
generated/sklearn.metrics.cohen_kappa_score.html>`_ .
13+
def _kappa_from_conf(conf: torch.Tensor, weights: Literal["linear", "quadratic"] | None) -> float:
14+
n = conf.sum()
15+
if n == 0:
16+
raise NotComputableError("CohenKappa cannot be computed on an empty confusion matrix (n == 0).")
17+
18+
n_classes = conf.shape[0]
19+
20+
if weights is None:
21+
p_o = conf.trace() / n
22+
row = conf.sum(dim=1)
23+
col = conf.sum(dim=0)
24+
p_e = (row * col).sum() / (n * n)
25+
else:
26+
idx = torch.arange(n_classes, device=conf.device)
27+
if weights == "linear":
28+
w = torch.abs(idx.unsqueeze(0) - idx.unsqueeze(1)).to(dtype=conf.dtype)
29+
else:
30+
w = ((idx.unsqueeze(0) - idx.unsqueeze(1)) ** 2).to(dtype=conf.dtype)
31+
32+
w = w / w.max()
33+
p_o = 1 - (w * conf).sum() / n
34+
row = conf.sum(dim=1)
35+
col = conf.sum(dim=0)
36+
expected = row.unsqueeze(1) * col.unsqueeze(0) / n
37+
p_e = 1 - (w * expected).sum() / n
38+
39+
epsilon = 1e-9
40+
return ((p_o - p_e) / (1 - p_e).clamp(min=epsilon)).item()
41+
42+
43+
def _cohen_kappa_score(
44+
y_pred: torch.Tensor,
45+
y: torch.Tensor,
46+
weights: Literal["linear", "quadratic"] | None,
47+
double_dtype: torch.dtype,
48+
) -> float:
49+
if y_pred.ndim > 1 or y.ndim > 1:
50+
raise ValueError("multilabel-indicator is not supported")
51+
52+
num_classes = int(max(y_pred.max().item(), y.max().item())) + 1
53+
54+
# Build the confusion matrix locally with plain tensor ops. We must NOT use the
55+
# ``ConfusionMatrix`` metric here: its ``compute()`` is wrapped with ``sync_all_reduce``
56+
# and this function runs on rank 0 only (see ``EpochMetric.compute``), so a collective
57+
# would deadlock the other ranks.
58+
y, y_pred = y.long(), y_pred.long()
59+
indices = num_classes * y + y_pred
60+
conf = torch.bincount(indices, minlength=num_classes**2)
61+
conf = conf.reshape(num_classes, num_classes).to(dtype=double_dtype)
62+
63+
return _kappa_from_conf(conf, weights)
64+
65+
66+
class _CohenKappaEpochMetric(EpochMetric):
67+
"""CohenKappa backed by EpochMetric — infers num_classes dynamically from data."""
68+
69+
def __init__(
70+
self,
71+
weights: Literal["linear", "quadratic"] | None,
72+
output_transform: Callable,
73+
device: str | torch.device,
74+
skip_unrolling: bool,
75+
check_compute_fn: bool,
76+
):
77+
super().__init__(
78+
# ``self._double_dtype`` (set in Metric.__init__, float32 on MPS / float64 otherwise)
79+
# is resolved lazily at compute time.
80+
compute_fn=lambda y_pred, y: _cohen_kappa_score(y_pred, y, weights, self._double_dtype),
81+
output_transform=output_transform,
82+
check_compute_fn=check_compute_fn,
83+
device=device,
84+
skip_unrolling=skip_unrolling,
85+
)
86+
87+
@reinit__is_reduced
88+
def update(self, output: tuple[torch.Tensor, torch.Tensor]) -> None:
89+
y_pred, y = output[0].detach(), output[1].detach()
90+
91+
if y_pred.ndim == 2 and y_pred.shape[1] == 1:
92+
y_pred = y_pred.squeeze(dim=-1)
93+
if y.ndim == 2 and y.shape[1] == 1:
94+
y = y.squeeze(dim=-1)
95+
96+
super().update((y_pred, y))
97+
98+
99+
class _CohenKappaConfusionMatrix(Metric):
100+
"""CohenKappa backed by ConfusionMatrix — requires num_classes at construction time.
101+
Accumulates a running confusion matrix; no raw tensor buffering.
102+
"""
103+
104+
_state_dict_all_req_keys = ("_cm",)
105+
106+
def __init__(
107+
self,
108+
num_classes: int,
109+
weights: Literal["linear", "quadratic"] | None,
110+
output_transform: Callable,
111+
device: str | torch.device,
112+
skip_unrolling: bool,
113+
):
114+
self._weights = weights
115+
self._cm = ConfusionMatrix(
116+
num_classes=num_classes,
117+
output_transform=output_transform,
118+
device=device,
119+
skip_unrolling=skip_unrolling,
120+
)
121+
super().__init__(output_transform=output_transform, device=device, skip_unrolling=skip_unrolling)
122+
123+
@reinit__is_reduced
124+
def reset(self) -> None:
125+
self._cm.reset()
126+
127+
@reinit__is_reduced
128+
def update(self, output: tuple[torch.Tensor, torch.Tensor]) -> None:
129+
y_pred, y = output[0].detach(), output[1].detach()
130+
131+
if y_pred.ndim == 2 and y_pred.shape[1] == 1:
132+
y_pred = y_pred.squeeze(dim=-1)
133+
if y.ndim == 2 and y.shape[1] == 1:
134+
y = y.squeeze(dim=-1)
135+
136+
if y_pred.ndim > 1 or y.ndim > 1:
137+
raise ValueError("multilabel-indicator is not supported")
138+
139+
num_classes = self._cm.num_classes
140+
y_pred_oh = F.one_hot(y_pred.long(), num_classes).float().to(self._device)
141+
self._cm.update((y_pred_oh, y.long().to(self._device)))
142+
143+
def compute(self) -> float:
144+
conf = self._cm.compute().to(dtype=self._double_dtype)
145+
return _kappa_from_conf(conf, self._weights)
146+
147+
148+
class CohenKappa(Metric):
149+
"""Compute different types of Cohen's Kappa: Non-Weighted, Linear, Quadratic.
150+
151+
When ``num_classes`` is provided, accumulates a running confusion matrix via
152+
:class:`~ignite.metrics.confusion_matrix.ConfusionMatrix` (memory-efficient, no raw tensor buffering).
153+
When ``num_classes`` is ``None`` (default), buffers predictions and targets via
154+
:class:`~ignite.metrics.EpochMetric` and infers the number of classes from data.
14155
15156
Args:
16157
output_transform: a callable that is used to transform the
@@ -19,19 +160,20 @@ class CohenKappa(EpochMetric):
19160
you want to compute the metric with respect to one of the outputs.
20161
weights: a string is used to define the type of Cohen's Kappa whether Non-Weighted or Linear
21162
or Quadratic. Default, None.
22-
check_compute_fn: Default False. If True, `cohen_kappa_score
23-
<https://scikit-learn.org/stable/modules/generated/sklearn.metrics.cohen_kappa_score.html>`_
24-
is run on the first batch of data to ensure there are
25-
no issues. User will be warned in case there are any issues computing the function.
163+
check_compute_fn: Default False. If True, the compute function is run on the first batch
164+
of data to ensure there are no issues. User will be warned in case there are any issues
165+
computing the function.
26166
device: optional device specification for internal storage.
27167
skip_unrolling: specifies whether output should be unrolled before being fed to update method. Should be
28168
true for multi-output model, for example, if ``y_pred`` contains multi-output as ``(y_pred_a, y_pred_b)``
29169
Alternatively, ``output_transform`` can be used to handle this.
170+
num_classes: number of classes. If provided, uses a running confusion matrix
171+
(memory-efficient). If ``None``, infers from data at compute time (backward-compatible default).
30172
31173
Examples:
32174
To use with ``Engine`` and ``process_function``, simply attach the metric instance to the engine.
33175
The output of the engine's ``process_function`` needs to be in the format of
34-
``(y_pred, y)`` or ``{'y_pred': y_pred, 'y': y, ...}``. If not, ``output_tranform`` can be added
176+
``(y_pred, y)`` or ``{'y_pred': y_pred, 'y': y, ...}``. If not, ``output_transform`` can be added
35177
to the metric to transform the output into the form expected by the metric.
36178
37179
.. include:: defaults.rst
@@ -52,6 +194,10 @@ class CohenKappa(EpochMetric):
52194
53195
.. versionchanged:: 0.5.1
54196
``skip_unrolling`` argument is added.
197+
198+
.. versionchanged:: 0.6.0
199+
Replaced scikit-learn dependency with a native PyTorch implementation.
200+
Added ``num_classes`` argument; routes to a running-confusion-matrix backend when provided.
55201
"""
56202

57203
def __init__(
@@ -61,28 +207,39 @@ def __init__(
61207
check_compute_fn: bool = False,
62208
device: str | torch.device = torch.device("cpu"),
63209
skip_unrolling: bool = False,
210+
num_classes: int | None = None,
64211
):
65-
try:
66-
from sklearn.metrics import cohen_kappa_score # noqa: F401
67-
except ImportError:
68-
raise ModuleNotFoundError("This contrib module requires scikit-learn to be installed.")
69212
if weights not in (None, "linear", "quadratic"):
70213
raise ValueError("Kappa Weighting type must be None or linear or quadratic.")
71214

72-
# initialize weights
73215
self.weights: Literal["linear", "quadratic"] | None = weights
74216

75-
super().__init__(
76-
self._cohen_kappa_score,
77-
output_transform=output_transform,
78-
check_compute_fn=check_compute_fn,
79-
device=device,
80-
skip_unrolling=skip_unrolling,
81-
)
82-
83-
def _cohen_kappa_score(self, y_targets: torch.Tensor, y_preds: torch.Tensor) -> float:
84-
from sklearn.metrics import cohen_kappa_score
85-
86-
y_true = y_targets.cpu().numpy()
87-
y_pred = y_preds.cpu().numpy()
88-
return cohen_kappa_score(y_true, y_pred, weights=self.weights)
217+
if num_classes is not None:
218+
self._impl: Metric = _CohenKappaConfusionMatrix(
219+
num_classes=num_classes,
220+
weights=weights,
221+
output_transform=output_transform,
222+
device=device,
223+
skip_unrolling=skip_unrolling,
224+
)
225+
else:
226+
self._impl = _CohenKappaEpochMetric(
227+
weights=weights,
228+
output_transform=output_transform,
229+
device=device,
230+
skip_unrolling=skip_unrolling,
231+
check_compute_fn=check_compute_fn,
232+
)
233+
234+
super().__init__(output_transform=output_transform, device=device, skip_unrolling=skip_unrolling)
235+
236+
@reinit__is_reduced
237+
def reset(self) -> None:
238+
self._impl.reset()
239+
240+
@reinit__is_reduced
241+
def update(self, output: tuple[torch.Tensor, torch.Tensor]) -> None:
242+
self._impl.update(output)
243+
244+
def compute(self) -> float:
245+
return self._impl.compute()

ignite/metrics/epoch_metric.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ def update(self, output: tuple[torch.Tensor, torch.Tensor]) -> None:
144144

145145
def compute(self) -> float:
146146
if len(self._predictions) < 1 or len(self._targets) < 1:
147-
raise NotComputableError("EpochMetric must have at least one example before it can be computed.")
147+
raise NotComputableError(f"{type(self).__name__} must have at least one example before it can be computed.")
148148

149149
if self._result is None:
150150
_prediction_tensor = torch.cat(self._predictions, dim=0)

tests/ignite/metrics/regression/test_median_absolute_error.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
def test_zero_sample():
1414
m = MedianAbsoluteError()
1515
with pytest.raises(
16-
NotComputableError, match=r"EpochMetric must have at least one example before it can be computed"
16+
NotComputableError, match=r"MedianAbsoluteError must have at least one example before it can be computed"
1717
):
1818
m.compute()
1919

tests/ignite/metrics/regression/test_median_absolute_percentage_error.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
def test_zero_sample():
1414
m = MedianAbsolutePercentageError()
1515
with pytest.raises(
16-
NotComputableError, match=r"EpochMetric must have at least one example before it can be computed"
16+
NotComputableError,
17+
match=r"MedianAbsolutePercentageError must have at least one example before it can be computed",
1718
):
1819
m.compute()
1920

tests/ignite/metrics/regression/test_median_relative_absolute_error.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
def test_zero_sample():
1414
m = MedianRelativeAbsoluteError()
1515
with pytest.raises(
16-
NotComputableError, match=r"EpochMetric must have at least one example before it can be computed"
16+
NotComputableError,
17+
match=r"MedianRelativeAbsoluteError must have at least one example before it can be computed",
1718
):
1819
m.compute()
1920

tests/ignite/metrics/test_average_precision.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def test_no_update():
2929
ap = AveragePrecision()
3030

3131
with pytest.raises(
32-
NotComputableError, match=r"EpochMetric must have at least one example before it can be computed"
32+
NotComputableError, match=r"AveragePrecision must have at least one example before it can be computed"
3333
):
3434
ap.compute()
3535

0 commit comments

Comments
 (0)