Trabajo práctico final

Del barro al oro: estructuras para datos tabulares bien desde abajo

1 Introducción

En el lenguaje R, los data frames son una de las estructuras de datos más utilizadas. Permiten almacenar y manipular información tabular de forma sencilla, donde cada columna representa una variable y cada fila una observación.

En Python no existe una estructura built-in equivalente al data frame. En la práctica, se utilizan librerías como pandas o polars, que implementan estructuras de datos diseñadas específicamente para este propósito. Ambas librerías se apoyan en dos estructuras fundamentales: Series, que representa una columna, y DataFrame, que representa una tabla completa compuesta por varias series.

El objetivo de este trabajo es implementar de forma básica las clases Series y DataFrame, con el fin de profundizar en la programación orientada a objetos y en el uso de Python como lenguaje de desarrollo. Además, busca comprender el funcionamiento interno de las estructuras de datos tabulares y los principios de diseño que las hacen posibles.

2 Clase Series

Una Series representa una estructura unidimensional de datos, similar a un vector o una columna. En nuestro caso, soportan cuatro tipos de datos: numéricos enteros (int), numéricos flotantes (float), texto (str) y booleanos (bool). Las series pueden también incluir valores nulos, representados por None.

2.1 Inicialización

Para inicializar una Series se necesita una secuencia de valores del mismo tipo. Opcionalmente, se pasan valores para los argumentos name y dtype. El primero le asigna un nombre a la serie y el segundo un tipo de dato. El nombre de la serie es una cadena de texto y el tipo puede ser "int", "float", "str" o "bool".

Ejemplos

Se muestran algunos bloques de código donde se inicializan objetos de la clase Series.

Ejemplo mínimo, donde se pasa una secuencia de enteros.

# Ejemplo 1
serie = Series([1, 2, 3, 4])
serie
# Series: ''
# len: 4
# dtype: int
# [
#     1
#     2
#     3
#     4
# ]

Serie nombrada:

serie = Series([1.0, 2.0, 3.0], name="x")
serie
# Series: 'x'
# len: 3
# dtype: float
# [
#     1.0
#     2.0
#     3.0
# ]

Serie con nombre y tipo explícito:

serie = Series([1, 2, 3], name="cantidad", dtype="float")
serie
# Series: 'cantidad'
# len: 3
# dtype: float
# [
#     1.0
#     2.0
#     3.0
# ]

Notar que al utilizar dtype="float" los valores enteros son convertidos a flotantes.

2.2 Atributos

Los objetos de la clase Series tienen los siguientes atributos públicos:

Método Descripción
dtype Tipo de dato ("int", "float", "str", "bool")
name El nombre de la serie
len La longitud de la serie

2.3 Métodos para manipular de datos

La clase Series disponibiliza los siguientes métodos para la manipulación de datos:

Método Descripción
clone(self) Devuelve una nueva serie, idéntica a la original.
head(self, n=5) Devuelve una nueva serie con los primeros n valores.
tail(self, n=5) Devuelve una nueva serie con los últimos n valores.
append(self, x) Agrega el elemento x al final de la serie.
extend(self, s) Extiende la serie con los elementos de la serie s.
filter(self, f) Devuelve una nueva serie con los elementos de la serie que al ser pasados a f devuelven un valor verdadero. Por ejemplo, serie.filter(lambda x: x > 5) devuelve una serie con los valores que son mayores a 5.
where(self, f) Devuelve una nueva lista con los índices de los elementos que al ser pasados a f devuelven True
is_null(self) Devuelve una serie de valores booleanos.
Cada elemento será True si el elemento original es nulo.
is_not_null(self) Devuelve una serie de valores booleanos.
Cada elemento será True si el original es no nulo.
fill_null(self, x) Reemplaza los valores nulos por x.
rename(self, name) Cambia el nombre de la serie por name.
sort(self, ...) Ordena la serie. El parámetro descending determina si se ordena de forma ascendente (por defecto) o descendente. y el parámetro in_place determina si se modifica la serie in-place o si se devuelve una nueva (por defecto).
argsort(self, ...) Devuelve una lista con los índices que ordenan a la serie. El parámetro descending determina si se ordena de forma ascendente (por defecto) o descendente.

Ejemplos

