Recipe 6.8: Python Implementation Example

Heads up: This is a deliberately simplified, illustrative implementation of the disease subtype discovery pipeline from Recipe 6.8. It demonstrates the core workflow (synthetic patient data generation, preprocessing, multi-algorithm clustering, consensus analysis, and clinical validation) using scikit-learn and boto3. It is not production-ready. Real subtype discovery requires clinical collaboration, rigorous statistical validation, and months of iterative refinement. Think of this as the sketch that helps you understand the shape of the problem, not something you'd publish in The Lancet.


Setup

You'll need the AWS SDK for Python and standard ML libraries:

pip install boto3 numpy pandas scikit-learn scipy

Your environment needs credentials configured (via environment variables, an instance profile, or ~/.aws/credentials). The IAM role or user needs:

  • s3:GetObject, s3:PutObject (feature matrix and results storage)
  • sagemaker:CreateTrainingJob, sagemaker:CreateEndpoint (if deploying the classifier)
  • dynamodb:PutItem, dynamodb:GetItem (subtype assignment storage)

For this example, we'll run clustering locally using scikit-learn. In production, you'd use SageMaker training jobs for larger cohorts. The concepts are identical; only the compute substrate changes.


Config and Constants

import json
import logging
from datetime import datetime, timezone
from decimal import Decimal

import numpy as np
import pandas as pd
from scipy import stats
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.cluster import KMeans, AgglomerativeClustering
from sklearn.mixture import GaussianMixture
from sklearn.metrics import silhouette_score
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split

import boto3
from botocore.config import Config

# Structured logging. Never log PHI field values.
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

# Retry config for AWS API calls.
BOTO3_RETRY_CONFIG = Config(retries={"max_attempts": 3, "mode": "adaptive"})

# AWS clients
s3_client = boto3.client("s3", config=BOTO3_RETRY_CONFIG)
dynamodb = boto3.resource("dynamodb", config=BOTO3_RETRY_CONFIG)

# Configuration
RESULTS_BUCKET = "subtype-discovery-results"
FEATURE_MATRIX_KEY = "features/heart-failure-cohort.csv"
SUBTYPE_TABLE = "patient-subtypes"

# Clustering parameters
K_RANGE = range(2, 11)  # Explore 2 through 10 clusters
VARIANCE_THRESHOLD = 0.90  # Keep PCA components explaining 90% of variance
CONSENSUS_ITERATIONS = 100  # Number of bootstrap resamples for consensus
SUBSAMPLE_FRACTION = 0.80  # Fraction of patients to sample each iteration
RANDOM_SEED = 42  # Reproducibility

Step 1: Generate Synthetic Patient Cohort

The main recipe's Step 1 defines the cohort and Step 2 extracts features. Here we generate synthetic data that mimics what you'd get from a real EHR feature extraction pipeline. The synthetic data has four embedded subtypes with different clinical profiles, so we can verify the clustering recovers them.

