Crear un contenedor personalizado

Beginner

This tutorial is from open-source community. Access the source code

Introducción

En este laboratorio, aprenderás sobre los contenedores de Python y la gestión de memoria. Explorarás cómo Python gestiona la memoria para las estructuras de datos integradas y descubrirás cómo crear una clase de contenedor personalizada que sea eficiente en términos de memoria.

Los objetivos de este laboratorio son examinar el comportamiento de asignación de memoria de las listas y diccionarios de Python, crear una clase de contenedor personalizada para optimizar el uso de memoria y comprender los beneficios del almacenamiento de datos orientado a columnas.

Este es un Guided Lab, que proporciona instrucciones paso a paso para ayudarte a aprender y practicar. Sigue las instrucciones cuidadosamente para completar cada paso y obtener experiencia práctica. Los datos históricos muestran que este es un laboratorio de nivel principiante con una tasa de finalización del 93%. Ha recibido una tasa de reseñas positivas del 97% por parte de los estudiantes.

Comprendiendo la asignación de memoria de las listas

En Python, las listas son una estructura de datos muy útil, especialmente cuando necesitas agregar elementos a ellas. Las listas de Python están diseñadas para ser eficientes en las operaciones de anexión. En lugar de asignar exactamente la cantidad de memoria necesaria, Python asigna memoria adicional anticipando futuras adiciones. Esta estrategia minimiza la cantidad de reasignaciones de memoria necesarias cuando la lista crece.

Comprendamos mejor este concepto utilizando la función sys.getsizeof(). Esta función devuelve el tamaño de un objeto en bytes, lo que nos ayuda a ver cuánta memoria está utilizando una lista en diferentes etapas.

  1. Primero, necesitas abrir una shell interactiva de Python en tu terminal. Esto es como un espacio de prueba donde puedes ejecutar código de Python de inmediato. Para abrirla, escribe el siguiente comando en tu terminal y presiona Enter:
python3
  1. Una vez que estés en la shell interactiva de Python, necesitas importar el módulo sys. Los módulos en Python son como cajas de herramientas que contienen funciones útiles. El módulo sys tiene la función getsizeof() que necesitamos. Después de importar el módulo, crea una lista vacía llamada items. Aquí está el código para hacerlo:
import sys
items = []
  1. Ahora, veamos el tamaño inicial de la lista vacía. Usaremos la función sys.getsizeof() con la lista items como argumento. Escribe el siguiente código en la shell interactiva de Python y presiona Enter:
sys.getsizeof(items)

Deberías ver un valor como 64 bytes. Este valor representa la sobrecarga de una lista vacía. La sobrecarga es la cantidad básica de memoria que Python utiliza para gestionar la lista, incluso cuando no tiene elementos.

  1. A continuación, comenzaremos a agregar elementos a la lista uno por uno y observaremos cómo cambia la asignación de memoria. Usaremos el método append() para agregar un elemento a la lista y luego comprobaremos el tamaño nuevamente. Aquí está el código:
items.append(1)
sys.getsizeof(items)

Deberías ver un valor mayor, alrededor de 96 bytes. Este aumento de tamaño muestra que Python ha asignado más memoria para acomodar el nuevo elemento.

  1. Continuemos agregando más elementos a la lista y comprobemos el tamaño después de cada adición. Aquí está el código para hacerlo:
items.append(2)
sys.getsizeof(items)  ## El tamaño sigue siendo el mismo

items.append(3)
sys.getsizeof(items)  ## El tamaño sigue sin cambiar

items.append(4)
sys.getsizeof(items)  ## El tamaño sigue sin cambiar

items.append(5)
sys.getsizeof(items)  ## El tamaño salta a un valor mayor

Notarás que el tamaño no aumenta con cada operación de anexión. En lugar de eso, aumenta periódicamente. Esto demuestra que Python está asignando memoria en bloques en lugar de individualmente para cada nuevo elemento.

Este comportamiento es intencional. Python asigna más memoria de la necesaria inicialmente para evitar reasignaciones frecuentes a medida que la lista crece. Cada vez que la lista supera su capacidad actual, Python asigna un bloque de memoria más grande.

