Skip to content

Commit 34ba357

Browse files
committed
Add odds ratio classification function and corresponding tests
1 parent a216960 commit 34ba357

File tree

2 files changed

+198
-0
lines changed

2 files changed

+198
-0
lines changed

src/mavedb/lib/oddspaths.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from typing import Optional
2+
3+
from mavedb.lib.acmg import StrengthOfEvidenceProvided, ACMGCriterion
4+
5+
6+
def oddspaths_evidence_strength_equivalent(
7+
ratio: float,
8+
) -> tuple[Optional[ACMGCriterion], Optional[StrengthOfEvidenceProvided]]:
9+
"""
10+
Based on the guidelines laid out in Table 3 of:
11+
Brnich, S.E., Abou Tayoun, A.N., Couch, F.J. et al. Recommendations for application
12+
of the functional evidence PS3/BS3 criterion using the ACMG/AMP sequence variant
13+
interpretation framework. Genome Med 12, 3 (2020).
14+
https://doi.org/10.1186/s13073-019-0690-2
15+
16+
Classify an odds (likelihood) ratio into a ACMGCriterion and StrengthOfEvidenceProvided.
17+
18+
This function infers the ACMG/AMP-style evidence strength category from a
19+
precomputed odds (likelihood) ratio by applying a series of descending
20+
threshold comparisons. The mapping is asymmetric: higher ratios favor
21+
pathogenic (PS3*) evidence levels; lower ratios favor benign (BS3*) evidence
22+
levels; an intermediate band is considered indeterminate.
23+
24+
Threshold logic (first condition matched is returned):
25+
ratio > 350 -> (PS3, VERY_STRONG)
26+
ratio > 18.6 -> (PS3, STRONG)
27+
ratio > 4.3 -> (PS3, MODERATE)
28+
ratio > 2.1 -> (PS3, SUPPORTING)
29+
ratio >= 0.48 -> Indeterminate (None, None)
30+
ratio >= 0.23 -> (BS3, SUPPORTING)
31+
ratio >= 0.053 -> (BS3, MODERATE)
32+
ratio < 0.053 -> (BS3, STRONG)
33+
34+
Interval semantics:
35+
- Upper (pathogenic) tiers use strictly greater-than (>) comparisons.
36+
- Lower (benign) tiers and the indeterminate band use inclusive lower
37+
bounds (>=) to form closed intervals extending downward until a prior
38+
condition matches.
39+
- Because of the ordering, each numeric ratio falls into exactly one tier.
40+
41+
Parameters
42+
----------
43+
ratio : float
44+
The odds or likelihood ratio to classify. Must be a positive value in
45+
typical use. Values <= 0 are not biologically meaningful in this context
46+
and will be treated as < 0.053, yielding a benign-leaning classification.
47+
48+
Returns
49+
-------
50+
tuple[Optional[ACMGCriterion], Optional[StrengthOfEvidenceProvided]]
51+
The enumerated evidence strength and criterion corresponding to the ratio.
52+
53+
Raises
54+
------
55+
TypeError
56+
If ratio is not a real (float/int) number (depending on external validation;
57+
this function assumes a float input and does not explicitly check type).
58+
ValueError
59+
If the ratio is negative (less than 0).
60+
61+
Examples
62+
--------
63+
>>> inferred_evidence_strength_from_ratio(500.0)
64+
(ACMGCriterion.PS3, StrengthOfEvidenceProvided.VERY_STRONG)
65+
>>> inferred_evidence_strength_from_ratio(10.0)
66+
(ACMGCriterion.PS3, StrengthOfEvidenceProvided.MODERATE)
67+
>>> inferred_evidence_strength_from_ratio(0.30)
68+
(ACMGCriterion.BS3, StrengthOfEvidenceProvided.SUPPORTING)
69+
>>> inferred_evidence_strength_from_ratio(0.06)
70+
(ACMGCriterion.BS3, StrengthOfEvidenceProvided.MODERATE)
71+
>>> inferred_evidence_strength_from_ratio(0.5)
72+
(None, None)
73+
74+
Notes
75+
-----
76+
These thresholds reflect predefined likelihood ratio cut points aligning with
77+
qualitative evidence strength categories. Adjust carefully if underlying
78+
classification criteria change, ensuring ordering and exclusivity are preserved.
79+
"""
80+
if ratio < 0:
81+
raise ValueError("OddsPaths ratio must be a non-negative value")
82+
83+
if ratio > 350:
84+
return (ACMGCriterion.PS3, StrengthOfEvidenceProvided.VERY_STRONG)
85+
elif ratio > 18.6:
86+
return (ACMGCriterion.PS3, StrengthOfEvidenceProvided.STRONG)
87+
elif ratio > 4.3:
88+
return (ACMGCriterion.PS3, StrengthOfEvidenceProvided.MODERATE)
89+
elif ratio > 2.1:
90+
return (ACMGCriterion.PS3, StrengthOfEvidenceProvided.SUPPORTING)
91+
elif ratio >= 0.48:
92+
return (None, None)
93+
elif ratio >= 0.23:
94+
return (ACMGCriterion.BS3, StrengthOfEvidenceProvided.SUPPORTING)
95+
elif ratio >= 0.053:
96+
return (ACMGCriterion.BS3, StrengthOfEvidenceProvided.MODERATE)
97+
else: # ratio < 0.053
98+
return (ACMGCriterion.BS3, StrengthOfEvidenceProvided.STRONG)

