sentinel / tests /test_risk_models /test_pcpt_model.py
jeuko's picture
Sync from GitHub (main)
8018595 verified
# pylint: disable=missing-docstring
"""Basic tests for the PCPT prostate cancer risk model.
Web calculator available at: https://riskcalc.org/PCPTRC/
"""
import pytest
from sentinel.risk_models import PCPTRiskModel
from sentinel.user_input import (
Anthropometrics,
CancerType,
ClinicalTests,
Demographics,
DREResult,
DRETest,
Ethnicity,
FamilyMemberCancer,
FamilyRelation,
FamilySide,
Lifestyle,
PCA3Test,
PercentFreePSATest,
PersonalMedicalHistory,
PSATest,
RelationshipDegree,
Sex,
SmokingHistory,
SmokingStatus,
T2ERGTest,
UserInput,
)
# Ground-truth regression fixtures collected from the official PCPT web calculator.
GROUND_TRUTH_CASES = [
{
"name": "low_risk",
"input": UserInput(
demographics=Demographics(
age_years=70,
sex=Sex.MALE,
ethnicity=Ethnicity.WHITE,
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
),
lifestyle=Lifestyle(
smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
),
personal_medical_history=PersonalMedicalHistory(
prior_negative_prostate_biopsy=False,
),
family_history=[],
clinical_tests=ClinicalTests(
psa=PSATest(value_ng_ml=10.0),
dre=DRETest(result=DREResult.NORMAL),
),
),
"expected_high_grade": 15.0,
"expected_low_grade": 23.0,
"expected_no_cancer": 62.0,
},
{
"name": "medium_high_risk",
"input": UserInput(
demographics=Demographics(
age_years=80,
sex=Sex.MALE,
ethnicity=Ethnicity.WHITE,
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
),
lifestyle=Lifestyle(
smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
),
personal_medical_history=PersonalMedicalHistory(
prior_negative_prostate_biopsy=False,
),
family_history=[
FamilyMemberCancer(
relation=FamilyRelation.FATHER,
side=FamilySide.PATERNAL,
degree=RelationshipDegree.FIRST,
cancer_type=CancerType.PROSTATE,
age_at_diagnosis=70,
)
],
clinical_tests=ClinicalTests(
psa=PSATest(value_ng_ml=25.0),
),
),
"expected_high_grade": 42.0,
"expected_low_grade": 26.0,
"expected_no_cancer": 32.0,
},
{
"name": "high_risk",
"input": UserInput(
demographics=Demographics(
age_years=65,
sex=Sex.MALE,
ethnicity=Ethnicity.BLACK,
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
),
lifestyle=Lifestyle(
smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
),
personal_medical_history=PersonalMedicalHistory(
prior_negative_prostate_biopsy=False,
),
family_history=[],
clinical_tests=ClinicalTests(
psa=PSATest(value_ng_ml=36.0),
dre=DRETest(result=DREResult.ABNORMAL),
),
),
"expected_high_grade": 66.0,
"expected_low_grade": 13.0,
"expected_no_cancer": 21.0,
},
]
class TestPCPTRiskModel:
def setup_method(self) -> None:
self.model = PCPTRiskModel()
def test_metadata(self) -> None:
assert self.model.name == "pcpt"
assert self.model.cancer_type() == "prostate"
assert "PCPT" in self.model.description()
assert "percent" in self.model.interpretation().lower()
assert len(self.model.references()) > 0
def test_absolute_risk_basic(self) -> None:
user = UserInput(
demographics=Demographics(
age_years=60,
sex=Sex.MALE,
ethnicity=Ethnicity.WHITE,
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
),
lifestyle=Lifestyle(
smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
),
personal_medical_history=PersonalMedicalHistory(
prior_negative_prostate_biopsy=False,
),
family_history=[],
clinical_tests=ClinicalTests(
psa=PSATest(value_ng_ml=2.5),
dre=DRETest(result=DREResult.NORMAL),
),
)
risks = self.model.absolute_risk(user)
assert risks["no_cancer"] > 0
assert risks["low_grade"] > 0
assert risks["high_grade"] > 0
total = risks["no_cancer"] + risks["low_grade"] + risks["high_grade"]
assert 99.5 <= total <= 100.5
@pytest.mark.parametrize("case", GROUND_TRUTH_CASES, ids=lambda case: case["name"])
def test_ground_truth_cases(self, case) -> None:
risks = self.model.absolute_risk(case["input"])
assert risks["high_grade"] == pytest.approx(
case["expected_high_grade"], abs=2.0
)
assert risks["low_grade"] == pytest.approx(case["expected_low_grade"], abs=2.0)
assert risks["no_cancer"] == pytest.approx(case["expected_no_cancer"], abs=2.0)
def test_compute_score_with_male_user_input(self) -> None:
user = UserInput(
demographics=Demographics(
age_years=60,
sex=Sex.MALE,
ethnicity=Ethnicity.WHITE,
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
),
lifestyle=Lifestyle(
smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
),
personal_medical_history=PersonalMedicalHistory(
prior_negative_prostate_biopsy=False,
),
family_history=[],
clinical_tests=ClinicalTests(
psa=PSATest(value_ng_ml=2.5),
percent_free_psa=PercentFreePSATest(value_percent=18.0),
dre=DRETest(result=DREResult.NORMAL),
pca3=PCA3Test(score=25.0),
t2erg=T2ERGTest(score=10.0),
),
)
score = self.model.compute_score(user)
assert "No Cancer" in score
assert "Low Grade" in score
assert "High Grade" in score
def test_compute_score_rejects_female_user(self) -> None:
user = UserInput(
demographics=Demographics(
age_years=60,
sex=Sex.FEMALE,
ethnicity=Ethnicity.WHITE,
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
),
lifestyle=Lifestyle(
smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
),
personal_medical_history=PersonalMedicalHistory(),
family_history=[],
clinical_tests=ClinicalTests(
psa=PSATest(value_ng_ml=2.5),
),
)
score = self.model.compute_score(user)
assert score == "N/A: PCPT applies to male patients only."
def test_validation_errors(self) -> None:
"""Test validation errors for missing required fields."""
user = UserInput(
demographics=Demographics(
age_years=60,
sex=Sex.MALE,
ethnicity=Ethnicity.WHITE,
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
),
lifestyle=Lifestyle(
smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
),
personal_medical_history=PersonalMedicalHistory(),
family_history=[],
clinical_tests=ClinicalTests(), # Missing PSA
)
with pytest.raises(ValueError, match=r"Invalid inputs for PCPT:"):
self.model.compute_score(user)
def test_age_out_of_range(self) -> None:
"""Test age outside validated range raises ValueError."""
user = UserInput(
demographics=Demographics(
age_years=50, # Below minimum
sex=Sex.MALE,
ethnicity=Ethnicity.WHITE,
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
),
lifestyle=Lifestyle(
smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
),
personal_medical_history=PersonalMedicalHistory(),
family_history=[],
clinical_tests=ClinicalTests(
psa=PSATest(value_ng_ml=2.5),
),
)
with pytest.raises(ValueError, match=r"Invalid inputs for PCPT:"):
self.model.compute_score(user)