def generate_synthetic_cohort(n_patients=2000, random_state=42):
    """
    Generate a synthetic heart failure cohort with embedded subtypes.

    This creates four distinct patient phenotypes:
    - Metabolic HFpEF: high BMI, diabetes, preserved EF
    - Ischemic progressive: low EF, prior MI, CAD history
    - Elderly multimorbid: old, CKD, anemia, many comorbidities
    - Young idiopathic: young, low EF, few comorbidities

    In real life, you'd never have these labels. That's the whole point of
    unsupervised clustering. We include them here only so we can verify
    the algorithm recovers meaningful structure.
    """
    rng = np.random.default_rng(random_state)

    # Allocate patients to subtypes (unequal sizes, like reality)
    subtype_sizes = [600, 640, 400, 360]  # roughly 30%, 32%, 20%, 18%
    assert sum(subtype_sizes) == n_patients

    records = []

    # Subtype 0: Metabolic HFpEF
    for _ in range(subtype_sizes[0]):
        records.append({
            "age": rng.normal(62, 8),
            "bmi": rng.normal(35, 5),
            "ejection_fraction": rng.normal(52, 8),  # preserved
            "bnp": rng.normal(450, 150),
            "creatinine": rng.normal(1.2, 0.3),
            "hemoglobin": rng.normal(13.0, 1.5),
            "hba1c": rng.normal(7.8, 1.2),  # diabetic
            "sodium": rng.normal(138, 3),
            "potassium": rng.normal(4.3, 0.4),
            "heart_rate": rng.normal(78, 12),
            "systolic_bp": rng.normal(142, 18),
            "has_diabetes": 1,
            "has_hypertension": rng.choice([0, 1], p=[0.15, 0.85]),
            "has_cad": rng.choice([0, 1], p=[0.7, 0.3]),
            "has_ckd": rng.choice([0, 1], p=[0.6, 0.4]),
            "has_afib": rng.choice([0, 1], p=[0.6, 0.4]),
            "has_copd": rng.choice([0, 1], p=[0.8, 0.2]),
            "prior_mi": 0,
            "comorbidity_count": rng.poisson(3) + 2,
            "true_subtype": 0,
        })

    # Subtype 1: Ischemic progressive
    for _ in range(subtype_sizes[1]):
        records.append({
            "age": rng.normal(67, 10),
            "bmi": rng.normal(28, 4),
            "ejection_fraction": rng.normal(30, 8),  # reduced
            "bnp": rng.normal(1200, 400),  # elevated
            "creatinine": rng.normal(1.4, 0.4),
            "hemoglobin": rng.normal(12.5, 1.8),
            "hba1c": rng.normal(6.2, 0.8),
            "sodium": rng.normal(136, 4),
            "potassium": rng.normal(4.5, 0.5),
            "heart_rate": rng.normal(82, 14),
            "systolic_bp": rng.normal(125, 15),
            "has_diabetes": rng.choice([0, 1], p=[0.55, 0.45]),
            "has_hypertension": rng.choice([0, 1], p=[0.3, 0.7]),
            "has_cad": 1,
            "has_ckd": rng.choice([0, 1], p=[0.6, 0.4]),
            "has_afib": rng.choice([0, 1], p=[0.5, 0.5]),
            "has_copd": rng.choice([0, 1], p=[0.75, 0.25]),
            "prior_mi": 1,
            "comorbidity_count": rng.poisson(2) + 2,
            "true_subtype": 1,
        })

    # Subtype 2: Elderly multimorbid
    for _ in range(subtype_sizes[2]):
        records.append({
            "age": rng.normal(81, 5),  # elderly
            "bmi": rng.normal(26, 4),
            "ejection_fraction": rng.normal(40, 12),
            "bnp": rng.normal(900, 350),
            "creatinine": rng.normal(2.1, 0.6),  # CKD
            "hemoglobin": rng.normal(10.5, 1.5),  # anemic
            "hba1c": rng.normal(6.8, 1.0),
            "sodium": rng.normal(134, 4),  # slightly low
            "potassium": rng.normal(4.8, 0.6),
            "heart_rate": rng.normal(72, 10),
            "systolic_bp": rng.normal(130, 20),
            "has_diabetes": rng.choice([0, 1], p=[0.4, 0.6]),
            "has_hypertension": rng.choice([0, 1], p=[0.15, 0.85]),
            "has_cad": rng.choice([0, 1], p=[0.4, 0.6]),
            "has_ckd": 1,
            "has_afib": rng.choice([0, 1], p=[0.3, 0.7]),
            "has_copd": rng.choice([0, 1], p=[0.5, 0.5]),
            "prior_mi": rng.choice([0, 1], p=[0.6, 0.4]),
            "comorbidity_count": rng.poisson(4) + 3,  # high burden
            "true_subtype": 2,
        })

    # Subtype 3: Young idiopathic
    for _ in range(subtype_sizes[3]):
        records.append({
            "age": rng.normal(44, 8),  # young
            "bmi": rng.normal(27, 4),
            "ejection_fraction": rng.normal(28, 7),  # reduced
            "bnp": rng.normal(800, 300),
            "creatinine": rng.normal(0.9, 0.2),  # normal kidneys
            "hemoglobin": rng.normal(14.0, 1.2),  # normal
            "hba1c": rng.normal(5.5, 0.4),  # not diabetic
            "sodium": rng.normal(139, 2),
            "potassium": rng.normal(4.2, 0.3),
            "heart_rate": rng.normal(85, 15),
            "systolic_bp": rng.normal(118, 12),
            "has_diabetes": 0,
            "has_hypertension": rng.choice([0, 1], p=[0.75, 0.25]),
            "has_cad": 0,
            "has_ckd": 0,
            "has_afib": rng.choice([0, 1], p=[0.7, 0.3]),
            "has_copd": 0,
            "prior_mi": 0,
            "comorbidity_count": rng.poisson(1),
            "true_subtype": 3,
        })

    df = pd.DataFrame(records)

    # Clip physiologically implausible values
    df["ejection_fraction"] = df["ejection_fraction"].clip(5, 75)
    df["bmi"] = df["bmi"].clip(15, 60)
    df["creatinine"] = df["creatinine"].clip(0.3, 8.0)
    df["hemoglobin"] = df["hemoglobin"].clip(6.0, 18.0)
    df["age"] = df["age"].clip(18, 100)

    logger.info("Generated synthetic cohort: %d patients, %d features", len(df), len(df.columns) - 1)
    return df

