Recipe 6.3: Python Implementation Example
Heads up: This is a deliberately simple, illustrative implementation of the pseudocode walkthrough from Recipe 6.3. It shows one way you could translate payer mix financial risk clustering into working Python code. It is not production-ready. There's no error handling, no retry logic, no input validation. Think of it as the whiteboard sketch: useful for understanding the shape of the solution, not something you'd deploy against your actual patient population on Monday morning. Consider it a starting point, not a destination.
This example uses SageMaker Processing Jobs with scikit-learn rather than SageMaker's built-in K-Means algorithm. For populations under 500K patients, scikit-learn is simpler and more flexible. The built-in algorithm is better when you're clustering millions of records and need distributed compute.
Setup
You'll need the AWS SDK for Python and scikit-learn:
pip install boto3 pandas numpy scikit-learn pyarrow
Your environment needs credentials configured (via environment variables, an instance profile, or ~/.aws/credentials). The IAM role or user needs:
s3:GetObjects3:PutObjectsagemaker:CreateProcessingJobsagemaker:DescribeProcessingJobathena:StartQueryExecutionathena:GetQueryResultssns:Publish
For the SageMaker Processing Job, you'll also need a SageMaker execution role with S3 access. That's a separate IAM role from the one running this script.
Configuration and Constants
Everything that's really configuration rather than logic lives here. The feature definitions, payer encodings, and thresholds are the pieces that change between organizations. Treat them as configuration you version-control, not magic numbers buried in functions.
import logging import json import datetime from datetime import timezone import boto3 import numpy as np import pandas as pd from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler from sklearn.metrics import silhouette_score logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) # --- Feature Configuration --- # The features we'll use for clustering. Each maps to a column in the # patient feature matrix. Order matters: it determines column order in # the numpy array passed to K-Means. FEATURE_COLUMNS = [ "payer_ordinal", "payment_ratio", "write_off_ratio", "avg_days_to_pay", "utilization_intensity", "ed_proportion", "avg_complexity", "no_show_rate", "coverage_changes_24mo", "deductible_amount", "collections_count", "charity_app_count", ] # Payer type ordinal encoding. Higher = generally better reimbursement. # This is a simplification. In reality, a Medicaid managed care plan # might reimburse better than a high-deductible commercial plan for # certain services. But as a clustering feature, this ordinal captures # the broad financial ordering that matters for segmentation. PAYER_ENCODING = { "commercial": 4, "medicare": 3, "medicaid": 2, "self_pay": 1, } # Range of k values to evaluate. Start at 3 (fewer is just rediscovering # payer categories) and stop at 8 (more is too granular to operationalize). K_RANGE = range(3, 9) # Population shift detection threshold (percentage points). # A shift of 5+ pp in any cluster between runs triggers an alert. SHIFT_THRESHOLD_PP = 5.0 # S3 paths for the pipeline artifacts. S3_BUCKET = "my-health-system-analytics" S3_PREFIX_FEATURES = "payer-risk-clustering/features/" S3_PREFIX_RESULTS = "payer-risk-clustering/results/" S3_PREFIX_HISTORY = "payer-risk-clustering/history/" # SNS topic for shift alerts. SNS_TOPIC_ARN = "arn:aws:sns:us-east-1:123456789012:payer-risk-alerts"
Step 1: Generate Synthetic Patient Data
The pseudocode calls this extract_patient_financial_data(date_range). In production, this step pulls from your billing system, EHR, and eligibility feeds via Glue ETL. Here we generate synthetic data that mimics the statistical properties of a real patient population. No real PHI is used or needed for development.
def generate_synthetic_patients(n_patients: int = 5000, seed: int = 42) -> pd.DataFrame: """ Generate synthetic patient financial data for clustering development. The distributions here are loosely based on published healthcare utilization statistics. They're realistic enough to produce meaningful clusters but are NOT derived from any actual patient data. In production, replace this entire function with your Glue ETL job that joins billing, EHR, and eligibility data. Args: n_patients: Number of synthetic patients to generate. seed: Random seed for reproducibility. Returns: DataFrame with one row per patient and columns matching the raw data you'd get from source system extracts. """ # TODO (TechWriter): Code review Issue 1 (WARNING). The iterrows/loc pattern below # is correct but extremely slow (~35K individual cell assignments for 5K patients). # Consider vectorizing with boolean masks per payer type, or at minimum add a # prominent comment warning readers not to copy this pattern for large populations. rng = np.random.default_rng(seed) # Assign payer types with a realistic distribution. # National averages: ~50% commercial, ~20% Medicare, ~20% Medicaid, ~10% self-pay. # Your system's mix will differ. That's fine; the clustering adapts. payer_types = rng.choice( ["commercial", "medicare", "medicaid", "self_pay"], size=n_patients, p=[0.48, 0.22, 0.20, 0.10], ) patients = pd.DataFrame({"patient_id": range(1, n_patients + 1), "payer_type": payer_types}) # Generate financial and utilization features conditioned on payer type. # Each payer segment has different statistical properties. This conditioning # is what makes the synthetic data produce realistic clusters. for idx, row in patients.iterrows(): payer = row["payer_type"] if payer == "commercial": # Commercial patients: generally good payment, moderate utilization. # But with a subgroup of HDHP patients who struggle with patient responsibility. is_hdhp = rng.random() < 0.35 # 35% of commercial are high-deductible if is_hdhp: patients.loc[idx, "payment_ratio"] = rng.beta(3, 4) # skewed lower patients.loc[idx, "avg_days_to_pay"] = rng.gamma(8, 8) # longer patients.loc[idx, "deductible_amount"] = rng.uniform(3000, 8000) patients.loc[idx, "write_off_ratio"] = rng.beta(2, 8) else: patients.loc[idx, "payment_ratio"] = rng.beta(8, 2) # skewed higher patients.loc[idx, "avg_days_to_pay"] = rng.gamma(3, 5) patients.loc[idx, "deductible_amount"] = rng.uniform(250, 2000) patients.loc[idx, "write_off_ratio"] = rng.beta(1, 20) patients.loc[idx, "utilization_intensity"] = rng.gamma(2, 1.5) patients.loc[idx, "ed_proportion"] = rng.beta(1, 10) patients.loc[idx, "coverage_changes_24mo"] = rng.poisson(0.3) elif payer == "medicare": # Medicare: reliable payer, higher utilization, older population. patients.loc[idx, "payment_ratio"] = rng.beta(7, 2) patients.loc[idx, "avg_days_to_pay"] = rng.gamma(4, 7) patients.loc[idx, "deductible_amount"] = rng.uniform(200, 500) patients.loc[idx, "write_off_ratio"] = rng.beta(1, 15) patients.loc[idx, "utilization_intensity"] = rng.gamma(4, 1.5) patients.loc[idx, "ed_proportion"] = rng.beta(2, 8) patients.loc[idx, "coverage_changes_24mo"] = rng.poisson(0.1) elif payer == "medicaid": # Medicaid: variable payment, higher ED use, coverage instability. patients.loc[idx, "payment_ratio"] = rng.beta(4, 3) patients.loc[idx, "avg_days_to_pay"] = rng.gamma(5, 8) patients.loc[idx, "deductible_amount"] = rng.uniform(0, 100) patients.loc[idx, "write_off_ratio"] = rng.beta(2, 10) patients.loc[idx, "utilization_intensity"] = rng.gamma(3, 2) patients.loc[idx, "ed_proportion"] = rng.beta(3, 7) patients.loc[idx, "coverage_changes_24mo"] = rng.poisson(1.2) else: # self_pay # Self-pay: lowest payment rates, highest write-offs, coverage churn. patients.loc[idx, "payment_ratio"] = rng.beta(2, 5) patients.loc[idx, "avg_days_to_pay"] = rng.gamma(10, 10) patients.loc[idx, "deductible_amount"] = 0.0 # no insurance, no deductible patients.loc[idx, "write_off_ratio"] = rng.beta(4, 5) patients.loc[idx, "utilization_intensity"] = rng.gamma(1.5, 2) patients.loc[idx, "ed_proportion"] = rng.beta(4, 6) patients.loc[idx, "coverage_changes_24mo"] = rng.poisson(2.0) # Features that are less payer-dependent. patients["avg_complexity"] = rng.gamma(2, 1, size=n_patients) patients["no_show_rate"] = rng.beta(2, 10, size=n_patients) patients["collections_count"] = rng.poisson(0.5, size=n_patients) patients["charity_app_count"] = rng.poisson(0.2, size=n_patients) # Clip unrealistic values. patients["payment_ratio"] = patients["payment_ratio"].clip(0, 1) patients["write_off_ratio"] = patients["write_off_ratio"].clip(0, 1) patients["no_show_rate"] = patients["no_show_rate"].clip(0, 1) patients["ed_proportion"] = patients["ed_proportion"].clip(0, 1) patients["avg_days_to_pay"] = patients["avg_days_to_pay"].clip(0, 365) logger.info("Generated %d synthetic patients", n_patients) return patients
Step 2: Engineer and Normalize Features
The pseudocode calls this engineer_features(patient_features). This transforms raw patient data into a numeric feature matrix suitable for K-Means. The key operations: encode payer type as an ordinal, impute missing values with medians, and z-score normalize so no single feature dominates the distance calculation.
def engineer_features(patients: pd.DataFrame) -> tuple[np.ndarray, StandardScaler, pd.DataFrame]: """ Transform raw patient data into a normalized feature matrix for clustering. Three things happen here: 1. Payer type gets ordinal encoding (preserving financial ordering). 2. Missing values get median imputation (new patients land in the middle). 3. All features get z-score normalization (equal contribution to distance). The scaler object is returned because you'll need it later to transform new patients into the same feature space for cluster assignment. Args: patients: DataFrame from generate_synthetic_patients or your ETL. Returns: Tuple of (feature_matrix, scaler, patients_with_features): - feature_matrix: numpy array ready for K-Means (n_patients x n_features) - scaler: fitted StandardScaler for transforming new data - patients_with_features: original DataFrame with payer_ordinal added """ # Encode payer type as ordinal. This preserves the financial ordering # that's central to this use case. One-hot encoding would work too, # but it loses the "commercial > medicare > medicaid > self_pay" # reimbursement hierarchy that we want the clustering to see. patients = patients.copy() patients["payer_ordinal"] = patients["payer_type"].map(PAYER_ENCODING) # Handle missing values with median imputation. # In production, new patients (< 6 months of history) will have nulls # in payment_ratio, avg_days_to_pay, etc. Median imputation places them # in the middle of the distribution rather than at an extreme. feature_df = patients[FEATURE_COLUMNS].copy() for col in FEATURE_COLUMNS: median_val = feature_df[col].median() feature_df[col] = feature_df[col].fillna(median_val) # Z-score normalization. Without this, deductible_amount (range 0-8000) # would completely dominate no_show_rate (range 0-1) in the distance # calculation. After normalization, both have mean=0 and std=1. scaler = StandardScaler() feature_matrix = scaler.fit_transform(feature_df.values) logger.info( "Feature matrix shape: %s (patients x features)", feature_matrix.shape ) return feature_matrix, scaler, patients
Step 3: Run Clustering and Evaluate
The pseudocode calls this cluster_patients(feature_matrix, k_range). We run K-Means for each candidate k, compute silhouette scores, and select the best segmentation. The silhouette score is a starting point; final k selection requires human review of the cluster profiles.
def cluster_and_evaluate( feature_matrix: np.ndarray, ) -> tuple[KMeans, np.ndarray, list[dict]]: """ Run K-Means for multiple k values and select the best segmentation. For each k in K_RANGE, we: 1. Fit K-Means with 10 random initializations (n_init=10 avoids local minima). 2. Compute the silhouette score (measures cluster separation quality). 3. Record inertia (within-cluster sum of squares) for the elbow plot. The "best" k is the one with the highest silhouette score. But this is a suggestion, not a final answer. If k=5 has silhouette 0.38 and k=4 has silhouette 0.36, but your finance team can only act on 4 strategies, pick k=4. Actionability beats metrics. Args: feature_matrix: Normalized numpy array from engineer_features. Returns: Tuple of (best_model, best_labels, all_results): - best_model: fitted KMeans object for the best k - best_labels: cluster assignments (array of ints, one per patient) - all_results: list of dicts with k, silhouette, inertia for comparison """ all_results = [] best_score = -1 best_model = None best_labels = None for k in K_RANGE: # n_init=10: run 10 times with different random centroids, keep the best. # random_state=42: reproducible results for development. Remove in production # if you want to assess stability across random seeds. model = KMeans(n_clusters=k, n_init=10, random_state=42) labels = model.fit_predict(feature_matrix) score = silhouette_score(feature_matrix, labels) inertia = model.inertia_ all_results.append({ "k": k, "silhouette_score": round(score, 4), "inertia": round(inertia, 2), }) logger.info(" k=%d: silhouette=%.4f, inertia=%.2f", k, score, inertia) if score > best_score: best_score = score best_model = model best_labels = labels logger.info( "Best k=%d with silhouette=%.4f", best_model.n_clusters, best_score, ) return best_model, best_labels, all_results
Step 4: Profile Clusters
The pseudocode calls this profile_clusters(patient_data, labels, feature_columns). This is where raw cluster IDs become something a CFO can understand. For each cluster, we compute summary statistics and generate a human-readable label based on the dominant characteristics.
def profile_clusters(patients: pd.DataFrame, labels: np.ndarray) -> list[dict]: """ Generate human-readable profiles for each cluster. For each cluster, compute: - Size and percentage of total population - Mean values for all financial and utilization features - Dominant payer type (mode) - A suggested label based on the most distinguishing characteristics The suggested labels are heuristic. They're a starting point for the conversation with your finance team, who will rename them to match their mental model. "Cluster 3" means nothing. "HDHP Payment-Challenged" means everything. Args: patients: DataFrame with patient data (including payer_type). labels: Cluster assignment array from cluster_and_evaluate. Returns: List of profile dicts, one per cluster, sorted by cluster ID. """ patients = patients.copy() patients["cluster"] = labels profiles = [] for cluster_id in sorted(patients["cluster"].unique()): cluster_df = patients[patients["cluster"] == cluster_id] n = len(cluster_df) pct = round(n / len(patients) * 100, 1) # Compute feature means for this cluster. profile = { "cluster_id": int(cluster_id), "size": n, "percentage": pct, "dominant_payer": cluster_df["payer_type"].mode().iloc[0], "payer_distribution": cluster_df["payer_type"].value_counts(normalize=True).round(3).to_dict(), "avg_payment_ratio": round(cluster_df["payment_ratio"].mean(), 3), "avg_write_off_ratio": round(cluster_df["write_off_ratio"].mean(), 3), "avg_days_to_pay": round(cluster_df["avg_days_to_pay"].mean(), 1), "avg_utilization_intensity": round(cluster_df["utilization_intensity"].mean(), 2), "avg_ed_proportion": round(cluster_df["ed_proportion"].mean(), 3), "avg_no_show_rate": round(cluster_df["no_show_rate"].mean(), 3), "avg_deductible": round(cluster_df["deductible_amount"].mean(), 0), "avg_coverage_changes": round(cluster_df["coverage_changes_24mo"].mean(), 2), "avg_collections_count": round(cluster_df["collections_count"].mean(), 2), "avg_charity_apps": round(cluster_df["charity_app_count"].mean(), 2), } # Generate a suggested label based on dominant characteristics. profile["suggested_label"] = _generate_cluster_label(profile) profiles.append(profile) return profiles def _generate_cluster_label(profile: dict) -> str: """ Heuristic label generation based on cluster characteristics. This is intentionally simple. The real labels come from your finance team after they review the profiles. This just gives them a starting point. """ payer = profile["dominant_payer"] payment = profile["avg_payment_ratio"] write_off = profile["avg_write_off_ratio"] deductible = profile["avg_deductible"] if payment > 0.85 and write_off < 0.05: risk_level = "Low Risk" elif payment > 0.6 and write_off < 0.15: risk_level = "Moderate Risk" else: risk_level = "High Risk" payer_label = payer.replace("_", " ").title() if payer == "commercial" and deductible > 3000: payer_label = "HDHP Commercial" return f"{payer_label} - {risk_level}"
Step 5: Detect Population Shifts
The pseudocode calls this detect_population_shift(current_distribution, previous_distribution, threshold). This compares cluster distributions between runs and fires alerts when the population is shifting in a financially meaningful direction.
def detect_population_shift( current_profiles: list[dict], previous_profiles: list[dict], ) -> list[dict]: """ Compare cluster distributions between the current and previous run. A shift of SHIFT_THRESHOLD_PP percentage points in any cluster triggers an alert. In practice, a 5pp shift in a quarter is significant: it means thousands of patients are moving between financial risk segments. WARNING: This implementation matches clusters by suggested_label, which is non-deterministic across runs. In production, match clusters by centroid similarity (Hungarian method on centroid distances) or use the previous run's centroids as initialization. Label-based matching will produce false positives when a cluster hovers near a threshold boundary (e.g., payment_ratio near 0.85 flipping between "Low Risk" and "Moderate Risk" labels). Args: current_profiles: Profiles from this run's clustering. previous_profiles: Profiles from the last run (loaded from S3 history). Returns: List of alert dicts for clusters that shifted beyond threshold. Empty list means the population is stable (good news). """ # TODO (TechWriter): Code review Issue 3 (WARNING). Replace label-based matching # with centroid-distance matching for production reliability. The current approach # will produce false shift alerts when labels flip due to threshold boundary effects. # Build lookup by cluster label (not ID, since IDs can shift between runs). previous_by_label = {p["suggested_label"]: p["percentage"] for p in previous_profiles} alerts = [] for profile in current_profiles: label = profile["suggested_label"] current_pct = profile["percentage"] previous_pct = previous_by_label.get(label, 0.0) shift = current_pct - previous_pct if abs(shift) >= SHIFT_THRESHOLD_PP: direction = "growing" if shift > 0 else "shrinking" alerts.append({ "cluster_label": label, "direction": direction, "shift_pp": round(shift, 1), "current_pct": current_pct, "previous_pct": previous_pct, "message": ( f"Cluster '{label}' is {direction}: " f"{previous_pct}% -> {current_pct}% ({shift:+.1f} pp)" ), }) if alerts: logger.info("Population shift detected: %d cluster(s) beyond threshold", len(alerts)) else: logger.info("Population stable: no clusters shifted beyond %.1f pp", SHIFT_THRESHOLD_PP) return alerts
Step 6: Upload Results to S3
In production, cluster assignments go back to S3 as Parquet for Athena queries. Profiles and evaluation metrics go as JSON for dashboards and monitoring.
def upload_results_to_s3( patients: pd.DataFrame, labels: np.ndarray, profiles: list[dict], evaluation: list[dict], ) -> dict: """ Write cluster assignments and profiles to S3 for downstream consumption. Three artifacts are written: 1. Cluster assignments (Parquet): patient_id + cluster_id for Athena queries. 2. Cluster profiles (JSON): summary stats for dashboards. 3. Evaluation metrics (JSON): silhouette scores for audit trail. Args: patients: DataFrame with patient data. labels: Cluster assignment array. profiles: List of profile dicts from profile_clusters. evaluation: List of evaluation dicts from cluster_and_evaluate. Returns: Dict with S3 keys for each uploaded artifact. """ s3_client = boto3.client("s3") run_date = datetime.datetime.now(timezone.utc).strftime("%Y-%m-%d") # 1. Cluster assignments as Parquet (for Athena). assignments_df = pd.DataFrame({ "patient_id": patients["patient_id"], "cluster_id": labels, "run_date": run_date, }) assignments_key = f"{S3_PREFIX_RESULTS}{run_date}/assignments.parquet" # to_parquet() with no path argument returns bytes in memory, # which we pass directly to S3. parquet_buffer = assignments_df.to_parquet(index=False) s3_client.put_object( Bucket=S3_BUCKET, Key=assignments_key, Body=parquet_buffer, ServerSideEncryption="aws:kms", ) # 2. Cluster profiles as JSON (for dashboards and monitoring). profiles_key = f"{S3_PREFIX_RESULTS}{run_date}/profiles.json" s3_client.put_object( Bucket=S3_BUCKET, Key=profiles_key, Body=json.dumps(profiles, indent=2), ContentType="application/json", ServerSideEncryption="aws:kms", ) # 3. Evaluation metrics as JSON (audit trail). eval_key = f"{S3_PREFIX_RESULTS}{run_date}/evaluation.json" s3_client.put_object( Bucket=S3_BUCKET, Key=eval_key, Body=json.dumps(evaluation, indent=2), ContentType="application/json", ServerSideEncryption="aws:kms", ) # 4. Copy profiles to history for shift detection on next run. history_key = f"{S3_PREFIX_HISTORY}{run_date}/profiles.json" s3_client.put_object( Bucket=S3_BUCKET, Key=history_key, Body=json.dumps(profiles, indent=2), ContentType="application/json", ServerSideEncryption="aws:kms", ) logger.info("Uploaded results to s3://%s/%s", S3_BUCKET, S3_PREFIX_RESULTS + run_date) return { "assignments_key": assignments_key, "profiles_key": profiles_key, "evaluation_key": eval_key, "history_key": history_key, }
Putting It All Together
Here's the full pipeline assembled into a single function. This runs locally for development. In production, you'd run the clustering step as a SageMaker Processing Job and orchestrate with Step Functions or EventBridge.
def run_payer_risk_clustering_pipeline( n_patients: int = 5000, previous_profiles: list[dict] | None = None, ) -> dict: """ Run the full payer mix financial risk clustering pipeline. Covers all five steps from the Recipe 6.3 pseudocode: 1. Extract/generate patient financial data 2. Engineer and normalize features 3. Run clustering and evaluate multiple k values 4. Profile clusters with human-readable labels 5. Detect population shifts (if previous run available) Plus uploads results to S3 for Athena and QuickSight consumption. Args: n_patients: Number of patients (synthetic data mode). previous_profiles: Profiles from last run for shift detection. None on first run. Returns: Dict with profiles, evaluation metrics, alerts, and S3 keys. """ print("=" * 60) print("PAYER MIX FINANCIAL RISK CLUSTERING") print("=" * 60) # Step 1: Generate synthetic patient data. # In production: replace with Glue ETL pulling from billing, EHR, eligibility. print("\nStep 1: Generating patient financial data...") patients = generate_synthetic_patients(n_patients=n_patients) print(f" {len(patients)} patients with {len(patients.columns)} raw features") # Step 2: Engineer features and normalize. print("\nStep 2: Engineering and normalizing features...") feature_matrix, scaler, patients = engineer_features(patients) print(f" Feature matrix: {feature_matrix.shape[0]} patients x {feature_matrix.shape[1]} features") print(f" Features: {FEATURE_COLUMNS}") # Step 3: Cluster and evaluate. print("\nStep 3: Running K-Means for k={} through k={}...".format(K_RANGE.start, K_RANGE.stop - 1)) best_model, best_labels, evaluation = cluster_and_evaluate(feature_matrix) print(f"\n Best k={best_model.n_clusters}") print(" Evaluation summary:") for result in evaluation: marker = " <-- best" if result["k"] == best_model.n_clusters else "" print(f" k={result['k']}: silhouette={result['silhouette_score']:.4f}{marker}") # Step 4: Profile clusters. print("\nStep 4: Profiling clusters...") profiles = profile_clusters(patients, best_labels) print(f"\n Cluster Profiles:") print(f" {'Label':<35} {'Size':>6} {'%':>6} {'Pay Ratio':>10} {'Write-off':>10} {'Days AR':>8}") print(f" {'-'*35} {'-'*6} {'-'*6} {'-'*10} {'-'*10} {'-'*8}") for p in profiles: print( f" {p['suggested_label']:<35} {p['size']:>6} {p['percentage']:>5.1f}% " f"{p['avg_payment_ratio']:>10.3f} {p['avg_write_off_ratio']:>10.3f} " f"{p['avg_days_to_pay']:>7.1f}" ) # Step 5: Detect population shifts. alerts = [] if previous_profiles: print("\nStep 5: Checking for population shifts...") alerts = detect_population_shift(profiles, previous_profiles) if alerts: print(" ALERTS:") for alert in alerts: print(f" {alert['message']}") else: print(" No significant shifts detected.") else: print("\nStep 5: Skipped (no previous run for comparison).") # Upload to S3 (commented out for local development). # s3_keys = upload_results_to_s3(patients, best_labels, profiles, evaluation) print("\n" + "=" * 60) print("PIPELINE COMPLETE") print("=" * 60) return { "profiles": profiles, "evaluation": evaluation, "best_k": best_model.n_clusters, "alerts": alerts, } if __name__ == "__main__": result = run_payer_risk_clustering_pipeline(n_patients=5000) # Pretty-print the profiles as JSON. print("\n\nFull profiles JSON:") print(json.dumps(result["profiles"], indent=2))
The Gap Between This and Production
This example works: run it and it will produce meaningful financial risk clusters from synthetic data. The distance between that and a production deployment is real. Here's where it lives.
Real data integration is 70% of the work. This example generates synthetic data in one function call. In production, you're joining billing system extracts (often in HL7 835/837 formats), EHR utilization data (FHIR or proprietary APIs), and eligibility feeds (X12 270/271). Each system has its own patient identifier, its own data model, and its own update cadence. The Glue ETL job that resolves these to a single patient record is where most of the engineering effort goes. Budget 3-4 weeks for data integration alone.
Patient identity resolution. The join in Step 1 assumes a shared patient_id. In reality, your billing system uses account numbers, your EHR uses MRNs, and your eligibility feed uses member IDs. You need an MPI (Master Patient Index) or entity resolution layer (see Chapter 5) to link these. Without it, you'll cluster on incomplete records and produce segments that reflect data availability rather than actual financial risk.
Feature drift and staleness. Payment behavior changes. Payer contracts get renegotiated. New plan designs emerge. The features that separated clusters well last year might not work this year. Monitor feature distributions between runs. If a feature's variance collapses (everyone has the same value), it's no longer contributing to separation and should be replaced or re-engineered.
Cluster stability across runs. This example runs once. In production, you re-cluster monthly or quarterly. If 30% of patients change clusters between runs, your segments aren't stable enough to act on. Common causes: too many clusters (reduce k), noisy features (smooth or remove them), or genuine population change (which is the signal you're trying to detect, not noise). Track the percentage of patients who stay in the same cluster between runs. Below 85% stability, investigate before operationalizing.
SageMaker Processing Jobs for scale. This example runs scikit-learn locally. For populations over 100K patients, run the clustering as a SageMaker Processing Job with a larger instance type (ml.m5.4xlarge or ml.m5.12xlarge). The code is identical; you just package it in a container and submit it to SageMaker. For populations over 1M, consider SageMaker's built-in K-Means algorithm, which distributes across multiple instances.
Ethical guardrails and access controls. Cluster assignments reveal financial vulnerability. They must never be used to gate clinical access, delay care, or discriminate in scheduling. Build this into your data governance: restrict who can query cluster assignments, audit all access via CloudTrail, and document the approved use cases (financial planning, charity care budgeting, financial counseling targeting). If someone queries cluster assignments joined with scheduling data, that's an alert.
Missing data handling for new patients. Median imputation works for patients with partial history. But a brand-new patient (first visit, no payment history, no utilization data) gets median values for everything, which places them in the middle of the distribution. That's not wrong, but it's not informative either. Consider a separate "insufficient data" category for patients with less than 6 months of history, and assign them to clusters only after enough behavioral data accumulates.
Outlier handling. A single patient with $2M in charges from a transplant episode will distort the clustering if left untreated. Options: winsorize extreme values (cap at the 99th percentile), use robust scaling (median and IQR instead of mean and std), or remove extreme outliers before clustering and assign them to a separate "high-cost outlier" segment. This example does none of these. Production should.
VPC and encryption. Patient financial data combined with utilization data is PHI. The SageMaker Processing Job runs in a VPC with no internet access. S3 buckets use SSE-KMS with customer-managed keys. Athena query results are encrypted. All API calls over TLS. CloudTrail logs every access to the cluster assignment data.
Testing. There are no tests here. A production pipeline has unit tests for feature engineering (does the scaler produce the expected output for known inputs?), integration tests for the full pipeline with synthetic data, and regression tests that verify cluster stability when re-run on the same data with the same seed. The synthetic data generator itself should be tested: verify that the statistical properties match your expectations.
Part of the Healthcare AI/ML Cookbook. See Recipe 6.3: Payer Mix Financial Risk Clustering for the full architectural walkthrough, pseudocode, and honest take on where this gets hard.