EDICOM Analytics: camino hacia el machine learning

Pedro Ramírez y Manuel Roselló, Software Engineers en EDICOM, nos explican cómo diseñar y entrenar un modelo para predecir y ayudar en la toma de decisiones, basándonos en un conjunto de datos y gráficos.

Introducción

En la actualidad, los datos históricos proporcionan una gran cantidad de información que nos puede ayudar en la toma de decisiones y predicción de eventos futuros.

En este artículo vamos a hablar de cómo diseñar y entrenar un modelo para predecir y ayudar en la toma de decisiones, basándonos en un conjunto de datos y gráficos. Para ello, vamos a utilizar como fuente de datos nuestra plataforma de almacenamiento de información EdicomLta (Edicom Long-Term Archiving) y EDICOM Analytics como herramienta para explotar gráficamente la información.

Introducción al experimento

Para llevar a cabo una simulación práctica de un posible caso de uso para nuestros datos, se ha realizado un experimento que busca obtener una predicción del tráfico de datos de la aplicación EDICOMNet para realizar un HPA (Horizontal Pod Autoscaler) para el escalado horizontal de Pods en el sistema. Un Pod es la unidad básica controlable mediante el gestor de escalado y despliegue, Kubernetes. Cada Pod puede estar compuesto de uno o varios contenedores de aplicaciones, que comparten almacenamiento y recursos, así como sus especificaciones de ejecución.

EDICOMNet es un servicio de EDICOM que opera como una VAN (Value-Added Network) y permite el intercambio seguro y trazable de documentos EDI, con una disponibilidad de 24 horas al día y monitorización constante. Dicha información se almacena en la plataforma EdicomLta, la cual servirá como fuente de datos para realizar el experimento.

Para el experimento, necesitamos predecir el número de documentos por hora para abastecer de Pods la plataforma y satisfacer las necesidades de recursos con antelación. Para ello, se ha ideado un escenario en el que cada Pod es capaz de recibir de forma eficiente aproximadamente 10.000 documentos por hora.

Con dicha información, vamos a realizar un experimento para determinar la viabilidad del modelo, mostrando gráficamente la predicción de tráfico por horas y por semanas y evaluando la fiabilidad de los resultados y obteniendo una representación visual mediante la aplicación EDICOMAnalytics.

Experimento práctico

Para realizar el experimento, el código se ha escrito empleando el lenguaje de programación Python, debido a la simplicidad de uso que tienen sus librerías aplicadas a machine learning. Concretamente, se utilizarán cuatro bibliotecas muy comunes y populares en este campo, que además cuentan con buena documentación:

  • Pandas (pd): manipulación de datos en formato tabla.
  • Numpy (np): operaciones algebraicas.
  • Sklearn: modelos de aprendizaje automático y utilidades relacionadas.
  • Matplotlib (plt): representaciones gráficas de los datos.

Aprendizaje automático mediante Random Forests

En los últimos años, el machine learning ha sido tendencia y abundan las publicaciones repletas de términos que se refieren a diversos modelos de aprendizaje: las redes neuronales, las técnicas de clustering, las SVMs, etc. Sin embargo, existen más tipos de algoritmo que, pese a no presumir de tanta fama fuera del contexto académico, son tan capaces, o incluso superiores, en ciertos contextos, a las alternativas mencionadas anteriormente.

Uno de estos modelos es el Random Forest (Bosque Aleatorio). El nombre se debe a que realiza una combinación de varios árboles de decisión que no guardan correlación entre sí. Pero, ¿qué es un árbol de decisión? Este artículo no pretende entrar en gran detalle en los algoritmos que rigen este modelo computacional, no obstante, a continuación se presentará un reducido marco teórico para contextualizar el experimento.

Un árbol de decisión es una estructura muy sencilla que, probablemente, todo lector haya usado inconscientemente en más de una ocasión. En cada nodo, nos hacemos una pregunta cuya respuesta puede ser únicamente sí o no. En función de la respuesta, tomamos una rama u otra y llegamos a otro nodo con pregunta. Así, hasta llegar a un nodo «hoja» que nos da una respuesta final.

