Spaces:
Runtime error
Runtime error
| """Tests for the Tyrer-Cuzick (IBIS) breast cancer risk model. | |
| This test suite validates the Tyrer-Cuzick model implementation against reference values | |
| from the IBIS web calculator. Test cases cover various scenarios including: | |
| - Different personal risk factor combinations | |
| - Various family history patterns | |
| - Edge cases and boundary conditions | |
| Test cases should be populated with ground truth values from the IBIS web calculator: | |
| https://www.ems-trials.org/riskevaluator/ | |
| """ | |
| import pytest | |
| from sentinel.risk_models.tyrer_cuzick import ( | |
| BRCA1_CUMULATIVE_RISK_BY_AGE, | |
| BRCA2_CUMULATIVE_RISK_BY_AGE, | |
| TyrerCuzickRiskModel, | |
| build_brca_survivor, | |
| build_population_survivor, | |
| build_s0_survivor, | |
| compute_personal_relative_risk, | |
| relative_risk_bmi_post_menopausal, | |
| relative_risk_first_birth, | |
| relative_risk_height, | |
| relative_risk_menarche, | |
| ) | |
| from sentinel.user_input import ( | |
| Anthropometrics, | |
| BreastHealthHistory, | |
| CancerType, | |
| Demographics, | |
| FamilyMemberCancer, | |
| FamilyRelation, | |
| FamilySide, | |
| FemaleSpecific, | |
| Lifestyle, | |
| MenstrualHistory, | |
| ParityHistory, | |
| PersonalMedicalHistory, | |
| RelationshipDegree, | |
| Sex, | |
| SmokingHistory, | |
| SmokingStatus, | |
| UserInput, | |
| ) | |
| def create_test_user( | |
| age: int = 40, | |
| menarche_age: int | None = 13, | |
| has_given_birth: bool = False, | |
| age_first_birth: int | None = None, | |
| num_live_births: int | None = None, | |
| is_postmenopausal: bool = False, | |
| menopause_age: int | None = None, | |
| height_m: float | None = None, | |
| bmi: float | None = None, | |
| atypical_hyperplasia: bool = False, | |
| lcis: bool = False, | |
| polygenic_relative_risk: float | None = None, | |
| family_history: list[FamilyMemberCancer] | None = None, | |
| ) -> UserInput: | |
| """Helper to create UserInput for testing. | |
| Args: | |
| age: Patient age. | |
| menarche_age: Age at menarche. | |
| has_given_birth: Whether patient has given birth. | |
| age_first_birth: Age at first birth. | |
| num_live_births: Number of live births. | |
| is_postmenopausal: Whether patient is postmenopausal. | |
| menopause_age: Age at menopause. | |
| height_m: Height in meters. | |
| bmi: Body mass index. | |
| atypical_hyperplasia: Whether patient has atypical hyperplasia. | |
| lcis: Whether patient has LCIS. | |
| polygenic_relative_risk: Polygenic relative risk multiplier. | |
| family_history: List of family cancer history. | |
| Returns: | |
| UserInput: Configured user input for testing. | |
| """ | |
| # Calculate height and weight from height_m and bmi if provided | |
| height_cm = height_m * 100 if height_m is not None else 165.0 | |
| weight_kg = ( | |
| bmi * (height_m**2) if bmi is not None and height_m is not None else 70.0 | |
| ) | |
| return UserInput( | |
| demographics=Demographics( | |
| age_years=age, | |
| sex=Sex.FEMALE, | |
| anthropometrics=Anthropometrics( | |
| height_cm=height_cm, | |
| weight_kg=weight_kg, | |
| ), | |
| ), | |
| female_specific=FemaleSpecific( | |
| menstrual=MenstrualHistory( | |
| age_at_menarche=menarche_age, | |
| age_at_menopause=menopause_age if is_postmenopausal else None, | |
| ), | |
| parity=ParityHistory( | |
| num_live_births=num_live_births or (1 if has_given_birth else None), | |
| age_at_first_live_birth=age_first_birth, | |
| ), | |
| breast_health=BreastHealthHistory( | |
| atypical_hyperplasia=atypical_hyperplasia, | |
| lobular_carcinoma_in_situ=lcis, | |
| ), | |
| ), | |
| lifestyle=Lifestyle( | |
| smoking=SmokingHistory(status=SmokingStatus.NEVER), | |
| ), | |
| personal_medical_history=PersonalMedicalHistory( | |
| tyrer_cuzick_polygenic_risk_score=polygenic_relative_risk, | |
| ), | |
| family_history=family_history or [], | |
| ) | |
| class TestPersonalRiskFactors: | |
| """Test personal risk factor calculations.""" | |
| def test_relative_risk_menarche_baseline(self): | |
| """Test menarche RR at baseline age 13.""" | |
| assert relative_risk_menarche(13) == pytest.approx(1.0, rel=1e-6) | |
| def test_relative_risk_menarche_early(self): | |
| """Test menarche RR for early age (11).""" | |
| expected = 0.95 ** (11 - 13) | |
| assert relative_risk_menarche(11) == pytest.approx(expected, rel=1e-6) | |
| def test_relative_risk_menarche_late(self): | |
| """Test menarche RR for late age (15).""" | |
| expected = 0.95 ** (15 - 13) | |
| assert relative_risk_menarche(15) == pytest.approx(expected, rel=1e-6) | |
| def test_relative_risk_first_birth_nulliparous(self): | |
| """Test first birth RR for nulliparous women.""" | |
| assert relative_risk_first_birth("nulliparous", None) == pytest.approx(1.0) | |
| def test_relative_risk_first_birth_early(self): | |
| """Test first birth RR for age < 20.""" | |
| assert relative_risk_first_birth("parous", 18) == pytest.approx(0.67) | |
| def test_relative_risk_first_birth_20_24(self): | |
| """Test first birth RR for age 20-24.""" | |
| assert relative_risk_first_birth("parous", 22) == pytest.approx(0.74) | |
| def test_relative_risk_first_birth_25_29(self): | |
| """Test first birth RR for age 25-29.""" | |
| assert relative_risk_first_birth("parous", 27) == pytest.approx(0.88) | |
| def test_relative_risk_first_birth_30_plus(self): | |
| """Test first birth RR for age >= 30.""" | |
| assert relative_risk_first_birth("parous", 32) == pytest.approx(1.04) | |
| def test_relative_risk_height_low(self): | |
| """Test height RR for height < 1.60m.""" | |
| assert relative_risk_height(1.55) == pytest.approx(1.0) | |
| def test_relative_risk_height_medium(self): | |
| """Test height RR for height 1.60-1.70m.""" | |
| assert relative_risk_height(1.65) == pytest.approx(1.15) | |
| def test_relative_risk_height_high(self): | |
| """Test height RR for height >= 1.70m.""" | |
| assert relative_risk_height(1.75) == pytest.approx(1.24) | |
| def test_relative_risk_bmi_post_menopausal_low(self): | |
| """Test BMI RR for BMI < 21 (post-menopausal).""" | |
| assert relative_risk_bmi_post_menopausal(20, "post") == pytest.approx(1.0) | |
| def test_relative_risk_bmi_post_menopausal_21_23(self): | |
| """Test BMI RR for BMI 21-23 (post-menopausal).""" | |
| assert relative_risk_bmi_post_menopausal(22, "post") == pytest.approx(1.14) | |
| def test_relative_risk_bmi_post_menopausal_high(self): | |
| """Test BMI RR for BMI >= 27 (post-menopausal).""" | |
| assert relative_risk_bmi_post_menopausal(28, "post") == pytest.approx(1.32) | |
| def test_rr_bmi_premenopausal(self): | |
| """Test BMI RR for pre-menopausal (should be 1.0).""" | |
| assert relative_risk_bmi_post_menopausal(28, "pre") == pytest.approx(1.0) | |
| def test_compute_personal_relative_risk_baseline(self): | |
| """Test combined personal RR with baseline values.""" | |
| rr = compute_personal_relative_risk( | |
| menarche_age=13, | |
| has_given_birth=False, | |
| age_first_birth=None, | |
| menopausal_status="pre", | |
| menopause_age=None, | |
| height_m=None, | |
| bmi=None, | |
| atypical_hyperplasia=False, | |
| lcis=False, | |
| polygenic_relative_risk=None, | |
| ) | |
| assert rr > 0.0 | |
| class TestSurvivorFunctions: | |
| """Test survivor function computations.""" | |
| def test_build_population_survivor(self): | |
| """Test population survivor function construction.""" | |
| s_pop = build_population_survivor() | |
| assert len(s_pop) == 13 | |
| assert s_pop[0][2] == pytest.approx(1.0) | |
| for i in range(len(s_pop) - 1): | |
| assert s_pop[i][2] >= s_pop[i + 1][2] | |
| def test_build_brca1_survivor(self): | |
| """Test BRCA1 survivor function construction.""" | |
| s_brca1 = build_brca_survivor(BRCA1_CUMULATIVE_RISK_BY_AGE) | |
| assert len(s_brca1) > 0 | |
| assert s_brca1[0][2] <= 1.0 | |
| def test_build_brca2_survivor(self): | |
| """Test BRCA2 survivor function construction.""" | |
| s_brca2 = build_brca_survivor(BRCA2_CUMULATIVE_RISK_BY_AGE) | |
| assert len(s_brca2) > 0 | |
| assert s_brca2[0][2] <= 1.0 | |
| def test_build_s0_survivor(self): | |
| """Test S_0 (no BRCA, no LPG) survivor function construction.""" | |
| s_pop = build_population_survivor() | |
| s_brca1 = build_brca_survivor(BRCA1_CUMULATIVE_RISK_BY_AGE) | |
| s_brca2 = build_brca_survivor(BRCA2_CUMULATIVE_RISK_BY_AGE) | |
| s0 = build_s0_survivor(s_pop, s_brca1, s_brca2) | |
| assert len(s0) == len(s_pop) | |
| for _, _, survival in s0: | |
| assert 0.0 <= survival <= 1.0 | |
| class TestTyrerCuzickModel: | |
| """Test Tyrer-Cuzick model calculations.""" | |
| def test_model_initialization(self): | |
| """Test model initialization.""" | |
| model = TyrerCuzickRiskModel() | |
| assert model.name == "tyrer_cuzick" | |
| assert model.cancer_type() == "breast" | |
| def test_calculate_risk_baseline(self): | |
| """Test risk calculation with baseline inputs.""" | |
| model = TyrerCuzickRiskModel() | |
| user = create_test_user(age=45, menarche_age=13) | |
| result = model.calculate_risk(user, projection_years=10) | |
| assert "cumulative_risk" in result | |
| assert "interval_risks" in result | |
| assert "personal_relative_risk" in result | |
| assert "phenotype_probs" in result | |
| assert 0.0 <= result["cumulative_risk"] <= 1.0 | |
| assert sum(result["phenotype_probs"]) == pytest.approx(1.0, rel=1e-6) | |
| def test_calculate_risk_with_family_history(self): | |
| """Test risk calculation with family history.""" | |
| model = TyrerCuzickRiskModel() | |
| # Mother with breast cancer at age 45 | |
| family_history = [ | |
| FamilyMemberCancer( | |
| relation=FamilyRelation.MOTHER, | |
| cancer_type=CancerType.BREAST, | |
| age_at_diagnosis=45, | |
| degree=RelationshipDegree.FIRST, | |
| side=FamilySide.MATERNAL, | |
| ) | |
| ] | |
| user = create_test_user( | |
| age=40, | |
| menarche_age=12, | |
| has_given_birth=True, | |
| age_first_birth=28, | |
| family_history=family_history, | |
| ) | |
| result = model.calculate_risk(user, projection_years=10) | |
| assert 0.0 <= result["cumulative_risk"] <= 1.0 | |
| def test_calculate_risk_high_risk_factors(self): | |
| """Test risk calculation with multiple high-risk factors.""" | |
| model = TyrerCuzickRiskModel() | |
| user = create_test_user( | |
| age=50, | |
| menarche_age=11, | |
| has_given_birth=False, | |
| is_postmenopausal=True, | |
| menopause_age=55, | |
| height_m=1.75, | |
| bmi=28, | |
| atypical_hyperplasia=True, | |
| ) | |
| result = model.calculate_risk(user, projection_years=10) | |
| assert result["personal_relative_risk"] > 2.0 | |
| def test_polygenic_relative_risk_increases_risk(self): | |
| """Test that polygenic RR multiplies risk appropriately.""" | |
| model = TyrerCuzickRiskModel() | |
| user_baseline = create_test_user(age=50) | |
| result_baseline = model.calculate_risk(user_baseline, projection_years=10) | |
| user_high_prs = create_test_user(age=50, polygenic_relative_risk=2.0) | |
| result_high_prs = model.calculate_risk(user_high_prs, projection_years=10) | |
| user_low_prs = create_test_user(age=50, polygenic_relative_risk=0.5) | |
| result_low_prs = model.calculate_risk(user_low_prs, projection_years=10) | |
| assert result_high_prs["personal_relative_risk"] == pytest.approx( | |
| result_baseline["personal_relative_risk"] * 2.0 | |
| ) | |
| assert result_low_prs["personal_relative_risk"] == pytest.approx( | |
| result_baseline["personal_relative_risk"] * 0.5 | |
| ) | |
| assert result_high_prs["cumulative_risk"] > result_baseline["cumulative_risk"] | |
| assert result_low_prs["cumulative_risk"] < result_baseline["cumulative_risk"] | |
| def test_polygenic_relative_risk_with_other_factors(self): | |
| """Test that polygenic RR combines multiplicatively with other risk factors.""" | |
| model = TyrerCuzickRiskModel() | |
| user_personal = create_test_user( | |
| age=50, | |
| menarche_age=11, | |
| has_given_birth=True, | |
| age_first_birth=35, | |
| ) | |
| result_personal = model.calculate_risk(user_personal, projection_years=10) | |
| user_combined = create_test_user( | |
| age=50, | |
| menarche_age=11, | |
| has_given_birth=True, | |
| age_first_birth=35, | |
| polygenic_relative_risk=1.5, | |
| ) | |
| result_combined = model.calculate_risk(user_combined, projection_years=10) | |
| assert result_combined["personal_relative_risk"] == pytest.approx( | |
| result_personal["personal_relative_risk"] * 1.5 | |
| ) | |
| assert result_combined["cumulative_risk"] > result_personal["cumulative_risk"] | |
| def test_polygenic_relative_risk_boundary_values(self): | |
| """Test polygenic RR with boundary values.""" | |
| model = TyrerCuzickRiskModel() | |
| user_min = create_test_user(age=50, polygenic_relative_risk=0.1) | |
| result_min = model.calculate_risk(user_min, projection_years=10) | |
| assert result_min["cumulative_risk"] > 0 | |
| user_max = create_test_user(age=50, polygenic_relative_risk=10.0) | |
| result_max = model.calculate_risk(user_max, projection_years=10) | |
| assert result_max["cumulative_risk"] < 1.0 | |
| assert result_max["cumulative_risk"] > result_min["cumulative_risk"] * 5 | |
| def test_model_description(self): | |
| """Test model description methods.""" | |
| model = TyrerCuzickRiskModel() | |
| assert len(model.description()) > 0 | |
| assert len(model.interpretation()) > 0 | |
| assert len(model.references()) > 0 | |
| class TestReferenceCalculations: | |
| """Test cases from IBIS web calculator validated against https://ibis.ikonopedia.com/""" | |
| def test_reference_case_1_baseline_low_risk(self): | |
| """Baseline low risk with average factors and no family history.""" | |
| model = TyrerCuzickRiskModel() | |
| user = create_test_user( | |
| age=40, | |
| height_m=1.65, | |
| bmi=25.7, | |
| menarche_age=13, | |
| has_given_birth=True, | |
| age_first_birth=25, | |
| ) | |
| result = model.calculate_risk(user, projection_years=10) | |
| expected_10yr_risk = 0.015 | |
| assert result["cumulative_risk"] == pytest.approx(expected_10yr_risk, abs=0.001) | |
| def test_reference_case_2_high_personal_risk(self): | |
| """High personal risk with early menarche, nulliparous, and late menopause.""" | |
| model = TyrerCuzickRiskModel() | |
| user = create_test_user( | |
| age=60, | |
| height_m=1.75, | |
| bmi=27.8, | |
| menarche_age=11, | |
| is_postmenopausal=True, | |
| menopause_age=55, | |
| has_given_birth=False, | |
| ) | |
| result = model.calculate_risk(user, projection_years=10) | |
| expected_10yr_risk = 0.053 | |
| assert result["cumulative_risk"] == pytest.approx(expected_10yr_risk, abs=0.015) | |
| def test_reference_case_3_strong_family_history(self): | |
| """Family history with mother diagnosed at age 42.""" | |
| model = TyrerCuzickRiskModel() | |
| family_history = [ | |
| FamilyMemberCancer( | |
| relation=FamilyRelation.MOTHER, | |
| cancer_type=CancerType.BREAST, | |
| age_at_diagnosis=42, | |
| degree=RelationshipDegree.FIRST, | |
| side=FamilySide.MATERNAL, | |
| ), | |
| ] | |
| user = create_test_user( | |
| age=35, | |
| height_m=1.60, | |
| bmi=25.4, | |
| menarche_age=12, | |
| has_given_birth=False, | |
| family_history=family_history, | |
| ) | |
| result = model.calculate_risk(user, projection_years=10) | |
| expected_10yr_risk = 0.026 | |
| assert result["cumulative_risk"] == pytest.approx(expected_10yr_risk, abs=0.015) | |
| def test_reference_case_4_moderate_family_history(self): | |
| """Moderate family history with mother and maternal aunt.""" | |
| model = TyrerCuzickRiskModel() | |
| family_history = [ | |
| FamilyMemberCancer( | |
| relation=FamilyRelation.MOTHER, | |
| cancer_type=CancerType.BREAST, | |
| age_at_diagnosis=50, | |
| degree=RelationshipDegree.FIRST, | |
| side=FamilySide.MATERNAL, | |
| ), | |
| FamilyMemberCancer( | |
| relation=FamilyRelation.MATERNAL_AUNT, | |
| cancer_type=CancerType.BREAST, | |
| age_at_diagnosis=55, | |
| degree=RelationshipDegree.SECOND, | |
| side=FamilySide.MATERNAL, | |
| ), | |
| ] | |
| user = create_test_user( | |
| age=45, | |
| height_m=1.68, | |
| bmi=25.5, | |
| menarche_age=13, | |
| has_given_birth=True, | |
| age_first_birth=30, | |
| family_history=family_history, | |
| ) | |
| result = model.calculate_risk(user, projection_years=10) | |
| expected_10yr_risk = 0.029 | |
| assert result["cumulative_risk"] == pytest.approx(expected_10yr_risk, abs=0.015) | |
| def test_reference_case_5_young_with_early_onset_family_history(self): | |
| """Early onset family history with mother at 38 and maternal grandmother at 52.""" | |
| model = TyrerCuzickRiskModel() | |
| family_history = [ | |
| FamilyMemberCancer( | |
| relation=FamilyRelation.MOTHER, | |
| cancer_type=CancerType.BREAST, | |
| age_at_diagnosis=38, # Early onset | |
| degree=RelationshipDegree.FIRST, | |
| side=FamilySide.MATERNAL, | |
| ), | |
| FamilyMemberCancer( | |
| relation=FamilyRelation.MATERNAL_GRANDMOTHER, | |
| cancer_type=CancerType.BREAST, | |
| age_at_diagnosis=52, | |
| degree=RelationshipDegree.SECOND, | |
| side=FamilySide.MATERNAL, | |
| ), | |
| ] | |
| user = create_test_user( | |
| age=30, | |
| height_m=1.62, | |
| bmi=22.1, | |
| menarche_age=12, | |
| has_given_birth=True, | |
| age_first_birth=28, | |
| family_history=family_history, | |
| ) | |
| result = model.calculate_risk(user, projection_years=10) | |
| expected_10yr_risk = 0.024 | |
| assert result["cumulative_risk"] == pytest.approx(expected_10yr_risk, abs=0.015) | |
| class TestEdgeCases: | |
| """Test edge cases and boundary conditions.""" | |
| def test_minimum_age(self): | |
| """Test calculation at minimum valid age (20).""" | |
| model = TyrerCuzickRiskModel() | |
| user = create_test_user(age=20, has_given_birth=False) | |
| result = model.calculate_risk(user, projection_years=10) | |
| assert 0.0 <= result["cumulative_risk"] <= 1.0 | |
| def test_maximum_age(self): | |
| """Test calculation at maximum valid age (85).""" | |
| model = TyrerCuzickRiskModel() | |
| user = create_test_user( | |
| age=85, | |
| is_postmenopausal=True, | |
| menopause_age=52, | |
| has_given_birth=True, | |
| age_first_birth=25, | |
| ) | |
| result = model.calculate_risk(user, projection_years=5) | |
| assert 0.0 <= result["cumulative_risk"] <= 1.0 | |
| def test_empty_pedigree(self): | |
| """Test calculation with empty pedigree.""" | |
| model = TyrerCuzickRiskModel() | |
| user = create_test_user(age=45) | |
| result = model.calculate_risk(user, projection_years=10) | |
| # Should use population priors | |
| assert 0.0 <= result["cumulative_risk"] <= 1.0 | |