UdelaR  ·  Máster en Análisis de Datos

Introducción a Python

Clase 7 — Módulos, paquetes y el ecosistema de datos

Asignatura  Introducción a Python Profesor  Daniel Miles Touya Universidad  UdelaR-Uvigo
Parte I

Módulos y paquetes: el sistema de importación

Qué es un módulo y cómo importarlo teoría

Un módulo es simplemente un fichero con extensión .py que contiene código Python — funciones, variables, clases. Cuando escribes import os, Python busca un fichero llamado os.py — o una carpeta llamada os con código dentro — y lo ejecuta. Todo lo que ese fichero define queda disponible bajo el nombre os.

El sistema de módulos resuelve un problema concreto: cómo organizar código grande en piezas reutilizables sin que los nombres de unas piezas interfieran con los de otras. Cada módulo tiene su propio espacio de nombres — su propio scope global — completamente aislado.

— Las tres formas de importar —

# Forma 1: import módulo
# Entra en el namespace solo el nombre del módulo
import os

print(type(os))         # 
print(os.getcwd())      # acceso con punto — os.función
print(os.path.sep)      # os.submodulo.atributo

# ¿Qué entró en el namespace global?
print("os" in globals())        # True — solo el nombre os
print("getcwd" in globals())    # False — no entró getcwd

print("---")

# Forma 2: from módulo import nombre
# Entra en el namespace solo lo que se especifica
from os import getcwd, listdir

print(getcwd())         # sin prefijo — está en el namespace local
print(type(listdir))   # 

# ¿Qué entró ahora?
print("getcwd" in globals())    # True — entró directamente
print("listdir" in globals())   # True

print("---")

# Forma 3: from módulo import *
# Entra TODO lo que el módulo define — peligroso
from os.path import *

# ¿Cuántas cosas entraron?
import os.path
nombres_os_path = dir(os.path)
print(f"os.path define {len(nombres_os_path)} nombres")
# Todos entraron en tu namespace — potencial para conflictos
Por qué from módulo import * es peligroso: importa todos los nombres del módulo en tu namespace — incluyendo nombres genéricos como join, split, open o path. Si ya tenías una variable con ese nombre, la sobrescribe silenciosamente. El error resultante es muy difícil de detectar porque no hay mensaje de error — solo un comportamiento inesperado. La única excepción aceptable es en sesiones interactivas exploratorias — nunca en código de producción.

— La sintaxis uno.dos.tres: acceso a atributos anidados —

La sintaxis con punto que ves en os.path.join() o en pd.DataFrame.head() no es sintaxis especial de módulos — es exactamente el mismo acceso a atributos que ya conoces de la Clase 6a. os es un objeto, os.path accede al atributo path de ese objeto — que a su vez es otro objeto —, y os.path.join accede al atributo join de ese segundo objeto. Tres niveles de acceso a atributos — nada más.

import os

# os es un objeto — tiene tipo, atributos, métodos
print(type(os))           # 
print(type(os.path))      #  — también un objeto
print(type(os.path.join)) # 

print("---")

# La sintaxis punto es siempre acceso a atributo
# Estos tres son exactamente equivalentes en concepto:
lista = [1, 2, 3]
lista.append(4)           # objeto.método()
os.getcwd()               # módulo.función()
os.path.join("a", "b")   # módulo.submódulo.función()

print("---")

# Descomponer os.path.join paso a paso
nivel_1 = os           # el módulo os
nivel_2 = os.path      # el submódulo path de os
nivel_3 = os.path.join # la función join de path

print(nivel_1.__name__)   # 'os'
print(nivel_2.__name__)   # 'posixpath' o 'ntpath' según el SO
print(nivel_3.__name__)   # 'join'

# Llamar a través de la referencia — igual que cualquier objeto
ruta = nivel_3("datos", "ventas", "2024.csv")
print(ruta)   # datos/ventas/2024.csv o datos\ventas\2024.csv

— Alias con as: por qué pd y np —

La palabra clave as crea un alias — importa el módulo bajo un nombre distinto. Las convenciones import pandas as pd e import numpy as np son universales en el ecosistema de datos Python. Todo el mundo las usa — la IA también. No son obligatorias pero no seguirlas genera confusión.

# Alias — el módulo se importa con un nombre distinto
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# pd, np, plt son exactamente los mismos objetos
# — solo con nombres más cortos
print(type(pd))   # 
print(pd.__name__)   # 'pandas' — el nombre original del módulo

print("---")

# Las convenciones estándar del ecosistema
convenciones = {
    "pandas":             "pd",
    "numpy":              "np",
    "matplotlib.pyplot":  "plt",
    "seaborn":            "sns",
    "statsmodels.api":    "sm",
    "sklearn":            "sk",   # menos estandarizado
}

for modulo, alias in convenciones.items():
    print(f"import {modulo} as {alias}")

print("---")

# También se puede dar alias a nombres específicos
from os.path import join as path_join
from os.path import exists as existe

print(path_join("datos", "fichero.csv"))
print(existe("datos/fichero.csv"))

— __all__: qué exporta un módulo —

# __all__ es una lista que define qué se exporta
# cuando alguien hace from módulo import *
# Los módulos bien escritos lo definen

import os
import pandas as pd

# Algunos módulos definen __all__
print(hasattr(os, "__all__"))   # False — os no lo define
print(hasattr(pd, "__all__"))   # True — pandas sí lo define
print(len(pd.__all__))           # número de nombres exportados
print(pd.__all__[:5])           # primeros 5
  1. ¿Qué diferencia hay entre estos tres bloques en términos de qué entra en el namespace? Usa globals() para verificarlo:
    # Bloque A
    import os
    
    # Bloque B
    from os import getcwd
    
    # Bloque C
    from os import getcwd as directorio_actual
  2. ¿Por qué este código produce un resultado inesperado? ¿Cómo lo corregirías?
    join = "unión"   # variable propia
    print(join)
    
    from os.path import *   # importa todo
    print(join)              # ¿qué imprime ahora?
  3. Descompón esta línea de código de IA en sus tres niveles de acceso a atributos. ¿Qué tipo tiene cada nivel?
    import os
    ruta = os.path.join("datos", "2024", "ventas.csv")
  4. ¿Por qué la convención import pandas as pd es tan universal? ¿Qué pasaría si cada programador usara un alias distinto? ¿Qué ventaja tiene que sea un estándar?
Respuesta orientativa
a

Bloque A: entra solo os — el objeto módulo completo. Para acceder a getcwd hay que escribir os.getcwd().

Bloque B: entra getcwd directamente en el namespace. Se puede llamar sin prefijo. El nombre os no entra.

Bloque C: entra directorio_actual — un alias para getcwd. Ni os ni getcwd entran en el namespace.

b

El segundo print(join) imprime la función os.path.join — no el string "unión". from os.path import * sobrescribió la variable join con la función del mismo nombre del módulo. No hay error — solo un comportamiento silencioso e inesperado. La corrección: usar import os.path y acceder con os.path.join, o usar un alias: from os.path import join as path_join.

c

Nivel 1: os — tipo module. El módulo del sistema operativo.

Nivel 2: os.path — tipo module. El submódulo de rutas de sistema de ficheros — posixpath en Linux/Mac, ntpath en Windows.

Nivel 3: os.path.join — tipo builtin_function_or_method. La función que une segmentos de ruta con el separador correcto para el sistema.

d

La convención es universal porque la documentación oficial, los tutoriales, Stack Overflow y la propia IA usan siempre pd y np. Si cada programador usara un alias distinto — p, pan, pandas — el código sería incompatible visualmente: copiar un fragmento de Stack Overflow en tu código fallaría si usas un alias distinto. El estándar convierte el alias en parte del lenguaje — al ver pd.DataFrame cualquier programador de datos sabe exactamente qué es sin leer el import.

Paquetes y módulos de usuario: organizar y reutilizar código propio teoría

Un paquete es una carpeta que contiene módulos Python. Para que Python la reconozca como paquete — y no como una carpeta cualquiera — debe contener un fichero especial llamado __init__.py, que puede estar vacío o contener código de inicialización.

La jerarquía de nombres refleja la jerarquía de carpetas. pandas.core.frame corresponde a la carpeta pandas/, dentro la carpeta core/, dentro el fichero frame.py. Cada punto en el nombre es un nivel de carpeta.

— Estructura típica de un proyecto con módulos propios —

proyecto/
├── main.py ← script principal
├── utilidades/ ← paquete propio
│ ├── __init__.py ← hace que sea paquete
│ ├── limpieza.py ← módulo de limpieza
│ └── estadisticas.py ← módulo estadístico
├── datos/ ← carpeta de datos
│ └── ventas.csv
└── notebooks/ ← análisis exploratorio
# Contenido de utilidades/limpieza.py
# ────────────────────────────────────
# import pandas as pd
#
# def quitar_nulos(df):
#     """Elimina filas con valores nulos."""
#     return df.dropna()
#
# def normalizar_columna(df, columna):
#     """Normaliza una columna entre 0 y 1."""
#     minimo = df[columna].min()
#     maximo = df[columna].max()
#     df[columna] = (df[columna] - minimo) / (maximo - minimo)
#     return df

# Desde main.py — importar el módulo propio
from utilidades import limpieza
from utilidades.limpieza import quitar_nulos
from utilidades.estadisticas import calcular_media

# Usar las funciones propias igual que las de cualquier librería
# df_limpio = quitar_nulos(df)
# media = calcular_media(df_limpio, "ventas")

— Importar un fichero propio: el caso más frecuente —

En análisis de datos es muy frecuente tener un fichero con funciones auxiliares — limpieza de datos, transformaciones, visualizaciones — y querer importarlo desde un script o un notebook. Python lo trata exactamente igual que cualquier otro módulo siempre que el fichero esté en un lugar donde Python lo busca.

# Supongamos que tenemos mis_funciones.py en la misma carpeta
# con este contenido:
#
# def calcular_iva(precio, tipo=0.21):
#     return round(precio * (1 + tipo), 2)
#
# def es_outlier(valor, datos):
#     media = sum(datos) / len(datos)
#     desv  = (sum((x-media)**2 for x in datos)/len(datos))**0.5
#     return abs(valor - media) > 2 * desv

# Importarlo es exactamente igual que importar cualquier módulo
import mis_funciones

print(type(mis_funciones))          # 
print(mis_funciones.__file__)       # ruta al fichero .py
print(dir(mis_funciones))           # todo lo que define

print(mis_funciones.calcular_iva(100))     # 121.0
print(mis_funciones.calcular_iva(100, 0.10))  # 110.0

print("---")

# O importar solo lo que se necesita
from mis_funciones import calcular_iva, es_outlier

precios = [10, 25, 100, 8, 15]
print([calcular_iva(p) for p in precios])
print(es_outlier(100, precios))   # True — es un outlier

— Cuando Python no encuentra el módulo: sys.path —

Cuando Python recibe import mis_funciones, busca el fichero en una lista de carpetas llamada sys.path. Si el fichero no está en ninguna de esas carpetas, lanza ModuleNotFoundError. La carpeta actual del script siempre está en sys.path — por eso los módulos propios en la misma carpeta se encuentran sin configuración. Si el módulo está en otro lugar, hay que añadir esa carpeta a sys.path.

import sys

# Ver dónde busca Python
print("Python busca módulos en:")
for ruta in sys.path:
    print(f"  {ruta}")

print("---")

# Si el módulo está en otra carpeta — añadirla a sys.path
import sys
import os

# Añadir la carpeta que contiene el módulo
carpeta_utils = os.path.join(os.getcwd(), "utilidades")
if carpeta_utils not in sys.path:
    sys.path.insert(0, carpeta_utils)

# Ahora Python encontrará los módulos de esa carpeta
# import limpieza  ← funcionaría aunque no esté en la carpeta actual