EDICOM Analytics: camino hacia el machine learning

Este ejemplo podría ser parte de un razonamiento muy simplificado para clasificar figuras. Vemos como cada decisión se basa principalmente en una característica: líneas rectas, longitud de los lados… Podríamos preguntar incluso por el color, pero en este caso no sería determinante. Del mismo modo, en el experimento dispondremos de una serie de características o features que determinarán las decisiones de los árboles. Veremos como unas son más relevantes que otras para llegar a nodos hoja con precisión. En un bosque aleatorio de regresión, cada árbol realiza una predicción individual y el valor final es la media de todos ellos (el voto mayoritario en problemas de clasificación). La idea de este algoritmo de ensemble es que la predicción combinada de todos los clasificadores «débiles» (los árboles) será siempre mejor que sus predicciones individuales¹.

La clave de este mecanismo es minimizar la correlación entre los árboles. Para ello, se utiliza la técnica conocida como bagging (Bootstrap Aggregation), que consiste en transmitir a cada árbol una muestra aleatoria y con reemplazo de tamaño igual al total original. Por ejemplo, si nuestros datos son una lista [a, b, c] podríamos tener un árbol [c, a, b] y otro [b, c, b] (el reemplazo permite valores duplicados). Gracias a este sistema, los árboles son distintos entre sí y garantizan generar estructuras y resultados diferentes.

Además, existe un parámetro típicamente configurable que determina el número de features (campos, características) que se utilizan para determinar un split (decisión de seguir una rama u otra), en lugar de utilizar todas cada vez. De esta forma, se genera más aleatoriedad y se evita un posible overfitting.

Sin embargo, es lógico hacerse la siguiente pregunta: ¿por qué Random Forest en vez de red neuronal? Las redes neuronales son increíblemente polivalentes y presentan un enorme potencial. A día de hoy, son una de las herramientas más comunes para afrontar cualquier tipo de problema de aprendizaje automático. Pero es precisamente esa polivalencia, la que permite que sean superadas cuando se trata de problemas específicos. En el caso de nuestro experimento, sabemos que se trata de datos textuales en formato tabla: no es un problema de reconocimiento facial, del habla, etc. Los Random Forests han demostrado un buen desempeño para tareas con este tipo de datos tabulares y nos aportan ciertas ventajas frente a una red neuronal²:

  • Un Random Forest requiere menos datos para producir un buen resultado. Nuestro conjunto de datos puede parecer relativamente grande con sus miles de datos, pero lo cierto es que es bastante reducido para los estándares de las redes neuronales.
  • El coste computacional es muchísimo menor. Tanto, que no es necesario recurrir a GPUs o TPUs para entrenar el modelo.
  • Resulta más sencillo de configurar e interpretar. Como veremos en las siguientes secciones, podemos incluso obtener una medida objetiva de la relevancia que tiene cada feature para el modelo. Una red neuronal suele ser mucho más difícil de «descifrar» y, en muchas ocasiones, requiere de cierta «prueba y error» a tientas.

Estudio y tratamiento de los datos

Para el experimento, trabajaremos con un conjunto de datos que recopila el tráfico de documentos en la aplicación Edicomnet. Concretamente, dispondremos de un total de 17.927 datos que recogen el tráfico por hora desde enero de 2019 hasta finales de febrero de 2021. Se trata de un modelo de datos inicial con una estructura muy sencilla, ya que consta únicamente de dos campos:

  • key_as_string: expresa la fecha y la hora en formato UTC, de acuerdo con la ISO-8601.
  • doc_count: indica el número de documentos (tráfico) registrados durante la última hora.

A continuación, podemos ver los primeros cinco elementos del conjunto de datos (donde el campo index se autogenera para asignar un identificador único a cada elemento):

df = pd.read_json('edicomnet_traffic.json') # También compatible con CSV
df.head() # Cargamos los primeros 5 elementos
indexkey_as_stringdoc_count
02019-01-01T00:00:00.000Z14139
12019-01-01T01:00:00.000Z12432
22019-01-01T02:00:00.000Z19827
32019-01-01T03:00:00.000Z10413
42019-01-01T04:00:00.000Z14284

