Clase 7 — Módulos, paquetes y el ecosistema de datos
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
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
globals()
para verificarlo:
# Bloque A
import os
# Bloque B
from os import getcwd
# Bloque C
from os import getcwd as directorio_actual
join = "unión" # variable propia
print(join)
from os.path import * # importa todo
print(join) # ¿qué imprime ahora?
import os
ruta = os.path.join("datos", "2024", "ventas.csv")
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?
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.
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.
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.
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.
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 —
# 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
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.
__init__.py?
¿Puede estar vacío?
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
# fichero: herramienta.py
print(f"__name__ = {__name__}")
def procesar(x):
return x * 2
if __name__ == "__main__":
print("ejecutando directamente")
print(procesar(5))
from analisis.datos.limpieza import quitar_outliers
¿Qué ficheros y carpetas
deben existir?
¿Cuáles necesitan
__init__.py?
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.
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.
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.
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.
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
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__"))
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.
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))
import pandas as pd
print(type(pd))
print(type(pd.DataFrame))
print(type(pd.DataFrame({"a": [1]})))
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.
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}")
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.
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.
pd →
pd.DataFrame →
pd.DataFrame({...}):
contenedor → tipo → instancia.
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]}")
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.
globals()
y vars(os)?
¿Son el mismo diccionario?
¿Contienen los mismos nombres?
Verifica con código.
import math
# Acceso A
resultado_a = math.sqrt(16)
# Acceso B
resultado_b = math.__dict__["sqrt"](16)
print(resultado_a == resultado_b)
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?
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
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.
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].
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.
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.
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.
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)
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.
ruta = "datos" + "/" + "2024" + "/" + "ventas.csv"
print(ruta)
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")
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))
.xlsx
en la carpeta actual
y sus subcarpetas,
imprima sus nombres
y tamaños en bytes,
y los ordene por tamaño
descendente.
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
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.
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.
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 '✗'}")
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.
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"
importlib.import_module()
en lugar de import?
Escribe un ejemplo
donde el nombre del módulo
venga de una lista
de configuración.
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))
sys.modules
y luego reimportarlo,
versus usar
importlib.reload()?
¿En qué casos importa
esa diferencia?
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
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")
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.
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.
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.
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}")
shape, dtype,
axis y broadcasting —
es entender el lenguaje común
de todo el ecosistema científico
de Python.
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?
import numpy as np
m = np.ones((3, 3))
v = np.array([1, 2, 3])
print(m + v)
print(m * v)
print(m * 5)
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
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.
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}")
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)
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.
df["ventas"].values?
¿De qué tipo es?
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)
)
df["ventas"]
y df.ventas
son equivalentes
en la mayoría de casos
pero no siempre?
¿Cuándo falla
la sintaxis de atributo?
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.
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.
.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.
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.
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)
— 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 |
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.
— 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()
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.
Figure
y Axes?
¿Cuántos Axes
puede tener una Figure?
¿Cuántas Figure
puede tener un Axes?
fig y
axes?
fig, axes = plt.subplots(2, 3, figsize=(14, 8))
# 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()
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?
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.
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].
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.
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.
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.