print(f"Añadida: {carpeta_utils}")
print(f"sys.path[0]: {sys.path[0]}")

print("---")

# ModuleNotFoundError — qué hacer cuando aparece
try:
    import modulo_que_no_existe
except ModuleNotFoundError as e:
    print(f"ModuleNotFoundError: {e}")
    print("Posibles causas:")
    print("  1. El módulo no está instalado — pip install nombre")
    print("  2. El fichero .py no está en ninguna carpeta de sys.path")
    print("  3. Error tipográfico en el nombre del módulo")

— Ejecutar vs importar: __name__ == "__main__" —

Cuando Python ejecuta un fichero directamente — python script.py — la variable __name__ de ese fichero vale "__main__". Cuando ese mismo fichero se importa como módulo, __name__ vale el nombre del módulo. Este mecanismo permite tener código que solo se ejecuta cuando el fichero se llama directamente — no cuando se importa. Es el patrón más frecuente al final de los scripts Python y aparece constantemente en código de IA.

# mis_funciones.py — con el patrón __name__
# ────────────────────────────────────────
# def calcular_iva(precio, tipo=0.21):
#     return round(precio * (1 + tipo), 2)
#
# def es_outlier(valor, datos):
#     media = sum(datos) / len(datos)
#     desv  = (sum((x-media)**2 for x in datos)/len(datos))**0.5
#     return abs(valor - media) > 2 * desv
#
# if __name__ == "__main__":
#     # Este bloque solo se ejecuta si llamas directamente
#     # python mis_funciones.py
#     # No se ejecuta si alguien hace import mis_funciones
#     print("Prueba de calcular_iva:")
#     print(calcular_iva(100))      # 121.0
#     print(calcular_iva(100, 0.10)) # 110.0
#     print("Prueba de es_outlier:")
#     print(es_outlier(100, [10, 25, 100, 8, 15]))  # True

# Demostración del mecanismo
print(f"__name__ en este script: {__name__}")
# Si ejecutas este fichero directamente: __main__
# Si lo importas desde otro fichero: el nombre del módulo

import os
print(f"__name__ del módulo os: {os.__name__}")
# 'os' — os siempre se importa, nunca es __main__

print("---")

# El patrón completo — siempre al final de un script
def main():
    """Función principal del script."""
    print("Ejecutando main()")
    print(calcular_iva(100))

if __name__ == "__main__":
    main()   # solo si se ejecuta directamente
El patrón if __name__ == "__main__" en código de IA: la IA lo genera casi siempre al final de scripts completos. Significa: "este código es una prueba o un punto de entrada — no se ejecuta si alguien importa este fichero como módulo". Es buena práctica y señal de código bien estructurado.
  1. ¿Cuál es la diferencia entre un módulo y un paquete? ¿Qué hace especial al fichero __init__.py? ¿Puede estar vacío?
  2. ¿Por qué este código produce ModuleNotFoundError aunque el fichero existe? ¿Cómo lo solucionarías?
    # Estructura:
    # proyecto/
    #   main.py          ← estás aquí
    #   herramientas/
    #     limpieza.py    ← quieres importar esto
    
    # En main.py:
    import limpieza   # ← ModuleNotFoundError
  3. ¿Qué imprime este código cuando se ejecuta directamente? ¿Y cuando se importa desde otro fichero?
    # fichero: herramienta.py
    print(f"__name__ = {__name__}")
    
    def procesar(x):
        return x * 2
    
    if __name__ == "__main__":
        print("ejecutando directamente")
        print(procesar(5))
  4. Describe la jerarquía de carpetas que corresponde a esta importación:
    from analisis.datos.limpieza import quitar_outliers
    ¿Qué ficheros y carpetas deben existir? ¿Cuáles necesitan __init__.py?
Respuesta orientativa
a

Un módulo es un fichero .py individual. Un paquete es una carpeta que contiene módulos — y potencialmente otros paquetes. Lo que hace especial a __init__.py es que le dice a Python que trate la carpeta como un paquete importable — sin él, Python la ignora como posible módulo. Sí puede estar vacío — su mera presencia es suficiente. También puede contener código que se ejecuta al importar el paquete — por ejemplo, importar automáticamente los submódulos más usados.

b

Python busca limpieza en las carpetas de sys.path, que incluye la carpeta donde está main.py — no la subcarpeta herramientas/. Hay tres soluciones:

Primera — añadir __init__.py a herramientas/ y usar la ruta completa: from herramientas import limpieza.

Segunda — añadir la carpeta a sys.path: sys.path.insert(0, "herramientas") y luego import limpieza.

La primera opción es más limpia y portable.

c

Ejecutado directamente — python herramienta.py: imprime __name__ = __main__, luego ejecutando directamente, luego 10.

Importado desde otro fichero — import herramienta: imprime solo __name__ = herramienta — el bloque if __name__ == "__main__" no se ejecuta. La función procesar queda disponible pero no se llama.

d

La jerarquía necesaria:

# analisis/                ← carpeta — necesita __init__.py
#   __init__.py
#   datos/                 ← carpeta — necesita __init__.py
#     __init__.py
#     limpieza.py          ← módulo con quitar_outliers

Tanto analisis/ como analisis/datos/ necesitan __init__.py para ser reconocidas como paquetes importables. limpieza.py debe definir la función quitar_outliers. La carpeta que contiene analisis/ debe estar en sys.path — normalmente la carpeta del script que importa.

Parte II

El módulo como objeto: tipo, atributos y namespace

El módulo como objeto: tipo, atributos y métodos teoría

En la Clase 6a establecimos que todo en Python es un objeto. Las funciones son objetos. Los números son objetos. Las clases son objetos. Los módulos también son objetos — de tipo module. Tienen tipo, tienen identidad, tienen atributos y métodos. Se pueden asignar a variables, pasar como argumentos y almacenar en estructuras de datos.

Esto no es un detalle técnico menor. Entender que un módulo es un objeto explica por qué la sintaxis os.getcwd() funciona exactamente igual que lista.append() — en ambos casos es acceso a un atributo de un objeto seguido de una llamada.

— El módulo tiene tipo — igual que cualquier objeto —

import os
import math
import pandas as pd
import types

# Los módulos son objetos de tipo 'module'
print(type(os))       # 
print(type(math))     # 
print(type(pd))       # 

print("---")

# El tipo module está en el módulo types
print(type(os) is types.ModuleType)   # True
print(type(pd) is types.ModuleType)   # True

print("---")

# Comparar con otros objetos
print(type(42))          # 
print(type("hola"))     # 
print(type([1,2,3]))    # 
print(type(os))          # 
# Cuatro objetos — cuatro tipos distintos
# El mecanismo es exactamente el mismo

print("---")

# Un módulo tiene identidad — dirección en memoria
print(id(os))
print(id(math))
print(id(pd))

# Importar el mismo módulo dos veces
# devuelve el mismo objeto — Python lo cachea
import os as os2
print(os is os2)   # True — el mismo objeto
print(id(os) == id(os2))   # True

— Los atributos especiales de un módulo —

Como cualquier objeto Python, un módulo tiene atributos que describen su identidad y su contenido. Los más importantes son __name__, __file__ y __doc__ — que ya conoces de las funciones, donde tienen exactamente el mismo significado.

import os
import math

# __name__ — el nombre del módulo
print(os.__name__)     # 'os'
print(math.__name__)   # 'math'

print("---")

# __file__ — la ruta al fichero .py del módulo
print(os.__file__)     # /usr/lib/python3.x/os.py
print(math.__file__)   # ruta a math — puede ser .so (C compilado)

print("---")

# __doc__ — la documentación del módulo
print(os.__doc__[:200])   # primeras 200 letras del docstring

print("---")

# __package__ — el paquete al que pertenece
print(os.__package__)        # '' — os es un módulo de nivel superior
print(os.path.__package__)   # 'os' — os.path pertenece al paquete os

print("---")

# Comparar con los mismos atributos en una función
def mi_funcion():
    """Mi función de ejemplo."""
    pass

print("Función:", mi_funcion.__name__, "—", mi_funcion.__doc__)
print("Módulo: ", os.__name__,        "—", os.__doc__[:30])
# __name__ y __doc__ existen en funciones Y en módulos
# — son convenciones de Python para todos los objetos

— dir(módulo): explorar todo lo que define —

import math

# dir() — lista todos los atributos y nombres del módulo
print(dir(math))
# ['__doc__', '__file__', '__loader__', ...,
#  'acos', 'acosh', 'asin', ..., 'sqrt', 'tan', 'tau']

print("---")

# Separar atributos especiales de nombres propios del módulo
todo = dir(math)
especiales  = [n for n in todo if n.startswith("__")]
propios     = [n for n in todo if not n.startswith("_")]

print(f"Atributos especiales ({len(especiales)}): {especiales}")
print(f"Nombres propios ({len(propios)}): {propios}")

print("---")

# Inspeccionar el tipo de cada atributo
for nombre in propios[:5]:
    valor = getattr(math, nombre)
    print(f"math.{nombre:12} → {type(valor).__name__:30} = {valor}")

print("---")

# getattr — acceder a un atributo por nombre como string
# equivalente a math.sqrt pero más flexible
funcion = getattr(math, "sqrt")
print(funcion(16))   # 4.0 — igual que math.sqrt(16)

# Útil cuando el nombre de la función viene de una variable
nombre_funcion = "sin"
resultado = getattr(math, nombre_funcion)(0)
print(resultado)   # 0.0 — math.sin(0)

— El módulo define el tipo de objeto —

Cuando escribes import pandas as pd y luego pd.DataFrame, estás accediendo al atributo DataFrame del objeto módulo pd. DataFrame es una clase — un tipo de objeto — definida dentro del módulo pandas. El módulo actúa como contenedor de tipos: define clases que luego se usan para crear objetos. pd.DataFrame() crea un objeto de tipo DataFrame — igual que int() crea un objeto de tipo int.

import pandas as pd
import numpy as np

# El módulo define el tipo
print(type(pd.DataFrame))   #  — es una clase
print(type(np.ndarray))     #  — es una clase

print("---")

# Crear un objeto de ese tipo
df = pd.DataFrame({"a": [1, 2, 3]})
arr = np.array([1, 2, 3])

print(type(df))    # 
print(type(arr))   # 

print("---")

# La jerarquía completa
# pd        → objeto de tipo module
# pd.DataFrame → objeto de tipo type (una clase)
# df        → objeto de tipo DataFrame

print(f"pd:            {type(pd)}")
print(f"pd.DataFrame:  {type(pd.DataFrame)}")
print(f"df:            {type(df)}")

print("---")

# Los métodos de df vienen del tipo DataFrame
# definido en el módulo pandas
print(type(df.head))      # 
print(type(df.describe))  # 
# — exactamente igual que list.append es un método
# definido en el tipo list
  1. ¿Qué tienen en común estos cuatro objetos? ¿En qué se diferencian?
    import math
    
    a = 42
    b = "hola"
    c = math.sqrt
    d = math
    
    print(type(a), type(b), type(c), type(d))
    print(hasattr(a, "__doc__"))
    print(hasattr(b, "__doc__"))
    print(hasattr(c, "__doc__"))
    print(hasattr(d, "__doc__"))
  2. Usa dir() y getattr() para listar todas las funciones del módulo math que empiezan por "a" y llamar a cada una con el argumento 0.5.
  3. ¿Por qué este código imprime True? ¿Qué nos dice sobre cómo Python gestiona los módulos importados?
    import os
    import os as sistema_operativo
    
    print(os is sistema_operativo)
    print(id(os) == id(sistema_operativo))
  4. Explica la diferencia entre estos tres niveles usando el concepto de tipo:
    import pandas as pd
    
    print(type(pd))
    print(type(pd.DataFrame))
    print(type(pd.DataFrame({"a": [1]})))
Respuesta orientativa
a

