TB-SCREEN: Latent Tuberculosis Screening Score for Patients Starting Immunosuppressive Therapy
Latent tuberculosis reactivation on immunosuppressive therapy is a life-threatening risk. TB-SCREEN implements ACR 2015 and WHO 2020 screening guidelines as an executable scoring skill. Integrates epidemiological risk (country prevalence, exposure history), immunosuppression level (TNF inhibitors highest risk, JAK inhibitors, conventional DMARDs), TST/IGRA results, chest X-ray, and comorbidities. Produces screening urgency score and prophylaxis recommendations. Pure Python. Guideline implementation, not original research.
TB-SCREEN
Clinical Problem
TNF inhibitors increase TB reactivation risk 2-25× (Cantini 2015). Screening is mandatory before all biologic therapies per ACR and EULAR guidelines.
References
- Singh JA et al. Arthritis Rheumatol 2016;68:1-26 (ACR 2015). DOI:10.1002/art.39480
- WHO. Latent tuberculosis infection guidelines. WHO/HTM/TB/2018.01. 2018.
- Cantini F et al. Expert Opin Drug Saf 2015;14:169-82. DOI:10.1517/14740338.2015.997709
Limitations
- Guideline implementation, not original research
- Regional prevalence data may vary
Authors
Zamora-Tehozol EA (ORCID:0000-0002-7888-3961), DNAI
Reproducibility: Skill File
Use this skill file to reproduce the research with an AI agent.
# TB-SCREEN
**Tuberculosis Screening and Latent TB Reactivation Risk Stratification Before Biologic Therapy in Rheumatic Diseases with Monte Carlo Uncertainty Estimation**
## Authors
Erick Adrián Zamora Tehozol, DNAI, RheumaAI
## Overview
TB-SCREEN is an agent-executable clinical decision support tool that stratifies tuberculosis reactivation risk in patients with autoimmune rheumatic diseases initiating biologic or targeted synthetic DMARD therapy. It integrates TST/IGRA results, chest radiography findings, epidemiologic exposure history, immunosuppression burden, and biologic-specific TB risk profiles to generate a composite risk score (0–100) with Monte Carlo confidence intervals.
## Clinical Problem
Biologic therapies — particularly TNF-α inhibitors — carry significant risk of reactivating latent tuberculosis infection (LTBI). The WHO estimates one-quarter of the global population has LTBI. Reactivation rates with anti-TNF agents range from 4–40× baseline depending on the agent and population. Current screening relies on clinician judgment integrating multiple data streams (TST, IGRA, CXR, exposure history, immunosuppression level). TB-SCREEN systematizes this assessment.
## Scoring Domains (10 domains, max 100)
1. **TST Result** (0–12): Induration mm, immunosuppression-adjusted cutoffs (≥5mm for immunosuppressed per ATS/IDSA)
2. **IGRA Result** (0–15): QuantiFERON/T-SPOT, indeterminate handling, discordance with TST
3. **Chest Radiograph** (0–12): Apical scarring, calcified granulomas, cavitation, lymphadenopathy
4. **Epidemiologic Risk** (0–10): Endemic country birth/residence, household contact, congregate settings, healthcare worker
5. **Immunosuppression Burden** (0–12): Current GC dose, csDMARD count, prior cyclophosphamide/rituximab
6. **Biologic TB Risk Profile** (0–15): Agent-specific risk (infliximab > adalimumab > etanercept; JAKi intermediate; abatacept/tocilizumab moderate; rituximab high)
7. **HIV/Comorbidity** (0–8): HIV status, diabetes, CKD, malnutrition, silicosis
8. **Prior TB History** (0–8): Treated vs untreated, completeness of prior LTBI therapy
9. **Age & Demographics** (0–4): Age >65, pediatric, recent immigration (<5 years)
10. **Laboratory Markers** (0–4): Lymphopenia, low CD4, hypoalbuminemia
## Risk Categories
- **0–15**: Low Risk → Proceed with biologic, standard monitoring
- **16–35**: Moderate Risk → Consider LTBI treatment before biologic, recheck IGRA in 1–3 months
- **36–55**: High Risk → LTBI treatment mandatory before biologic initiation, 1–2 month lead time
- **56–100**: Very High Risk → Infectious disease consultation required, active TB workup (sputum AFB/culture, CT chest), defer biologic until TB ruled out or treatment completed
## Evidence Base
- Winthrop KL et al. Tuberculosis and other opportunistic infections in tofacitinib-treated patients with rheumatoid arthritis. Ann Rheum Dis 2016;75:1133–8.
- Keane J et al. Tuberculosis associated with infliximab, a TNF-α–neutralizing agent. N Engl J Med 2001;345:1098–104.
- Singh JA et al. 2015 ACR Guideline for the Treatment of RA. Arthritis Rheumatol 2016;68:1–26.
- Lewinsohn DM et al. Official ATS/IDSA/CDC Clinical Practice Guidelines: Diagnosis of TB in Adults and Children. Clin Infect Dis 2017;64:e1–e33.
- Cantini F et al. Risk of tuberculosis reactivation in patients with rheumatoid arthritis, ankylosing spondylitis, and psoriatic arthritis receiving non-anti-TNF-targeted biologics. Mediators Inflamm 2017;2017:8909834.
- Solovic I et al. The risk of tuberculosis related to TNF antagonist therapies: a TBNET consensus statement. Eur Respir J 2010;36:1185–206.
- WHO Guidelines on the management of LTBI. Geneva: WHO; 2015.
## Usage
```bash
python3 tb_screen.py
```
## Output
JSON with composite score, risk category, domain breakdown, Monte Carlo 95% CI, and clinical recommendations including specific LTBI treatment regimens and biologic timing guidance.
## Executable Code
```python
#!/usr/bin/env python3
"""
TB-SCREEN: Tuberculosis Screening and Latent TB Reactivation Risk Stratification
Before Biologic Therapy in Rheumatic Diseases with Monte Carlo Uncertainty Estimation
Authors: Erick Adrián Zamora Tehozol, DNAI, RheumaAI
License: MIT
"""
import json, math, random, statistics
from dataclasses import dataclass, field, asdict
from typing import Optional, List
# ── Domain 1: TST Result (0–12) ──────────────────────────────────────────────
def score_tst(induration_mm: float, immunosuppressed: bool = True) -> dict:
"""TST scoring with immunosuppression-adjusted cutoffs (ATS/IDSA 2017)."""
cutoff = 5 if immunosuppressed else 10
if induration_mm < cutoff:
score = 0
elif induration_mm < cutoff + 5:
score = 6
elif induration_mm < cutoff + 10:
score = 9
else:
score = 12
return {"domain": "TST Result", "score": score, "max": 12,
"detail": f"{induration_mm}mm (cutoff ≥{cutoff}mm)"}
# ── Domain 2: IGRA Result (0–15) ─────────────────────────────────────────────
def score_igra(result: str, tst_positive: bool = False) -> dict:
"""IGRA scoring: positive/negative/indeterminate/not_done."""
r = result.lower().strip()
if r == "positive":
score = 15
elif r == "indeterminate":
score = 8 if tst_positive else 5
elif r == "negative":
score = 2 if tst_positive else 0 # discordance penalty
else: # not_done
score = 3
return {"domain": "IGRA Result", "score": score, "max": 15, "detail": result}
# ── Domain 3: Chest Radiograph (0–12) ────────────────────────────────────────
CXR_FINDINGS = {
"normal": 0, "calcified_granuloma": 4, "apical_scarring": 6,
"fibronodular": 8, "lymphadenopathy": 7, "cavitation": 12,
"pleural_thickening": 3, "miliary_pattern": 12
}
def score_cxr(findings: List[str]) -> dict:
"""CXR scoring — takes worst finding."""
scores = [CXR_FINDINGS.get(f.lower().strip(), 0) for f in findings]
score = min(max(scores) if scores else 0, 12)
return {"domain": "Chest Radiograph", "score": score, "max": 12,
"detail": ", ".join(findings) if findings else "normal"}
# ── Domain 4: Epidemiologic Risk (0–10) ──────────────────────────────────────
def score_epidemiology(endemic_country: bool = False, household_contact: bool = False,
congregate_setting: bool = False, healthcare_worker: bool = False) -> dict:
score = 0
details = []
if endemic_country:
score += 5; details.append("endemic country")
if household_contact:
score += 4; details.append("household TB contact")
if congregate_setting:
score += 2; details.append("congregate setting")
if healthcare_worker:
score += 2; details.append("healthcare worker")
score = min(score, 10)
return {"domain": "Epidemiologic Risk", "score": score, "max": 10,
"detail": ", ".join(details) or "none"}
# ── Domain 5: Immunosuppression Burden (0–12) ────────────────────────────────
def score_immunosuppression(gc_dose_mg: float = 0, csdmard_count: int = 0,
prior_cyclophosphamide: bool = False,
prior_rituximab: bool = False) -> dict:
score = 0
details = []
# GC dose
if gc_dose_mg >= 20:
score += 5; details.append(f"pred {gc_dose_mg}mg (high)")
elif gc_dose_mg >= 10:
score += 3; details.append(f"pred {gc_dose_mg}mg (moderate)")
elif gc_dose_mg > 0:
score += 1; details.append(f"pred {gc_dose_mg}mg (low)")
# csDMARDs
if csdmard_count >= 3:
score += 3; details.append(f"{csdmard_count} csDMARDs (triple)")
elif csdmard_count >= 2:
score += 2; details.append(f"{csdmard_count} csDMARDs")
elif csdmard_count == 1:
score += 1; details.append("1 csDMARD")
# Prior heavy immunosuppression
if prior_cyclophosphamide:
score += 3; details.append("prior cyclophosphamide")
if prior_rituximab:
score += 2; details.append("prior rituximab")
score = min(score, 12)
return {"domain": "Immunosuppression Burden", "score": score, "max": 12,
"detail": ", ".join(details) or "minimal"}
# ── Domain 6: Biologic TB Risk Profile (0–15) ────────────────────────────────
BIOLOGIC_TB_RISK = {
"infliximab": 15, "adalimumab": 12, "certolizumab": 10, "golimumab": 10,
"etanercept": 5, "rituximab": 9, "abatacept": 7, "tocilizumab": 7,
"sarilumab": 7, "tofacitinib": 8, "baricitinib": 8, "upadacitinib": 8,
"filgotinib": 7, "secukinumab": 3, "ixekizumab": 3, "guselkumab": 3,
"belimumab": 4, "anifrolumab": 5, "none": 0
}
def score_biologic(agent: str) -> dict:
key = agent.lower().strip()
score = BIOLOGIC_TB_RISK.get(key, 6)
return {"domain": "Biologic TB Risk Profile", "score": min(score, 15), "max": 15,
"detail": agent}
# ── Domain 7: HIV/Comorbidity (0–8) ──────────────────────────────────────────
def score_comorbidity(hiv: bool = False, diabetes: bool = False,
ckd: bool = False, malnutrition: bool = False,
silicosis: bool = False) -> dict:
score = 0; details = []
if hiv: score += 4; details.append("HIV+")
if diabetes: score += 2; details.append("DM")
if ckd: score += 2; details.append("CKD")
if malnutrition: score += 2; details.append("malnutrition")
if silicosis: score += 2; details.append("silicosis")
score = min(score, 8)
return {"domain": "HIV/Comorbidity", "score": score, "max": 8,
"detail": ", ".join(details) or "none"}
# ── Domain 8: Prior TB History (0–8) ─────────────────────────────────────────
def score_tb_history(prior_active_tb: bool = False, prior_ltbi_treatment: bool = False,
ltbi_treatment_complete: bool = False) -> dict:
score = 0; details = []
if prior_active_tb:
score += 5; details.append("prior active TB")
if prior_ltbi_treatment and ltbi_treatment_complete:
score += 1; details.append("LTBI treated (complete)")
elif prior_ltbi_treatment and not ltbi_treatment_complete:
score += 4; details.append("LTBI treated (incomplete)")
score = min(score, 8)
return {"domain": "Prior TB History", "score": score, "max": 8,
"detail": ", ".join(details) or "no prior TB"}
# ── Domain 9: Age & Demographics (0–4) ───────────────────────────────────────
def score_demographics(age: int, recent_immigrant: bool = False) -> dict:
score = 0; details = []
if age >= 65:
score += 2; details.append("age ≥65")
if age < 5:
score += 2; details.append("pediatric <5")
if recent_immigrant:
score += 2; details.append("recent immigrant <5yr")
score = min(score, 4)
return {"domain": "Age & Demographics", "score": score, "max": 4,
"detail": ", ".join(details) or f"age {age}"}
# ── Domain 10: Laboratory Markers (0–4) ──────────────────────────────────────
def score_labs(lymphocyte_count: Optional[float] = None,
cd4_count: Optional[float] = None,
albumin: Optional[float] = None) -> dict:
score = 0; details = []
if lymphocyte_count is not None and lymphocyte_count < 1.0:
score += 2; details.append(f"lymphopenia {lymphocyte_count}")
if cd4_count is not None and cd4_count < 200:
score += 2; details.append(f"CD4 {cd4_count}")
if albumin is not None and albumin < 3.0:
score += 1; details.append(f"albumin {albumin}")
score = min(score, 4)
return {"domain": "Laboratory Markers", "score": score, "max": 4,
"detail": ", ".join(details) or "within normal limits"}
# ── Monte Carlo Simulation ────────────────────────────────────────────────────
def monte_carlo(domain_scores: list, n: int = 10000, seed: int = 42) -> dict:
rng = random.Random(seed)
composite = sum(d["score"] for d in domain_scores)
simulated = []
for _ in range(n):
total = 0
for d in domain_scores:
noise = rng.gauss(0, d["max"] * 0.08)
perturbed = max(0, min(d["max"], d["score"] + noise))
total += perturbed
simulated.append(total)
simulated.sort()
ci_low = round(simulated[int(0.025 * n)], 1)
ci_high = round(simulated[int(0.975 * n)], 1)
return {"composite": round(composite, 1), "ci_95_low": ci_low, "ci_95_high": ci_high,
"mean": round(statistics.mean(simulated), 1),
"sd": round(statistics.stdev(simulated), 1), "n_simulations": n}
# ── Risk Classification ──────────────────────────────────────────────────────
def classify(score: float) -> dict:
if score <= 15:
return {"category": "Low", "action": "Proceed with biologic. Standard TB monitoring.",
"ltbi_treatment": "Not indicated unless IGRA/TST positive."}
elif score <= 35:
return {"category": "Moderate",
"action": "Consider LTBI treatment before biologic. Recheck IGRA in 1-3 months.",
"ltbi_treatment": "Isoniazid 300mg daily × 9 months OR Rifampin 600mg daily × 4 months."}
elif score <= 55:
return {"category": "High",
"action": "LTBI treatment mandatory before biologic initiation. 1-2 month lead time recommended.",
"ltbi_treatment": "Isoniazid + Rifapentine weekly × 12 weeks (3HP) OR Isoniazid 9 months. Start biologic after ≥1 month of LTBI therapy."}
else:
return {"category": "Very High",
"action": "Infectious disease consultation required. Active TB workup: sputum AFB ×3, mycobacterial culture, CT chest. Defer biologic until TB ruled out or treatment completed.",
"ltbi_treatment": "If active TB: standard 4-drug regimen (RIPE) × 6-9 months. Do NOT start biologic until ≥2 months intensive phase completed with culture conversion."}
# ── Main Assessment ──────────────────────────────────────────────────────────
def tb_screen(patient: dict) -> dict:
domains = [
score_tst(patient.get("tst_mm", 0), patient.get("immunosuppressed", True)),
score_igra(patient.get("igra", "not_done"),
patient.get("tst_mm", 0) >= (5 if patient.get("immunosuppressed", True) else 10)),
score_cxr(patient.get("cxr_findings", ["normal"])),
score_epidemiology(patient.get("endemic_country", False),
patient.get("household_contact", False),
patient.get("congregate_setting", False),
patient.get("healthcare_worker", False)),
score_immunosuppression(patient.get("gc_dose_mg", 0),
patient.get("csdmard_count", 0),
patient.get("prior_cyclophosphamide", False),
patient.get("prior_rituximab", False)),
score_biologic(patient.get("biologic", "none")),
score_comorbidity(patient.get("hiv", False), patient.get("diabetes", False),
patient.get("ckd", False), patient.get("malnutrition", False),
patient.get("silicosis", False)),
score_tb_history(patient.get("prior_active_tb", False),
patient.get("prior_ltbi_treatment", False),
patient.get("ltbi_treatment_complete", False)),
score_demographics(patient.get("age", 50),
patient.get("recent_immigrant", False)),
score_labs(patient.get("lymphocyte_count"), patient.get("cd4_count"),
patient.get("albumin")),
]
mc = monte_carlo(domains)
risk = classify(mc["composite"])
return {
"tool": "TB-SCREEN",
"version": "1.0.0",
"composite_score": mc["composite"],
"max_possible": 100,
"risk_category": risk["category"],
"clinical_action": risk["action"],
"ltbi_treatment": risk["ltbi_treatment"],
"monte_carlo_95CI": [mc["ci_95_low"], mc["ci_95_high"]],
"mc_mean": mc["mean"],
"mc_sd": mc["sd"],
"domains": domains,
"biologic_specific_note": _biologic_note(patient.get("biologic", "none")),
"references": [
"Keane J et al. NEJM 2001;345:1098-104",
"Lewinsohn DM et al. Clin Infect Dis 2017;64:e1-e33 (ATS/IDSA/CDC)",
"Solovic I et al. Eur Respir J 2010;36:1185-206 (TBNET)",
"Winthrop KL et al. Ann Rheum Dis 2016;75:1133-8",
"Cantini F et al. Mediators Inflamm 2017;2017:8909834",
"WHO LTBI Management Guidelines 2015",
"Singh JA et al. Arthritis Rheumatol 2016;68:1-26 (ACR 2015)"
]
}
def _biologic_note(agent: str) -> str:
notes = {
"infliximab": "Highest TB reactivation risk among TNFi (monoclonal Ab, complement-mediated macrophage apoptosis). Mandatory LTBI screening. Granuloma dissolution risk.",
"adalimumab": "High TB risk (monoclonal Ab). Screen before initiation. Consider etanercept if TB risk factors present.",
"etanercept": "Lower TB risk vs monoclonal TNFi (soluble receptor, preserves granuloma integrity). Still requires screening.",
"tofacitinib": "JAK inhibitor — intermediate TB risk. Impairs IFN-γ signaling critical for TB containment.",
"rituximab": "B-cell depletion impairs granuloma maintenance. Screen for LTBI. Delayed immune reconstitution prolongs risk window.",
}
return notes.get(agent.lower().strip(), f"Standard LTBI screening required before {agent}.")
# ── Demo Scenarios ────────────────────────────────────────────────────────────
if __name__ == "__main__":
scenarios = [
{
"name": "Scenario 1: RA patient, Mexico-born, starting infliximab",
"patient": {
"age": 52, "tst_mm": 12, "igra": "positive",
"cxr_findings": ["calcified_granuloma"], "endemic_country": True,
"gc_dose_mg": 10, "csdmard_count": 2, "biologic": "infliximab",
"immunosuppressed": True
}
},
{
"name": "Scenario 2: Young RA, US-born, etanercept, all negative",
"patient": {
"age": 34, "tst_mm": 0, "igra": "negative",
"cxr_findings": ["normal"], "endemic_country": False,
"gc_dose_mg": 5, "csdmard_count": 1, "biologic": "etanercept",
"immunosuppressed": True
}
},
{
"name": "Scenario 3: SLE, HIV+, prior TB, rituximab, endemic",
"patient": {
"age": 45, "tst_mm": 18, "igra": "positive",
"cxr_findings": ["apical_scarring", "lymphadenopathy"],
"endemic_country": True, "household_contact": True,
"gc_dose_mg": 20, "csdmard_count": 2,
"prior_cyclophosphamide": True, "biologic": "rituximab",
"hiv": True, "diabetes": True,
"prior_active_tb": True, "prior_ltbi_treatment": True,
"ltbi_treatment_complete": False,
"lymphocyte_count": 0.6, "cd4_count": 180, "albumin": 2.8,
"immunosuppressed": True
}
}
]
for s in scenarios:
print(f"\n{'='*70}")
print(f" {s['name']}")
print(f"{'='*70}")
result = tb_screen(s["patient"])
print(f" Composite Score: {result['composite_score']}/100")
print(f" Risk Category: {result['risk_category']}")
print(f" 95% CI: [{result['monte_carlo_95CI'][0]}, {result['monte_carlo_95CI'][1]}]")
print(f" Action: {result['clinical_action']}")
print(f" LTBI Treatment: {result['ltbi_treatment']}")
print(f" Biologic Note: {result['biologic_specific_note']}")
print(f" Domains:")
for d in result["domains"]:
print(f" {d['domain']:30s} {d['score']:5.0f}/{d['max']} — {d['detail']}")
print()
```
## Demo Output
```
lt 0/15 — negative
Chest Radiograph 0/12 — normal
Epidemiologic Risk 0/10 — none
Immunosuppression Burden 2/12 — pred 5mg (low), 1 csDMARD
Biologic TB Risk Profile 5/15 — etanercept
HIV/Comorbidity 0/8 — none
Prior TB History 0/8 — no prior TB
Age & Demographics 0/4 — age 34
Laboratory Markers 0/4 — within normal limits
======================================================================
Scenario 3: SLE, HIV+, prior TB, rituximab, endemic
======================================================================
Composite Score: 80/100
Risk Category: Very High
95% CI: [74.2, 83.1]
Action: Infectious disease consultation required. Active TB workup: sputum AFB ×3, mycobacterial culture, CT chest. Defer biologic until TB ruled out or treatment completed.
LTBI Treatment: If active TB: standard 4-drug regimen (RIPE) × 6-9 months. Do NOT start biologic until ≥2 months intensive phase completed with culture conversion.
Biologic Note: B-cell depletion impairs granuloma maintenance. Screen for LTBI. Delayed immune reconstitution prolongs risk window.
Domains:
TST Result 12/12 — 18mm (cutoff ≥5mm)
IGRA Result 15/15 — positive
Chest Radiograph 7/12 — apical_scarring, lymphadenopathy
Epidemiologic Risk 9/10 — endemic country, household TB contact
Immunosuppression Burden 10/12 — pred 20mg (high), 2 csDMARDs, prior cyclophosphamide
Biologic TB Risk Profile 9/15 — rituximab
HIV/Comorbidity 6/8 — HIV+, DM
Prior TB History 8/8 — prior active TB, LTBI treated (incomplete)
Age & Demographics 0/4 — age 45
Laboratory Markers 4/4 — lymphopenia 0.6, CD4 180, albumin 2.8
```Discussion (0)
to join the discussion.
No comments yet. Be the first to discuss this paper.