Step 2: Preprocess and Reduce Dimensions

Maps to pseudocode Steps 2-3. Standardize features so clustering isn't dominated by high-magnitude variables, then apply PCA to reduce dimensionality while preserving meaningful variance.

def preprocess_and_reduce(df, variance_threshold=VARIANCE_THRESHOLD):
    """
    Standardize features and apply PCA for dimensionality reduction.

    Why standardize? BNP ranges from 0 to 5000+. Ejection fraction ranges
    from 5 to 75. Without scaling, BNP would dominate every distance
    calculation and you'd find "BNP subtypes" rather than meaningful phenotypes.

    Why PCA? With 18 features, clustering works fine directly. But in a real
    scenario with 50-100 features, the curse of dimensionality makes distance
    metrics unreliable. PCA compresses to the directions of maximum variance.
    """
    # Separate features from the ground truth label (which we won't use for clustering)
    feature_cols = [c for c in df.columns if c != "true_subtype"]
    X = df[feature_cols].values

    # Standardize: zero mean, unit variance for each feature
    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(X)

    # PCA: find how many components capture the target variance
    # Note: PCA with the default full SVD solver is deterministic (no random_state needed).
    pca = PCA()
    pca.fit(X_scaled)

    cumulative_variance = np.cumsum(pca.explained_variance_ratio_)
    n_components = np.searchsorted(cumulative_variance, variance_threshold) + 1

    logger.info(
        "PCA: %d components capture %.1f%% of variance (from %d original features)",
        n_components, cumulative_variance[n_components - 1] * 100, X_scaled.shape[1]
    )

    # Transform to reduced space (full SVD is deterministic; no random_state needed)
    pca_reduced = PCA(n_components=n_components)
    X_reduced = pca_reduced.fit_transform(X_scaled)

    return X_scaled, X_reduced, scaler, pca_reduced, feature_cols

Step 3: Multi-Algorithm Clustering

Maps to pseudocode Step 4. Run K-means, GMM, and hierarchical clustering across a range of K values. Record internal validation metrics for each. The goal is to find values of K where multiple algorithms agree.