Los cuatro tienen en común que son objetos Python — tienen tipo, identidad y atributos. Los cuatro tienen __doc__ — es un atributo estándar de todos los objetos Python. Se diferencian en su tipo: a es int, b es str, c es builtin_function_or_method, d es module. Cuatro tipos distintos — mismo mecanismo de objetos.

b
import math

funciones_a = [
    nombre for nombre in dir(math)
    if nombre.startswith("a")
    and callable(getattr(math, nombre))
]

print(f"Funciones que empiezan por 'a': {funciones_a}")

for nombre in funciones_a:
    f = getattr(math, nombre)
    try:
        resultado = f(0.5)
        print(f"math.{nombre}(0.5) = {resultado:.4f}")
    except Exception as e:
        print(f"math.{nombre}(0.5) → Error: {e}")
c

Imprime True porque Python mantiene un caché de módulos en sys.modules. Cuando importas os por segunda vez — aunque sea con un alias — Python detecta que ya está cargado y devuelve el mismo objeto. No crea un nuevo módulo. os y sistema_operativo son dos nombres distintos que apuntan exactamente al mismo objeto en memoria — el mismo id.

d

type(pd) → module — pd es el módulo pandas, un contenedor de nombres.

type(pd.DataFrame) → type — DataFrame es una clase, un tipo de objeto. Las clases en Python son objetos de tipo type.

type(pd.DataFrame({"a": [1]})) → DataFrame — el resultado de llamar a la clase es una instancia, un objeto concreto de ese tipo.

El ciclo completo: el módulo contiene la clase, la clase define el tipo, el tipo se usa para crear instancias. pd → pd.DataFrame → pd.DataFrame({...}): contenedor → tipo → instancia.
El namespace del módulo: globals(), vars() y __dict__ teoría

En la Clase 6b vimos que cada función tiene su propio scope local y que el script principal tiene un scope global. Ahora conectamos eso con los módulos: cada módulo tiene su propio scope global — su propio namespace — completamente aislado del tuyo.

Cuando importas un módulo, su namespace no se mezcla con el tuyo. import os añade el nombre os a tu namespace — pero todo lo que os define internamente vive en el namespace de os, no en el tuyo. Por eso necesitas os.getcwd() y no simplemente getcwd().

— globals(): qué cambia en el namespace al importar —

# globals() devuelve el namespace del scope global actual
# — el diccionario de todo lo definido en este fichero

# Antes de importar nada
nombres_iniciales = set(globals().keys())
print(f"Nombres antes de importar: {len(nombres_iniciales)}")

# import os — entra solo el nombre 'os'
import os
nombres_tras_os = set(globals().keys())
nuevos = nombres_tras_os - nombres_iniciales
print(f"Nuevos tras 'import os': {nuevos}")
# {'os'} — solo el nombre del módulo

print("---")

# from os import getcwd — entra solo getcwd
from os import getcwd
nombres_tras_getcwd = set(globals().keys())
nuevos2 = nombres_tras_getcwd - nombres_tras_os
print(f"Nuevos tras 'from os import getcwd': {nuevos2}")
# {'getcwd'} — solo la función

print("---")

# import numpy as np — entra el alias 'np'
import numpy as np
nombres_tras_np = set(globals().keys())
nuevos3 = nombres_tras_np - nombres_tras_getcwd
print(f"Nuevos tras 'import numpy as np': {nuevos3}")
# {'np'} — el alias, no 'numpy'

print("---")

# Verificar qué hay en globals() ahora
print("'os' in globals():",      "os"     in globals())   # True
print("'getcwd' in globals():",  "getcwd" in globals())   # True
print("'np' in globals():",      "np"     in globals())   # True
print("'numpy' in globals():",   "numpy"  in globals())   # False — alias
print("'listdir' in globals():", "listdir"in globals())   # False — no importado

— vars() y __dict__: el diccionario interno del módulo —

Cada módulo guarda su namespace en un diccionario interno llamado __dict__. vars(módulo) devuelve ese diccionario. La sintaxis de punto os.getcwd es exactamente equivalente a os.__dict__["getcwd"] — Python simplemente busca el nombre en el diccionario del módulo.

import math

# vars(módulo) devuelve el __dict__ del módulo
# — el namespace completo de math
namespace_math = vars(math)
print(type(namespace_math))   # 
print(f"math define {len(namespace_math)} nombres")

print("---")

# La sintaxis punto es acceso al diccionario
# Estos tres son exactamente equivalentes
print_a = math.sqrt(16)
print_b = math.__dict__["sqrt"](16)
print_c = vars(math)["sqrt"](16)

print(print_a, print_b, print_c)   # 4.0 4.0 4.0 — idénticos

print("---")

# Explorar el namespace de math
for nombre, valor in sorted(vars(math).items()):
    if not nombre.startswith("_"):
        tipo = type(valor).__name__
        print(f"  {nombre:12} {tipo:25} {valor}")

print("---")

# vars() sin argumento — namespace del scope actual
# equivalente a globals() en el nivel superior
x = 42
y = "hola"
print("x" in vars())   # True — x está en el namespace actual
print(vars() is globals())   # True — el mismo diccionario

— Dos namespaces completamente distintos —

El namespace del módulo importado y el namespace de tu fichero son dos diccionarios distintos. Cuando haces from os import getcwd, Python copia la referencia a getcwd de os.__dict__ a tu globals(). Son dos entradas en dos diccionarios distintos que apuntan al mismo objeto. Si os redefiniera getcwd internamente, tu copia no cambiaría.

import os
from os import getcwd

# Son el mismo objeto — misma referencia
print(os.getcwd is getcwd)   # True — apuntan al mismo objeto
print(id(os.getcwd) == id(getcwd))   # True

print("---")

# Pero viven en namespaces distintos
print("getcwd" in globals())        # True — en mi namespace
print("getcwd" in vars(os))         # True — en el namespace de os
print(globals() is vars(os))       # False — diccionarios distintos

print("---")

# sys.modules — el caché de módulos ya importados
import sys

print(type(sys.modules))         # 
print("os" in sys.modules)       # True — os ya está cargado
print("math" in sys.modules)     # True si math fue importado
print(sys.modules["os"] is os)   # True — el mismo objeto

print("---")

# Ver todos los módulos cargados
modulos_cargados = list(sys.modules.keys())
print(f"Módulos en sys.modules: {len(modulos_cargados)}")
print("Algunos:", modulos_cargados[:10])

— Conexión con LEGB: el namespace del módulo es su G —

Cuando Python ejecuta el código de un módulo, aplica exactamente la misma regla LEGB de la Clase 6b. El scope Global de ese módulo es su propio __dict__ — no el tuyo. Cuando una función de os usa un nombre sin importarlo, lo busca en el namespace de os — no en el tuyo. Cada módulo vive en su propio mundo de nombres.

import os

# Las funciones de os buscan sus dependencias
# en el namespace de os — no en el tuyo
# Por ejemplo, os.path.join usa internamente
# funciones definidas dentro del módulo os.path
# — son invisibles desde tu namespace

# Demostración: definir 'sep' en tu namespace
# no afecta a os.path que usa el suyo propio
sep = "MI_SEPARADOR"

print(f"Mi sep:     {sep}")
print(f"os.path.sep: {os.path.sep}")   # '/' o '\' — el del sistema
# os.path.sep viene del namespace de os.path — no del tuyo

print("---")

# Resumen visual de los tres namespaces
print("Tu namespace (globals()):")
tus_nombres = [k for k in globals() if not k.startswith("_")]
print(f"  {tus_nombres}")

print("Namespace de os (vars(os)):")
nombres_os = [k for k in vars(os) if not k.startswith("_")]
print(f"  primeros 5: {nombres_os[:5]}")

print("Namespace de os.path (vars(os.path)):")
nombres_path = [k for k in vars(os.path) if not k.startswith("_")]
print(f"  primeros 5: {nombres_path[:5]}")
El modelo mental completo: un módulo es un objeto de tipo module cuyo atributo __dict__ es su namespace — el diccionario de todos los nombres que define. La sintaxis os.getcwd es exactamente os.__dict__["getcwd"]. import os añade una entrada "os" a tu globals(). from os import getcwd añade "getcwd" a tu globals() — copiando la referencia desde os.__dict__. Dos namespaces distintos, el mismo objeto función.
  1. ¿Qué diferencia hay entre globals() y vars(os)? ¿Son el mismo diccionario? ¿Contienen los mismos nombres? Verifica con código.
  2. ¿Por qué estos dos accesos son equivalentes? ¿Cuál usarías en código real y cuándo usarías el otro?
    import math
    
    # Acceso A
    resultado_a = math.sqrt(16)
    
    # Acceso B
    resultado_b = math.__dict__["sqrt"](16)
    
    print(resultado_a == resultado_b)
  3. Usa sys.modules para verificar cuántos módulos están cargados en tu sesión. ¿Te sorprende el número? ¿Por qué hay tantos aunque solo hayas importado tres o cuatro?
  4. ¿Qué ocurre en el namespace cuando ejecutas cada una de estas líneas? Describe exactamente qué entra en globals() en cada caso:
    # Línea 1
    import os
    
    # Línea 2
    from os import path
    
    # Línea 3
    from os.path import join as unir
    
    # Línea 4
    import os.path
Respuesta orientativa
a

globals() devuelve el namespace del fichero actual — el diccionario de todo lo definido en tu script. vars(os) devuelve el namespace del módulo os — todo lo que os define internamente. No son el mismo diccionario — globals() is vars(os) devuelve False. No contienen los mismos nombres — el tuyo tiene tus variables y los módulos que importaste, el de os tiene sus funciones internas. Solo comparten algunos nombres si hiciste from os import algo.

b

Son equivalentes porque math.sqrt es exactamente math.__dict__["sqrt"] — Python implementa el acceso por punto como búsqueda en el diccionario del objeto. En código real siempre se usa math.sqrt — más legible y más seguro. math.__dict__["sqrt"] tiene sentido cuando el nombre de la función es dinámico — viene de una variable string — en cuyo caso se usa getattr(math, nombre) que es más limpio y más idiomático que math.__dict__[nombre].

c

El número suele ser sorprendentemente alto — entre 50 y 200 módulos incluso en una sesión fresca. Esto ocurre porque Python carga automáticamente los módulos que necesita para funcionar — el intérprete, el sistema de ficheros, la codificación de strings, los mecanismos de importación. Además, cuando importas un módulo grande como os, este a su vez importa sus dependencias, que importan las suyas. sys.modules refleja el grafo completo de dependencias cargadas.

d

Línea 1 — import os: entra "os" en globals() — el objeto módulo completo.

Línea 2 — from os import path: entra "path" — el submódulo os.path directamente en tu namespace.

Línea 3 — from os.path import join as unir: entra "unir" — el alias de la función join. Ni "os" ni "path" ni "join" entran.

Línea 4 — import os.path: entra "os" — el módulo os con path accesible como os.path. El nombre "path" no entra directamente.

La regla general: lo que entra en globals() es exactamente el primer nombre después de import — o el alias si usas as. Todo lo demás queda en el namespace del módulo, accesible con punto.
Parte III

Módulos esenciales: os, sys, glob, importlib

os, sys y glob: ficheros, rutas y el intérprete práctica

Estos tres módulos aparecen constantemente en código de IA que gestiona ficheros, rutas y configuración del entorno. No hace falta memorizarlos — hace falta reconocerlos y saber leer qué hacen cuando la IA los usa.

— os: interactuar con el sistema operativo —

os es el módulo que permite a Python hablar con el sistema operativo — crear carpetas, listar ficheros, leer variables de entorno, construir rutas. Su parte más importante para el análisis de datos es os.path — el submódulo de rutas.

import os

# Dónde estamos
print(os.getcwd())   # directorio de trabajo actual

print("---")