Con estos datos iniciales ya podríamos construir y entrenar un modelo. Sin embargo, es probable que su eficacia dejara bastante que desear. En cualquier problema de machine learning, el análisis previo de los datos es la etapa más importante, ya que nos permite idear un modelo a medida que se ajuste a las necesidades específicas del conjunto de datos. Principalmente, debemos comprender los datos antes de procesarlos. Como primer paso, podemos generar un histograma para visualizar la distribución de los valores:

EDICOM Analytics: camino hacia el machine learning

Se aprecia con facilidad como los datos siguen una distribución bimodal, con bastantes valores agrupados en torno a los 10.000 documentos y una gran mayoría en la zona de 40.000 (de hecho, la media es de 33.327). Este gráfico nos permite ver que el conjunto de datos tiene un buen potencial para predicciones, ya que escasean los valores atípicos o outliers.

No obstante, el aspecto más interesante de nuestros datos es que conforman una serie temporal, por lo que puede resultar muy relevante visualizar su evolución a lo largo del tiempo para detectar posibles patrones. Para que observemos el tráfico en un contexto temporal de manera óptima, modificamos nuestra tabla de datos subdividiendo la fecha en varios campos más informativos:

# Convertimos a formato fecha
df.loc[:,'key_as_string'] = pd.to_datetime(df.loc[:,'key_as_string'],utc=True)

# Extraemos campos de la fecha y reestructuramos
df['YEAR'] = df['key_as_string'].dt.year
df['MONTH'] = df['key_as_string'].dt.month
df['DAY'] = df['key_as_string'].dt.day
df['WEEKDAY'] = df['key_as_string'].dt.weekday
df['HOUR'] = df['key_as_string'].dt.hour
df = df.drop(['key_as_string'], axis=1)
df.columns = ['TRAFFIC', 'YEAR', 'MONTH', 'DAY', 'WEEKDAY', 'HOUR']
df = df[['YEAR', 'MONTH', 'DAY', 'WEEKDAY', 'HOUR', 'TRAFFIC']]

# Ordenamos cronológicamente
df = df.sort_values(['YEAR', 'MONTH', 'DAY'], axis=0, ascending=[True, True, True])

Donde YEAR, MONTH y DAY representan, respectivamente, el año, mes y día; WEEKDAY indicará el día de la semana (0=Lunes, 6=Viernes); y HOUR expresa la hora sin minutos entre 0 y 23.

indexYEARMONTHDAYWEEKDAYHOURTRAFFIC
02019111014139
12019111112432
22019111219827
32019111310413
42019111414284
17946202122141946127
17947202122142042835
17948202122142144219
17949202122142239986
17950202122142362337


Con esta nueva estructura de datos, resulta mucho más sencillo representar gráficamente los datos en función del tiempo. Por ejemplo, podemos ver la evolución global distinguiendo de forma visual los años:

EDICOM Analytics: camino hacia el machine learning

Está claro que hay demasiados datos para verlos claramente, pero se pueden extraer dos conclusiones de este gráfico. Por una parte, el tráfico mantiene un comportamiento similar a lo largo del tiempo y, por otra, parece tender a las subidas y bajadas de manera periódica. Basados en estas «pistas», procedemos a construir otro gráfico. En esta ocasión, agrupando los datos por día del mes:

Tabla: Tráfico mensual por día

Con esta visualización el patrón se deja entrever mucho más, pero parece que hay cierto desplazamiento entre los distintos meses. Lo que sugiere dicho desplazamiento es que el patrón que buscamos no estará necesariamente relacionado con el día del mes, sino con el día de la semana:

Tabla: Tráfico mensual por día de la semana

Con esta última representación el enigma parece resuelto: existe un claro patrón en el tráfico que parece presentar una relación directa entre su volumen y el día de la semana. Esto es, por supuesto, ante nuestros ojos humanos. ¿Cómo se traduce entonces al modelo? Antes comentamos que nuestra estructura de datos era bastante básica y podía ser mejorada. Cuando se habla de «mejorar» el modelo de datos, nos estamos refiriendo a añadir, eliminar y modificar campos. Nuestro Random Forest es inteligente, pero podemos (y debemos) ayudarlo a serlo más todavía si le proporcionamos datos con una estructura que facilite encontrar información relevante.