tests/lib/test_odds_paths.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import pytest
2+
3+
from mavedb.lib.acmg import ACMGCriterion, StrengthOfEvidenceProvided
4+
from mavedb.lib.oddspaths import oddspaths_evidence_strength_equivalent
5+
6+
7+
@pytest.mark.parametrize(
8+
"ratio,expected_criterion,expected_strength",
9+
[
10+
# Upper pathogenic tiers (strict >)
11+
(351, ACMGCriterion.PS3, StrengthOfEvidenceProvided.VERY_STRONG),
12+
(350.0001, ACMGCriterion.PS3, StrengthOfEvidenceProvided.VERY_STRONG),
13+
(350, ACMGCriterion.PS3, StrengthOfEvidenceProvided.STRONG), # boundary
14+
(19, ACMGCriterion.PS3, StrengthOfEvidenceProvided.STRONG),
15+
(18.60001, ACMGCriterion.PS3, StrengthOfEvidenceProvided.STRONG),
16+
(18.6, ACMGCriterion.PS3, StrengthOfEvidenceProvided.MODERATE), # boundary
17+
(5, ACMGCriterion.PS3, StrengthOfEvidenceProvided.MODERATE),
18+
(4.30001, ACMGCriterion.PS3, StrengthOfEvidenceProvided.MODERATE),
19+
(4.3, ACMGCriterion.PS3, StrengthOfEvidenceProvided.SUPPORTING), # boundary
20+
(2.10001, ACMGCriterion.PS3, StrengthOfEvidenceProvided.SUPPORTING),
21+
# Indeterminate band
22+
(2.1, None, None), # boundary just below >2.1
23+
(0.48, None, None),
24+
(0.50001, None, None),
25+
# Benign supporting
26+
(0.479999, ACMGCriterion.BS3, StrengthOfEvidenceProvided.SUPPORTING),
27+
(0.23, ACMGCriterion.BS3, StrengthOfEvidenceProvided.SUPPORTING),
28+
# Benign moderate
29+
(0.229999, ACMGCriterion.BS3, StrengthOfEvidenceProvided.MODERATE),
30+
(0.053, ACMGCriterion.BS3, StrengthOfEvidenceProvided.MODERATE),
31+
# Benign strong
32+
(0.052999, ACMGCriterion.BS3, StrengthOfEvidenceProvided.STRONG),
33+
(0.01, ACMGCriterion.BS3, StrengthOfEvidenceProvided.STRONG),
34+
(0.0, ACMGCriterion.BS3, StrengthOfEvidenceProvided.STRONG),
35+
# Very high ratio
36+
(1000, ACMGCriterion.PS3, StrengthOfEvidenceProvided.VERY_STRONG),
37+
],
38+
)
39+
def test_oddspaths_classification(ratio, expected_criterion, expected_strength):
40+
criterion, strength = oddspaths_evidence_strength_equivalent(ratio)
41+
assert criterion == expected_criterion
42+
assert strength == expected_strength
43+
44+
45+
@pytest.mark.parametrize("neg_ratio", [-1e-9, -0.01, -5])
46+
def test_negative_ratio_raises_value_error(neg_ratio):
47+
with pytest.raises(ValueError):
48+
oddspaths_evidence_strength_equivalent(neg_ratio)
49+
50+
51+
def test_each_interval_is_exclusive():
52+
# Sorted representative ratios spanning all tiers
53+
samples = [
54+
(0.0, 0.0529999), # BS3 STRONG
55+
(0.053, 0.229999), # BS3 MODERATE
56+
(0.23, 0.479999), # BS3 SUPPORTING
57+
(0.48, 2.1), # Indeterminate
58+
(2.10001, 4.3), # PS3 SUPPORTING
59+
(4.30001, 18.6), # PS3 MODERATE
60+
(18.60001, 350), # PS3 STRONG
61+
(350.0001, float("inf")), # PS3 VERY_STRONG (no upper bound)
62+
]
63+
seen = set()
64+
for r in samples:
65+
lower_result = oddspaths_evidence_strength_equivalent(r[0])
66+
upper_result = oddspaths_evidence_strength_equivalent(r[1])
67+
assert lower_result == upper_result, f"Mismatch at interval {r}"
68+
69+
assert all(
70+
result not in seen for result in [lower_result, upper_result]
71+
), f"Duplicate classification for ratio {r}"
72+
seen.add(lower_result)
73+
74+
75+
@pytest.mark.parametrize(
76+
"lower,upper",
77+
[
78+
(0.053, 0.23), # BS3 MODERATE -> BS3 SUPPORTING transition
79+
(0.23, 0.48), # BS3 SUPPORTING -> Indeterminate
80+
(0.48, 2.1), # Indeterminate band
81+
(2.1, 4.3), # Indeterminate -> PS3 SUPPORTING
82+
(4.3, 18.6), # PS3 SUPPORTING -> PS3 MODERATE
83+
(18.6, 350), # PS3 MODERATE -> PS3 STRONG
84+
(350, 351), # PS3 STRONG -> PS3 VERY_STRONG
85+
],
86+
)
87+
def test_monotonic_direction(lower, upper):
88+
crit_low, strength_low = oddspaths_evidence_strength_equivalent(lower)
89+
crit_high, strength_high = oddspaths_evidence_strength_equivalent(upper)
90+
# If categories differ, ensure ordering progression (not regression to benign when moving upward)
91+
benign_set = {ACMGCriterion.BS3}
92+
pathogenic_set = {ACMGCriterion.PS3}
93+
if crit_low != crit_high:
94+
# Moving upward should not go from pathogenic to benign
95+
assert not (crit_low in pathogenic_set and crit_high in benign_set)
96+
97+
98+
def test_return_types():
99+
c, s = oddspaths_evidence_strength_equivalent(0.7)
100+
assert (c is None and s is None) or (isinstance(c, ACMGCriterion) and isinstance(s, StrengthOfEvidenceProvided))

0 commit comments

Comments
 (0)