Spaces:
Runtime error
Runtime error
Sync from GitHub (main)
Browse files- AGENTS.md +3 -1
- GEMINI.md +3 -1
- README.md +10 -5
- src/sentinel/risk_models/__init__.py +3 -0
- src/sentinel/risk_models/llpi.py +230 -0
- tests/test_risk_models/test_llpi_model.py +786 -0
AGENTS.md
CHANGED
|
@@ -45,11 +45,13 @@ When making changes to the project, ensure that the following files are updated
|
|
| 45 |
Implemented risk calculators include:
|
| 46 |
- **Gail** - Breast cancer risk
|
| 47 |
- **Claus** - Breast cancer risk based on family history
|
|
|
|
| 48 |
- **PLCOm2012** - Lung cancer risk
|
|
|
|
| 49 |
- **CRC-PRO** - Colorectal cancer risk
|
| 50 |
- **PCPT** - Prostate cancer risk
|
| 51 |
- **Extended PBCG** - Prostate cancer risk (extended model)
|
| 52 |
-
- **
|
| 53 |
- **QCancer** - Multi-site cancer differential
|
| 54 |
|
| 55 |
Additional models should follow the interfaces under `src/sentinel/risk_models`.
|
|
|
|
| 45 |
Implemented risk calculators include:
|
| 46 |
- **Gail** - Breast cancer risk
|
| 47 |
- **Claus** - Breast cancer risk based on family history
|
| 48 |
+
- **BOADICEA** - Breast and ovarian cancer risk (via CanRisk API)
|
| 49 |
- **PLCOm2012** - Lung cancer risk
|
| 50 |
+
- **LLPi** - Liverpool Lung Project improved model for lung cancer risk (8.7-year prediction)
|
| 51 |
- **CRC-PRO** - Colorectal cancer risk
|
| 52 |
- **PCPT** - Prostate cancer risk
|
| 53 |
- **Extended PBCG** - Prostate cancer risk (extended model)
|
| 54 |
+
- **MRAT** - Melanoma risk (5-year prediction)
|
| 55 |
- **QCancer** - Multi-site cancer differential
|
| 56 |
|
| 57 |
Additional models should follow the interfaces under `src/sentinel/risk_models`.
|
GEMINI.md
CHANGED
|
@@ -45,11 +45,13 @@ When making changes to the project, ensure that the following files are updated
|
|
| 45 |
Risk calculators exposed to Gemini-based agents include:
|
| 46 |
- **Gail** - Breast cancer risk
|
| 47 |
- **Claus** - Breast cancer risk based on family history
|
|
|
|
| 48 |
- **PLCOm2012** - Lung cancer risk
|
|
|
|
| 49 |
- **CRC-PRO** - Colorectal cancer risk
|
| 50 |
- **PCPT** - Prostate cancer risk
|
| 51 |
- **Extended PBCG** - Prostate cancer risk (extended model)
|
| 52 |
-
- **
|
| 53 |
- **QCancer** - Multi-site cancer differential
|
| 54 |
|
| 55 |
Register additional models in `src/sentinel/risk_models/__init__.py` so they are available system-wide.
|
|
|
|
| 45 |
Risk calculators exposed to Gemini-based agents include:
|
| 46 |
- **Gail** - Breast cancer risk
|
| 47 |
- **Claus** - Breast cancer risk based on family history
|
| 48 |
+
- **BOADICEA** - Breast and ovarian cancer risk (via CanRisk API)
|
| 49 |
- **PLCOm2012** - Lung cancer risk
|
| 50 |
+
- **LLPi** - Liverpool Lung Project improved model for lung cancer risk (8.7-year prediction)
|
| 51 |
- **CRC-PRO** - Colorectal cancer risk
|
| 52 |
- **PCPT** - Prostate cancer risk
|
| 53 |
- **Extended PBCG** - Prostate cancer risk (extended model)
|
| 54 |
+
- **MRAT** - Melanoma risk (5-year prediction)
|
| 55 |
- **QCancer** - Multi-site cancer differential
|
| 56 |
|
| 57 |
Register additional models in `src/sentinel/risk_models/__init__.py` so they are available system-wide.
|
README.md
CHANGED
|
@@ -129,11 +129,16 @@ When making changes to the project, check if the following files should also upd
|
|
| 129 |
|
| 130 |
The assistant currently includes the following built-in risk calculators:
|
| 131 |
|
| 132 |
-
- Gail
|
| 133 |
-
-
|
| 134 |
-
-
|
| 135 |
-
-
|
| 136 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
## Generating Documentation
|
| 139 |
|
|
|
|
| 129 |
|
| 130 |
The assistant currently includes the following built-in risk calculators:
|
| 131 |
|
| 132 |
+
- **Gail** - Breast cancer risk
|
| 133 |
+
- **Claus** - Breast cancer risk based on family history
|
| 134 |
+
- **BOADICEA** - Breast and ovarian cancer risk (via CanRisk API)
|
| 135 |
+
- **PLCOm2012** - Lung cancer risk
|
| 136 |
+
- **LLPi** - Liverpool Lung Project improved model for lung cancer risk (8.7-year prediction)
|
| 137 |
+
- **CRC-PRO** - Colorectal cancer risk
|
| 138 |
+
- **PCPT** - Prostate cancer risk
|
| 139 |
+
- **Extended PBCG** - Prostate cancer risk (extended model)
|
| 140 |
+
- **MRAT** - Melanoma risk (5-year prediction)
|
| 141 |
+
- **QCancer** - Multi-site cancer differential
|
| 142 |
|
| 143 |
## Generating Documentation
|
| 144 |
|
src/sentinel/risk_models/__init__.py
CHANGED
|
@@ -5,6 +5,7 @@ from sentinel.risk_models.claus import ClausRiskModel
|
|
| 5 |
from sentinel.risk_models.crc_pro import CRCProRiskModel
|
| 6 |
from sentinel.risk_models.extended_pbcg import ExtendedPBCGRiskModel
|
| 7 |
from sentinel.risk_models.gail import GailRiskModel
|
|
|
|
| 8 |
from sentinel.risk_models.mrat import MRATRiskModel
|
| 9 |
from sentinel.risk_models.pcpt import PCPTRiskModel
|
| 10 |
from sentinel.risk_models.plcom2012 import PLCOm2012RiskModel
|
|
@@ -13,6 +14,7 @@ from sentinel.risk_models.qcancer import QCancerRiskModel
|
|
| 13 |
RISK_MODELS = [
|
| 14 |
GailRiskModel,
|
| 15 |
PLCOm2012RiskModel,
|
|
|
|
| 16 |
BOADICEARiskModel,
|
| 17 |
CRCProRiskModel,
|
| 18 |
ExtendedPBCGRiskModel,
|
|
@@ -26,6 +28,7 @@ __all__ = [
|
|
| 26 |
"RISK_MODELS",
|
| 27 |
"ClausRiskModel",
|
| 28 |
"GailRiskModel",
|
|
|
|
| 29 |
"MRATRiskModel",
|
| 30 |
"PLCOm2012RiskModel",
|
| 31 |
]
|
|
|
|
| 5 |
from sentinel.risk_models.crc_pro import CRCProRiskModel
|
| 6 |
from sentinel.risk_models.extended_pbcg import ExtendedPBCGRiskModel
|
| 7 |
from sentinel.risk_models.gail import GailRiskModel
|
| 8 |
+
from sentinel.risk_models.llpi import LLPiRiskModel
|
| 9 |
from sentinel.risk_models.mrat import MRATRiskModel
|
| 10 |
from sentinel.risk_models.pcpt import PCPTRiskModel
|
| 11 |
from sentinel.risk_models.plcom2012 import PLCOm2012RiskModel
|
|
|
|
| 14 |
RISK_MODELS = [
|
| 15 |
GailRiskModel,
|
| 16 |
PLCOm2012RiskModel,
|
| 17 |
+
LLPiRiskModel,
|
| 18 |
BOADICEARiskModel,
|
| 19 |
CRCProRiskModel,
|
| 20 |
ExtendedPBCGRiskModel,
|
|
|
|
| 28 |
"RISK_MODELS",
|
| 29 |
"ClausRiskModel",
|
| 30 |
"GailRiskModel",
|
| 31 |
+
"LLPiRiskModel",
|
| 32 |
"MRATRiskModel",
|
| 33 |
"PLCOm2012RiskModel",
|
| 34 |
]
|
src/sentinel/risk_models/llpi.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lung cancer risk estimation using the Liverpool Lung Project improved (LLPi) model.
|
| 2 |
+
|
| 3 |
+
This module provides a Python implementation of the LLPi model, which predicts the
|
| 4 |
+
8.7-year probability of developing lung cancer. The model is based on a Cox proportional
|
| 5 |
+
hazards model calibrated to Liverpool data.
|
| 6 |
+
|
| 7 |
+
The model was published by Marcus et al. (2015) and represents an improvement over
|
| 8 |
+
the original Liverpool Lung Project (LLP) model.
|
| 9 |
+
|
| 10 |
+
Reference:
|
| 11 |
+
Marcus MW, Chen Y, Raji OY, Duffy SW, Field JK.
|
| 12 |
+
LLPi: Liverpool lung project risk prediction model for lung cancer incidence.
|
| 13 |
+
Cancer Prev Res (Phila) 2015;8:570-5.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import math
|
| 17 |
+
from typing import Annotated
|
| 18 |
+
|
| 19 |
+
from pydantic import Field
|
| 20 |
+
|
| 21 |
+
from sentinel.risk_models.base import RiskModel
|
| 22 |
+
from sentinel.user_input import (
|
| 23 |
+
CancerType,
|
| 24 |
+
ChronicCondition,
|
| 25 |
+
Sex,
|
| 26 |
+
SmokingStatus,
|
| 27 |
+
UserInput,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class LLPiRiskModel(RiskModel):
|
| 32 |
+
"""Compute 8.7-year absolute lung cancer risk using the LLPi model.
|
| 33 |
+
|
| 34 |
+
The LLPi model uses a Cox proportional hazards approach with baseline
|
| 35 |
+
survival calibrated to Liverpool population data. It requires:
|
| 36 |
+
- Age
|
| 37 |
+
- Sex
|
| 38 |
+
- Years smoked
|
| 39 |
+
- COPD diagnosis
|
| 40 |
+
- Prior cancer history
|
| 41 |
+
- Family history of lung cancer (early vs late onset)
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
# Model coefficients from the R implementation
|
| 45 |
+
_COEFFICIENTS = {
|
| 46 |
+
"age": 0.036,
|
| 47 |
+
"male": 0.391,
|
| 48 |
+
"smkyears": 0.043,
|
| 49 |
+
"copd": 0.890,
|
| 50 |
+
"prior_cancer": 1.044,
|
| 51 |
+
"fam_cancer_early": 0.521, # Early onset family history
|
| 52 |
+
"fam_cancer_late": 0.071, # Late onset family history
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
# Baseline survival and centering constant from Liverpool data
|
| 56 |
+
_BASELINE_SURVIVAL = 0.9728386 # Baseline survival at 8.7 years
|
| 57 |
+
_MEAN_LINEAR_PREDICTOR = 3.556 # Mean linear predictor in Liverpool data
|
| 58 |
+
|
| 59 |
+
def __init__(self):
|
| 60 |
+
super().__init__("llpi")
|
| 61 |
+
|
| 62 |
+
REQUIRED_INPUTS: dict[str, tuple[type, bool]] = {
|
| 63 |
+
"demographics.age_years": (Annotated[int, Field(ge=40, le=85)], True),
|
| 64 |
+
"demographics.sex": (Sex, True),
|
| 65 |
+
"lifestyle.smoking.years_smoked": (Annotated[int, Field(ge=0)], True),
|
| 66 |
+
"personal_medical_history.chronic_conditions": (
|
| 67 |
+
list,
|
| 68 |
+
False,
|
| 69 |
+
), # list[ChronicCondition]
|
| 70 |
+
"personal_medical_history.previous_cancers": (list, False), # list[CancerType]
|
| 71 |
+
"family_history": (list, False), # list[FamilyMemberCancer]
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
def calculate_risk(
|
| 75 |
+
self,
|
| 76 |
+
*,
|
| 77 |
+
age: int,
|
| 78 |
+
male: int,
|
| 79 |
+
smkyears: int,
|
| 80 |
+
copd: int,
|
| 81 |
+
prior_cancer: int,
|
| 82 |
+
fam_cancer_onset: int,
|
| 83 |
+
) -> float:
|
| 84 |
+
"""Calculate the 8.7-year lung cancer risk using the LLPi formula.
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
age: Patient age in years.
|
| 88 |
+
male: 1 if male, 0 if female.
|
| 89 |
+
smkyears: Total years smoked.
|
| 90 |
+
copd: 1 if COPD/emphysema present, 0 otherwise.
|
| 91 |
+
prior_cancer: 1 if prior cancer history, 0 otherwise.
|
| 92 |
+
fam_cancer_onset: 0=none, 1=early onset, 2=late onset.
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
Risk as a decimal (0-1).
|
| 96 |
+
"""
|
| 97 |
+
# Calculate linear predictor
|
| 98 |
+
linear_predictors = (
|
| 99 |
+
self._COEFFICIENTS["age"] * age
|
| 100 |
+
+ self._COEFFICIENTS["male"] * male
|
| 101 |
+
+ self._COEFFICIENTS["smkyears"] * smkyears
|
| 102 |
+
+ self._COEFFICIENTS["copd"] * copd
|
| 103 |
+
+ self._COEFFICIENTS["prior_cancer"] * prior_cancer
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# Add family history component
|
| 107 |
+
if fam_cancer_onset == 1: # Early onset
|
| 108 |
+
linear_predictors += self._COEFFICIENTS["fam_cancer_early"]
|
| 109 |
+
elif fam_cancer_onset == 2: # Late onset
|
| 110 |
+
linear_predictors += self._COEFFICIENTS["fam_cancer_late"]
|
| 111 |
+
|
| 112 |
+
# Calculate risk using survival formula
|
| 113 |
+
# risk = 1 - S0^exp(linear_predictors - mean_linear_predictors)
|
| 114 |
+
exponent = math.exp(linear_predictors - self._MEAN_LINEAR_PREDICTOR)
|
| 115 |
+
risk = 1 - (self._BASELINE_SURVIVAL**exponent)
|
| 116 |
+
|
| 117 |
+
return risk
|
| 118 |
+
|
| 119 |
+
def _determine_family_cancer_onset(self, user: UserInput) -> int:
|
| 120 |
+
"""Determine family cancer onset category from family history.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
user: The user profile.
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
0 = no family history of lung cancer
|
| 127 |
+
1 = early onset (diagnosed before age 60)
|
| 128 |
+
2 = late onset (diagnosed at age 60 or later)
|
| 129 |
+
"""
|
| 130 |
+
if not user.family_history:
|
| 131 |
+
return 0
|
| 132 |
+
|
| 133 |
+
# Check for lung cancer in first-degree relatives
|
| 134 |
+
lung_cancer_ages = [
|
| 135 |
+
member.age_at_diagnosis
|
| 136 |
+
for member in user.family_history
|
| 137 |
+
if member.cancer_type == CancerType.LUNG
|
| 138 |
+
and member.age_at_diagnosis is not None
|
| 139 |
+
]
|
| 140 |
+
|
| 141 |
+
if not lung_cancer_ages:
|
| 142 |
+
return 0
|
| 143 |
+
|
| 144 |
+
# If any relative had early onset (< 60), classify as early onset
|
| 145 |
+
if any(age < 60 for age in lung_cancer_ages):
|
| 146 |
+
return 1
|
| 147 |
+
else:
|
| 148 |
+
return 2
|
| 149 |
+
|
| 150 |
+
def compute_score(self, user: UserInput) -> str:
|
| 151 |
+
"""Compute the score for the LLPi model.
|
| 152 |
+
|
| 153 |
+
Args:
|
| 154 |
+
user: The user profile.
|
| 155 |
+
|
| 156 |
+
Returns:
|
| 157 |
+
str: Risk percentage as a string or an N/A message if not applicable.
|
| 158 |
+
|
| 159 |
+
Raises:
|
| 160 |
+
ValueError: If required inputs are missing or invalid.
|
| 161 |
+
"""
|
| 162 |
+
# Validate inputs first
|
| 163 |
+
is_valid, errors = self.validate_inputs(user)
|
| 164 |
+
if not is_valid:
|
| 165 |
+
raise ValueError(f"Invalid inputs for LLPi: {'; '.join(errors)}")
|
| 166 |
+
|
| 167 |
+
# Check if the user is a current or former smoker
|
| 168 |
+
if user.lifestyle.smoking.status == SmokingStatus.NEVER:
|
| 169 |
+
return "N/A: Model is for current or former smokers only."
|
| 170 |
+
|
| 171 |
+
# Check for minimum smoking history
|
| 172 |
+
if user.lifestyle.smoking.years_smoked == 0:
|
| 173 |
+
return "N/A: Model requires smoking history."
|
| 174 |
+
|
| 175 |
+
# Determine sex (male = 1, female = 0)
|
| 176 |
+
male = 1 if user.demographics.sex == Sex.MALE else 0
|
| 177 |
+
|
| 178 |
+
# Check for COPD
|
| 179 |
+
copd = (
|
| 180 |
+
1
|
| 181 |
+
if ChronicCondition.COPD in user.personal_medical_history.chronic_conditions
|
| 182 |
+
else 0
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
# Check for prior cancer history
|
| 186 |
+
prior_cancer = 1 if user.personal_medical_history.previous_cancers else 0
|
| 187 |
+
|
| 188 |
+
# Determine family cancer onset category
|
| 189 |
+
fam_cancer_onset = self._determine_family_cancer_onset(user)
|
| 190 |
+
|
| 191 |
+
# Calculate the risk
|
| 192 |
+
risk = self.calculate_risk(
|
| 193 |
+
age=user.demographics.age_years,
|
| 194 |
+
male=male,
|
| 195 |
+
smkyears=user.lifestyle.smoking.years_smoked,
|
| 196 |
+
copd=copd,
|
| 197 |
+
prior_cancer=prior_cancer,
|
| 198 |
+
fam_cancer_onset=fam_cancer_onset,
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
# Convert to percentage
|
| 202 |
+
risk_percentage = risk * 100
|
| 203 |
+
|
| 204 |
+
return f"{risk_percentage:.2f}%"
|
| 205 |
+
|
| 206 |
+
def cancer_type(self) -> str:
|
| 207 |
+
return "lung"
|
| 208 |
+
|
| 209 |
+
def description(self) -> str:
|
| 210 |
+
return (
|
| 211 |
+
"The Liverpool Lung Project improved (LLPi) model predicts the 8.7-year "
|
| 212 |
+
"probability of developing lung cancer for current or former smokers. "
|
| 213 |
+
"The model uses age, sex, smoking history, COPD status, prior cancer history, "
|
| 214 |
+
"and family history of lung cancer."
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
def interpretation(self) -> str:
|
| 218 |
+
return (
|
| 219 |
+
"The score is the percentage chance of developing lung cancer in the next "
|
| 220 |
+
"8.7 years. A higher score indicates greater risk. This score is based on "
|
| 221 |
+
"data from the Liverpool Lung Project and can help identify individuals "
|
| 222 |
+
"who may benefit from lung cancer screening."
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
def references(self) -> list[str]:
|
| 226 |
+
return [
|
| 227 |
+
"Marcus MW, Chen Y, Raji OY, Duffy SW, Field JK. "
|
| 228 |
+
"LLPi: Liverpool lung project risk prediction model for lung cancer incidence. "
|
| 229 |
+
"Cancer Prev Res (Phila) 2015;8:570-5."
|
| 230 |
+
]
|
tests/test_risk_models/test_llpi_model.py
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the Liverpool Lung Project improved (LLPi) Risk Model.
|
| 2 |
+
|
| 3 |
+
Ground truth values are calculated from the reference R implementation
|
| 4 |
+
in lcmodels/R/lcmodels.R (risk.llpi function).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from sentinel.risk_models.llpi import LLPiRiskModel
|
| 10 |
+
from sentinel.user_input import (
|
| 11 |
+
Anthropometrics,
|
| 12 |
+
CancerType,
|
| 13 |
+
ChronicCondition,
|
| 14 |
+
Demographics,
|
| 15 |
+
FamilyMemberCancer,
|
| 16 |
+
FamilyRelation,
|
| 17 |
+
FamilySide,
|
| 18 |
+
Lifestyle,
|
| 19 |
+
PersonalMedicalHistory,
|
| 20 |
+
RelationshipDegree,
|
| 21 |
+
Sex,
|
| 22 |
+
SmokingHistory,
|
| 23 |
+
SmokingStatus,
|
| 24 |
+
UserInput,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Ground truth test cases generated from R implementation
|
| 28 |
+
GROUND_TRUTH_CASES = [
|
| 29 |
+
{
|
| 30 |
+
"name": "low_risk_young_female",
|
| 31 |
+
"input": UserInput(
|
| 32 |
+
demographics=Demographics(
|
| 33 |
+
age_years=50,
|
| 34 |
+
sex=Sex.FEMALE,
|
| 35 |
+
anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=70.0),
|
| 36 |
+
),
|
| 37 |
+
lifestyle=Lifestyle(
|
| 38 |
+
smoking=SmokingHistory(
|
| 39 |
+
status=SmokingStatus.FORMER,
|
| 40 |
+
years_smoked=10,
|
| 41 |
+
cigarettes_per_day=10,
|
| 42 |
+
years_since_quit=5,
|
| 43 |
+
)
|
| 44 |
+
),
|
| 45 |
+
personal_medical_history=PersonalMedicalHistory(
|
| 46 |
+
chronic_conditions=[],
|
| 47 |
+
previous_cancers=[],
|
| 48 |
+
),
|
| 49 |
+
family_history=[],
|
| 50 |
+
),
|
| 51 |
+
"expected": 0.73,
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"name": "moderate_risk_male",
|
| 55 |
+
"input": UserInput(
|
| 56 |
+
demographics=Demographics(
|
| 57 |
+
age_years=60,
|
| 58 |
+
sex=Sex.MALE,
|
| 59 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
|
| 60 |
+
),
|
| 61 |
+
lifestyle=Lifestyle(
|
| 62 |
+
smoking=SmokingHistory(
|
| 63 |
+
status=SmokingStatus.CURRENT,
|
| 64 |
+
years_smoked=25,
|
| 65 |
+
cigarettes_per_day=20,
|
| 66 |
+
years_since_quit=None,
|
| 67 |
+
)
|
| 68 |
+
),
|
| 69 |
+
personal_medical_history=PersonalMedicalHistory(
|
| 70 |
+
chronic_conditions=[],
|
| 71 |
+
previous_cancers=[],
|
| 72 |
+
),
|
| 73 |
+
family_history=[],
|
| 74 |
+
),
|
| 75 |
+
"expected": 2.91,
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"name": "high_risk_copd",
|
| 79 |
+
"input": UserInput(
|
| 80 |
+
demographics=Demographics(
|
| 81 |
+
age_years=70,
|
| 82 |
+
sex=Sex.MALE,
|
| 83 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=75.0),
|
| 84 |
+
),
|
| 85 |
+
lifestyle=Lifestyle(
|
| 86 |
+
smoking=SmokingHistory(
|
| 87 |
+
status=SmokingStatus.FORMER,
|
| 88 |
+
years_smoked=45,
|
| 89 |
+
cigarettes_per_day=30,
|
| 90 |
+
years_since_quit=2,
|
| 91 |
+
)
|
| 92 |
+
),
|
| 93 |
+
personal_medical_history=PersonalMedicalHistory(
|
| 94 |
+
chronic_conditions=[ChronicCondition.COPD],
|
| 95 |
+
previous_cancers=[],
|
| 96 |
+
),
|
| 97 |
+
family_history=[],
|
| 98 |
+
),
|
| 99 |
+
"expected": 21.62,
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"name": "high_risk_prior_cancer",
|
| 103 |
+
"input": UserInput(
|
| 104 |
+
demographics=Demographics(
|
| 105 |
+
age_years=65,
|
| 106 |
+
sex=Sex.MALE,
|
| 107 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=78.0),
|
| 108 |
+
),
|
| 109 |
+
lifestyle=Lifestyle(
|
| 110 |
+
smoking=SmokingHistory(
|
| 111 |
+
status=SmokingStatus.FORMER,
|
| 112 |
+
years_smoked=35,
|
| 113 |
+
cigarettes_per_day=25,
|
| 114 |
+
years_since_quit=5,
|
| 115 |
+
)
|
| 116 |
+
),
|
| 117 |
+
personal_medical_history=PersonalMedicalHistory(
|
| 118 |
+
chronic_conditions=[],
|
| 119 |
+
previous_cancers=[CancerType.PROSTATE],
|
| 120 |
+
),
|
| 121 |
+
family_history=[],
|
| 122 |
+
),
|
| 123 |
+
"expected": 14.31,
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
"name": "early_onset_family_history",
|
| 127 |
+
"input": UserInput(
|
| 128 |
+
demographics=Demographics(
|
| 129 |
+
age_years=55,
|
| 130 |
+
sex=Sex.FEMALE,
|
| 131 |
+
anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=68.0),
|
| 132 |
+
),
|
| 133 |
+
lifestyle=Lifestyle(
|
| 134 |
+
smoking=SmokingHistory(
|
| 135 |
+
status=SmokingStatus.CURRENT,
|
| 136 |
+
years_smoked=20,
|
| 137 |
+
cigarettes_per_day=15,
|
| 138 |
+
years_since_quit=None,
|
| 139 |
+
)
|
| 140 |
+
),
|
| 141 |
+
personal_medical_history=PersonalMedicalHistory(
|
| 142 |
+
chronic_conditions=[],
|
| 143 |
+
previous_cancers=[],
|
| 144 |
+
),
|
| 145 |
+
family_history=[
|
| 146 |
+
FamilyMemberCancer(
|
| 147 |
+
relation=FamilyRelation.MOTHER,
|
| 148 |
+
side=FamilySide.MATERNAL,
|
| 149 |
+
degree=RelationshipDegree.FIRST,
|
| 150 |
+
cancer_type=CancerType.LUNG,
|
| 151 |
+
age_at_diagnosis=55, # Early onset (< 60)
|
| 152 |
+
)
|
| 153 |
+
],
|
| 154 |
+
),
|
| 155 |
+
"expected": 2.24,
|
| 156 |
+
},
|
| 157 |
+
{
|
| 158 |
+
"name": "late_onset_family_history",
|
| 159 |
+
"input": UserInput(
|
| 160 |
+
demographics=Demographics(
|
| 161 |
+
age_years=58,
|
| 162 |
+
sex=Sex.MALE,
|
| 163 |
+
anthropometrics=Anthropometrics(height_cm=178.0, weight_kg=82.0),
|
| 164 |
+
),
|
| 165 |
+
lifestyle=Lifestyle(
|
| 166 |
+
smoking=SmokingHistory(
|
| 167 |
+
status=SmokingStatus.FORMER,
|
| 168 |
+
years_smoked=30,
|
| 169 |
+
cigarettes_per_day=20,
|
| 170 |
+
years_since_quit=3,
|
| 171 |
+
)
|
| 172 |
+
),
|
| 173 |
+
personal_medical_history=PersonalMedicalHistory(
|
| 174 |
+
chronic_conditions=[],
|
| 175 |
+
previous_cancers=[],
|
| 176 |
+
),
|
| 177 |
+
family_history=[
|
| 178 |
+
FamilyMemberCancer(
|
| 179 |
+
relation=FamilyRelation.FATHER,
|
| 180 |
+
side=FamilySide.PATERNAL,
|
| 181 |
+
degree=RelationshipDegree.FIRST,
|
| 182 |
+
cancer_type=CancerType.LUNG,
|
| 183 |
+
age_at_diagnosis=65, # Late onset (>= 60)
|
| 184 |
+
)
|
| 185 |
+
],
|
| 186 |
+
),
|
| 187 |
+
"expected": 3.59,
|
| 188 |
+
},
|
| 189 |
+
{
|
| 190 |
+
"name": "multiple_risk_factors",
|
| 191 |
+
"input": UserInput(
|
| 192 |
+
demographics=Demographics(
|
| 193 |
+
age_years=68,
|
| 194 |
+
sex=Sex.MALE,
|
| 195 |
+
anthropometrics=Anthropometrics(height_cm=172.0, weight_kg=76.0),
|
| 196 |
+
),
|
| 197 |
+
lifestyle=Lifestyle(
|
| 198 |
+
smoking=SmokingHistory(
|
| 199 |
+
status=SmokingStatus.CURRENT,
|
| 200 |
+
years_smoked=40,
|
| 201 |
+
cigarettes_per_day=30,
|
| 202 |
+
years_since_quit=None,
|
| 203 |
+
)
|
| 204 |
+
),
|
| 205 |
+
personal_medical_history=PersonalMedicalHistory(
|
| 206 |
+
chronic_conditions=[ChronicCondition.COPD],
|
| 207 |
+
previous_cancers=[CancerType.PROSTATE],
|
| 208 |
+
),
|
| 209 |
+
family_history=[
|
| 210 |
+
FamilyMemberCancer(
|
| 211 |
+
relation=FamilyRelation.MOTHER,
|
| 212 |
+
side=FamilySide.MATERNAL,
|
| 213 |
+
degree=RelationshipDegree.FIRST,
|
| 214 |
+
cancer_type=CancerType.LUNG,
|
| 215 |
+
age_at_diagnosis=58, # Early onset
|
| 216 |
+
)
|
| 217 |
+
],
|
| 218 |
+
),
|
| 219 |
+
"expected": 58.29,
|
| 220 |
+
},
|
| 221 |
+
{
|
| 222 |
+
"name": "female_copd_family_history",
|
| 223 |
+
"input": UserInput(
|
| 224 |
+
demographics=Demographics(
|
| 225 |
+
age_years=62,
|
| 226 |
+
sex=Sex.FEMALE,
|
| 227 |
+
anthropometrics=Anthropometrics(height_cm=162.0, weight_kg=65.0),
|
| 228 |
+
),
|
| 229 |
+
lifestyle=Lifestyle(
|
| 230 |
+
smoking=SmokingHistory(
|
| 231 |
+
status=SmokingStatus.FORMER,
|
| 232 |
+
years_smoked=28,
|
| 233 |
+
cigarettes_per_day=18,
|
| 234 |
+
years_since_quit=4,
|
| 235 |
+
)
|
| 236 |
+
),
|
| 237 |
+
personal_medical_history=PersonalMedicalHistory(
|
| 238 |
+
chronic_conditions=[ChronicCondition.COPD],
|
| 239 |
+
previous_cancers=[],
|
| 240 |
+
),
|
| 241 |
+
family_history=[
|
| 242 |
+
FamilyMemberCancer(
|
| 243 |
+
relation=FamilyRelation.FATHER,
|
| 244 |
+
side=FamilySide.PATERNAL,
|
| 245 |
+
degree=RelationshipDegree.FIRST,
|
| 246 |
+
cancer_type=CancerType.LUNG,
|
| 247 |
+
age_at_diagnosis=70, # Late onset
|
| 248 |
+
)
|
| 249 |
+
],
|
| 250 |
+
),
|
| 251 |
+
"expected": 6.19,
|
| 252 |
+
},
|
| 253 |
+
{
|
| 254 |
+
"name": "minimal_smoking_no_risk",
|
| 255 |
+
"input": UserInput(
|
| 256 |
+
demographics=Demographics(
|
| 257 |
+
age_years=52,
|
| 258 |
+
sex=Sex.FEMALE,
|
| 259 |
+
anthropometrics=Anthropometrics(height_cm=160.0, weight_kg=62.0),
|
| 260 |
+
),
|
| 261 |
+
lifestyle=Lifestyle(
|
| 262 |
+
smoking=SmokingHistory(
|
| 263 |
+
status=SmokingStatus.FORMER,
|
| 264 |
+
years_smoked=5,
|
| 265 |
+
cigarettes_per_day=8,
|
| 266 |
+
years_since_quit=10,
|
| 267 |
+
)
|
| 268 |
+
),
|
| 269 |
+
personal_medical_history=PersonalMedicalHistory(
|
| 270 |
+
chronic_conditions=[],
|
| 271 |
+
previous_cancers=[],
|
| 272 |
+
),
|
| 273 |
+
family_history=[],
|
| 274 |
+
),
|
| 275 |
+
"expected": 0.63,
|
| 276 |
+
},
|
| 277 |
+
{
|
| 278 |
+
"name": "heavy_smoker_female",
|
| 279 |
+
"input": UserInput(
|
| 280 |
+
demographics=Demographics(
|
| 281 |
+
age_years=58,
|
| 282 |
+
sex=Sex.FEMALE,
|
| 283 |
+
anthropometrics=Anthropometrics(height_cm=168.0, weight_kg=72.0),
|
| 284 |
+
),
|
| 285 |
+
lifestyle=Lifestyle(
|
| 286 |
+
smoking=SmokingHistory(
|
| 287 |
+
status=SmokingStatus.CURRENT,
|
| 288 |
+
years_smoked=38,
|
| 289 |
+
cigarettes_per_day=25,
|
| 290 |
+
years_since_quit=None,
|
| 291 |
+
)
|
| 292 |
+
),
|
| 293 |
+
personal_medical_history=PersonalMedicalHistory(
|
| 294 |
+
chronic_conditions=[],
|
| 295 |
+
previous_cancers=[],
|
| 296 |
+
),
|
| 297 |
+
family_history=[],
|
| 298 |
+
),
|
| 299 |
+
"expected": 3.20,
|
| 300 |
+
},
|
| 301 |
+
]
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
class TestLLPiModel:
|
| 305 |
+
"""Test suite for LLPiRiskModel."""
|
| 306 |
+
|
| 307 |
+
def setup_method(self):
|
| 308 |
+
"""Initialize LLPiRiskModel instance for testing."""
|
| 309 |
+
self.model = LLPiRiskModel()
|
| 310 |
+
|
| 311 |
+
@pytest.mark.parametrize("case", GROUND_TRUTH_CASES, ids=lambda x: x["name"])
|
| 312 |
+
def test_ground_truth_validation(self, case):
|
| 313 |
+
"""Test against calculated ground truth results from R implementation.
|
| 314 |
+
|
| 315 |
+
Args:
|
| 316 |
+
case: Parameterized ground truth case dict.
|
| 317 |
+
"""
|
| 318 |
+
user = case["input"]
|
| 319 |
+
score_str = self.model.compute_score(user)
|
| 320 |
+
calculated = float(score_str.rstrip("%"))
|
| 321 |
+
expected = case["expected"]
|
| 322 |
+
|
| 323 |
+
# Using tight tolerance since these are calculated values
|
| 324 |
+
assert calculated == pytest.approx(expected, abs=0.01)
|
| 325 |
+
|
| 326 |
+
def test_never_smoker_handling(self):
|
| 327 |
+
"""Test that never smokers receive N/A response."""
|
| 328 |
+
never_smoker = UserInput(
|
| 329 |
+
demographics=Demographics(
|
| 330 |
+
age_years=55,
|
| 331 |
+
sex=Sex.MALE,
|
| 332 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
|
| 333 |
+
),
|
| 334 |
+
lifestyle=Lifestyle(
|
| 335 |
+
smoking=SmokingHistory(
|
| 336 |
+
status=SmokingStatus.NEVER,
|
| 337 |
+
cigarettes_per_day=0,
|
| 338 |
+
years_smoked=0,
|
| 339 |
+
years_since_quit=None,
|
| 340 |
+
)
|
| 341 |
+
),
|
| 342 |
+
personal_medical_history=PersonalMedicalHistory(),
|
| 343 |
+
family_history=[],
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
score = self.model.compute_score(never_smoker)
|
| 347 |
+
assert score == "N/A: Model is for current or former smokers only."
|
| 348 |
+
|
| 349 |
+
def test_no_smoking_history(self):
|
| 350 |
+
"""Test handling when years_smoked is 0."""
|
| 351 |
+
user = UserInput(
|
| 352 |
+
demographics=Demographics(
|
| 353 |
+
age_years=55,
|
| 354 |
+
sex=Sex.MALE,
|
| 355 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
|
| 356 |
+
),
|
| 357 |
+
lifestyle=Lifestyle(
|
| 358 |
+
smoking=SmokingHistory(
|
| 359 |
+
status=SmokingStatus.CURRENT,
|
| 360 |
+
cigarettes_per_day=10,
|
| 361 |
+
years_smoked=0,
|
| 362 |
+
years_since_quit=None,
|
| 363 |
+
)
|
| 364 |
+
),
|
| 365 |
+
personal_medical_history=PersonalMedicalHistory(),
|
| 366 |
+
family_history=[],
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
score = self.model.compute_score(user)
|
| 370 |
+
assert "Model requires smoking history" in score
|
| 371 |
+
|
| 372 |
+
def test_age_validation(self):
|
| 373 |
+
"""Test age validation (40-85 range)."""
|
| 374 |
+
young_user = UserInput(
|
| 375 |
+
demographics=Demographics(
|
| 376 |
+
age_years=35,
|
| 377 |
+
sex=Sex.MALE,
|
| 378 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
|
| 379 |
+
),
|
| 380 |
+
lifestyle=Lifestyle(
|
| 381 |
+
smoking=SmokingHistory(
|
| 382 |
+
status=SmokingStatus.CURRENT,
|
| 383 |
+
cigarettes_per_day=20,
|
| 384 |
+
years_smoked=15,
|
| 385 |
+
years_since_quit=None,
|
| 386 |
+
)
|
| 387 |
+
),
|
| 388 |
+
personal_medical_history=PersonalMedicalHistory(),
|
| 389 |
+
family_history=[],
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
with pytest.raises(ValueError, match=r"Invalid inputs for LLPi:"):
|
| 393 |
+
self.model.compute_score(young_user)
|
| 394 |
+
|
| 395 |
+
old_user = UserInput(
|
| 396 |
+
demographics=Demographics(
|
| 397 |
+
age_years=90,
|
| 398 |
+
sex=Sex.MALE,
|
| 399 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
|
| 400 |
+
),
|
| 401 |
+
lifestyle=Lifestyle(
|
| 402 |
+
smoking=SmokingHistory(
|
| 403 |
+
status=SmokingStatus.FORMER,
|
| 404 |
+
cigarettes_per_day=20,
|
| 405 |
+
years_smoked=30,
|
| 406 |
+
years_since_quit=10,
|
| 407 |
+
)
|
| 408 |
+
),
|
| 409 |
+
personal_medical_history=PersonalMedicalHistory(),
|
| 410 |
+
family_history=[],
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
+
with pytest.raises(ValueError, match=r"Invalid inputs for LLPi:"):
|
| 414 |
+
self.model.compute_score(old_user)
|
| 415 |
+
|
| 416 |
+
def test_copd_detection(self):
|
| 417 |
+
"""Test COPD detection from chronic conditions."""
|
| 418 |
+
user = UserInput(
|
| 419 |
+
demographics=Demographics(
|
| 420 |
+
age_years=60,
|
| 421 |
+
sex=Sex.MALE,
|
| 422 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
|
| 423 |
+
),
|
| 424 |
+
lifestyle=Lifestyle(
|
| 425 |
+
smoking=SmokingHistory(
|
| 426 |
+
status=SmokingStatus.CURRENT,
|
| 427 |
+
cigarettes_per_day=20,
|
| 428 |
+
years_smoked=25,
|
| 429 |
+
years_since_quit=None,
|
| 430 |
+
)
|
| 431 |
+
),
|
| 432 |
+
personal_medical_history=PersonalMedicalHistory(
|
| 433 |
+
chronic_conditions=[ChronicCondition.COPD, ChronicCondition.DIABETES],
|
| 434 |
+
),
|
| 435 |
+
family_history=[],
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
score = self.model.compute_score(user)
|
| 439 |
+
assert "%" in score
|
| 440 |
+
|
| 441 |
+
# Risk should be higher with COPD
|
| 442 |
+
risk_with_copd = float(score.rstrip("%"))
|
| 443 |
+
|
| 444 |
+
# Compare to same user without COPD
|
| 445 |
+
user_no_copd = UserInput(
|
| 446 |
+
demographics=Demographics(
|
| 447 |
+
age_years=60,
|
| 448 |
+
sex=Sex.MALE,
|
| 449 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
|
| 450 |
+
),
|
| 451 |
+
lifestyle=Lifestyle(
|
| 452 |
+
smoking=SmokingHistory(
|
| 453 |
+
status=SmokingStatus.CURRENT,
|
| 454 |
+
cigarettes_per_day=20,
|
| 455 |
+
years_smoked=25,
|
| 456 |
+
years_since_quit=None,
|
| 457 |
+
)
|
| 458 |
+
),
|
| 459 |
+
personal_medical_history=PersonalMedicalHistory(
|
| 460 |
+
chronic_conditions=[ChronicCondition.DIABETES],
|
| 461 |
+
),
|
| 462 |
+
family_history=[],
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
score_no_copd = self.model.compute_score(user_no_copd)
|
| 466 |
+
risk_no_copd = float(score_no_copd.rstrip("%"))
|
| 467 |
+
|
| 468 |
+
assert risk_with_copd > risk_no_copd
|
| 469 |
+
|
| 470 |
+
def test_prior_cancer_detection(self):
|
| 471 |
+
"""Test prior cancer history detection."""
|
| 472 |
+
user = UserInput(
|
| 473 |
+
demographics=Demographics(
|
| 474 |
+
age_years=60,
|
| 475 |
+
sex=Sex.MALE,
|
| 476 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
|
| 477 |
+
),
|
| 478 |
+
lifestyle=Lifestyle(
|
| 479 |
+
smoking=SmokingHistory(
|
| 480 |
+
status=SmokingStatus.CURRENT,
|
| 481 |
+
cigarettes_per_day=20,
|
| 482 |
+
years_smoked=25,
|
| 483 |
+
years_since_quit=None,
|
| 484 |
+
)
|
| 485 |
+
),
|
| 486 |
+
personal_medical_history=PersonalMedicalHistory(
|
| 487 |
+
chronic_conditions=[],
|
| 488 |
+
previous_cancers=[CancerType.COLORECTAL, CancerType.PROSTATE],
|
| 489 |
+
),
|
| 490 |
+
family_history=[],
|
| 491 |
+
)
|
| 492 |
+
|
| 493 |
+
score = self.model.compute_score(user)
|
| 494 |
+
assert "%" in score
|
| 495 |
+
|
| 496 |
+
# Risk should be higher with prior cancer
|
| 497 |
+
risk_with_cancer = float(score.rstrip("%"))
|
| 498 |
+
|
| 499 |
+
# Compare to same user without prior cancer
|
| 500 |
+
user_no_cancer = UserInput(
|
| 501 |
+
demographics=Demographics(
|
| 502 |
+
age_years=60,
|
| 503 |
+
sex=Sex.MALE,
|
| 504 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
|
| 505 |
+
),
|
| 506 |
+
lifestyle=Lifestyle(
|
| 507 |
+
smoking=SmokingHistory(
|
| 508 |
+
status=SmokingStatus.CURRENT,
|
| 509 |
+
cigarettes_per_day=20,
|
| 510 |
+
years_smoked=25,
|
| 511 |
+
years_since_quit=None,
|
| 512 |
+
)
|
| 513 |
+
),
|
| 514 |
+
personal_medical_history=PersonalMedicalHistory(
|
| 515 |
+
chronic_conditions=[],
|
| 516 |
+
previous_cancers=[],
|
| 517 |
+
),
|
| 518 |
+
family_history=[],
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
score_no_cancer = self.model.compute_score(user_no_cancer)
|
| 522 |
+
risk_no_cancer = float(score_no_cancer.rstrip("%"))
|
| 523 |
+
|
| 524 |
+
assert risk_with_cancer > risk_no_cancer
|
| 525 |
+
|
| 526 |
+
def test_family_history_early_vs_late(self):
|
| 527 |
+
"""Test that early onset family history has higher coefficient than late onset."""
|
| 528 |
+
base_user_data = {
|
| 529 |
+
"demographics": Demographics(
|
| 530 |
+
age_years=60,
|
| 531 |
+
sex=Sex.MALE,
|
| 532 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
|
| 533 |
+
),
|
| 534 |
+
"lifestyle": Lifestyle(
|
| 535 |
+
smoking=SmokingHistory(
|
| 536 |
+
status=SmokingStatus.CURRENT,
|
| 537 |
+
cigarettes_per_day=20,
|
| 538 |
+
years_smoked=25,
|
| 539 |
+
years_since_quit=None,
|
| 540 |
+
)
|
| 541 |
+
),
|
| 542 |
+
"personal_medical_history": PersonalMedicalHistory(),
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
# Early onset family history
|
| 546 |
+
user_early = UserInput(
|
| 547 |
+
**base_user_data,
|
| 548 |
+
family_history=[
|
| 549 |
+
FamilyMemberCancer(
|
| 550 |
+
relation=FamilyRelation.MOTHER,
|
| 551 |
+
side=FamilySide.MATERNAL,
|
| 552 |
+
degree=RelationshipDegree.FIRST,
|
| 553 |
+
cancer_type=CancerType.LUNG,
|
| 554 |
+
age_at_diagnosis=55, # Early onset
|
| 555 |
+
)
|
| 556 |
+
],
|
| 557 |
+
)
|
| 558 |
+
|
| 559 |
+
# Late onset family history
|
| 560 |
+
user_late = UserInput(
|
| 561 |
+
**base_user_data,
|
| 562 |
+
family_history=[
|
| 563 |
+
FamilyMemberCancer(
|
| 564 |
+
relation=FamilyRelation.FATHER,
|
| 565 |
+
side=FamilySide.PATERNAL,
|
| 566 |
+
degree=RelationshipDegree.FIRST,
|
| 567 |
+
cancer_type=CancerType.LUNG,
|
| 568 |
+
age_at_diagnosis=70, # Late onset
|
| 569 |
+
)
|
| 570 |
+
],
|
| 571 |
+
)
|
| 572 |
+
|
| 573 |
+
# No family history
|
| 574 |
+
user_none = UserInput(**base_user_data, family_history=[])
|
| 575 |
+
|
| 576 |
+
risk_early = float(self.model.compute_score(user_early).rstrip("%"))
|
| 577 |
+
risk_late = float(self.model.compute_score(user_late).rstrip("%"))
|
| 578 |
+
risk_none = float(self.model.compute_score(user_none).rstrip("%"))
|
| 579 |
+
|
| 580 |
+
# Early onset should confer higher risk than late onset
|
| 581 |
+
assert risk_early > risk_late > risk_none
|
| 582 |
+
|
| 583 |
+
def test_family_history_non_lung_cancer_ignored(self):
|
| 584 |
+
"""Test that non-lung cancer family history is ignored."""
|
| 585 |
+
user_lung = UserInput(
|
| 586 |
+
demographics=Demographics(
|
| 587 |
+
age_years=60,
|
| 588 |
+
sex=Sex.MALE,
|
| 589 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
|
| 590 |
+
),
|
| 591 |
+
lifestyle=Lifestyle(
|
| 592 |
+
smoking=SmokingHistory(
|
| 593 |
+
status=SmokingStatus.CURRENT,
|
| 594 |
+
cigarettes_per_day=20,
|
| 595 |
+
years_smoked=25,
|
| 596 |
+
years_since_quit=None,
|
| 597 |
+
)
|
| 598 |
+
),
|
| 599 |
+
personal_medical_history=PersonalMedicalHistory(),
|
| 600 |
+
family_history=[
|
| 601 |
+
FamilyMemberCancer(
|
| 602 |
+
relation=FamilyRelation.MOTHER,
|
| 603 |
+
side=FamilySide.MATERNAL,
|
| 604 |
+
degree=RelationshipDegree.FIRST,
|
| 605 |
+
cancer_type=CancerType.LUNG,
|
| 606 |
+
age_at_diagnosis=65,
|
| 607 |
+
)
|
| 608 |
+
],
|
| 609 |
+
)
|
| 610 |
+
|
| 611 |
+
user_breast = UserInput(
|
| 612 |
+
demographics=Demographics(
|
| 613 |
+
age_years=60,
|
| 614 |
+
sex=Sex.MALE,
|
| 615 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
|
| 616 |
+
),
|
| 617 |
+
lifestyle=Lifestyle(
|
| 618 |
+
smoking=SmokingHistory(
|
| 619 |
+
status=SmokingStatus.CURRENT,
|
| 620 |
+
cigarettes_per_day=20,
|
| 621 |
+
years_smoked=25,
|
| 622 |
+
years_since_quit=None,
|
| 623 |
+
)
|
| 624 |
+
),
|
| 625 |
+
personal_medical_history=PersonalMedicalHistory(),
|
| 626 |
+
family_history=[
|
| 627 |
+
FamilyMemberCancer(
|
| 628 |
+
relation=FamilyRelation.MOTHER,
|
| 629 |
+
side=FamilySide.MATERNAL,
|
| 630 |
+
degree=RelationshipDegree.FIRST,
|
| 631 |
+
cancer_type=CancerType.BREAST,
|
| 632 |
+
age_at_diagnosis=65,
|
| 633 |
+
)
|
| 634 |
+
],
|
| 635 |
+
)
|
| 636 |
+
|
| 637 |
+
user_none = UserInput(
|
| 638 |
+
demographics=Demographics(
|
| 639 |
+
age_years=60,
|
| 640 |
+
sex=Sex.MALE,
|
| 641 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
|
| 642 |
+
),
|
| 643 |
+
lifestyle=Lifestyle(
|
| 644 |
+
smoking=SmokingHistory(
|
| 645 |
+
status=SmokingStatus.CURRENT,
|
| 646 |
+
cigarettes_per_day=20,
|
| 647 |
+
years_smoked=25,
|
| 648 |
+
years_since_quit=None,
|
| 649 |
+
)
|
| 650 |
+
),
|
| 651 |
+
personal_medical_history=PersonalMedicalHistory(),
|
| 652 |
+
family_history=[],
|
| 653 |
+
)
|
| 654 |
+
|
| 655 |
+
risk_lung = float(self.model.compute_score(user_lung).rstrip("%"))
|
| 656 |
+
risk_breast = float(self.model.compute_score(user_breast).rstrip("%"))
|
| 657 |
+
risk_none = float(self.model.compute_score(user_none).rstrip("%"))
|
| 658 |
+
|
| 659 |
+
# Lung cancer family history should increase risk
|
| 660 |
+
assert risk_lung > risk_none
|
| 661 |
+
# Breast cancer family history should not affect risk
|
| 662 |
+
assert risk_breast == pytest.approx(risk_none, abs=0.01)
|
| 663 |
+
|
| 664 |
+
def test_sex_difference(self):
|
| 665 |
+
"""Test that males have higher risk than females (all else equal)."""
|
| 666 |
+
user_male = UserInput(
|
| 667 |
+
demographics=Demographics(
|
| 668 |
+
age_years=60,
|
| 669 |
+
sex=Sex.MALE,
|
| 670 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
|
| 671 |
+
),
|
| 672 |
+
lifestyle=Lifestyle(
|
| 673 |
+
smoking=SmokingHistory(
|
| 674 |
+
status=SmokingStatus.CURRENT,
|
| 675 |
+
cigarettes_per_day=20,
|
| 676 |
+
years_smoked=25,
|
| 677 |
+
years_since_quit=None,
|
| 678 |
+
)
|
| 679 |
+
),
|
| 680 |
+
personal_medical_history=PersonalMedicalHistory(),
|
| 681 |
+
family_history=[],
|
| 682 |
+
)
|
| 683 |
+
|
| 684 |
+
user_female = UserInput(
|
| 685 |
+
demographics=Demographics(
|
| 686 |
+
age_years=60,
|
| 687 |
+
sex=Sex.FEMALE,
|
| 688 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
|
| 689 |
+
),
|
| 690 |
+
lifestyle=Lifestyle(
|
| 691 |
+
smoking=SmokingHistory(
|
| 692 |
+
status=SmokingStatus.CURRENT,
|
| 693 |
+
cigarettes_per_day=20,
|
| 694 |
+
years_smoked=25,
|
| 695 |
+
years_since_quit=None,
|
| 696 |
+
)
|
| 697 |
+
),
|
| 698 |
+
personal_medical_history=PersonalMedicalHistory(),
|
| 699 |
+
family_history=[],
|
| 700 |
+
)
|
| 701 |
+
|
| 702 |
+
risk_male = float(self.model.compute_score(user_male).rstrip("%"))
|
| 703 |
+
risk_female = float(self.model.compute_score(user_female).rstrip("%"))
|
| 704 |
+
|
| 705 |
+
# Males should have higher risk (positive coefficient for male)
|
| 706 |
+
assert risk_male > risk_female
|
| 707 |
+
|
| 708 |
+
def test_model_metadata(self):
|
| 709 |
+
"""Test model metadata methods."""
|
| 710 |
+
assert self.model.name == "llpi"
|
| 711 |
+
assert self.model.cancer_type() == "lung"
|
| 712 |
+
assert "LLPi" in self.model.description()
|
| 713 |
+
assert "8.7-year" in self.model.description()
|
| 714 |
+
assert "percentage chance" in self.model.interpretation()
|
| 715 |
+
assert isinstance(self.model.references(), list)
|
| 716 |
+
assert len(self.model.references()) > 0
|
| 717 |
+
assert "Marcus" in self.model.references()[0]
|
| 718 |
+
|
| 719 |
+
def test_calculate_risk_directly(self):
|
| 720 |
+
"""Test the calculate_risk method directly with known inputs."""
|
| 721 |
+
# Test case: moderate_risk_male from ground truth
|
| 722 |
+
risk = self.model.calculate_risk(
|
| 723 |
+
age=60,
|
| 724 |
+
male=1,
|
| 725 |
+
smkyears=25,
|
| 726 |
+
copd=0,
|
| 727 |
+
prior_cancer=0,
|
| 728 |
+
fam_cancer_onset=0,
|
| 729 |
+
)
|
| 730 |
+
|
| 731 |
+
# Should match ground truth: 2.91%
|
| 732 |
+
assert risk * 100 == pytest.approx(2.91, abs=0.01)
|
| 733 |
+
|
| 734 |
+
def test_increasing_age_increases_risk(self):
|
| 735 |
+
"""Test that increasing age increases risk (positive age coefficient)."""
|
| 736 |
+
risks = []
|
| 737 |
+
for age in [50, 60, 70]:
|
| 738 |
+
user = UserInput(
|
| 739 |
+
demographics=Demographics(
|
| 740 |
+
age_years=age,
|
| 741 |
+
sex=Sex.MALE,
|
| 742 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
|
| 743 |
+
),
|
| 744 |
+
lifestyle=Lifestyle(
|
| 745 |
+
smoking=SmokingHistory(
|
| 746 |
+
status=SmokingStatus.CURRENT,
|
| 747 |
+
cigarettes_per_day=20,
|
| 748 |
+
years_smoked=25,
|
| 749 |
+
years_since_quit=None,
|
| 750 |
+
)
|
| 751 |
+
),
|
| 752 |
+
personal_medical_history=PersonalMedicalHistory(),
|
| 753 |
+
family_history=[],
|
| 754 |
+
)
|
| 755 |
+
risk = float(self.model.compute_score(user).rstrip("%"))
|
| 756 |
+
risks.append(risk)
|
| 757 |
+
|
| 758 |
+
# Risk should increase with age
|
| 759 |
+
assert risks[0] < risks[1] < risks[2]
|
| 760 |
+
|
| 761 |
+
def test_increasing_smoking_years_increases_risk(self):
|
| 762 |
+
"""Test that longer smoking history increases risk."""
|
| 763 |
+
risks = []
|
| 764 |
+
for years in [10, 25, 40]:
|
| 765 |
+
user = UserInput(
|
| 766 |
+
demographics=Demographics(
|
| 767 |
+
age_years=60,
|
| 768 |
+
sex=Sex.MALE,
|
| 769 |
+
anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
|
| 770 |
+
),
|
| 771 |
+
lifestyle=Lifestyle(
|
| 772 |
+
smoking=SmokingHistory(
|
| 773 |
+
status=SmokingStatus.CURRENT,
|
| 774 |
+
cigarettes_per_day=20,
|
| 775 |
+
years_smoked=years,
|
| 776 |
+
years_since_quit=None,
|
| 777 |
+
)
|
| 778 |
+
),
|
| 779 |
+
personal_medical_history=PersonalMedicalHistory(),
|
| 780 |
+
family_history=[],
|
| 781 |
+
)
|
| 782 |
+
risk = float(self.model.compute_score(user).rstrip("%"))
|
| 783 |
+
risks.append(risk)
|
| 784 |
+
|
| 785 |
+
# Risk should increase with more smoking years
|
| 786 |
+
assert risks[0] < risks[1] < risks[2]
|