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]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.252.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)
# 100Por su parte, __contains__(self, item) se ejecuta cuando utilizamos el operador in, de esta manera:
20 in s
# True
200 in s
# FalseEl 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] # 10Finalmente, 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
# 303 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:
- Un diccionario donde las claves son los nombres de las columnas y los valores son listas o instancias de
Series. - 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 # 33.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)
# 3El 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)
s1Filtrar 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 enterosDetectar 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 enterosOrdenar y obtener índices de ordenamiento:
s = Series([42, 7, 100, 3])
s.sort() # Una serie de enteros
s.argsort() # Una lista de indices enterosCombinar 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 10Iterar 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 s4.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.schemaSeleccionar columnas:
df["edad"] # Devuelve una SeriesAplicar 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:
series.py: implementación de la claseSeries. Puede incluir un bloque de código de prueba, siempre que no se ejecute automáticamente al importar la clase desde otro módulo.dataframe.py: implementación de la claseDataFrame. También puede contener pruebas, que no deben ejecutarse al importar el módulo.test_series.py: incluye los ejemplos y pruebas correspondientes a la claseSeriespresentados en este trabajo práctico.test_dataframe.py: incluye los ejemplos y pruebas correspondientes a la claseDataFrame. Si se pruebanSeriesobtenidas desde unDataFrame, 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:
Ejecución y revisión del código: Se verificará que los archivos
test_series.pyytest_dataframe.pyse ejecuten correctamente. Se valorará la presencia de comentarios que faciliten la comprensión de las pruebas, así como la correcta implementación de las clasesSeriesyDataFrameenseries.pyydataframe.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.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).
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
Seriesnecesita una estructura de datos subyacente donde almacenar valores, se recomienda usar una lista. - El
DataFrametambién necesita una estructura de datos subyacente, se recomienda un diccionario, donde las claves sean los nombres de las columnas y los valores lasSeries. - El método
__repr__de lasSeriesyDataFramerequiere 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
whereyargsortdeSeriespueden parecer confusos e inútiles, pero serán de gran ayuda a la hora de implementarfilterysortenDataFrame. - 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_nullsdeDataFramepuede ser visto como un caso particular defilter.