Después de una serie de pruebas, el modelo final incluye diversas mejoras:

  • Prescinde del año, al no aportar demasiada información.
  • Añade tres campos que pueden resultar de utilidad: el tráfico hace justo 24 horas, el acumulado desde entonces y la diferencia de dicho acumulado con respecto al dato anterior. Para ello, debemos sacrificar del conjunto de datos el primer día (01/01/2019), ya que no tiene datos previos con los que generar los nuevos campos.
# Día anterior a la misma hora
df.loc[:, 'PREVIOUS'] = df.loc[:, 'TRAFFIC'].shift(periods=24)

# Suma de las ultimas 24 horas y su diferencia
df.loc[:, 'CUMSUM'] = df.loc[:, 'TRAFFIC'].rolling(min_periods=1, window=24).sum()
df.loc[:, 'CUMSUM_DIFF'] = df.loc[:, 'CUMSUM'].diff(periods=1)

# Limpiamos datos
df = df.dropna()
df.loc[:, ['PREVIOUS', 'PREVIOUS_DIFF', 'CUMSUM', 'CUMSUM_DIFF']] = df.loc[:, ['PREVIOUS', 'CUMSUM', 'CUMSUM_DIFF']].astype(int)
df = df[['YEAR', 'MONTH', 'DAY', 'WEEKDAY', 'HOUR', 'PREVIOUS', 'CUMSUM', 'CUMSUM_DIFF', 'TRAFFIC']]

# Eliminamos el año
df = df.drop(['YEAR'], axis=1)

Así pues, el conjunto final presenta el siguiente formato:

indexMONTHDAYWEEKDAYHOURPREVIOUSCUMSUMCUMSUM_DIFFTRAFFIC
24122014139228254-80486091
25122112432222503-57516681
26122219827214247-825611571
27122310413210232-40156398
28122414284209572-66013624
17946226419438771136414225046127
17947226420375471141702528842835
17948226421421431143778207644219
17949226422414731142291-148739986
179502264234740211572261493562337

Preparación del modelo de aprendizaje

Una vez preparado el conjunto de datos, podemos entrenar con él nuestro modelo de aprendizaje. En primer lugar, substraeremos los datos más recientes (febrero de 2021) para emplearlos en una simulación de predicciones una vez tengamos el modelo final. Dejando a un lado los datos de estas semanas para dicho ejemplo práctico, dispondremos de los datos de dos años y un mes para preparar nuestro modelo.

Con estos datos, debemos crear un subconjunto aleatorio de datos para entrenamiento y otro para test. La proporción escogida para esta división ha sido de 80-20; totalmente arbitraria pero muy frecuente en experimentos de machine learning.

Repasemos brevemente cómo están organizados nuestros datos: febrero de 2021 está separado para una simulación posterior y el resto de datos se han reordenado aleatoriamente y se van a dedicar en un 80% a entrenar el modelo y en un 20% a su fase de testeo.

# Las primeras tres semanas de Febrero para la prediccion
experiment_data = df.copy()
experiment_data = experiment_data.loc[~((experiment_data['YEAR'] == 2021) & (experiment_data['MONTH'] > 1))]
prediction_data = df.copy()
prediction_data = prediction_data.loc[((prediction_data['YEAR'] == 2021) & (prediction_data['MONTH'] == 2))]

# Separamos en train 80% y test 20% tras un shuffle
RANDOM_SEED = 42
TEST_SIZE = 0.2
train, test = train_test_split(experiment_data.sample(frac=1, random_state=RANDOM_SEED), test_size=TEST_SIZE)

# Construimos los datasets
y_train = train['TRAFFIC']
x_train = train.drop(['TRAFFIC'], axis=1)
y_test = test['TRAFFIC']
x_test = test.drop(['TRAFFIC'], axis=1)

