Ir al contenido principal

Manipulación de datos con pandas


Cuando uno lee un libro o un artículo sobre machine learning encuentra multitud de explicaciones sobre el algoritmo tal o cual. Sin embargo, no se habla demasiado sobre la manipulación y el limpiado de los datos, que bajo mi punto de vista es tan o incluso más importante que utilizar el algoritmo adecuado. Nuestro aliado en esta tarea es la librería pandas.
En lugar de hacer un recorrido exhaustivo por las funcionalidades de la librería, he preferido hacer uso de ella con un dataset real, para poder ver así, no sólo cuáles son sus funcionalidades, sino también cómo se aplican a datos reales. Así pues, en lugar de usar un dataset de los clásicos, he recurrido a uno real sacado de la web de datos abiertos del Ayuntamiento de Málaga. En concreto vamos a trabajar con el siguiente dataset, que se corresponde con las lecturas energéticas de los cuadros eléctricos durante el mes de marzo de 2017: https://datosabiertos.malaga.eu/dataset/lecturas-cuadros-electricos-marzo-2017.
Como siempre os dejo un enlace al notebook python con todo lo que me dispongo a contaros: https://github.com/albgarse/InteligenciaArtificial/blob/master/Machine%20Learning/Manipulacion%20datos%20Pandas.ipynb
El dataset es un archivo CSV y tiene los siguientes campos:
  • _id: Id Autonumérico.
  • ID_NODO: Identificador del nodo.
  • ID_DIA: Identificador del día de la toma (número de día de mes).
  • FECHA_TOMA_DATO: Fecha y hora de la toma del dato.
  • ENERGIA_ACTIVA_ACUMULADA: Energía activa acumulada.
  • LECTURA_ENERGIA_ACTIVA: Energía activa en el momento de la toma.
  • ENERGIA_REACTIVA_ACUMULADA: Energía reactiva acumulada.
  • LECTURA_ENERGIA_REACTIVA: Energía reactiva en el momento de la toma.
  • POTENCIA_TOTAL_ACUMULADA: Acumulado de la potencia total manejada por el cuadro (expresada en W).
  • LECTURA_POTENCIA_TOTAL: Potencia total manejada por el cuadro (expresada en W).

Comenzamos importando y cargando el archivo CSV. Para realizar la carga se utiliza la función read_csv(). Por defecto, esta función espera los datos separados por comas, pero si echas un ojo a nuestro fichero veras que el separador es el punto y coma. Por eso usamos el parámetro sep=";". En la variable data_mar se almacenan los datos. Pandas devuelve los datos en una estructura llamada DataFrame. Con la función head() podemos ver las primeras filas de los datos que hemos cargado.
import pandas as pd
data_mar = pd.read_csv("201703.csv", sep=";")
data_mar.head()
ID_NODO ID_DIA FECHA_TOMA_DATO ENERGIA_ACTIVA_ACUMULADA LECTURA_ENERGIA_ACTIVA ENERGIA_REACTIVA_ACUMULADA LECTURA_ENERGIA_REACTIVA POTENCIA_TOTAL_ACUMULADA LECTURA_POTENCIA_TOTAL
0 81848 28 2017-02-28 23:11:54 66884.601562 0.000000 46464.699219 0.000000 0.000000 0.0
1 98372 28 2017-02-28 23:11:54 264010.593750 0.000000 169612.296875 0.000000 0.000000 0.0
2 98526 28 2017-02-28 23:11:54 56888.000000 0.398438 61891.300781 0.000000 14.000000 0.0
3 98680 28 2017-02-28 23:11:54 35347.699219 0.398438 37424.699219 0.398438 77.006493 0.0
4 98834 28 2017-02-28 23:11:54 93080.703125 0.000000 47944.500000 0.000000 0.000000 0.0