def run_multi_algorithm_clustering(X_reduced, k_range=K_RANGE):
    """
    Run three clustering algorithms across a range of K values.

    Why multiple algorithms? Each makes different assumptions about cluster shape.
    K-means assumes spherical clusters. GMM allows elliptical. Hierarchical
    builds a tree. If all three find similar groupings at K=4, that's stronger
    evidence than any single algorithm alone.

    Returns a list of result dicts, one per K value, with labels and metrics
    for each algorithm.
    """
    results = []

    for k in k_range:
        k_result = {"k": k, "algorithms": {}}

        # K-means: fast, spherical cluster assumption
        kmeans = KMeans(n_clusters=k, random_state=RANDOM_SEED, n_init=10)
        kmeans_labels = kmeans.fit_predict(X_reduced)
        kmeans_sil = silhouette_score(X_reduced, kmeans_labels)

        k_result["algorithms"]["kmeans"] = {
            "labels": kmeans_labels,
            "silhouette": kmeans_sil,
        }

        # Gaussian Mixture Model: elliptical clusters, probabilistic assignments
        gmm = GaussianMixture(n_components=k, random_state=RANDOM_SEED, n_init=3)
        gmm_labels = gmm.fit_predict(X_reduced)
        gmm_sil = silhouette_score(X_reduced, gmm_labels)
        gmm_bic = gmm.bic(X_reduced)  # lower BIC = better fit/complexity tradeoff

        k_result["algorithms"]["gmm"] = {
            "labels": gmm_labels,
            "silhouette": gmm_sil,
            "bic": gmm_bic,
        }

        # Hierarchical (Ward linkage): builds dendrogram, cut at K
        hier = AgglomerativeClustering(n_clusters=k, linkage="ward")
        hier_labels = hier.fit_predict(X_reduced)
        hier_sil = silhouette_score(X_reduced, hier_labels)

        k_result["algorithms"]["hierarchical"] = {
            "labels": hier_labels,
            "silhouette": hier_sil,
        }

        results.append(k_result)
        logger.info(
            "K=%d | Silhouette: KMeans=%.3f, GMM=%.3f, Hier=%.3f | GMM BIC=%.0f",
            k, kmeans_sil, gmm_sil, hier_sil, gmm_bic
        )

    return results

Step 4: Consensus Clustering

Maps to pseudocode Step 5. This is the gold standard for disease subtype discovery. Run clustering 100 times on bootstrap resamples and track which patients consistently end up together. Stable co-clustering across resamples is evidence of genuine structure.

def consensus_clustering(X_scaled, k, n_iterations=CONSENSUS_ITERATIONS,
                         subsample_fraction=SUBSAMPLE_FRACTION):
    """
    Build a consensus matrix by repeatedly clustering bootstrap subsamples.

    The consensus matrix entry (i, j) represents the fraction of times
    patient i and patient j ended up in the same cluster across all iterations
    where both were sampled. Values near 1.0 mean "always together." Values
    near 0.0 mean "never together." Values near 0.5 mean "unstable" (boundary
    patients who bounce between clusters depending on who else is in the sample).

    We then cluster the consensus matrix itself to get final assignments.
    """
    n_patients = X_scaled.shape[0]
    co_cluster_count = np.zeros((n_patients, n_patients))
    co_occurrence_count = np.zeros((n_patients, n_patients))

    rng = np.random.default_rng(RANDOM_SEED)

    for iteration in range(n_iterations):
        # Bootstrap: sample 80% of patients
        n_sample = int(n_patients * subsample_fraction)
        indices = rng.choice(n_patients, size=n_sample, replace=False)
        indices.sort()

        # Cluster the subsample
        subsample = X_scaled[indices]
        kmeans = KMeans(n_clusters=k, random_state=iteration, n_init=5)
        labels = kmeans.fit_predict(subsample)

        # Update co-clustering counts for all pairs in this subsample
        for i_idx in range(n_sample):
            for j_idx in range(i_idx + 1, n_sample):
                pi, pj = indices[i_idx], indices[j_idx]
                co_occurrence_count[pi, pj] += 1
                co_occurrence_count[pj, pi] += 1
                if labels[i_idx] == labels[j_idx]:
                    co_cluster_count[pi, pj] += 1
                    co_cluster_count[pj, pi] += 1

    # Compute consensus matrix (avoid division by zero)
    with np.errstate(divide="ignore", invalid="ignore"):
        consensus_matrix = np.divide(co_cluster_count, co_occurrence_count)
        consensus_matrix = np.nan_to_num(consensus_matrix, nan=0.0)

    # Set diagonal to 1 (a patient always co-clusters with itself)
    np.fill_diagonal(consensus_matrix, 1.0)

    # Cluster the consensus matrix using hierarchical clustering
    # Distance = 1 - consensus (patients always together have distance 0)
    distance_matrix = 1.0 - consensus_matrix
    final_clustering = AgglomerativeClustering(
        n_clusters=k, metric="precomputed", linkage="average"
    )
    final_labels = final_clustering.fit_predict(distance_matrix)

    # Compute PAC (Proportion of Ambiguous Clustering)
    # PAC = fraction of off-diagonal consensus values between 0.1 and 0.9
    off_diag = consensus_matrix[np.triu_indices_from(consensus_matrix, k=1)]
    pac = np.mean((off_diag > 0.1) & (off_diag < 0.9))

    logger.info(
        "Consensus clustering (K=%d, %d iterations): PAC=%.3f (lower is more stable)",
        k, n_iterations, pac
    )

    return final_labels, consensus_matrix, pac

