Manipulación de datos y Pandas

Este notebook es una traducción y adaptación de esta notebook creada por Christopher Fonnesbeck y de estas notebooks creadas por Jake Vanderplas. El material aquí presentado es un pequeño subconjunto de las capacidades disponibles en Pandas. Para mayor información referirse a:


In [1]:
%matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-darkgrid')
import seaborn as sns
import numpy as np
import pandas as pd  # asi se suele importar Pandas

Pandas es un paquete que Python que provee estructuras de datos rápidas, flexibles y expresivas diseñadas para trabajar con datos rotulados. Dichas estructuras de datos se pueden pensar como arrays de NumPy donde las filas y columnas están rotuladas. O de forma similar como una planilla de cálculo bajo Python.

Así como NumPy es una muy buena herramienta para trabajar con números, vectores, álgebra lineal, etc. Pandas es adecuado para trabajar con:

  • Datos tabulares y heterogéneos (flotantes, string, enteros, etc)
  • Series temporales
  • Los mismos datos que se pueden manipular con arreglos de NumPy!

¿Por qué es importante tener una herramienta como Pandas?

El tiempo consumido (por un humano) en aplicar métodos estadísticos y/o de machine learning es en muchos casos mucho menor que el tiempo requerido para obtener y procesar los datos. Pandas intenta facilitar el procesado de los datos y reducir el tiempo que este consume para que podamos aumentar el tiempo que pasamos pensando en los problemas que queremos resolver. Procesar datos es una tarea que suele involucrar los siguientes pasos

  1. Leer los datos: Los datos pueden estar contenidos en diversos formatos, CSV, HTML, xls, pdf, texto plano, imágenes, hojas de papel, etc
  2. Procesar datos: Los datos rara vez están listos para usar, pueden faltar datos, haber dudas sobre los valores registrados, inconsistencias, etc. Además puede ser necesario generar datos derivados a partir de los datos disponibles, por ej podríamos necesitar la densidad poblacional pero solo tenemos la población y la superficie.
  3. Almacenar datos: Ya sea para pasárselos a otra pieza de software para por ej visualizarlos o hacer un análisis estadístico de los datos, o para su posterior uso por nosotros o terceros.

Pandas introduce fundamentalmente 3 nuevas estructuras de datos:

  • Las Series
  • Los DataFrame
  • Los Index

Empecemos por la primera de estas.

Series

Una Series de Pandas es un conjunto unidimensional de datos (similar a un array) acompañado de un índice que "rotula" a cada elemento del vector. Puede ser creada a partir de un array o tupla o lista.


In [2]:
conteo = pd.Series([632, 1638, 569, 115])
conteo


Out[2]:
0     632
1    1638
2     569
3     115
dtype: int64

Una Series tiene siempre dos columnas. La primer columna contiene los índices y la segunda los datos. En el ejemplo anterior, pasamos una lista de datos y omitimos el índice por lo que Pandas creo un índice automáticamente usando una secuencia de enteros, empezando por 0 (como es usual en Python).

La idea que una Series es un array con un índice explícito, no es solo una metáfora. De hecho a partir de una Serie es posible obtener el array de NumPy "contenido" en ella.


In [3]:
conteo.values


Out[3]:
array([ 632, 1638,  569,  115])

Y también es posible obtener el índice.


In [4]:
conteo.index


Out[4]:
RangeIndex(start=0, stop=4, step=1)

Indexado

Es importante notar que los arreglos de NumPy también tienen índices, solo que estos son implícitios y siempre son enteros comenzando desde el 0. En cambio los Index en Pandas son explícitos y no están limitados a enteros. Podemos asignar rótulos que tengan sentido según nuestros datos. Si nuestros datos representan la cantidad de bacterias según sus especies, podríamos tener algo como:


In [5]:
bacteria = pd.Series([632, 1638, 569, 115],
                     index=['Firmicutes', 'Proteobacteria',
                            'Actinobacteria', 'Bacteroidetes'])

bacteria


Out[5]:
Firmicutes         632
Proteobacteria    1638
Actinobacteria     569
Bacteroidetes      115
dtype: int64

Ahora el Index contiene strings en lugar de enteros. Es posible que estos pares rótulo-dato nos recuerden a un diccionario. Si esta analogía es válida deberíamos poder usar los rótulos para referirnos directamente a los valores contenidos en la serie.


In [6]:
bacteria['Actinobacteria']


Out[6]:
569

Incluso podemos crear Series a partir de diccionarios


In [7]:
bacteria_dict = {'Firmicutes': 632,
                 'Proteobacteria': 1638,
                 'Actinobacteria': 569,
                 'Bacteroidetes': 115}

pd.Series(bacteria_dict)


Out[7]:
Actinobacteria     569
Bacteroidetes      115
Firmicutes         632
Proteobacteria    1638
dtype: int64

O podemos hacerlo de forma algo más breve usando atributos. Un atributo es el nombre que se le da a un dato o propiedad en programación orientada a objetos. En el siguiente ejemplo bacteria es un objeto y Actinobacteria el atributo.


In [8]:
bacteria.Actinobacteria


Out[8]:
569

El hecho que tengamos índices explícitos, como los nombres de bacterias, no elimina la posibilidad de acceder a los datos usando índices implicitos, como es común hacer con listas y arreglos.


In [9]:
bacteria[2]


Out[9]:
569

Si tuvieras una Series con índices que fuesen enteros ¿Qué pasaría al indexarla?

una_series[1]

¿Obtendríamos el segundo elemento de la serie o el elemento cuyo índice explícito es 1? ¿Y si el ńumero 1 no estuviera contenido en el índice de la serie?

Más adelante veremos que solución ofrece Pandas para evitar confusiones. Pero mientras quizá podemos pensar una solución y discutirla.





Al igual que con los arreglos de NumPy podemos usar booleanos para indexar una serie. De esta forma podemos contestar la pregunta; cuáles bacterias dieron conteos superiores a 1000 de forma bastante intuitiva (¿Es realmente intuitiva?).


In [10]:
bacteria[bacteria > 1000]


Out[10]:
Proteobacteria    1638
dtype: int64

O podríamos necesitar encontrar el subconjunto de bacterias cuyos nombres terminan en "bacteria":


In [11]:
bacteria[[nombre.endswith('bacteria') for nombre in bacteria.index]]


Out[11]:
Proteobacteria    1638
Actinobacteria     569
dtype: int64

Slicing

Es posible hacer slicing incluso cuando los índices son strings.


In [12]:
bacteria[:'Actinobacteria']


Out[12]:
Firmicutes         632
Proteobacteria    1638
Actinobacteria     569
dtype: int64

In [13]:
bacteria[:3]


Out[13]:
Firmicutes         632
Proteobacteria    1638
Actinobacteria     569
dtype: int64

Al indexar con un índice implícito el último índice NO se incluye. Esto es lo que esperamos de listas, tuplas, arreglos etc. En cambio, al indexar con un índice explícito el último índice se incluye!

También es posible indexar usando una lista.


In [14]:
bacteria[['Actinobacteria', 'Proteobacteria']]


Out[14]:
Actinobacteria     569
Proteobacteria    1638
dtype: int64

Indexadores: loc e iloc

La presencia de índices implícitos y explícitos puede ser una fuente de gran confusión al usar Pandas. Veamos, que sucede cuando tenemos una serie con índices explícitos que son enteros.


In [15]:
datos = pd.Series(['x', 'y', 'z'], index=range(10, 13))
datos


Out[15]:
10    x
11    y
12    z
dtype: object

Pandas usará el índice explítico al indexar


In [16]:
datos[10]


Out[16]:
'x'

Pero el implícito al tomar rebanadas!


In [17]:
datos[0:2]


Out[17]:
10    x
11    y
dtype: object

Pandas provee de dos métodos para indexar. El primero de ellos es loc que permite hacer las operaciones de indexado/rebanado usando SIEMPRE el índice explícito.


In [18]:
datos.loc[10]


Out[18]:
'x'

In [19]:
datos.loc[10:11]


Out[19]:
10    x
11    y
dtype: object

El otro método es iloc el cual permite usar el índice implícito.


