Introducción Práctica a FGSM
Este va a ser nuestro primer post práctico y vamos a empezar con un tipo de ataque bastante visual y que vamos a poder implementar de forma gratuita utilizando un servicio como Google Colab o en entornos locales usando Anaconda/Miniconda, un entorno virtual Python, Docker u otras opciones con las que estemos más cómodos.
Las redes neuronales, y concretamente las redes neuronales convolucionales (CNN), se utilizan de forma habitual en escenarios de visión artificial como la clasificación de imágenes, la detección de objetos, el reconocimiento facial y otros.
En este laboratorio no vamos a usar una CNN, sino una red neuronal densa (fully connected), que es más vulnerable a ataques como FGSM (Fast Gradient Sign Method). También usaremos el conjunto de datos de dígitos manuscritos de MNIST (Modified National Institute of Standards and Technology).

Nuestro objetivo al entrenar la red neuronal es que si le damos uno de estos dígitos manuscritos sea capaz de decirnos qué número es, es decir, asignarle una etiqueta. Para esto, la red tiene que aprender. Simplificando mucho, el proceso de aprendizaje es:
- Empezamos con miles de imágenes de dígitos manuscritos (0 al 9). Estos dígitos están etiquetados, es decir, le vamos a decir a la red neuronal el dígito que es.
- La red neuronal dará varias pasadas (epochs) por el conjunto de imágenes tratando de predecir qué número es. Al principio, la red neuronal hará predicciones al azar y estará inicializada con unos pesos que luego va a ir modificando en función de los errores que cometa. Estos pesos son los que determinan cómo se combinan las neuronas entre capas. Inicialmente, estos pesos suelen tener valores pequeños aleatorios que pueden ser positivos o negativos. Podría ocurrir que si entrenamos dos redes iguales pero con pesos iniciales distintos, las dos aprendan bien, que no lleguen necesariamente al mismo resultado o que cometan errores distintos frente a los ataques.
- Para cada imagen, la red neuronal hace una predicción. Por ejemplo, la red neuronal «dice» que se trata de un «6».
- Comparamos la predicción con la etiqueta real, por ejemplo, sabemos que es un «4».
- Calculamos el error. Cuanto más se equivoque, mayor será el error.
- Le decimos a la red cómo se ha equivocado mediante una técnica que se denomina backpropagation.
- La red ajusta sus «conexiones internas» para tratar de equivocarse menos la próxima vez. Esto lo hace cambiando los valores de los pesos.
La forma en que la red va cambiando los pesos no es aleatoria, sino utilizando métodos matemáticos como el descenso de gradiente. El símil que solemos utilizar para explicar el descenso de gradiente es que estamos en una montaña con los ojos vendados y queremos llegar al punto más bajo. No podemos ver, pero podemos notar la inclinación del terreno bajo nuestros pies:
- Notamos en qué dirección baja la pendiente
- Damos un pequeño paso en esa dirección, con cuidado de no caernos 😉
- Repetimos esto muchas veces hasta bajar la montaña
Por supuesto, podría ocurrir que con un método tan simple no consigamos bajar de la montaña (mínimo absoluto), sino que nos quedemos en una pequeña hondonada en la montaña o un pequeño valle. Esto ya sería tema de estudio para otro post.
En nuestro ejemplo vamos a utilizar las imágenes de MNIST de 28×28 píxeles, nuestra red las va a convertir en vectores de 784 (28×28) valores y tendremos una primera capa densa de 128 neuronas, luego una función de activación ReLU y una capa de salida de 10 neuronas (una por cada dígito). Esto nos da 138 neuronas (con pesos) en 4 capas incluyendo la de aplanado (flatten) y la ReLU. Nuestro modelo tendrá un total de 101.770 parámetros entrenables:
- Primera capa (784 x 128) + 128 biases = 100.480
- Segunda capa (128 x 10) + 10 biases = 1.290
¿Por qué explicamos todo esto del descenso de gradiente? Porque FGSM no busca reducir el error, sino aumentarlo todo lo posible, justo lo contrario que el entrenamiento, pero no modificando los pesos del modelo, sino añadiendo ruido a las imágenes para que el modelo se equivoque. FGSM ataca al modelo ya entrenado modificando las imágenes de entrada de una forma muy precisa para engañar al modelo. Dicho de otra forma, si tomamos un «7» manuscrito va a modificar la imagen muy poco pero de forma que provoque que el error del modelo aumente lo máximo posible haciendo que la etiqueta que asigna a la imagen sea incorrecta. Para una persona seguirá pareciendo un «7», pero el modelo dirá que es otro dígito con un alto grado de confianza.
FGSM es lo que denominamos un ataque de «caja blanca«, porque el atacante conoce todo lo necesario sobre el modelo:
- La arquitectura
- Los pesos del modelo
- La función de pérdida
- El cálculo del gradiente
En otros posts hablaremos de ataques de «caja negra«, en las que el atacante solo puede enviar entradas al modelo, ver las salidas y probar suerte para engañar al modelo.
En el siguiente enlace tenemos al cuadernos con el código que vamos a usar para entrenar un modelo y llevar a cabo el ataque FGSM. Veamos algunas partes de este código que son relevantes:
https://github.com/redteamailabs/ComputerVision/blob/main/FGSM_Practical_Fundamentals.ipynb
Definimos una red neuronal sencilla con 4 capas, una para aplanar la imagen y convertirla en un vector, una capa oculta de 128 neuronas, una función de activación ReLU y una capa de salida con una neurona por dígito. El método forward define cómo pasan los datos por la red.
class SimpleNN(nn.Module):
def __init__(self):
super(SimpleNN, self).__init__()
self.fc = nn.Sequential(
nn.Flatten(),
nn.Linear(28*28, 128),
nn.ReLU(),
nn.Linear(128, 10)
)
def forward(self, x):
return self.fc(x)
model = SimpleNN()
Luego entrenamos el modelo cargando el conjunto de entrenamiento, un batch_size de 64 y un optimizador Adam (Adaptive Moment Estimation, que es una versión mejorada del descenso de gradiente clásico) y función de pérdida cross entropy, que son típicas en tareas de clasificación:
train_set = MNIST(root='./data', train=True, download=True, transform=transform)
train_loader = torch.utils.data.DataLoader(train_set, batch_size=64, shuffle=True)
optimizer = optim.Adam(model.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()
Entrenamos la red 1 única vez (1 epoch), calculamos las predicciones, el error, se limpian gradientes, se hace backpropagation y se actualizan los pesos. Usamos un learning rate de 0.001 que suele ser un buen valor inicial. Solo entrenamos una vez para que sea más rápido de ejecutar, pero podemos incrementar el número de epochs para ver la diferencia.
for epoch in range(1): # Entrenamiento corto
for images, labels in train_loader:
outputs = model(images)
loss = criterion(outputs, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
Y definimos la función de ataque FGSM que toma el gradiente de el error respecto a la imagen y se queda solo con el signo. Multiplica por un valor «epsilon» que permite ajustar la intensidad del ataque y podemos probar diferentes valores para comprobar el resultado. Suma ese pequeño cambio a la imagen original:
def fgsm_attack(image, epsilon, data_grad):
sign_data_grad = data_grad.sign()
perturbed_image = image + epsilon * sign_data_grad
return torch.clamp(perturbed_image, 0, 1)
Tomamos una imagen aleatoria del conjunto de prueba y vamos a calcular el gradiente respecto a esa imagen:
data_iter = iter(test_loader)
image, label = next(data_iter)
image.requires_grad = True
Pedimos al modelo que haga una predicción sobre qué digito es y la mostramos:
output = model(image)
init_pred = output.max(1, keepdim=True)[1]
print(f"Predicción original: {init_pred.item()} (correcta: {label.item()})")

Calculamos la pérdida y hacemos backpropagation para obtener el gradiente del error respecto a la imagen:
loss = criterion(output, label)
model.zero_grad()
loss.backward()
data_grad = image.grad.data
Generamos la imagen modificada aplicando FGSM. Aquí podemos probar con diferentes valores de epsilon:
epsilon = 0.25
perturbed_image = fgsm_attack(image, epsilon, data_grad)
Y pedimos al modelo que haga una predicción con la imagen modificada, lo que provocará que el modelo la etiquete de forma incorrecta:
output_adv = model(perturbed_image)
final_pred = output_adv.max(1, keepdim=True)[1]
print(f"Predicción adversaria: {final_pred.item()}")

Nosotros seguimos viendo un 2, pero el modelo «ve» un 3.
En este caso hemos utilizado una red neuronal muy sencilla y en otros post pasaremos a una red neuronal convolucional, que es más robusta ante este ataque, aunque no es invulnerable.
Deja una respuesta