Introduction: The Allure of Synthetic Imaging

Researchers and product teams are increasingly tempted by synthetic medical images because they promise to solve data‑scarcity, privacy, and labeling bottlenecks. A single‑click generator that spits out thousands of chest X‑rays or brain MRIs sounds like a shortcut to training high‑performing diagnostic models. However, beneath that convenience lies a set of subtle but serious problems that can undermine model reliability, regulatory compliance, and patient safety.

What Makes Synthetic Data Risky?

Synthetic datasets are created by a generative model—often a GAN or diffusion network—trained on a limited set of real scans. The generator learns statistical patterns, but it also inherits biases, artefacts, and privacy‑leakage risks from the source data. When those synthetic images are used uncritically, the downstream classifier may:

  • Misinterpret pathological features that never existed in the training set.
  • Over‑fit to artefacts that are artefacts of the generator rather than of real anatomy.
  • Expose patient‑level information if the generator memorizes and reproduces fragments of the original scans.
  • Fail regulatory audits that require provenance and traceability of every training sample.

The following tutorial shows how to detect these hidden liabilities before you ship a model into production.

Step 1: Set Up a Minimal Synthetic Generator

For illustration we will use medgan, a lightweight GAN implementation for 2‑D chest X‑rays. Install the dependencies and pull a pre‑trained checkpoint.

python -m venv synth-env
source synth-env/bin/activate
pip install torch torchvision tqdm medgan
# Download a small public checkpoint (example only)
wget https://example.com/medgan_chest_xray.pt -O medgan.pt

The code below loads the generator and produces 10 synthetic images. Note the use of a fixed random seed to make the experiment reproducible.

import torch
import numpy as np
from medgan import Generator

torch.manual_seed(42)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

# Load the checkpoint
state = torch.load('medgan.pt', map_location=device)
gen = Generator().to(device)
gen.load_state_dict(state['generator'])

def synth_images(num=10, noise_dim=100):
    z = torch.randn(num, noise_dim, 1, 1, device=device)
    with torch.no_grad():
        imgs = gen(z).cpu().numpy()
    # Rescale to [0, 255] uint8
    imgs = ((imgs + 1) * 127.5).astype(np.uint8).squeeze()
    return imgs

synthetic = synth_images()
print(f'Generated {synthetic.shape[0]} images')  # → Generated 10 images

Step 2: Quantify Distribution Drift

The first sanity check is to compare the pixel‑level distribution of synthetic images against a small hold‑out set of real scans. A Kolmogorov‑Smirnov (KS) test on the histogram of intensities can surface glaring mismatches.

import matplotlib.pyplot as plt
from scipy.stats import ks_2samp
import glob
import cv2

# Load a few real images (assume PNG format)
real_paths = glob.glob('real_chest_xrays/*.png')[:30]
real_imgs = [cv2.imread(p, cv2.IMREAD_GRAYSCALE) for p in real_paths]

def ks_distance(a, b):
    return ks_2samp(a.ravel(), b.ravel()).statistic

# Compute average KS distance across the set
distances = [ks_distance(r, s) for r in real_imgs for s in synthetic]
print(f'Average KS distance: {np.mean(distances):.4f}')

An average KS distance above 0.2 typically indicates that the synthetic data lives in a different intensity space, which can cause the classifier to learn spurious thresholds. If the distance is high, you should either retrain the generator with more diverse source data or discard the synthetic set.

Step 3: Detect Memorization of Real Patients

Even if the overall distribution looks correct, the generator might have memorized whole patches from the training set. A simple nearest‑neighbor search in feature space (using a pretrained ResNet‑50) can reveal such leakage.

import torchvision.models as models
import torchvision.transforms as T

feature_extractor = models.resnet50(pretrained=True).eval().to(device)
preprocess = T.Compose([
    T.ToPILImage(),
    T.Resize(224),
    T.CenterCrop(224),
    T.ToTensor(),
    T.Normalize(mean=[0.485], std=[0.229])
])

def embed(img):
    img_t = preprocess(img).unsqueeze(0).to(device)
    with torch.no_grad():
        feat = feature_extractor(img_t)
    return feat.squeeze().cpu().numpy()