In [20]:
datos.iloc[0]


Out[20]:
'x'

In [21]:
datos.iloc[0:2]


Out[21]:
10    x
11    y
dtype: object

Siguiendo el zen de Python que dice “explícito es mejor que implícito", la recomendación general es usar loc e iloc. De esta forma la intención del código se hace explícita lo que contribuye a una lectura más fluida y a reducir la posibilidad de errores.

Funciones universales

Una de las características más valiosas de NumPy es la posiblidad de vectorizar código, evitando escribir loops, al realizar operaciones como sumas, multiplicaciones, logaritmos, etc. Pandas hereda de NumPy esta capacidad y la adapta de dos formas:

  • Para operaciones unarias al aplicar funciones universales se preserva el índice, es decir solo se aplican las operaciones a los valores y no a los rótulos.

In [22]:
np.log(bacteria)


Out[22]:
Firmicutes        6.448889
Proteobacteria    7.401231
Actinobacteria    6.343880
Bacteroidetes     4.744932
dtype: float64
  • Para operaciones binarias, las mismas se realizan sobre los índices alineados

Esto facilita el realizar operaciones que implican combinar datos de distintas fuentes, algo que puede no ser tan simple al usar NumPy.

Para ejemplificar este comportamiento vamos a crear una nueva Series a partir de un diccionario, pero especificando los índices


In [23]:
bacteria2 = pd.Series(bacteria_dict,
                      index=['Cyanobacteria',
                             'Firmicutes',
                             'Actinobacteria'])
bacteria2


Out[23]:
Cyanobacteria       NaN
Firmicutes        632.0
Actinobacteria    569.0
dtype: float64

Observemos dos detalles de este ejemplo. El orden en el que aparecen los elementos es el mismo que el orden especificado por el argumento index, comparemos esto con el caso anterior donde creamos una serie a partir de un diccionario, pero sin especificar el índice. El otro detalle es que hemos pasado un rótulo para un valor que no existe en el diccionario (y hemos omitido dos valores que si existen). Como resultado Pandas no devolvió un error, si no que interpretó que tenemos datos faltantes (missing data). El dato faltante se indica utilizando un tipo especial de float NaN (del inglés Not A Number).

Los índices son una conveniencia para manipular datos haciendo referencia a nombres que nos puede resultar más familiares o convenientes (comparado con recordar la posición de los datos). Además, los índices son usados para alinear datos al operar con más de una serie, por ej podríamos querer obtener el total de bacterias en dos conjuntos de datos.


In [24]:
bacteria + bacteria2


Out[24]:
Actinobacteria    1138.0
Bacteroidetes        NaN
Cyanobacteria        NaN
Firmicutes        1264.0
Proteobacteria       NaN
dtype: float64

El resultado es una serie donde el índice corresponde a la unión de los índices originales. Pandas suma solo los valores para los cuales los índices de ambas Series coinciden! Y además propaga los valores faltantes (NaN).

¿Qué sucede si intentamos sumar dos arreglos de NumPy de distinta longitud?



Es posible controlar que pasa con los datos faltantes usando el método .add() (en vez del operador +). Usando este método podemos indicar que cambie NaN por otro valor.


In [25]:
bacteria.add(bacteria2, fill_value=0)


Out[25]:
Actinobacteria    1138.0
Bacteroidetes      115.0
Cyanobacteria        NaN
Firmicutes        1264.0
Proteobacteria    1638.0
dtype: float64

Una variante sería hacer la operación y luego cambiar los NaN por cualquier otro valor.


In [26]:
(bacteria + bacteria2).fillna(0)


Out[26]:
Actinobacteria    1138.0
Bacteroidetes        0.0
Cyanobacteria        0.0
Firmicutes        1264.0
Proteobacteria       0.0
dtype: float64

DataFrame

Al analizar datos es común que tengamos que trabajar con datos multivariados. Para esos casos es útil tener algo como una Series donde a cada índice le correspondan más de una columna de valores. Ese objeto se llama DataFrame.

Un DataFrame es una estructura de datos tabular que se puede pensar como una colección de Series que comparten un mismo índice. También es posible pensar un DataFrame como una generalización de un arreglo de NumPy o la generalización de un diccionario.


In [27]:
datos = pd.DataFrame({'conteo':[632, 1638, 569, 115, 433, 1130, 754, 555],
                      'phylum':['Firmicutes', 'Proteobacteria', 'Actinobacteria', 'Bacteroidetes'] * 2,
                      'paciente':np.repeat([1, 2], 4)})

datos


Out[27]:
conteo paciente phylum
0 632 1 Firmicutes
1 1638 1 Proteobacteria
2 569 1 Actinobacteria
3 115 1 Bacteroidetes
4 433 2 Firmicutes
5 1130 2 Proteobacteria
6 754 2 Actinobacteria
7 555 2 Bacteroidetes

Lo primero que notamos es que Jupyter le pone onda al DataFrame, y lo muestra como una tabla con algunas mejoras estéticas.

También podemos ver que contrario a un arreglo de NumPy en un DataFrame es posible tener datos de distinto tipo (enteros y strings en este caso). Además se ve que las columnas están ordenadas alfabéticamente, podemos cambiar el orden indexando el DataFrame en el orden preferido.


In [28]:
datos[['paciente', 'phylum', 'conteo']]


Out[28]:
paciente phylum conteo
0 1 Firmicutes 632
1 1 Proteobacteria 1638
2 1 Actinobacteria 569
3 1 Bacteroidetes 115
4 2 Firmicutes 433
5 2 Proteobacteria 1130
6 2 Actinobacteria 754
7 2 Bacteroidetes 555

Los DataFrame tienen dos Index:

  • Uno que se corresponde con las filas, al igual que como vimos con las Series
  • Uno que se corresponde con las columnas

In [29]:
datos.columns


Out[29]:
Index(['conteo', 'paciente', 'phylum'], dtype='object')

Es posible acceder a los valores de las columnas de forma similar a como lo haríamos en una serie o en un diccionario.


In [30]:
datos['conteo']


Out[30]:
0     632
1    1638
2     569
3     115
4     433
5    1130
6     754
7     555
Name: conteo, dtype: int64

También podemos hacerlo por atributo.


In [31]:
datos.conteo


Out[31]:
0     632
1    1638
2     569
3     115
4     433
5    1130
6     754
7     555
Name: conteo, dtype: int64

Esta sintáxis no funciona para todos los casos. Algunos casos donde fallará es si la columna continene espacios o si el nombre de la columna entra en conflicto con algún método existente para DataFrames, por ejemplo no sería raro que llamaramos a una columna con alguno de estos nombres all, cov, index, mean.

Una posible fuente de confusión es que la sintaxis que acabamos de ver devuelve filas en una Series, pero columnas en un DataFrame. Si queremos acceder a las filas de un DataFrame podemos hacerlo usando el atributo loc:


In [32]:
datos.loc[3]


Out[32]:
conteo                115
paciente                1
phylum      Bacteroidetes
Name: 3, dtype: object

¿Que pasa si intentamos acceder a una fila usando la sintaxis datos[3]?





La Series que se obtienen al indexar un DataFrame es una vista (view) del DataFrame y NO una copia. Por lo que hay que tener cuidado al manipularla, por ello Pandas nos devuelve una advertencia.


In [33]:
cont = datos['conteo']
cont


Out[33]:
0     632
1    1638
2     569
3     115
4     433
5    1130
6     754
7     555
Name: conteo, dtype: int64

In [34]:
cont[5] = 0
cont


/home/osvaldo/anaconda3/lib/python3.6/site-packages/ipykernel_launcher.py:1: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame

See the caveats in the documentation: http://pandas.pydata.org/pandas-docs/stable/indexing.html#indexing-view-versus-copy
  """Entry point for launching an IPython kernel.
Out[34]:
0     632
1    1638
2     569
3     115
4     433
5       0
6     754
7     555
Name: conteo, dtype: int64

In [35]:
datos


Out[35]:
conteo paciente phylum
0 632 1 Firmicutes
1 1638 1 Proteobacteria
2 569 1 Actinobacteria
3 115 1 Bacteroidetes
4 433 2 Firmicutes
5 0 2 Proteobacteria
6 754 2 Actinobacteria
7 555 2 Bacteroidetes

Si queremos modificar una Series que proviene de un DataFrame puede ser buena idea hacer una copia primero.


In [36]:
cont = datos['conteo'].copy()
cont[5] = 1000
datos


Out[36]:
conteo paciente phylum
0 632 1 Firmicutes
1 1638 1 Proteobacteria
2 569 1 Actinobacteria
3 115 1 Bacteroidetes
4 433 2 Firmicutes
5 0 2 Proteobacteria
6 754 2 Actinobacteria
7 555 2 Bacteroidetes

Es posible agregar columnas a un DataFrame mediante una asignación.


In [37]:
datos['año'] = 2013
datos


Out[37]:
conteo paciente phylum año
0 632 1 Firmicutes 2013
1 1638 1 Proteobacteria 2013
2 569 1 Actinobacteria 2013
3 115 1 Bacteroidetes 2013
4 433 2 Firmicutes 2013
5 0 2 Proteobacteria 2013
6 754 2 Actinobacteria 2013
7 555 2 Bacteroidetes 2013

Podemos agregar una Series como una nueva columna en un DataFrame, el resultado dependerá de los índices de ambos objetos.


In [38]:
tratamiento = pd.Series([0] * 4 + [1] * 4)
tratamiento


Out[38]:
0    0
1    0
2    0
3    0
4    1
5    1
6    1
7    1
dtype: int64

In [39]:
datos['tratamiento'] = tratamiento
datos


Out[39]:
conteo paciente phylum año tratamiento
0 632 1 Firmicutes 2013 0
1 1638 1 Proteobacteria 2013 0
2 569 1 Actinobacteria 2013 0
3 115 1 Bacteroidetes 2013 0
4 433 2 Firmicutes 2013 1
5 0 2 Proteobacteria 2013 1
6 754 2 Actinobacteria 2013 1
7 555 2 Bacteroidetes 2013 1

¿Qué sucede si intentamos agregar una nueva columna a partir de una lista cuya longitud no coincida con la del DataFrame? ¿Y si en vez de una lista es una Series?






In [40]:
datos['mes'] = ['enero'] * len(datos)
datos


Out[40]:
conteo paciente phylum año tratamiento mes
0 632 1 Firmicutes 2013 0 enero
1 1638 1 Proteobacteria 2013 0 enero
2 569 1 Actinobacteria 2013 0 enero
3 115 1 Bacteroidetes 2013 0 enero
4 433 2 Firmicutes 2013 1 enero
5 0 2 Proteobacteria 2013 1 enero
6 754 2 Actinobacteria 2013 1 enero
7 555 2 Bacteroidetes 2013 1 enero

Podemos usar del para eliminar columnas, de la misma forma que podemos hacerlo con diccionarios.


In [41]:
del datos['mes']
datos


Out[41]:
conteo paciente phylum año tratamiento
0 632 1 Firmicutes 2013 0
1 1638 1 Proteobacteria 2013 0
2 569 1 Actinobacteria 2013 0
3 115 1 Bacteroidetes 2013 0
4 433 2 Firmicutes 2013 1
5 0 2 Proteobacteria 2013 1
6 754 2 Actinobacteria 2013 1
7 555 2 Bacteroidetes 2013 1

Es posible extraer los datos de un DataFrame en forma de arreglo de NumPy.


In [42]:
datos.values


Out[42]:
array([[632, 1, 'Firmicutes', 2013, 0],
       [1638, 1, 'Proteobacteria', 2013, 0],
       [569, 1, 'Actinobacteria', 2013, 0],
       [115, 1, 'Bacteroidetes', 2013, 0],
       [433, 2, 'Firmicutes', 2013, 1],
       [0, 2, 'Proteobacteria', 2013, 1],
       [754, 2, 'Actinobacteria', 2013, 1],
       [555, 2, 'Bacteroidetes', 2013, 1]], dtype=object)

Fijense que el dtype del arreglo es object. Esto se debe a la mezcla de enteros, strings y flotantes. El dtype es elegido por Pandas automaticamente de forma tal de acomodar todos los tipos de valores presentes en el DataFrame.

¿Qué hubiéramos obtenido si la columna phylum no fuese parte del dataFrame?





Además es posible realizar operaciones típica de arrays sobre DataFrame, por ejemplo transponerlo.


In [43]:
datos.T


Out[43]:
0 1 2 3 4 5 6 7
conteo 632 1638 569 115 433 0 754 555
paciente 1 1 1 1 2 2 2 2
phylum Firmicutes Proteobacteria Actinobacteria Bacteroidetes Firmicutes Proteobacteria Actinobacteria Bacteroidetes
año 2013 2013 2013 2013 2013 2013 2013 2013
tratamiento 0 0 0 0 1 1 1 1

Index

La última estructura de datos que nos queda ver es Index, la cual en realidad la venimos usando desde el principio de este capítulo. Solo que ahora hablaremos de ella de forma un poco más explícita.


In [44]:
datos.index


Out[44]:
RangeIndex(start=0, stop=8, step=1)

Los Index son inmutables, no puede ser modificados una vez creados, lo cual pueden comprobar al descomentar y ejecutar la siguiente celda.


In [45]:
#datos.index[0] = 15

La inmutabilidad del Index tiene como función permitir que los Index se compartan entre objetos sin riesgo de que se modifiquen en algún momento. Esto es lo que permite agregar una Series a un DataFrame aunque sus longitudes no coincidan, Pandas busca que sean los índices los que coincidan y si hiciera falta completa los valores que sean necesarios.

Importando datos

En principio es posible usar Python para leer cualquier archivo que uno desee, pero para el trabajo rutinario de anñalisis de datos es preferible tener a mano funciones enlatadas que permitan leer los formatos más comunes. NumPy provee de algunas funciones para leer archivos, como genfromtxt y loadtxt. Pandas ofrece funciones más vestátiles y robustas. Empecemos leyendo un archivo en formato csv (comma separated values).


In [46]:
!head datos/microbioma.csv  # este es un comando de Linux que nos permite ver las primeras lineas de un archivo


Taxon,Paciente,Grupo,Tejido,Heces
Firmicutes,1,0,136,4182
Firmicutes,2,1,1174,703
Firmicutes,3,0,408,3946
Firmicutes,4,1,831,8605
Firmicutes,5,0,693,50
Firmicutes,6,1,718,717
Firmicutes,7,0,173,33
Firmicutes,8,1,228,80
Firmicutes,9,0,162,3196

Pandas ofrece una función llamada read_csv ideal para leer este tipo de datos:


In [47]:
mb = pd.read_csv('datos/microbioma.csv')

In [48]:
type(mb)


Out[48]:
pandas.core.frame.DataFrame

De forma similar al comando head de Linux, le podemos pedir a Pandas que nos nuestre solo las primeras lineas.


In [49]:
mb.head()


Out[49]:
Taxon Paciente Grupo Tejido Heces
0 Firmicutes 1 0 136 4182
1 Firmicutes 2 1 1174 703
2 Firmicutes 3 0 408 3946
3 Firmicutes 4 1 831 8605
4 Firmicutes 5 0 693 50

Por defecto read_csv usará la primer linea del archivo como encabezado (header). Este comportamiento lo podemos modificar usando el argumento header.


In [50]:
pd.read_csv('datos/microbioma.csv', header=None).head()


Out[50]:
0 1 2 3 4
0 Taxon Paciente Grupo Tejido Heces
1 Firmicutes 1 0 136 4182
2 Firmicutes 2 1 1174 703
3 Firmicutes 3 0 408 3946
4 Firmicutes 4 1 831 8605

Por defecto read_csv usa , como separadores, pero es posible modificar este comportamiento usando el argumento sep. Un caso muy común es el de archivos que tienen una cantidad variable de espacios en blanco. En esos casos podemos usar una expresión regular:

sep='\s+'

'\s+' quiere decir "1 o más espacios en blanco". Otro caso común son archivos separados por tabulaciones (tabs), en ese caso podemos usar \t.

Si queremos omitir datos (por ejemplo datos mal tomados), podemos indicaselo a Pandas usando el argumento skiprows:


In [51]:
pd.read_csv('datos/microbioma.csv', skiprows=[3,4,6]).head()


Out[51]:
Taxon Paciente Grupo Tejido Heces
0 Firmicutes 1 0 136 4182
1 Firmicutes 2 1 1174 703
2 Firmicutes 5 0 693 50
3 Firmicutes 7 0 173 33
4 Firmicutes 8 1 228 80

También podemos indicarque que solo queremos importar unas pocas columnas, lo que puede ser muy útil cuando estamos haciendo pruebas y explorando los datos y queremos evitar importar un larga lista de datos.


In [52]:
pd.read_csv('datos/microbioma.csv', nrows=4)


Out[52]:
Taxon Paciente Grupo Tejido Heces
0 Firmicutes 1 0 136 4182
1 Firmicutes 2 1 1174 703
2 Firmicutes 3 0 408 3946
3 Firmicutes 4 1 831 8605

Pandas ofrece la capacidad de leer varios otros formatos incluyendo archivos xls, xlsx, JSON, XML, HDF5, etc. Para más información leer la documentación de Pandas o Python for Data Analysis.

Agregando datos

Se llama agregación de datos a las operaciones que reducen un conjunto de números a uno (o unos pocos) números. Entre estas operaciones se encuentran la media, mediana, desviación estándar, mínimo, máximo, conteo, suma, producto, etc.

Para averiguar el total de bacterias encontradas en tejido en nuestro conjunto de datos podemos hacer:


In [53]:
mb['Tejido'].sum()


Out[53]:
70057

Pandas ofrece además un método que describe un DataFrame utilizando varias de estas operaciones de agregado:


In [54]:
mb.describe()


Out[54]:
Paciente Grupo Tejido Heces
count 70.000000 70.00000 70.000000 70.000000
mean 7.500000 0.50000 1000.814286 696.971429
std 4.060235 0.50361 1874.395596 1451.943774
min 1.000000 0.00000 0.000000 0.000000
25% 4.000000 0.00000 106.250000 12.500000
50% 7.500000 0.50000 307.500000 67.000000
75% 11.000000 1.00000 837.000000 658.500000
max 14.000000 1.00000 12044.000000 8605.000000

Es común al analizar datos, que nos interese agregar datos respecto de alguna variable en particular por ejemplo podríamos querer obtener los valores promedios por Grupo. Para ello podemos usar el método groupby.


In [55]:
mb.groupby('Grupo').mean().round(0)


Out[55]:
Paciente Tejido Heces
Grupo
0 7.0 787.0 734.0
1 8.0 1214.0 660.0

Conceptualmente podemos pensar que groupby realiza los siguientes pasos:

  • Dividir el DataFrame en subgrupos según las variables que le pasemos.
  • Aplicar la operación especificada sobre cada subgrupo.
  • Combinar los subgrupos en un solo DataFrame.

In [56]:
mb.groupby('Paciente').mean()


Out[56]:
Grupo Tejido Heces
Paciente
1 0.0 891.4 1205.0
2 1.0 416.0 273.6
3 0.0 1096.4 862.4
4 1.0 2757.6 1748.4
5 0.0 979.8 14.6
6 1.0 1961.0 372.4
7 0.0 251.8 585.4
8 1.0 715.6 269.0
9 0.0 415.6 663.4
10 1.0 1541.2 259.4
11 0.0 1040.4 1093.4
12 1.0 649.0 1159.2
13 0.0 834.6 711.6
14 1.0 461.0 539.8

No estamos limitados a una sola variable


In [57]:
mb.groupby(['Grupo', 'Paciente']).mean()


Out[57]:
Tejido Heces
Grupo Paciente
0 1 891.4 1205.0
3 1096.4 862.4
5 979.8 14.6
7 251.8 585.4
9 415.6 663.4
11 1040.4 1093.4
13 834.6 711.6
1 2 416.0 273.6
4 2757.6 1748.4
6 1961.0 372.4
8 715.6 269.0
10 1541.2 259.4
12 649.0 1159.2
14 461.0 539.8

Al aplicar el método groupby se obtiene un objeto sobre el cual se puede operar de forma flexible


In [58]:
mb.groupby('Grupo')


Out[58]:
<pandas.core.groupby.DataFrameGroupBy object at 0x7f88a0d9e908>

Por ejemplo podemos indexarlo usando los nombres de las columnas, podríamos querer hacer esto para saber cuantos pacientes tenemos en cada grupo


In [59]:
mb.groupby('Grupo')['Paciente'].count()


Out[59]:
Grupo
0    35
1    35
Name: Paciente, dtype: int64

In [60]:
mb.groupby('Grupo')['Heces'].describe().round(2)


Out[60]:
count mean std min 25% 50% 75% max
Grupo
0 35.0 733.69 1314.03 0.0 11.5 50.0 658.5 4361.0
1 35.0 660.26 1596.51 0.0 28.5 83.0 604.0 8605.0

El método aggregate permite agregar datos de forma muy flexible. Este método acepta una lista de funciones (o incluso algunos strings asocidados a ciertas funciones), siempre y cuando estas funciones agreguen datos.


In [61]:
mb.groupby('Grupo')['Heces'].aggregate([min, np.median, 'max'])


Out[61]:
min median max
Grupo
0 0 50 4361
1 0 83 8605

Incluso es posible pasar un diccionario indicando que operaciones (values) se aplican sobre cuales columnas (keys).


In [62]:
mb.groupby('Grupo').aggregate({'Heces': [min, max], 'Paciente': 'count'})


Out[62]:
Heces Paciente
min max count
Grupo
0 0 4361 35
1 0 8605 35

In [63]:
da = mb.groupby('Grupo').aggregate({'Heces': [min, max], 'Paciente': 'count'})

Otro método que se puede aplicar sobre un objeto groupby es filter. Este método permite filtrar el resultado mediante una función arbitraria. Supongamos que fuese de interés saber cuales pacientes tuvieron un conteo mayor a 120 bacterias para todas las categorías taxonómicas medidas.


In [64]:
def filtro(x):
    return np.all(x['Tejido'] > 120)

mb.groupby('Paciente').filter(filtro)


Out[64]:
Taxon Paciente Grupo Tejido Heces
3 Firmicutes 4 1 831 8605
5 Firmicutes 6 1 718 717
17 Proteobacteria 4 1 12044 83
19 Proteobacteria 6 1 3053 547
31 Actinobacteria 4 1 568 7
33 Actinobacteria 6 1 678 377
45 Bacteroidetes 4 1 143 7
47 Bacteroidetes 6 1 4829 209
59 Other 4 1 202 40
61 Other 6 1 527 12

Para mayor claridad de los resultados podríamos querer ordenar el DataFrame resultante en función de la columna 'Paciente'


In [65]:
mb.groupby('Paciente').filter(filtro).sort_values('Paciente')


Out[65]:
Taxon Paciente Grupo Tejido Heces
3 Firmicutes 4 1 831 8605
17 Proteobacteria 4 1 12044 83
31 Actinobacteria 4 1 568 7
45 Bacteroidetes 4 1 143 7
59 Other 4 1 202 40
5 Firmicutes 6 1 718 717
19 Proteobacteria 6 1 3053 547
33 Actinobacteria 6 1 678 377
47 Bacteroidetes 6 1 4829 209
61 Other 6 1 527 12

La función que pasamos a filter debe ser una función que devuelva un booleano. Los grupos para los cuales esta función evaluen como True serán los que observaremos como resultado.

Otro método disponible es transform. A diferencia de aggregate y de filter la función que pasamos debe devolver un resultado con el mismo shape de los datos que le pasamos. Una transformación que es común al analizar datos es estandarizarlos (esto facilita el desempeño de muchos métodos y algoritmos).


In [66]:
def estandarizar(x):
    return (x - x.mean()) / x.std()

mb.groupby('Paciente')[['Tejido', 'Heces']].transform(estandarizar)


Out[66]:
Tejido Heces
0 -0.695985 1.617757
1 1.372912 1.150858
2 -0.370440 1.784453
3 -0.370585 1.788795
4 -0.348833 1.736045
5 -0.649164 1.246223
6 -0.597971 -0.595566
7 -0.447974 -0.637291
8 -0.532662 1.788536
9 -0.393204 -0.678962
10 1.777509 1.770881
11 -0.421206 0.292948
12 -0.659334 -0.517024
13 -0.374486 1.784500
14 1.453515 0.334746
15 0.766150 1.038291
16 1.785259 -0.488647
17 1.786254 -0.434481
18 1.617914 -0.127506
19 0.570303 0.631429
20 1.086668 1.712736
21 1.778116 1.679211
22 1.637054 -0.414825
23 1.787713 1.599174
24 -0.308214 -0.231630
25 1.788185 1.635385
26 0.630590 0.110473
27 1.760082 -0.472836
28 0.643652 -0.652646
29 -0.708191 -0.727930
... ... ...
40 -0.699504 -0.559351
41 -0.314152 -0.326168
42 -0.759558 -0.654819
43 -0.753472 -0.733290
44 -0.544252 -0.496170
45 -0.502923 -0.454308
46 -0.367077 -0.617914
47 1.497830 -0.590925
48 -1.349229 0.070726
49 -0.502179 -0.050579
50 -0.650285 -0.461435
51 -0.493758 0.363069
52 -0.558699 -0.398009
53 -0.464726 -0.639316
54 1.462568 1.695618
55 -0.746891 -0.492262
56 -0.641625 -0.645038
57 -0.677400 -0.727930
58 -0.419947 -0.474180
59 -0.491574 -0.445699
60 -1.050635 -0.715996
61 -0.748915 -1.303363
62 0.798306 -0.619285
63 -0.560060 -0.869953
64 -0.732201 -0.458610
65 -0.450039 -0.756592
66 -0.358532 -0.589318
67 -0.482600 -0.654315
68 -0.734319 -0.729717
69 -0.324554 -0.493234

70 rows × 2 columns

Una característica de Python que no vimos en la primer notebook es la posibilidad de escribir funciones anónimas, es decir funciones que no tienen nombre. Esto se conoce como funciones lambda. Siguiendo el ejemplo anterior la sintaxis para una función anónima que estandariza datos es:

lambda x: (x - x.mean()) / x.std()

Por lo que podríamos escribir.


In [67]:
mb.groupby('Paciente')[['Tejido', 'Heces']].transform(lambda x: (x - x.mean()) / x.std())


Out[67]:
Tejido Heces
0 -0.695985 1.617757
1 1.372912 1.150858
2 -0.370440 1.784453
3 -0.370585 1.788795
4 -0.348833 1.736045
5 -0.649164 1.246223
6 -0.597971 -0.595566
7 -0.447974 -0.637291
8 -0.532662 1.788536
9 -0.393204 -0.678962
10 1.777509 1.770881
11 -0.421206 0.292948
12 -0.659334 -0.517024
13 -0.374486 1.784500
14 1.453515 0.334746
15 0.766150 1.038291
16 1.785259 -0.488647
17 1.786254 -0.434481
18 1.617914 -0.127506
19 0.570303 0.631429
20 1.086668 1.712736
21 1.778116 1.679211
22 1.637054 -0.414825
23 1.787713 1.599174
24 -0.308214 -0.231630
25 1.788185 1.635385
26 0.630590 0.110473
27 1.760082 -0.472836
28 0.643652 -0.652646
29 -0.708191 -0.727930
... ... ...
40 -0.699504 -0.559351
41 -0.314152 -0.326168
42 -0.759558 -0.654819
43 -0.753472 -0.733290
44 -0.544252 -0.496170
45 -0.502923 -0.454308
46 -0.367077 -0.617914
47 1.497830 -0.590925
48 -1.349229 0.070726
49 -0.502179 -0.050579
50 -0.650285 -0.461435
51 -0.493758 0.363069
52 -0.558699 -0.398009
53 -0.464726 -0.639316
54 1.462568 1.695618
55 -0.746891 -0.492262
56 -0.641625 -0.645038
57 -0.677400 -0.727930
58 -0.419947 -0.474180
59 -0.491574 -0.445699
60 -1.050635 -0.715996
61 -0.748915 -1.303363
62 0.798306 -0.619285
63 -0.560060 -0.869953
64 -0.732201 -0.458610
65 -0.450039 -0.756592
66 -0.358532 -0.589318
67 -0.482600 -0.654315
68 -0.734319 -0.729717
69 -0.324554 -0.493234

70 rows × 2 columns

Por último tenemos al método apply, el cual nos permite aplicar una función arbitraria a los subgrupos. La función debe tomar un DataFrame y devolver un DataFrame, Series o un escalar.

Si necesitaramos normalizar el conteo en las Heces usando el conteo en el Tejido.


In [68]:
def norm(x):
    x['Heces'] /= x['Tejido'].sum()
    return x

In [69]:
mb.groupby('Grupo').apply(norm)


Out[69]:
Taxon Paciente Grupo Tejido Heces
0 Firmicutes 1 0 136 0.151797
1 Firmicutes 2 1 1174 0.016538
2 Firmicutes 3 0 408 0.143230
3 Firmicutes 4 1 831 0.202437
4 Firmicutes 5 0 693 0.001815
5 Firmicutes 6 1 718 0.016868
6 Firmicutes 7 0 173 0.001198
7 Firmicutes 8 1 228 0.001882
8 Firmicutes 9 0 162 0.116007
9 Firmicutes 10 1 372 0.000753
10 Firmicutes 11 0 4255 0.158294
11 Firmicutes 12 1 107 0.039217
12 Firmicutes 13 0 96 0.008094
13 Firmicutes 14 1 281 0.055920
14 Proteobacteria 1 0 2469 0.066098
15 Proteobacteria 2 1 839 0.015550
16 Proteobacteria 3 0 4414 0.000653
17 Proteobacteria 4 1 12044 0.001953
18 Proteobacteria 5 0 2310 0.000436
19 Proteobacteria 6 1 3053 0.012868
20 Proteobacteria 7 0 395 0.078911
21 Proteobacteria 8 1 2651 0.018044
22 Proteobacteria 9 0 1195 0.002759
23 Proteobacteria 10 1 6857 0.018703
24 Proteobacteria 11 0 483 0.024174
25 Proteobacteria 12 1 2950 0.093961
26 Proteobacteria 13 0 1541 0.029619
27 Proteobacteria 14 1 1307 0.001247
28 Actinobacteria 1 0 1590 0.000145
29 Actinobacteria 2 1 25 0.000047
... ... ... ... ... ...
40 Actinobacteria 13 0 51 0.006642
41 Actinobacteria 14 1 310 0.004799
42 Bacteroidetes 1 0 67 0.000000
43 Bacteroidetes 2 1 0 0.000000
44 Bacteroidetes 3 0 85 0.000181
45 Bacteroidetes 4 1 143 0.000165
46 Bacteroidetes 5 0 678 0.000073
47 Bacteroidetes 6 1 4829 0.004917
48 Bacteroidetes 7 0 74 0.023630
49 Bacteroidetes 8 1 169 0.005975
50 Bacteroidetes 9 0 106 0.000363
51 Bacteroidetes 10 1 73 0.008963
52 Bacteroidetes 11 0 30 0.013031
53 Bacteroidetes 12 1 51 0.001200
54 Bacteroidetes 13 0 2473 0.083993
55 Bacteroidetes 14 1 102 0.000776
56 Other 1 0 195 0.000653
57 Other 2 1 42 0.000047
58 Other 3 0 316 0.001561
59 Other 4 1 202 0.000941
60 Other 5 0 116 0.000000
61 Other 6 1 527 0.000282
62 Other 7 0 357 0.000399
63 Other 8 1 106 0.000259
64 Other 9 0 67 0.000508
65 Other 10 1 203 0.000141
66 Other 11 0 392 0.000218
67 Other 12 1 28 0.000588
68 Other 13 0 12 0.000799
69 Other 14 1 305 0.000753

70 rows × 5 columns

Vamos a cerrar esta sección con un ejemplo tomado de Python Data Science Handbook. Este ejemplo combina varias de las operaciones vistas hasta ahora. El objetivo es obtener un listado por década de la cantidad de exoplanetas descubiertos con diversos métodos.

Primero cargamos los datos


In [70]:
planets = sns.load_dataset('planets')
planets.head()


Out[70]:
method number orbital_period mass distance year
0 Radial Velocity 1 269.300 7.10 77.40 2006
1 Radial Velocity 1 874.774 2.21 56.95 2008
2 Radial Velocity 1 763.000 2.60 19.84 2011
3 Radial Velocity 1 326.030 19.40 110.62 2007
4 Radial Velocity 1 516.220 10.50 119.47 2009

Luego, apartir de la columna year creamos una Series llamada decades. Es importante notar que no estamos modificando el DataFrame planets


In [71]:
decade = (10 * (planets['year'] // 10)).astype(str) + 's'
decade.name = 'decade'
decade.head(8)


Out[71]:
0    2000s
1    2000s
2    2010s
3    2000s
4    2000s
5    2000s
6    2000s
7    1990s
Name: decade, dtype: object

Y por último aplicamos una combinación de operaciones en una sola linea!


In [72]:
planets.groupby(['method', decade])['number'].sum().unstack().fillna(0)


Out[72]:
decade 1980s 1990s 2000s 2010s
method
Astrometry 0.0 0.0 0.0 2.0
Eclipse Timing Variations 0.0 0.0 5.0 10.0
Imaging 0.0 0.0 29.0 21.0
Microlensing 0.0 0.0 12.0 15.0
Orbital Brightness Modulation 0.0 0.0 0.0 5.0
Pulsar Timing 0.0 9.0 1.0 1.0
Pulsation Timing Variations 0.0 0.0 1.0 0.0
Radial Velocity 1.0 52.0 475.0 424.0
Transit 0.0 0.0 64.0 712.0
Transit Timing Variations 0.0 0.0 0.0 9.0

Antes de continuar conviene detenerse a estudiar que es lo que hace cada elemento de la expresión anterior



Datos faltantes

Es común al analizar datos encontrarnos con datos faltantes. Las razones son variadas desde errores de transcripción, errores en la toma de muestra, observaciones incompletas, etc. En algunos casos estos datos faltantes quedan registrados simplemente como huecos en el conjunto de datos o usando algunos valores sentinelas como NaN, None o valores que esten claramente fuera del rango de los datos como podrían ser -9999 para valores de datos positivos o 999 para valores que, estamos seguros, son inferiores a 100.


In [73]:
!head datos/microbioma_faltantes.csv


Taxon,Paciente,Tejido,Heces
Firmicutes,1,632,305
Firmicutes,2,136,4182
Firmicutes,3,,703
Firmicutes,4,408,3946
Firmicutes,5,831,8605
Firmicutes,6,693,50
Firmicutes,7,718,717
Firmicutes,8,173,33
Firmicutes,9,228,NA

In [74]:
mbm = pd.read_csv('datos/microbioma_faltantes.csv')
mbm.head(14)


Out[74]:
Taxon Paciente Tejido Heces
0 Firmicutes 1 632 305.0
1 Firmicutes 2 136 4182.0
2 Firmicutes 3 NaN 703.0
3 Firmicutes 4 408 3946.0
4 Firmicutes 5 831 8605.0
5 Firmicutes 6 693 50.0
6 Firmicutes 7 718 717.0
7 Firmicutes 8 173 33.0
8 Firmicutes 9 228 NaN
9 Firmicutes 10 162 3196.0
10 Firmicutes 11 372 -99999.0
11 Firmicutes 12 4255 4361.0
12 Firmicutes 13 107 1667.0
13 Firmicutes 14 ? 223.0

En el ejemplo anterior Pandas reconoció correctamente a NA y a un campo vacío como datos faltantes, pero pasó por alto a ? y a -99999. Es facil pasar por alto estos errores, por lo que siempre es buena idea hacer gráficos de los datos y resúmenes como el siguiente:


In [75]:
mbm.describe()


Out[75]:
Paciente Heces
count 75.000000 74.000000
mean 8.000000 -619.283784
std 4.349588 11801.273013
min 1.000000 -99999.000000
25% 4.000000 12.500000
50% 8.000000 79.500000
75% 12.000000 658.500000
max 15.000000 8605.000000

Se puede ver que el conteo (count) para Paciente y Heces no coinciden, que el valor más pequeño para Heces es un número negativo cuando debería ser mayor o igual a cero. Y vemos que no tenemos descripción para Tejido!

¿Se te ocurre por que falta la columna para Tejido?



Podemos indicarle a Pandas que valores debe considerar como datos faltantes. Para eso usamos el argumento na_values.


In [76]:
mbm = pd.read_csv('datos/microbioma_faltantes.csv', na_values=['?', -99999])

In [77]:
mbm.describe()


Out[77]:
Paciente Tejido Heces
count 75.000000 73.000000 73.000000
mean 8.000000 984.315068 742.082192
std 4.349588 1840.338155 1467.675342
min 1.000000 0.000000 0.000000
25% 4.000000 109.000000 14.000000
50% 8.000000 310.000000 83.000000
75% 12.000000 831.000000 661.000000
max 15.000000 12044.000000 8605.000000

Si fuese necesario especificar valores distintos para distintas columnas es posible pasar un diccionario a na_values, indicando los nombres de las columnas y los valores a usar como indicadores.

Este es un buen momento para que pruebes como hacer esto antes de seguir con la nueva sección.



Operaciones con datos faltantes

Pandas ofrece métodos que nos permiten detectar, remover y reemplazar datos faltantes. Podemos preguntar a Pandas cuales son los valores null.


In [78]:
mbm.isnull()[:3]  # y su opuesto .notnull()


Out[78]:
Taxon Paciente Tejido Heces
0 False False False False
1 False False False False
2 False False True False

O podríamos querer eliminar los valores nulos. Esto es posible usando dropna(). En el caso de una Series es posible eliminar solo los valores nulos. En el caso de DataFrames esto no es posible, a menos que eliminemos toda la fila (o columna) donde aparece ese valor nulo. Por defecto dropna() eliminará todas las filas que contengan al menos un valor nulo.


In [79]:
mbm.dropna().head(11)


Out[79]:
Taxon Paciente Tejido Heces
0 Firmicutes 1 632.0 305.0
1 Firmicutes 2 136.0 4182.0
3 Firmicutes 4 408.0 3946.0
4 Firmicutes 5 831.0 8605.0
5 Firmicutes 6 693.0 50.0
6 Firmicutes 7 718.0 717.0
7 Firmicutes 8 173.0 33.0
9 Firmicutes 10 162.0 3196.0
11 Firmicutes 12 4255.0 4361.0
12 Firmicutes 13 107.0 1667.0
14 Firmicutes 15 281.0 2377.0

Es posible que no estemos interesados en remover los valores nulos si no que en cambio nos interese rellenar los valores nulos con algún número que tenga sentido para nuestro análisis.


In [80]:
mbm.fillna(42).head(11)


Out[80]:
Taxon Paciente Tejido Heces
0 Firmicutes 1 632.0 305.0
1 Firmicutes 2 136.0 4182.0
2 Firmicutes 3 42.0 703.0
3 Firmicutes 4 408.0 3946.0
4 Firmicutes 5 831.0 8605.0
5 Firmicutes 6 693.0 50.0
6 Firmicutes 7 718.0 717.0
7 Firmicutes 8 173.0 33.0
8 Firmicutes 9 228.0 42.0
9 Firmicutes 10 162.0 3196.0
10 Firmicutes 11 372.0 42.0

O simplemente completando con otros valores del propio DataFrame.


In [81]:
mbm.fillna(method='ffill').head(11)


Out[81]:
Taxon Paciente Tejido Heces
0 Firmicutes 1 632.0 305.0
1 Firmicutes 2 136.0 4182.0
2 Firmicutes 3 136.0 703.0
3 Firmicutes 4 408.0 3946.0
4 Firmicutes 5 831.0 8605.0
5 Firmicutes 6 693.0 50.0
6 Firmicutes 7 718.0 717.0
7 Firmicutes 8 173.0 33.0
8 Firmicutes 9 228.0 33.0
9 Firmicutes 10 162.0 3196.0
10 Firmicutes 11 372.0 3196.0

In [82]:
dada = pd.read_csv('datos/microbioma.csv')
dada.head()


Out[82]:
Taxon Paciente Grupo Tejido Heces
0 Firmicutes 1 0 136 4182
1 Firmicutes 2 1 1174 703
2 Firmicutes 3 0 408 3946
3 Firmicutes 4 1 831 8605
4 Firmicutes 5 0 693 50

Combinación de DataFrames

concat

Así como NumPy tiene una función concatenate que permite concatenar arrays, Pandas tiene una función llamada concat que esencialmente permite concatenar Series o DataFrame.


In [83]:
ser1 = pd.Series(['A', 'B', 'C'], index=[1, 2, 3])
ser2 = pd.Series(['D', 'E', 'F'], index=[4, 5, 6])
pd.concat([ser1, ser2])


Out[83]:
1    A
2    B
3    C
4    D
5    E
6    F
dtype: object

In [84]:
df0 = pd.DataFrame({'A': [0, 2], 'B': [1, 3]})
df0


Out[84]:
A B
0 0 1
1 2 3

In [85]:
df1 = pd.DataFrame({'A': [4, 6], 'B': [5, 7]})
df1


Out[85]:
A B
0 4 5
1 6 7

Al concatenar los índices se preservan incluso si esto da origen a duplicados


In [86]:
pd.concat([df0, df1])


Out[86]:
A B
0 0 1
1 2 3
0 4 5
1 6 7

Es posible concatenar ignorando los índices originales. En este caso Pandas generará un nuevo índice.


In [87]:
pd.concat([df0, df1], ignore_index=True)


Out[87]:
A B
0 0 1
1 2 3
2 4 5
3 6 7

Como vimos al concatenar arrays es posible pasar el argumento axis


In [88]:
pd.concat([df0, df1], axis=1)


Out[88]:
A B A B
0 0 1 4 5
1 2 3 6 7

En caso que sea necesario verificar que los índices no se superponen es posible pasar el argumento verify_integrity=True.


In [89]:
df2 = pd.DataFrame({'A': [2, 6], 'B': [0, 1], 'C': [9, -1]})
df2


Out[89]:
A B C
0 2 0 9
1 6 1 -1

En caso de que los nombre de las columnas no coincidan al contenar Pandas completará los espacios faltantes con NaN.


In [90]:
pd.concat([df0, df2])


Out[90]:
A B C
0 0 1 NaN
1 2 3 NaN
0 2 0 9.0
1 6 1 -1.0

concat tiene un argumento llamado join que por defecto toma el valor outer, en este caso la concatenación funciona como una unión de los nombres de las columnas. Si cambiamos el valor de join por inner, entonces la concatenación se comporta como una intersección.


In [91]:
pd.concat([df0, df2], join='inner')


Out[91]:
A B
0 0 1
1 2 3
0 2 0
1 6 1

Una alternativa es especificar cuales son las columnas que queremos concatenar, para ello debemos pasarle al argumento join_axes una lista de Index.


In [92]:
pd.concat([df0, df2], join_axes=[pd.Index(['B', 'C'])])


Out[92]:
B C
0 1 NaN
1 3 NaN
0 0 9.0
1 1 -1.0

append

La concatenación directa de de Series y DataFrame es tan común que en vez llamar pd.concat([df0, df1]) es posible obtener el mismo resultado haciendo:


In [93]:
df0.append(df1)


Out[93]:
A B
0 0 1
1 2 3
0 4 5
1 6 7

Una diferencia importante entre este append() y el que vimos para listas de Python es que Pandas no modifica el objeto original (como sucedía con las listas) si no que cada vez que hacemos un append() estamos creando un nuevo objeto con un nuevo índice. Esto hace que este método no sea muy eficiente. Dicho de otras forma mientras las listas están pensadas para hacer uso repetido del método append() los DataFrames no. Por lo tanto en caso de ser necesario hacer varios append() sobre DataFrames es mejor crear una lista de DataFrames y concatenar esa lista una sola vez.

Merge and join

Pandas ofrece operaciones del tipo merge y join que son familiares para quienes hayan trabajado con bases de datos. merge tiene implementado un subconjunto de lo que se conoce como algebra relacional que son un conjunto de reglas formales para manipular datos relacionales. Este conjunto de reglas funciona como los bloques básicos (o primitivas) con los que se construyen operaciones más complejas sobre los datos. Pandas implementa varios de estos bloques básicos.

Para mayor información por favor referirse a la documentación de Pandas y/o libros como Python Data Science Handbook y Python for Data Analysis

Indexado jerárquico

Hasta ahora hemos visto como trabajar con datos uni y bi-dimensionales. El indexado jerárquico (o multi-indexado) permite usar más de un índice en un DataFrame, de esta forma podemos trabajar en más de dos dimensiones pero usando un DataFrame.


In [94]:
index = pd.MultiIndex.from_product([[2013, 2014], [1, 2]],
names=['año', 'visita'])
columns = pd.MultiIndex.from_product([['Ana', 'Brian', 'Carlos'], ['RC', 'Temp']],
names=['sujeto', 'tipo'])

datos = np.zeros((4, 6))
datos[:, 1::2] += np.random.normal(37, 0.5, (4, 3))
datos[:, ::2] += np.random.normal(65, 1, (4, 3))

datos_médicos = pd.DataFrame(np.round(datos, 1), index=index, columns=columns)
datos_médicos


Out[94]:
sujeto Ana Brian Carlos
tipo RC Temp RC Temp RC Temp
año visita
2013 1 64.8 37.8 64.9 37.0 65.1 37.2
2 62.2 37.2 65.8 36.8 65.1 36.2
2014 1 64.6 37.4 64.6 38.0 63.7 36.5
2 66.2 38.0 64.8 37.1 66.6 37.6

In [95]:
datos_médicos['Brian']


Out[95]:
tipo RC Temp
año visita
2013 1 64.9 37.0
2 65.8 36.8
2014 1 64.6 38.0
2 64.8 37.1

Recordemos que son las columnas quienes tiene prioridad en un DataFrame, si quisieramos el ritmo cardíaco de Ana:


In [96]:
datos_médicos['Ana', 'RC']


Out[96]:
año   visita
2013  1         64.8
      2         62.2
2014  1         64.6
      2         66.2
Name: (Ana, RC), dtype: float64

También es posible usar los idexadores iloc o loc.


In [97]:
datos_médicos.iloc[:2, :2]


Out[97]:
sujeto Ana
tipo RC Temp
año visita
2013 1 64.8 37.8
2 62.2 37.2

A cada índice individual en loc e iloc se le puede pasar una tupla con índices.


In [98]:
datos_médicos.loc[:, ('Ana', 'RC')]


Out[98]:
año   visita
2013  1         64.8
      2         62.2
2014  1         64.6
      2         66.2
Name: (Ana, RC), dtype: float64

Es necesario ser cuidadosos al intentar tomar slices por ejemplo intentar algo como

datos_médicos.loc[(:, 1), (:, 'RC')]

Daría un error de syntaxis. El resultado deseado se puede obtener empleando el objeto IndexSlice object, que Pandas provee para este tipo de situación.


In [99]:
idx = pd.IndexSlice
datos_médicos.loc[idx[:, 1], idx[:, 'RC']]


Out[99]:
sujeto Ana Brian Carlos
tipo RC RC RC
año visita
2013 1 64.8 64.9 65.1
2014 1 64.6 64.6 63.7

In [100]:
datos_médicos


Out[100]:
sujeto Ana Brian Carlos
tipo RC Temp RC Temp RC Temp
año visita
2013 1 64.8 37.8 64.9 37.0 65.1 37.2
2 62.2 37.2 65.8 36.8 65.1 36.2
2014 1 64.6 37.4 64.6 38.0 63.7 36.5
2 66.2 38.0 64.8 37.1 66.6 37.6

Así como en arrays y DataFrames es posible agregar datos (médias, sumas, etc) a lo largo de axes es posible en DataFrame multi-indexados agregar datos a lo largo de niveles. Dado que tenemos registros de dos visitas por año podríamos querer saber el promedio anual, es decir promediar a lo largo del nivel año.


In [101]:
media_anual = datos_médicos.mean(level='año')
media_anual


Out[101]:
sujeto Ana Brian Carlos
tipo RC Temp RC Temp RC Temp
año
2013 63.5 37.5 65.35 36.90 65.10 36.70
2014 65.4 37.7 64.70 37.55 65.15 37.05

Es posible hacer uso combinado del argumento level y axis. En el siguiente ejemplo promediamos a lo largo de las columnas, axis=1 y a lo largo del nivel 'tipo'.


In [102]:
media_anual.mean(axis=1, level='tipo').round(1)


Out[102]:
tipo RC Temp
año
2013 64.6 37.0
2014 65.1 37.4

Gráficos

Pandas tiene la capacidad de generar gráficos a partir de DataFrames (y Series) mediante el método plot.


In [103]:
mbm[['Tejido', 'Heces']].plot(kind='kde', xlim=(0));


Internamente los gráficos son generados usando MatplotLib por lo que tenemos libertad para modificarlos, usando la misma sintáxis de Matplotlib.


In [104]:
mbm[['Tejido', 'Heces']].plot(kind='kde')
plt.xlim(0);


Caso de estudio: El Titanic

Hasta ahora hemos visto algunas de las funciones más básicas de Pandas, equipados con estos conocimientos haremos un análisis de un conjunto de datos muy usando en Ciencia de Datos, estos datos tienen información sobre el famoso y trágico viaje del transatlántico Titanic.

Usando este conjunto de datos intentaremos contestar una serie de preguntas relacionadas con las chances de supervivencia:

  • ¿Qué porcentaje de las personas a bordo sobrevivieron?
  • ¿Existió alguna relación entre la tasa de supervivencia y el costo del pasaje?
  • ¿Se salvaron mujeres y niños primero como dice la conocida frase?

Podemos tomarnos un momento para pensar sobre estas preguntas y tratar de aventurar algunas respuestas, luego veremos si los datos se condicen con nuestras sospechas.



Empecemos cargando los datos


In [105]:
titanic = pd.read_csv('datos/Titanic.csv')
print(titanic.shape)
titanic.head()


(1310, 14)
Out[105]:
pclass survived name sex age sibsp parch ticket fare cabin embarked boat body home.dest
0 1.0 1.0 Allen, Miss. Elisabeth Walton female 29.0000 0.0 0.0 24160 211.3375 B5 S 2 NaN St Louis, MO
1 1.0 1.0 Allison, Master. Hudson Trevor male 0.9167 1.0 2.0 113781 151.5500 C22 C26 S 11 NaN Montreal, PQ / Chesterville, ON
2 1.0 0.0 Allison, Miss. Helen Loraine female 2.0000 1.0 2.0 113781 151.5500 C22 C26 S NaN NaN Montreal, PQ / Chesterville, ON
3 1.0 0.0 Allison, Mr. Hudson Joshua Creighton male 30.0000 1.0 2.0 113781 151.5500 C22 C26 S NaN 135.0 Montreal, PQ / Chesterville, ON
4 1.0 0.0 Allison, Mrs. Hudson J C (Bessie Waldo Daniels) female 25.0000 1.0 2.0 113781 151.5500 C22 C26 S NaN NaN Montreal, PQ / Chesterville, ON

Se puede ver que tenemos información sobre 1310 personas (filas) que estuvieron a bordo y para cada persona tenemos 14 variables (columnas). En machinelearngua se le suele llamar features a las variables.

¿Qué tipos de variables se corresponden con cada columna?



Recordemos que podemos acceder a los nombres de las variables mediante el atributo columns.


In [106]:
titanic.columns


Out[106]:
Index(['pclass', 'survived', 'name', 'sex', 'age', 'sibsp', 'parch', 'ticket',
       'fare', 'cabin', 'embarked', 'boat', 'body', 'home.dest'],
      dtype='object')

Para algunas de estas variables veamos que porcentaje de datos faltantes tenemos:


In [107]:
for i in ['fare', 'pclass', 'age', 'sex']:
    print('{:8s}{:4.1f} % datos faltantes'.format(i, titanic[i].isnull().mean() * 100))


fare     0.2 % datos faltantes
pclass   0.1 % datos faltantes
age     20.2 % datos faltantes
sex      0.1 % datos faltantes

Podemos ver que los registros están casi completos excepto para la edad (age), variable para la cual el 20% de los datos son faltantes. Luego volveremos sobre esto, por ahora tratemos de constestar cual fue el porcentaje de sobrevivientes. Dado que los sobrevivientes están indicados con 1.0 y los fallecidos con 0.0, podemos obtener el porcentaje de sobrevivientes haciendo:


In [108]:
round(titanic['survived'].mean() * 100, 0)


Out[108]:
38.0

Pero esta no es la única forma de hacerlo, una forma alternativa de calcular el número de sobrevivientes y fallecidos es:


In [109]:
(titanic['survived'].value_counts(normalize=True) * 100).round(0)


Out[109]:
0.0    62.0
1.0    38.0
Name: survived, dtype: float64

¿Habrá alguna diferencia para los sobrevivientes en función del costo del boleto que pagaron?


In [110]:
boleto_fallecidos = titanic['fare'][titanic['survived'] == 0]
boleto_sobrevivientes = titanic['fare'][titanic['survived'] == 1]

In [111]:
{:.0f}, £{:.0f}'.format(boleto_fallecidos.mean(), boleto_sobrevivientes.mean())


Out[111]:
'£23, £49'

Podemos ver que en promedio el grupo de fallecidos pagó menos que el grupo de sobrevivientes.

Veamos estos mismos datos, pero de forma gráfica.


In [112]:
boleto_fallecidos.plot(kind='kde')
boleto_sobrevivientes.plot(kind='kde')

plt.xlim(0);  # valores por debajo de 0 no tienen sentido



In [113]:
sns.violinplot(x="survived", y="fare", data=titanic[titanic['fare'] < 200]);


Veamos que además de la variable continua fare tenemos la variable discreta pclass que toma 3 valores de acuerdo a si las personas viajaban en primera (1), segunda (2) o tercera (3) clase. Veamos que nos dicen los datos cuando exploramos la tasa de supervivencia en función de la clase.


In [114]:
sns.barplot(x="pclass", y="survived", data=titanic);


El plot anterior incluye barras de error, por defecto, es posible removerlas pasando como argumento ci=None.

¿Consideran correcto usar barras de error en este caso particular? Discutan y fundamenten con sus pares





Ejercicios

Ya hemos contestado las dos primeras preguntas que nos hicimos. Ahora llegó el turno de explorar la tercer pregunta. Usaremos la siguiente lista como guía para explorar estas preguntas y para obtener información de este conjunto de datos, pero somos libres de ir más allá de este listado y sumar otras preguntas o análisis.

  1. Repitamos los análisis anteriores pero en términos de mujeres y hombres en vez de "ricos" y "pobres"
  2. La función sns.barplot tiene un argumento llamado hue que podemos usar para combinar información de clase, género y supervivencia.
  3. Pandas incluye una función llamada crosstab que puede sernos útil para analizar estas diferencias de genero. Esta función tiene varios argumentos probemos usar normalize.
  4. ¿Cuál es la media, mediana y desviación estándar de las edades? ¿Cuál es la distribución de las edades?
  5. ¿Cuantos de los datos faltantes corresponden a hombres, cuantos a mujeres, cuantos a hombres que viajaban en clase baja?
  6. Seaborn incluye varios datasets uno de ellos se conoce como iris y contiene medidas de 4 variables para 3 especies de flores. Estos datos se pueden cargar en un DataFrame haciendo:

     iris = sns.load_dataset('iris')
    

    Exploren este conjunto de datos usando las herramientas aprendidas hasta el momento.