Privacidad Diferencial y Federated Learning: Arquitectura de Pipelines ML Sanitarios Conformes a HIPAA
La Arquitectura
Hospital A ──┐
Hospital B ──┤
Hospital C ──┼──→ [Servidor de Agregación Segura (AWS)] ──→ [Modelo Global]
Hospital D ──┤ ↑ solo gradientes encriptados
Hospital E ──┘ ↓ solo updates agregados
Cada hospital corre un nodo de entrenamiento local. El servidor central nunca ve datos crudos, gradientes crudos ni updates de modelo individuales. Solo ve el resultado de la agregación segura — la suma encriptada de todos los updates.
Privacidad Diferencial: La Teoría que Realmente Se necesita
La privacidad diferencial es una definición matemática, no una técnica. Un algoritmo aleatorizado M satisface (ε, δ)-privacidad diferencial si para todos los datasets D y D' que difieren en un registro, y para todas las posibles salidas S:
P[M(D) ∈ S] ≤ e^ε · P[M(D') ∈ S] + δ
En criollo: sacar o agregar los datos de cualquier paciente individual cambia la distribución de salida como máximo por un factor de e^ε, más una probabilidad de fallo δ despreciable.
Para deep learning, aplicamos esto a través de DP-SGD:
import torch
from opacus import PrivacyEngine
model = PneumoniaResNet(pretrained=True)
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
data_loader = torch.utils.data.DataLoader(hospital_dataset, batch_size=64)
privacy_engine = PrivacyEngine()
model, optimizer, data_loader = privacy_engine.make_private_with_epsilon(
module=model,
optimizer=optimizer,
data_loader=data_loader,
epochs=50,
target_epsilon=8.0,
target_delta=1e-5,
max_grad_norm=1.0,
)
for epoch in range(50):
for batch in data_loader:
images, labels = batch
optimizer.zero_grad()
output = model(images)
loss = F.binary_cross_entropy_with_logits(output, labels)
loss.backward()
optimizer.step() # Opacus maneja clipping + ruido internamente
epsilon = privacy_engine.get_epsilon(delta=1e-5)
print(f"Epoch {epoch}: ε = {epsilon:.2f}")
if epsilon > 8.0:
print("Budget de privacidad agotado. Parando entrenamiento.")
break
Las decisiones clave de parámetros que requieren experimentación cuidadosa:
- max_grad_norm=1.0: Muy bajo y clipeás señal útil. Muy alto y se necesita más ruido para lograr el mismo ε. Barrer de 0.1 a 10.0 típicamente arroja 1.0 como óptimo para arquitecturas ResNet.
- target_epsilon=8.0: Una elección pragmática. ε=1.0 es el gold standard académico pero nos costaba 8% de accuracy. ε=8.0 dio garantías de privacidad significativas manteniendo accuracy dentro del 2% del baseline sin privacidad.
- target_delta=1e-5: Práctica estándar — δ tiene que ser menor a 1/N donde N es el tamaño del dataset.
Rényi DP para Tracking de Budget
Este es el punto donde la mayoría de los tutoriales cometen errores en la matemática. Si ejecutars DP-SGD por 100 epochs, el costo naive de privacidad es 100 × ε_por_paso. Esto se llama composición básica, y es dramáticamente pesimista.
from opacus.accountants import RDPAccountant
accountant = RDPAccountant()
for epoch in range(num_epochs):
for step in range(steps_per_epoch):
accountant.step(
noise_multiplier=noise_multiplier,
sample_rate=batch_size / len(dataset),
)
epsilon = accountant.get_epsilon(delta=1e-5)
print(f"Después de epoch {epoch}: ε = {epsilon:.2f} (RDP)")
En un pipeline representativo, composición básica da ε=12.4 después de 50 epochs. RDP dio ε=4.7 para la exacta misma corrida de entrenamiento. Eso no es una mejora menor — es la diferencia entre un resultado publicable y un paper rechazado.
Federated Learning con PySyft
PySyft provee la capa de federación. Cada hospital corre un DataSite que controla acceso a sus datos:
import syft as sy
hospital_a = sy.orchestra.launch(
name="hospital-a",
port=8081,
dev_mode=False,
reset=True,
)
dataset = sy.Dataset(
name="chest-xray-hospital-a",
description="Radiografías de tórax de-identificadas, 2020-2025",
asset_list=[
sy.Asset(
name="images",
data=real_image_tensor, # Nunca sale de este nodo
mock=generate_mock_images(), # Datos sintéticos para testing
),
sy.Asset(
name="labels",
data=real_labels,
mock=generate_mock_labels(),
),
],
)
hospital_a.upload_dataset(dataset)
El coordinador central orquesta el entrenamiento sin acceder a los datos:
hospitals = [
sy.login(url="https://hospital-a.internal:8081", email="ml@coordinator.org", password="..."),
sy.login(url="https://hospital-b.internal:8082", email="ml@coordinator.org", password="..."),
sy.login(url="https://hospital-c.internal:8083", email="ml@coordinator.org", password="..."),
sy.login(url="https://hospital-d.internal:8084", email="ml@coordinator.org", password="..."),
sy.login(url="https://hospital-e.internal:8085", email="ml@coordinator.org", password="..."),
]
@sy.syft_function_single_use(
images=hospitals[0].datasets["chest-xray-hospital-a"]["images"],
labels=hospitals[0].datasets["chest-xray-hospital-a"]["labels"],
)
def train_local(images, labels):
import torch
from opacus import PrivacyEngine
model = load_global_model()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
data_loader = make_loader(images, labels, batch_size=64)
privacy_engine = PrivacyEngine()
model, optimizer, data_loader = privacy_engine.make_private_with_epsilon(
module=model, optimizer=optimizer, data_loader=data_loader,
epochs=1, target_epsilon=0.5, target_delta=1e-5, max_grad_norm=1.0,
)
model.train()
for batch_images, batch_labels in data_loader:
optimizer.zero_grad()
loss = F.binary_cross_entropy_with_logits(model(batch_images), batch_labels)
loss.backward()
optimizer.step()
return extract_model_updates(model)
Agregación Segura: La Pieza Faltante
Incluso con privacidad diferencial en el update de cada hospital, el coordinador podría aprender algo sobre la distribución de datos de un hospital específico a partir de su gradient update individual. La agregación segura previene esto.
import numpy as np
def secure_aggregate(hospital_updates, threshold=3):
"""
Los hospitales secret-share máscaras pairwise para que el
coordinador solo vea la suma de todos los updates, no los individuales.
"""
n = len(hospital_updates)
masked_updates = []
for i in range(n):
mask = np.zeros_like(hospital_updates[i])
for j in range(n):
if i == j:
continue
shared_seed = derive_shared_seed(private_keys[i], public_keys[j])
pairwise_mask = prng_from_seed(shared_seed, shape=mask.shape)
if i < j:
mask += pairwise_mask
else:
mask -= pairwise_mask
masked_updates.append(hospital_updates[i] + mask)
aggregate = sum(masked_updates) / n
return aggregate
Cuando el coordinador suma todos los updates enmascarados, las máscaras pairwise se cancelan perfectamente, dejando solo el promedio real de los updates de todos los hospitales.
El Problema Non-IID: Por Qué los Datos de Hospitales Son Raros
Los datos reales de hospitales son wildly non-IID. Hospital A es un centro pediátrico — mayormente radiografías de chicos. Hospital C es un geriátrico — pacientes ancianos con comorbilidades. Hospital E es una clínica rural — equipamiento de imagen diferente, demografía diferente.
FedAvg estándar se cae a pedazos con datos non-IID. Después de 10 rondas, veíamos modelos locales divergentes que promediados daban basura. La solución fue FedProx con un término proximal:
def train_local_fedprox(model, global_model, data_loader, mu=0.01):
model.train()
global_params = {name: param.clone() for name, param in global_model.named_parameters()}
for batch_images, batch_labels in data_loader:
optimizer.zero_grad()
loss = F.binary_cross_entropy_with_logits(model(batch_images), batch_labels)
# Término proximal: penaliza drift del modelo global
proximal_loss = 0.0
for name, param in model.named_parameters():
proximal_loss += ((param - global_params[name]) ** 2).sum()
loss += (mu / 2) * proximal_loss
loss.backward()
optimizer.step()
Este solo cambio llevó la convergencia de "nunca" a 30 rondas.
Resultados que Pasaron la Auditoría
Después de 50 rondas federadas con DP (ε=8.0 budget total por hospital):
| Métrica | Centralizado | Federado (sin DP) | Federado + DP (ε=8) | |---------|-------------|--------------------|-----------------------| | AUC | 0.961 | 0.942 | 0.934 | | Sensibilidad | 0.923 | 0.911 | 0.897 | | Especificidad | 0.954 | 0.938 | 0.931 | | PPV | 0.891 | 0.872 | 0.858 |
La caída de 2.7% en AUC de centralizado a federado+DP es clínicamente aceptable para una herramienta de screening. El auditor de HIPAA específicamente destacó la garantía formal de privacidad (ε=8.0, δ=1e-5) como fortaleza — nunca habían visto una cota cuantitativa de privacidad, solo "anonimizamos los datos".
Testing de Ataques de Membership Inference
Correr ataques de membership inference contra el modelo entrenado valida las garantías de privacidad empíricamente:
from ml_privacy_meter import run_population_metric_attack
attack_results = run_population_metric_attack(
target_model=federated_dp_model,
population_data=held_out_data,
member_data=training_data_sample,
num_shadow_models=10,
)
print(f"Attack AUC: {attack_results.auc:.3f}")
# Sin DP: Attack AUC = 0.71 (el modelo está leakeando info de membership)
# Con DP ε=8: Attack AUC = 0.53 (apenas mejor que adivinar al azar)
# Con DP ε=1: Attack AUC = 0.51 (esencialmente random)
Con ε=8.0, el ataque de membership inference logró solo 53% de AUC — apenas arriba del baseline random de 50%.
Lecciones Prácticas
Los costos de comunicación dominan. Cada ronda federada manda model updates (~180 MB para un ResNet-50) desde cinco hospitales por canales encriptados. Son casi 1 GB por ronda. Comprimir gradientes con top-k sparsification reduce la comunicación a ~5 MB por ronda.
Los departamentos de IT de hospitales son el cuello de botella real. La ingeniería de ML típicamente toma 6 semanas. Conseguir que cinco departamentos de IT de hospitales abran puertos de firewall, aprueben containers Docker y asignen recurse es GPU puede tomar 10 semanas. Arrancar el proceso de procurement de IT el día uno es esencial.
Este tipo de pipeline corre en producción, reentrenándose mensualmente con datos frescos de todos los hospitales participantes. Ningún dato de paciente sale jamás de la red del hospital. El modelo mejora con el tiempo a medida que encuentra case es más diversos. Y la garantía de privacidad es matemáticamente demostrable, no solo una promesa en una política de privacidad.