real_feats = np.stack([embed(r) for r in real_imgs])
synth_feats = np.stack([embed(s) for s in synthetic])

# Compute cosine similarity
def cosine(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

leaked = 0
threshold = 0.99  # Very high similarity suggests memorization
for sf in synth_feats:
    sims = [cosine(sf, rf) for rf in real_feats]
    if max(sims) > threshold:
        leaked += 1

print(f'Potentially leaked images: {leaked}/{len(synth_feats)}')

Any synthetic image that exceeds the similarity threshold should be removed from the training pool. Regulatory bodies (e.g., FDA, EMA) may view such leakage as a privacy violation, leading to costly compliance reviews.

Step 4: Validate Clinical Relevance with a Small Expert Panel

Statistical checks are not enough. A brief blind review by a radiologist can surface clinically implausible artefacts—such as impossible bone shapes or inconsistent laterality. Below is a minimal Flask app that lets a reviewer scroll through the synthetic set and flag problematic images.

from flask import Flask, render_template_string, request, redirect, url_for
import base64, io
from PIL import Image

app = Flask(__name__)

TEMPLATE = """

Synthetic Image Review

Review Synthetic Chest X‑Ray {{ idx + 1 }}/{{ total }}

""" def encode(img): buf = io.BytesIO() Image.fromarray(img).save(buf, format='PNG') return base64.b64encode(buf.getvalue()).decode('utf-8') @app.route('/', methods=['GET', 'POST']) def review(): idx = int(request.args.get('idx', 0)) if request.method == 'POST': action = request.form['action'] # In a real implementation you would persist the decision idx = (idx + 1) % len(synthetic) return redirect(url_for('review', idx=idx)) img_data = encode(synthetic[idx]) return render_template_string(TEMPLATE, img_data=img_data, idx=idx, total=len(synthetic)) if __name__ == '__main__': app.run(debug=True, port=5001)

Running this app ( python review_app.py ) gives the clinical team a quick way to prune the synthetic pool. Any image flagged as “Reject” should be excluded from model training, and the reasons for rejection should be documented for audit trails.

Step 5: Train a Baseline Classifier with and without Synthetic Data

Finally, compare model performance when trained on real data alone versus a mix of real + vetted synthetic images. Use a simple CNN to keep the experiment focused on data quality rather than architecture tricks.

import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset

# Assume real_imgs and synthetic have been pre‑processed to tensors
def make_loader(images, labels, batch=32):
    ds = TensorDataset(torch.tensor(images, dtype=torch.float32).unsqueeze(1),
                       torch.tensor(labels, dtype=torch.long))
    return DataLoader(ds, batch_size=batch, shuffle=True)

# Labels: 0 = normal, 1 = pneumonia (dummy example)
real_labels = np.random.randint(0, 2, size=len(real_imgs))
synth_labels = np.random.randint(0, 2, size=len(synthetic))

loader_real = make_loader(real_imgs, real_labels)
loader_mix  = make_loader(np.concatenate([real_imgs, synthetic]),
                          np.concatenate([real_labels, synth_labels]))

class SimpleCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv = nn.Sequential(
            nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(),
            nn.MaxPool2d(2), nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(),
            nn.MaxPool2d(2)
        )
        self.fc = nn.Linear(32 * 56 * 56, 2)

    def forward(self, x):
        x = self.conv(x)
        x = x.view(x.size(0), -1)
        return self.fc(x)

def train(loader, epochs=5):
    model = SimpleCNN().to(device)
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters(), lr=1e-3)
    for epoch in range(epochs):
        for imgs, lbl in loader:
            imgs, lbl = imgs.to(device), lbl.to(device)
            optimizer.zero_grad()
            out = model(imgs)
            loss = criterion(out, lbl)
            loss.backward()
            optimizer.step()
    return model

model_real = train(loader_real)
model_mix  = train(loader_mix)

Evaluate both models on a held‑out real test set. If the mixed‑data model shows a statistically significant drop in AUC or an increase in false positives for rare pathologies, the synthetic data has introduced harmful bias.

Security and Best Practices

Treat synthetic medical datasets as “sensitive” assets. Apply