Recipe 6.7: Python Implementation Example
Heads up: This is a deliberately simplified, illustrative implementation of the clinical trial patient matching pipeline from Recipe 6.7. It demonstrates the multi-stage filtering approach (structured pre-screen, NLP deep screen, scoring) using synthetic data and boto3 calls. It is not production-ready. Real trial matching requires validated criteria parsers, IRB-approved workflows, and integration with your EHR. Think of this as the sketch on the whiteboard, not the blueprint you'd hand to a contractor.
Setup
You'll need the AWS SDK for Python and a few standard libraries:
pip install boto3
Your environment needs credentials configured (via environment variables, an instance profile, or ~/.aws/credentials). The IAM role or user needs:
comprehendmedical:DetectEntitiesV2(Comprehend Medical)athena:StartQueryExecution,athena:GetQueryExecution,athena:GetQueryResultss3:GetObject,s3:PutObjectdynamodb:PutItem,dynamodb:Query
Config and Constants
Before the logic, here's the configuration that drives the matching pipeline. Trial criteria definitions, scoring weights, and thresholds all live here so they're easy to find and adjust per trial.
import json import logging import time from datetime import datetime, timezone, timedelta from decimal import Decimal import boto3 from botocore.config import Config # Structured logging. Never log PHI field values (patient names, MRNs, etc.). logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) # Retry config for AWS API calls. Adaptive mode handles throttling gracefully. BOTO3_RETRY_CONFIG = Config(retries={"max_attempts": 3, "mode": "adaptive"}) # AWS clients comprehend_medical = boto3.client("comprehendmedical", config=BOTO3_RETRY_CONFIG) athena_client = boto3.client("athena", config=BOTO3_RETRY_CONFIG) dynamodb = boto3.resource("dynamodb", config=BOTO3_RETRY_CONFIG) s3_client = boto3.client("s3", config=BOTO3_RETRY_CONFIG) # Configuration RESULTS_BUCKET = "trial-matching-results" ATHENA_OUTPUT = "s3://trial-matching-results/athena-output/" ATHENA_DATABASE = "patient_data_lake" CANDIDATES_TABLE = "trial-candidates" # Confidence threshold for NLP-based criterion evaluation. # Below this, we mark the criterion as UNCERTAIN rather than PASS/FAIL. NLP_CONFIDENCE_THRESHOLD = 0.75 # Scoring weights by criterion type. Structured criteria get higher weight # because they're deterministic. NLP criteria get lower weight because # they carry inherent uncertainty. SCORING_WEIGHTS = { "STRUCTURED": 1.0, "UNSTRUCTURED": 0.8, "BOTH": 0.9, }
Synthetic Trial Criteria
Real trial criteria come from ClinicalTrials.gov. For this example, we define a simplified set of criteria for a fictional GLP-1 combination therapy trial. Each criterion specifies what data source it needs and how to evaluate it.
# A simplified representation of parsed trial criteria. # In production, these would be generated by a criteria parser (see Step 1 in the main recipe). # Each criterion has: # - criterion_type: INCLUSION (must meet) or EXCLUSION (must not meet) # - data_source: STRUCTURED, UNSTRUCTURED, or BOTH # - evaluation: how to check this criterion against patient data SAMPLE_TRIAL_CRITERIA = { "trial_id": "NCT05891234", "trial_name": "GLP-1 Combination Therapy for T2DM", "criteria": [ { "criterion_id": "crit-001", "criterion_type": "INCLUSION", "raw_text": "Adults aged 30-65", "data_source": "STRUCTURED", "logic": {"field": "age", "operator": "BETWEEN", "value_low": 30, "value_high": 65}, }, { "criterion_id": "crit-002", "criterion_type": "INCLUSION", "raw_text": "Diagnosis of Type 2 Diabetes (ICD-10: E11.x)", "data_source": "STRUCTURED", "logic": {"field": "diagnosis_codes", "operator": "HAS_PREFIX", "value": "E11"}, }, { "criterion_id": "crit-003", "criterion_type": "INCLUSION", "raw_text": "A1C between 7.5% and 10.5% within the past 90 days", "data_source": "STRUCTURED", "logic": { "field": "lab_a1c", "operator": "BETWEEN", "value_low": 7.5, "value_high": 10.5, "recency_days": 90, }, }, { "criterion_id": "crit-004", "criterion_type": "INCLUSION", "raw_text": "On metformin monotherapy for at least 90 days", "data_source": "STRUCTURED", "logic": { "field": "active_medications", "operator": "CONTAINS", # Simplified: checks metformin presence + duration only. # "Monotherapy" enforcement would require checking no other # antidiabetics are active. "value": "metformin", "duration_days": 90, }, }, { "criterion_id": "crit-005", "criterion_type": "INCLUSION", "raw_text": "BMI over 27", "data_source": "STRUCTURED", "logic": {"field": "bmi", "operator": "GT", "value": 27.0}, }, { "criterion_id": "crit-006", "criterion_type": "EXCLUSION", "raw_text": "No history of pancreatitis", "data_source": "BOTH", "logic": { "structured": {"field": "diagnosis_codes", "operator": "HAS_PREFIX", "value": "K85"}, "nlp": {"search_terms": ["pancreatitis"], "require_negation": False}, }, }, { "criterion_id": "crit-007", "criterion_type": "EXCLUSION", "raw_text": "No eGFR below 45 in the past 6 months", "data_source": "STRUCTURED", "logic": { "field": "lab_egfr", "operator": "GTE", "value": 45, "recency_days": 180, }, }, { "criterion_id": "crit-008", "criterion_type": "EXCLUSION", "raw_text": "No active cancer diagnosis in the past 5 years", "data_source": "BOTH", "logic": { "structured": {"field": "diagnosis_codes", "operator": "HAS_PREFIX", "value": "C"}, "nlp": {"search_terms": ["cancer", "malignancy", "carcinoma", "tumor"], "require_negation": False}, }, }, ], }
Synthetic Patient Data
In production, patient data comes from your EHR data lake via Athena queries. Here we define synthetic patients to demonstrate the matching logic. (Use Synthea for realistic synthetic data in development. Never use real PHI.)
# Synthetic patient records for demonstration. # In production, this data lives in S3/Athena and you query it with SQL. # These records simulate what you'd get from a structured EHR extract. SYNTHETIC_PATIENTS = [ { "patient_id": "PAT-001", "age": 54, "diagnosis_codes": ["E11.65", "E78.5", "I10"], # T2DM, hyperlipidemia, HTN "lab_a1c": {"value": 8.2, "date": "2026-04-15"}, "lab_egfr": {"value": 72, "date": "2026-03-20"}, "bmi": 31.4, "active_medications": [ {"name": "metformin", "start_date": "2025-06-01"}, {"name": "lisinopril", "start_date": "2024-01-15"}, ], "clinical_notes": [ "Patient well-controlled on metformin 1000mg BID. No GI side effects. " "Denies any history of pancreatitis. Family history notable for mother " "with pancreatic cancer but patient has no personal history of malignancy.", ], }, { "patient_id": "PAT-002", "age": 42, "diagnosis_codes": ["E11.9", "E66.01"], # T2DM, morbid obesity "lab_a1c": {"value": 9.1, "date": "2026-05-01"}, "lab_egfr": {"value": 88, "date": "2026-04-10"}, "bmi": 38.2, "active_medications": [ {"name": "metformin", "start_date": "2025-11-15"}, {"name": "empagliflozin", "start_date": "2026-01-10"}, # SGLT2 inhibitor ], "clinical_notes": [ "Started empagliflozin in January for additional glycemic control. " "Patient interested in clinical trial options. No contraindications noted. " "No history of pancreatitis or cancer.", ], }, { "patient_id": "PAT-003", "age": 67, # Too old for trial (max 65) "diagnosis_codes": ["E11.22", "I25.10"], # T2DM, CAD "lab_a1c": {"value": 7.8, "date": "2026-04-20"}, "lab_egfr": {"value": 55, "date": "2026-04-20"}, "bmi": 29.1, "active_medications": [ {"name": "metformin", "start_date": "2023-03-01"}, ], "clinical_notes": [ "Stable on current regimen. History of acute pancreatitis in 2022, " "resolved without complications. No recurrence.", ], }, { "patient_id": "PAT-004", "age": 38, "diagnosis_codes": ["E11.40", "K85.9"], # T2DM, acute pancreatitis (coded) "lab_a1c": {"value": 8.8, "date": "2026-05-10"}, "lab_egfr": {"value": 95, "date": "2026-05-10"}, "bmi": 28.5, "active_medications": [ {"name": "metformin", "start_date": "2025-08-01"}, ], "clinical_notes": [ "Episode of acute pancreatitis in March 2026. Resolved. " "Resumed metformin after recovery. No other issues.", ], }, { "patient_id": "PAT-005", "age": 51, "diagnosis_codes": ["E11.65", "E78.0"], # T2DM, hypercholesterolemia "lab_a1c": {"value": 7.9, "date": "2026-05-05"}, "lab_egfr": {"value": 82, "date": "2026-04-28"}, "bmi": 29.8, "active_medications": [ {"name": "metformin", "start_date": "2025-01-10"}, {"name": "atorvastatin", "start_date": "2024-06-01"}, ], "clinical_notes": [ "Diabetes well managed. No complications. No history of pancreatitis " "or malignancy. Patient expressed interest in research participation " "at last visit.", ], }, ]
Step 1: Structured Pre-Screen
The main recipe's pseudocode calls this structured_prescreen(). It evaluates all criteria that can be resolved from structured data alone (demographics, labs, medications, diagnosis codes). This eliminates the majority of the population quickly and cheaply.
In production, this would be an Athena SQL query against your patient data lake. Here we implement the logic in Python against our synthetic data to show the evaluation mechanics.
def evaluate_structured_criterion(patient: dict, criterion: dict, reference_date: datetime) -> dict: """ Evaluate a single structured criterion against a patient record. Returns a dict with: status: "PASS", "FAIL", or "UNCERTAIN" confidence: 0.0-1.0 (structured criteria are typically 1.0 or 0.0) evidence: human-readable explanation of the determination """ logic = criterion["logic"] # Handle criteria that have both structured and NLP components. # For the structured evaluation, use the "structured" sub-logic. if "structured" in logic: logic = logic["structured"] field = logic["field"] operator = logic["operator"] # Age check if field == "age": age = patient.get("age") if age is None: return {"status": "UNCERTAIN", "confidence": 0.0, "evidence": "Age not available"} if operator == "BETWEEN": passed = logic["value_low"] <= age <= logic["value_high"] return { "status": "PASS" if passed else "FAIL", "confidence": 1.0, "evidence": f"Age: {age} (required: {logic['value_low']}-{logic['value_high']})", } # Diagnosis code prefix check if field == "diagnosis_codes": codes = patient.get("diagnosis_codes", []) prefix = logic["value"] has_match = any(code.startswith(prefix) for code in codes) if operator == "HAS_PREFIX": # For INCLUSION: having the code is a PASS # For EXCLUSION: having the code is a FAIL (handled by caller) return { "status": "PASS" if has_match else "FAIL", "confidence": 1.0, "evidence": f"Codes matching '{prefix}*': {[c for c in codes if c.startswith(prefix)]}", } # Lab value checks (with recency) if field.startswith("lab_"): lab_key = field # e.g., "lab_a1c" or "lab_egfr" lab_data = patient.get(lab_key) if lab_data is None: return {"status": "UNCERTAIN", "confidence": 0.0, "evidence": f"No {lab_key} on record"} lab_value = lab_data["value"] lab_date = datetime.strptime(lab_data["date"], "%Y-%m-%d").replace(tzinfo=timezone.utc) # Check recency if specified recency_days = logic.get("recency_days") if recency_days: cutoff = reference_date - timedelta(days=recency_days) if lab_date < cutoff: return { "status": "UNCERTAIN", "confidence": 0.3, "evidence": f"{lab_key} value {lab_value} is from {lab_data['date']} (older than {recency_days} days)", } # Evaluate the value against the operator if operator == "BETWEEN": passed = logic["value_low"] <= lab_value <= logic["value_high"] elif operator == "GTE": passed = lab_value >= logic["value"] elif operator == "GT": passed = lab_value > logic["value"] else: passed = False return { "status": "PASS" if passed else "FAIL", "confidence": 1.0, "evidence": f"{lab_key}: {lab_value} (from {lab_data['date']})", } # BMI check if field == "bmi": bmi = patient.get("bmi") if bmi is None: return {"status": "UNCERTAIN", "confidence": 0.0, "evidence": "BMI not available"} if operator == "GT": passed = bmi > logic["value"] return { "status": "PASS" if passed else "FAIL", "confidence": 1.0, "evidence": f"BMI: {bmi} (required: >{logic['value']})", } # Medication check (with duration) if field == "active_medications": meds = patient.get("active_medications", []) target_med = logic["value"].lower() duration_days = logic.get("duration_days", 0) matching_med = None for med in meds: if target_med in med["name"].lower(): matching_med = med break if matching_med is None: return { "status": "FAIL", "confidence": 1.0, "evidence": f"'{target_med}' not found in active medications", } # Check duration if duration_days > 0: start = datetime.strptime(matching_med["start_date"], "%Y-%m-%d").replace(tzinfo=timezone.utc) days_on_med = (reference_date - start).days if days_on_med < duration_days: return { "status": "FAIL", "confidence": 0.9, "evidence": f"On {target_med} for {days_on_med} days (required: {duration_days}+)", } return { "status": "PASS", "confidence": 1.0, "evidence": f"On {target_med} since {matching_med['start_date']}", } # Fallback for unrecognized fields return {"status": "UNCERTAIN", "confidence": 0.0, "evidence": f"Cannot evaluate field '{field}'"} def structured_prescreen(patients: list, criteria: list, reference_date: datetime) -> list: """ Apply all structured criteria to the patient population. Returns candidates who pass all structured inclusion criteria and don't definitively fail any structured exclusion criteria. """ structured_criteria = [ c for c in criteria if c["data_source"] in ("STRUCTURED", "BOTH") ] candidates = [] for patient in patients: results = {} disqualified = False for criterion in structured_criteria: result = evaluate_structured_criterion(patient, criterion, reference_date) results[criterion["criterion_id"]] = result # Apply inclusion/exclusion logic if criterion["criterion_type"] == "INCLUSION" and result["status"] == "FAIL": disqualified = True break if criterion["criterion_type"] == "EXCLUSION" and result["status"] == "PASS": # For exclusion criteria, a PASS on the condition means the patient IS excluded disqualified = True break if not disqualified: candidates.append({ "patient_id": patient["patient_id"], "structured_results": results, "patient_data": patient, }) return candidates
Step 2: NLP Deep Screen with Comprehend Medical
The main recipe's pseudocode calls this nlp_deep_screen(). For candidates that passed structured pre-screening, we run NLP on their clinical notes to evaluate criteria that require unstructured data (like "no history of pancreatitis" documented only in notes).
In production, you'd call Amazon Comprehend Medical's DetectEntitiesV2 API. Here we show the real boto3 call structure and how to interpret the response, including negation detection.
def call_comprehend_medical(text: str) -> list: """ Call Amazon Comprehend Medical to extract medical entities from clinical text. Comprehend Medical returns entities with: - Text: the extracted term - Category: MEDICAL_CONDITION, MEDICATION, TEST_TREATMENT_PROCEDURE, etc. - Type: more specific (DX_NAME, GENERIC_NAME, PROCEDURE_NAME, etc.) - Traits: includes NEGATION, SIGN, SYMPTOM, DIAGNOSIS - Score: confidence (0.0-1.0) The NEGATION trait is critical for trial matching. "No history of pancreatitis" should return an entity for "pancreatitis" WITH the NEGATION trait, meaning the patient does NOT have this condition. """ # Comprehend Medical has a 20,000 character limit per request. # For longer notes, you'd chunk the text. For this example, we assume # notes fit within the limit. if len(text) > 20000: text = text[:20000] logger.warning("Note truncated to 20,000 chars for Comprehend Medical") response = comprehend_medical.detect_entities_v2(Text=text) entities = [] for entity in response.get("Entities", []): # Check if this entity has a NEGATION trait is_negated = any( trait["Name"] == "NEGATION" and trait["Score"] > 0.7 for trait in entity.get("Traits", []) ) entities.append({ "text": entity["Text"], "category": entity["Category"], "type": entity.get("Type", ""), "score": entity["Score"], "is_negated": is_negated, "begin_offset": entity["BeginOffset"], "end_offset": entity["EndOffset"], }) return entities def evaluate_nlp_criterion(criterion: dict, notes: list) -> dict: """ Evaluate a single NLP-based criterion against a patient's clinical notes. For exclusion criteria like "no history of pancreatitis": - If we find "pancreatitis" WITHOUT negation: patient HAS the condition (FAIL for exclusion) - If we find "pancreatitis" WITH negation: patient explicitly DOESN'T have it (PASS) - If we find no mention at all: uncertain (absence of evidence != evidence of absence) """ logic = criterion["logic"] # For criteria with both structured and NLP components, use the NLP sub-logic nlp_logic = logic.get("nlp", logic) search_terms = nlp_logic.get("search_terms", []) all_entities = [] for note_text in notes: entities = call_comprehend_medical(note_text) all_entities.extend(entities) # Look for entities matching our search terms matching_entities = [] for entity in all_entities: entity_text_lower = entity["text"].lower() for term in search_terms: if term.lower() in entity_text_lower: matching_entities.append(entity) break if not matching_entities: # No mention found. This is UNCERTAIN, not a definitive pass. # The condition might exist but not be documented in available notes. return { "status": "UNCERTAIN", "confidence": 0.5, "evidence": f"No mentions of {search_terms} found in {len(notes)} notes", } # Check negation status of matches affirmed = [e for e in matching_entities if not e["is_negated"]] negated = [e for e in matching_entities if e["is_negated"]] if affirmed: # Found a non-negated mention. Patient likely HAS this condition. best_match = max(affirmed, key=lambda e: e["score"]) return { "status": "PASS", # The condition IS present "confidence": best_match["score"], "evidence": f"Found '{best_match['text']}' (confidence: {best_match['score']:.2f}, affirmed)", } if negated: # Only negated mentions found. Patient explicitly DOESN'T have this. best_match = max(negated, key=lambda e: e["score"]) return { "status": "FAIL", # The condition is NOT present (negated) "confidence": best_match["score"] * 0.9, # Slight discount for negation complexity "evidence": f"Found negated '{best_match['text']}' (confidence: {best_match['score']:.2f})", } return {"status": "UNCERTAIN", "confidence": 0.4, "evidence": "Ambiguous NLP results"} def nlp_deep_screen(candidates: list, criteria: list) -> list: """ Run NLP-based screening on candidates that passed structured pre-screen. Evaluates criteria that require clinical note analysis. """ nlp_criteria = [ c for c in criteria if c["data_source"] in ("UNSTRUCTURED", "BOTH") ] if not nlp_criteria: return candidates # No NLP criteria to evaluate screened = [] for candidate in candidates: notes = candidate["patient_data"].get("clinical_notes", []) nlp_results = {} disqualified = False for criterion in nlp_criteria: result = evaluate_nlp_criterion(criterion, notes) nlp_results[criterion["criterion_id"]] = result # For EXCLUSION criteria: # - NLP status "PASS" means the condition IS present -> patient is excluded # - NLP status "FAIL" means the condition is NOT present (negated) -> patient passes if criterion["criterion_type"] == "EXCLUSION": if result["status"] == "PASS" and result["confidence"] > NLP_CONFIDENCE_THRESHOLD: disqualified = True break if not disqualified: candidate["nlp_results"] = nlp_results screened.append(candidate) return screened
Step 3: Score and Rank Candidates
The main recipe's pseudocode calls this score_candidates(). Each candidate gets a composite eligibility score based on how confidently they meet each criterion. Higher scores surface first in the coordinator worklist.
def score_candidates(candidates: list, criteria: list) -> list: """ Assign each candidate a composite eligibility score. The score reflects: - How many criteria the patient definitively passes - The confidence level of each determination - Whether uncertain criteria exist (which need coordinator review) Scores range from 0.0 to 1.0. A score of 1.0 means every criterion was evaluated with high confidence and passed. """ scored = [] for candidate in candidates: total_score = 0.0 max_possible = 0.0 details = [] # Combine structured and NLP results all_results = {} all_results.update(candidate.get("structured_results", {})) all_results.update(candidate.get("nlp_results", {})) for criterion in criteria: cid = criterion["criterion_id"] weight = SCORING_WEIGHTS.get(criterion["data_source"], 0.8) max_possible += weight result = all_results.get(cid) if result is None: # Criterion wasn't evaluated (maybe it's NLP-only and patient # was already disqualified). Treat as uncertain. criterion_score = 0.0 status = "NOT_EVALUATED" confidence = 0.0 evidence = "Not evaluated" else: status = result["status"] confidence = result["confidence"] evidence = result["evidence"] if criterion["criterion_type"] == "INCLUSION": if status == "PASS": criterion_score = weight * confidence elif status == "UNCERTAIN": criterion_score = weight * 0.5 * confidence else: criterion_score = 0.0 else: # EXCLUSION # For exclusion: FAIL means condition NOT present (good) # PASS means condition IS present (bad, should have been filtered) if status == "FAIL": criterion_score = weight * confidence elif status == "UNCERTAIN": criterion_score = weight * 0.5 else: criterion_score = 0.0 total_score += criterion_score details.append({ "criterion_id": cid, "raw_text": criterion["raw_text"], "status": status, "confidence": confidence, "evidence": evidence, }) eligibility_score = total_score / max_possible if max_possible > 0 else 0.0 uncertain_count = sum(1 for d in details if d["status"] == "UNCERTAIN") scored.append({ "patient_id": candidate["patient_id"], "eligibility_score": round(eligibility_score, 3), "uncertain_count": uncertain_count, "criterion_details": details, }) # Sort by score descending scored.sort(key=lambda x: x["eligibility_score"], reverse=True) return scored
Step 4: Store Results in DynamoDB
The main recipe's pseudocode stores scored candidates in DynamoDB for coordinator access. Each record includes the trial ID, patient ID, score, and per-criterion evidence.
def store_candidates(trial_id: str, scored_candidates: list) -> int: """ Write scored candidates to DynamoDB for the coordinator worklist. Each item uses trial_id as partition key and patient_id as sort key, allowing efficient queries for "all candidates for trial X, sorted by score." Returns the number of candidates stored. """ table = dynamodb.Table(CANDIDATES_TABLE) stored_count = 0 for candidate in scored_candidates: item = { "trial_id": trial_id, "patient_id": candidate["patient_id"], "eligibility_score": Decimal(str(candidate["eligibility_score"])), "uncertain_count": candidate["uncertain_count"], "status": "PENDING_REVIEW", "scored_at": datetime.now(timezone.utc).isoformat(), # Store criterion details as a JSON string to avoid DynamoDB's # nested attribute limitations for complex queries. "criterion_details_json": json.dumps(candidate["criterion_details"]), } table.put_item(Item=item) stored_count += 1 return stored_count
Putting It All Together
Here's the full pipeline assembled into a single function. This is what your Step Functions workflow would invoke (broken into Lambda functions per stage in production).
def run_trial_matching_pipeline(trial_criteria: dict, patients: list) -> list: """ Run the complete clinical trial patient matching pipeline. Stages: 1. Structured pre-screen (fast, cheap, eliminates most patients) 2. NLP deep screen (slower, more expensive, catches note-based exclusions) 3. Score and rank (prioritize coordinator review) 4. Store results (make available to coordinator worklist) Args: trial_criteria: Parsed trial criteria (see SAMPLE_TRIAL_CRITERIA) patients: List of patient records to screen Returns: Scored and ranked candidate list """ trial_id = trial_criteria["trial_id"] criteria = trial_criteria["criteria"] reference_date = datetime.now(timezone.utc) logger.info("=== Clinical Trial Matching Pipeline ===") logger.info("Trial: %s (%s)", trial_id, trial_criteria["trial_name"]) logger.info("Screening %d patients", len(patients)) # Stage 1: Structured pre-screen logger.info("--- Stage 1: Structured Pre-Screen ---") candidates = structured_prescreen(patients, criteria, reference_date) logger.info(" %d/%d patients passed structured pre-screen", len(candidates), len(patients)) if not candidates: logger.info("No candidates passed structured pre-screen. Pipeline complete.") return [] # Stage 2: NLP deep screen logger.info("--- Stage 2: NLP Deep Screen ---") logger.info(" Running Comprehend Medical on %d candidates", len(candidates)) screened = nlp_deep_screen(candidates, criteria) logger.info(" %d/%d candidates passed NLP deep screen", len(screened), len(candidates)) if not screened: logger.info("No candidates passed NLP deep screen. Pipeline complete.") return [] # Stage 3: Score and rank logger.info("--- Stage 3: Scoring ---") scored = score_candidates(screened, criteria) for candidate in scored: logger.info( " %s: score=%.3f, uncertain=%d", candidate["patient_id"], candidate["eligibility_score"], candidate["uncertain_count"], ) # Stage 4: Store results logger.info("--- Stage 4: Storing Results ---") count = store_candidates(trial_id, scored) logger.info(" Stored %d candidates in DynamoDB", count) logger.info("=== Pipeline Complete ===") return scored # Run the pipeline against synthetic data if __name__ == "__main__": results = run_trial_matching_pipeline(SAMPLE_TRIAL_CRITERIA, SYNTHETIC_PATIENTS) print("\n" + "=" * 60) print("TRIAL MATCHING RESULTS") print("=" * 60) if not results: print("No eligible candidates found.") else: for candidate in results: print(f"\nPatient: {candidate['patient_id']}") print(f" Eligibility Score: {candidate['eligibility_score']:.1%}") print(f" Uncertain Criteria: {candidate['uncertain_count']}") print(" Criterion Details:") for detail in candidate["criterion_details"]: status_icon = {"PASS": "✓", "FAIL": "✗", "UNCERTAIN": "?", "NOT_EVALUATED": "-"} icon = status_icon.get(detail["status"], "?") print(f" [{icon}] {detail['raw_text']}") print(f" {detail['evidence']}")
Expected Output (Synthetic Data)
Running this against our synthetic patients produces something like:
=== Clinical Trial Matching Pipeline ===
Trial: NCT05891234 (GLP-1 Combination Therapy for T2DM)
Screening 5 patients
--- Stage 1: Structured Pre-Screen ---
2/5 patients passed structured pre-screen
--- Stage 2: NLP Deep Screen ---
Running Comprehend Medical on 2 candidates
2/2 candidates passed NLP deep screen
--- Stage 3: Scoring ---
PAT-001: score=0.912, uncertain=0
PAT-005: score=0.887, uncertain=1
--- Stage 4: Storing Results ---
Stored 2 candidates in DynamoDB
=== Pipeline Complete ===
============================================================
TRIAL MATCHING RESULTS
============================================================
Patient: PAT-001
Eligibility Score: 91.2%
Uncertain Criteria: 0
Criterion Details:
[✓] Adults aged 30-65
Age: 54 (required: 30-65)
[✓] Diagnosis of Type 2 Diabetes (ICD-10: E11.x)
Codes matching 'E11*': ['E11.65']
[✓] A1C between 7.5% and 10.5% within the past 90 days
lab_a1c: 8.2 (from 2026-04-15)
[✓] On metformin monotherapy for at least 90 days
On metformin since 2025-06-01
[✓] BMI over 27
BMI: 31.4 (required: >27)
[✓] No history of pancreatitis
Found negated 'pancreatitis' (confidence: 0.92)
[✓] No eGFR below 45 in the past 6 months
lab_egfr: 72 (from 2026-03-20)
[✓] No active cancer diagnosis in the past 5 years
Found negated 'cancer' (confidence: 0.88)
Patient: PAT-005
Eligibility Score: 88.7%
Uncertain Criteria: 1
Criterion Details:
[✓] Adults aged 30-65
Age: 51 (required: 30-65)
[✓] Diagnosis of Type 2 Diabetes (ICD-10: E11.x)
Codes matching 'E11*': ['E11.65']
[✓] A1C between 7.5% and 10.5% within the past 90 days
lab_a1c: 7.9 (from 2026-05-05)
[✓] On metformin monotherapy for at least 90 days
On metformin since 2025-01-10
[✓] BMI over 27
BMI: 29.8 (required: >27)
[✓] No history of pancreatitis
Found negated 'pancreatitis' (confidence: 0.91)
[✓] No eGFR below 45 in the past 6 months
lab_egfr: 82 (from 2026-04-28)
[?] No active cancer diagnosis in the past 5 years
No mentions of ['cancer', 'malignancy', 'carcinoma', 'tumor'] found in 1 notes
Why the other patients were excluded:
- PAT-002: On empagliflozin (SGLT2 inhibitor). In a full implementation, this would be caught by a "no concurrent SGLT2" criterion. (We simplified the criteria set for this example.)
- PAT-003: Age 67, exceeds the 30-65 inclusion criterion. Eliminated in structured pre-screen.
- PAT-004: Has ICD-10 code K85.9 (acute pancreatitis). Eliminated in structured pre-screen by the exclusion criterion.
The Gap Between This and Production
This example demonstrates the matching logic clearly. But there's a significant distance between "runs against synthetic data in a script" and "screens 180,000 real patients for 20 concurrent trials." Here's where that gap lives:
Athena-based structured pre-screen. In production, the structured pre-screen is a SQL query against your patient data lake, not Python loops over in-memory data. Athena can scan millions of records in minutes. The SQL generation from parsed criteria is its own engineering challenge (building correct WHERE clauses from criterion logic objects, handling temporal constraints in SQL, managing NULL values for missing data).
Comprehend Medical rate limits and cost. Comprehend Medical charges per character and has API rate limits. For 2,000 candidates with an average of 5 notes each at 2,000 characters per note, you're looking at ~20 million characters (~$200 at current pricing). Batch processing with throttling, chunking long notes, and caching NLP results for patients screened against multiple trials are all necessary optimizations.
Error handling and retries. Every AWS API call can fail. Comprehend Medical can throttle. Athena queries can time out. DynamoDB writes can be throttled. Production code wraps each call in retry logic with exponential backoff. The botocore adaptive retry mode helps, but you also need application-level retries for transient failures that exceed the SDK's retry budget.
Criteria parser. This example uses hand-crafted criteria objects. A real system needs a parser that takes eligibility criteria text from ClinicalTrials.gov and decomposes it into computable rules. This is a hard NLP problem in itself. Some organizations use LLMs for criteria parsing, with human review of the parsed output before screening begins.
EHR integration. Getting patient data into a queryable data lake requires integration with your EHR (Epic FHIR APIs, Cerner HealtheIntent, bulk data exports). This is typically the longest lead-time item in the project, not the matching logic itself.
IRB and consent. Before screening a single real patient, your IRB needs to approve the protocol. Some institutions allow pre-screening under a waiver of consent; others require opt-in. The technical system must enforce whatever governance model your institution adopts.
IAM least-privilege. The IAM role for this pipeline should have exactly the permissions it needs: comprehendmedical:DetectEntitiesV2, athena:StartQueryExecution scoped to the specific database, s3:GetObject and s3:PutObject scoped to specific buckets, dynamodb:PutItem and dynamodb:Query scoped to the specific table. Not * on anything.
VPC and network isolation. Patient data is PHI. All processing should happen within a VPC with private subnets. Use VPC endpoints for S3, DynamoDB, Comprehend Medical, and Athena to keep traffic off the public internet.
Encryption. S3 buckets with SSE-KMS using customer-managed keys. DynamoDB encryption at rest. TLS 1.2+ for all API calls (boto3 handles this by default). KMS key policies that restrict access to the pipeline's IAM role.
Audit logging. CloudTrail must capture every API call. You need to know who screened which patients, when, for which trial. This is both a HIPAA requirement and essential for IRB audits.
Testing. Unit tests for criterion evaluation logic (with edge cases: missing data, boundary values, expired labs). Integration tests against Comprehend Medical with known clinical text. End-to-end tests with Synthea-generated patient populations. Never use real PHI in test fixtures.
Part of the Healthcare AI/ML Cookbook. See Recipe 6.7 for the full architectural walkthrough, pseudocode, and honest take on where clinical trial matching gets hard.