# Construir rutas — siempre con os.path.join
# no con strings — funciona en Windows, Mac y Linux
ruta = os.path.join("datos", "2024", "ventas.csv")
print(ruta)   # datos/ventas/2024.csv o datos\ventas\2024.csv

print("---")

# Descomponer una ruta
ruta = "/datos/2024/ventas_enero.csv"
print(os.path.dirname(ruta))    # '/datos/2024' — la carpeta
print(os.path.basename(ruta))   # 'ventas_enero.csv' — el fichero
print(os.path.splitext(ruta))   # ('/datos/2024/ventas_enero', '.csv')

nombre, extension = os.path.splitext(os.path.basename(ruta))
print(f"nombre: {nombre}, extensión: {extension}")

print("---")

# Verificar existencia
print(os.path.exists("/datos/ventas.csv"))   # True o False
print(os.path.isfile("/datos/ventas.csv"))   # True si es fichero
print(os.path.isdir("/datos"))               # True si es carpeta

print("---")

# Listar contenido de una carpeta
carpeta = os.getcwd()
print("Contenido:", os.listdir(carpeta)[:5])

print("---")

# Crear y eliminar carpetas
# os.makedirs crea toda la jerarquía de carpetas
# exist_ok=True — no falla si ya existe
os.makedirs("temp/prueba/datos", exist_ok=True)
print("Creada: temp/prueba/datos")
print(os.path.exists("temp/prueba/datos"))   # True

print("---")

# Variables de entorno
print(os.environ.get("HOME", "no definida"))
print(os.environ.get("PATH", "no definida")[:60])
# Útil para claves de API, configuración, rutas de instalación
API_KEY = os.environ.get("ANTHROPIC_API_KEY", "no configurada")
print(f"API key: {API_KEY[:8]}...")

print("---")

# os.walk — recorrer un árbol de carpetas
# devuelve (carpeta, subcarpetas, ficheros) para cada nivel
for carpeta, subcarpetas, ficheros in os.walk("temp"):
    nivel = carpeta.count(os.sep)
    sangria = "  " * nivel
    print(f"{sangria}{os.path.basename(carpeta)}/")
    for f in ficheros:
        print(f"{sangria}  {f}")

— sys: información del intérprete —

sys da acceso a información sobre el intérprete Python que está ejecutando el código — su versión, las rutas de búsqueda, los argumentos con los que se llamó al script. Es el módulo que aparece cuando hay que ajustar el comportamiento de Python en tiempo de ejecución.

import sys

# Versión de Python
print(sys.version)
print(sys.version_info)
print(f"Python {sys.version_info.major}.{sys.version_info.minor}")

print("---")

# sys.path — dónde busca Python los módulos
print("Rutas de búsqueda:")
for ruta in sys.path:
    print(f"  {ruta}")

print("---")

# sys.argv — argumentos de línea de comandos
# sys.argv[0] es el nombre del script
# sys.argv[1:] son los argumentos que le pasas
print(f"Script: {sys.argv[0]}")
print(f"Argumentos: {sys.argv[1:]}")

# Patrón frecuente en scripts de IA
# — ejecutar con: python analizar.py datos.csv 2024
if len(sys.argv) >= 3:
    fichero = sys.argv[1]
    año = sys.argv[2]
    print(f"Analizando {fichero} para el año {año}")
else:
    print("Uso: python analizar.py fichero.csv año")

print("---")

# sys.platform — el sistema operativo
print(sys.platform)   # 'linux', 'darwin' (Mac), 'win32'

# Código que se adapta al sistema operativo
if sys.platform == "win32":
    separador = "\\"
else:
    separador = "/"

# Mejor usar os.sep — que hace esto automáticamente
print(f"Separador: {separador}")
print(f"os.sep:    {os.sep}")

print("---")

# sys.exit — salir del script
# sys.exit(0) — salida normal
# sys.exit(1) — salida con error
# Lanza SystemExit — que se puede capturar
try:
    sys.exit(0)
except SystemExit as e:
    print(f"SystemExit({e}) — el script terminaría aquí")

— glob: buscar ficheros por patrón —

glob permite buscar ficheros usando patrones con comodines — * para cualquier secuencia de caracteres, ? para un carácter, ** para búsqueda recursiva en subcarpetas. Es mucho más cómodo que os.listdir() con filtrado manual cuando buscas ficheros de un tipo concreto.

import glob
import os

# Crear estructura de prueba
import os
for carpeta in ["datos/2023", "datos/2024"]:
    os.makedirs(carpeta, exist_ok=True)

# Crear ficheros de prueba
ficheros_prueba = [
    "datos/2023/ventas_enero.csv",
    "datos/2023/ventas_febrero.csv",
    "datos/2024/ventas_enero.csv",
    "datos/2024/costes_enero.csv",
    "datos/2024/informe.xlsx",
]
for f in ficheros_prueba:
    open(f, "w").close()

print("---")

# Todos los CSV de una carpeta
csvs = glob.glob("datos/2024/*.csv")
print("CSVs de 2024:", csvs)

# Todos los CSV en cualquier subcarpeta
todos_csvs = glob.glob("datos/**/*.csv", recursive=True)
print("Todos los CSVs:", todos_csvs)

# Ficheros que empiezan por 'ventas'
ventas = glob.glob("datos/**/ventas*.csv", recursive=True)
print("Ficheros de ventas:", ventas)

print("---")

# Patrón frecuente en IA: cargar todos los CSV de una carpeta
import pandas as pd

ficheros_csv = glob.glob("datos/**/*.csv", recursive=True)
print(f"Encontrados {len(ficheros_csv)} ficheros CSV")

# Cargar y combinar todos
dataframes = []
for fichero in ficheros_csv:
    nombre = os.path.basename(fichero)
    print(f"  Cargando: {nombre}")
    # df = pd.read_csv(fichero)  ← en un caso real
    # dataframes.append(df)

# df_total = pd.concat(dataframes, ignore_index=True)

print("---")

# glob vs os.listdir — comparación
# Con os.listdir necesitas filtrar manualmente
ficheros_manual = [
    f for f in os.listdir("datos/2024")
    if f.endswith(".csv")
]
print("os.listdir + filter:", ficheros_manual)

# Con glob — más directo y con soporte de patrones
ficheros_glob = [
    os.path.basename(f)
    for f in glob.glob("datos/2024/*.csv")
]
print("glob:", ficheros_glob)
El patrón más frecuente en código de IA: glob.glob("ruta/**/*.csv", recursive=True) para encontrar todos los ficheros de un tipo en una estructura de carpetas. Seguido de un bucle que los carga con pd.read_csv() y pd.concat() para combinarlos en un DataFrame. Reconocer este patrón permite entender y modificar scripts de carga de datos sin necesidad de releerlos enteros.
  1. ¿Por qué este código es problemático en Windows? ¿Cómo lo corregirías?
    ruta = "datos" + "/" + "2024" + "/" + "ventas.csv"
    print(ruta)
  2. ¿Qué hace este fragmento de código de IA? Descríbelo paso a paso:
    import os, glob, pandas as pd
    
    carpeta = os.environ.get("DATOS_DIR", "datos")
    ficheros = glob.glob(
        os.path.join(carpeta, "**", "*.csv"),
        recursive=True
    )
    df = pd.concat(
        [pd.read_csv(f) for f in ficheros],
        ignore_index=True
    )
    print(f"Cargadas {len(ficheros)} tablas: {len(df)} filas")
  3. ¿Qué contiene sys.argv cuando ejecutas este comando en la terminal? ¿Qué imprimiría el script?
    # Terminal: python analizar.py ventas.csv 2024 --verbose
    import sys
    print(sys.argv)
    print(len(sys.argv))
  4. Escribe un fragmento que busque todos los ficheros .xlsx en la carpeta actual y sus subcarpetas, imprima sus nombres y tamaños en bytes, y los ordene por tamaño descendente.
Respuesta orientativa
a

En Windows el separador de rutas es \ — no /. Concatenar con / produce una ruta que Python puede aceptar en algunos contextos pero no en todos, y que es inconsistente con el sistema. La corrección:

import os
ruta = os.path.join("datos", "2024", "ventas.csv")
print(ruta)
# datos/ventas.csv en Linux/Mac
# datos\ventas.csv en Windows
# — siempre correcto sin importar el sistema
b

Paso 1: lee la variable de entorno DATOS_DIR — si no existe usa "datos" como valor por defecto. Esto permite configurar la carpeta de datos sin modificar el código.

Paso 2: busca recursivamente todos los ficheros .csv en esa carpeta y sus subcarpetas. os.path.join construye el patrón de forma portable.

Paso 3: carga cada CSV como DataFrame con pd.read_csv, los combina en uno solo con pd.concat e imprime cuántos ficheros cargó y cuántas filas tiene el resultado total.

c

sys.argv contiene: ['analizar.py', 'ventas.csv', '2024', '--verbose']. sys.argv[0] es siempre el nombre del script. sys.argv[1:] son los argumentos. len(sys.argv) devuelve 4.

d
import glob, os

ficheros = glob.glob("**/*.xlsx", recursive=True)

# Obtener nombre y tamaño de cada fichero
info = [
    (os.path.basename(f), os.path.getsize(f), f)
    for f in ficheros
]

# Ordenar por tamaño descendente
info_ordenada = sorted(info, key=lambda x: x[1], reverse=True)

print(f"Encontrados {len(info_ordenada)} ficheros .xlsx")
for nombre, tamaño, ruta in info_ordenada:
    print(f"  {nombre:30} {tamaño:10} bytes  ({ruta})")
importlib: importación dinámica y recarga de módulos práctica

importlib es el módulo que implementa el sistema de importación de Python — es lo que está detrás de cada import que escribes. Normalmente no necesitas usarlo directamente, pero hay dos casos donde es imprescindible: cuando el nombre del módulo que quieres importar viene de una variable — importación dinámica — y cuando quieres recargar un módulo que ha cambiado en disco sin reiniciar el intérprete.

El segundo caso es especialmente útil en desarrollo: cuando modificas un fichero .py propio y quieres que los cambios se reflejen inmediatamente sin cerrar y reabrir Python o el notebook.

— Importación dinámica: el nombre como variable —

Con import normal el nombre del módulo debe conocerse al escribir el código. Con importlib.import_module() el nombre puede venir de una variable, de un fichero de configuración o de la entrada del usuario. La IA usa este patrón en sistemas de plugins, en configuración dinámica y en cualquier situación donde los módulos a cargar no se conocen de antemano.

import importlib

# import normal — el nombre es literal
import os
import math

# importlib.import_module — el nombre es una variable
nombre_modulo = "os"
os_dinamico = importlib.import_module(nombre_modulo)

# Es el mismo objeto
print(os_dinamico is os)   # True — mismo objeto del caché
print(type(os_dinamico))    # 

print("---")

# Cargar distintos módulos según configuración
modulos_necesarios = ["os", "sys", "math", "json"]

modulos = {}
for nombre in modulos_necesarios:
    modulos[nombre] = importlib.import_module(nombre)
    print(f"Cargado: {nombre} → {modulos[nombre].__file__}")

print("---")

# Patrón de plugin — cargar módulos por nombre
def cargar_plugin(nombre_plugin):
    """Carga un módulo de plugin por su nombre."""
    try:
        plugin = importlib.import_module(nombre_plugin)
        print(f"Plugin '{nombre_plugin}' cargado")
        return plugin
    except ModuleNotFoundError:
        print(f"Plugin '{nombre_plugin}' no encontrado")
        return None

plugins_config = ["json", "csv", "plugin_inexistente"]
plugins_cargados = {
    nombre: cargar_plugin(nombre)
    for nombre in plugins_config
}

print("---")

# Importar submódulos con punto
path_modulo = importlib.import_module("os.path")
print(path_modulo.join("datos", "ventas.csv"))