Podemos empezar por obtener algunos estadísticos básicos con el objetivo de empezar a conocer con qué datos nos estamos enfrentando. Para ello usamos la función describe().
data_mar.describe()
ID_NODO ID_DIA ENERGIA_ACTIVA_ACUMULADA LECTURA_ENERGIA_ACTIVA ENERGIA_REACTIVA_ACUMULADA LECTURA_ENERGIA_REACTIVA POTENCIA_TOTAL_ACUMULADA LECTURA_POTENCIA_TOTAL
count 413108.000000 413108.000000 4.131080e+05 4.131080e+05 4.131080e+05 4.131080e+05 413108.000000 413108.000000
mean 71962.043165 15.982503 2.330070e+05 1.268065e+02 1.679989e+05 1.002321e+02 2291.475042 42.950668
std 26718.517878 8.936663 1.130742e+06 2.271224e+04 8.109683e+05 1.819730e+04 12654.772009 1263.035618
min 540.000000 1.000000 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 0.000000 0.000000
25% 70605.000000 8.000000 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 0.000000 0.000000
50% 76150.000000 16.000000 2.475400e+03 0.000000e+00 3.182920e+04 0.000000e+00 0.000000 0.000000
75% 81540.000000 24.000000 1.568925e+05 0.000000e+00 9.713650e+04 0.000000e+00 3.000000 0.000000
max 103620.000000 31.000000 1.267291e+07 1.148814e+07 9.325143e+06 9.117862e+06 234922.625488 216123.260318

Por cada variable numérica obtenemos el número de elementos, la media, la desviación típica, el máximo y mínimo valor, así como los percentiles 25%, 50% y 75%. También podemos obtener el número de elementos de la tabla, así como el número de columnas.
data_mar.shape
(413108, 9)

El dataset contiene información sobre lecturas de contadores eléctricos. En concreto mide la energía activa y la reactiva. Muchas veces, cuando uno estudia un dataset, no domina el campo sobre el cuál tratan los datos, por lo toca investigar un poco. Os resumo un poco en qué consiste esto de la energía activa y reactiva:
  • Energía activa: los receptores eléctricos alimentados por corriente eléctrica transforman la energía eléctrica en trabajo mecánico y en calor. A este efecto útil se le denomina “energía activa” y se mide en kWh.
  • Energía reactiva: existen numerosos receptores, tales como motores, transformadores, reactancias, etc., que para funcionar necesitan que se formen campos magnéticos. Estos equipos, en general inductivos, absorben energía de la red para crear los campos magnéticos y la devuelven mientras desaparecen. Con este intercambio de energía, se provoca un consumo suplementario que no es aprovechable por los receptores. Se mide en kVArh. La energía reactiva provoca una sobrecarga en líneas, transformadores y generadores, sin llegar a producir un rendimiento útil. Sin embargo, la factura de energía sí la contabiliza, por lo que puede llegar a incrementarla en cantidades importantes

Vamos a centrarnos en el estudio de la energía acumulada, así que por ahora no nos van a hacer falta los campos de lectura instantánea de energía activa y reactiva. Una buena práctica, sobre todo si el dataset es muy grande y andamos cortos de memoria, es eliminar las columnas que no vamos a necesitar, así que vamos a eliminarlas.
# Borramos los campos que no vamos a necesitar: 
# LECTURA_ENERGIA_ACTIVA, LECTURA_ENERGIA_REACTIVA, LECTURA_POTENCIA_TOTAL
campos = data_mar.columns.values.tolist()
eliminar = ["LECTURA_ENERGIA_ACTIVA", "LECTURA_ENERGIA_REACTIVA", \
    "LECTURA_POTENCIA_TOTAL"]
data_mar = data_mar[[e for e in campos if e not in eliminar]]

También nos puede interesar a veces crear nuevos campos en los que la misma información esté en un formato diferente que nos facilite la operación los datos. Por ejemplo, el campo de fecha y hora están en formato string, y nos puede interesar que sea un campo numérico para poder representarlo cómodamente en una gráfica. Como ejemplo, voy a crear un campo nuevo con la fecha en formato numérico.
# vamos a crear un campo nuevo para la fecha en formato timestamp, 
# que nos será más cómodo a la hora de representar las gráficas.
data_mar["FECHA_NUM"] = pd.to_numeric(pd.to_datetime(data_mar["FECHA_TOMA_DATO"]))
data_mar.head()

