Skip to content

Commit 8a0a851

Browse files
qinxwewclaude
andcommitted
feat(metrics): add positive and negative likelihood ratios to ConfusionMatrixMetric
Add LR+ (sensitivity / (1 - specificity)) as requested in #4422, along with its natural companion LR- ((1 - sensitivity) / specificity), matching how other libraries expose the pair (e.g. torchmetrics). Both are computed from the confusion-matrix components following the existing pattern for compound rates (tpr/fpr guarded by class prevalence, NaN on undefined denominator), and are exposed through the usual aliases: 'positive likelihood ratio', 'plr', 'lr+' and 'negative likelihood ratio', 'nlr', 'lr-'. Add tests with hand-computed values covering the undefined cases (fpr = 0 -> LR+ is NaN, fnr = 0 -> LR- is 0) and a classification-task integration test using the space-separated aliases. Fixes #4422 Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: LiQing <325196192+qinxwew@users.noreply.github.com>
1 parent 1f60f13 commit 8a0a851

2 files changed

Lines changed: 80 additions & 2 deletions

File tree

monai/metrics/confusion_matrix.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ class ConfusionMatrixMetric(CumulativeIterationMetric):
4343
``"miss rate"``, ``"fall out"``, ``"false discovery rate"``, ``"false omission rate"``,
4444
``"prevalence threshold"``, ``"threat score"``, ``"accuracy"``, ``"balanced accuracy"``,
4545
``"f1 score"``, ``"matthews correlation coefficient"``, ``"fowlkes mallows index"``,
46-
``"informedness"``, ``"markedness"``]
46+
``"informedness"``, ``"markedness"``, ``"positive likelihood ratio"``,
47+
``"negative likelihood ratio"``]
4748
Some of the metrics have multiple aliases (as shown in the wikipedia page aforementioned),
4849
and you can also input those names instead.
4950
Except for input only one metric, multiple metrics are also supported via input a sequence of metric names, such as
@@ -185,7 +186,8 @@ def compute_confusion_matrix_metric(metric_name: str, confusion_matrix: torch.Te
185186
``"miss rate"``, ``"fall out"``, ``"false discovery rate"``, ``"false omission rate"``,
186187
``"prevalence threshold"``, ``"threat score"``, ``"accuracy"``, ``"balanced accuracy"``,
187188
``"f1 score"``, ``"matthews correlation coefficient"``, ``"fowlkes mallows index"``,
188-
``"informedness"``, ``"markedness"``]
189+
``"informedness"``, ``"markedness"``, ``"positive likelihood ratio"``,
190+
``"negative likelihood ratio"``]
189191
Some of the metrics have multiple aliases (as shown in the wikipedia page aforementioned),
190192
and you can also input those names instead.
191193
confusion_matrix: Please see the doc string of the function ``get_confusion_matrix`` for more details.
@@ -263,6 +265,16 @@ def compute_confusion_matrix_metric(metric_name: str, confusion_matrix: torch.Te
263265
npv = torch.where((tn + fn) > 0, tn / (tn + fn), nan_tensor)
264266
numerator = ppv + npv - 1.0
265267
denominator = 1.0
268+
elif metric == "plr":
269+
# LR+ = sensitivity / (1 - specificity) = tpr / fpr; fpr == 0 yields NaN
270+
tpr = torch.where(p > 0, tp / p, nan_tensor)
271+
fpr = torch.where(n > 0, fp / n, nan_tensor)
272+
numerator, denominator = tpr, fpr
273+
elif metric == "nlr":
274+
# LR- = (1 - sensitivity) / specificity = fnr / tnr; tnr == 0 yields NaN
275+
fnr = torch.where(p > 0, fn / p, nan_tensor)
276+
tnr = torch.where(n > 0, tn / n, nan_tensor)
277+
numerator, denominator = fnr, tnr
266278
else:
267279
raise NotImplementedError("the metric is not implemented.")
268280

@@ -319,4 +331,8 @@ def check_confusion_matrix_metric_name(metric_name: str) -> str:
319331
return "bm"
320332
if metric_name in ["markedness", "deltap", "mk"]:
321333
return "mk"
334+
if metric_name in ["positive_likelihood_ratio", "plr", "lr+"]:
335+
return "plr"
336+
if metric_name in ["negative_likelihood_ratio", "nlr", "lr-"]:
337+
return "nlr"
322338
raise NotImplementedError("the metric is not implemented.")

tests/metrics/test_compute_confusion_matrix.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,26 @@
218218
torch.tensor([[[0.0, 0.0, 46137344.0, 0.0]]]),
219219
]
220220