# Importar con nombre relativo — desde un paquete
# importlib.import_module(".limpieza", package="utilidades")
# equivale a: from utilidades import limpieza

— importlib.reload(): recargar un módulo modificado —

Cuando Python importa un módulo, lo guarda en sys.modules. Si vuelves a escribir import mis_funciones, Python lo encuentra en el caché y no lo reimporta — aunque el fichero haya cambiado. importlib.reload(módulo) fuerza la relectura del fichero desde disco y la reejección del código, actualizando el objeto módulo en sys.modules.

Es la herramienta correcta cuando modificas un módulo propio durante una sesión de desarrollo y quieres que los cambios se reflejen sin reiniciar.

import importlib
import sys
import os

# Crear un módulo propio de prueba
contenido_v1 = '''
VERSION = "1.0"

def saludar(nombre):
    return f"Hola, {nombre} — versión {VERSION}"

def calcular(x):
    return x * 2
'''

with open("mi_modulo.py", "w") as f:
    f.write(contenido_v1)

print("--- Versión 1 ---")

# Importar por primera vez
import mi_modulo

print(mi_modulo.VERSION)           # "1.0"
print(mi_modulo.saludar("Ana"))   # "Hola, Ana — versión 1.0"
print(mi_modulo.calcular(5))      # 10

print("---")

# Modificar el módulo en disco
contenido_v2 = '''
VERSION = "2.0"

def saludar(nombre):
    return f"Buenos días, {nombre} — versión {VERSION}"

def calcular(x):
    return x ** 2   # ahora eleva al cuadrado

def nuevo_metodo():
    return "Este método no existía en v1"
'''

with open("mi_modulo.py", "w") as f:
    f.write(contenido_v2)

print("--- Tras modificar el fichero ---")

# Sin reload — Python usa el caché — versión antigua
import mi_modulo   # ← no hace nada — ya está en sys.modules
print(mi_modulo.VERSION)           # "1.0" — NO cambió
print(mi_modulo.saludar("Ana"))   # versión 1.0 — NO cambió

print("---")

# Con reload — fuerza la relectura del fichero
importlib.reload(mi_modulo)

print("--- Tras reload ---")
print(mi_modulo.VERSION)              # "2.0" — actualizado
print(mi_modulo.saludar("Ana"))      # versión 2.0 — actualizado
print(mi_modulo.calcular(5))         # 25 — ahora eleva al cuadrado
print(mi_modulo.nuevo_metodo())      # disponible — nuevo en v2

— Cuándo usar reload y cuándo no —

Cuándo usar reload

Durante el desarrollo — modificas un módulo propio y quieres ver los cambios sin reiniciar Python o el notebook. En sesiones interactivas largas donde los módulos evolucionan.

Cuándo NO usar reload

En código de producción — el comportamiento del programa no debe cambiar en ejecución. Cuando el módulo tiene dependencias circulares. Las instancias creadas antes del reload siguen siendo del tipo antiguo.

— sys.modules: el caché de módulos —

import sys
import importlib

# sys.modules es el diccionario de módulos ya cargados
print("mi_modulo" in sys.modules)   # True — está en el caché

# Ver el objeto en el caché
modulo_cacheado = sys.modules["mi_modulo"]
print(modulo_cacheado is mi_modulo)   # True — el mismo objeto

print("---")

# Forzar reimportación eliminando del caché
# — alternativa a reload para casos extremos
if "mi_modulo" in sys.modules:
    del sys.modules["mi_modulo"]
    print("Eliminado del caché")

# Ahora import lo tratará como nuevo
import mi_modulo   # ← ahora sí reimporta desde disco
print(mi_modulo.VERSION)   # "2.0" — versión actual del fichero

print("---")

# La diferencia entre reload y del+import
# reload: actualiza el objeto existente en sys.modules
#         — las referencias al módulo siguen funcionando
# del+import: crea un objeto nuevo
#         — las referencias antiguas al módulo anterior
#           quedan apuntando al objeto viejo

print("Módulos del ecosistema de datos en sys.modules:")
ecosistema = ["pandas", "numpy", "matplotlib"]
for nombre in ecosistema:
    cargado = nombre in sys.modules
    print(f"  {nombre:15}: {'✓' if cargado else '✗'}")
El problema del reload con instancias existentes: si tienes un objeto creado antes del reload — por ejemplo un DataFrame de una clase del módulo — ese objeto sigue siendo del tipo antiguo. El tipo nuevo y el tipo antiguo tienen el mismo nombre pero son objetos distintos. isinstance(objeto, NuevoTipo) devolverá False. En desarrollo esto rara vez es un problema — en producción es una razón más para no usar reload.
  1. ¿Por qué este código imprime la versión antigua aunque el fichero cambió? ¿Cómo lo corregirías?
    import mi_modulo
    print(mi_modulo.VERSION)   # "1.0"
    
    # ... alguien modifica mi_modulo.py y cambia VERSION a "2.0" ...
    
    import mi_modulo           # ← ¿por qué no actualiza?
    print(mi_modulo.VERSION)   # sigue siendo "1.0"
  2. ¿Cuándo usarías importlib.import_module() en lugar de import? Escribe un ejemplo donde el nombre del módulo venga de una lista de configuración.
  3. Este código de IA carga módulos dinámicamente. ¿Qué hace exactamente? ¿Qué problema resuelve?
    import importlib
    
    backends = {
        "csv":   "csv",
        "excel": "openpyxl",
        "json":  "json",
    }
    
    def cargar_backend(formato):
        nombre = backends.get(formato)
        if nombre is None:
            raise ValueError(f"Formato no soportado: {formato}")
        try:
            return importlib.import_module(nombre)
        except ModuleNotFoundError:
            raise ImportError(
                f"Instala {nombre}: pip install {nombre}"
            )
    
    backend = cargar_backend("json")
    print(type(backend))
  4. ¿Cuál es la diferencia entre eliminar un módulo de sys.modules y luego reimportarlo, versus usar importlib.reload()? ¿En qué casos importa esa diferencia?
Respuesta orientativa
a

Porque Python guarda el módulo en sys.modules la primera vez que lo importa. Las siguientes llamadas a import mi_modulo lo encuentran en el caché y no releen el fichero. La corrección:

import importlib
import mi_modulo

print(mi_modulo.VERSION)   # "1.0"

# ... fichero modificado ...

importlib.reload(mi_modulo)
print(mi_modulo.VERSION)   # "2.0" — actualizado
b

Se usa cuando el nombre del módulo no se conoce hasta el momento de la ejecución — viene de una variable, un fichero de configuración o la entrada del usuario.

import importlib

# Configuración — podría venir de un fichero JSON
modulos_config = [
    "json",
    "csv",
    "os.path"
]

modulos = {}
for nombre in modulos_config:
    try:
        modulos[nombre] = importlib.import_module(nombre)
        print(f"✓ {nombre}")
    except ModuleNotFoundError:
        print(f"✗ {nombre} — no disponible")
c

El código implementa un sistema de backends intercambiables para distintos formatos de fichero. En lugar de importar todos los módulos al inicio — algunos pueden no estar instalados — los carga bajo demanda solo cuando se necesitan. Si el módulo no está instalado da un mensaje claro con la instrucción de instalación. Resuelve el problema de dependencias opcionales: no falla al arrancar por un módulo que quizás no se usa nunca.

d

importlib.reload(módulo) ejecuta el código del fichero de nuevo y actualiza el objeto módulo existente en sys.modules. Las referencias existentes al módulo — mi_modulo en tu namespace — siguen apuntando al mismo objeto y ven los cambios.

del sys.modules["mi_modulo"] seguido de import mi_modulo crea un objeto módulo completamente nuevo. Las referencias antiguas al módulo anterior — si las guardaste en alguna variable — siguen apuntando al objeto viejo y no ven los cambios.

La diferencia importa cuando tienes código como from mi_modulo import VERSION — que copia la referencia. Con reload el módulo actualiza pero VERSION en tu namespace puede no cambiar porque es una copia. Con del+import tampoco cambia. La solución correcta en ambos casos es usar siempre mi_modulo.VERSION — acceso por atributo — en lugar de copiar con from ... import.
Parte IV

El ecosistema de datos: pandas y numpy

numpy: el motor numérico de Python teoría

Las listas de Python son flexibles — pueden contener cualquier tipo de objeto — pero esa flexibilidad tiene un coste: son lentas para cálculo numérico. Sumar dos listas de un millón de números requiere un bucle que comprueba el tipo de cada elemento en cada paso.

numpy resuelve esto con el ndarray: un array homogéneo — todos los elementos del mismo tipo — almacenado en memoria contigua. Las operaciones se ejecutan en C compilado sin bucles Python. El resultado es entre 10 y 100 veces más rápido para operaciones numéricas. Pandas, matplotlib, sklearn y prácticamente todo el ecosistema científico de Python están construidos sobre numpy.

— El problema de las listas para cálculo numérico —

import numpy as np
import time

# Con listas — bucle Python elemento a elemento
n = 1_000_000
lista_a = list(range(n))
lista_b = list(range(n))

inicio = time.time()
lista_suma = [a + b for a, b in zip(lista_a, lista_b)]
tiempo_lista = time.time() - inicio
print(f"Lista:  {tiempo_lista:.4f}s")

# Con numpy — operación vectorizada en C
arr_a = np.arange(n)
arr_b = np.arange(n)

inicio = time.time()
arr_suma = arr_a + arr_b   # sin bucle — vectorizado
tiempo_numpy = time.time() - inicio
print(f"numpy:  {tiempo_numpy:.4f}s")
print(f"numpy es {tiempo_lista/tiempo_numpy:.0f}x más rápido")

print("---")

# Las listas pueden mezclar tipos — numpy no
lista_mixta = [1, "dos", 3.0, True]   # ✓ en una lista
arr_mixto = np.array([1, "dos", 3.0, True])
print(arr_mixto)         # ['1' 'dos' '3.0' 'True'] — todo string
print(arr_mixto.dtype)   # 

— El ndarray: el objeto central de numpy —

ndarray significa n-dimensional array — un array que puede tener una, dos, tres o más dimensiones. Un array 1D es un vector, un array 2D es una matriz, un array 3D puede representar una imagen en color (alto × ancho × canales). Todos comparten los mismos atributos y operaciones.

import numpy as np

# Array 1D — vector
arr_1d = np.array([1, 2, 3, 4, 5])
print(f"1D: {arr_1d}")
print(f"  shape: {arr_1d.shape}")   # (5,)
print(f"  ndim:  {arr_1d.ndim}")    # 1
print(f"  dtype: {arr_1d.dtype}")   # int64
print(f"  size:  {arr_1d.size}")    # 5 — número de elementos

print("---")

# Array 2D — matriz
arr_2d = np.array([
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
])
print(f"2D:\n{arr_2d}")
print(f"  shape: {arr_2d.shape}")   # (3, 3) — 3 filas, 3 columnas
print(f"  ndim:  {arr_2d.ndim}")    # 2

print("---")

# dtype — el tipo de los elementos
print(np.array([1, 2, 3]).dtype)          # int64
print(np.array([1.0, 2.0, 3.0]).dtype)    # float64
print(np.array([True, False]).dtype)       # bool

# Especificar dtype explícitamente
arr_float = np.array([1, 2, 3], dtype=np.float32)
print(arr_float.dtype)   # float32 — menos memoria que float64

print("---")

# reshape — cambiar la forma sin cambiar los datos
arr = np.arange(12)               # [0, 1, 2, ..., 11]
matriz = arr.reshape(3, 4)        # 3 filas, 4 columnas
cubo   = arr.reshape(2, 2, 3)     # 2 × 2 × 3

print(f"Original: {arr.shape}")    # (12,)
print(f"Matriz:   {matriz.shape}") # (3, 4)
print(f"Cubo:     {cubo.shape}")   # (2, 2, 3)