ID_NODO ID_DIA FECHA_TOMA_DATO ENERGIA_ACTIVA_ACUMULADA ENERGIA_REACTIVA_ACUMULADA POTENCIA_TOTAL_ACUMULADA FECHA_NUM
0 81848 28 2017-02-28 23:11:54 66884.601562 46464.699219 0.000000 1488323514000000000
1 98372 28 2017-02-28 23:11:54 264010.593750 169612.296875 0.000000 1488323514000000000
2 98526 28 2017-02-28 23:11:54 56888.000000 61891.300781 14.000000 1488323514000000000
3 98680 28 2017-02-28 23:11:54 35347.699219 37424.699219 77.006493 1488323514000000000
4 98834 28 2017-02-28 23:11:54 93080.703125 47944.500000 0.000000 1488323514000000000

Vamos a examinar un nodo para ver cómo se comportan los datos para un sólo individuo de la muestra. Primero necesitamos averiguar cuántos nodos (contadores eléctricos) hay. Con la función unique() podemos filtrar el campo ID_NODO para ver cuántos nodos distintos hay.
# ¿cuantos nodos tenemos?
len(data_mar["ID_NODO"].unique())
139

Vamos a estudiar un nodo en concreto. El ID_NODO=542
# vamos a estudiar un nodo al azar: el ID_NODO = 542
data_mar[data_mar["ID_NODO"] == 542].plot(x="FECHA_NUM", y="ENERGIA_ACTIVA_ACUMULADA")


Observamos en la gráfica que a partir de cierta fecha el acumulado de energía activa es cero. Si probamos con otros nodos vemos que el patrón se repite. Además, si examinamos las fechas de las lecturas vemos que hay datos del mes anterior (febrero). Un posible interpretación que podemos hacer es que el periodo de lecturas no comienza exactamente el día 1 del mes en curso. Podemos consultar para qué fecha y hora la lectura del nodo 542 es cero.
# veamos las fechas para las que las lecturas son cero
data_mar[(data_mar["ENERGIA_ACTIVA_ACUMULADA"]==0) & (data_mar["ID_NODO"]==542)].head()

ID_NODO ID_DIA FECHA_TOMA_DATO ENERGIA_ACTIVA_ACUMULADA ENERGIA_REACTIVA_ACUMULADA POTENCIA_TOTAL_ACUMULADA FECHA_NUM
337194 542 26 2017-03-26 06:27:10 0.0 0.0 0.0 1490509630000000000
337342 542 26 2017-03-26 06:42:10 0.0 0.0 0.0 1490510530000000000
337468 542 26 2017-03-26 06:57:10 0.0 0.0 0.0 1490511430000000000
337492 542 26 2017-03-26 07:12:10 0.0 0.0 0.0 1490512330000000000
337759 542 26 2017-03-26 07:27:10 0.0 0.0 0.0 1490513230000000000

Tenemos dos opciones. Podemos borrar los datos anteriores o posteriores a una fecha concreta. Otra opción es eliminar aquellas lecturas cuyo valor para el campo ENERGIA_ACTIVA_ACUMULADA sea 0. Para ver un ejemplo de cada caso, vamos a borrar las filas que pertenecen al mes anterior y seguidamente vamos a eliminar todas las filas cuyo campo ENERGIA_ACTIVA_ACUMULADA sea 0.
# borramos datos del mes anterior
data_mar = data_mar[data_mar["FECHA_TOMA_DATO"]>"2017-03"]
data_mar.head()

ID_NODO ID_DIA FECHA_TOMA_DATO ENERGIA_ACTIVA_ACUMULADA ENERGIA_REACTIVA_ACUMULADA POTENCIA_TOTAL_ACUMULADA FECHA_NUM
556 542 1 2017-03-01 00:11:54 360591.906250 121981.601562 0.000000 1488327114000000000
557 543 1 2017-03-01 00:11:54 300521.894531 242683.304688 0.000000 1488327114000000000
558 544 1 2017-03-01 00:11:54 2475.300049 51700.800781 80.024994 1488327114000000000
559 545 1 2017-03-01 00:11:54 0.000000 26725.900391 0.000000 1488327114000000000
560 546 1 2017-03-01 00:11:54 0.000000 0.000000 0.000000 1488327114000000000

