jeuko commited on
Commit
0ba176c
·
verified ·
1 Parent(s): 7638cbd

Sync from GitHub (main)

Browse files
AGENTS.md CHANGED
@@ -168,13 +168,16 @@ The assistant currently includes the following built-in risk calculators:
168
 
169
  - **Gail** - Breast cancer risk
170
  - **Claus** - Breast cancer risk based on family history
 
171
  - **BOADICEA** - Breast and ovarian cancer risk (via CanRisk API)
172
  - **PLCOm2012** - Lung cancer risk
173
  - **LLPi** - Liverpool Lung Project improved model for lung cancer risk (8.7-year prediction)
174
  - **CRC-PRO** - Colorectal cancer risk
175
  - **PCPT** - Prostate cancer risk
176
  - **Extended PBCG** - Prostate cancer risk (extended model)
 
177
  - **MRAT** - Melanoma risk (5-year prediction)
 
178
  - **QCancer** - Multi-site cancer differential
179
 
180
  Additional models should follow the interfaces under `src/sentinel/risk_models`.
 
168
 
169
  - **Gail** - Breast cancer risk
170
  - **Claus** - Breast cancer risk based on family history
171
+ - **Tyrer-Cuzick** - Breast cancer risk (IBIS model)
172
  - **BOADICEA** - Breast and ovarian cancer risk (via CanRisk API)
173
  - **PLCOm2012** - Lung cancer risk
174
  - **LLPi** - Liverpool Lung Project improved model for lung cancer risk (8.7-year prediction)
175
  - **CRC-PRO** - Colorectal cancer risk
176
  - **PCPT** - Prostate cancer risk
177
  - **Extended PBCG** - Prostate cancer risk (extended model)
178
+ - **Prostate Mortality** - Prostate cancer-specific mortality prediction
179
  - **MRAT** - Melanoma risk (5-year prediction)
180
+ - **aMAP** - Hepatocellular carcinoma (liver cancer) risk
181
  - **QCancer** - Multi-site cancer differential
182
 
183
  Additional models should follow the interfaces under `src/sentinel/risk_models`.
GEMINI.md CHANGED
@@ -45,13 +45,16 @@ 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
  - **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.
 
45
  Risk calculators exposed to Gemini-based agents include:
46
  - **Gail** - Breast cancer risk
47
  - **Claus** - Breast cancer risk based on family history
48
+ - **Tyrer-Cuzick** - Breast cancer risk (IBIS model)
49
  - **BOADICEA** - Breast and ovarian cancer risk (via CanRisk API)
50
  - **PLCOm2012** - Lung cancer risk
51
  - **LLPi** - Liverpool Lung Project improved model for lung cancer risk (8.7-year prediction)
52
  - **CRC-PRO** - Colorectal cancer risk
53
  - **PCPT** - Prostate cancer risk
54
  - **Extended PBCG** - Prostate cancer risk (extended model)
55
+ - **Prostate Mortality** - Prostate cancer-specific mortality prediction
56
  - **MRAT** - Melanoma risk (5-year prediction)
57
+ - **aMAP** - Hepatocellular carcinoma (liver cancer) risk
58
  - **QCancer** - Multi-site cancer differential
59
 
60
  Register additional models in `src/sentinel/risk_models/__init__.py` so they are available system-wide.
README.md CHANGED
@@ -131,13 +131,16 @@ 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
 
131
 
132
  - **Gail** - Breast cancer risk
133
  - **Claus** - Breast cancer risk based on family history
134
+ - **Tyrer-Cuzick** - Breast cancer risk (IBIS model)
135
  - **BOADICEA** - Breast and ovarian cancer risk (via CanRisk API)
136
  - **PLCOm2012** - Lung cancer risk
137
  - **LLPi** - Liverpool Lung Project improved model for lung cancer risk (8.7-year prediction)
138
  - **CRC-PRO** - Colorectal cancer risk
139
  - **PCPT** - Prostate cancer risk
140
  - **Extended PBCG** - Prostate cancer risk (extended model)
141
+ - **Prostate Mortality** - Prostate cancer-specific mortality prediction
142
  - **MRAT** - Melanoma risk (5-year prediction)
143
+ - **aMAP** - Hepatocellular carcinoma (liver cancer) risk
144
  - **QCancer** - Multi-site cancer differential
145
 
146
  ## Generating Documentation
examples/benchmark/benchmark_female.yaml CHANGED
@@ -44,6 +44,14 @@ female_specific:
44
  hormone_use:
45
  estrogen_use: never
46
 
 
 
 
 
 
 
 
 
47
  symptoms:
48
  - symptom_type: breast_lump
49
  - symptom_type: weight_loss
 
44
  hormone_use:
45
  estrogen_use: never
46
 
47
+ clinical_tests:
48
+ albumin:
49
+ value_g_per_L: 42.0
50
+ bilirubin:
51
+ value_umol_per_L: 12.0
52
+ platelets:
53
+ value_10e9_per_L: 200.0
54
+
55
  symptoms:
56
  - symptom_type: breast_lump
57
  - symptom_type: weight_loss
examples/benchmark/benchmark_male.yaml CHANGED
@@ -34,6 +34,7 @@ family_history:
34
  personal_medical_history:
35
  chronic_conditions:
36
  - diabetes
 
37
  previous_cancers: []
38
  aspirin_use: never
39
 
@@ -44,6 +45,12 @@ clinical_tests:
44
  dre:
45
  result: normal
46
  date: 2025-09-15
 
 
 
 
 
 
47
 
48
  symptoms:
49
  - symptom_type: persistent_cough
 
34
  personal_medical_history:
35
  chronic_conditions:
36
  - diabetes
37
+ - chronic_hepatitis_b
38
  previous_cancers: []
39
  aspirin_use: never
40
 
 
45
  dre:
46
  result: normal
47
  date: 2025-09-15
48
+ albumin:
49
+ value_g_per_L: 36.0
50
+ bilirubin:
51
+ value_umol_per_L: 22.0
52
+ platelets:
53
+ value_10e9_per_L: 130.0
54
 
55
  symptoms:
56
  - symptom_type: persistent_cough
src/sentinel/risk_models/__init__.py CHANGED
@@ -1,5 +1,6 @@
1
  """Exports for available risk models and their registry."""
2
 
 
3
  from sentinel.risk_models.boadicea import BOADICEARiskModel
4
  from sentinel.risk_models.claus import ClausRiskModel
5
  from sentinel.risk_models.crc_pro import CRCProRiskModel
@@ -14,6 +15,7 @@ from sentinel.risk_models.qcancer import QCancerRiskModel
14
  from sentinel.risk_models.tyrer_cuzick import TyrerCuzickRiskModel
15
 