Recuerda que una lista almacena referencias a objetos, no los objetos en sí. En un sistema de 64 bits, cada referencia generalmente requiere 8 bytes de memoria. Esto es importante de entender porque afecta cuánta memoria realmente utiliza una lista cuando contiene diferentes tipos de objetos.

Asignación de memoria de los diccionarios

En Python, al igual que las listas, los diccionarios son una estructura de datos fundamental. Un aspecto importante que debes entender sobre ellos es cómo asignan memoria. La asignación de memoria se refiere a cómo Python reserva espacio en la memoria del ordenador para almacenar los datos de tu diccionario. Al igual que las listas, los diccionarios de Python también asignan memoria en bloques. Exploremos cómo funciona la asignación de memoria con los diccionarios.

  1. Primero, necesitamos crear un diccionario con el que trabajar. En la misma shell de Python (o abre una nueva si la cerraste), crearemos un diccionario que represente un registro de datos. Un diccionario en Python es una colección de pares clave - valor, donde cada clave es única y se utiliza para acceder a su valor correspondiente.
import sys  ## Importa sys si estás comenzando una nueva sesión
row = {'route': '22', 'date': '01/01/2001', 'daytype': 'U', 'rides': 7354}

Aquí, hemos importado el módulo sys que proporciona acceso a algunas variables utilizadas o mantenidas por el intérprete de Python y a funciones que interactúan fuertemente con el intérprete. Luego, creamos un diccionario llamado row con cuatro pares clave - valor.

  1. Ahora que tenemos nuestro diccionario, queremos comprobar su tamaño inicial. El tamaño de un diccionario se refiere a la cantidad de memoria que ocupa en el ordenador.
sys.getsizeof(row)

La función sys.getsizeof() devuelve el tamaño de un objeto en bytes. Cuando ejecutes este código, deberías ver un valor alrededor de 240 bytes. Esto te da una idea de cuánta memoria ocupa el diccionario inicialmente.

  1. A continuación, agregaremos nuevos pares clave - valor al diccionario y observaremos cómo cambia la asignación de memoria. Agregar elementos a un diccionario es una operación común, y entender cómo afecta a la memoria es crucial.
row['a'] = 1
sys.getsizeof(row)  ## El tamaño puede permanecer igual

row['b'] = 2
sys.getsizeof(row)  ## El tamaño puede aumentar

Cuando agregas el primer par clave - valor ('a': 1), el tamaño del diccionario puede permanecer igual. Esto se debe a que Python ya ha asignado un cierto bloque de memoria, y puede haber suficiente espacio en ese bloque para acomodar el nuevo elemento. Sin embargo, cuando agregas el segundo par clave - valor ('b': 2), el tamaño puede aumentar. Notarás que después de agregar un cierto número de elementos, el tamaño del diccionario aumenta repentinamente. Esto se debe a que los diccionarios, como las listas, asignan memoria en bloques para optimizar el rendimiento. Asignar memoria en bloques reduce la cantidad de veces que Python tiene que solicitar más memoria al sistema, lo que acelera el proceso de agregar nuevos elementos.

  1. Intentemos eliminar un elemento del diccionario para ver si el uso de memoria disminuye. Eliminar elementos de un diccionario también es una operación común, y es interesante ver cómo afecta a la memoria.
del row['b']
sys.getsizeof(row)

Curiosamente, eliminar un elemento generalmente no reduce la asignación de memoria. Esto se debe a que Python conserva la memoria asignada para evitar reasignarla si se agregan elementos nuevamente. Reasignar memoria es una operación relativamente costosa en términos de rendimiento, por lo que Python intenta evitarla tanto como sea posible.

Consideraciones sobre la eficiencia de memoria:

Cuando trabajas con grandes conjuntos de datos donde necesitas crear muchos registros, usar diccionarios para cada registro puede no ser el enfoque más eficiente en términos de memoria. Los diccionarios son muy flexibles y fáciles de usar, pero pueden consumir una cantidad significativa de memoria, especialmente cuando se trata de un gran número de registros. Aquí hay algunas alternativas que consumen menos memoria:

  • Tuplas: Secuencias inmutables simples. Una tupla es una colección de valores que no se pueden cambiar después de su creación. Utiliza menos memoria que un diccionario porque no necesita almacenar claves ni gestionar la correspondiente asignación clave - valor.
  • Tuplas con nombres (Named tuples): Tuplas con nombres de campos. Las tuplas con nombres son similares a las tuplas normales, pero te permiten acceder a los valores por nombre, lo que puede hacer el código más legible. También utilizan menos memoria que los diccionarios.
  • Clases con __slots__: Clases que definen explícitamente atributos para evitar usar un diccionario para las variables de instancia. Cuando se utiliza __slots__ en una clase, Python no crea un diccionario para almacenar las variables de instancia, lo que reduce el uso de memoria.

Estas alternativas pueden reducir significativamente el uso de memoria cuando se manejan muchos registros.

Optimización de la memoria con datos orientados a columnas

En el almacenamiento de datos tradicional, a menudo almacenamos cada registro como un diccionario separado, lo que se conoce como enfoque orientado a filas (row-oriented approach). Sin embargo, este método puede consumir una cantidad significativa de memoria. Una forma alternativa es almacenar los datos en columnas. En el enfoque orientado a columnas, creamos listas separadas para cada atributo, y cada lista contiene todos los valores de ese atributo específico. Esto puede ayudarnos a ahorrar memoria.

  1. Primero, necesitas crear un nuevo archivo de Python en el directorio de tu proyecto. Este archivo contendrá el código para leer datos de manera orientada a columnas. Nombrar el archivo readrides.py. Puedes usar los siguientes comandos en la terminal para lograr esto:
cd ~/project
touch readrides.py

El comando cd ~/project cambia el directorio actual al directorio de tu proyecto, y el comando touch readrides.py crea un nuevo archivo vacío llamado readrides.py.

  1. A continuación, abre el archivo readrides.py en el editor WebIDE. Luego, agrega el siguiente código de Python al archivo. Este código define una función read_rides_as_columns que lee los datos de viajes en autobús desde un archivo CSV y los almacena en cuatro listas separadas, cada una representando una columna de datos.
## readrides.py
import csv
import sys
import tracemalloc

def read_rides_as_columns(filename):
    '''
    Read the bus ride data into 4 lists, representing columns
    '''
    routes = []
    dates = []
    daytypes = []
    numrides = []
    with open(filename) as f:
        rows = csv.reader(f)
        headings = next(rows)     ## Skip headers
        for row in rows:
            routes.append(row[0])
            dates.append(row[1])
            daytypes.append(row[2])
            numrides.append(int(row[3]))
    return dict(routes=routes, dates=dates, daytypes=daytypes, numrides=numrides)

En este código, primero importamos los módulos necesarios csv, sys y tracemalloc. El módulo csv se utiliza para leer archivos CSV, sys se puede utilizar para operaciones relacionadas con el sistema (aunque no se utiliza en esta función), y tracemalloc se utiliza para el análisis de memoria. Dentro de la función, inicializamos cuatro listas vacías para almacenar diferentes columnas de datos. Luego, abrimos el archivo, omitimos la fila de encabezados y recorremos cada fila del archivo, anexando los valores correspondientes a las listas adecuadas. Finalmente, devolvemos un diccionario que contiene estas cuatro listas.

  1. Ahora, analicemos por qué el enfoque orientado a columnas puede ahorrar memoria. Lo haremos en la shell de Python. Ejecuta el siguiente código:
import readrides
import tracemalloc

## Estimate memory for row-oriented approach
nrows = 577563     ## Number of rows in original file
dict_overhead = 240  ## Approximate dictionary overhead in bytes
row_memory = nrows * dict_overhead
print(f"Estimated memory for row-oriented data: {row_memory} bytes ({row_memory/1024/1024:.2f} MB)")

## Estimate memory for column-oriented approach
pointer_size = 8   ## Size of a pointer in bytes on 64-bit systems
column_memory = nrows * 4 * pointer_size  ## 4 columns with one pointer per entry
print(f"Estimated memory for column-oriented data: {column_memory} bytes ({column_memory/1024/1024:.2f} MB)")

