sentinel / tests /test_risk_models /test_tyrer_cuzick_model.py
jeuko's picture
Sync from GitHub (main)
8018595 verified
"""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