Autoencoders: explicación y tutorial en Python

En este post veremos una completa explicación y un tutorial acerca de los Autoencoders, una importante arquitectura del Machine Learning que usa el aprendizaje no supervisado y que tiene aplicaciones en el procesamiento de imágenes y la detección de anomalías.

En el tutorial veremos cómo implementar un Autoencoder en Python, capaz de detectar transacciones fraudulentas en el uso de tarjetas débito o crédito.

Al final del artículo se encuentra el enlace para descargar el set de datos y el código fuente.

¡Así que listo, comencemos!

Video

Como siempre, en el canal de YouTube se encuentra el video de este post:

Introducción

En posts anteriores hemos visto cómo usar las Redes Neuronales para clasificar imágenes donde, a través del entrenamiento, la red aprende a extraer la información relevante de estos datos para luego determinar a qué categoría pertenece cada uno de ellos.

En este post hablaremos de otra de las aplicaciones de las Redes Neuronales: los Autoencoders, que permiten obtener una representación compacta de los datos de entrada, y que se usan por ejemplo para detectar anomalías o para la eliminación de ruido o generación de imágenes, entre otras aplicaciones.

Además de analizar en detalle en qué consiste un Autoencoder, veremos una aplicación práctica. En el ejemplo que realizaremos veremos paso a paso cómo implementar un Autoencoder usando Python y la librería Keras para detectar fraudes en transacciones hechas con tarjetas debito o crédito.

Principio de funcionamiento de las Redes Neuronales

Las Redes Neuronales que hemos visto en posts anteriores son capaces de tomar datos relativamente complejos (imágenes, sonidos, videos, etc.) y de clasificarlos, determinando así si el dato pertenece a una de varias posibles categorías:

Las Redes Neuronales como modelos para clasificar datos
Las Redes Neuronales como modelos para clasificar datos

Para lograr esto la Red Neuronal se entrena con una cantidad considerable de datos, y durante este proceso aprende a extraer la información o características más relevantes de esos datos de entrenamiento.

Este proceso se conoce como extracción de características, y permite representar la entrada a la Red Neuronal de una forma más compacta, reduciendo así la dimensionalidad de los datos de entrada:

Una Red Neuronal es capaz de reducir la dimensionalidad del dato procesado. En la imagen se observa un dato que a la entrada de la red posee 12 características (12 dimensiones) y que a la salida es representado de forma compacta con sólo 5 características (o 5 dimensiones)
Una Red Neuronal es capaz de reducir la dimensionalidad del dato procesado. En la imagen se observa un dato que a la entrada de la red posee 12 características (12 dimensiones) y que a la salida es representado de forma compacta con sólo 5 características (o 5 dimensiones)

¿Qué es un Autoencoder?

Una idea general

¿Qué pasa si en lugar de clasificar el dato de entrada intentamos reconstruirlo?

Es decir, ¿qué pasa si entrenamos la Red Neuronal para que aprenda a generar a la salida el mismo dato de entrada?

¡En este caso tendremos precisamente un Autoencoder!

Si en lugar de entrenar la Red Neuronal para clasificar el dato, la entrenamos para que a la salida intente generar el mismo dato, tendremos precisamente un Autoencoder
Si en lugar de entrenar la Red Neuronal para clasificar el dato, la entrenamos para que a la salida intente generar el mismo dato, tendremos precisamente un Autoencoder

Elementos del Autoencoder

Un Autoencoder tiene tres elementos:

  1. Un encoder que permite comprimir el dato de entrada:
El encoder permite obtener una representación compacta del dato de entrada
El encoder permite obtener una representación compacta del dato de entrada
  1. El bottleneck (o cuello de botella, resultado de la compresión) que es como tal la representación compacta obtenida:
El bottleneck es la representación compacta, el dato comprimido, que se obtiene a la salida del encoder
El bottleneck es la representación compacta, el dato comprimido, que se obtiene a la salida del encoder
  1. Y el decoder, que reconstruye la entrada a partir del bottleneck:
El decoder realiza el proceso inverso del encoder: toma el dato comprimido (con menores dimensiones) y lo lleva al espacio del dato original (con mayores dimensiones)
El decoder realiza el proceso inverso del encoder: toma el dato comprimido (con menores dimensiones) y lo lleva al espacio del dato original (con mayores dimensiones)