Step 5: Clinical Validation and Characterization

Maps to pseudocode Step 6. For each discovered cluster, compute the feature profile and compare to the overall population. In a real scenario, you'd also compare outcomes (mortality, readmission). Here we characterize by feature means and effect sizes.

def characterize_clusters(df, feature_cols, labels):
    """
    Generate interpretable profiles for each discovered cluster.

    For each cluster, compute the mean of every feature and the effect size
    (how many standard deviations the cluster mean differs from the population
    mean). Large positive effect sizes indicate defining characteristics.

    This is what you'd present to a clinician: "Cluster 2 is characterized by
    age 81 (+2.1 SD), creatinine 2.1 (+1.8 SD), hemoglobin 10.5 (-1.5 SD)."
    """
    X = df[feature_cols].values
    overall_means = X.mean(axis=0)
    overall_stds = X.std(axis=0)

    n_clusters = len(np.unique(labels))
    characterization = {}

    for cluster_id in range(n_clusters):
        mask = labels == cluster_id
        cluster_data = X[mask]
        cluster_means = cluster_data.mean(axis=0)

        # Effect size: how far is this cluster's mean from the population mean,
        # measured in population standard deviations
        effect_sizes = (cluster_means - overall_means) / (overall_stds + 1e-8)

        # Top distinguishing features (largest absolute effect sizes)
        top_indices = np.argsort(np.abs(effect_sizes))[::-1][:5]
        top_features = [
            {"feature": feature_cols[i], "effect_size": round(float(effect_sizes[i]), 2)}
            for i in top_indices
        ]

        characterization[cluster_id] = {
            "size": int(mask.sum()),
            "fraction": round(float(mask.mean()), 3),
            "top_features": top_features,
        }

        logger.info(
            "Cluster %d: n=%d (%.1f%%) | Top features: %s",
            cluster_id, mask.sum(), mask.mean() * 100,
            ", ".join(f"{f['feature']}({f['effect_size']:+.1f})" for f in top_features)
        )

    return characterization

Step 6: Train Subtype Classifier

Maps to pseudocode Step 7. Once you've validated the subtypes, train a supervised classifier so new patients can be assigned to a subtype without re-running the full clustering pipeline.

def train_subtype_classifier(X_scaled, labels, feature_cols):
    """
    Train a gradient boosting classifier to assign new patients to subtypes.

    Why gradient boosting? It handles mixed feature types well, provides
    feature importance rankings, and is robust to the moderate class imbalance
    typical in subtype discovery. The trained model can be deployed as a
    SageMaker endpoint for real-time assignment.
    """
    X_train, X_test, y_train, y_test = train_test_split(
        X_scaled, labels, test_size=0.2, random_state=RANDOM_SEED, stratify=labels
    )

    clf = GradientBoostingClassifier(
        n_estimators=200,
        max_depth=5,
        learning_rate=0.1,
        random_state=RANDOM_SEED,
    )
    clf.fit(X_train, y_train)

    accuracy = clf.score(X_test, y_test)
    logger.info("Classifier accuracy: %.3f", accuracy)

    # Feature importance: which measurements drive subtype assignment?
    importance = sorted(
        zip(feature_cols, clf.feature_importances_),
        key=lambda x: x[1],
        reverse=True,
    )
    logger.info("Top 5 features for classification: %s",
                ", ".join(f"{name}({imp:.3f})" for name, imp in importance[:5]))

    return clf, accuracy, importance