serie = Series([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
serie.head()
# Series: ''
# len: 5
# dtype: int
# [
#     1
#     2
#     3
#     4
#     5
# ]
serie = Series([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
serie.tail(3)
# Series: ''
# len: 3
# dtype: int
# [
#     8
#     9
#     10
# ]
serie = Series(list("ABCD"))
serie.clone()
# Series: ''
# len: 4
# dtype: str
# [
#     A
#     B
#     C
#     D
# ]
s1 = Series([True, True, False])
s1.append(False)
s1
# Series: ''
# len: 4
# dtype: bool
# [
#     True
#     True
#     False
#     False
# ]
s1 = Series([1, 2, 3])
s2 = Series([4, 5, 6])
s1.extend(s2)
s1
# Series: ''
# len: 6
# dtype: int
# [
#     1
#     2
#     3
#     4
#     5
#     6
# ]
s = Series([1, 20, 50, 2, 100, 3])
s.filter(lambda x: x < 20)
# Series: ''
# len: 3
# dtype: int
# [
#     1
#     2
#     3
# ]
s = Series([1, 20, 50, 2, 100, 3])
indices = s.where(lambda x: x < 20)

indices
# [0, 3, 5]

[s[i] for indices in indices]
# [1, 2, 3]
s = Series([1, 20, 50, 2, 100, 3])
s.is_null()
# Series: ''
# len: 6
# dtype: bool
# [
#     False
#     False
#     False
#     False
#     False
#     False
# ]
s = Series([5, None, None, 10])
s.is_not_null()
# Series: ''
# len: 4
# dtype: bool
# [
#     True
#     False
#     False
#     True
# ]
s = Series([5, None, None, 10])
s.fill_null(-1)
# Series: ''
# len: 4
# dtype: int
# [
#     5
#     -1
#     -1
#     10
# ]
s = Series(list("xyz"))
s.rename("letras")
s
# Series: 'letras'
# len: 3
# dtype: str
# [
#     x
#     y
#     z
# ]
s = Series([128, 256.0, 42.5, 35])
s.sort()
# Series: ''
# len: 4
# dtype: float
# [
#     35.0
#     42.5
#     128.0
#     256.0
# ]
s = Series([128, 256.0, 42.5, 35])
s.sort(descending=True)
# Series: ''
# len: 4
# dtype: float
# [
#     256.0
#     128.0
#     42.5
#     35.0
# ]
s = Series([128, 256.0, 42.5, 35])
indices = s.argsort()
indices
# [3, 2, 0, 1]

[s[i] for i in indices] # Se utilizan los indices
# [35.0, 42.5, 128.0, 256.0]
Sobre los tipos de datos

Todas las operaciones que modifican una serie solo deben soportar valores del mismo tipo que el de la serie. Por ejemplo, debe ser posible usar append(125.5) en una serie de tipo flotante, pero no en una serie de tipo texto.

2.4 Métodos para calcular agregaciones

Los siguientes métodos obtienen un valor a partir de todos los valores de la serie. En todos los casos se ignoran los valores nulos. Solo se pueden aplicar a series numéricas.

Método Descripción
min(self) El valor más pequeño.
max(self) El valor más grande.
sum(self) La suma de los elementos.
mean(self) El promedio de los elementos.
product(self) El producto de los elementos.
std(self) El desvío estándar.
var(self) La varianza.

Ejemplos

s1 = Series([1, 4, 5, 2, 10, 6, 3, 7, 8, 9])
s2 = Series([True, True, False, True])

s1.min()     # 1
s1.max()     # 9
s1.sum()     # 55
s1.mean()    # 5.5
s1.product() # 3628800
s1.std()     # 2.87228
s1.var()     # 8.25

2.5 Métodos especiales

Aritméticos

Estos operadores solo se pueden utilizar con series de tipo numérico. Si other es un número, se recicla para todos los elementos de la serie. Por ejemplo:

s = Series([5, 6, 7])
s * 3.0
# Series: ''
# len: 3
# dtype: float
# [
#     15.0
#     18.0
#     21.0
# ]

Si other es otra Series, deben tener la misma longitud y la operación se hace elemento a elemento. Por ejemplo:

s1 = Series([10, 20, 30])
s2 = Series([5, 25, 28])
s1 > s2
# Series: ''
# len: 3
# dtype: bool
# [
#     True
#     False
#     True
# ]

Los métodos a implementar se resumen en la siguiente tabla:

Método Descripción
__eq__(self, other) Igual a
__gt__(self, other) Mayor que
__ge__(self, other) Mayor o igual que
__lt__(self, other) Menor que
__le__(self, other) Menor o igual que
__add__(self, other) Suma
__sub__(self, other) Resta
__mul__(self, other) Multiplicación
__truediv__(self, other) División flotante
__pow__(self, other) Potencia

Acceso e iteración

Estos métodos permiten interactuar con la serie de forma natural.

Método Descripción
__repr__(self) Representación textual
__len__(self) Longitud de la serie
__contains__(self, item) Determina si item se encuentra en la serie
__getitem__(self, index) Obtiene el elemento en la posición index
__iter__(self) Permite iterar sobre los elementos de la serie
Ejemplos

El método de representación es el que determina que la serie se vea de la siguiente manera al mostrarla en la terminal:

# Series: ''
# len: 3
# dtype: float
# [
#     15.0
#     18.0
#     21.0
# ]

La cantidad máxima de elementos que se muestra es 10. Una serie con los números del 1 al 10 se ve de la siguiente manera:

# Series: ''
# len: 10
# dtype: float
# [
#     1
#     2
#     3
#     4
#     5
#     6
#     7
#     8
#     9
#     10
# ]

Y si la serie tiene más elementos, se muestran los primeros cinco, luego tres puntos suspensivos, y finalmente los últimos cinco. Por ejemplo, una serie con los números del 1 al 100.

# Series: ''
# len: 100
# dtype: float
# [
#     1
#     2
#     3
#     4
#     5
#     ...
#     96
#     97
#     98
#     99
#     100
# ]

El método especial __len__ permite obtener la cantidad de elementos de la serie con len():

len(s)
# 100

Por su parte, __contains__(self, item) se ejecuta cuando utilizamos el operador in, de esta manera:

20 in s
# True

200 in s
# False

El método __getitem__(self, index) nos permite indexar la serie, tratandola como una secuencia (¡qué es lo que es!):

s = Series([-10, 10, -20, 20, -30, 30])
s[0] # -10
s[1] # 10

Finalmente, el método __iter__(self) nos permite iterar a través de la secuencia.

s = Series([-10, 10, -20, 20, -30, 30])
for s_i in s:
    if s_i > 0:
        print(s_i)
# 10
# 20
# 30

3 Clase DataFrame

Un DataFrame representa una estructura bidimensional de datos, organizada en filas y columnas, donde cada columna está asociada a una instancia de Series. Se trata de una tabla en la que cada columna tiene un nombre único y un tipo de dato consistente a lo largo de toda la columna.

En este trabajo, la clase DataFrame se construye a partir de un conjunto de objetos Series, todas con la misma longitud, o a partir de un diccionario que mapea nombres de columnas a secuencias de valores compatibles.

3.1 Inicialización

Para crear un DataFrame, se puede pasar:

  1. Un diccionario donde las claves son los nombres de las columnas y los valores son listas o instancias de Series.
  2. Una lista de Series, donde cada una tiene asignado un nombre (name) distinto.

El inicializador debe validar que todas las columnas tengan la misma cantidad de filas. Si alguna columna contiene valores nulos, estos se preservan.

Ejemplos

A partir de listas:

df = DataFrame({
    "x": [1, 2, 3, 4],
    "y": [10, 20, 30, 40]
})
df
# shape: (4, 2)
# ┌───┬────┐
# │ x │ y  │
# ├───┼────┤
# │ 1 │ 10 │
# │ 2 │ 20 │
# │ 3 │ 30 │
# │ 4 │ 40 │
# └───┴────┘

A partir de Series:

s1 = Series([1, 2, 3], name="x")
s2 = Series([True, False, True], name="condicion")

df = DataFrame([s1, s2])
df
# shape: (3, 2)
# ┌───┬───────────┐
# │ x │ condicion │
# ├───┼───────────┤
# │ 1 │ True      │
# │ 2 │ False     │
# │ 3 │ True      │
# └───┴───────────┘

3.2 Atributos

Los objetos DataFrame exponen los siguientes atributos públicos:

Atributo Descripción
columns Lista con los nombres de las columnas, en orden
dtypes Lista con los tipos de datos de las columnas, en orden
shape Dimensión de la tabla (filas, columnas)
schema Diccionario que mapea las columnas a sus tipos
height Cantidad de filas
width Cantidad de columnas

Ejemplos

df = DataFrame({
    "nombre": ["Ana", "Juan", "María", "Luna"],
    "edad": [25, 32, 29, 18],
    "activo": [True, False, True, True]
})

df.columns   # ["nombre", "edad", "activo"]
df.dtypes    # ["str", "int", "bool"]
df.shape     # (4, 3)
df.schema    # {"nombre": "str", "edad": "int", "activo": "bool"}
df.height    # 4
df.width     # 3

3.3 Métodos

Los DataFrame cuentan con un conjunto de métodos que permiten manipular y transformar los datos de manera sencilla.

Método Descripción
head(self, n=5) Devuelve un nuevo DataFrame con las primeras n filas.
tail(self, n=5) Devuelve un nuevo DataFrame con las últimas n filas.
select(self, *columns) Devuelve un nuevo DataFrame con solo las columnas indicadas.
filter(self, *predicates) Devuelve un DataFrame con las filas que cumplen todas las condiciones.
drop_nulls(self) Elimina todas las filas que contengan valores nulos.
sort(self, name, descending) Devuelve un nuevo DataFrame con las filas ordenadas según la columna name. Por defecto, descending es False.

Ejemplos

df = DataFrame({
    "x": [1, 2, 3, 4, 5, 6],
    "y": [10, 20, 30, 40, 50, 60],
    "z": ["a", "b", "c", "d", "e", "f"]
})

df.head(3)
# shape: (3, 3)
# ┌────┬────┬────┐
# │ x  │ y  │ z  │
# ├────┼────┼────┤
# │ 1  │ 10 │ a  │
# │ 2  │ 20 │ b  │
# │ 3  │ 30 │ c  │
# └────┴────┴────┘

```python
df.tail(1)
# shape: (1, 3)
# ┌────┬────┬────┐
# │ x  │ y  │ z  │
# ├────┼────┼────┤
# │ 6  │ 60 │ f  │
# └────┴────┴────┘
df.select("x", "z")
# shape: (6, 2)
# ┌────┬────┐
# │ x  │ z  │
# ├────┼────┤
# │ 1  │ a  │
# │ 2  │ b  │
# │ 3  │ c  │
# │ 4  │ d  │
# │ 5  │ e  │
# │ 6  │ f  │
# └────┴────┘

Para filtrar, se pasan tuplas de longitud dos. El primer valor es el nombre de la columna, y el segundo es una función que se aplica a cada elemento de esa columna y devuelve un valor booleano. Por ejemplo, se seleccionan las filas donde "x" es impar e "y" es mayor a 30.

df.filter(("x", lambda x: x % 2 != 0), ("y", lambda x: x > 30))
# shape: (1, 2)
# ┌────┬────┬────┐
# │ x  │ y  │ z  │
# ├────┼────┼────┤
# │ 5  │ 50 │ e  │
# └────┴────┴────┘
df = DataFrame({
    "a": [1, None, 3],
    "b": ["x", "y", None]
})
df.drop_nulls()
# shape: (1, 2)
# ┌────┬────┐
# │ a  │ b  │
# ├────┼────┤
# │ 1  │ x  │
# └────┴────┘
df = DataFrame({
    "x": [3, 1, 2],
    "y": [5, 15, 20]
})
df.sort("x")
# shape: (3, 2)
# ┌────┬────┐
# │ x  │ y  │
# ├────┼────┤
# │ 1  │ 15 │
# │ 2  │ 20 │
# │ 3  │ 5  │
# └────┴────┘

3.4 Métodos especiales

Estos métodos permiten interactuar con el DataFrame de manera natural y en sintonía con el comportamiento esperado de una colección de datos tabulares.

Método Descripción
__len__(self) Devuelve la cantidad de filas del DataFrame.
__repr__(self) Representación textual del DataFrame.
__getitem__(self, name) Devuelve la Series asociada a la columna name.
__setitem__(self, name, value) Agrega o sobreescribe la columna name con la Series value.

Ejemplos

El método especial __len__ permite obtener la cantidad de filas del DataFrame mediante la función built-in len():

df = DataFrame({
    "nombre": ["Ana", "Juan", "María"],
    "edad": [25, 32, 29]
})

len(df)
# 3

El método __repr__ define la representación textual del DataFrame, es decir, cómo se muestra al imprimirlo en la terminal o al evaluarlo en una celda interactiva.

df
# shape: (3, 2)
# ┌────────┬──────┐
# │ nombre │ edad │
# ├────────┼──────┤
# │ Ana    │ 25   │
# │ Juan   │ 32   │
# │ María  │ 29   │
# └────────┴──────┘

Cuando el DataFrame contiene más de diez filas, se muestran las primeras cinco y las últimas cinco, separadas por puntos suspensivos (...). Esto permite obtener una vista general del contenido sin saturar la salida.

# shape: (20, 2)
# ┌────────┬──────┐
# │ nombre │ edad │
# ├────────┼──────┤
# │ Ana    │ 25   │
# │ Juan   │ 32   │
# │ María  │ 29   │
# │ David  │ 24   │
# │ Pipo   │ 12   │
# │ ...    │ ...  │
# │ Laura  │ 45   │
# │ Marcos │ 17   │
# │ Lucas  │ 41   │
# │ Nora   │ 37   │
# │ Zoe    │ 23   │
# └────────┴──────┘

Por su parte, __getitem__ permite acceder al objeto Series de una columna por su nombre:

df["nombre"]
# Series: 'nombre'
# len: 3
# dtype: str
# [
#     Ana
#     Juan
#     María
# ]

En conjunto con __setitem__, permiten crear o modificar columnas en la tabla:

df["edad_meses"] = df["edad"] * 12
df
# shape: (3, 3)
# ┌────────┬──────┐────────────┐
# │ nombre │ edad │ edad_meses │
# ├────────┼──────┤────────────┤
# │ Ana    │ 25   │ 300        │
# │ Juan   │ 32   │ 384        │
# │ María  │ 29   │ 248        │
# └────────┴──────┘────────────┘

4 Pruebas

4.1 Pruebas para Series

Crear una serie y verificar operaciones básicas de acceso y longitud:

s = Series([10, 20, 30, 40, 50], name="valores")
s.head(3)
s.tail(2)
len(s)

Agregar y extender una serie, manteniendo el tipo de datos:

s1 = Series([1, 2, 3])
s2 = Series([4, 5])
s1.append(6)
s1.extend(s2)
s1

Filtrar valores según una condición y obtener sus índices:

s = Series([10, 25, 50, 75, 90, 100])
s.filter(lambda x: x < 60) # Una Series
s.where(lambda x: x % 25 == 0) # Una lista de enteros

Detectar y reemplazar valores nulos:

s = Series([5, None, 15, None])
s.is_null() # Una Series de booleanos
s.is_not_null() # Otra Series de booleanos
s.fill_null(0) # Una series de enteros

Ordenar y obtener índices de ordenamiento:

s = Series([42, 7, 100, 3])
s.sort() # Una serie de enteros
s.argsort() # Una lista de indices enteros

Combinar filtrado y agregaciones:

s = Series([5, 10, 15, 20, 25, 30])
s.filter(lambda x: x > 10).mean() # Promedio de los valores mayores a 10
s.filter(lambda x: x > 10).sum() # Suma de los valores mayores a 10

Iterar a través de la serie:

for x in Series(list("xyz")):
    print(x)

Determinar si la serie contiene un valor:

s = Series(["a", "a", "a", None, "z"])
"a" in s

4.2 Pruebas para DataFrame

Este conjunto de datos de ejemplo permite probar los distintos métodos implementados en las clases Series y DataFrame.

df = DataFrame({
    "id": list(range(1, 31)),
    "nombre": [f"persona_{i}" for i in range(1, 31)],
    "edad": [20 + (i % 15) for i in range(30)],
    "activo": [i % 2 == 0 for i in range(30)],
    "puntaje": [
        round(50 + (i * 1.5) % 25, 1) if i not in (4, 11, 19, 25) else None
        for i in range(30)
    ]
})

Verificar atributos:

df.height
df.width
df.shape
df.columns
df.schema

Seleccionar columnas:

df["edad"] # Devuelve una Series

Aplicar filtros sobre una o múltiples columnas:

df.filter(("edad", lambda e: e > 30))
df.filter(("activo", lambda a: a), ("puntaje", lambda p: p > 60))

Seleccionar subconjunto de columnas:

df.select("nombre", "puntaje")

Ordenar filas según una columna:

df.sort("edad")
df.sort("puntaje", descending=True)

Combinar varios métodos: filtrar, seleccionar una columna y calcular una agregación:

df.filter(("activo", lambda a: a))["puntaje"].mean()

Estandarizar el puntaje de las personas:

df["puntaje_z"] = (df["puntaje"] - df["puntaje"].mean()) / df["puntaje"].std()

5 Entregable

La entrega de este trabajo práctico debe consistir exclusivamente en un archivo comprimido (.zip) que contenga los siguientes archivos, con los nombres y contenidos indicados:

  1. series.py: implementación de la clase Series. Puede incluir un bloque de código de prueba, siempre que no se ejecute automáticamente al importar la clase desde otro módulo.
  2. dataframe.py: implementación de la clase DataFrame. También puede contener pruebas, que no deben ejecutarse al importar el módulo.
  3. test_series.py: incluye los ejemplos y pruebas correspondientes a la clase Series presentados en este trabajo práctico.
  4. test_dataframe.py: incluye los ejemplos y pruebas correspondientes a la clase DataFrame. Si se prueban Series obtenidas desde un DataFrame, también deben incluirse en este archivo.

No se deben agregar otros archivos ni utilizar nombres diferentes a los especificados.

El nombre del archivo comprimido debe seguir el formato: {Apellido}_{Nombre}.zip. Por ejemplo: Alvarez_Julian.zip.

6 Evaluación

La evaluación consta de dos partes:

  1. Ejecución y revisión del código: Se verificará que los archivos test_series.py y test_dataframe.py se ejecuten correctamente. Se valorará la presencia de comentarios que faciliten la comprensión de las pruebas, así como la correcta implementación de las clases Series y DataFrame en series.py y dataframe.py. También se considerarán la organización del código, su claridad, robustez y la reutilización de los métodos desarrollados. Esta instancia se califica de 0 a 10 y se aprueba con una nota mínima de 6. Quienes la aprueben pasarán a la etapa de presentación individual. Quienes la desaprueben podrán acceder al examen final en condición regular.

  2. Presentación individual: Cada estudiante deberá realizar una breve exposición (hasta 10 minutos) en la que describa cómo implementó las estructuras de datos y muestre la ejecución correcta de las pruebas. Luego, el docente realizará preguntas sobre el código desarrollado, que el estudiante deberá responder (hasta 10 minutos).

Nota importante

Los archivos test_series.py y test_dataframe.py deberían incluir todos los ejemplos y pruebas mencionados en este trabajo. Dado que la complejidad y extensión del trabajo puede dificultar este objetivo, se considerarán válidas las entregas que incluyan al menos el 80 % de las pruebas y que todas se ejecuten correctamente.

7 Ayuda

  • La Series necesita una estructura de datos subyacente donde almacenar valores, se recomienda usar una lista.
  • El DataFrame también necesita una estructura de datos subyacente, se recomienda un diccionario, donde las claves sean los nombres de las columnas y los valores las Series.
  • El método __repr__ de las Series y DataFrame requiere que nos amiguemos con los métodos de las cadenas de caracteres. Tengan presentes: .ljust(), .rjust() y .center().
  • Los métodos __getitem__, __setitem__ e __iter__ no han sido explorados a lo largo del curso. Pueden consultar el recurso Contenedores.
  • Los métodos where y argsort de Series pueden parecer confusos e inútiles, pero serán de gran ayuda a la hora de implementar filter y sort en DataFrame.
  • Este trabajo práctico describe la interface pública de las clases a implementar, no los detalles internos. Se recomienda que reutilicen, en la medida de lo posible, métodos y propiedades para reducir el trabajo y evitar errores. Por ejemplo, el método drop_nulls de DataFrame puede ser visto como un caso particular de filter.