¿Pero para qué queremos entrenar una Red Neuronal que sea capaz de generar el mismo dato de entrada?

El bottleneck: una forma de comprimir el dato

Si observamos el bottleneck veremos que en este punto del Autoencoder se tiene una representación compacta de la entrada. Es decir, que el dato obtenido es una versión comprimida de la entrada, y que por tanto contiene una menor cantidad de datos.

La representación obtenida en el bottleneck se conoce como espacio latente y es el resultado del entrenamiento, en donde la red aprende a extraer la información más relevante del dato de entrada:

El espacio latente es simplemente la versión comprimida del dato de entrada. En el ejemplo de la figura la entrada tiene 4 datos (4 dimensiones) y el espacio latente tiene únicamente 2 dimensiones
El espacio latente es simplemente la versión comprimida del dato de entrada. En el ejemplo de la figura la entrada tiene 4 datos (4 dimensiones) y el espacio latente tiene únicamente 2 dimensiones

Entrenamiento del Autoencoder

Para lograr encontrar esta representación compacta, el Autoencoder es entrenado de manera similar a una Red Neuronal.

Sin embargo, en este caso la función de error usada para actualizar los coeficientes del autoencoder es simplemente el resultado de comparar, punto a punto, el dato reconstruido con el dato original:

Durante el entrenamiento el error es calculado como la diferencia entre el dato original (izquierda) y el dato reconstruido (derecha)
Durante el entrenamiento el error es calculado como la diferencia entre el dato original (izquierda) y el dato reconstruido (derecha)

Teniendo esto en cuenta podemos decir que el Autoencoder es un ejemplo de aprendizaje no supervisado, pues realmente durante el entrenamiento no definimos la categoría a la que pertenece cada entrada, ¡pues lo que queremos es que la salida sea precisamente el mismo dato de entrada!

Algunas aplicaciones de los Autoencoders

La representación compacta obtenida con el Autoencoder es útil por ejemplo para eliminar ruido en imágenes o para detectar anomalías en una serie de datos:

Algunas aplicaciones del Autoencoder: la eliminación de ruido en imágenes (izquierda) y la detección de anomalías (derecha)
Algunas aplicaciones del Autoencoder: la eliminación de ruido en imágenes (izquierda) y la detección de anomalías (derecha)

Es precisamente en la detección de anomalías en donde se centra el tutorial que veremos a continuación.

Tutorial: detección de fraudes usando Autoencoders

El problema a resolver

A nivel mundial, y durante el año 2015, el fraude por uso de tarjetas (débito o crédito) fue de 22 billones de dólares, y se espera que para el año 2020 este monto ascienda a 32 billones.

Varias empresas del sector han venido usando técnicas de Machine Learnin, como las Máquinas de Soporte Vectorial y técnicas de clustering, para detectar estos fraudes, pero sin resultados exitosos.

Recientemente, empresas como PayPal, han comenzado a hacer uso de técnicas de “Deep Learning” y autoencoders para la detección de fraudes.

Para implementar el autoencoder de este ejemplo usaremos un set de datos que contiene registros de transacciones realizadas en Europa durante septiembre de 2013. La idea es determinar cuáles de estos registros corresponden a transacciones fraudulentas, para lo cual diseñaremos un Autoencoder.

Este tutorial está implementado en Python, y se divide en cinco partes: primero realizaremos la lectura y análisis exploratorio del set de datos. En segundo lugar realizaremos el pre-procesamiento de los datos. Luego implementaremos el autoencoder y haremos su entrenamiento con ayuda de la librería Keras, y finalmente lo usaremos para detectar posibles transacciones fraudulentas en nuestro set de datos.

Comencemos entonces con la lectura y pre-procesamiento de los datos.

1. Lectura y análisis exploratorio de los datos

El set de datos contiene en total 284.315 transacciones “normales” y tan solo 492 “fraudulentas”. Para realizar la lectura usaremos la librería Pandas de Python:

import pandas as pd

datos = pd.read_csv("creditcard.csv")
print(datos.head())

nr_clases = datos['Class'].value_counts(sort=True)

La variable nr_clases contiene precisamente las transacciones normales y las fraudulentas.

Cada uno de estos datos contiene 30 características. Por motivos de confidencialidad, el proveedor del set de datos ha transformado las características V1 a V28 usando PCA (principal component analysis, una técnica usada para reducir la redundancia de información). Las características 29 y 30 (“Time” y “Amount”) indican la cantidad de segundos transcurridos desde el primer registro del dataset así como el monto (en euros) de la transacción:

Algunos registros del set de datos a utilizar
Algunos registros del set de datos a utilizar

Hagamos una exploración preliminar de los datos. Con ello podremos verificar que no hay una forma simple de detectar los fraudes, lo que justificará el uso de un Autoencoder.

En primer lugar podemos obtener una gráfica que muestre la distribución de los registros “normales” y “fraudulentos”:

nr_clases.plot(kind = 'bar', rot=0)
plt.xticks(range(2), ['Normales', 'Fraudulentos'])
plt.title("Distribución de los datos")
plt.xlabel("Clase")
plt.ylabel("Cantidad")
plt.show()

Podemos ver que los datos no están para nada balanceados, y la gran mayoría corresponde a registros “normales”:

Distribución del set de datos: normales (a la izquierda) y fraudulentos (a la derecha)
Distribución del set de datos: normales (a la izquierda) y fraudulentos (a la derecha)

Veamos ahora cómo se comportan los dos tipos de dato con respecto al tiempo:

normales = datos[datos.Class==0]
fraudulentos = datos[datos.Class==1]
plt.scatter(normales.Time/3600, normales.Amount, 
	alpha = 0.5, c='#19323C', label='Normales', s=3)
plt.scatter(fraudulentos.Time/3600, fraudulentos.Amount, 
	alpha = 0.5, c='#F2545B', label='Fraudulentos', s=3)
plt.xlabel('Tiempo desde la primera transacción (h)')
plt.ylabel('Monto (Euros)')
plt.legend(loc='upper right')
plt.show()

Observamos que no hay un patrón en particular, y que las transacciones “normales” y las “fraudulentas” no pueden ser discriminadas usando la variable tiempo:

Distribución del set de datos con respecto al tiempo: normales (con tonalidad oscura) y fraudulentos (en rojo)
Distribución del set de datos con respecto al tiempo: normales (con tonalidad oscura) y fraudulentos (en rojo)

¿Es posible que el monto de cada transacción nos permita diferenciar un tipo de dato de otro?:

import numpy as np

bins = np.linspace(200, 2500, 100)
plt.hist(normales.Amount, bins, alpha=1, normed=True,
	label='Normales', color='#19323C')
plt.hist(fraudulentos.Amount, bins, alpha=0.6, 
	normed=True, label='Fraudulentos', color='#F2545B')
plt.legend(loc='upper right')
plt.xlabel("Monto de la transacción (Euros)")
plt.ylabel("Porcentaje de las transacciones (%)")
plt.show()
Monto de las transacciones para las categorías normal y fraudulento
Monto de las transacciones para las categorías normal y fraudulento

Al observar la distribución de transacciones con respecto al monto, vemos que tampoco hay una clara diferencia, pues a pesar de que los registros “fraudulentos” tienden a tener montos más bajos, de todos modos estos se solapan con la distribución de los registros “normales”.

Por último, veamos si las distribuciones de características V1 a V28 presentan alguna diferencia entre los dos tipos de registros:

import matplotlib.gridspec as gridspec
import seaborn as sns

v_1_28 = datos.iloc[:,1:29].columns
gs = gridspec.GridSpec(28, 1)
for i, cn in enumerate(datos[v_1_28]):
    sns.distplot(datos[cn][datos.Class == 1], bins=50, 
    	label='Fraudulentos', color='#F2545B')
    sns.distplot(datos[cn][datos.Class == 0], bins=50, 
    	label='Normales', color='#19323C')
    plt.xlabel('')
    plt.title('Histograma característica: ' + str(cn))
    plt.legend(loc='upper right')
    plt.show()
Distribución de las características V1 a V28
Distribución de las características V1 a V28

Observamos que ninguna de estas características permite una clara diferenciación de los datos, y con esto concluimos que un método simple (enfocado por ejemplo en definir un umbral que permita separar unas características de otras) no resulta útil.

Con este análisis preliminar podemos concluir que no existe una forma sencilla de separar una categoría de la otra, y que tampoco podemos entrenar una Red Neuronal para realizar esta clasificación pues el set de datos está desbalanceado: existen muchos más registros “normales” que “fraudulentos”.