Step 7: Store Subtype Assignments

Writes validated subtype assignments to DynamoDB so downstream systems (care management, clinical decision support) can look up a patient's subtype in real time.

def store_subtype_assignments(patient_ids, labels, characterization, table_name=SUBTYPE_TABLE):
    """
    Write patient subtype assignments to DynamoDB.

    Each record contains the patient ID, their assigned subtype, the
    characterization of that subtype, and a timestamp for audit purposes.

    In production, you'd batch these writes using batch_write_item for
    efficiency. Here we write one at a time for clarity.
    """
    table = dynamodb.Table(table_name)
    timestamp = datetime.now(timezone.utc).isoformat()

    for patient_id, label in zip(patient_ids, labels):
        record = {
            "patient_id": str(patient_id),
            "subtype_id": int(label),
            # Serialize to JSON string; DynamoDB rejects Python floats (requires Decimal)
            "subtype_characterization": json.dumps(characterization[int(label)]),
            "assignment_timestamp": timestamp,
            "model_version": "consensus-v1",
        }
        table.put_item(Item=record)

    logger.info("Stored %d subtype assignments to DynamoDB", len(patient_ids))

Putting It All Together

Here's the full pipeline assembled into a single callable function.

def run_subtype_discovery_pipeline():
    """
    Execute the complete disease subtype discovery pipeline.

    In production, each step would be a separate stage in a Step Functions
    state machine, with intermediate results stored in S3 between stages.
    Here we run everything in-memory for demonstration.
    """

    # Step 1: Generate (or load) the patient cohort with clinical features
    logger.info("=" * 60)
    logger.info("STEP 1: Generating synthetic heart failure cohort")
    logger.info("=" * 60)
    df = generate_synthetic_cohort(n_patients=2000)
    true_labels = df["true_subtype"].values

    # Step 2: Preprocess and reduce dimensions
    logger.info("=" * 60)
    logger.info("STEP 2: Preprocessing and dimensionality reduction")
    logger.info("=" * 60)
    X_scaled, X_reduced, scaler, pca_model, feature_cols = preprocess_and_reduce(df)

    # Step 3: Multi-algorithm clustering sweep
    logger.info("=" * 60)
    logger.info("STEP 3: Multi-algorithm clustering (K=2 through K=10)")
    logger.info("=" * 60)
    clustering_results = run_multi_algorithm_clustering(X_reduced)

    # Find the K with best average silhouette across algorithms
    best_k = max(
        clustering_results,
        key=lambda r: np.mean([
            r["algorithms"][alg]["silhouette"]
            for alg in r["algorithms"]
        ])
    )["k"]
    logger.info("Best K by average silhouette: %d", best_k)

    # Step 4: Consensus clustering at the best K
    # We use the full scaled feature space (X_scaled) rather than PCA-reduced space here.
    # Bootstrap resampling provides implicit regularization against dimensionality issues,
    # and retaining all features ensures the consensus matrix captures full phenotypic similarity.
    logger.info("=" * 60)
    logger.info("STEP 4: Consensus clustering (K=%d, %d iterations)", best_k, CONSENSUS_ITERATIONS)
    logger.info("=" * 60)
    final_labels, consensus_matrix, pac = consensus_clustering(X_scaled, k=best_k)

    # Step 5: Characterize the discovered clusters
    logger.info("=" * 60)
    logger.info("STEP 5: Clinical characterization of discovered subtypes")
    logger.info("=" * 60)
    characterization = characterize_clusters(df, feature_cols, final_labels)

    # Step 6: Train a classifier for assigning new patients
    logger.info("=" * 60)
    logger.info("STEP 6: Training subtype classifier")
    logger.info("=" * 60)
    classifier, accuracy, importance = train_subtype_classifier(
        X_scaled, final_labels, feature_cols
    )

    # Step 7: Store assignments (commented out since we don't have a real table)
    # store_subtype_assignments(range(len(df)), final_labels, characterization)

    # Summary
    logger.info("=" * 60)
    logger.info("PIPELINE COMPLETE")
    logger.info("=" * 60)
    logger.info("Cohort size: %d patients", len(df))
    logger.info("Features: %d (reduced to %d PCA components)", len(feature_cols), X_reduced.shape[1])
    logger.info("Optimal K: %d", best_k)
    logger.info("Consensus PAC: %.3f", pac)
    logger.info("Classifier accuracy: %.3f", accuracy)

    # Return results for inspection
    return {
        "cohort_size": len(df),
        "n_features": len(feature_cols),
        "n_pca_components": X_reduced.shape[1],
        "optimal_k": best_k,
        "consensus_pac": pac,
        "classifier_accuracy": accuracy,
        "characterization": characterization,
        "top_features": importance[:5],
    }