— Formas frecuentes de crear arrays —

import numpy as np

# Desde una lista
arr = np.array([1, 2, 3, 4, 5])

# Secuencias
print(np.arange(0, 10, 2))         # [0 2 4 6 8] — inicio, fin, paso
print(np.linspace(0, 1, 5))        # [0. 0.25 0.5 0.75 1.] — N puntos

print("---")

# Arrays predefinidos
print(np.zeros((2, 3)))            # matriz 2×3 de ceros
print(np.ones((2, 3)))             # matriz 2×3 de unos
print(np.eye(3))                    # matriz identidad 3×3
print(np.full((2, 3), 7))          # matriz 2×3 rellena con 7

print("---")

# Arrays aleatorios — frecuentes en código de IA
np.random.seed(42)
print(np.random.rand(3, 3))        # uniformes [0, 1)
print(np.random.randn(3, 3))       # normales μ=0, σ=1
print(np.random.randint(0, 10, (3, 3)))  # enteros en [0, 10)

— Operaciones vectorizadas y broadcasting —

Las operaciones sobre arrays se aplican elemento a elemento sin escribir bucles — es lo que significa vectorizado. El broadcasting es la regla que permite operar arrays de distinta forma: numpy "expande" el array más pequeño para que tenga la misma forma que el grande — sin copiar los datos en memoria.

import numpy as np

arr = np.array([1, 2, 3, 4, 5])

# Operaciones elemento a elemento — sin bucle
print(arr * 2)           # [2 4 6 8 10]
print(arr + 10)          # [11 12 13 14 15]
print(arr ** 2)          # [1 4 9 16 25]
print(arr > 3)           # [False False False True True]

print("---")

# Operaciones entre arrays
a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
print(a + b)             # [5 7 9]
print(a * b)             # [4 10 18]
print(np.dot(a, b))      # 32 — producto escalar

print("---")

# Broadcasting — operar arrays de distinta forma
matriz = np.array([
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
])
vector = np.array([10, 20, 30])   # shape (3,)

# numpy "expande" vector a (3, 3) sumando fila a fila
print(matriz + vector)
# [[ 11  22  33]
#  [ 14  25  36]
#  [ 17  28  39]]

print("---")

# Funciones matemáticas universales — ufuncs
angulos = np.linspace(0, np.pi, 5)
print(np.sin(angulos).round(2))   # [0. 0.71 1. 0.71 0.]
print(np.sqrt(arr))               # [1. 1.41 1.73 2. 2.24]
print(np.log(arr))                # logaritmo natural

print("---")

# Indexación y slicing — igual que listas pero en N dimensiones
matriz = np.arange(16).reshape(4, 4)
print(matriz)
print(matriz[0])          # primera fila
print(matriz[:2, 1:3])   # primeras 2 filas, columnas 1 y 2
print(matriz[matriz > 8]) # indexación booleana

print("---")

# Estadísticas — axis=0 por columnas, axis=1 por filas
datos = np.random.randint(0, 100, (4, 3))
print(datos)
print(f"media total:     {datos.mean():.1f}")
print(f"media por col:   {datos.mean(axis=0)}")   # shape (3,)
print(f"media por fila:  {datos.mean(axis=1)}")   # shape (4,)
print(f"suma:    {datos.sum()}")
print(f"máximo:  {datos.max()}")
print(f"mínimo:  {datos.min()}")
print(f"std:     {datos.std():.2f}")
numpy como base del ecosistema: cuando pandas opera sobre una columna numérica, internamente usa numpy. Cuando sklearn entrena un modelo, los datos son arrays numpy. Cuando matplotlib dibuja una curva, los puntos son arrays numpy. Entender numpy — especialmente shape, dtype, axis y broadcasting — es entender el lenguaje común de todo el ecosistema científico de Python.
  1. ¿Por qué este código produce un resultado distinto del esperado? ¿Cómo lo corregirías?
    import numpy as np
    
    a = np.array([1, 2, 3])
    b = np.array([4, 5, 6])
    
    # Intenta calcular el producto escalar
    resultado = a * b
    print(resultado)   # ¿qué imprime?
  2. Predice el resultado de cada operación de broadcasting:
    import numpy as np
    
    m = np.ones((3, 3))
    v = np.array([1, 2, 3])
    
    print(m + v)
    print(m * v)
    print(m * 5)
  3. Crea una matriz 4×4 con valores aleatorios entre 0 y 1, calcula la media por filas y por columnas, y filtra solo los elementos mayores que la media total.
  4. ¿Por qué numpy es más rápido que las listas de Python para cálculo numérico? Explícalo con el concepto de tipo homogéneo y memoria contigua.
Respuesta orientativa
a

a * b produce la multiplicación elemento a elemento — [4, 10, 18] — no el producto escalar. Para el producto escalar:

print(np.dot(a, b))    # 32 — producto escalar
print(a @ b)           # 32 — operador @ equivalente
print((a * b).sum())   # 32 — suma del producto elemento a elemento
b

m + v: numpy expande v a forma (3, 3) sumando el vector a cada fila. Resultado: matriz con filas [2, 3, 4].

m * v: mismo broadcasting — multiplica cada fila por [1, 2, 3]. Resultado: filas [1, 2, 3].

m * 5: el escalar 5 se expande a toda la matriz. Resultado: matriz de unos multiplicada por 5 — todas las celdas valen 5.

c
import numpy as np

np.random.seed(42)
matriz = np.random.rand(4, 4)
print("Matriz:\n", matriz.round(2))

media_filas = matriz.mean(axis=1)
media_cols  = matriz.mean(axis=0)
media_total = matriz.mean()

print(f"\nMedia por filas: {media_filas.round(2)}")
print(f"Media por cols:  {media_cols.round(2)}")
print(f"Media total:     {media_total:.2f}")

mayores = matriz[matriz > media_total]
print(f"\nElementos > media: {mayores.round(2)}")
print(f"Cuántos: {len(mayores)} de {matriz.size}")
d

Las listas de Python pueden contener cualquier tipo de objeto — cada elemento es un puntero a un objeto Python en el heap, con su propio tipo, su propio contador de referencias y su propio overhead de memoria. Sumar dos listas requiere un bucle Python que en cada iteración comprueba el tipo de cada elemento, desempaqueta el valor, lo suma y empaqueta el resultado.

Los arrays numpy son homogéneos — todos los elementos del mismo tipo — y se almacenan en un bloque contiguo de memoria sin overhead por elemento. Las operaciones se delegan a código C compilado que opera directamente sobre la memoria sin pasar por el intérprete Python en cada elemento. Menos overhead, mejor uso de la caché del procesador, operaciones SIMD posibles.

pandas: el DataFrame como objeto central del análisis teoría

pandas construye sobre numpy para ofrecer estructuras de datos diseñadas específicamente para el análisis de datos: el DataFrame — una tabla con filas y columnas etiquetadas — y la Series — una columna con índice. Si numpy es la aritmética, pandas es la hoja de cálculo programable.

El DataFrame es el objeto más frecuente en todo el código de análisis de datos que genera la IA. Entender su estructura — qué es, qué atributos tiene, cómo se opera sobre él — es el prerequisito para leer y supervisar ese código. Esta clase es una presentación del ecosistema — los métodos de pandas merecen un curso propio.

— Series y DataFrame: los dos tipos fundamentales —

import pandas as pd
import numpy as np

# Series — una columna con índice
ventas = pd.Series([150, 80, 200, 95, 175],
                   index=["ene", "feb", "mar", "abr", "may"],
                   name="ventas_2024")

print(ventas)
print(f"\ntipo:  {type(ventas)}")
print(f"dtype: {ventas.dtype}")
print(f"shape: {ventas.shape}")

print("---")

# El array numpy subyacente
print(f"values: {ventas.values}")      # array numpy
print(f"index:  {ventas.index.tolist()}")

print("---")

# DataFrame — tabla con filas y columnas etiquetadas
df = pd.DataFrame({
    "producto":  ["manzana", "pera", "naranja", "kiwi"],
    "ventas":    [150, 80, 120, 200],
    "precio":    [1.20, 0.90, 0.80, 1.50],
    "categoria": ["fruta", "fruta", "cítrico", "exótico"]
})

print(df)
print(f"\ntipo:    {type(df)}")
print(f"shape:   {df.shape}")     # (4, 4) — filas, columnas
print(f"columns: {df.columns.tolist()}")
print(f"index:   {df.index.tolist()}")
print(f"dtypes:\n{df.dtypes}")

— El DataFrame es un objeto: atributos y métodos —

Conectando con la Parte II: un DataFrame es un objeto de tipo DataFrame definido en el módulo pandas.core.frame. Como cualquier objeto Python, tiene atributos que describen su estado y métodos que realizan operaciones. La sintaxis df.shape es acceso a un atributo, df.describe() es llamada a un método — exactamente igual que lista.append().

import pandas as pd

# El DataFrame como objeto
print(type(df))           # 
print(type(df).__module__) # 'pandas.core.frame'

print("---")

# Atributos — describen el estado del DataFrame
print(f"shape:   {df.shape}")      # (filas, columnas)
print(f"ndim:    {df.ndim}")       # 2 — siempre 2D
print(f"size:    {df.size}")       # total de elementos
print(f"dtypes:\n{df.dtypes}")     # tipo de cada columna

print("---")

# Métodos de exploración — los más frecuentes en código de IA
print(df.head(2))         # primeras 2 filas
print(df.tail(2))         # últimas 2 filas
print(df.describe())      # estadísticas de columnas numéricas
df.info()                  # resumen — tipos, nulos, memoria

print("---")

# Acceso a columnas — dos sintaxis equivalentes
print(df["ventas"])        # sintaxis diccionario — siempre funciona
print(df.ventas)            # sintaxis atributo — solo si el nombre es válido
print(df["ventas"] is df.ventas)   # False — copias distintas

print("---")

# Acceso a filas — loc e iloc
print(df.loc[0])           # fila por etiqueta del índice
print(df.iloc[0])          # fila por posición numérica
print(df.loc[0:1, ["producto", "ventas"]])   # filas y columnas

— Crear un DataFrame desde distintas fuentes —

import pandas as pd
import numpy as np

# Desde un diccionario — lo más frecuente
df_dict = pd.DataFrame({
    "nombre": ["Ana", "Bob", "Carlos"],
    "edad":   [25, 30, 28],
    "nota":   [8.5, 7.2, 9.1]
})

# Desde un array numpy
arr = np.random.randint(0, 100, (4, 3))
df_numpy = pd.DataFrame(arr,
                        columns=["A", "B", "C"],
                        index=["fila1", "fila2", "fila3", "fila4"])

# Desde CSV — el caso más frecuente en análisis real
# df_csv = pd.read_csv("datos/ventas.csv")
# df_csv = pd.read_csv("datos/ventas.csv",
#                      sep=";", decimal=",",  ← para CSVs europeos
#                      encoding="utf-8",
#                      parse_dates=["fecha"])

print(df_dict)
print("---")
print(df_numpy)

print("---")

# La relación con numpy — cada columna es un array
print(type(df_dict["edad"].values))   # 
print(df_dict["edad"].values)         # [25 30 28] — array numpy

# Un DataFrame es esencialmente
# un diccionario de arrays numpy con índice compartido
print({col: df_dict[col].values for col in df_dict.columns})

— Las operaciones más frecuentes en código de IA —

import pandas as pd
import numpy as np

df = pd.DataFrame({
    "producto":  ["manzana", "pera", "naranja", "kiwi", "fresa"],
    "ventas":    [150, 80, None, 200, 95],
    "precio":    [1.20, 0.90, 0.80, 1.50, 2.00],
    "categoria": ["fruta", "fruta", "cítrico", "exótico", "fruta"]
})