# borramos las filas donde se cumpla que 
# ENERGIA_ACTIVA_ACUMULADA = 0 para todos los nodos
data_mar = data_mar[data_mar["ENERGIA_ACTIVA_ACUMULADA"]>0]
data_mar.shape
(237946, 7)

Podemos volver a examinar la gráfica que, ahora sí, nos permite comprender mejor cómo es la demanda de consumo para ese nodo.
# creamos un dataframe con los datos del nodo 542
data_mar_542 = data_mar[data_mar["ID_NODO"] == 542]
data_mar_542.plot(x="FECHA_NUM", y="ENERGIA_ACTIVA_ACUMULADA")


Vamos a comprar la energía activa y la reactiva poniéndolas juntas en una gráfica.
data_mar_542.plot(x="FECHA_NUM", y=["ENERGIA_ACTIVA_ACUMULADA", \
    "ENERGIA_REACTIVA_ACUMULADA"], subplots=True )


Aunque los valores de la energía reactiva son muy inferiores a los de la energía activa, parece que hay una correlación bastante acusada entre ambos tipos de energía. De hecho, si usamos la función corr() de Pandas para calcular la correlación entre ambos campos, vemos que es una correlación casi perfecta (0.99).
data_mar_542["ENERGIA_ACTIVA_ACUMULADA"].corr(data_mar_542["ENERGIA_REACTIVA_ACUMULADA"])
0.99990652807701275

La energía reactiva es un tipo de energía eléctrica, que absorben de la red algunos equipos eléctricos pero que luego la devuelven, por lo que no supone un consumo, aunque sí hay que generarla y transportarla hasta los equipos. Por ello las compañías eléctricas penalizan en factura el consumo de energía reactiva a partir de un cierto valor.
Si quisieramos hacer un análisis de en qué nodos hay un consumo anomalo de energía reactiva, podríamos ver si la relación entre la energía activa y reactiva tienen una baja correlación (aquí estoy haciendo muchas suposiciones que podrían no ser ciertas a nivel eléctrico, pero valga como ejemplo).
¿Se observará la misma correlación en todos los nodos? Vamos a crear un nuevo DataFrame con la correlación entre ambas lecturas para cada contador.
# creamos un nuevo dataframe con los campos NODO_ID y la correlación
c = []
for i in data_mar["ID_NODO"].unique().tolist():
    c.append([i, data_mar[data_mar["ID_NODO"]==i]["ENERGIA_ACTIVA_ACUMULADA"].corr(data_mar["ENERGIA_REACTIVA_ACUMULADA"])])

correlaciones = pd.DataFrame(c, columns=["ID_NODO", "CORRELACION"])
correlaciones.head(10)

ID_NODO CORRELACION
0 542 0.999907
1 543 0.961227
2 544 0.999266
3 547 0.999963
4 33756 0.998668
5 71530 NaN
6 71838 NaN
7 71992 NaN
8 72454 0.999997
9 73224 NaN

Observamos que hay correlaciones que toman el valor NaN (Not A Number). ¿Qué ha pasado? Si generamos la gráfica de uno de los nodos cuya correlación es NaN vemos que el valor de ambas variables es siempre constante, es decir, no crece en todo el periodo de tiempo que estamos observando. Por defecto, Pandas calcula la correlación de pearson, cuya formula es cor(i,j) = cov(i,j)/[stdev(i)*stdev(j)]. Como la desviación estandad es cero y un divisor no puede ser cero, obtenemos un bonito NaN. Lo cuál, dicho sea de paso, nos viene de perlas para ver cómo tratar con valores nulos en un DataFrame.
data_mar[data_mar["ID_NODO"]==71530].plot(x="FECHA_NUM", \
    y=["ENERGIA_ACTIVA_ACUMULADA","ENERGIA_REACTIVA_ACUMULADA"], subplots=True )


Vamos a ver qué opciones tenemos para tratar con los valores nulos. Podemos:
  • Eliminar las filas con valor nulo (con el método dropna())
  • Rellenar las filas con un valor calculado (con el método fillna())
