Skip to content

Commit bde3e35

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 bde3e35

2 files changed

Lines changed: 71 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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,24 @@
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+
TEST_CASE_LR = [torch.tensor([[80.0, 20.0, 70.0, 20.0], [10.0, 0.0, 90.0, 0.0]])]
225+
226+
# classification-style input, channel-wise hand-computed values, no undefined cases:
227+
# 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
228+
TEST_CASE_LR_CLF = [
229+
{
230+
"y_pred": torch.tensor([[1, 0], [1, 0], [0, 1], [1, 0], [0, 1]]),
231+
"y": torch.tensor([[1, 0], [1, 0], [0, 1], [0, 1], [1, 0]]),
232+
"include_background": True,
233+
"metric_name": ["positive likelihood ratio", "negative likelihood ratio"],
234+
"reduction": "sum_batch",
235+
},
236+
[torch.tensor([4.0 / 3.0, 3.0 / 2.0]), torch.tensor([2.0 / 3.0, 3.0 / 4.0])],
237+
]
238+
221239

222240
class TestConfusionMatrix(unittest.TestCase):
223241
@parameterized.expand([TEST_CASE_CONFUSION_MATRIX])
@@ -289,6 +307,41 @@ def test_precision(self, input_data, expected_value):
289307
assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4)
290308
np.testing.assert_equal(result.device, input_data["y_pred"].device)
291309

310+
@parameterized.expand([TEST_CASE_LR])
311+
def test_likelihood_ratios(self, confusion_matrix):
312+
"""Check likelihood-ratio aliases and edge cases on a per-class confusion matrix.
313+
314+
Args:
315+
confusion_matrix: a stacked [2, 4] confusion-matrix tensor built by
316+
``compute_confusion_matrix`` for the two test classes.
317+
"""
318+
plr = compute_confusion_matrix_metric("lr+", confusion_matrix)
319+
nlr = compute_confusion_matrix_metric("lr-", confusion_matrix)
320+
assert_allclose(plr[0], torch.tensor(3.6), atol=1e-4, rtol=1e-4)
321+
assert_allclose(nlr[0], torch.tensor(0.2 / (70.0 / 90.0)), atol=1e-4, rtol=1e-4)
322+
# fpr == 0 makes LR+ undefined (NaN by convention); fnr == 0 makes LR- equal to 0
323+
self.assertTrue(torch.isnan(plr[1]))
324+
assert_allclose(nlr[1], torch.tensor(0.0), atol=1e-4, rtol=1e-4)
325+
326+
@parameterized.expand([TEST_CASE_LR_CLF])
327+
def test_likelihood_ratios_clf(self, input_data, expected_values):
328+
"""Check likelihood ratios through the ``ConfusionMatrixMetric`` classification API.
329+
330+
Args:
331+
input_data: keyword arguments for ``ConfusionMatrixMetric`` plus ``y_pred``/``y``
332+
to feed the metric.
333+
expected_values: expected per-channel LR+ / LR- values after aggregation.
334+
"""
335+
params = input_data.copy()
336+
vals = {}
337+
vals["y_pred"] = params.pop("y_pred")
338+
vals["y"] = params.pop("y")
339+
metric = ConfusionMatrixMetric(**params)
340+
metric(**vals)
341+
results = metric.aggregate()
342+
for result, expected_value in zip(results, expected_values):
343+
assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4)
344+
292345

293346
if __name__ == "__main__":
294347
unittest.main()

0 commit comments

Comments
 (0)