if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(message)s")
    results = run_subtype_discovery_pipeline()
    print("\n" + json.dumps(results, indent=2, default=str))

The Gap Between This and Production

This example runs end-to-end on synthetic data and demonstrates the core workflow. But there's a significant distance between "runs on my laptop" and "produces publishable, clinically validated disease subtypes." Here's where that gap lives:

Real data is messy. This synthetic data has no missing values, no outliers from data entry errors, no temporal inconsistencies. Real EHR data has 20-40% missingness in lab values, vitals recorded at inconsistent intervals, and diagnosis codes that reflect billing incentives as much as clinical reality. You'll need a robust imputation strategy (MICE, or multiple imputation with sensitivity analysis) and explicit handling of informative missingness (a missing BNP might mean "not ordered because the patient looked fine," which is itself informative).

Feature selection requires clinical expertise. We used 18 features here because they're the obvious ones. A real subtype discovery project starts with 50-200 candidate features and requires iterative refinement with domain experts. Which lab value matters? At what time point? Should you use the baseline value, the trajectory, or the response to treatment? These choices determine what subtypes you can find. Budget significant time for clinical collaboration on feature engineering.

Consensus clustering at scale. With 2,000 patients, the O(n^2) consensus matrix fits in memory. With 50,000 patients, it's 50,000 x 50,000 = 2.5 billion entries. You'll need either sparse representations, block-wise computation on SageMaker training instances, or approximate consensus methods. The SageMaker distributed training infrastructure handles this, but the code needs restructuring.

Clinical validation is a human process. The characterization step here computes feature profiles. In reality, you'd present these profiles to cardiologists, run survival analyses per cluster, check for treatment response differences, and iterate. This takes months, not minutes. The algorithm is the easy part; convincing clinicians that the clusters are real and actionable is the hard part.

Stability across time. Subtypes discovered on 2024 data should still be valid on 2025 data. You need temporal validation: train on one time period, validate on another. If the clusters shift dramatically, they may reflect data collection changes rather than biology.

SageMaker deployment. The classifier here is a local scikit-learn model. In production, you'd serialize it, upload to S3, and deploy as a SageMaker endpoint. The endpoint receives a patient's feature vector and returns the predicted subtype with a confidence score. SageMaker handles auto-scaling, A/B testing, and model monitoring for drift detection.

Error handling and retries. No try/except blocks here. A production pipeline wraps every AWS call in error handling with exponential backoff. Glue ETL jobs fail. SageMaker training jobs get preempted. S3 uploads timeout. Each failure mode needs specific handling.

IAM least-privilege. The IAM role for this pipeline should have exactly the permissions listed in Setup and nothing else. Scope S3 access to the specific bucket and prefix. Scope DynamoDB access to the specific table. Never use broad wildcards in production.

VPC and encryption. Patient clinical data is PHI. The SageMaker notebook, training jobs, and endpoints all run in a VPC with no internet egress. VPC endpoints for S3, DynamoDB, and SageMaker API keep traffic on the AWS backbone. KMS customer-managed keys encrypt all data at rest with rotation enabled.

Experiment tracking. This example doesn't track which parameters produced which results. SageMaker Experiments logs every training run with its hyperparameters, metrics, and artifacts. When you've run 50 clustering experiments with different feature sets and K values, you'll be grateful for systematic tracking.


Part of the Healthcare AI/ML Cookbook. See Recipe 6.8 for the full architectural walkthrough, pseudocode, and honest take on where this gets hard.