Lo anterior justifica el uso de un Autoencoder. Sin embargo, primero veamos cómo realizar el pre-procesamiento de los datos.

2. Pre-procesamiento de los datos

Comencemos eliminando la característica “tiempo” del set de datos (pues, como vimos anteriormente, no brinda información relevante):

from sklearn.preprocessing import StandardScaler
datos.drop(['Time'], axis=1, inplace=True)

Además, normalicemos la característica “amount” (el monto de las transacciones), para que tenga valor medio igual a cero y desviación estándar igual a 1 (comparables con las características V1 a V28). Esto garantizará la convergencia del algoritmo del Gradiente Descendente durante el entrenamiento:

datos['Amount'] = StandardScaler().fit_transform(datos['Amount'].values.reshape(-1,1))

Ahora creemos el set de entrenamiento (que corresponde al 80% de los registros) y el de validación (que tendrá el 20% restante). Esto se logra fácilmente con la función train_test_split de la librería Scikit-Learn.

El autoencoder será entrenado únicamente con registros “normales”, para que de esta forma aprenda a obtener una representación compacta de esos datos “normales”. Una vez entrenado, y cuando se introduzca un registro “fraudulento”, se esperaría que la reconstrucción del dato no sea tan precisa y por tanto la diferencia (o error) entre el dato reconstruido y el original será más grande que aquella obtenida cuando el dato ingresado es normal.

Así que el set de entrenamiento contendrá únicamente registros normales, mientras que el de validación tendrá los dos tipos de registros (normales y fraudulentos):

from sklearn.model_selection import train_test_split

X_train, X_test = train_test_split(datos, test_size=0.2, random_state=42)
X_train = X_train[X_train.Class == 0]  ## Usaremos únicamente la clase 0 (transacciones normales)
X_train = X_train.drop(['Class'], axis=1)
X_train = X_train.values

Y_test = X_test['Class']
X_test = X_test.drop(['Class'], axis=1)
X_test = X_test.values

Perfecto. Tenemos listos nuestros sets de entrenamiento y validación. Veamos ahora cómo implementar el Autoencoder en Keras.

3. Creación del Autoencoder en Keras

Diseñaremos un autoencoder con las siguientes características:

La estructura del Autoencoder a implementar
La estructura del Autoencoder a implementar

Para crear este Autoencoder primero definimos la capa de entrada, que tendrá el mismo número de elementos que cada ejemplo en el set de entrenamiento (es decir 29 datos):

import numpy as np
np.random.seed(5)
from keras.layers import Input

dim_entrada = X_train.shape[1]
capa_entrada = Input(shape=(dim_entrada,))

En donde en las líneas de código anteriores hemos usado np.random.seed(5) para fijar la semilla del generatorio aleatorio y garantizar de esta forma la reproducibilidad del algoritmo.

A continuación creamos el encoder con dos capas, de 20 y 14 neuronas, y funciones de activación tanh y relu respectivamente:

from keras.layers import Dense

encoder = Dense(20, activation='tanh')(capa_entrada)
encoder = Dense(14, activation='relu')(encoder)

El encoder que acabamos de crear permite pasar de un dato de entrada con 29 características a una representación compacta con 14 características.

Ahora implementaremos el decoder, que realiza el proceso de reconstrucción del dato, pasando de una representación compacta con 14 características a la versión reconstruida con 29 características.

El decoder tendrá entonces dos capas: la primera con 20 y la segunda con 29 neuronas. Acá usamos nuevamente las funciones de activación tanh y relu:

decoder = Dense(20, activation='tanh')(encoder)
decoder = Dense(29, activation='relu')(decoder)

Finalmente, combinamos la capa de entrada, el encoder y el decoder en un modelo de Keras, que será precisamente el autoencoder. Esto lo hacemos con la función Model, donde basta únicamente con especificar la capa de entrada y el decoder, y Keras automáticamente se encarga de interconectar todos los objetos:

from keras.models import Model

autoencoder = Model(inputs=capa_entrada, outputs=decoder)

Ahora debemos compilar el modelo. Es decir que debemos definir el algoritmo para minimizar la función de error, que será el Gradiente Descendente (SGD en Keras). Este método usará una tasa de aprendizaje de 0.01.

También debemos definir la función de error, que en este caso será el error cuadrático medio, que permitirá comparar punto a punto el dato de salida reconstruido con el dato original. En keras esta función se especifica con el parámetro loss='mse'.