# Filtrar filas
print(df[df["ventas"] > 100])
print(df[df["categoria"] == "fruta"])

print("---")

# Añadir columna
df["ingreso"] = df["ventas"] * df["precio"]
print(df[["producto", "ventas", "precio", "ingreso"]])

print("---")

# Nulos — detectar y eliminar
print(df.isnull().sum())        # nulos por columna
df_limpio = df.dropna()         # eliminar filas con nulos
df_relleno = df.fillna(0)      # rellenar nulos con 0

print("---")

# groupby — agrupar y agregar
resumen = (
    df_limpio
    .groupby("categoria")["ventas"]
    .agg(["sum", "mean", "count"])
    .round(1)
)
print(resumen)

print("---")

# sort_values — ordenar
print(df_limpio.sort_values("ventas", ascending=False))

print("---")

# El estilo encadenado — paradigma funcional en pandas
resultado = (
    df
    .dropna()
    .query("ventas > 100")
    .assign(ingreso=lambda d: d["ventas"] * d["precio"])
    .sort_values("ingreso", ascending=False)
    .reset_index(drop=True)
)
print(resultado)

— Combinar DataFrames: merge y concat —

import pandas as pd

ventas = pd.DataFrame({
    "producto_id": [1, 2, 3],
    "ventas": [150, 80, 200]
})

productos = pd.DataFrame({
    "producto_id": [1, 2, 3],
    "nombre": ["manzana", "pera", "kiwi"],
    "categoria": ["fruta", "fruta", "exótico"]
})

# merge — unir por una columna clave (como SQL JOIN)
df_merged = pd.merge(ventas, productos, on="producto_id")
print(df_merged)

print("---")

# concat — apilar DataFrames
df_2023 = pd.DataFrame({"mes": ["ene", "feb"], "ventas": [100, 120]})
df_2024 = pd.DataFrame({"mes": ["ene", "feb"], "ventas": [130, 150]})

df_total = pd.concat([df_2023, df_2024],
                     keys=["2023", "2024"],
                     ignore_index=True)
print(df_total)
pandas y numpy en el contexto del curso: esta clase es una presentación del ecosistema — los tipos fundamentales, los atributos, los métodos más frecuentes en código de IA. Pandas y numpy tienen cada uno suficiente profundidad para un curso completo. Lo que sí queda claro aquí: un DataFrame es un objeto de tipo DataFrame definido en el módulo pandas, construido sobre arrays numpy, con atributos que describen su estructura y métodos que operan sobre sus datos siguiendo el paradigma funcional que vimos en la Clase 6c.
  1. ¿Cuál es la relación entre un DataFrame y numpy? ¿Qué devuelve df["ventas"].values? ¿De qué tipo es?
  2. Lee este fragmento de código de IA y describe qué hace cada línea del encadenamiento:
    resultado = (
        df
        .dropna(subset=["ventas"])
        .query("categoria == 'fruta'")
        .assign(
            ingreso=lambda d: d["ventas"] * d["precio"],
            rentable=lambda d: d["ingreso"] > 100
        )
        .groupby("rentable")["ingreso"]
        .agg(["sum", "mean", "count"])
        .round(2)
    )
  3. ¿Por qué df["ventas"] y df.ventas son equivalentes en la mayoría de casos pero no siempre? ¿Cuándo falla la sintaxis de atributo?
  4. Crea un DataFrame con datos de tres alumnos — nombre, nota_teoria, nota_practica —, añade una columna nota_final como media ponderada (60% teoría, 40% práctica), filtra los aprobados (nota_final ≥ 5) y ordénalos por nota descendente. Hazlo en un solo encadenamiento.
Respuesta orientativa
a

Un DataFrame es esencialmente un diccionario de arrays numpy con un índice compartido. Cada columna numérica se almacena internamente como un array numpy. df["ventas"].values devuelve el array numpy subyacente de la columna — tipo numpy.ndarray. Las operaciones de pandas sobre columnas numéricas se delegan a numpy, que las ejecuta en C sin bucles Python.

b

.dropna(subset=["ventas"]): elimina filas donde ventas es nulo.

.query("categoria == 'fruta'"): filtra solo las filas de categoría fruta.

.assign(ingreso=..., rentable=...): añade dos columnas nuevas — ingreso como ventas × precio y rentable como booleano si ingreso > 100.

.groupby("rentable")["ingreso"]: agrupa por la columna booleana rentable y selecciona la columna ingreso.

.agg(["sum", "mean", "count"]): calcula suma, media y conteo del ingreso para cada grupo. .round(2): redondea a 2 decimales.

c

Son equivalentes cuando el nombre de la columna es un identificador Python válido — sin espacios, sin guiones, sin empezar por número. La sintaxis de atributo falla cuando el nombre de la columna: contiene espacios — df["mi columna"] ✓, df.mi columna ✗ —, empieza por número — df["2024"] ✓, df.2024 ✗ —, coincide con un método de DataFrame — df["count"] devuelve la columna, df.count devuelve el método. Por eso se recomienda siempre usar df["columna"] en código serio.

d
import pandas as pd

resultado = (
    pd.DataFrame({
        "nombre":       ["Ana", "Bob", "Carlos",
                          "Diana", "Eva"],
        "nota_teoria":  [8.5, 4.0, 6.5, 9.0, 3.5],
        "nota_practica":[7.0, 6.5, 5.0, 8.0, 4.0]
    })
    .assign(
        nota_final=lambda d:
            d["nota_teoria"] * 0.6 +
            d["nota_practica"] * 0.4
    )
    .query("nota_final >= 5")
    .sort_values("nota_final", ascending=False)
    .reset_index(drop=True)
)
print(resultado)
El encadenamiento funcional de pandas es exactamente el paradigma funcional de la Clase 6c — cada método devuelve un DataFrame nuevo, el original no se modifica, y la composición de operaciones simples resuelve el problema complejo.

— Más allá de pandas: cuando los datos no caben en memoria —

pandas carga los datos enteros en la memoria RAM del ordenador. Con datos de hasta unos pocos gigabytes funciona perfectamente. Cuando los datos son más grandes — decenas o cientos de gigabytes, o datos distribuidos en varios servidores — pandas no es la herramienta correcta. El ecosistema Python tiene alternativas que mantienen una API muy similar a pandas para que el salto no sea brusco.

polars

El sustituto más directo de pandas para datos que caben en memoria pero son grandes. Escrito en Rust — mucho más rápido que pandas en operaciones de transformación y agrupación. API similar a pandas pero con evaluación lazy — construye el plan de ejecución antes de ejecutar, optimizando automáticamente las operaciones. La IA lo propone cada vez más como alternativa a pandas cuando el rendimiento importa.

import polars as pl

df = pl.read_csv("ventas.csv")
resultado = (
    df
    .filter(pl.col("ventas") > 100)
    .group_by("categoria")
    .agg(pl.col("ventas").mean())
)

dask

Extiende pandas para datos que no caben en memoria — los divide en particiones y los procesa en paralelo. Su API es casi idéntica a pandas — casi cualquier código pandas se convierte a dask cambiando el import. Ejecuta en un solo ordenador o en un cluster de varios. Ideal para datos de entre 10 GB y varios TB.

import dask.dataframe as dd

# Lee en particiones — no todo a la vez
df = dd.read_csv("datos/*.csv")
resultado = (
    df[df["ventas"] > 100]
    .groupby("categoria")["ventas"]
    .mean()
    .compute()   # ← ejecuta el plan
)

PySpark

La interfaz Python de Apache Spark — el estándar industrial para datos distribuidos a gran escala. Opera sobre clusters de decenas o cientos de máquinas. API de DataFrames similar a pandas — pero la ejecución es distribuida y lazy. Es la herramienta que usan grandes empresas para procesar petabytes de datos. La curva de aprendizaje es mayor que dask o polars.

from pyspark.sql import SparkSession

spark = SparkSession.builder \
    .appName("analisis").getOrCreate()

df = spark.read.csv("datos/*.csv",
                    header=True)
resultado = (
    df.filter(df.ventas > 100)
    .groupBy("categoria")
    .avg("ventas")
)

DuckDB

Una base de datos analítica que se ejecuta dentro de Python sin servidor externo. Permite ejecutar SQL directamente sobre DataFrames de pandas, ficheros CSV o Parquet sin cargarlos en memoria. Extremadamente rápido para consultas analíticas — a menudo más que pandas para agregaciones complejas sobre datos grandes. La IA lo propone con frecuencia cuando el problema es de consulta más que de transformación.

import duckdb

# SQL directamente sobre CSV o pandas
resultado = duckdb.sql("""
    SELECT categoria,
           AVG(ventas) as media
    FROM 'datos/*.csv'
    WHERE ventas > 100
    GROUP BY categoria
""").df()   # ← convierte a pandas

— Cuándo usar cada herramienta —

Herramienta Escala Cuándo usarla API
pandas hasta ~5 GB Análisis general, exploración, limpieza Estándar
polars hasta ~50 GB Cuando pandas es lento, datos medianos Similar a pandas
dask 10 GB — varios TB Código pandas existente que necesita escalar Casi idéntica a pandas
DuckDB hasta ~100 GB Consultas analíticas, SQL sobre ficheros SQL
PySpark TB — PB Datos distribuidos, clusters, producción Similar con diferencias
Lo que cambia y lo que no: las cuatro alternativas mantienen el concepto de DataFrame — tabla con filas y columnas — y operaciones similares: filtrar, agrupar, agregar. Lo que cambia es la ejecución: pandas es inmediata y en memoria, las alternativas son lazy — construyen un plan de ejecución y lo ejecutan cuando se lo pides. Todo lo aprendido sobre pandas transfiere directamente — las diferencias son de API, no de concepto.
matplotlib: visualización como objeto teoría

matplotlib es la librería de visualización fundamental de Python — la base sobre la que están construidas seaborn, pandas.plot y muchas otras. Cuando la IA genera código de visualización en Python, casi siempre usa matplotlib directamente o a través de una de estas librerías.

Entender matplotlib requiere entender su modelo de objetos: una figura tiene una jerarquía de objetos anidados — Figure, Axes, Line2D, Text — cada uno con atributos y métodos. Es exactamente el mismo modelo de objetos que hemos visto en toda la clase.

— Figure y Axes: la distinción más importante —

La confusión más frecuente con matplotlib es no entender la diferencia entre Figure y Axes. La Figure es el contenedor exterior — la ventana o el fichero de imagen. El Axes es el área de dibujo dentro de la Figure — donde van los ejes, los datos, las etiquetas. Una Figure puede contener varios Axes — por ejemplo una cuadrícula de 2×2 gráficos.

La distinción importa porque hay dos formas de usar matplotlib: la interfaz pyplot — que gestiona Figure y Axes automáticamente y es más sencilla — y la interfaz orientada a objetos — donde tú controlas explícitamente ambos. La IA usa cada vez más la segunda porque es más clara y más fácil de depurar.

Jerarquía de objetos de matplotlib
Figure — el contenedor exterior (fig)
Axes — el área de dibujo (ax)
Line2D, PathCollection, Rectangle... — los elementos del gráfico
XAxis, YAxis, Title, Legend... — decoradores
Axes — otro Axes si hay subplots

— Las dos interfaces: pyplot vs orientada a objetos —

import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, 2 * np.pi, 100)
y = np.sin(x)

# Interfaz pyplot — más sencilla, gestiona Figure/Axes implícitamente
plt.figure(figsize=(8, 4))
plt.plot(x, y, color="steelblue", label="sin(x)")
plt.title("Seno — interfaz pyplot")
plt.xlabel("x")
plt.ylabel("y")
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("seno_pyplot.png", dpi=150)
plt.show()

print("---")

# Interfaz orientada a objetos — más explícita y más clara
# fig es el contenedor, ax es el área de dibujo
fig, ax = plt.subplots(figsize=(8, 4))