A la hora de ajustar los hiperparámetros del modelo se ha recurrido al método Grid Search, el cual testea automáticamente todas las posibles combinaciones de unos valores introducidos manualmente, y determina cuáles proporcionan un modelo más óptimo para trabajar con el conjunto de datos de entrenamiento. Este proceso se repitió en varias ocasiones, al principio con rangos de valores muy amplios (por ejemplo, profundidad máxima desde 10 hasta 300), y se fueron ajustando en base a los resultados (la última prueba tuvo profundidad máxima entre 14 y 20). En el siguiente snippet se puede ver un ejemplo de Grid Search, ya con valores muy localizados al tratarse de la prueba más reciente.

# Random Forest con búsqueda de parámetros por GridSearch
model = RandomForestRegressor()
param_search = {
  'bootstrap': [True, False], # Activa muestreo con bootstrap
  'max_depth': [14, 17, 20], # Profundidad máxima de los árboles
  'max_features': ['auto', 'sqrt'], # Núm. features por split
  'min_samples_leaf': [1, 2], # Mín. muestras por nodo hoja
  'min_samples_split': [2, 5, 10], # Mín. muestras para split
  'n_estimators': [700, 750, 800], # Número de árboles
}

tscv = TimeSeriesSplit(n_splits=12)
gsearch = GridSearchCV(estimator=model,
                       cv=tscv,
                       param_grid=param_search,
                       scoring=rmse_score,
                       n_jobs=multiprocessing.cpu_count() - 4,
                       verbose=3)
gsearch.fit(x_train, y_train)
best_score = gsearch.best_score_
best_model = gsearch.best_estimator_

# Cross validation score del modelo
np.mean(cross_val_score(best_model, x_train, y_train, cv=tscv, scoring='r2', n_jobs = multiprocessing.cpu_count() - 4))

# Entrenamiento
best_model.fit(x_train, y_train)

Se trata de un proceso muy costoso que, afortunadamente, se puede ejecutar en paralelo utilizando varios núcleos de la CPU. Empleando ocho núcleos de manera simultánea, observamos que las evaluaciones iniciales en las que combinábamos múltiples valores muy diversos podían llegar a tardar más de diez horas, mientras que las últimas búsquedas con pocos valores finalizaban en cuestión de minutos. El modelo óptimo que se eligió finalmente presenta la siguiente configuración:

bootstrapmax_depthmax_featuresmin_samples_leafmin_samples_splitn_estimators
True17auto12750

Al contrario que la búsqueda de hiperparámetros, el entrenamiento del modelo es increíblemente rápido: unos 35 segundos. Obtenemos una media de cross validation de 0,98 empleando una función de puntuación R2.

Análisis de los resultados

En primer lugar, conviene recurrir a una herramienta extremadamente útil que se encuentra disponible para los Random Forests de sklearn: la importancia de las features.

Tabla: Importancia por feature

A la hora de decantarse por una rama, el modelo evalúa lo relevante que ha sido cada campo en su decisión. En base a estos resultados podríamos, por ejemplo, prescindir de los campos DAY y MONTH en una versión futura del modelo, ya que no parecen aportar información relevante. No obstante, esto se debe probablemente a que los datos acumulan tan solo dos años. Si incluyéramos más años, es muy posible que aparecieran patrones de tráfico por estación y en meses vacacionales, por lo que estos campos pasarían a tener más relevancia. Cabe destacar como claramente WEEKDAY ha sido el dato más importante para el modelo, confirmando la teoría de patrones en función del día de la semana que habíamos planteado en la sección de análisis de datos.

De cara a evaluar el modelo, y al tratarse de un entrenamiento no determinista, se han recogido datos de cinco ejecuciones diferentes y se han extraído los valores medios:

Varianza explicadaRMSER2Diferencia mediaPrecisión (5.000)
0,99521.267,330,9951200,3598,30%
  • La varianza explicada representa la proporción en la que el modelo se adapta a la variación de los datos, siendo uno el máximo o mejor valor posible.
  • El RMSE (Root Mean Squared Error) mide las diferencias entre los valores de la predicción y los valores observados. Su valor escala con el rango de posibles valores en los datos. En nuestro caso, oscilamos entre 0 y 170.000 documentos, por lo que puede considerarse un error bajo.
  • El coeficiente de determinación R2 estima la calidad del modelo para replicar los resultados en futuras predicciones y la variación de resultados que puede explicarse por el modelo. Al igual que en el caso de la explained variance, el valor óptimo es 1.
  • La diferencia media es sencillamente un cálculo del valor promedio de la diferencia, en valor absoluto, entre los valores predichos y los valores reales, en número de documentos. Para nuestro proyecto, una diferencia de 200 documentos es perfectamente aceptable.
  • La precisión se ha calculado en base a un umbral de ±5.000 documentos, ya que un pod nuevo es necesario por cada 10.000. Se trata de un simple cálculo de aciertos dentro del umbral sobre el total de datos predichos.

Visualizando las predicciones

Una vez entrenado el modelo y viendo que obtiene buenos resultados, es hora de realizar la simulación de predicción con los datos de febrero. Idealmente, el modelo se emplearía para predicciones a 24 horas vista:

Tabla: Predicción de tráfico por hora (01/02/2021)

Como se puede ver en una predicción de 24 horas, los valores son muy ajustados, teniendo una precisión media alrededor del 98% para cinco ejecuciones. Con esta información se puede obtener una evaluación bastante precisa de cómo escalar los Pods en base al tráfico por hora. Si extrapolamos estos datos a número de Pods, por un máximo de 10.000 peticiones por Pod nos quedaría una precisión media del 94.17%, siendo la desviación máxima de un Pod, la cual puede ser asumible.

Tabla: Predicción de pods por hora (01/02/2021)

Podemos ir más allá y aplicar la misma predicción al conjunto de las primeras tres semanas de febrero. Al revisar los resultados vemos que los datos siguen teniendo aproximadamente la misma precisión (93.45%) dentro del margen de ±5.000 documentos. Resulta especialmente interesante ver cómo, pese a que las semanas siguen un patrón similar, no necesariamente alcanzan los mismos picos y las predicciones aciertan también estas variaciones.

Table: Predicción de tráfico por hora (01/02/2021 - 21/02/2021)

Concretamente, para los 504 valores la desviación máxima continúa siendo de 1 solo Pod:

Tabla: Predicción de pods por hora (01/02/2021 - 21/02/2021)

En base a estos resultados, podemos comprobar que la precisión del modelo para esta predicción es correcta y que se podría implantar como solución para gestionar el escalado dinámico de Pods, con una fiabilidad muy alta.

Conclusiones

EDICOM gestiona grandes cantidades de datos, por lo que su explotación mediante técnicas de aprendizaje automático, sería una buena fórmula para mejorar nuestros servicios y abrir nuevas líneas de desarrollo tecnológico.

Los resultados de este primer experimento nos han permitido verificar la viabilidad de este tipo de predicciones, no solo de forma teórica, sino para un uso real y práctico; abriendo así nuevos horizontes para la explotación de datos almacenados en EdicomLta. En este caso concreto, hemos sido capaces de predecir con exactitud el escalado dinámico de Pods, lo cual es fácilmente extrapolable a otros problemas de asignación de recursos y monitorización de anomalías. Pero, además, este tipo de modelos y predicciones se podrían adaptar a numerosas aplicaciones gracias al volumen y la gran diversidad de datos manejados por EDICOM: estimación de pedidos, facturas, horas de consultoría o soporte, etc. Además, al obtener datos de predicción estructurados, sería posible enriquecer el servicio de EDICOMAnalytics al permitir su visualización gráfica de manera automatizada.

Para concluir debemos enfatizar que, si logramos analizar y comprender correctamente los datos, resulta viable construir un modelo que se adapte a las necesidades del problema dado. Este pequeño experimento nos ha servido para comprobar que disponemos tanto de los datos como de las herramientas necesarias para realizar futuros proyectos en este ámbito.

Referencias

[1] Toni Yiu. Understanding Random Forest. (Towards Data Science, 2019) Link

[2] James Montantes. 3 Reasons to Use Random Forest Over a Neural Network–Comparing Machine Learning versus Deep Learning. (Towards Data Science, 2020) Link