Recipe 15.3: Python Implementation Example
Heads up: This is a deliberately simple, illustrative implementation of the adaptive randomization system from Recipe 15.3. It demonstrates Thompson Sampling for clinical trial allocation using boto3 for AWS integration. This is not production-ready. A real adaptive trial system requires validated statistical software, regulatory review, and months of simulation studies. Think of this as a learning tool, not something you'd submit to the FDA.
Setup
pip install boto3 numpy
Your environment needs credentials configured with permissions for dynamodb:GetItem, dynamodb:PutItem, dynamodb:UpdateItem, s3:GetObject, s3:PutObject, and sagemaker:CreateProcessingJob. You'll also need a signed BAA if working with any real trial data (which you shouldn't be in development, but the infrastructure should assume it).
Configuration and Constants
These define the trial parameters. In a real system, these would be locked into the protocol before the first patient enrolls. Changing them mid-trial requires a protocol amendment and regulatory approval.
import json import time import uuid from decimal import Decimal import boto3 import numpy as np # ============================================================================ # TRIAL CONFIGURATION # ============================================================================ # These parameters define the adaptive design. They're set during the design # phase (after simulation studies confirm operating characteristics) and locked # before enrollment begins. Treat them as immutable during the trial. TRIAL_CONFIG = { "trial_id": "TRIAL-2026-001", "arms": ["Control", "Treatment_A", "Treatment_B"], "endpoint_type": "binary", # response vs. no response # Prior distributions: Beta(alpha, beta) for each arm. # Beta(1, 1) is a uniform prior: "we have no information about this arm's # response rate." This is the standard uninformative prior for binary endpoints. # If you have historical data (e.g., known control rate from prior trials), # you could use an informative prior like Beta(30, 70) for a 30% historical rate. "priors": { "Control": {"alpha": 1, "beta": 1}, "Treatment_A": {"alpha": 1, "beta": 1}, "Treatment_B": {"alpha": 1, "beta": 1}, }, # Allocation constraints prevent the algorithm from going to extremes. # min_allocation: no arm drops below this. Ensures we always collect some data # on every arm, which is critical for valid statistical inference at trial end. # max_allocation: no arm exceeds this. Prevents premature convergence before # we have enough data to be confident. "min_allocation": 0.10, "max_allocation": 0.80, # Burn-in: the first N patients get equal randomization (no adaptation). # This gives the algorithm a baseline of data before it starts shifting. # Rule of thumb: at least 10 patients per arm before adaptation begins. "burn_in_patients": 30, # Thompson Sampling simulations: more = more stable allocation probabilities, # but diminishing returns past ~10,000. "num_thompson_samples": 10000, } # AWS resource names ALLOCATION_STATE_TABLE = "adaptive-trial-allocation-state" ASSIGNMENT_AUDIT_TABLE = "adaptive-trial-assignment-audit" OUTCOMES_BUCKET = "adaptive-trial-outcomes" # DynamoDB requires Decimal instead of float. This is a known gotcha. # We handle the conversion explicitly rather than letting it blow up at runtime.
Step 1: Initialize Trial State
This creates the initial allocation state in DynamoDB. You run this once, before the first patient enrolls. It sets up the prior distributions and equal allocation probabilities.
def initialize_trial(config): """ Create the initial trial state in DynamoDB. This is a one-time setup step. After this runs, the randomization service can start accepting enrollment requests (which will use equal allocation until the burn-in period completes). """ dynamodb = boto3.resource("dynamodb") table = dynamodb.Table(ALLOCATION_STATE_TABLE) # Start with equal allocation across all arms num_arms = len(config["arms"]) equal_prob = Decimal(str(round(1.0 / num_arms, 4))) initial_state = { "trial_id": config["trial_id"], "posteriors": { arm: { "alpha": Decimal(str(params["alpha"])), "beta": Decimal(str(params["beta"])), } for arm, params in config["priors"].items() }, "allocation_probs": { arm: equal_prob for arm in config["arms"] }, "total_enrolled": 0, "enrollment_paused": False, "trial_stopped": False, "last_update": "initialization", "version": 0, } table.put_item(Item=initial_state) print(f"Trial {config['trial_id']} initialized with {num_arms} arms") print(f"Burn-in period: first {config['burn_in_patients']} patients get equal randomization") return initial_state
Step 2: Posterior Update Engine
This is the Bayesian learning component. When new outcomes arrive, it updates the Beta distribution parameters for each arm and recomputes allocation probabilities using Thompson Sampling. In production, this runs as a SageMaker Processing Job triggered by Step Functions.
def update_posteriors(trial_id, new_outcomes): """ Update posterior distributions based on newly confirmed outcomes. Parameters ---------- trial_id : str The trial identifier. new_outcomes : list of dict Each dict has {"patient_id": str, "arm": str, "outcome": str} where outcome is "response" or "no_response". This function: 1. Reads current state from DynamoDB 2. Updates Beta posteriors with new data (conjugate update) 3. Recomputes Thompson Sampling allocation probabilities 4. Writes updated state back to DynamoDB with optimistic locking """ dynamodb = boto3.resource("dynamodb") table = dynamodb.Table(ALLOCATION_STATE_TABLE) # Read current state response = table.get_item(Key={"trial_id": trial_id}) state = response["Item"] if state.get("trial_stopped"): print("Trial is stopped. No further updates.") return None # Count successes and failures per arm in the new batch for outcome in new_outcomes: arm = outcome["arm"] if arm not in state["posteriors"]: # Arm was dropped by DSMB; skip this outcome for allocation purposes # (still recorded in audit log for final analysis) continue if outcome["outcome"] == "response": state["posteriors"][arm]["alpha"] += 1 else: state["posteriors"][arm]["beta"] += 1 # Recompute allocation probabilities using Thompson Sampling # Only adapt if we're past the burn-in period total_enrolled = int(state["total_enrolled"]) if total_enrolled >= TRIAL_CONFIG["burn_in_patients"]: new_probs = compute_thompson_allocation( posteriors=state["posteriors"], min_alloc=TRIAL_CONFIG["min_allocation"], max_alloc=TRIAL_CONFIG["max_allocation"], num_samples=TRIAL_CONFIG["num_thompson_samples"], ) state["allocation_probs"] = new_probs # Update metadata state["last_update"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) state["version"] = int(state["version"]) + 1 # Write back with optimistic locking (condition on version) # If another process updated the state between our read and write, # this will fail and we retry. Prevents lost updates. try: table.put_item( Item=state, ConditionExpression="version = :expected_version", ExpressionAttributeValues={ ":expected_version": state["version"] - 1, }, ) except dynamodb.meta.client.exceptions.ConditionalCheckFailedException: # Another process updated state. In production, retry with backoff. print("Concurrent update detected. Retry needed.") raise print(f"Posteriors updated. New allocation: {state['allocation_probs']}") return state
Step 3: Thompson Sampling Allocation
The core RL logic. We draw samples from each arm's posterior distribution many times and count how often each arm "wins" (produces the highest sample). The win frequency becomes the allocation probability.
def compute_thompson_allocation(posteriors, min_alloc, max_alloc, num_samples=10000): """ Compute allocation probabilities using Thompson Sampling. For each simulation: 1. Draw a random sample from each arm's Beta posterior 2. The arm with the highest sample "wins" The allocation probability for each arm = fraction of simulations it won. Then we clip to [min_alloc, max_alloc] and renormalize. Why Thompson Sampling works for clinical trials: - Arms with high posterior means win often (exploitation) - Arms with wide posteriors (high uncertainty) occasionally win (exploration) - No tuning parameters needed (unlike epsilon-greedy or UCB) - The randomization is inherent (important for regulatory acceptance) """ arms = list(posteriors.keys()) num_arms = len(arms) # Convert Decimal to float for numpy (DynamoDB stores as Decimal) alphas = [float(posteriors[arm]["alpha"]) for arm in arms] betas = [float(posteriors[arm]["beta"]) for arm in arms] # Draw num_samples samples from each arm's Beta distribution # Shape: (num_samples, num_arms) samples = np.column_stack([ np.random.beta(a, b, size=num_samples) for a, b in zip(alphas, betas) ]) # For each simulation, find which arm had the highest sample winners = np.argmax(samples, axis=1) # Count wins per arm win_counts = np.bincount(winners, minlength=num_arms) raw_probs = win_counts / num_samples # Apply constraints and renormalize constrained = np.clip(raw_probs, min_alloc, max_alloc) constrained = constrained / constrained.sum() # Convert back to Decimal for DynamoDB storage result = { arm: Decimal(str(round(float(prob), 4))) for arm, prob in zip(arms, constrained) } return result
Step 4: Randomization Service
This is the Lambda function that sites call when enrolling a patient. It reads the current allocation probabilities and performs a weighted random draw. Speed matters here: sites are waiting on the phone.
def randomize_patient(trial_id, patient_id, stratification_factors=None): """ Assign a patient to a treatment arm. This function is the core of the randomization service (runs in Lambda). It must be: - Fast (sub-second; sites are waiting) - Auditable (every input and output is logged) - Deterministic given inputs (for reproducibility) Parameters ---------- trial_id : str Trial identifier. patient_id : str Unique patient identifier. stratification_factors : dict, optional Patient characteristics for stratified randomization (e.g., site, biomarker). Not used in this simple implementation but included for completeness. Returns ------- str The assigned arm name. """ dynamodb = boto3.resource("dynamodb") state_table = dynamodb.Table(ALLOCATION_STATE_TABLE) audit_table = dynamodb.Table(ASSIGNMENT_AUDIT_TABLE) # Read current allocation state response = state_table.get_item(Key={"trial_id": trial_id}) state = response["Item"] # Check if enrollment is allowed if state.get("trial_stopped"): raise ValueError("Trial has been stopped. No further enrollment.") if state.get("enrollment_paused"): raise ValueError("Enrollment is paused by DSMB. Contact trial coordinator.") # Determine allocation probabilities total_enrolled = int(state["total_enrolled"]) if total_enrolled < TRIAL_CONFIG["burn_in_patients"]: # During burn-in: equal randomization arms = TRIAL_CONFIG["arms"] probs = [1.0 / len(arms)] * len(arms) else: # After burn-in: use adaptive allocation arms = list(state["allocation_probs"].keys()) probs = [float(state["allocation_probs"][arm]) for arm in arms] # Generate assignment using numpy's weighted choice # In production, use a cryptographically secure RNG for regulatory compliance. # numpy's default RNG is fine for illustration but not for a real trial. random_seed = int.from_bytes(uuid.uuid4().bytes[:8], "big") rng = np.random.default_rng(random_seed) assigned_arm = rng.choice(arms, p=probs) # Log the complete assignment record # This audit trail is critical for regulatory compliance (21 CFR Part 11) # and for reproducing the randomization sequence if challenged. assignment_record = { "assignment_id": str(uuid.uuid4()), "trial_id": trial_id, "patient_id": patient_id, "assigned_arm": assigned_arm, "allocation_probs": {arm: str(p) for arm, p in zip(arms, probs)}, # Stored as strings for human readability in audit log "random_seed": str(random_seed), "state_version": int(state["version"]), "stratification": stratification_factors or {}, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "total_enrolled_at_time": total_enrolled, } audit_table.put_item(Item=assignment_record) # Increment enrollment counter atomically state_table.update_item( Key={"trial_id": trial_id}, UpdateExpression="SET total_enrolled = total_enrolled + :inc", ExpressionAttributeValues={":inc": 1}, ) print(f"Patient {patient_id} assigned to {assigned_arm} " f"(probs: {dict(zip(arms, probs))})") return assigned_arm
Step 5: DSMB Override Support
The Data Safety Monitoring Board can override the algorithm at any time. These are human decisions with regulatory authority. The system must support them cleanly.
def apply_dsmb_override(trial_id, override_type, parameters, authorized_by): """ Apply a DSMB decision that overrides the adaptive algorithm. The DSMB operates independently and has authority to: - Drop an arm (futility or safety) - Pause enrollment - Stop the trial entirely These decisions are logged as part of the audit trail and take immediate effect on the randomization service. """ dynamodb = boto3.resource("dynamodb") state_table = dynamodb.Table(ALLOCATION_STATE_TABLE) audit_table = dynamodb.Table(ASSIGNMENT_AUDIT_TABLE) response = state_table.get_item(Key={"trial_id": trial_id}) state = response["Item"] if override_type == "DROP_ARM": arm_to_drop = parameters["arm"] if arm_to_drop not in state["allocation_probs"]: raise ValueError(f"Arm {arm_to_drop} not found or already dropped.") # Remove the arm from allocation del state["allocation_probs"][arm_to_drop] del state["posteriors"][arm_to_drop] # Renormalize remaining probabilities remaining_total = sum( float(p) for p in state["allocation_probs"].values() ) state["allocation_probs"] = { arm: Decimal(str(round(float(p) / remaining_total, 4))) for arm, p in state["allocation_probs"].items() } print(f"Arm {arm_to_drop} dropped. Remaining: {state['allocation_probs']}") elif override_type == "PAUSE_ENROLLMENT": state["enrollment_paused"] = True print("Enrollment paused by DSMB.") elif override_type == "RESUME_ENROLLMENT": state["enrollment_paused"] = False print("Enrollment resumed by DSMB.") elif override_type == "STOP_TRIAL": state["trial_stopped"] = True print("Trial stopped by DSMB.") else: raise ValueError(f"Unknown override type: {override_type}") # Update state with optimistic locking (same pattern as update_posteriors). # DSMB overrides are rare, but a concurrent posterior update could silently # overwrite the override without this condition check. state["version"] = int(state["version"]) + 1 try: state_table.put_item( Item=state, ConditionExpression="version = :expected_version", ExpressionAttributeValues={ ":expected_version": state["version"] - 1, }, ) except state_table.meta.client.exceptions.ConditionalCheckFailedException: # Concurrent update detected. DSMB overrides are critical, so retry # immediately rather than failing silently. print("Concurrent update detected during DSMB override. Retry needed.") raise # Log the override in the audit trail override_record = { "assignment_id": str(uuid.uuid4()), "trial_id": trial_id, "patient_id": "DSMB_OVERRIDE", "assigned_arm": "N/A", "override_type": override_type, "override_parameters": parameters, "authorized_by": authorized_by, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "state_version": int(state["version"]), } audit_table.put_item(Item=override_record)
Full Pipeline: Simulated Trial Run
This assembles all the pieces into a simulated trial execution. It generates synthetic patient outcomes and demonstrates how the allocation shifts over time. You'd use something like this during the design phase to validate operating characteristics.
def run_simulated_trial(config, true_response_rates, total_patients=150): """ Simulate a complete adaptive trial to demonstrate the system. Parameters ---------- config : dict Trial configuration (TRIAL_CONFIG). true_response_rates : dict The actual (unknown to the algorithm) response rates per arm. e.g., {"Control": 0.30, "Treatment_A": 0.45, "Treatment_B": 0.25} total_patients : int Total patients to enroll in the simulation. This function does NOT call AWS services. It runs the Thompson Sampling logic locally to demonstrate how allocation evolves. In production, each step would hit DynamoDB/Lambda/SageMaker as shown above. """ print("=" * 60) print(f"SIMULATED ADAPTIVE TRIAL: {config['trial_id']}") print(f"True response rates: {true_response_rates}") print(f"Total patients: {total_patients}") print("=" * 60) # Initialize local state (mirrors what DynamoDB would hold) posteriors = { arm: {"alpha": float(p["alpha"]), "beta": float(p["beta"])} for arm, p in config["priors"].items() } arms = config["arms"] num_arms = len(arms) enrolled_per_arm = {arm: 0 for arm in arms} responses_per_arm = {arm: 0 for arm in arms} # Track allocation history for visualization allocation_history = [] # Enroll patients one at a time for patient_num in range(1, total_patients + 1): # Determine allocation probabilities if patient_num <= config["burn_in_patients"]: probs = [1.0 / num_arms] * num_arms else: # Thompson Sampling probs_dict = compute_thompson_allocation_local( posteriors, config["min_allocation"], config["max_allocation"], config["num_thompson_samples"], ) probs = [probs_dict[arm] for arm in arms] # Randomize this patient assigned_arm = np.random.choice(arms, p=probs) enrolled_per_arm[assigned_arm] += 1 # Simulate outcome based on true response rate true_rate = true_response_rates[assigned_arm] responded = np.random.random() < true_rate if responded: responses_per_arm[assigned_arm] += 1 posteriors[assigned_arm]["alpha"] += 1 else: posteriors[assigned_arm]["beta"] += 1 # Log progress at intervals if patient_num % 25 == 0 or patient_num == total_patients: print(f"\n--- After {patient_num} patients ---") print(f" Allocation: {dict(zip(arms, [f'{p:.2f}' for p in probs]))}") print(f" Enrolled: {enrolled_per_arm}") print(f" Responses: {responses_per_arm}") for arm in arms: n = enrolled_per_arm[arm] r = responses_per_arm[arm] obs_rate = r / n if n > 0 else 0 post_mean = posteriors[arm]["alpha"] / ( posteriors[arm]["alpha"] + posteriors[arm]["beta"] ) print(f" {arm}: {r}/{n} = {obs_rate:.1%} observed, " f"posterior mean = {post_mean:.3f}") allocation_history.append(dict(zip(arms, probs))) # Final summary print("\n" + "=" * 60) print("TRIAL COMPLETE") print("=" * 60) print(f"Total enrolled: {total_patients}") print(f"Per-arm enrollment: {enrolled_per_arm}") print(f"Per-arm responses: {responses_per_arm}") print(f"\nCompare to fixed randomization (equal allocation):") fixed_per_arm = total_patients // num_arms for arm in arms: rate = true_response_rates[arm] expected_fixed = int(fixed_per_arm * rate) actual = responses_per_arm[arm] print(f" {arm}: fixed would expect ~{expected_fixed} responses " f"from {fixed_per_arm} patients; adaptive got {actual} " f"from {enrolled_per_arm[arm]} patients") total_responses = sum(responses_per_arm.values()) expected_fixed_total = sum( int(fixed_per_arm * true_response_rates[arm]) for arm in arms ) print(f"\nTotal responses: {total_responses} (adaptive) vs " f"~{expected_fixed_total} (fixed)") improvement = (total_responses - expected_fixed_total) / expected_fixed_total * 100 print(f"Improvement: ~{improvement:.1f}% more patients received effective treatment") def compute_thompson_allocation_local(posteriors, min_alloc, max_alloc, num_samples): """ Local version of Thompson Sampling (no DynamoDB Decimal conversion). Used for simulation only. """ arms = list(posteriors.keys()) alphas = [posteriors[arm]["alpha"] for arm in arms] betas = [posteriors[arm]["beta"] for arm in arms] samples = np.column_stack([ np.random.beta(a, b, size=num_samples) for a, b in zip(alphas, betas) ]) winners = np.argmax(samples, axis=1) win_counts = np.bincount(winners, minlength=len(arms)) raw_probs = win_counts / num_samples constrained = np.clip(raw_probs, min_alloc, max_alloc) constrained = constrained / constrained.sum() return dict(zip(arms, constrained)) # Run the simulation if __name__ == "__main__": run_simulated_trial( config=TRIAL_CONFIG, true_response_rates={ "Control": 0.30, "Treatment_A": 0.45, "Treatment_B": 0.25, }, total_patients=150, )
Gap to Production
This example demonstrates the core logic, but a real adaptive trial system needs substantially more:
Statistical validation. Before running a real trial, you need thousands of simulation runs under different scenarios (null hypothesis, various alternatives, different enrollment rates, dropout patterns) to verify Type I error control and characterize power. This is months of biostatistician work, not a weekend project. The simulation framework above is a starting point, but production simulations need to account for delayed outcomes, interim analyses, and the specific test statistic you'll use for the final analysis.
Cryptographic randomization. The numpy RNG is fine for simulation but not for a real trial. Production systems need a cryptographically secure random number generator (e.g., secrets.SystemRandom() or hardware RNG) with the seed logged for reproducibility. Some regulatory frameworks require the randomization algorithm to be validated as a medical device.
Delayed outcome handling. Real trials have outcomes that take weeks or months to confirm. The posterior update engine needs to distinguish between "no outcome yet" (patient still being followed) and "treatment failure" (confirmed negative outcome). The current implementation assumes immediate outcomes, which is unrealistic for most trials.
Error handling and retries. DynamoDB conditional writes can fail under concurrent access. The Lambda function needs retry logic with exponential backoff. The Step Functions workflow needs error states and dead-letter queues. Network failures between the EDC system and the randomization service need graceful handling (what happens if the site doesn't receive the assignment response?).
Input validation. Every API call needs validation: Is this patient already enrolled? Is this trial still active? Is the patient eligible based on inclusion/exclusion criteria? Are the stratification factors valid? The randomization service should reject invalid requests with clear error messages.
Structured logging and monitoring. CloudWatch metrics on randomization latency, allocation drift, posterior convergence, and enrollment rate. Alarms if the randomization service becomes unavailable or if allocation probabilities change unexpectedly fast (could indicate a data quality issue).
IAM least-privilege. The Lambda function should only have dynamodb:GetItem on the state table and dynamodb:PutItem on the audit table. The SageMaker Processing Job needs broader access but should be scoped to specific S3 prefixes and DynamoDB tables. No wildcards.
VPC and network isolation. Production: Lambda and SageMaker in a VPC with VPC endpoints for DynamoDB, S3, and CloudWatch Logs. No internet access needed. The API Gateway endpoint should use mutual TLS or API keys for site authentication.
KMS encryption. All data at rest encrypted with customer-managed KMS keys. DynamoDB tables with encryption enabled. S3 buckets with SSE-KMS. Lambda environment variables encrypted. Key rotation policy in place.
21 CFR Part 11 compliance. If this is an FDA-regulated trial: electronic records need audit trails (covered by DynamoDB + CloudTrail), access controls (IAM), and electronic signatures (not covered here; you'd need an additional authentication layer for DSMB overrides).
Testing. Unit tests for the Thompson Sampling logic (deterministic given a seed). Integration tests for the DynamoDB read/write path. Load tests to verify sub-second randomization under concurrent enrollment. Chaos testing to verify behavior when DynamoDB is throttled or Lambda cold-starts.
Multi-site coordination. Real trials have dozens of sites enrolling simultaneously. The DynamoDB atomic counter handles concurrent enrollment, but you also need to handle the case where two sites call the randomization service at the exact same moment and both read the same state version. The current optimistic locking approach works but needs retry logic on the client side.
| ← Recipe 15.3: Clinical Trial Adaptive Randomization | Chapter 15 Index | Recipe 15.4: Sepsis Treatment Optimization → |
|---|