16
  RISK_MODELS = [
 
17
  GailRiskModel,
18
  PLCOm2012RiskModel,
19
  LLPiRiskModel,
@@ -30,6 +32,7 @@ RISK_MODELS = [
30
 
31
  __all__ = [
32
  "RISK_MODELS",
 
33
  "ClausRiskModel",
34
  "GailRiskModel",
35
  "LLPiRiskModel",
 
1
  """Exports for available risk models and their registry."""
2
 
3
+ from sentinel.risk_models.amap import AMAPRiskModel
4
  from sentinel.risk_models.boadicea import BOADICEARiskModel
5
  from sentinel.risk_models.claus import ClausRiskModel
6
  from sentinel.risk_models.crc_pro import CRCProRiskModel
 
15
  from sentinel.risk_models.tyrer_cuzick import TyrerCuzickRiskModel
16
 
17
  RISK_MODELS = [
18
+ AMAPRiskModel,
19
  GailRiskModel,
20
  PLCOm2012RiskModel,
21
  LLPiRiskModel,
 
32
 
33
  __all__ = [
34
  "RISK_MODELS",
35
+ "AMAPRiskModel",
36
  "ClausRiskModel",
37
  "GailRiskModel",
38
  "LLPiRiskModel",
src/sentinel/risk_models/amap.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """aMAP (Age-Male-ALBI-Platelets) risk model for hepatocellular carcinoma."""
2
+
3
+ from math import exp, log10
4
+
5
+ from sentinel.risk_models.base import RiskModel
6
+ from sentinel.user_input import AlbuminTest, BilirubinTest, PlateletTest, Sex, UserInput
7
+
8
+
9
+ class AMAPRiskModel(RiskModel):
10
+ """Compute HCC risk using the aMAP score for chronic hepatitis B patients.
11
+
12
+ The aMAP score combines Age, Male sex, ALBI (albumin-bilirubin) score,
13
+ and Platelet count to predict 5-year hepatocellular carcinoma risk.
14
+ """
15
+
16
+ REQUIRED_INPUTS: dict[str, tuple[type, bool]] = {
17
+ "demographics.age_years": (int, True),
18
+ "demographics.sex": (Sex, True),
19
+ "clinical_tests.albumin": (AlbuminTest, True),
20
+ "clinical_tests.bilirubin": (BilirubinTest, True),
21
+ "clinical_tests.platelets": (PlateletTest, True),
22
+ }
23
+
24
+ def __init__(self) -> None:
25
+ super().__init__("amap")
26
+
27
+ def compute_score(self, user: UserInput) -> str:
28
+ """Compute and return the aMAP risk score.
29
+
30
+ Args:
31
+ user: The user profile to score.
32
+
33
+ Returns:
34
+ String representation of aMAP score with risk band.
35
+
36
+ Raises:
37
+ ValueError: If required inputs are missing or invalid.
38
+ """
39
+ is_valid, errors = self.validate_inputs(user)
40
+ if not is_valid:
41
+ raise ValueError(f"Invalid inputs for {self.name}: {'; '.join(errors)}")
42
+
43
+ age_years = user.demographics.age_years
44
+ sex = user.demographics.sex
45
+ albumin_g_per_L = user.clinical_tests.albumin.value_g_per_L
46
+ bilirubin_umol_per_L = user.clinical_tests.bilirubin.value_umol_per_L
47
+ platelets_10e9_per_L = user.clinical_tests.platelets.value_10e9_per_L
48
+
49
+ score = self.amap_score(
50
+ age_years=age_years,
51
+ sex=sex,
52
+ albumin_g_per_L=albumin_g_per_L,
53
+ bilirubin_umol_per_L=bilirubin_umol_per_L,
54
+ platelets_10e9_per_L=platelets_10e9_per_L,
55
+ )
56
+
57
+ risk_probability, _ci = self.amap_5yr_band_stats(score)
58
+ risk_percent = risk_probability * 100.0
59
+
60
+ return f"{risk_percent:.1f}%"
61
+
62
+ def cancer_type(self) -> str:
63
+ """Return the cancer type handled by this model.
64
+
65
+ Returns:
66
+ Cancer type identifier.
67
+ """
68
+ return "liver"
69
+
70
+ def description(self) -> str:
71
+ """Return a short description of the model.
72
+
73
+ Returns:
74
+ Model description text.
75
+ """
76
+ return (
77
+ "The aMAP (Age-Male-ALBI-Platelets) score predicts 5-year risk of "
78
+ "hepatocellular carcinoma (HCC) in patients with chronic hepatitis B. "
79
+ "It combines age, sex, liver function tests (albumin, bilirubin), and "
80
+ "platelet count to stratify HCC risk into low, medium, and high categories."
81
+ )
82
+
83
+ def interpretation(self) -> str:
84
+ """Return a user-facing interpretation guideline for the score.
85
+
86
+ Returns:
87
+ Interpretation text for the risk output.
88
+ """
89
+ return (
90
+ "Output is the 5-year HCC risk based on validated band-level statistics from "
91
+ "Fan et al. 2020: Low risk (<50): 0.8%, Medium risk (50-60): 4.2%, High risk (>60): 19.9%. "
92
+ "These represent group-averaged risks with 95% CIs published in the original validation study. "
93
+ "Note: aMAP was originally validated in chronic hepatitis B patients."
94
+ )
95
+
96
+ def references(self) -> list[str]:
97
+ """Return academic or source references for the model.
98
+
99
+ Returns:
100
+ List of reference citations.
101
+ """
102
+ return [
103
+ "Fan R, Papatheodoridis G, Sun J, et al. aMAP risk score predicts hepatocellular "
104
+ "carcinoma development in patients with chronic hepatitis. J Hepatol. 2020;73(6):1368-1378.",
105
+ "Johnson PJ, Berhane S, Kagebayashi C, et al. Assessment of liver function in patients "
106
+ "with hepatocellular carcinoma: a new evidence-based approach-the ALBI grade. J Clin Oncol. "
107
+ "2015;33(6):550-558.",
108
+ "CUHK aMAP Calculator: https://mdac.cuhk.edu.hk/calculators/amap/",
109
+ ]
110
+
111
+ def time_horizon_years(self) -> float | None:
112
+ """Return time horizon in years for probability output.
113
+
114
+ Returns:
115
+ Number of years for risk prediction horizon.
116
+ """
117
+ return 5.0
118
+
119
+ @staticmethod
120
+ def amap_score(
121
+ *,
122
+ age_years: float,
123
+ sex: Sex,
124
+ albumin_g_per_L: float,
125
+ bilirubin_umol_per_L: float,
126
+ platelets_10e9_per_L: float,
127
+ clip_0_100: bool = True,
128
+ ) -> float:
129
+ """Compute the aMAP (Age-Male-ALBI-Platelets) HCC risk score on 0-100 scale.
130
+
131
+ Formula (Fan et al. 2020; Johnson et al. 2022):
132
+ ALBI = 0.66 * log10(bilirubin [µmol/L]) - 0.085 * albumin [g/L]
133
+ LP = 0.06 * age + 0.89 * male + 0.48 * ALBI - 0.01 * platelets [10^9/L]
134
+ aMAP = ((LP + 7.4) / 14.77) * 100
135
+
136
+ Args:
137
+ age_years: Age in years.
138
+ sex: Biological sex (male=1, female=0 for calculation).
139
+ albumin_g_per_L: Serum albumin in g/L.
140
+ bilirubin_umol_per_L: Total bilirubin in µmol/L.
141
+ platelets_10e9_per_L: Platelet count in 10^9/L (same as 10^3/µL).
142
+ clip_0_100: If True, clip to [0, 100].
143
+
144
+ Returns:
145
+ aMAP score on 0-100 scale.
146
+
147
+ Raises:
148
+ ValueError: If bilirubin is <= 0 (required for log10).
149
+ """
150
+ male = 1 if sex == Sex.MALE else 0
151
+
152
+ if bilirubin_umol_per_L <= 0:
153
+ raise ValueError("bilirubin must be > 0 µmol/L for log10")
154
+
155
+ albi = 0.66 * log10(bilirubin_umol_per_L) - 0.085 * albumin_g_per_L
156
+
157
+ linear_predictor = (
158
+ 0.06 * age_years + 0.89 * male + 0.48 * albi - 0.01 * platelets_10e9_per_L
159
+ )
160
+
161
+ score = ((linear_predictor + 7.4) / 14.77) * 100.0
162
+
163
+ if clip_0_100:
164
+ score = max(0.0, min(100.0, score))
165
+
166
+ return score
167
+
168
+ @staticmethod
169
+ def amap_risk_band(score: float) -> str:
170
+ """Map aMAP score to risk band.
171
+
172
+ Args:
173
+ score: aMAP score (0-100).
174
+
175
+ Returns:
176
+ str: Risk band category - "low", "medium", or "high".
177
+ """
178
+ if score < 50.0:
179
+ return "low"
180
+ if score <= 60.0:
181
+ return "medium"
182
+ return "high"
183
+
184
+ @staticmethod
185
+ def amap_lp_from_score(score: float) -> float:
186
+ """Return the linear predictor (LP) from aMAP score on 0-100 scale.
187
+
188
+ The aMAP score is a scaled version of the Cox linear predictor.
189
+ This inverts the scaling: LP = (score / 100) * 14.77 - 7.4
190
+
191
+ Args:
192
+ score: aMAP score (0-100).
193
+
194
+ Returns:
195
+ Linear predictor value.
196
+ """
197
+ return (score / 100.0) * 14.77 - 7.4
198
+
199
+ @staticmethod
200
+ def amap_5yr_risk_continuous(
201
+ score: float, baseline_survival_5yr: float = 0.984
202
+ ) -> float:
203
+ """Return continuous 5-year HCC risk using Cox model with baseline survival.
204
+
205
+ Uses the Cox proportional hazards formula: P(5) = 1 - S0(5)^exp(LP)
206
+ where S0(5) = 0.984 is the 5-year baseline survival from Fan et al. 2020.
207
+
208
+ Args:
209
+ score: aMAP score (0-100).
210
+ baseline_survival_5yr: Baseline 5-year survival probability (default: 0.984).
211
+
212
+ Returns:
213
+ 5-year HCC risk as probability (0-1 scale).
214
+
215
+ References:
216
+ Fan R et al. J Hepatol. 2020;73(6):1368-1378.
217
+ Johnson PJ et al. Br J Cancer. 2022;126(7):1021-1028.
218
+ """
219
+ linear_predictor = AMAPRiskModel.amap_lp_from_score(score)
220
+ return 1.0 - (baseline_survival_5yr ** exp(linear_predictor))
221
+
222
+ @staticmethod
223
+ def amap_5yr_band_stats(score: float) -> tuple[float, tuple[float, float]]:
224
+ """Return band-level 5-year risk and 95% CI matching CUHK calculator.
225
+
226
+ Returns the group-averaged risk for each band as published in Fan et al. 2020.
227
+ These are the values displayed on the CUHK web calculator.
228
+
229
+ Args:
230
+ score: aMAP score (0-100).
231
+
232
+ Returns:
233
+ Tuple of (risk_probability, (CI_lower, CI_upper)) on 0-1 scale.
234
+ - Low (<50): 0.8% (95% CI 0.3-1.3%)
235
+ - Medium (50-60): 4.2% (95% CI 2.6-5.7%)
236
+ - High (≥60): 19.9% (95% CI 12.8-26.5%)
237
+ """
238
+ band = AMAPRiskModel.amap_risk_band(score)
239
+ if band == "low":
240
+ return 0.008, (0.003, 0.013)
241
+ if band == "medium":
242
+ return 0.042, (0.026, 0.057)
243
+ return 0.199, (0.128, 0.265)
src/sentinel/user_input.py CHANGED
@@ -160,6 +160,7 @@ class ChronicCondition(str, Enum):
160
  CHRONIC_PANCREATITIS: Chronic pancreatitis
161
  ENDOMETRIAL_POLYPS: Endometrial polyps
162
  ANAEMIA: Anaemia (low hemoglobin)
 
163
  """
164
 
165
  COPD = "copd"
@@ -168,6 +169,7 @@ class ChronicCondition(str, Enum):
168
  CHRONIC_PANCREATITIS = "chronic_pancreatitis"
169
  ENDOMETRIAL_POLYPS = "endometrial_polyps"
170
  ANAEMIA = "anaemia"
 
171
 
172
 
173
  # ---------------------------------------------------------------------------
@@ -474,6 +476,91 @@ class ProstateVolumeTest(StrictBaseModel):
474
  )
475
 
476
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  class ClinicalTests(StrictBaseModel):
478
  """Container for all clinical test results.
479
 
@@ -484,6 +571,9 @@ class ClinicalTests(StrictBaseModel):
484
  t2erg: T2:ERG score test result
485
  dre: Digital rectal examination result
486
  prostate_volume: Prostate volume measurement
 
 
 
487
  """
488
 
489
  psa: PSATest | None = Field(None, description="PSA test result")
@@ -496,6 +586,13 @@ class ClinicalTests(StrictBaseModel):
496
  prostate_volume: ProstateVolumeTest | None = Field(
497
  None, description="Prostate volume measurement"
498
  )
 
 
 
 
 
 
 
499
 
500
 
501
  # ---------------------------------------------------------------------------
 
160
  CHRONIC_PANCREATITIS: Chronic pancreatitis
161
  ENDOMETRIAL_POLYPS: Endometrial polyps
162
  ANAEMIA: Anaemia (low hemoglobin)
163
+ CHRONIC_HEPATITIS_B: Chronic hepatitis B
164
  """
165
 
166
  COPD = "copd"
 
169
  CHRONIC_PANCREATITIS = "chronic_pancreatitis"
170
  ENDOMETRIAL_POLYPS = "endometrial_polyps"
171
  ANAEMIA = "anaemia"
172
+ CHRONIC_HEPATITIS_B = "chronic_hepatitis_b"
173
 
174
 
175
  # ---------------------------------------------------------------------------
 
476
  )
477
 
478
 
479
+ class AlbuminTest(StrictBaseModel):
480
+ """Serum albumin test result.
481
+
482
+ Albumin is a protein produced by the liver that helps maintain blood volume
483
+ and transport substances. Low albumin levels may indicate liver dysfunction,
484
+ malnutrition, or chronic disease. Normal range is typically 35-50 g/L.
485
+
486
+ Used in risk models:
487
+ - aMAP: Component of ALBI (Albumin-Bilirubin) score for liver cancer risk
488
+
489
+ Attributes:
490
+ value_g_per_L: Albumin value in g/L (valid range: 10-60)
491
+ date: Date when test was performed
492
+ """
493
+
494
+ value_g_per_L: float = Field(
495
+ ge=10,
496
+ le=60,
497
+ description="Albumin value in g/L",
498
+ examples=[35.0, 40.0, 45.0],
499
+ )
500
+ date: Date | None = Field(
501
+ None,
502
+ description="Date when test was performed",
503
+ examples=[Date(2023, 1, 15), Date(2024, 6, 20), Date(2025, 3, 10)],
504
+ )
505
+
506
+
507
+ class BilirubinTest(StrictBaseModel):
508
+ """Total bilirubin test result.
509
+
510
+ Bilirubin is a yellow pigment produced during the breakdown of red blood cells
511
+ and processed by the liver. Elevated bilirubin levels indicate liver dysfunction,
512
+ bile duct obstruction, or hemolytic conditions. Normal range is typically 5-20 µmol/L.
513
+
514
+ Used in risk models:
515
+ - aMAP: Component of ALBI (Albumin-Bilirubin) score for liver cancer risk
516
+
517
+ Attributes:
518
+ value_umol_per_L: Bilirubin value in µmol/L (valid range: 1-500)
519
+ date: Date when test was performed
520
+ """
521
+
522
+ value_umol_per_L: float = Field(
523
+ ge=1,
524
+ le=500,
525
+ description="Bilirubin value in µmol/L",
526
+ examples=[12.0, 20.0, 35.0],
527
+ )
528
+ date: Date | None = Field(
529
+ None,
530
+ description="Date when test was performed",
531
+ examples=[Date(2023, 1, 15), Date(2024, 6, 20), Date(2025, 3, 10)],
532
+ )
533
+
534
+
535
+ class PlateletTest(StrictBaseModel):
536
+ """Platelet count test result.
537
+
538
+ Platelets are blood cells essential for clotting. Low platelet counts (thrombocytopenia)
539
+ can indicate liver disease, bone marrow disorders, or increased destruction. In liver
540
+ disease, reduced platelet counts often result from portal hypertension and splenic
541
+ sequestration. Normal range is typically 150-400 × 10⁹/L.
542
+
543
+ Used in risk models:
544
+ - aMAP: Platelet count inversely correlates with liver cancer risk
545
+
546
+ Attributes:
547
+ value_10e9_per_L: Platelet count in 10^9/L (valid range: 10-1000)
548
+ date: Date when test was performed
549
+ """
550
+
551
+ value_10e9_per_L: float = Field(
552
+ ge=10,
553
+ le=1000,
554
+ description="Platelet count in 10^9/L",
555
+ examples=[150.0, 250.0, 350.0],
556
+ )
557
+ date: Date | None = Field(
558
+ None,
559
+ description="Date when test was performed",
560
+ examples=[Date(2023, 1, 15), Date(2024, 6, 20), Date(2025, 3, 10)],
561
+ )
562
+
563
+
564
  class ClinicalTests(StrictBaseModel):
565
  """Container for all clinical test results.
566
 
 
571
  t2erg: T2:ERG score test result
572
  dre: Digital rectal examination result
573
  prostate_volume: Prostate volume measurement
574
+ albumin: Serum albumin test result
575
+ bilirubin: Total bilirubin test result
576
+ platelets: Platelet count test result
577
  """
578
 
579
  psa: PSATest | None = Field(None, description="PSA test result")
 
586
  prostate_volume: ProstateVolumeTest | None = Field(
587
  None, description="Prostate volume measurement"
588
  )
589
+ albumin: AlbuminTest | None = Field(None, description="Serum albumin test result")
590
+ bilirubin: BilirubinTest | None = Field(
591
+ None, description="Total bilirubin test result"
592
+ )
593
+ platelets: PlateletTest | None = Field(
594
+ None, description="Platelet count test result"
595
+ )
596
 
597
 
598
  # ---------------------------------------------------------------------------
tests/test_risk_models/test_amap_model.py ADDED
@@ -0,0 +1,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the aMAP Liver Cancer Risk Model.
2
+
3
+ Ground truth values to be collected from: https://mdac.cuhk.edu.hk/calculators/amap/
4
+ """
5
+
6
+ import pytest
7
+
8
+ from sentinel.risk_models import AMAPRiskModel
9
+ from sentinel.user_input import (
10
+ AlbuminTest,
11
+ Anthropometrics,
12
+ BilirubinTest,
13
+ ChronicCondition,
14
+ ClinicalTests,
15
+ Demographics,
16
+ Lifestyle,
17
+ PersonalMedicalHistory,
18
+ PlateletTest,
19
+ Sex,
20
+ SmokingHistory,
21
+ SmokingStatus,
22
+ UserInput,
23
+ )
24
+
25
+ GROUND_TRUTH_CASES = [
26
+ {
27
+ "name": "young_male_low_risk",
28
+ "input": UserInput(
29
+ demographics=Demographics(
30
+ age_years=35,
31
+ sex=Sex.MALE,
32
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0),
33
+ ),
34
+ lifestyle=Lifestyle(
35
+ smoking=SmokingHistory(status=SmokingStatus.NEVER),
36
+ ),
37
+ personal_medical_history=PersonalMedicalHistory(
38
+ chronic_conditions=[ChronicCondition.CHRONIC_HEPATITIS_B]
39
+ ),
40
+ clinical_tests=ClinicalTests(
41
+ albumin=AlbuminTest(value_g_per_L=42.0),
42
+ bilirubin=BilirubinTest(value_umol_per_L=12.0),
43
+ platelets=PlateletTest(value_10e9_per_L=200.0),
44
+ ),
45
+ ),
46
+ "expected": 0.8, # From web calculator: Score 48, Risk: Low, 5-year HCC risk 0.8%
47
+ },
48
+ {
49
+ "name": "middle_aged_female_medium_risk",
50
+ "input": UserInput(
51
+ demographics=Demographics(
52
+ age_years=50,
53
+ sex=Sex.FEMALE,
54
+ anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0),
55
+ ),
56
+ lifestyle=Lifestyle(
57
+ smoking=SmokingHistory(status=SmokingStatus.NEVER),
58
+ ),
59
+ personal_medical_history=PersonalMedicalHistory(
60
+ chronic_conditions=[ChronicCondition.CHRONIC_HEPATITIS_B]
61
+ ),
62
+ clinical_tests=ClinicalTests(
63
+ albumin=AlbuminTest(value_g_per_L=35.0),
64
+ bilirubin=BilirubinTest(value_umol_per_L=20.0),
65
+ platelets=PlateletTest(value_10e9_per_L=120.0),
66
+ ),
67
+ ),
68
+ "expected": 4.2, # From web calculator: Score 55, Risk: Intermediate, 5-year HCC risk 4.2%
69
+ },
70
+ {
71
+ "name": "elderly_male_high_risk",
72
+ "input": UserInput(
73
+ demographics=Demographics(
74
+ age_years=70,
75
+ sex=Sex.MALE,
76
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0),
77
+ ),
78
+ lifestyle=Lifestyle(
79
+ smoking=SmokingHistory(status=SmokingStatus.NEVER),
80
+ ),
81
+ personal_medical_history=PersonalMedicalHistory(
82
+ chronic_conditions=[ChronicCondition.CHRONIC_HEPATITIS_B]
83
+ ),
84
+ clinical_tests=ClinicalTests(
85
+ albumin=AlbuminTest(value_g_per_L=28.0),
86
+ bilirubin=BilirubinTest(value_umol_per_L=40.0),
87
+ platelets=PlateletTest(value_10e9_per_L=80.0),
88
+ ),
89
+ ),
90
+ "expected": 19.9, # From web calculator: Score 75, Risk: High, 5-year HCC risk 19.9%
91
+ },
92
+ {
93
+ "name": "edge_case_low_platelets",
94
+ "input": UserInput(
95
+ demographics=Demographics(
96
+ age_years=45,
97
+ sex=Sex.FEMALE,
98
+ anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0),
99
+ ),
100
+ lifestyle=Lifestyle(
101
+ smoking=SmokingHistory(status=SmokingStatus.NEVER),
102
+ ),
103
+ personal_medical_history=PersonalMedicalHistory(
104
+ chronic_conditions=[ChronicCondition.CHRONIC_HEPATITIS_B]
105
+ ),
106
+ clinical_tests=ClinicalTests(
107
+ albumin=AlbuminTest(value_g_per_L=38.0),
108
+ bilirubin=BilirubinTest(value_umol_per_L=15.0),
109
+ platelets=PlateletTest(value_10e9_per_L=50.0),
110
+ ),
111
+ ),
112
+ "expected": 4.2, # From web calculator: Score 57, Risk: Intermediate, 5-year HCC risk 4.2%
113
+ },
114
+ {
115
+ "name": "edge_case_high_bilirubin",
116
+ "input": UserInput(
117
+ demographics=Demographics(
118
+ age_years=60,
119
+ sex=Sex.MALE,
120
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0),
121
+ ),
122
+ lifestyle=Lifestyle(
123
+ smoking=SmokingHistory(status=SmokingStatus.NEVER),
124
+ ),
125
+ personal_medical_history=PersonalMedicalHistory(
126
+ chronic_conditions=[ChronicCondition.CHRONIC_HEPATITIS_B]
127
+ ),
128
+ clinical_tests=ClinicalTests(
129
+ albumin=AlbuminTest(value_g_per_L=32.0),
130
+ bilirubin=BilirubinTest(value_umol_per_L=60.0),
131
+ platelets=PlateletTest(value_10e9_per_L=100.0),
132
+ ),
133
+ ),
134
+ "expected": 19.9, # From web calculator: Score 69, Risk: High, 5-year HCC risk 19.9%
135
+ },
136
+ {
137
+ "name": "boundary_case_low_medium",
138
+ "input": UserInput(
139
+ demographics=Demographics(
140
+ age_years=40,
141
+ sex=Sex.FEMALE,
142
+ anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0),
143
+ ),
144
+ lifestyle=Lifestyle(
145
+ smoking=SmokingHistory(status=SmokingStatus.NEVER),
146
+ ),
147
+ personal_medical_history=PersonalMedicalHistory(
148
+ chronic_conditions=[ChronicCondition.CHRONIC_HEPATITIS_B]
149
+ ),
150
+ clinical_tests=ClinicalTests(
151
+ albumin=AlbuminTest(value_g_per_L=36.0),
152
+ bilirubin=BilirubinTest(value_umol_per_L=18.0),
153
+ platelets=PlateletTest(value_10e9_per_L=140.0),
154
+ ),
155
+ ),
156
+ "expected": 0.8, # Actual score 49.6 (web shows rounded 50) → Low risk (<50) → 0.8%
157
+ },
158
+ {
159
+ "name": "boundary_case_medium_high",
160
+ "input": UserInput(
161
+ demographics=Demographics(
162
+ age_years=55,
163
+ sex=Sex.MALE,
164
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0),
165
+ ),
166
+ lifestyle=Lifestyle(
167
+ smoking=SmokingHistory(status=SmokingStatus.NEVER),
168
+ ),
169
+ personal_medical_history=PersonalMedicalHistory(
170
+ chronic_conditions=[ChronicCondition.CHRONIC_HEPATITIS_B]
171
+ ),
172
+ clinical_tests=ClinicalTests(
173
+ albumin=AlbuminTest(value_g_per_L=33.0),
174
+ bilirubin=BilirubinTest(value_umol_per_L=25.0),
175
+ platelets=PlateletTest(value_10e9_per_L=110.0),
176
+ ),
177
+ ),
178
+ "expected": 19.9, # From web calculator: Score 65, Risk: High, 5-year HCC risk 19.9%
179
+ },
180
+ ]
181
+
182
+
183
+ class TestAMAPModel:
184
+ """Test suite for AMAPRiskModel."""
185
+
186
+ def setup_method(self) -> None:
187
+ """Initialize AMAPRiskModel instance for testing."""
188
+ self.model = AMAPRiskModel()
189
+
190
+ @pytest.mark.parametrize("case", GROUND_TRUTH_CASES, ids=lambda x: x["name"])
191
+ def test_ground_truth_placeholders(self, case):
192
+ """Placeholder test for ground truth validation.
193
+
194
+ Once expected values are filled in from the web calculator,
195
+ this will validate our implementation against known reference values.
196
+
197
+ Args:
198
+ case: Parameterized ground truth case dict.
199
+ """
200
+ user_input = case["input"]
201
+ score_str = self.model.compute_score(user_input)
202
+
203
+ # Verify we get a valid output format
204
+ assert isinstance(score_str, str)
205
+ assert score_str.endswith("%")
206
+
207
+ # Validate against expected percentage
208
+ expected_percent = case["expected"]
209
+ if expected_percent is not None:
210
+ # Extract probability percentage from output
211
+ actual_percent = float(score_str.rstrip("%"))
212
+
213
+ # Should exactly match expected percentage
214
+ assert actual_percent == pytest.approx(expected_percent, abs=0.1)
215
+
216
+ def test_compute_score_male_with_hep_b(self):
217
+ """Test male patient with chronic hepatitis B."""
218
+ user = UserInput(
219
+ demographics=Demographics(
220
+ age_years=55,
221
+ sex=Sex.MALE,
222
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0),
223
+ ),
224
+ lifestyle=Lifestyle(
225
+ smoking=SmokingHistory(status=SmokingStatus.NEVER),
226
+ ),
227
+ personal_medical_history=PersonalMedicalHistory(
228
+ chronic_conditions=[ChronicCondition.CHRONIC_HEPATITIS_B]
229
+ ),
230
+ clinical_tests=ClinicalTests(
231
+ albumin=AlbuminTest(value_g_per_L=40.0),
232
+ bilirubin=BilirubinTest(value_umol_per_L=14.0),
233
+ platelets=PlateletTest(value_10e9_per_L=180.0),
234
+ ),
235
+ )
236
+
237
+ score_str = self.model.compute_score(user)
238
+ assert score_str.endswith("%")
239
+ # Should be a valid percentage
240
+ assert float(score_str.rstrip("%")) > 0
241
+
242
+ def test_compute_score_female_without_hep_b(self):
243
+ """Test female patient without chronic hepatitis B."""
244
+ user = UserInput(
245
+ demographics=Demographics(
246
+ age_years=45,
247
+ sex=Sex.FEMALE,
248
+ anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0),
249
+ ),
250
+ lifestyle=Lifestyle(
251
+ smoking=SmokingHistory(status=SmokingStatus.NEVER),
252
+ ),
253
+ personal_medical_history=PersonalMedicalHistory(
254
+ chronic_conditions=[] # No hepatitis B
255
+ ),
256
+ clinical_tests=ClinicalTests(
257
+ albumin=AlbuminTest(value_g_per_L=38.0),
258
+ bilirubin=BilirubinTest(value_umol_per_L=15.0),
259
+ platelets=PlateletTest(value_10e9_per_L=150.0),
260
+ ),
261
+ )
262
+
263
+ score_str = self.model.compute_score(user)
264
+ assert score_str.endswith("%")
265
+ # Should be a valid percentage
266
+ assert float(score_str.rstrip("%")) > 0
267
+
268
+ def test_missing_albumin(self):
269
+ """Test that missing albumin raises ValueError."""
270
+ user = UserInput(
271
+ demographics=Demographics(
272
+ age_years=50,
273
+ sex=Sex.MALE,
274
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0),
275
+ ),
276
+ lifestyle=Lifestyle(
277
+ smoking=SmokingHistory(status=SmokingStatus.NEVER),
278
+ ),
279
+ personal_medical_history=PersonalMedicalHistory(),
280
+ clinical_tests=ClinicalTests(
281
+ # albumin missing
282
+ bilirubin=BilirubinTest(value_umol_per_L=15.0),
283
+ platelets=PlateletTest(value_10e9_per_L=150.0),
284
+ ),
285
+ )
286
+
287
+ with pytest.raises(ValueError, match=r"Invalid inputs for amap.*albumin"):
288
+ self.model.compute_score(user)
289
+
290
+ def test_missing_bilirubin(self):
291
+ """Test that missing bilirubin raises ValueError."""
292
+ user = UserInput(
293
+ demographics=Demographics(
294
+ age_years=50,
295
+ sex=Sex.MALE,
296
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0),
297
+ ),
298
+ lifestyle=Lifestyle(
299
+ smoking=SmokingHistory(status=SmokingStatus.NEVER),
300
+ ),
301
+ personal_medical_history=PersonalMedicalHistory(),
302
+ clinical_tests=ClinicalTests(
303
+ albumin=AlbuminTest(value_g_per_L=38.0),
304
+ # bilirubin missing
305
+ platelets=PlateletTest(value_10e9_per_L=150.0),
306
+ ),
307
+ )
308
+
309
+ with pytest.raises(ValueError, match=r"Invalid inputs for amap.*bilirubin"):
310
+ self.model.compute_score(user)
311
+
312
+ def test_missing_platelets(self):
313
+ """Test that missing platelets raises ValueError."""
314
+ user = UserInput(
315
+ demographics=Demographics(
316
+ age_years=50,
317
+ sex=Sex.MALE,
318
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0),
319
+ ),
320
+ lifestyle=Lifestyle(
321
+ smoking=SmokingHistory(status=SmokingStatus.NEVER),
322
+ ),
323
+ personal_medical_history=PersonalMedicalHistory(),
324
+ clinical_tests=ClinicalTests(
325
+ albumin=AlbuminTest(value_g_per_L=38.0),
326
+ bilirubin=BilirubinTest(value_umol_per_L=15.0),
327
+ # platelets missing
328
+ ),
329
+ )
330
+
331
+ with pytest.raises(ValueError, match=r"Invalid inputs for amap.*platelets"):
332
+ self.model.compute_score(user)
333
+
334
+ def test_amap_score_calculation(self):
335
+ """Test direct aMAP score calculation method."""
336
+ # Example from the provided code snippet
337
+ score = self.model.amap_score(
338
+ age_years=55,
339
+ sex=Sex.MALE,
340
+ albumin_g_per_L=40.0,
341
+ bilirubin_umol_per_L=14.0,
342
+ platelets_10e9_per_L=180.0,
343
+ )
344
+
345
+ # Score should be between 0 and 100
346
+ assert 0.0 <= score <= 100.0
347
+ assert isinstance(score, float)
348
+
349
+ def test_amap_risk_band_low(self):
350
+ """Test risk band classification for low risk."""
351
+ assert self.model.amap_risk_band(30.0) == "low"
352
+ assert self.model.amap_risk_band(49.9) == "low"
353
+
354
+ def test_amap_risk_band_medium(self):
355
+ """Test risk band classification for medium risk."""
356
+ assert self.model.amap_risk_band(50.0) == "medium"
357
+ assert self.model.amap_risk_band(55.0) == "medium"
358
+ assert self.model.amap_risk_band(60.0) == "medium"
359
+
360
+ def test_amap_risk_band_high(self):
361
+ """Test risk band classification for high risk."""
362
+ assert self.model.amap_risk_band(60.1) == "high"
363
+ assert self.model.amap_risk_band(80.0) == "high"
364
+
365
+ def test_model_metadata(self):
366
+ """Test model metadata methods."""
367
+ assert self.model.name == "amap"
368
+ assert self.model.cancer_type() == "liver"
369
+ assert "aMAP" in self.model.description()
370
+ assert "hepatocellular carcinoma" in self.model.description().lower()
371
+ assert "low risk" in self.model.interpretation().lower()
372
+ assert isinstance(self.model.references(), list)
373
+ assert len(self.model.references()) > 0
374
+ assert self.model.time_horizon_years() == 5.0
375
+
376
+ def test_invalid_bilirubin_zero(self):
377
+ """Test that zero bilirubin raises ValueError (needed for log10)."""
378
+ with pytest.raises(ValueError, match=r"bilirubin must be > 0"):
379
+ self.model.amap_score(
380
+ age_years=50,
381
+ sex=Sex.MALE,
382
+ albumin_g_per_L=40.0,
383
+ bilirubin_umol_per_L=0.0, # Invalid: zero
384
+ platelets_10e9_per_L=150.0,
385
+ )
386
+
387
+ def test_score_clipping(self):
388
+ """Test that aMAP score is clipped to 0-100 range."""
389
+ # Test with extreme values that might produce out-of-range scores
390
+ score_clipped = self.model.amap_score(
391
+ age_years=80,
392
+ sex=Sex.MALE,
393
+ albumin_g_per_L=20.0, # Very low
394
+ bilirubin_umol_per_L=100.0, # Very high
395
+ platelets_10e9_per_L=20.0, # Very low
396
+ clip_0_100=True,
397
+ )
398
+ assert 0.0 <= score_clipped <= 100.0
399
+
400
+ # Test without clipping
401
+ score_unclipped = self.model.amap_score(
402
+ age_years=80,
403
+ sex=Sex.MALE,
404
+ albumin_g_per_L=20.0,
405
+ bilirubin_umol_per_L=100.0,
406
+ platelets_10e9_per_L=20.0,
407
+ clip_0_100=False,
408
+ )
409
+ # Unclipped might be outside range
410
+ assert isinstance(score_unclipped, float)