Finalmente, todos estos parámetros se definen a través de la función compile:

from keras.optimizers import SGD

sgd = SGD(lr=0.01)
autoencoder.compile(optimizer='sgd', loss='mse')

4. Entrenamiento del Autoencoder

Recordemos que el entrenamiento se hará únicamente con las transacciones normales (set X_train).

Entrenaremos el modelo usando un total de 100 iteraciones, y usando tamaños de lote de 32 (es decir, presentaremos al modelo bloques de 32 ejemplos durante el entrenamiento).

Es importante observar que para este entrenamiento definimos X_train como el dato tanto de entrada como de salida del modelo:

nits = 100
tam_lote = 32
autoencoder.fit(X_train, X_train, epochs=nits, batch_size=tam_lote, shuffle=True, validation_data=(X_test,X_test), verbose=1)

5. Validación del modelo y detección de fraudes

Como el modelo fue entrenado sólo con transacciones normales, es de esperar que al introducir un registro fraudulento la reconstrucción del dato no sea tan precisa y por tanto la diferencia entre el dato reconstruido y el original será más grande que la obtenida cuando el dato ingresado es normal:

Al reconstruir un dato fraudulento con el Autoencoder entrenado el error en la reconstrucción debería ser mayor que para un dato normal
Al reconstruir un dato fraudulento con el Autoencoder entrenado el error en la reconstrucción debería ser mayor que para un dato normal

Así que para realizar la detección de fraudes seguiremos este procedimiento:

  1. En primer lugar tomaremos el Autoencoder entrenado y lo usaremos para generar una predicción, usando el set de validación (X_test)
  2. Luego calcularemos el error cuadrático medio entre el dato original (a la entrada del Autoencoder) y el dato reconstruido (generado a través de la predicción)
  3. Posteriormente estableceremos un umbral: si el error calculado en el punto anterior supera el umbral, tendremos un registro “fraudulento”, y en caso contrario tendremos un registro “normal”.

Esta idea del umbral debería funcionar, pues se espera que los registros “normales” tengan un error en la reconstrucción menor que los registros “fraudulentos”.

Veamos en detalle cada uno de estos pasos:

  1. Predicción: para generar la predicción usaremos la función predict, que permitirá obtener la reconstrucción de cada dato de entrada:
X_pred = autoencoder.predict(X_test)

Cada uno de los datos predichos por el modelo tiene un total de 29 características (el mismo tamaño del dato de entrada).

  1. Cálculo del error cuadrático medio
ecm = np.mean(np.power(X_test-X_pred,2), axis=1)
  1. Definición del umbral:

Para definir el umbral, usaremos dos métricas que resultan útiles en casos como este (donde los datos no están balanceados): “precisión” y “recall”

Para esto tengamos en cuenta que el modelo puede generar cuatro tipos de clasificación:

Un clasificador ideal no debería generar ni falsos positivos ni falsos negativos. Sin embargo, ningún clasificador es ideal y por tanto siempre tendremos, en mayor o menor grado, falsos positivos y negativos.

El parámetro “precision” es una medida de la cantidad de falsos positivos generados por el clasificador. Si la cantidad de falsos positivos se reduce a cero, se alcanzaría una “precisión” máxima igual a 1.

Por su parte, el “recall” es una medida de la cantidad de falsos negativos generados por el clasificador. Si la cantidad de falsos negativos (FN) se reduce a cero, se alcanzaría un “recall” ideal igual a 1.

Definición del 'precision' y el 'recall'
Definición del 'precision' y el 'recall'

En la práctica no se pueden lograr simultáneamente los dos valores ideales: una mayor “precisión” implica sacrificar el “recall” y viceversa.

En nuestro Autoencoder, el precisión y el recall cambiarán dependiendo del umbral escogido. Para observar este comportamiento podemos hacer una gráfica del precisión y el recall vs el umbral, que en Scikit-Learn se puede obtener fácilmente usando la función precision_recall_curve:

from sklearn.metrics import precision_recall_curve
precision, recall, umbral = precision_recall_curve(Y_test, ecm)

