Pandas#

En la sección anterior ya vimos como numpy proporciona soporte para arrays de con tipos homogéneos. En el último apartado vimos los arrays estructurados, un tipo de array para tratar con datos tabulares. Sin embargo, los propios desarrolladores de numpy indican que esta estructura solo es apropiada para los casos de uso más sencillos.

Pandas es un paquete construido sobre numpy que proporciona un tipo de datos DataFrame. Este tipo es, básicamente, un array multidimensional heterogéneo, con capacidad de manejar datos faltantes y con nombres para filas y columnas.

import pandas as pd
pd.__version__
'2.2.2'

Tipos de datos#

Los tipos de datos de pandas pueden entenderse como versiones mejoradas de los arrays estructurados de numpy, donde además se pueden añadir etiquetas tanto a filas como a columnas.

Pandas proporciona, básicamente, objetos para 1d, 2d y 3d. El tipo 3d se denomina Panel y no lo veremos aquí.

Series#

El tipo 1D se denomina Series

data = pd.Series([-1.0, -0.5, 0, 0.5, 1.0])
data
0   -1.0
1   -0.5
2    0.0
3    0.5
4    1.0
dtype: float64

Podemos ver que además de tener los datos, también tenemos una secuencia de índices. Se puede acceder a ambos con values e indexp, respectivamente

print(type(data.values))
data.values
<class 'numpy.ndarray'>
array([-1. , -0.5,  0. ,  0.5,  1. ])
print(type(data.index))
data.index
<class 'pandas.core.indexes.range.RangeIndex'>
RangeIndex(start=0, stop=5, step=1)

Podemos acceder a los valores indexando como una lista:

data[2]
np.float64(0.0)
data[1:4]
1   -0.5
2    0.0
3    0.5
dtype: float64

Podría parecer que Series es intercambiable con ndarray. Una diferencia fundamental es el índice. Mientras que en numpy es implícito (entero, empieza en cero), en pandas es explícito.

Podemos, por ejemplo, utilizar una cadena como índice:

data = pd.Series([-1.0, -0.5, 0, 0.5, 1.0],
                 index=['a', 'b', 'c', 'd', 'e'])
data
a   -1.0
b   -0.5
c    0.0
d    0.5
e    1.0
dtype: float64

El acceso a los datos sigue funcionando:

data['a']
np.float64(-1.0)
data['a':'d']
a   -1.0
b   -0.5
c    0.0
d    0.5
dtype: float64

También podemos usar como índices enteros, pero no ordenados, por ejemplo:

data = pd.Series([-1.0, -0.5, 0, 0.5, 1.0],
                 index=[1, 3, 0, 2, 4])
data
1   -1.0
3   -0.5
0    0.0
2    0.5
4    1.0
dtype: float64

Y entonces tenemos que:

data[3]
np.float64(-0.5)

Como vemos, es el índice, y no la posición, lo que se utiliza para acceder a los datos con el operador [].

En esto, Series se parece a una generalización de un diccionario:

datadic = {1: -1.0, 3:-0.5, 0:0.0, 2:0.5, 4:1.0}
datadic[3]
-0.5

En realidad, Series puede crearse a partir de un diccionario; index se genera automáticamente a partir de las claves del diccionario

rocky = {'Mercurio': 0.39, 'Venus':0.72, 'Tierra':1.0, 'Marte':1.52}
pd.Series(rocky)
Mercurio    0.39
Venus       0.72
Tierra      1.00
Marte       1.52
dtype: float64

Incluso aquí podemos decidir reorder o no incluir valores:

pd.Series(rocky, index=['Marte', 'Tierra'])
Marte     1.52
Tierra    1.00
dtype: float64

Pueden encontrarse más formas de construir Series en la documentación de Pandas.

DataFrame#

La siguiente estructura es DataFrame. Igual que con Series, podemos entendarla como una generalización de un ndarray o de un diccionario.

Podemos imaginar un DataFrame como una secuencia objetos Serie con un índice común.

rocky_d = {'Mercurio': 0.39, 'Venus':0.72, 'Tierra':1.0, 'Marte':1.52}
dis = pd.Series(rocky_d)
rocky_l = {'Mercurio': 0, 'Venus':0, 'Tierra':1, 'Marte':2}
lunas = pd.Series(rocky_l)

Ahora que tenemos dos series, las combinamos en un DataFrame

