censo.head(4)| nombre | edad | educacion | ganancia_capital | ingreso_alto | |
|---|---|---|---|---|---|
| 0 | Karen | 30 | 10 | 0.0 | 1 |
| 1 | P0001 | 17 | 16 | 0.0 | 1 |
| 2 | P0002 | 32 | 13 | 0.0 | 0 |
| 3 | P0003 | 49 | 13 | 0.0 | 1 |
Basado en Programming Differential Privacy, de Joseph P. Near y Chiké Abuah
Universidad de los Andes & Quantil
24 septiembre 2026
¿Cómo publicamos estadísticas útiles sobre un conjunto de personas sin revelar nada sobre ninguna de ellas en particular?
Todo el código de estas slides se ejecuta al renderizar: los números y las figuras que verás se calculan en el momento.
Trabajamos con un censo sintético de 5.000 personas, con la estructura del dataset Adult del US Census que usa el libro.
Publicamos solo un conteo. Sin nombres, sin identificadores. Parece seguro. Consideremos el caso de Karen.
def cuenta_ingresos_altos(df):
return len(df[df.ingreso_alto == 1])
total = cuenta_ingresos_altos(censo)
sin_karen = cuenta_ingresos_altos(censo[censo.nombre != "Karen"])
print(f"Personas con ingreso alto: {total}")
print(f"Idem, excluyendo a Karen: {sin_karen}")
print(f"Diferencia -> ingreso alto de Karen: {total - sin_karen}")Personas con ingreso alto: 2738
Idem, excluyendo a Karen: 2737
Diferencia -> ingreso alto de Karen: 1
Ataque diferencial. Dos agregados, restados, reconstruyen un dato individual.
Si el resultado publicado hubiera sido casi el mismo con o sin Karen en la base, entonces observarlo no dice nada sobre ella.
Karen puede sostener: ese resultado se habría producido igual si yo no estuviera en los datos. Y sería creíble.
Formalizamos “casi el mismo” con dos piezas:
-privacidad diferencial. Un mecanismo aleatorio satisface -privacidad diferencial si para todo par de bases vecinas , y todo conjunto de salidas :
Tres lecturas de la misma desigualdad:
acota cuánto puede cambiar la creencia de un atacante sobre Karen.
epsilon = 0.01 -> razon de probabilidades <= e^eps = 1.01
epsilon = 0.10 -> razon de probabilidades <= e^eps = 1.11
epsilon = 0.50 -> razon de probabilidades <= e^eps = 1.65
epsilon = 1.00 -> razon de probabilidades <= e^eps = 2.72
epsilon = 2.00 -> razon de probabilidades <= e^eps = 7.39
epsilon = 10.00 -> razon de probabilidades <= e^eps = 22026.47
se considera protección fuerte; , poca privacidad. No hay umbral universal: es una decisión del investigador.
La receta más simple que satisface la definición: sumar ruido de Laplace calibrado al presupuesto.
def mecanismo_laplace(valor, sensibilidad, epsilon):
"""Devuelve valor + ruido Laplace(0, sensibilidad/epsilon)."""
escala = sensibilidad / epsilon
return valor + np.random.laplace(loc=0.0, scale=escala)
print(f"Conteo real: {total}")
for _ in range(3):
print(f"Conteo con eps = 1.0: {mecanismo_laplace(total, 1, 1.0):.2f}")Conteo real: 2738
Conteo con eps = 1.0: 2737.55
Conteo con eps = 1.0: 2737.43
Conteo con eps = 1.0: 2739.28
La sensibilidad de un conteo es : una persona cambia el resultado en, como máximo, uno. Volveremos sobre esto en el capítulo 3.
Menos significa más dispersión: más privacidad y menos utilidad. El mismo parámetro gobierna ambas cosas.
Repetimos el ataque, pero ahora las dos consultas pasan por el mecanismo.
ensayos = np.array([
mecanismo_laplace(total, 1, 0.5) - mecanismo_laplace(sin_karen, 1, 0.5)
for _ in range(10_000)
])
acierta = np.mean(np.round(np.clip(ensayos, 0, 1)) == 1)
print(f"Respuesta real del ataque: {total - sin_karen}")
print(f"Estimacion media del atacante: {ensayos.mean():.3f}")
print(f"Desviacion estandar: {ensayos.std():.2f}")
print(f"Acierta al redondear: {acierta:.1%} (azar = 50%)")Respuesta real del ataque: 1
Estimacion media del atacante: 1.003
Desviacion estandar: 3.98
Acierta al redondear: 56.1% (azar = 50%)
La señal que busca el atacante vale 1; el ruido tiene desviación estándar de varias unidades. La señal queda enterrada.
¿Por qué la definición exige que la garantía valga para todo par de bases vecinas, y no en promedio sobre las bases plausibles?
Porque una garantía promedio se rompe justo para quien más la necesita: el individuo atípico. El millonario en un censo de ingresos, el paciente con la enfermedad rara.
Una garantía en el peor caso es además la única que sobrevive a un atacante con conocimiento auxiliar arbitrario: no necesitamos suponer qué sabe.
Es la diferencia con la anonimización clásica: -anonimato protege contra los ataques que imaginaste; la privacidad diferencial, contra todos.
Nadie publica una sola consulta. Un instituto de estadística publica docenas de tablas; un modelo se entrena con miles de iteraciones.
Necesitamos saber qué pasa cuando los resultados privados se combinan, se transforman o se repiten. Tres propiedades responden eso:
Si es -DP y es cualquier función que no vuelve a mirar los datos, entonces también es -DP.
bruto = mecanismo_laplace(total, 1, 1.0)
redondeado = max(0, round(bruto)) # conteos negativos no tienen sentido
proporcion = redondeado / len(censo) # reescalar a proporción
print(f"Salida cruda del mecanismo: {bruto:.3f}")
print(f"Redondeada y truncada: {redondeado} <- sigue siendo 1.0-DP")
print(f"Como proporcion: {proporcion:.4f} <- sigue siendo 1.0-DP")Salida cruda del mecanismo: 2739.311
Redondeada y truncada: 2739 <- sigue siendo 1.0-DP
Como proporcion: 0.5478 <- sigue siendo 1.0-DP
Podemos limpiar, reescalar y graficar los resultados. La información privada ya fue protegida; ninguna función puede recuperarla.
Si es -DP y es -DP, publicar ambas es -DP.
El presupuesto se gasta: repetir una consulta permite promediar el ruido hasta hacerlo desaparecer.
def error_al_promediar(k, epsilon=1.0, repeticiones=400):
"""Error medio al promediar k respuestas ruidosas independientes."""
estimaciones = np.array([
np.mean([mecanismo_laplace(total, 1, epsilon) for _ in range(k)])
for _ in range(repeticiones)
])
return np.mean(np.abs(estimaciones - total))
for k in [1, 10, 100]:
err = error_al_promediar(k)
print(f"k = {k:3d} publicaciones -> error medio = {err:6.3f}")k = 1 publicaciones -> error medio = 0.973
k = 10 publicaciones -> error medio = 0.346
k = 100 publicaciones -> error medio = 0.109
Con publicaciones el error cae como : con suficientes consultas el atacante recupera el valor exacto. Por eso el presupuesto debe sumar.
Si los son -DP y se aplican sobre subconjuntos disjuntos de los datos, el total es -DP — no .
Un histograma es el caso canónico: cada persona cae en exactamente un nivel educativo, así que solo afecta a una barra.
conteos = censo.educacion.value_counts().sort_index()
ruidosos = {nivel: mecanismo_laplace(c, 1, 0.5) for nivel, c in conteos.items()}
for nivel in list(conteos.index)[:4]:
print(f"educacion = {nivel:2d} anios | real: {conteos[nivel]:5d} | "
f"privado: {ruidosos[nivel]:9.2f}")
print(f"\nCosto total del histograma completo: eps = 0.5 (no 0.5 x "
f"{len(conteos)} = {0.5*len(conteos)})")educacion = 9 anios | real: 1620 | privado: 1617.71
educacion = 10 anios | real: 1067 | privado: 1068.83
educacion = 12 anios | real: 202 | privado: 196.46
educacion = 13 anios | real: 1228 | privado: 1225.96
Costo total del histograma completo: eps = 0.5 (no 0.5 x 6 = 3.0)
La diferencia es visualmente imperceptible porque el ruido es de orden personas sobre barras de cientos. Los histogramas son el caso exitoso de la privacidad diferencial.
Hasta ahora usamos sensibilidad sin justificarlo. Esa es la pieza que conecta la consulta con el ruido.
Sensibilidad global. Para una consulta , es decir: cuánto puede mover el resultado una sola persona, en el peor caso.
El mecanismo de Laplace con escala satisface -DP. Todo el trabajo está en calcular correctamente.
def universitarios(df):
return len(df[df.educacion >= 13])
dentro = censo.index[censo.educacion >= 13][0] # alguien que SI cuenta
fuera = censo.index[censo.educacion < 13][0] # alguien que NO cuenta
print(f"Base completa: {universitarios(censo)}")
print(f"Quitando a quien SI cuenta: {universitarios(censo.drop(dentro))}")
print(f"Quitando a quien NO cuenta: {universitarios(censo.drop(fuera))}")Base completa: 2111
Quitando a quien SI cuenta: 2110
Quitando a quien NO cuenta: 2111
Agregar o quitar una persona cambia un conteo en o en . Nunca más. , independientemente del tamaño de la base.
Por eso los conteos son baratos: el ruido necesario no crece con , así que el error relativo se desvanece en bases grandes.
Suma de ganancias de capital: 4,224,162
Maximo individual observado: 545,772
Sensibilidad = el mayor valor *posible*, no el observado -> sin cota
Nadie impide que exista una persona con una ganancia de capital de mil millones. La sensibilidad de una suma sin restricciones es infinita, y un ruido infinito destruye toda utilidad.
Forzamos una cota. Si ningún valor puede exceder , entonces por construcción.
def suma_recortada(serie, b):
return serie.clip(upper=b).sum()
print(f"{'b':>9} | {'suma recortada':>15} | {'sesgo':>7} | {'ruido':>8}")
print("-" * 50)
for b in [5_000, 20_000, 100_000, 500_000]:
real = suma_recortada(censo.ganancia_capital, b)
desvios = [abs(mecanismo_laplace(real, b, 1.0) - real) for _ in range(300)]
sesgo = (real - suma_real) / suma_real
print(f"{b:>9,} | {real:>15,.0f} | {sesgo:>6.1%} | "
f"{np.mean(desvios)/suma_real:>7.1%}") b | suma recortada | sesgo | ruido
--------------------------------------------------
5,000 | 1,146,931 | -72.8% | 0.1%
20,000 | 2,233,632 | -47.1% | 0.5%
100,000 | 3,485,208 | -17.5% | 2.4%
500,000 | 4,178,390 | -1.1% | 12.4%
El dilema del recorte: pequeño sesga la suma hacia abajo; grande exige más ruido. Hay un óptimo intermedio.
A la izquierda, el sesgo desaparece al crecer . A la derecha, el ruido lo compensa: el error total tiene un mínimo interior.
Problema circular: el óptimo depende de los datos, y mirarlos cuesta presupuesto. La salida del libro: buscar el codo con consultas privadas.
def cota_privada(serie, epsilon, candidatos):
"""Sube b hasta que la suma recortada deja de crecer. Gasta epsilon."""
eps_i = epsilon / len(candidatos) # composición secuencial
anterior = None
for b in candidatos:
actual = mecanismo_laplace(suma_recortada(serie, b), b, eps_i)
if anterior is not None and actual <= anterior * 1.02:
return b, actual
anterior = actual
return candidatos[-1], anterior
b_elegido, _ = cota_privada(censo.ganancia_capital, 0.5,
[2_000 * 2**i for i in range(8)])
print(f"Cota elegida de forma privada: b = {b_elegido:,}")
print(f"Costo: eps = 0.5, pagado ANTES de publicar la suma")Cota elegida de forma privada: b = 32,000
Costo: eps = 0.5, pagado ANTES de publicar la suma
Honestidad contable: el presupuesto gastado en elegir cuenta. Es composición secuencial, no un preprocesamiento gratuito.
Un promedio no es una consulta lineal, así que no tiene sensibilidad simple. La descomponemos y usamos composición secuencial.
def media_privada(serie, cota, epsilon):
suma = mecanismo_laplace(serie.clip(upper=cota).sum(), cota, epsilon / 2)
conteo = mecanismo_laplace(len(serie), 1, epsilon / 2)
return suma / conteo
print(f"Edad media real: {censo.edad.mean():.4f}")
for _ in range(3):
m = media_privada(censo.edad, 90, 1.0)
print(f"Edad media privada: {m:.4f} (eps = 1.0)")Edad media real: 37.6352
Edad media privada: 37.6372 (eps = 1.0)
Edad media privada: 37.6700 (eps = 1.0)
Edad media privada: 37.6075 (eps = 1.0)
El presupuesto se parte: para la suma (sensibilidad , la edad máxima) y para el conteo (sensibilidad ).
Tentación: usar la sensibilidad en la base que tenemos, no en el peor caso. Sería mucho más chica.
Y rompe la garantía. La sensibilidad local depende de los datos; si la escala del ruido depende de los datos, la escala misma filtra información. Un atacante que observa un ruido pequeño aprende que la base no tiene valores extremos.
Las alternativas legítimas son más elaboradas:
Todas comparten el principio: nada que dependa de los datos puede escapar al presupuesto.
La -DP pura es exigente: prohíbe cualquier salida que sea más de veces más probable bajo que bajo , incluso las de probabilidad ínfima.
Relajar la definición con un término aditivo compra tres cosas:
-privacidad diferencial. Para todo par de bases vecinas , y todo conjunto de salidas :
El término es una probabilidad de falla del mecanismo: con probabilidad la garantía de -DP simplemente no aplica.
Elegir . Con el mecanismo “publicar los datos de una persona al azar” satisface técnicamente la definición — y es evidentemente inaceptable.
La convención habitual es o, directamente, para bases del orden de decenas de miles.
Diferencia conceptual clave:
Son dos ejes distintos, no dos formas del mismo parámetro.
Cuando la consulta devuelve un vector, hay dos formas de medir cuánto lo mueve una persona.
dimension k | L1 = k | L2 = sqrt(k) | razon
--------------------------------------------------
1 | 1 | 1.00 | 1.00x
4 | 4 | 2.00 | 2.00x
16 | 16 | 4.00 | 4.00x
100 | 100 | 10.00 | 10.00x
1000 | 1000 | 31.62 | 31.62x
Para una persona que afecta componentes en cada una: pero . Laplace necesita ; Gauss se conforma con . Ahí está la ganancia.
Misma receta que Laplace — sumar ruido calibrado a la sensibilidad — pero con ruido normal y calibrado a .
Mecanismo Gaussiano. Sea una consulta con valores en y sensibilidad . Para y , el mecanismo
satisface -privacidad diferencial.
Frente a Laplace, en cada componente:
def mecanismo_gaussiano(valor, sens_l2, epsilon, delta):
"""Valido para epsilon < 1 (analisis clasico de Dwork & Roth)."""
sigma = np.sqrt(2 * np.log(1.25 / delta)) * sens_l2 / epsilon
return valor + np.random.normal(0.0, sigma, size=np.shape(valor))
print(f"Conteo real: {total}")
for _ in range(3):
print(f"Gaussiano (eps=0.5, delta=1e-5): "
f"{mecanismo_gaussiano(float(total), 1.0, 0.5, 1e-5):.2f}")Conteo real: 2738
Gaussiano (eps=0.5, delta=1e-5): 2749.89
Gaussiano (eps=0.5, delta=1e-5): 2727.40
Gaussiano (eps=0.5, delta=1e-5): 2727.91
Nótese el precio: para una consulta escalar, incorpora el factor cuando .
En una consulta escalar Laplace gana: con el mismo concentra mucha más masa cerca de cero, mientras Gauss paga el precio de sin recibir nada a cambio.
A partir de componentes, Gauss domina — y sigue mejorando. Por eso el aprendizaje automático privado usa Gauss: un gradiente tiene tantas componentes como parámetros el modelo.
La relajación también mejora la contabilidad del presupuesto.
mecanismos -DP componen a -DP, para cualquier que elijamos, con
k | secuencial | avanzada | mejor
----------------------------------------------
1 | 0.10 | 0.96 | secuencial
10 | 1.00 | 3.03 | secuencial
100 | 10.00 | 9.60 | avanzada
1000 | 100.00 | 30.35 | avanzada
10000 | 1000.00 | 95.97 | avanzada
Crece como en vez de , pero con un factor constante alto: solo conviene cuando es grande. Para pocas consultas, la composición simple gana.
La composición avanzada sigue siendo mediocre para el aprendizaje automático, donde son miles de iteraciones. De ahí nacieron definiciones diseñadas para componer bien.
El problema de fondo: -DP acota la cola de la pérdida de privacidad. Componer cotas de cola es intrínsecamente costoso — cada composición vuelve a aplicar un argumento de peor caso sobre otro.
-privacidad diferencial de Rényi. la satisface si para todo par de bases vecinas , :
donde es la divergencia de Rényi de orden .
En vez de acotar una cola, acota una divergencia. Y las divergencias se suman:
Mismo mecanismo Gaussiano repetido veces (, , ). Solo cambia cómo llevamos la cuenta.
sigma, delta = 20.0, 1e-5
L = np.log(1 / delta)
def eps_secuencial(k): # sumar los epsilon, uno por iteracion
return k * np.sqrt(2 * np.log(1.25 / (delta / k))) / sigma
def eps_avanzada(k): # composicion avanzada sobre los mismos k
e_i = np.sqrt(2 * np.log(1.25 / (delta / (2 * k)))) / sigma
return 2 * e_i * np.sqrt(2 * k * np.log(2 / delta))
def eps_rdp(k): # componer en RDP y convertir una sola vez
return k / (2 * sigma**2) + np.sqrt(2 * k * L) / sigma
for k in [1, 100, 10_000]:
print(f"k = {k:>6} | secuencial {eps_secuencial(k):>8.1f} | "
f"avanzada {eps_avanzada(k):>7.1f} | RDP {eps_rdp(k):>6.1f}")k = 1 | secuencial 0.2 | avanzada 2.5 | RDP 0.2
k = 100 | secuencial 28.6 | avanzada 28.8 | RDP 2.5
k = 10000 | secuencial 3236.2 | avanzada 325.0 | RDP 36.5
La conversión final es : se compone en RDP y se optimiza sobre al final, convirtiendo una sola vez.
Con el presupuesto cae de a sin tocar el ruido. Esto es lo que hace viable el DP-SGD del capítulo siguiente.
Queremos entrenar un clasificador que prediga ingreso alto a partir de edad y educación — sin que el modelo memorice a nadie.
X_bruto = censo[["edad", "educacion"]].values.astype(float)
X = (X_bruto - X_bruto.mean(0)) / X_bruto.std(0)
X = np.hstack([X, np.ones((len(X), 1))]) # término constante
y = np.where(censo.ingreso_alto.values == 1, 1, -1)
corte = 4_000
X_ent, y_ent, X_test, y_test = X[:corte], y[:corte], X[corte:], y[corte:]
def exactitud(theta):
return np.mean(np.sign(X_test @ theta) == y_test)
print(f"Entrenamiento: {len(X_ent)} filas | Prueba: {len(X_test)} filas")
piso_trivial = max(np.mean(y_test == 1), np.mean(y_test == -1))
print(f"Clase mayoritaria (piso trivial): {piso_trivial:.3f}")Entrenamiento: 4000 filas | Prueba: 1000 filas
Clase mayoritaria (piso trivial): 0.546
Regresión logística por descenso de gradiente. El gradiente es una suma sobre personas — y cada término depende de una sola.
def gradientes(theta, X, y):
"""Matriz de gradientes: una fila por persona."""
margen = y * (X @ theta)
return (-y * expit(-margen))[:, None] * X
g = gradientes(np.zeros(3), X_ent, y_ent)
print(f"Forma de la matriz de gradientes: {g.shape} (una fila por persona)")
print(f"Gradiente de Karen: {g[0].round(4)}")
print(f"Gradiente agregado (lo que usamos): {g.sum(axis=0).round(2)}")Forma de la matriz de gradientes: (4000, 3) (una fila por persona)
Gradiente de Karen: [ 0.305 0.2961 -0.5 ]
Gradiente agregado (lo que usamos): [-481.78 -566.81 -192. ]
Publicar el gradiente agregado en cada iteración es publicar una consulta de suma. Ya sabemos protegerla: recortar y sumar ruido.
La sensibilidad de la suma de gradientes no está acotada — igual que la suma de ganancias de capital. Misma solución: recortar, ahora en norma .
def recortar_filas(G, b):
"""Escala cada fila para que su norma L2 no exceda b."""
normas = np.linalg.norm(G, axis=1, keepdims=True)
return G * np.minimum(1.0, b / np.maximum(normas, 1e-12))
normas = np.linalg.norm(g, axis=1)
print(f"Norma L2 de los gradientes: mediana {np.median(normas):.3f}, "
f"maxima {normas.max():.3f}")
tras = np.linalg.norm(recortar_filas(g, 1.0), axis=1).max()
print(f"Tras recortar con b = 1: maxima {tras:.3f}")
print(f"Sensibilidad L2 de la suma recortada = b = 1 (por construccion)")Norma L2 de los gradientes: mediana 0.779, maxima 2.135
Tras recortar con b = 1: maxima 1.000
Sensibilidad L2 de la suma recortada = b = 1 (por construccion)
Juntamos todo: recorte por persona, ruido Gaussiano sobre la suma, y composición secuencial a lo largo de las iteraciones.
def descenso_privado(X, y, iteraciones=30, epsilon=1.0, delta=1e-5,
b=1.0, eta=2.0):
theta = np.zeros(X.shape[1])
for _ in range(iteraciones):
G = recortar_filas(gradientes(theta, X, y), b)
suma = G.sum(axis=0)
if epsilon is not None: # el presupuesto se reparte
suma = mecanismo_gaussiano(suma, b, epsilon / iteraciones,
delta / iteraciones)
theta = theta - eta * suma / len(X)
return theta
theta_np = descenso_privado(X_ent, y_ent, epsilon=None) # referencia sin ruido
print(f"Exactitud sin privacidad: {exactitud(theta_np):.4f}")
for eps in [10.0, 1.0, 0.1]:
acc = exactitud(descenso_privado(X_ent, y_ent, epsilon=eps))
print(f"Exactitud con eps = {eps:>5}: {acc:.4f}")Exactitud sin privacidad: 0.6860
Exactitud con eps = 10.0: 0.6880
Exactitud con eps = 1.0: 0.6760
Exactitud con eps = 0.1: 0.6380
La curva es la respuesta honesta a “¿cuánto cuesta la privacidad?”: depende del régimen. Arriba de el costo es casi nulo; abajo de el modelo colapsa hacia la clase mayoritaria.
Más iteraciones convergen mejor, pero cada una gasta : el ruido por paso crece con . Hay un óptimo interior.
Con presupuesto fijo, el número de iteraciones pasa de ser un detalle de implementación a ser un hiperparámetro de privacidad. Y ajustarlo mirando los datos también cuesta presupuesto.
Lo que implementamos es noisy gradient descent de lote completo. El algoritmo estándar, DP-SGD, agrega dos piezas:
Ambas piezas atacan lo mismo: el presupuesto por iteración. Con ellas, entrenar redes profundas con deja de ser imposible.
| Capítulo | Idea central | Qué se lleva a la práctica |
|---|---|---|
| 1. Privacidad diferencial | Indistinguibilidad entre bases vecinas, en el peor caso | es la perilla única entre privacidad y utilidad |
| 2. Propiedades | Post-procesamiento gratis; composición secuencial y paralela | El presupuesto es un recurso que se asigna y se agota |
| 3. Sensibilidad | Cuánto mueve el resultado una persona | Sin cota no hay privacidad: recortar es obligatorio |
| 4. DP aproximada | compra Gauss, y mejor composición | Indispensable en alta dimensión |
| 5. Aprendizaje | El gradiente es una consulta de suma | Recortar por persona, ruido, contabilidad |
Near, J. P. y Abuah, C. (2021). Programming Differential Privacy. Capítulos 3 (Differential Privacy), 4 (Properties of Differential Privacy), 5 (Sensitivity), 6 (Approximate Differential Privacy) y 11 (Machine Learning). https://programming-dp.com/
Dwork, C. y Roth, A. (2014). The Algorithmic Foundations of Differential Privacy. Foundations and Trends in Theoretical Computer Science, 9(3-4).
Abadi, M. et al. (2016). Deep Learning with Differential Privacy. CCS 2016.
Esta presentación se hizo con la ayuda del Claude Code Academic Workflow de Sant’Anna, P. H. C. (2026). GitHub. https://github.com/pedrohcgs/claude-code-my-workflow