sentinel / tests /test_risk_models /test_crc_pro_model.py
jeuko's picture
Sync from GitHub (main)
8018595 verified
# pylint: disable=missing-docstring
"""Tests for the CRC-PRO colorectal cancer risk model.
Web calculator available at: https://riskcalc.org/ColorectalCancer/
"""
import pytest
from sentinel.risk_models import CRCProRiskModel
from sentinel.user_input import (
AlcoholConsumption,
Anthropometrics,
AspirinUse,
CancerType,
Demographics,
Ethnicity,
FamilyMemberCancer,
FamilyRelation,
FamilySide,
FemaleSpecific,
HormoneUse,
HormoneUseHistory,
Lifestyle,
NSAIDUse,
PersonalMedicalHistory,
RelationshipDegree,
Sex,
SmokingHistory,
SmokingStatus,
UserInput,
)
GROUND_TRUTH_CASES = [
{
"name": "low_risk",
"input": UserInput(
demographics=Demographics(
age_years=55,
sex=Sex.FEMALE,
ethnicity=Ethnicity.ASIAN,
anthropometrics=Anthropometrics(height_cm=152.4, weight_kg=45.4),
education_level=3,
),
lifestyle=Lifestyle(
smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
alcohol_consumption=AlcoholConsumption.MODERATE,
multivitamin_use=True,
),
personal_medical_history=PersonalMedicalHistory(
chronic_conditions=[],
nsaid_use=NSAIDUse.NEVER,
),
family_history=[],
female_specific=FemaleSpecific(
hormone_use=HormoneUseHistory(estrogen_use=HormoneUse.NEVER),
),
),
"expected": 1.0,
},
{
"name": "medium_risk",
"input": UserInput(
demographics=Demographics(
age_years=65,
sex=Sex.MALE,
ethnicity=Ethnicity.PACIFIC_ISLANDER,
anthropometrics=Anthropometrics(height_cm=177.8, weight_kg=81.6),
education_level=1,
),
lifestyle=Lifestyle(
smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
alcohol_consumption=AlcoholConsumption.HEAVY,
multivitamin_use=True,
moderate_physical_activity_hours_per_day=0.0,
red_meat_consumption_oz_per_day=1.0,
),
personal_medical_history=PersonalMedicalHistory(
chronic_conditions=[],
aspirin_use=AspirinUse.NEVER,
),
family_history=[
FamilyMemberCancer(
relation=FamilyRelation.FATHER,
cancer_type=CancerType.COLORECTAL,
age_at_diagnosis=60,
degree=RelationshipDegree.FIRST,
side=FamilySide.PATERNAL,
)
],
),
"expected": 3.8,
},
{
"name": "high_risk",
"input": UserInput(
demographics=Demographics(
age_years=75,
sex=Sex.MALE,
ethnicity=Ethnicity.WHITE,
anthropometrics=Anthropometrics(height_cm=177.8, weight_kg=158.8),
education_level=5,
),
lifestyle=Lifestyle(
smoking=SmokingHistory(status=SmokingStatus.FORMER, pack_years=25.0),
alcohol_consumption=AlcoholConsumption.HEAVY,
multivitamin_use=True,
moderate_physical_activity_hours_per_day=0.0,
red_meat_consumption_oz_per_day=1.0,
),
personal_medical_history=PersonalMedicalHistory(
chronic_conditions=[],
aspirin_use=AspirinUse.NEVER,
),
family_history=[
FamilyMemberCancer(
relation=FamilyRelation.MOTHER,
cancer_type=CancerType.COLORECTAL,
age_at_diagnosis=70,
degree=RelationshipDegree.FIRST,
side=FamilySide.MATERNAL,
)
],
),
"expected": 8.9,
},
]
def _base_user(sex: Sex, **overrides):
demographics = Demographics(
age_years=50,
sex=sex,
ethnicity=Ethnicity.WHITE,
anthropometrics=Anthropometrics(height_cm=170.0, weight_kg=75.0),
education_level=4,
)
lifestyle = Lifestyle(
smoking=SmokingHistory(status=SmokingStatus.FORMER, pack_years=10.0),
alcohol_consumption=AlcoholConsumption.MODERATE,
multivitamin_use=True,
)
personal_history = PersonalMedicalHistory(chronic_conditions=[])
if sex == Sex.FEMALE:
lifestyle.moderate_physical_activity_hours_per_day = None
lifestyle.red_meat_consumption_oz_per_day = None
personal_history.aspirin_use = None
personal_history.nsaid_use = NSAIDUse.NEVER
female_specific = FemaleSpecific(
hormone_use=HormoneUseHistory(estrogen_use=HormoneUse.NEVER)
)
else:
lifestyle.moderate_physical_activity_hours_per_day = 1.0
lifestyle.red_meat_consumption_oz_per_day = 1.5
personal_history.aspirin_use = AspirinUse.NEVER
personal_history.nsaid_use = None
female_specific = None
family_history = overrides.get("family_history", [])
return UserInput(
demographics=demographics,
lifestyle=lifestyle,
personal_medical_history=personal_history,
family_history=family_history,
female_specific=female_specific,
)
class TestCRCProRiskModel:
def setup_method(self):
self.model = CRCProRiskModel()
@pytest.mark.parametrize("case", GROUND_TRUTH_CASES, ids=lambda case: case["name"])
def test_ground_truth_validation(self, case):
user = case["input"]
score = self.model.compute_score(user)
# scores return a string; ensure numeric and close to expected
calculated = float(score)
assert calculated == pytest.approx(case["expected"], abs=0.5)
def test_metadata(self):
assert self.model.name == "crc_pro"
assert self.model.cancer_type() == "colorectal"
assert "CRC-PRO" in self.model.description()
assert "%" in self.model.interpretation()
refs = self.model.references()
assert isinstance(refs, list) and refs
def test_validation_errors(self):
"""Test that model raises ValueError for invalid inputs."""
# Test missing required field
user_input = UserInput(
demographics=Demographics(
age_years=50,
sex=Sex.FEMALE,
ethnicity=Ethnicity.WHITE,
anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0),
),
lifestyle=Lifestyle(
smoking=SmokingHistory(
status=SmokingStatus.NEVER, pack_years=None
), # Missing pack_years
multivitamin_use=True,
),
personal_medical_history=PersonalMedicalHistory(
nsaid_use=NSAIDUse.NEVER,
),
family_history=[],
female_specific=FemaleSpecific(
hormone_use=HormoneUseHistory(estrogen_use=HormoneUse.NEVER),
),
)
with pytest.raises(ValueError, match=r"Invalid inputs for CRC-PRO:"):
self.model.compute_score(user_input)
def test_inapplicable_sex(self):
"""Test unsupported sex returns N/A."""
user_input = UserInput(
demographics=Demographics(
age_years=50,
sex=Sex.UNKNOWN, # Unsupported sex
ethnicity=Ethnicity.WHITE,
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0),
),
lifestyle=Lifestyle(
smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
multivitamin_use=True,
),
personal_medical_history=PersonalMedicalHistory(),
family_history=[],
)
score = self.model.compute_score(user_input)
assert "N/A" in score
def test_age_out_of_range(self):
"""Test age outside validated range raises ValueError."""
user = _base_user(Sex.MALE)
user.demographics.age_years = 44 # Below minimum
with pytest.raises(ValueError, match=r"Invalid inputs for CRC-PRO:"):
self.model.compute_score(user)
def test_missing_ethnicity(self):
"""Test missing ethnicity returns N/A."""
user = _base_user(Sex.MALE)
user.demographics.ethnicity = None
result = self.model.compute_score(user)
assert "Ethnicity" in result
@pytest.mark.parametrize("sex", [Sex.MALE, Sex.FEMALE])
def test_valid_score(self, sex):
"""Test that valid inputs produce numeric scores.
Args:
sex: Sex enum value to test.
"""
user = _base_user(sex)
score = self.model.compute_score(user)
assert score not in ("N/A: Missing required data:")
assert float(score) >= 0
def test_family_history_detection(self):
"""Test that family history increases risk score."""
user = _base_user(
Sex.FEMALE,
family_history=[
FamilyMemberCancer(
relation=FamilyRelation.MOTHER,
cancer_type=CancerType.COLORECTAL,
age_at_diagnosis=60,
degree=RelationshipDegree.FIRST,
side=FamilySide.MATERNAL,
),
],
)
score = float(self.model.compute_score(user))
user_without_family_history = _base_user(Sex.FEMALE)
score_no_family = float(self.model.compute_score(user_without_family_history))
assert score > score_no_family