Spaces:
Runtime error
Runtime error
| # 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() | |
| 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 | |
| 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 | |