221+
# 5. likelihood ratios: hand-computed LR+ / LR- values, including undefined cases
222+
# sample 0: tp=80, fp=20, tn=70, fn=20 -> tpr=0.8, fpr=2/9 -> LR+=3.6; fnr=0.2, tnr=7/9 -> LR-=0.25714...
223+
# sample 1: tp=10, fp=0, tn=90, fn=0 -> fpr=0 -> LR+ is NaN; fnr=0 -> LR-=0.0
224+
# sample 2: tp=5, fp=5, tn=0, fn=0 -> tpr=1, fpr=1 -> LR+=1; tnr=0 -> LR- is NaN (zero specificity denominator)
225+
# (row order is [tp, fp, tn, fn], matching compute_confusion_matrix output)
226+
TEST_CASE_LR = [torch.tensor([[80.0, 20.0, 70.0, 20.0], [10.0, 0.0, 90.0, 0.0], [5.0, 5.0, 0.0, 0.0]])]
227+
228+
# classification-style input, channel-wise hand-computed values, no undefined cases:
229+
# ch0: tp=2, fp=1, tn=1, fn=1 -> LR+=4/3, LR-=2/3; ch1: tp=1, fp=1, tn=2, fn=1 -> LR+=3/2, LR-=3/4
230+
TEST_CASE_LR_CLF = [
231+
{
232+
"y_pred": torch.tensor([[1, 0], [1, 0], [0, 1], [1, 0], [0, 1]]),
233+
"y": torch.tensor([[1, 0], [1, 0], [0, 1], [0, 1], [1, 0]]),
234+
"include_background": True,
235+
"metric_name": ["positive likelihood ratio", "negative likelihood ratio"],
236+
"reduction": "sum_batch",
237+
},
238+
[torch.tensor([4.0 / 3.0, 3.0 / 2.0]), torch.tensor([2.0 / 3.0, 3.0 / 4.0])],
239+
]
240+
221241

222242
class TestConfusionMatrix(unittest.TestCase):
223243
@parameterized.expand([TEST_CASE_CONFUSION_MATRIX])
@@ -289,6 +309,48 @@ def test_precision(self, input_data, expected_value):
289309
assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4)
290310
np.testing.assert_equal(result.device, input_data["y_pred"].device)
291311

312+
@parameterized.expand([TEST_CASE_LR])
313+
def test_likelihood_ratios(self, confusion_matrix):
314+
"""Check likelihood-ratio aliases and edge cases on a per-class confusion matrix.
315+
316+
Args:
317+
confusion_matrix: a stacked [2, 4] confusion-matrix tensor, each row in
318+
``[tp, fp, tn, fn]`` order as produced by ``compute_confusion_matrix``.
319+
"""
320+
# every advertised spelling must resolve to the same result (case/space-insensitive)
321+
for alias in ("lr+", "plr", "Positive Likelihood Ratio", "POSITIVE_LIKELIHOOD_RATIO"):
322+
plr = compute_confusion_matrix_metric(alias, confusion_matrix)
323+
assert_allclose(plr[0], torch.tensor(3.6), atol=1e-4, rtol=1e-4)
324+
self.assertTrue(torch.isnan(plr[1]))
325+
assert_allclose(plr[2], torch.tensor(1.0), atol=1e-4, rtol=1e-4)
326+
for alias in ("lr-", "nlr", "Negative Likelihood Ratio", "NEGATIVE_LIKELIHOOD_RATIO"):
327+
nlr = compute_confusion_matrix_metric(alias, confusion_matrix)
328+
assert_allclose(nlr[0], torch.tensor(0.2 / (70.0 / 90.0)), atol=1e-4, rtol=1e-4)
329+
assert_allclose(nlr[1], torch.tensor(0.0), atol=1e-4, rtol=1e-4)
330+
# sample 2 has tn == 0 (zero specificity), so LR- is undefined -> NaN
331+
self.assertTrue(torch.isnan(nlr[2]))
332+
333+
@parameterized.expand([TEST_CASE_LR_CLF])
334+
def test_likelihood_ratios_clf(self, input_data, expected_values):
335+
"""Check likelihood ratios through the ``ConfusionMatrixMetric`` classification API.
336+
337+
Args:
338+
input_data: keyword arguments for ``ConfusionMatrixMetric`` plus ``y_pred``/``y``
339+
to feed the metric.
340+
expected_values: expected per-channel LR+ / LR- values after aggregation.
341+
"""
342+
params = input_data.copy()
343+
vals = {}
344+
vals["y_pred"] = params.pop("y_pred")
345+
vals["y"] = params.pop("y")
346+
metric = ConfusionMatrixMetric(**params)
347+
metric(**vals)
348+
results = metric.aggregate()
349+
# one aggregated channel per requested metric, in the same order as expected_values
350+
self.assertEqual(len(results), len(expected_values))
351+
for result, expected_value in zip(results, expected_values):
352+
assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4)
353+
292354

293355
if __name__ == "__main__":
294356
unittest.main()

0 commit comments

Comments
 (0)