# rellenar filas NaN con un valor concreto (0 en este caso)
cor_cero = correlaciones["CORRELACION"].fillna(0)
cor_cero.head(10)
0    0.999907
1    0.961227
2    0.999266
3    0.999963
4    0.998668
5    0.000000
6    0.000000
7    0.000000
8    0.999997
9    0.000000
Name: CORRELACION, dtype: float64
O podemos rellenar con la media.
# rellenar filas con el valor medio de la columna (o cualquier otra función))
cor_media = correlaciones["CORRELACION"].fillna(correlaciones["CORRELACION"].mean())
cor_media.head(10)
0    0.999907
1    0.961227
2    0.999266
3    0.999963
4    0.998668
5    0.971874
6    0.971874
7    0.971874
8    0.999997
9    0.971874
Name: CORRELACION, dtype: float64

Si preferimos eliminar las filas con NaN podemos usar el método dropna().
# pero en este caso vamos a optar por eliminarlas
# si axis=0 borra por filas. Si axis=1 borra por columnas
correlaciones = correlaciones.dropna(axis=0)
correlaciones.head(10)

ID_NODO CORRELACION
0 542 0.999907
1 543 0.961227
2 544 0.999266
3 547 0.999963
4 33756 0.998668
8 72454 0.999997
10 73840 0.999995
11 74918 0.999972
12 75072 0.999946
13 75534 0.999648

Si lo que queremos es estudiar cómo se comporta el consumo en los cuadros eléctricos donde hay baja correlación, podemos ordenar la lista para ver cuáles son más bajas.
correlaciones = correlaciones.sort_values(by="CORRELACION")
correlaciones.head()

ID_NODO CORRELACION
90 52061 0.514461
61 75688 0.617365
84 52775 0.649950
67 77382 0.826462
40 100682 0.885768

Echemos un vistazo a las gráficas de energía activa y reactiva para el nodo con la correlación más baja.
data_mar[data_mar["ID_NODO"]==52061].plot(x="FECHA_NUM", \ 
    y=["ENERGIA_ACTIVA_ACUMULADA","ENERGIA_REACTIVA_ACUMULADA"], subplots=True )



A partir de esta información podríamos deducir que a ese cuadro eléctrico se conecta maquinaria que hace uso intensivo de campos eléctricos, como bobinas o motores.
Vamos ahora a ver cómo se comporta el campo POTENCIA_TOTAL_ACUMULADA. Miramos el primer nodo.
data_mar[data_mar["ID_NODO"]==52061].plot(x="FECHA_NUM", \
    y=["POTENCIA_TOTAL_ACUMULADA"], subplots=True )


Está a cero, y si observamos otros nodos vemos que también. Como no tenemos más detalles sobre el dataset sólo podemos hacer suposiciones (como que por error no se ha rellenado este campo). De todas formas nos viene bien para ver cómo podemos combinar valores de dos o más campos para volcarlos en otro. Vamos a suponer que en este campo, el valor que debería haber es la suma de la energía activa y reactiva (en realidad ambos valores están en unidades distintas, pero por simplicidad supondremos que ambos valores son Kw/h y que podemos sumarlos alegremente). Vamos a combinar pues las suma de los dos valores en el campo POTENCIA_TOTAL_ACUMULADA.
data_mar["POTENCIA_TOTAL_ACUMULADA"] = data_mar["ENERGIA_ACTIVA_ACUMULADA"] \
    + data_mar["ENERGIA_REACTIVA_ACUMULADA"]
data_mar.head()

ID_NODO ID_DIA FECHA_TOMA_DATO ENERGIA_ACTIVA_ACUMULADA ENERGIA_REACTIVA_ACUMULADA POTENCIA_TOTAL_ACUMULADA FECHA_NUM
556 542 1 2017-03-01 00:11:54 3.605919e+05 1.219816e+05 4.825735e+05 1488327114000000000
557 543 1 2017-03-01 00:11:54 3.005219e+05 2.426833e+05 5.432052e+05 1488327114000000000
558 544 1 2017-03-01 00:11:54 2.475300e+03 5.170080e+04 5.417610e+04 1488327114000000000
561 547 1 2017-03-01 00:11:54 4.085408e+06 3.286907e+06 7.372315e+06 1488327114000000000
566 33756 1 2017-03-01 00:11:54 1.144733e+07 8.198532e+06 1.964586e+07 1488327114000000000