ax.plot(x, y, color="steelblue", label="sin(x)")
ax.set_title("Seno — interfaz OO")
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.legend()
ax.grid(True, alpha=0.3)

fig.tight_layout()
fig.savefig("seno_oo.png", dpi=150)
plt.show()

print("---")

# fig y ax son objetos — tienen tipo, atributos, métodos
print(type(fig))   # 
print(type(ax))    # 
print(fig.get_size_inches())   # (8.0, 4.0)
print(ax.get_title())          # 'Seno — interfaz OO'
print(ax.get_xlim())           # límites del eje x

— Los tipos de gráfico más frecuentes —

import matplotlib.pyplot as plt
import numpy as np

np.random.seed(42)

# Datos de ejemplo
x = np.linspace(0, 10, 50)
y = 2 * x + np.random.randn(50) * 2
categorias = ["A", "B", "C", "D"]
valores = [23, 45, 12, 38]
datos_hist = np.random.randn(500)

# Cuadrícula 2×3 de gráficos — subplots
fig, axes = plt.subplots(2, 3, figsize=(14, 8))

# plot — línea o puntos
axes[0, 0].plot(x, y, color="steelblue", linewidth=1.5)
axes[0, 0].set_title("plot — línea")
axes[0, 0].set_xlabel("x")
axes[0, 0].set_ylabel("y")

# scatter — dispersión
axes[0, 1].scatter(x, y, color="tomato", alpha=0.6, s=30)
axes[0, 1].set_title("scatter — dispersión")

# bar — barras
axes[0, 2].bar(categorias, valores,
               color="steelblue", edgecolor="white", linewidth=0.5)
axes[0, 2].set_title("bar — barras")

# hist — histograma
axes[1, 0].hist(datos_hist, bins=30, color="mediumseagreen",
               edgecolor="white", linewidth=0.5)
axes[1, 0].set_title("hist — histograma")

# boxplot — caja y bigotes
datos_box = [np.random.randn(100) * i for i in [1, 2, 3]]
axes[1, 1].boxplot(datos_box, labels=["G1", "G2", "G3"])
axes[1, 1].set_title("boxplot — distribución")

# fill_between — área bajo la curva
x2 = np.linspace(0, 2 * np.pi, 100)
y2 = np.sin(x2)
axes[1, 2].plot(x2, y2, color="steelblue")
axes[1, 2].fill_between(x2, y2, 0,
                        where=(y2 > 0), alpha=0.3,
                        color="steelblue", label="positivo")
axes[1, 2].fill_between(x2, y2, 0,
                        where=(y2 < 0), alpha=0.3,
                        color="tomato", label="negativo")
axes[1, 2].legend()
axes[1, 2].set_title("fill_between — área")

fig.suptitle("Tipos de gráfico más frecuentes",
             fontsize=14, y=1.02)
fig.tight_layout()
fig.savefig("tipos_graficos.png", dpi=150, bbox_inches="tight")
plt.show()

— Integración con pandas: df.plot() —

pandas tiene integración directa con matplotlib — el método df.plot() genera un gráfico matplotlib desde el DataFrame sin necesidad de extraer los datos manualmente. Internamente llama a matplotlib — el resultado es un objeto Axes normal que puedes seguir personalizando.

import pandas as pd
import matplotlib.pyplot as plt
import numpy as np

df = pd.DataFrame({
    "mes":     ["ene", "feb", "mar", "abr", "may"],
    "ventas":  [150, 180, 120, 200, 175],
    "costes":  [90, 110, 80, 130, 100],
}).set_index("mes")

# df.plot() — atajo para gráficos rápidos
fig, axes = plt.subplots(1, 3, figsize=(14, 4))

# Línea
df.plot(ax=axes[0], title="Línea")

# Barras
df.plot(kind="bar", ax=axes[1], title="Barras")

# Área
df.plot(kind="area", ax=axes[2], alpha=0.5, title="Área")

fig.tight_layout()
fig.savefig("pandas_plot.png", dpi=150)
plt.show()

print("---")

# df.plot() devuelve un Axes — puedes seguir personalizando
fig, ax = plt.subplots(figsize=(8, 4))
df["ventas"].plot(ax=ax, color="steelblue",
                   marker="o", linewidth=2)
df["costes"].plot(ax=ax, color="tomato",
                  marker="s", linewidth=2, linestyle="--")

# Personalización sobre el Axes devuelto
ax.set_title("Ventas vs Costes 2024", fontsize=14)
ax.set_ylabel("Euros")
ax.legend(["Ventas", "Costes"])
ax.grid(True, alpha=0.3)
ax.fill_between(df.index,
                df["ventas"], df["costes"],
                alpha=0.1, color="steelblue",
                label="margen")

fig.tight_layout()
fig.savefig("ventas_costes.png", dpi=150)
plt.show()

— Subplots y personalización: el patrón completo —

import matplotlib.pyplot as plt
import numpy as np

# El patrón completo que usa la IA
np.random.seed(42)
datos = {
    "grupo_a": np.random.normal(5, 1.5, 100),
    "grupo_b": np.random.normal(7, 2.0, 100),
    "grupo_c": np.random.normal(4, 1.0, 100),
}

fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# Histograma superpuesto
colores = ["steelblue", "tomato", "mediumseagreen"]
for (nombre, valores), color in zip(datos.items(), colores):
    axes[0].hist(valores, bins=20, alpha=0.5,
                 color=color, label=nombre,
                 edgecolor="white", linewidth=0.5)

axes[0].set_title("Distribuciones por grupo")
axes[0].set_xlabel("Valor")
axes[0].set_ylabel("Frecuencia")
axes[0].legend()
axes[0].grid(True, alpha=0.3)

# Boxplot comparativo
axes[1].boxplot(
    [v for v in datos.values()],
    labels=list(datos.keys()),
    patch_artist=True,
    boxprops=dict(facecolor="steelblue", alpha=0.5),
    medianprops=dict(color="tomato", linewidth=2)
)
axes[1].set_title("Boxplot por grupo")
axes[1].set_ylabel("Valor")
axes[1].grid(True, axis="y", alpha=0.3)

# Título general de la figura
fig.suptitle("Análisis comparativo de grupos",
             fontsize=14, fontweight="bold")
fig.tight_layout()
fig.savefig("analisis_grupos.png",
            dpi=150, bbox_inches="tight")
plt.show()

print("---")

# Los objetos gráficos — todo es accesible
print(type(fig))           # Figure
print(type(axes))          # ndarray de Axes
print(axes.shape)          # (1, 2) — 1 fila, 2 columnas
print(axes[0].get_title()) # 'Distribuciones por grupo'
print(len(fig.axes))       # 2 — número de Axes en la Figure

— seaborn: matplotlib con menos código —

seaborn está construido sobre matplotlib y produce gráficos estadísticos más elaborados con menos código. Trabaja directamente con DataFrames de pandas — le pasas el DataFrame y los nombres de las columnas y él se encarga del resto. El resultado sigue siendo un objeto Axes de matplotlib que puedes personalizar con los mismos métodos de siempre.

import seaborn as sns
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np

np.random.seed(42)
df = pd.DataFrame({
    "ventas":    np.random.normal(150, 30, 100),
    "precio":    np.random.uniform(0.5, 2.5, 100),
    "categoria": np.random.choice(["fruta", "cítrico", "exótico"], 100)
})

fig, axes = plt.subplots(1, 3, figsize=(14, 4))

# boxplot por categoría — mucho menos código que matplotlib puro
sns.boxplot(data=df, x="categoria", y="ventas",
            ax=axes[0], palette="Set2")
axes[0].set_title("Ventas por categoría")

# scatter con regresión
sns.regplot(data=df, x="precio", y="ventas",
            ax=axes[1], scatter_kws={"alpha": 0.4},
            line_kws={"color": "tomato"})
axes[1].set_title("Precio vs Ventas")

# histograma por categoría
for cat in df["categoria"].unique():
    datos_cat = df[df["categoria"] == cat]["ventas"]
    sns.kdeplot(datos_cat, ax=axes[2], label=cat)
axes[2].set_title("Densidad de ventas")
axes[2].legend()

# Los Axes de seaborn son Axes de matplotlib — misma API
for ax in axes:
    ax.grid(True, alpha=0.3)

fig.suptitle("seaborn sobre matplotlib", fontsize=13)
fig.tight_layout()
fig.savefig("seaborn_ejemplo.png", dpi=150, bbox_inches="tight")
plt.show()
El modelo mental completo: Figure es el contenedor, Axes es el área de dibujo. plt.subplots() crea ambos y los devuelve. Todo lo que dibujas va sobre el Axes — con métodos como ax.plot(), ax.scatter(), ax.bar(). Todo lo que personaliza va también sobre el Axes — ax.set_title(), ax.set_xlabel(), ax.legend(). df.plot(ax=ax) y sns.boxplot(ax=ax) dibujan sobre el mismo Axes — son atajos que delegan en matplotlib.
  1. ¿Cuál es la diferencia entre Figure y Axes? ¿Cuántos Axes puede tener una Figure? ¿Cuántas Figure puede tener un Axes?
  2. ¿Qué hace exactamente esta línea de código de IA? ¿Qué tipo tienen fig y axes?
    fig, axes = plt.subplots(2, 3, figsize=(14, 8))
  3. ¿Cuál es la diferencia entre estas dos formas de generar el mismo gráfico? ¿Cuándo usarías cada una?
    # Forma A
    plt.plot(x, y)
    plt.title("Mi gráfico")
    plt.show()
    
    # Forma B
    fig, ax = plt.subplots()
    ax.plot(x, y)
    ax.set_title("Mi gráfico")
    plt.show()
  4. ¿Por qué df.plot(ax=ax) y sns.boxplot(ax=ax) aceptan el mismo parámetro ax? ¿Qué tipo de objeto es ese ax? ¿Qué significa pasarlo como argumento?
Respuesta orientativa
a

La Figure es el contenedor exterior — la ventana o el fichero de imagen. El Axes es el área de dibujo dentro de la Figure — donde van los ejes, los datos y las etiquetas. Una Figure puede contener múltiples Axes — en una cuadrícula de subplots cada celda es un Axes. Un Axes pertenece exactamente a una sola Figure — no puede estar en dos figuras a la vez.

b

Crea una Figure de tamaño 14×8 pulgadas con una cuadrícula de 2 filas y 3 columnas de Axes — 6 áreas de dibujo en total. fig es de tipo matplotlib.figure.Figure. axes es un array numpy de forma (2, 3) cuyos elementos son objetos Axes. Para acceder al Axes de la fila 0, columna 1: axes[0, 1].

c

La Forma A usa la interfaz pyplot — gestiona Figure y Axes implícitamente a través de un estado global. Es más concisa para gráficos simples y rápidos en sesiones interactivas. El problema es que depende del estado global — si tienes varias figuras abiertas puede dibujar en la equivocada.

La Forma B usa la interfaz orientada a objetos — controla explícitamente sobre qué Axes se dibuja. Es más verbosa pero más clara y más robusta cuando hay múltiples figuras o subplots. Es la forma que usa la IA en código de producción y la que se recomienda para cualquier gráfico que no sea puramente exploratorio.

d

Aceptan el mismo parámetro porque ambas están construidas sobre matplotlib y el parámetro ax en ambas es un objeto matplotlib.axes.Axes. Pasar ax=ax le dice a la función "dibuja sobre este Axes concreto en lugar de crear uno nuevo". Esto permite combinar df.plot(), sns.boxplot() y ax.plot() puro en el mismo gráfico — cada uno añade elementos sobre el mismo Axes.

El modelo unificado: pandas, seaborn y matplotlib hablan el mismo idioma — el objeto Axes. Entender que ese objeto es el punto de conexión entre las tres librerías permite leer y combinar cualquier código de visualización que genere la IA.