## Estimate savings
savings = row_memory - column_memory
print(f"Estimated memory savings: {savings} bytes ({savings/1024/1024:.2f} MB)")

En este código, primero importamos el módulo readrides que acabamos de crear y el módulo tracemalloc. Luego, estimamos el uso de memoria para el enfoque orientado a filas. Asumimos que cada diccionario tiene una sobrecarga de 240 bytes, y multiplicamos esto por el número de filas en el archivo original para obtener el uso total de memoria para los datos orientados a filas. Para el enfoque orientado a columnas, asumimos que en un sistema de 64 bits, cada puntero ocupa 8 bytes. Dado que tenemos 4 columnas y un puntero por entrada, calculamos el uso total de memoria para los datos orientados a columnas. Finalmente, calculamos el ahorro de memoria restando el uso de memoria orientado a columnas del uso de memoria orientado a filas.

Este cálculo muestra que el enfoque orientado a columnas debería ahorrar alrededor de 120 MB de memoria en comparación con el enfoque orientado a filas con diccionarios.

  1. Verifiquemos esto midiendo el uso real de memoria con el módulo tracemalloc. Ejecuta el siguiente código:
## Start tracking memory
tracemalloc.start()

## Read the data
columns = readrides.read_rides_as_columns('ctabus.csv')

## Get current and peak memory usage
current, peak = tracemalloc.get_traced_memory()
print(f"Current memory usage: {current/1024/1024:.2f} MB")
print(f"Peak memory usage: {peak/1024/1024:.2f} MB")

## Stop tracking memory
tracemalloc.stop()

En este código, primero comenzamos a realizar un seguimiento de la memoria utilizando tracemalloc.start(). Luego, llamamos a la función read_rides_as_columns para leer los datos del archivo ctabus.csv. Después, usamos tracemalloc.get_traced_memory() para obtener el uso actual y máximo de memoria. Finalmente, detenemos el seguimiento de la memoria utilizando tracemalloc.stop().

La salida mostrará el uso real de memoria de tu estructura de datos orientada a columnas. Esto debería ser significativamente menor que nuestra estimación teórica para el enfoque orientado a filas.

El importante ahorro de memoria proviene de eliminar la sobrecarga de miles de objetos de diccionario. Cada diccionario en Python tiene una sobrecarga fija independientemente de cuántos elementos contenga. Al utilizar un almacenamiento orientado a columnas, solo necesitamos unas pocas listas en lugar de miles de diccionarios.

Creación de una clase de contenedor personalizada

En el procesamiento de datos, el enfoque orientado a columnas es excelente para ahorrar memoria. Sin embargo, puede causar problemas cuando tu código existente espera que los datos estén en forma de una lista de diccionarios. Para resolver este problema, crearemos una clase de contenedor personalizada. Esta clase presentará una interfaz orientada a filas, lo que significa que parecerá y actuará como una lista de diccionarios para tu código. Pero internamente, almacenará los datos en un formato orientado a columnas, lo que nos ayudará a ahorrar memoria.

  1. Primero, abre el archivo readrides.py en el editor WebIDE. Vamos a agregar una nueva clase a este archivo. Esta clase será la base de nuestro contenedor personalizado.
## Add this to readrides.py
from collections.abc import Sequence

class RideData(Sequence):
    def __init__(self):
        ## Each value is a list with all of the values (a column)
        self.routes = []
        self.dates = []
        self.daytypes = []
        self.numrides = []

    def __len__(self):
        ## All lists assumed to have the same length
        return len(self.routes)

    def __getitem__(self, index):
        return {'route': self.routes[index],
                'date': self.dates[index],
                'daytype': self.daytypes[index],
                'rides': self.numrides[index]}

    def append(self, d):
        self.routes.append(d['route'])
        self.dates.append(d['date'])
        self.daytypes.append(d['daytype'])
        self.numrides.append(d['rides'])

