Tutorial

Synthetic EHR Data Generation using GANs

Difficulty: Advanced Time: 25 min read

Introduction

Sharing Electronic Health Records (EHR) for research is severely restricted due to patient privacy laws. One emerging solution is to generate high-fidelity synthetic EHR data that mirrors the statistical properties of real patient cohorts without exposing any actual Protected Health Information (PHI). In this tutorial, we will train a Generative Adversarial Network (GAN) to synthesize structured tabular patient data (demographics, labs, and diagnoses).

Architecture Overview


graph LR
    Real(Real EHR Data) --> Pre(Preprocessing / One-Hot)
    Pre --> Disc(Discriminator)
    Noise(Latent Noise Z) --> Gen(Generator)
    Gen --> Fake(Fake EHR Data)
    Fake --> Disc
    Disc --> Loss(Minimax Loss)
    Loss --> Gen
    Fake --> Eval(ML Efficacy / TSNE Eval)
        

Prerequisites

  • Python 3.10+
  • PyTorch, Pandas, Scikit-learn, UMAP-learn
  • A sample de-identified EHR dataset (e.g., Synthea or MIMIC-III Demo)
  • A GPU is highly recommended

Step 1: Data Preprocessing

Tabular EHR data contains a mix of continuous variables (e.g., lab values, age) and categorical variables (e.g., ICD-10 codes, gender). We must normalize continuous variables and one-hot encode categorical variables before feeding them to a neural network.

import pandas as pd
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer

# Load real patient data
df = pd.read_csv('real_patient_cohort.csv')

continuous_cols = ['age', 'heart_rate', 'systolic_bp', 'creatinine']
categorical_cols = ['gender', 'primary_diagnosis_code', 'discharge_status']

preprocessor = ColumnTransformer(
    transformers=[
        ('num', StandardScaler(), continuous_cols),
        ('cat', OneHotEncoder(sparse_output=False), categorical_cols)
    ]
)

real_data_encoded = preprocessor.fit_transform(df)
print(f"Encoded shape: {real_data_encoded.shape}")

Step 2: Defining the GAN Architecture

A standard tabular GAN consists of a Generator that takes random noise and outputs synthetic patient vectors, and a Discriminator that tries to distinguish between real and synthetic vectors.

import torch
import torch.nn as nn

class Generator(nn.Module):
    def __init__(self, latent_dim, output_dim):
        super().__init__()
        self.model = nn.Sequential(
            nn.Linear(latent_dim, 128),
            nn.ReLU(),
            nn.BatchNorm1d(128),
            nn.Linear(128, 256),
            nn.ReLU(),
            nn.BatchNorm1d(256),
            nn.Linear(256, output_dim),
            nn.Tanh() # Assuming inputs are scaled appropriately
        )

    def forward(self, z):
        return self.model(z)

class Discriminator(nn.Module):
    def __init__(self, input_dim):
        super().__init__()
        self.model = nn.Sequential(
            nn.Linear(input_dim, 256),
            nn.LeakyReLU(0.2),
            nn.Dropout(0.3),
            nn.Linear(256, 128),
            nn.LeakyReLU(0.2),
            nn.Dropout(0.3),
            nn.Linear(128, 1),
            nn.Sigmoid()
        )

    def forward(self, x):
        return self.model(x)

Step 3: Training the GAN

The Generator and Discriminator play a minimax game. The Generator tries to fool the Discriminator, while the Discriminator tries to catch the Generator.

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
latent_dim = 64
output_dim = real_data_encoded.shape[1]

netG = Generator(latent_dim, output_dim).to(device)
netD = Discriminator(output_dim).to(device)

criterion = nn.BCELoss()
optimizerD = torch.optim.Adam(netD.parameters(), lr=0.0002, betas=(0.5, 0.999))
optimizerG = torch.optim.Adam(netG.parameters(), lr=0.0002, betas=(0.5, 0.999))

real_tensor = torch.tensor(real_data_encoded, dtype=torch.float32)
dataloader = torch.utils.data.DataLoader(real_tensor, batch_size=128, shuffle=True)

for epoch in range(100):
    for i, real_batch in enumerate(dataloader):
        real_batch = real_batch.to(device)
        batch_size = real_batch.size(0)
        
        # 1. Train Discriminator
        netD.zero_grad()
        label_real = torch.ones(batch_size, 1).to(device)
        output_real = netD(real_batch)
        lossD_real = criterion(output_real, label_real)
        
        noise = torch.randn(batch_size, latent_dim).to(device)
        fake_batch = netG(noise)
        label_fake = torch.zeros(batch_size, 1).to(device)
        output_fake = netD(fake_batch.detach())
        lossD_fake = criterion(output_fake, label_fake)
        
        lossD = lossD_real + lossD_fake
        lossD.backward()
        optimizerD.step()
        
        # 2. Train Generator
        netG.zero_grad()
        label_target = torch.ones(batch_size, 1).to(device) # G wants D to think fake is real
        output_G = netD(fake_batch)
        lossG = criterion(output_G, label_target)
        lossG.backward()
        optimizerG.step()

Step 4: Evaluating Synthetic Data Quality (UMAP & ML Efficacy)

After training, we generate synthetic patients. We evaluate quality visually using UMAP, and quantitatively using "Machine Learning Efficacy" (training a classifier on fake data, testing on real data).

import umap
import matplotlib.pyplot as plt

# Generate 1000 synthetic patients
noise = torch.randn(1000, latent_dim).to(device)
synthetic_encoded = netG(noise).detach().cpu().numpy()

# 1. Visual Evaluation (UMAP)
reducer = umap.UMAP()
real_umap = reducer.fit_transform(real_data_encoded[:1000])
fake_umap = reducer.transform(synthetic_encoded)

plt.scatter(real_umap[:, 0], real_umap[:, 1], label='Real', alpha=0.5)
plt.scatter(fake_umap[:, 0], fake_umap[:, 1], label='Synthetic', alpha=0.5)
plt.legend()
plt.title("UMAP Projection of Real vs Synthetic EHR")
plt.show()

# 2. Machine Learning Efficacy
# Train XGBoost on synthetic_encoded, evaluate on real_data_encoded...

Conclusion

Generating high-quality synthetic EHR data allows researchers and developers to iterate on healthcare algorithms openly without risking patient privacy. Advanced models like CTGAN handle categorical variables even better than standard GANs and are worth exploring for production use cases.