En este momento tenemos, por un lado, el DataFrame con las lecturas de los cuadros y por otro un DataFrame con las correlaciones que hemos calculado. ¿Podemos agregar las correlaciones a la tabla principal? Claro. Esta operación se llama unión (join en inglés). Hay cuatro tipos: inner join, left join, right join y outer join. No voy a entrar en detalle ya que son los mismos que los de SQL. Si queréis saber más podéis consultar aquí: https://es.wikipedia.org/wiki/Join
# unimos los DataFrames con left join, ya que el 
# segundo dataframe (correlaciones) tiene menos filas 
# y por lo tanto, no todos los nodos disponen de datos de correlación 
# (recuerda que borramos los NaN)
data_mar = pd.merge(left=data_mar, right=correlaciones, how="left", \
    left_on="ID_NODO", right_on="ID_NODO")
data_mar.head()

ID_NODO ID_DIA FECHA_TOMA_DATO ENERGIA_ACTIVA_ACUMULADA ENERGIA_REACTIVA_ACUMULADA POTENCIA_TOTAL_ACUMULADA FECHA_NUM CORRELACION
0 542 1 2017-03-01 00:11:54 3.605919e+05 1.219816e+05 4.825735e+05 1488327114000000000 0.999907
1 543 1 2017-03-01 00:11:54 3.005219e+05 2.426833e+05 5.432052e+05 1488327114000000000 0.961227
2 544 1 2017-03-01 00:11:54 2.475300e+03 5.170080e+04 5.417610e+04 1488327114000000000 0.999266
3 547 1 2017-03-01 00:11:54 4.085408e+06 3.286907e+06 7.372315e+06 1488327114000000000 0.999963
4 33756 1 2017-03-01 00:11:54 1.144733e+07 8.198532e+06 1.964586e+07 1488327114000000000 0.998668

Los parámetros left y right indican los DataFrames que vamos a fundir. El parámetro how nos permite indicar el tipo de join, en este caso left join. Finalmente left_on y right_on nos sirve para indicar cuál es el campo clave para unir ambos DataFrames. En este caso el campo común es ID_NODO.
Como dije al principio del artículo, mi intención no era hacer una descripción exhaustiva de toda la funcionalidad de pandas, ya que creo que podía ser más clarificador ver las operaciones básicas sobre un dataset real.

Comentarios

Entradas populares de este blog

Criptografía en Python con PyCrypto

A la hora de cifrar información con Python, tenemos algunas opciones, pero una de las más fiables es la librería criptográfica PyCrypto, que soporta funciones para cifrado por bloques, cifrado por flujo y cálculo de hash. Además incorpora sus propios generadores de números aleatorios. Seguidamente os presento algunas de sus características y también como se usa.


Creando firmas de virus para ClamAV

ClamAv es un antivirus opensource y multiplataforma creado por Tomasz Kojm muy utilizado en los servidores de correo Linux. Este antivirus es desarrollado por la comunidad, y su utilidad práctica depende de que su base de datos de firmas sea lo suficientemente grande y actualizado. Para ello es necesario que voluntarios contribuyan activamente aportando firmas.
El presente artículo pretende describir de manera sencilla cómo crear firmas de virus para ClamAV y contribuir con ellas a la comunidad.


Desbordamiento de enteros (Integer Overflow)

Ya os he hablado en este blog de posibles problemas potenciales que se pueden dar en los programas y que son susceptibles de ser explotados para hacer que dichos programas se comporten de forma diferente a la que deberían. Uno de estos problemas es el del desbordamiento de la pila. Sin embargo, hay otros posibles errores de programación que, aunque menos obvios, son igual de peligrosos. Uno de ellos es el desbordamiento de enteros o integer overflow. Para entender cómo funciona os presento un ejemplo muy sencillo pero didáctico.