En este código, definimos una clase llamada RideData que hereda de Sequence. El método __init__ inicializa cuatro listas vacías, cada una representando una columna de datos. El método __len__ devuelve la longitud del contenedor, que es la misma que la longitud de la lista routes. El método __getitem__ nos permite acceder a un registro específico por índice, devolviéndolo como un diccionario. El método append agrega un nuevo registro al contenedor anexando valores a cada lista de columnas.

  1. Ahora, necesitamos una función para leer los datos de viajes en autobús en nuestro contenedor personalizado. Agrega la siguiente función al archivo readrides.py.
## Add this to readrides.py
def read_rides_as_dicts(filename):
    '''
    Read the bus ride data as a list of dicts, but use our custom container
    '''
    records = RideData()
    with open(filename) as f:
        rows = csv.reader(f)
        headings = next(rows)     ## Skip headers
        for row in rows:
            route = row[0]
            date = row[1]
            daytype = row[2]
            rides = int(row[3])
            record = {
                'route': route,
                'date': date,
                'daytype': daytype,
                'rides': rides
            }
            records.append(record)
    return records

Esta función crea una instancia de la clase RideData y la llena con datos del archivo CSV. Lee cada fila del archivo, extrae la información relevante, crea un diccionario para cada registro y luego lo anexa al contenedor RideData. Lo importante es que mantiene la misma interfaz que una lista de diccionarios, pero internamente almacena los datos en columnas.

  1. Probemos nuestro contenedor personalizado en la shell de Python. Esto nos ayudará a verificar que funciona como se espera.
import readrides

## Read the data using our custom container
rows = readrides.read_rides_as_dicts('ctabus.csv')

## Check the type of the returned object
type(rows)  ## Should be readrides.RideData

## Check the length
len(rows)   ## Should be 577563

## Access individual records
rows[0]     ## Should return a dictionary for the first record
rows[1]     ## Should return a dictionary for the second record
rows[2]     ## Should return a dictionary for the third record

Nuestro contenedor personalizado implementa con éxito la interfaz Sequence, lo que significa que se comporta como una lista. Puedes usar la función len() para obtener el número de registros en el contenedor, y puedes usar la indexación para acceder a registros individuales. Cada registro parece ser un diccionario, aunque los datos se almacenen internamente en columnas. Esto es genial porque el código existente que espera una lista de diccionarios seguirá funcionando con nuestro contenedor personalizado sin ninguna modificación.

  1. Finalmente, midamos el uso de memoria de nuestro contenedor personalizado. Esto nos mostrará cuánta memoria estamos ahorrando en comparación con una lista de diccionarios.
import tracemalloc

tracemalloc.start()
rows = readrides.read_rides_as_dicts('ctabus.csv')
current, peak = tracemalloc.get_traced_memory()
print(f"Current memory usage: {current/1024/1024:.2f} MB")
print(f"Peak memory usage: {peak/1024/1024:.2f} MB")
tracemalloc.stop()

Cuando ejecutes este código, deberías ver que el uso de memoria es similar al enfoque orientado a columnas, que es mucho menor que el que utilizaría una lista de diccionarios. Esto demuestra la ventaja de nuestro contenedor personalizado en términos de eficiencia de memoria.

Mejorando el contenedor personalizado para el rebanado (slicing)

Nuestro contenedor personalizado es excelente para acceder a registros individuales. Sin embargo, hay un problema cuando se trata de hacer rebanados (slicing). Cuando intentas tomar una rebanada de nuestro contenedor, el resultado no es lo que normalmente esperarías.

Entendamos por qué sucede esto. En Python, el rebanado es una operación común utilizada para extraer una porción de una secuencia. Pero para nuestro contenedor personalizado, Python no sabe cómo crear un nuevo objeto RideData solo con los datos rebanados. En lugar de eso, crea una lista que contiene los resultados de llamar a __getitem__ para cada índice en la rebanada.

  1. Probemos el rebanado en la shell de Python:
import readrides

rows = readrides.read_rides_as_dicts('ctabus.csv')
r = rows[0:10]  ## Take a slice of the first 10 records
type(r)  ## This will likely be a list, not a RideData object
print(r)  ## This might look like a list of numbers, not dictionaries