plt.plot(umbral, precision[1:], label="Precision",linewidth=5)
plt.plot(umbral, recall[1:], label="Recall",linewidth=5)
plt.title('Precision y Recall para diferentes umbrales')
plt.xlabel('Umbral')
plt.ylabel('Precision/Recall')
plt.legend()
plt.show()
A medida que el umbral aumenta, la precisión aumenta pero el recall disminuye
A medida que el umbral aumenta, la precisión aumenta pero el recall disminuye

Como se mencionó anteriormente, en efecto el precisión y el recall tienen comportamientos opuestos: un aumento del umbral genera un aumento de la precisión pero una reducción en el recall.

Lo que queda ahora es definir el umbral, y esto depende de qué es lo que queremos favorecer. ¿Nos interesa detectar la mayor parte de los fraudes, es decir tener pocos falsos negativos, a costa de un alto número de falsos positivos? O por el contrario, ¿nos interesa que los registros normales sean clasificados correctamente, a costa de un elevado número de registros fraudulentos clasificados incorrectamente?

Para este ejemplo vamos a escoger la primera opción, es decir que ajustaremos el umbral para obtener un recall alto.

Fijaremos entonces un umbral de 0.75. Si el error de la predicción sobrepasa este umbral, asignaremos la categoría “0” (registro fraudulento), mientras que si el error es menor o igual al umbral asignaremos la categoría “1” (registro normal):

umbral_fijo = 0.75
Y_pred = [1 if e > umbral_fijo else 0 for e in ecm]

Finalmente, podemos imprimir la matriz de confusión de este clasificador tomando como referencia la categoría a la que corresponde realmente el dato (almacenada en la variable Y_test):

from sklearn.metrics import confusion_matrix

conf_matrix = confusion_matrix(Y_test, Y_pred)
print(conf_matrix)

Tengamos en cuenta que la matriz de confusión muestra en su diagonal principal el número de verdaderos negativos (normales clasificados como normales) y verdaderos positivos (fraudulentos clasificados como fraudulentos), mientras que en la diagonal secundaria encontraremos los falsos positivos (normales clasificados como fraudulentos) y los falsos negativos (fraudulentos clasificados como normales):

Definición de la matriz de confusión para el caso de las transacciones normales y fraudulentas
Definición de la matriz de confusión para el caso de las transacciones normales y fraudulentas

Al ejecutar el código anterior, obtenemos la siguiente matriz de confusión:

La matriz de confusión obtenida para el autoencoder diseñado y con un umbral de 0.75
La matriz de confusión obtenida para el autoencoder diseñado y con un umbral de 0.75

Con los datos de la matriz de confusión podemos obtener el precisión y recall logrados por el autoencoder con el umbral escogido.

Al momento de definir el umbral decidimos favorecer el recall, sacrificando el “precisión. De hecho, al observar la matriz de confusión vemos que 49580 datos normales fueron clasificados correctamente, mientras que 7284 fueron clasificados como fraudulentos. Es decir, tenemos una cantidad relativamente alta de falsos positivos, y por tanto un “precision” de tan solo 0.01!

Sin embargo, al analizar el recall, vemos que un total de 90 registros fraudulentos fueron detectados correctamente, mientras que 8 de ellos fueron detectados como registros normales. ¡No está nada mal!, pues tenemos un recall de 0.92.

Con esto podemos concluir que para el Autoencoder diseñado, y con el umbral seleccionado, se tiene un buen desempeño al momento de detectar registros fraudulentos.

Datos y código fuente

En este enlace de Github podrás descargar el set de datos y el código fuente de este tutorial.

Conclusión

Bien, en este post hemos visto una explicación detallada del Autoencoder, una variante de las Redes Neuronales que permite encontrar una representación compacta de los datos de entrada.

Este principio de funcionamiento lo aprovechamos en el tutorial, en donde implementamos en Python un Autoencoder capaz de detectar operaciones fraudulentas en el uso de tarjetas, con un recall bastante alto ( igual a 0.92).

Es posible aún refinar este modelo, probando variantes del autoenconder (agregando o quitando capas o cambiando el número de neuronas en cada capa) o modificando el umbral, para tratar de lograr un recall aún más alto que el que obtuve en este tutorial.

Si tienes alguna duda de este artículo o tienes alguna sugerencia no dudes en contactarme diligenciando el siguiente formulario:

Debes introducir tu nombre.
Debes introducir tu apellido.
Debes introducir un email válido.
Debes introducir un mensaje.
Debes aceptar la política de privacidad.

Otros artículos que te pueden interesar