rocky = pd.DataFrame({'distance': dis, 'moons': lunas})
rocky
distance moons
Mercurio 0.39 0
Venus 0.72 0
Tierra 1.00 1
Marte 1.52 2

El objeto DataFrame tiene un index, igual que Series

rocky.index
Index(['Mercurio', 'Venus', 'Tierra', 'Marte'], dtype='object')

Además, tiene un atributo columns:

rocky.columns
Index(['distance', 'moons'], dtype='object')

Así que podemos imaginar DataFrame como una generalización de un array estructurado de numpy, en el que tanto filas como columnas tienen un índice generalizado.

Pero quizá sea más claro pensar que DataFrame es una generalización de un diccionario, cuyos valores son Series. Así:

Además, tiene un atributo columns:

rocky['moons']
Mercurio    0
Venus       0
Tierra      1
Marte       2
Name: moons, dtype: int64

Mientras que un ndarray, data[0] devuelve la primera fila, en un DataFrame, data['valor'] devuelve una columna.

Se pueden construir objetos DataFrame de muchas maneras diferentes, atendiendo a su doble condición de seudo-array y seudo-diccionario.

Por ejemplo:

Con una Serie:

pd.DataFrame(dis, columns=['distance'])
distance
Mercurio 0.39
Venus 0.72
Tierra 1.00
Marte 1.52

Con una lista de diccionarios:

data = [{'a': 1, 'b': 2}, {'b': 6, 'c': 1}]
pd.DataFrame(data)
a b c
0 1.0 2 NaN
1 NaN 6 1.0

Aquí se usan las claves como columnas, y el número de orden del diccionario en la lista como índice. Y las claves no comunes se marcan como NaN

Como un diccionario de Series (que ya vimos más arriba):

pd.DataFrame({'distance': dis, 'moons': lunas})
distance moons
Mercurio 0.39 0
Venus 0.72 0
Tierra 1.00 1
Marte 1.52 2

A partir de un array:

import numpy as np
data = np.random.randint(10, size=(3,2))
pd.DataFrame(data)
0 1
0 5 5
1 4 2
2 5 0

Si no definimos ni columnas ni filas, se utizan secuencias de enteros.

pd.DataFrame(data, columns=['eggs', 'spam'], 
              index=['a', 'b', 'c'])
eggs spam
a 5 5
b 4 2
c 5 0

Y de un array estructurado:

estrc = np.zeros(3, dtype=[('A', 'i8'), ('B', 'f8')])
pd.DataFrame(estrc)
A B
0 0 0.0
1 0 0.0
2 0 0.0

Index#

Hemos visto que tanto Series como DataFrame tienen un objeto index. Este puede entenderse o bien como un array inmutable o bien como un conjunto ordenado (como el tipo set de python, pero manteniendo el orden de inserción). Como las propiedades del índice tiene consecuencias en la indexación en pandas, vamos a explorarlas.

ind = pd.Index([4, 2, 1, 7, 11])

Los objetos de índice tiene muchas propiedades en común con ndarray

print(ind.shape)
print(ind.dtype)
print(ind[1])
print(ind[::2])
(5,)
int64
2
Index([4, 1, 11], dtype='int64')

Una diferencias es que Index es inmutable

ind[0] = 8
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[28], line 1
----> 1 ind[0] = 8

File /opt/hostedtoolcache/Python/3.11.9/x64/lib/python3.11/site-packages/pandas/core/indexes/base.py:5371, in Index.__setitem__(self, key, value)
   5369 @final
   5370 def __setitem__(self, key, value) -> None:
-> 5371     raise TypeError("Index does not support mutable operations")

TypeError: Index does not support mutable operations

Por otro lado, Index comparte propiedades del tipo set. Al crear DataFrame tenemos que poder unir los indices que proceden de los objetos Serie

ind1 = pd.Index([1, 3, 5, 7, 9])
ind2 = pd.Index([2, 3, 5, 7, 11])

Tenemos las operaciones de intersección y unión de conjuntos:

ind1.intersection(ind2) 
Index([3, 5, 7], dtype='int64')
ind1.union(ind2) 
Index([1, 2, 3, 5, 7, 9, 11], dtype='int64')

Así como la diferencia simétrica (elementos de la unión que no están en la intersección)

ind1.symmetric_difference(ind2)
Index([1, 2, 9, 11], dtype='int64')

En versiones anteriores de pandas, era posible usar &, | y ^ como en los objetos set, pero esta opción se está eliminando:

ind1 | ind2 
Index([3, 3, 5, 7, 11], dtype='int64')