En este código, primero importamos el módulo readrides. Luego leemos los datos del archivo ctabus.csv en una variable rows. Cuando intentamos tomar una rebanada de los primeros 10 registros y comprobamos el tipo del resultado, encontramos que es una lista en lugar de un objeto RideData. Imprimir el resultado puede mostrar algo inesperado, como una lista de números en lugar de diccionarios.

  1. Modifiquemos nuestra clase RideData para manejar el rebanado adecuadamente. Abrir readrides.py y actualizar el método __getitem__:
def __getitem__(self, index):
    if isinstance(index, slice):
        ## Handle slice
        result = RideData()
        result.routes = self.routes[index]
        result.dates = self.dates[index]
        result.daytypes = self.daytypes[index]
        result.numrides = self.numrides[index]
        return result
    else:
        ## Handle single index
        return {'route': self.routes[index],
                'date': self.dates[index],
                'daytype': self.daytypes[index],
                'rides': self.numrides[index]}

En este método __getitem__ actualizado, primero comprobamos si el index es una rebanada. Si lo es, creamos un nuevo objeto RideData llamado result. Luego llenamos este nuevo objeto con rebanadas de las columnas de datos originales (routes, dates, daytypes y numrides). Esto asegura que cuando rebanamos nuestro contenedor personalizado, obtengamos otro objeto RideData en lugar de una lista. Si el index no es una rebanada (es decir, es un solo índice), devolvemos un diccionario que contiene el registro relevante.

  1. Probemos nuestra capacidad mejorada de rebanado:
import readrides

rows = readrides.read_rides_as_dicts('ctabus.csv')
r = rows[0:10]  ## Take a slice of the first 10 records
type(r)  ## Should now be readrides.RideData
len(r)   ## Should be 10
r[0]     ## Should be the same as rows[0]
r[1]     ## Should be the same as rows[1]

Después de actualizar el método __getitem__, podemos probar el rebanado nuevamente. Cuando tomamos una rebanada de los primeros 10 registros, el tipo del resultado ahora debe ser readrides.RideData. La longitud de la rebanada debe ser 10, y acceder a elementos individuales en la rebanada debe darnos los mismos resultados que acceder a los elementos correspondientes en el contenedor original.

  1. También puedes probar con diferentes patrones de rebanado:
## Get every other record from the first 20
r2 = rows[0:20:2]
len(r2)  ## Should be 10

## Get the last 10 records
r3 = rows[-10:]
len(r3)  ## Should be 10

Aquí, estamos probando diferentes patrones de rebanado. La primera rebanada rows[0:20:2] obtiene cada otro registro de los primeros 20 registros, y la longitud de la rebanada resultante debe ser 10. La segunda rebanada rows[-10:] obtiene los últimos 10 registros, y su longitud también debe ser 10.

Al implementar adecuadamente el rebanado, nuestro contenedor personalizado ahora se comporta aún más como una lista estándar de Python, mientras mantiene la eficiencia de memoria del almacenamiento orientado a columnas.

Este enfoque de crear clases de contenedores personalizados que imitan los contenedores incorporados de Python pero con diferentes representaciones internas es una técnica poderosa para optimizar el uso de memoria sin cambiar la interfaz que tu código presenta a los usuarios.

Resumen

En este laboratorio, has aprendido varias habilidades importantes. En primer lugar, has explorado el comportamiento de la asignación de memoria en las listas y diccionarios de Python y has aprendido a optimizar el uso de memoria al cambiar del almacenamiento de datos orientado a filas al orientado a columnas. En segundo lugar, has creado una clase de contenedor personalizada que mantiene la interfaz original mientras utiliza menos memoria y la has mejorado para manejar adecuadamente las operaciones de rebanado (slicing).

Estas técnicas son muy valiosas para trabajar con conjuntos de datos grandes, ya que pueden reducir significativamente el uso de memoria sin alterar la interfaz de tu código. La capacidad de crear clases de contenedores personalizados que imitan los contenedores incorporados de Python con diferentes representaciones internas es una poderosa herramienta de optimización para aplicaciones de Python. Puedes aplicar estos conceptos a otros proyectos críticos en términos de memoria, especialmente aquellos que involucren conjuntos de datos grandes y con estructura regular.