Clase Extra — Introducción a la Programación Orientada a Objetos
A lo largo del curso hemos trabajado con datos — listas, diccionarios, DataFrames — y con funciones que operan sobre esos datos. En programas pequeños esto funciona bien. En programas más grandes aparece un problema: los datos y las funciones que los manejan viven separados y hay que mantenerlos sincronizados manualmente.
La Programación Orientada a Objetos — OOP — resuelve esto agrupando datos y comportamiento en una sola estructura: el objeto. Un objeto sabe qué datos tiene y qué puede hacer con ellos. Todo en un mismo lugar.
— El problema sin OOP: datos y funciones separados —
# Sin OOP — datos y funciones separados
# Alumno 1
nombre_1 = "Ana García"
email_1 = "ana@uvigo.es"
notas_1 = [8.5, 7.0, 9.0, 8.0]
# Alumno 2
nombre_2 = "Bob Martínez"
email_2 = "bob@uvigo.es"
notas_2 = [6.0, 7.5, 5.5, 6.5]
# Funciones separadas que operan sobre esos datos
def calcular_media(notas):
return sum(notas) / len(notas)
def esta_aprobado(notas):
return calcular_media(notas) >= 5.0
def informe(nombre, email, notas):
media = calcular_media(notas)
aprobado = "Aprobado" if esta_aprobado(notas) else "Suspenso"
return f"{nombre} ({email}): media={media:.1f} — {aprobado}"
# Hay que recordar qué datos van con qué alumno
print(informe(nombre_1, email_1, notas_1))
print(informe(nombre_2, email_2, notas_2))
# Si añadimos un tercer alumno hay que crear más variables
# nombre_3, email_3, notas_3... el código crece sin control
# Fácil mezclar datos de un alumno con los de otro
— La solución OOP: todo en un solo lugar —
Con OOP agrupamos los datos y las funciones que los manejan en una clase. Cada alumno es un objeto que lleva consigo su nombre, su email y sus notas — y sabe calcular su media, saber si está aprobado y generar su informe. No hay riesgo de mezclar datos de un alumno con los de otro.
# Con OOP — datos y comportamiento juntos
class Alumno:
def __init__(self, nombre, email, notas):
self.nombre = nombre
self.email = email
self.notas = notas
def calcular_media(self):
return sum(self.notas) / len(self.notas)
def esta_aprobado(self):
return self.calcular_media() >= 5.0
def informe(self):
media = self.calcular_media()
estado = "Aprobado" if self.esta_aprobado() else "Suspenso"
return f"{self.nombre} ({self.email}): media={media:.1f} — {estado}"
# Crear objetos — cada alumno lleva sus propios datos
ana = Alumno("Ana García", "ana@uvigo.es", [8.5, 7.0, 9.0, 8.0])
bob = Alumno("Bob Martínez", "bob@uvigo.es", [6.0, 7.5, 5.5, 6.5])
# Cada objeto sabe hacer sus propias operaciones
print(ana.informe())
print(bob.informe())
# Añadir un tercero es trivial
carlos = Alumno("Carlos Pérez", "carlos@uvigo.es", [9.0, 8.5, 9.5])
print(carlos.informe())
— La analogía: molde y objeto concreto —
Clase — el molde
Define la estructura — qué datos tendrá cada objeto y qué operaciones podrá hacer. Es la plantilla. No es un alumno concreto — es la definición de qué es un alumno.
Instancia — el objeto concreto
Un objeto real creado
a partir del molde.
ana, bob
y carlos
son instancias de
Alumno
— cada una con sus propios datos
pero con las mismas capacidades.
# Más analogías — de las slides
# Clase: libro de recetas → Instancia: una receta concreta
# Clase: biblioteca → Instancia: un libro concreto
# Clase: Pizza → Instancia: una Margarita o una Tropical
# Clase: Alumno → Instancia: Ana, Bob, Carlos
# Clase: DataFrame → Instancia: df_ventas, df_precios
# Ya has usado clases sin saberlo
import pandas as pd
import matplotlib.pyplot as plt
df = pd.DataFrame({"a": [1, 2, 3]})
fig, ax = plt.subplots()
print(type(df)) #
print(type(fig)) #
print(type(ax)) #
print(type(ana)) #
# Todos son objetos — de clases distintas
# La clase define el tipo — el objeto es la instancia concreta
ana.informe()
no necesita recibir el nombre
ni el email como argumentos
si la versión sin OOP sí los necesitaba?
El problema principal es que
los datos de cada alumno
están en variables separadas
— nombre_1,
email_1,
notas_1 —
sin ninguna estructura
que las mantenga juntas.
Con 50 alumnos habría
150 variables separadas.
Es fácil pasar
notas_1
a la función de
nombre_2
por error —
sin que Python
lo detecte.
El código es imposible
de mantener.
df
es una instancia de
pandas.DataFrame.
fig
es una instancia de
matplotlib.figure.Figure.
ax
es una instancia de
matplotlib.axes.Axes.
Una lista como
[1, 2, 3]
es una instancia de
list.
Un string como
"hola"
es una instancia de
str.
Un entero como
42
es una instancia de
int.
La clase es el molde — define la estructura y el comportamiento de todos los objetos que se creen a partir de ella. No es un objeto concreto — es la plantilla. La instancia es el objeto concreto creado a partir del molde — con sus propios datos. El molde de una galleta define la forma — estrella, corazón — pero no es una galleta. Cada galleta que haces con ese molde es una instancia distinta con sus propios ingredientes.
Porque ana
es un objeto que lleva
consigo su nombre,
su email y sus notas —
están guardados
dentro del objeto.
Cuando ana.informe()
se ejecuta,
la función tiene acceso
automático a todos los datos
del objeto a través de
self.
No hay nada que pasar
como argumento
porque ya está todo dentro.
En la versión sin OOP,
la función no sabe
de qué alumno es —
hay que decírselo
cada vez.
En OOP se usan tres palabras que a veces se confunden: clase, instancia y objeto. No son sinónimos exactos — aunque en la práctica se usan indistintamente. Entender la diferencia precisa permite leer código de IA sin confusión.
— Las tres definiciones —
Clase
La plantilla — define qué datos
y qué comportamiento
tendrán los objetos.
Se escribe una vez.
class Alumno:
Instancia
Un ejemplar concreto
creado a partir de la clase.
Se pueden crear muchas.
ana = Alumno(...)
Objeto
Término general — cualquier cosa en Python es un objeto. Una instancia es un objeto. Un entero también.
class Alumno:
def __init__(self, nombre, notas):
self.nombre = nombre
self.notas = notas
def media(self):
return sum(self.notas) / len(self.notas)
# Alumno es la clase — existe en memoria como objeto de tipo 'type'
print(type(Alumno)) # — la clase es un objeto
print("---")
# Crear instancias — llamar a la clase como si fuera una función
ana = Alumno("Ana", [8.5, 7.0, 9.0])
bob = Alumno("Bob", [6.0, 7.5, 5.5])
carlos = Alumno("Carlos", [9.0, 8.5, 9.5])
# Cada instancia tiene su propio tipo
print(type(ana)) #
print(type(bob)) #
print(type(carlos)) #
print("---")
# isinstance — verificar si un objeto es de una clase
print(isinstance(ana, Alumno)) # True
print(isinstance(ana, str)) # False
print(isinstance(42, int)) # True — 42 es instancia de int
print(isinstance(42, object)) # True — todo hereda de object
print(isinstance(ana, object)) # True — también Alumno
— Las instancias son independientes entre sí —
Cada instancia vive en el heap — como vimos en la Clase 6b — con sus propios datos. Modificar una instancia no afecta a las demás. Dos instancias de la misma clase son objetos completamente distintos en memoria.
# Cada instancia tiene sus propios datos
print(ana.nombre) # 'Ana'
print(bob.nombre) # 'Bob'
print(carlos.nombre) # 'Carlos'
print("---")
# Modificar una instancia no afecta a las otras
ana.notas.append(10.0) # añadimos una nota a Ana
print(f"Ana: {ana.notas}") # tiene la nota nueva
print(f"Bob: {bob.notas}") # no cambió
print(f"Carlos: {carlos.notas}") # no cambió
print("---")
# Las instancias son objetos distintos en memoria
print(ana is bob) # False — objetos distintos
print(id(ana)) # dirección en memoria de ana
print(id(bob)) # distinta dirección
print("---")
# Las medias son distintas — cada objeto opera sobre sus propios datos
print(f"Media Ana: {ana.media():.2f}")
print(f"Media Bob: {bob.media():.2f}")
print(f"Media Carlos: {carlos.media():.2f}")
— Todo en Python es un objeto: la jerarquía completa —
En la Clase 6a establecimos
que todo en Python es un objeto.
Ahora lo vemos desde el ángulo
de OOP: todo es instancia
de alguna clase.
Los enteros son instancias de
int,
los strings de str,
las funciones de
function,
los módulos de module.
Y todas esas clases
heredan de object
— la raíz de toda la jerarquía.
# Todo es instancia de alguna clase
ejemplos = [
(42, "un entero"),
("hola", "un string"),
([1, 2, 3], "una lista"),
({"a": 1}, "un dict"),
(lambda x: x, "una función"),
(ana, "un Alumno"),
]
for obj, desc in ejemplos:
print(f"{desc:15} → tipo: {type(obj).__name__:12} "
f"| isinstance(object): {isinstance(obj, object)}")
print("---")
# La jerarquía de clases — __mro__ muestra los ancestros
print("Jerarquía de Alumno:")
print(Alumno.__mro__)
# (, )
# Alumno hereda de object — la raíz de todo
print("---")
print("Jerarquía de int:")
print(int.__mro__)
# (, )
# Hasta una clase es un objeto — de tipo 'type'
print(f"\nLa clase Alumno es de tipo: {type(Alumno)}")
print(f"La clase int es de tipo: {type(int)}")
print(f"La clase type es de tipo: {type(type)}")
# Todo es objeto — incluso las clases
ana = Alumno(...) —
es un objeto nuevo en el heap.
La variable ana
es una referencia a ese objeto —
una etiqueta, no el objeto en sí.
Si haces otra = ana,
no creas un objeto nuevo —
creas otra etiqueta
apuntando al mismo objeto.
Para crear un objeto nuevo
hay que llamar a la clase:
otra = Alumno(...).
# Variable es referencia — no el objeto
ana2 = ana # otra etiqueta al mismo objeto
print(ana is ana2) # True — el mismo objeto
print(id(ana) == id(ana2)) # True — misma dirección
# Modificar a través de una referencia afecta al objeto compartido
ana2.nombre = "Ana García"
print(ana.nombre) # 'Ana García' — cambió porque es el mismo objeto
print("---")
# Para un objeto nuevo hay que llamar a la clase
ana3 = Alumno("Ana García", [8.5, 7.0, 9.0, 10.0])
print(ana is ana3) # False — objeto nuevo
print(id(ana) == id(ana3)) # False — distinta dirección
type(ana)
e isinstance(ana, Alumno)?
¿Cuándo usarías cada uno?
ana
aunque solo se asignó
a copia?
copia = ana
copia.nombre = "Ana Otra"
print(ana.nombre) # ¿qué imprime?
type(Alumno)?
¿Por qué una clase
es también un objeto?
Alumno
con tus propios datos
y verifica con
isinstance
que son instancias
de Alumno
y también de
object.
¿Por qué ambas
devuelven True?
type(ana)
devuelve exactamente
la clase de ana
— Alumno.
Es preciso pero no tiene
en cuenta la jerarquía
de herencia.
isinstance(ana, Alumno)
devuelve True
si ana
es de la clase
Alumno
o de cualquier clase
que herede de ella.
Es más flexible y
es el patrón recomendado
para comprobar tipos
en código real.
Usa type()
cuando necesitas
el tipo exacto,
isinstance()
cuando quieres
comprobar compatibilidad.
Imprime
'Ana Otra'.
copia = ana
no crea un objeto nuevo —
crea una segunda etiqueta
que apunta al mismo objeto
en el heap.
Cuando modificas
copia.nombre
estás modificando
el objeto compartido.
ana
y copia
apuntan al mismo lugar
en memoria.
Para crear un objeto
independiente hay que
llamar a la clase:
copia = Alumno(ana.nombre, ana.notas.copy()).
type(Alumno)
devuelve
<class 'type'>.
En Python las clases
son objetos de tipo
type
— la metaclase.
Esto es consistente
con el principio
de que todo en Python
es un objeto:
los enteros son objetos
de tipo int,
las funciones de tipo
function,
y las clases de tipo
type.
Incluso type
es una instancia de sí mismo.
a1 = Alumno("María", [7.0, 8.0, 7.5])
a2 = Alumno("Pedro", [5.5, 6.0, 5.0])
a3 = Alumno("Lucía", [9.5, 9.0, 10.0])
for a in [a1, a2, a3]:
print(f"{a.nombre}: "
f"Alumno={isinstance(a, Alumno)} "
f"object={isinstance(a, object)}")
Ambas devuelven
True
porque Alumno
hereda de object
— como todas las clases
de Python.
isinstance
recorre la jerarquía
de herencia:
si eres Alumno
eres también
object
automáticamente.
__init__,
self
y atributos: los datos del objeto
teoría
Cuando creas una instancia
escribiendo Alumno("Ana", ...),
Python llama automáticamente
a un método especial
llamado __init__
— el constructor.
Su trabajo es inicializar
el objeto recién creado —
guardar los datos que recibe
como atributos del objeto.
Los nombres con doble guion bajo
al principio y al final
— __init__,
__str__,
__len__ —
se llaman dunder methods
(de double underscore).
Python los llama automáticamente
en situaciones concretas.
__init__
se llama al crear un objeto.
Son el mecanismo que hace
que los objetos de Python
se integren con la sintaxis
del lenguaje.
— __init__ paso a paso —
class Pizza:
# __init__ se ejecuta al crear cada instancia
# self → el objeto que se está creando
# nombre, ingredientes → los datos que recibe
def __init__(self, nombre, ingredientes):
# Guardar los datos como atributos del objeto
self.nombre = nombre # atributo del objeto
self.ingredientes = ingredientes # atributo del objeto
# Al escribir Pizza("Margarita", [...]) Python hace:
# 1. Crea un objeto Pizza vacío en el heap
# 2. Llama a __init__(objeto, "Margarita", [...])
# 3. __init__ guarda los datos en el objeto
# 4. Devuelve el objeto — se guarda en 'margarita'
margarita = Pizza("Margarita", ["tomate", "mozzarella"])
print(margarita.nombre) # 'Margarita'
print(margarita.ingredientes) # ['tomate', 'mozzarella']
print(type(margarita)) #
print("---")
# La diferencia entre parámetro y atributo
# nombre es el parámetro — existe solo durante __init__
# self.nombre es el atributo — existe toda la vida del objeto
class Demo:
def __init__(self, valor):
parametro = valor # variable local — desaparece al terminar __init__
self.atributo = valor # atributo — persiste en el objeto
d = Demo(42)
print(d.atributo) # 42 — accesible
try:
print(d.parametro) # AttributeError — no existe
except AttributeError as e:
print(f"AttributeError: {e}")
— self: la referencia al propio objeto —
self
es el primer parámetro
de todos los métodos
y es una referencia
al objeto que está ejecutando
el método.
Cuando escribes
margarita.preparar(),
Python llama internamente a
Pizza.preparar(margarita)
— pasa margarita
como self
automáticamente.
Por eso self
no aparece en la llamada
pero sí en la definición.
El nombre self
es una convención —
podría llamarse de otra forma,
pero todo el mundo usa
self
y alejarse de esa convención
genera confusión.
class Pizza:
def __init__(self, nombre, ingredientes):
self.nombre = nombre
self.ingredientes = ingredientes
def preparar(self):
# self es margarita cuando llamas margarita.preparar()
print(
f"Preparando {self.nombre} "
f"con: {', '.join(self.ingredientes)}"
)
margarita = Pizza("Margarita", ["tomate", "mozzarella"])
tropical = Pizza("Tropical", ["tomate", "jamón", "piña"])
# Llamada corta — Python pasa self automáticamente
margarita.preparar()
tropical.preparar()
print("---")
# Equivalente largo — pasar self manualmente
# Nunca se hace así — pero muestra qué hace Python
Pizza.preparar(margarita) # equivalente a margarita.preparar()
Pizza.preparar(tropical) # equivalente a tropical.preparar()
print("---")
# self es el objeto — podemos ver su id
class VerSelf:
def quien_soy(self):
print(f"self es el objeto con id: {id(self)}")
obj = VerSelf()
print(f"id de obj: {id(obj)}")
obj.quien_soy() # mismo id — self ES obj
— Atributos de instancia vs atributos de clase —
Los atributos de instancia
— los que se definen con
self.algo = valor
en __init__ —
son propios de cada objeto:
cada pizza tiene su propio nombre.
Los atributos de clase
se definen fuera de
__init__
directamente en el cuerpo
de la clase y son compartidos
por todas las instancias.
Se usan para valores
que son iguales para todos
los objetos de esa clase.
class Pizza:
# Atributo de clase — compartido por todas las pizzas
precio_por_ingrediente = 1.5
restaurante = "La Bella Napoli"
def __init__(self, nombre, ingredientes):
# Atributos de instancia — propios de cada pizza
self.nombre = nombre
self.ingredientes = ingredientes
def calcular_precio(self):
# Accede al atributo de clase con self o con Pizza
return len(self.ingredientes) * Pizza.precio_por_ingrediente
margarita = Pizza("Margarita", ["tomate", "mozzarella"])
tropical = Pizza("Tropical", ["tomate", "jamón", "piña"])
# Atributos de instancia — distintos
print(margarita.nombre) # 'Margarita'
print(tropical.nombre) # 'Tropical'
print("---")
# Atributo de clase — igual para todas
print(margarita.restaurante) # 'La Bella Napoli'
print(tropical.restaurante) # 'La Bella Napoli' — el mismo
print(Pizza.restaurante) # también accesible desde la clase
print("---")
# Cambiar el atributo de clase afecta a todas las instancias
Pizza.precio_por_ingrediente = 2.0 # sube el precio
print(margarita.calcular_precio()) # 4.0 — 2 ingredientes × 2.0
print(tropical.calcular_precio()) # 6.0 — 3 ingredientes × 2.0
print("---")
# Acceder y modificar atributos desde fuera
print(margarita.nombre) # leer atributo
margarita.nombre = "Margarita Especial" # modificar atributo
print(margarita.nombre) # 'Margarita Especial'
print(tropical.nombre) # 'Tropical' — no cambió
# Ver todos los atributos de un objeto
print("\nAtributos de margarita:")
print(margarita.__dict__)
# {'nombre': 'Margarita Especial', 'ingredientes': ['tomate', 'mozzarella']}
__init__
recibe self
como primer parámetro
pero al crear el objeto
no se pasa ningún
self?
Rectangulo
con atributos
base
y altura
y un atributo de clase
figura = "rectángulo".
Crea dos instancias
con dimensiones distintas
y verifica que
figura
es el mismo para ambas.
margarita.__dict__?
¿Por qué no incluye
los atributos de clase?
Python pasa
self
automáticamente —
es el objeto recién creado.
Cuando escribes
Pizza("Margarita", [...]),
Python crea el objeto,
lo asigna internamente
a self
y llama a
__init__(objeto, "Margarita", [...]).
Tú solo pasas los argumentos
que vienen después de
self
en la definición.
Un atributo de instancia
— self.nombre —
es propio de cada objeto.
Cada pizza tiene su nombre.
Un atributo de clase
— Pizza.restaurante —
es compartido por todas
las instancias.
Si cambias un atributo de clase,
el cambio se refleja
en todas las instancias
que no hayan sobreescrito
ese atributo localmente.
class Rectangulo:
figura = "rectángulo" # atributo de clase
def __init__(self, base, altura):
self.base = base
self.altura = altura
def area(self):
return self.base * self.altura
r1 = Rectangulo(4, 3)
r2 = Rectangulo(10, 5)
print(r1.figura) # 'rectángulo'
print(r2.figura) # 'rectángulo' — el mismo
print(r1.figura is r2.figura) # True — mismo objeto
print(r1.area(), r2.area()) # 12, 50
margarita.__dict__
devuelve un diccionario
con solo los atributos
de instancia —
los definidos con
self.algo
en __init__.
Los atributos de clase
no están en
__dict__
de la instancia —
están en
Pizza.__dict__.
Python busca primero
en el diccionario
de la instancia
y si no lo encuentra,
busca en el de la clase
— exactamente la misma
regla LEGB que vimos
en la Clase 6b
pero aplicada a objetos.
Un método
es una función definida
dentro de una clase.
La diferencia con una función normal
es que opera sobre
un objeto concreto —
tiene acceso a todos sus datos
a través de self
sin necesidad de que
se los pasen como argumentos.
El método es el comportamiento
del objeto — lo que sabe hacer.
— Función normal vs método: la diferencia concreta —
# Función normal — hay que pasarle los datos cada vez
def calcular_precio_fn(ingredientes, precio_por_ingrediente):
return len(ingredientes) * precio_por_ingrediente
precio = calcular_precio_fn(["tomate", "mozzarella"], 1.5)
print(f"Función: {precio}")
print("---")
# Método — tiene acceso a los datos del objeto
class Pizza:
precio_por_ingrediente = 1.5
def __init__(self, nombre, ingredientes):
self.nombre = nombre
self.ingredientes = ingredientes
def calcular_precio(self):
# No necesita argumentos — accede a self
return len(self.ingredientes) * Pizza.precio_por_ingrediente
margarita = Pizza("Margarita", ["tomate", "mozzarella"])
precio = margarita.calcular_precio() # sin argumentos
print(f"Método: {precio}")
print("---")
# Un método es un objeto — de tipo 'method'
print(type(margarita.calcular_precio)) #
print(type(calcular_precio_fn)) #
# La diferencia: el método lleva consigo la referencia al objeto
— Métodos que devuelven vs métodos que modifican el estado —
Hay dos tipos de métodos
según lo que hacen.
Los que devuelven
un valor sin modificar el objeto
— calcular_precio()
calcula y devuelve un número,
la pizza no cambia.
Los que modifican el estado
— agregar_ingrediente()
cambia la lista de ingredientes
del objeto y generalmente
no devuelven nada.
Es importante distinguirlos
al leer código de IA.
class Pizza:
precio_por_ingrediente = 1.5
def __init__(self, nombre, ingredientes):
self.nombre = nombre
self.ingredientes = list(ingredientes) # copia defensiva
# Método que DEVUELVE — no modifica el objeto
def calcular_precio(self):
return round(
len(self.ingredientes) * Pizza.precio_por_ingrediente, 2
)
# Método que DEVUELVE — no modifica el objeto
def preparar(self):
print(
f"Preparando {self.nombre} "
f"con: {', '.join(self.ingredientes)}"
)
# Método que MODIFICA EL ESTADO — cambia self.ingredientes
def agregar_ingrediente(self, ingrediente):
self.ingredientes.append(ingrediente)
# No devuelve nada — modifica el objeto
# Método que MODIFICA Y DEVUELVE — menos frecuente
def agregar_y_precio(self, ingrediente):
self.ingredientes.append(ingrediente)
return self.calcular_precio() # devuelve el precio actualizado
margarita = Pizza("Margarita", ["tomate", "mozzarella"])
# Método que devuelve — la pizza no cambia
precio = margarita.calcular_precio()
print(f"Precio: {precio}")
print(f"Ingredientes: {margarita.ingredientes}") # no cambió
print("---")
# Método que modifica — la pizza cambia
margarita.agregar_ingrediente("albahaca")
print(f"Ingredientes: {margarita.ingredientes}") # cambió
print(f"Nuevo precio: {margarita.calcular_precio()}")
print("---")
# Un método puede llamar a otro método del mismo objeto
margarita.preparar() # preparar llama a self.ingredientes
— Dunder methods: __str__ y __repr__ —
Sin dunder methods,
imprimir un objeto muestra
algo como
<__main__.Pizza object at 0x7f3a...>
— la dirección en memoria,
que no es útil.
__str__
define cómo se representa
el objeto cuando usas
print()
o str()
— pensado para el usuario.
__repr__
define la representación
técnica — pensado para
el desarrollador y el debugging.
Son los mismos dunder methods
que tienen los strings,
las listas y todos los objetos
de Python.
# Sin dunder methods — representación inútil
class PizzaSinDunder:
def __init__(self, nombre):
self.nombre = nombre
p = PizzaSinDunder("Margarita")
print(p) # <__main__.PizzaSinDunder object at 0x...>
print("---")
# Con __str__ y __repr__
class Pizza:
precio_por_ingrediente = 1.5
def __init__(self, nombre, ingredientes):
self.nombre = nombre
self.ingredientes = list(ingredientes)
def calcular_precio(self):
return round(
len(self.ingredientes) * Pizza.precio_por_ingrediente, 2
)
def agregar_ingrediente(self, ingrediente):
self.ingredientes.append(ingrediente)
# Para el usuario — print(pizza)
def __str__(self):
ingredientes = ", ".join(self.ingredientes)
precio = self.calcular_precio()
return f"Pizza {self.nombre} ({ingredientes}) — {precio}€"
# Para el desarrollador — repr(pizza) o en el REPL
def __repr__(self):
return (
f"Pizza(nombre={self.nombre!r}, "
f"ingredientes={self.ingredientes!r})"
)
margarita = Pizza("Margarita", ["tomate", "mozzarella"])
tropical = Pizza("Tropical", ["tomate", "jamón", "piña"])
# __str__ — para el usuario
print(margarita)
print(str(tropical))
print("---")
# __repr__ — para el desarrollador
print(repr(margarita))
print("---")
# En una lista — Python usa __repr__
pizzas = [margarita, tropical]
print(pizzas)
print("---")
# Otros dunder methods útiles
class PizzaExtra(Pizza):
# __len__ — len(pizza) devuelve número de ingredientes
def __len__(self):
return len(self.ingredientes)
# __contains__ — "tomate" in pizza
def __contains__(self, ingrediente):
return ingrediente in self.ingredientes
# __eq__ — margarita == tropical
def __eq__(self, otra):
return (
self.nombre == otra.nombre and
sorted(self.ingredientes) == sorted(otra.ingredientes)
)
p1 = PizzaExtra("Margarita", ["tomate", "mozzarella"])
p2 = PizzaExtra("Margarita", ["tomate", "mozzarella"])
p3 = PizzaExtra("Tropical", ["tomate", "jamón", "piña"])
print(len(p1)) # 2 — número de ingredientes
print("tomate" in p1) # True
print("piña" in p1) # False
print(p1 == p2) # True — mismos datos
print(p1 == p3) # False — distintos
— La clase Pizza completa: todo junto —
class Pizza:
"""Representa una pizza con nombre e ingredientes."""
precio_por_ingrediente = 1.5 # atributo de clase
def __init__(self, nombre, ingredientes):
"""Crea una pizza con nombre e ingredientes."""
self.nombre = nombre
self.ingredientes = list(ingredientes)
def preparar(self):
"""Imprime los ingredientes de la pizza."""
print(
f"Preparando {self.nombre} "
f"con: {', '.join(self.ingredientes)}"
)
def agregar_ingrediente(self, ingrediente):
"""Añade un ingrediente a la pizza."""
self.ingredientes.append(ingrediente)
def calcular_precio(self):
"""Devuelve el precio según el número de ingredientes."""
return round(
len(self.ingredientes) * Pizza.precio_por_ingrediente, 2
)
def __str__(self):
return (
f"Pizza {self.nombre} "
f"({', '.join(self.ingredientes)}) "
f"— {self.calcular_precio()}€"
)
def __repr__(self):
return (
f"Pizza(nombre={self.nombre!r}, "
f"ingredientes={self.ingredientes!r})"
)
def __len__(self):
return len(self.ingredientes)
# Usar la clase completa
margarita = Pizza("Margarita", ["tomate", "mozzarella"])
tropical = Pizza("Tropical", ["tomate", "jamón", "piña"])
margarita.preparar()
margarita.agregar_ingrediente("albahaca")
print(margarita) # usa __str__
print(repr(margarita)) # usa __repr__
print(len(margarita)) # usa __len__
print(margarita.calcular_precio())
print("---")
print(tropical)
print(tropical.calcular_precio())
__init__ con
self.algo —
y métodos
— las funciones que operan
sobre esos datos,
siempre con self
como primer parámetro.
Los dunder methods
permiten que los objetos
se integren con la sintaxis
de Python:
print(),
len(),
in,
==.
Es exactamente lo que hacen
los strings, las listas
y los DataFrames —
ahora puedes hacerlo tú.
__str__
y __repr__?
¿Cuándo usa Python
cada uno automáticamente?
margarita.calcular_precio()
no necesita argumentos
pero
calcular_precio_fn([...], 1.5)
sí los necesita?
Pizza
un método
es_vegetariana()
que devuelva
True
si ningún ingrediente
es carne —
define tú qué ingredientes
son carne.
p1 = Pizza("Margarita", ["tomate", "mozzarella"])
p2 = Pizza("Tropical", ["tomate", "jamón", "piña"])
# ¿qué dunder method necesitamos?
pizzas = [p1, p2]
pizza_mas_cara = max(pizzas)
__str__
devuelve una representación
legible para el usuario
— se llama cuando usas
print(obj)
o str(obj).
Debe ser clara y amigable.
__repr__
devuelve una representación
técnica para el desarrollador
— se llama en el REPL,
dentro de listas y dicts,
y con repr(obj).
Idealmente debe permitir
reconstruir el objeto —
eval(repr(obj)) == obj.
Si no defines
__str__,
Python usa
__repr__
como fallback.
Porque el método
tiene acceso automático
a los datos del objeto
a través de
self.
Cuando llamas a
margarita.calcular_precio(),
Python pasa
margarita
como self
automáticamente.
El método puede acceder
a self.ingredientes
directamente.
La función normal
no tiene esa conexión
— hay que pasarle
los datos explícitamente
cada vez que se llama.
class Pizza:
precio_por_ingrediente = 1.5
CARNES = {"jamón", "pollo", "bacon",
"atún", "anchoas", "pepperoni"}
def __init__(self, nombre, ingredientes):
self.nombre = nombre
self.ingredientes = list(ingredientes)
def es_vegetariana(self):
"""Devuelve True si no hay carne entre los ingredientes."""
return not any(
ing in Pizza.CARNES
for ing in self.ingredientes
)
margarita = Pizza("Margarita", ["tomate", "mozzarella"])
tropical = Pizza("Tropical", ["tomate", "jamón", "piña"])
print(margarita.es_vegetariana()) # True
print(tropical.es_vegetariana()) # False
max()
necesita comparar objetos.
Para que funcione
hay que implementar
__lt__
— less than —
que define el operador
<.
max()
usa comparaciones
para encontrar el mayor.
def __lt__(self, otra):
"""Compara pizzas por precio."""
return self.calcular_precio() < otra.calcular_precio()
# Con __lt__ definido:
# max(pizzas) devuelve la pizza con mayor precio
# min(pizzas) devuelve la pizza con menor precio
# sorted(pizzas) las ordena por precio
AlumnoMAD
práctica
Hasta ahora hemos visto
la clase Pizza
como ejemplo de sintaxis.
Ahora aplicamos los mismos conceptos
a un contexto real del máster:
gestionar alumnos con sus notas,
calcular estadísticas
y generar informes.
El objetivo no es que escribas
esta clase de cero —
es que entiendas
cómo está construida
cuando la IA te la genere.
— La clase AlumnoMAD —
class AlumnoMAD:
"""Representa un alumno del Máster en Análisis de Datos."""
# Atributo de clase — compartido por todas las instancias
# Cuenta cuántos alumnos se han creado
total_alumnos = 0
def __init__(self, nombre, email, notas):
"""
nombre: string con el nombre completo
email: string con el email institucional
notas: dict {materia: nota} con las notas por materia
"""
self.nombre = nombre
self.email = email
self.notas = notas # dict {materia: nota}
# Actualizar el contador de clase
AlumnoMAD.total_alumnos += 1
def media_general(self):
"""Devuelve la media de todas las materias."""
if not self.notas:
return 0.0
return round(
sum(self.notas.values()) / len(self.notas), 2
)
def nota_materia(self, materia):
"""Devuelve la nota de una materia concreta."""
return self.notas.get(materia, "No cursada")
def esta_aprobado(self, minimo=5.0):
"""True si la media general supera el mínimo."""
return self.media_general() >= minimo
def materia_mas_alta(self):
"""Devuelve la materia con la nota más alta."""
if not self.notas:
return None
return max(self.notas, key=self.notas.get)
def materia_mas_baja(self):
"""Devuelve la materia con la nota más baja."""
if not self.notas:
return None
return min(self.notas, key=self.notas.get)
def informe(self):
"""Imprime un informe completo del alumno."""
estado = "✓ Aprobado" if self.esta_aprobado() else "✗ Suspenso"
print(f"\n{'='*45}")
print(f" {self.nombre} ({self.email})")
print(f"{'='*45}")
for materia, nota in self.notas.items():
print(f" {materia:20} {nota:5.1f}")
print(f" {'—'*30}")
print(f" {'Media general':20} {self.media_general():5.2f}")
print(f" {estado}")
print(f" Mejor materia: {self.materia_mas_alta()}")
print(f" Peor materia: {self.materia_mas_baja()}")
def __str__(self):
estado = "Aprobado" if self.esta_aprobado() else "Suspenso"
return (
f"{self.nombre} | media: {self.media_general():.2f} | {estado}"
)
def __repr__(self):
return f"AlumnoMAD({self.nombre!r}, {self.email!r})"
— Crear instancias y usar los métodos —
# Crear alumnos
ana = AlumnoMAD(
"Ana García",
"ana@uvigo.es",
{
"Python": 8.5,
"Estadística": 7.0,
"Machine Learning": 9.0,
"Econometría": 8.0,
}
)
bob = AlumnoMAD(
"Bob Martínez",
"bob@uvigo.es",
{
"Python": 6.0,
"Estadística": 7.5,
"Machine Learning": 5.5,
"Econometría": 4.5,
}
)
carlos = AlumnoMAD(
"Carlos Pérez",
"carlos@uvigo.es",
{
"Python": 9.5,
"Estadística": 9.0,
"Machine Learning": 8.5,
"Econometría": 9.0,
}
)
# El contador de clase se actualiza automáticamente
print(f"Alumnos en el máster: {AlumnoMAD.total_alumnos}") # 3
print("---")
# Usar los métodos
print(ana) # __str__
print(ana.media_general())
print(ana.nota_materia("Python"))
print(ana.esta_aprobado())
print(ana.materia_mas_alta())
print("---")
# Informe completo
bob.informe()
print("---")
# Trabajar con una lista de instancias
alumnos = [ana, bob, carlos]
print("Resumen del máster:")
for alumno in alumnos:
print(f" {alumno}")
print("---")
# Estadísticas del grupo
medias = [a.media_general() for a in alumnos]
print(f"Media del grupo: {sum(medias)/len(medias):.2f}")
print(f"Mejor alumno: {max(alumnos, key=lambda a: a.media_general())}")
print(f"Aprobados: {sum(1 for a in alumnos if a.esta_aprobado())}")
— Conexión con pandas: de instancias a DataFrame —
Una de las operaciones más frecuentes en código de IA que usa OOP es convertir una lista de objetos en un DataFrame para análisis. Cada instancia se convierte en una fila — sus atributos en columnas. El patrón es siempre el mismo: una lista de diccionarios donde cada diccionario es un objeto resumido.
import pandas as pd
# Convertir lista de instancias a DataFrame
# Patrón: list comprehension de dicts
datos = [
{
"nombre": a.nombre,
"email": a.email,
"media": a.media_general(),
"estado": "Aprobado" if a.esta_aprobado() else "Suspenso",
"mejor": a.materia_mas_alta(),
"peor": a.materia_mas_baja(),
# Desempaquetar las notas como columnas
**a.notas
}
for a in alumnos
]
df = pd.DataFrame(datos)
print(df.to_string(index=False))
print("---")
# Analizar con pandas
print("\nEstadísticas por materia:")
materias = ["Python", "Estadística", "Machine Learning", "Econometría"]
print(df[materias].describe().round(2))
print("---")
# Ordenar por media
print("\nRanking:")
ranking = df[["nombre", "media", "estado"]].sort_values(
"media", ascending=False
).reset_index(drop=True)
ranking.index += 1 # ranking desde 1
print(ranking)
total_alumnos
es un atributo de clase
y no de instancia?
¿Qué pasaría si fuera
self.total_alumnos?
**a.notas
dentro del diccionario
de la list comprehension?
¿Por qué es útil aquí?
AlumnoMAD
un método
materias_suspensas()
que devuelva la lista
de materias con nota
inferior a 5.
Pruébalo con bob.
print(AlumnoMAD.total_alumnos)
nuevo = AlumnoMAD("Diana", "d@uvigo.es", {})
print(AlumnoMAD.total_alumnos)
print(ana.total_alumnos)
print(nuevo.total_alumnos)
total_alumnos
es de clase porque
pertenece al máster entero —
no a un alumno concreto.
Si fuera
self.total_alumnos,
cada instancia tendría
su propio contador
— siempre sería 1.
La línea
AlumnoMAD.total_alumnos += 1
en __init__
actualiza el contador compartido
cada vez que se crea
una instancia nueva.
**a.notas
desempaqueta el diccionario
de notas directamente
en el diccionario padre —
es el operador de
unpacking de la Clase 6a.
Si a.notas
es
{"Python": 8.5, "Estadística": 7.0},
el resultado es como si
hubieras escrito
"Python": 8.5, "Estadística": 7.0
directamente.
Es útil porque convierte
cada materia en una columna
del DataFrame
sin escribir cada una manualmente.
def materias_suspensas(self, minimo=5.0):
"""Devuelve la lista de materias con nota inferior al mínimo."""
return [
materia
for materia, nota in self.notas.items()
if nota < minimo
]
# Prueba con bob
print(bob.materias_suspensas())
# ['Econometría'] — nota 4.5 < 5.0
Imprime
3,
luego 4,
luego 4,
luego 4.
El primer
print
muestra el contador
antes de crear
nuevo — 3.
Al crear
nuevo
el contador sube a 4.
Tanto
ana.total_alumnos
como
nuevo.total_alumnos
devuelven 4 —
porque es un atributo
de clase compartido.
Acceder desde una instancia
o desde la clase
devuelve el mismo valor.
Python no obliga a elegir entre OOP y programación funcional. Permite usar ambos estilos — y mezclarlos. La misma funcionalidad se puede implementar de las dos formas. Entender las diferencias permite leer código de IA sin importar qué estilo use — y saber cuándo uno es más apropiado que el otro.
— La misma Pizza: OOP vs funcional —
Versión OOP
class Pizza:
def __init__(self, nombre,
ingredientes):
self.nombre = nombre
self.ingredientes = \
list(ingredientes)
def agregar(self, ing):
self.ingredientes.append(ing)
def precio(self):
return round(
len(self.ingredientes)
* 1.5, 2
)
def preparar(self):
ings = ", ".join(
self.ingredientes)
print(f"{self.nombre}: {ings}")
# Uso
p = Pizza("Margarita",
["tomate", "queso"])
p.agregar("albahaca")
p.preparar()
print(p.precio())
Versión funcional
def crear_pizza(nombre,
ingredientes):
return {
"nombre": nombre,
"ingredientes": list(ingredientes)
}
def agregar(pizza, ing):
return {
**pizza,
"ingredientes":
pizza["ingredientes"] + [ing]
}
def precio(pizza):
return round(
len(pizza["ingredientes"])
* 1.5, 2
)
def preparar(pizza):
ings = ", ".join(
pizza["ingredientes"])
print(f"{pizza['nombre']}: {ings}")
# Uso
p = crear_pizza("Margarita",
["tomate", "queso"])
p = agregar(p, "albahaca")
preparar(p)
print(precio(p))
# La diferencia clave: mutabilidad
# OOP — agregar modifica el objeto existente
class Pizza:
def __init__(self, nombre, ingredientes):
self.nombre = nombre
self.ingredientes = list(ingredientes)
def agregar(self, ing):
self.ingredientes.append(ing) # modifica self
def precio(self):
return round(len(self.ingredientes) * 1.5, 2)
p_oop = Pizza("Margarita", ["tomate", "queso"])
print(f"Antes: {p_oop.ingredientes}")
p_oop.agregar("albahaca")
print(f"Después: {p_oop.ingredientes}") # el objeto cambió
print("---")
# Funcional — agregar devuelve un dict nuevo
def crear_pizza(nombre, ingredientes):
return {"nombre": nombre, "ingredientes": list(ingredientes)}
def agregar_fn(pizza, ing):
return {**pizza, "ingredientes": pizza["ingredientes"] + [ing]}
p_fn = crear_pizza("Margarita", ["tomate", "queso"])
print(f"Original: {p_fn['ingredientes']}")
p_fn2 = agregar_fn(p_fn, "albahaca")
print(f"Original: {p_fn['ingredientes']}") # no cambió
print(f"Nueva: {p_fn2['ingredientes']}") # objeto nuevo
— dataclass: OOP con menos código —
dataclass
es un decorador de la Clase 6d
aplicado a clases.
Genera automáticamente
__init__,
__repr__
y __eq__
a partir de las anotaciones
de tipo de los atributos.
Menos código, misma funcionalidad.
La IA lo usa frecuentemente
cuando la clase es principalmente
un contenedor de datos.
from dataclasses import dataclass, field
from typing import List
# Sin dataclass — código repetitivo
class PizzaManual:
def __init__(self, nombre: str, ingredientes: list):
self.nombre = nombre
self.ingredientes = ingredientes
def __repr__(self):
return f"Pizza({self.nombre!r}, {self.ingredientes!r})"
def __eq__(self, otra):
return self.nombre == otra.nombre and \
self.ingredientes == otra.ingredientes
print("---")
# Con dataclass — el decorador genera todo
@dataclass
class Pizza:
nombre: str
ingredientes: List[str]
# Los métodos propios siguen igual
def precio(self):
return round(len(self.ingredientes) * 1.5, 2)
def preparar(self):
print(f"{self.nombre}: {', '.join(self.ingredientes)}")
# __init__, __repr__ y __eq__ generados automáticamente
margarita = Pizza("Margarita", ["tomate", "queso"])
tropical = Pizza("Tropical", ["tomate", "jamón", "piña"])
print(margarita) # __repr__ generado
print(margarita.precio())
margarita.preparar()
print("---")
# __eq__ generado — compara atributos automáticamente
p1 = Pizza("Margarita", ["tomate", "queso"])
p2 = Pizza("Margarita", ["tomate", "queso"])
print(p1 == p2) # True — mismos atributos
print("---")
# dataclass frozen=True — inmutable como en las slides
@dataclass(frozen=True)
class PizzaInmutable:
nombre: str
ingredientes: tuple # tuple — inmutable
def precio(self):
return round(len(self.ingredientes) * 1.5, 2)
def agregar(self, ing):
# No modifica — devuelve una nueva instancia
return PizzaInmutable(self.nombre, self.ingredientes + (ing,))
pi = PizzaInmutable("Margarita", ("tomate", "queso"))
pi2 = pi.agregar("albahaca") # nueva instancia — pi no cambia
print(pi)
print(pi2)
try:
pi.nombre = "otra" # FrozenInstanceError — no se puede modificar
except Exception as e:
print(f"Error: {e}")
— namedtuple: la alternativa más funcional —
from collections import namedtuple
# namedtuple — tupla con nombres de campo
# inmutable por definición
Pizza = namedtuple("Pizza", ["nombre", "ingredientes"])
margarita = Pizza("Margarita", ("tomate", "queso"))
print(margarita)
print(margarita.nombre)
print(margarita.ingredientes)
print(type(margarita)) #
print("---")
# Las funciones operan sobre namedtuples
def precio(pizza):
return round(len(pizza.ingredientes) * 1.5, 2)
def agregar(pizza, ing, nuevo_nombre=None):
return Pizza(
nuevo_nombre if nuevo_nombre else pizza.nombre,
pizza.ingredientes + (ing,)
)
print(precio(margarita))
natural = agregar(margarita, "albahaca", "Natural")
print(natural)
print(precio(natural))
print("---")
# Comparación de las cuatro opciones
opciones = {
"class": "mutable, métodos propios, más código",
"dataclass": "mutable, menos código, __init__ automático",
"dataclass frozen": "inmutable, OOP con estilo FP",
"namedtuple": "inmutable, más ligero, estilo FP puro",
}
for opcion, desc in opciones.items():
print(f" {opcion:22} → {desc}")
— Cuándo usar cada estilo —
| Situación | Mejor opción | Por qué |
|---|---|---|
| Objeto con estado que cambia y comportamiento complejo | class | Encapsula estado y comportamiento juntos |
| Contenedor de datos con pocos métodos | dataclass | Menos código, __init__ automático |
| Datos que no deben cambiar | dataclass frozen o namedtuple | Inmutabilidad garantizada |
| Transformaciones de datos sin estado | funciones puras | Más predecibles, fáciles de testear |
| Pipeline de análisis de datos | mezcla | Objetos para datos, funciones para transformaciones |
Pizza?
¿Cuál modifica el objeto original
y cuál crea uno nuevo?
@dataclass?
¿Qué ventaja tiene
sobre escribir la clase
manualmente?
dataclass(frozen=True)
usa tuple
para los ingredientes
en lugar de list?
¿Qué pasaría si usara
una lista?
from dataclasses import dataclass
import pandas as pd
@dataclass
class Experimento:
nombre: str
parametros: dict
resultados: dict
def comparar_experimentos(exps):
return pd.DataFrame([
{"nombre": e.nombre, **e.resultados}
for e in exps
])
exp1 = Experimento(
"baseline",
{"lr": 0.01},
{"accuracy": 0.82}
)
exp2 = Experimento(
"tuned",
{"lr": 0.001},
{"accuracy": 0.91}
)
print(comparar_experimentos([exp1, exp2]))
La diferencia principal
es la mutabilidad.
La versión OOP
— agregar(self, ing) —
modifica el objeto existente
con append:
el objeto antes y después
es el mismo en memoria,
con la lista de ingredientes
modificada.
La versión funcional
— agregar_fn(pizza, ing) —
crea un diccionario nuevo
con {**pizza, ...}:
el original no cambia,
la función devuelve
una estructura nueva.
Es más predecible
pero consume más memoria
porque crea objetos nuevos.
@dataclass
genera automáticamente
__init__
— construye el objeto
asignando cada atributo,
__repr__
— representación legible
con todos los atributos,
y __eq__
— compara dos instancias
comparando sus atributos.
La ventaja es que
elimina código repetitivo —
en una clase manual
hay que escribir
esos tres métodos
a mano aunque hagan
siempre lo mismo.
frozen=True
hace que los atributos
no se puedan modificar
después de crear el objeto.
Si el atributo
ingredientes
fuera una lista,
aunque no se pudiera
reasignar el atributo —
pizza.ingredientes = [...] —
sí se podría modificar
su contenido con
pizza.ingredientes.append(...)
porque la lista en sí
es mutable.
Con una tupla
la inmutabilidad
es completa —
ni el atributo
ni su contenido
se pueden cambiar.
Es una mezcla de ambos estilos —
el patrón más frecuente
en código de IA real.
Experimento
es una dataclass
— OOP — que modela
una entidad con datos:
nombre, parámetros
y resultados.
comparar_experimentos
es una función pura
— estilo funcional —
que recibe una lista
de objetos y devuelve
un DataFrame sin
modificar nada.
OOP para modelar entidades,
funciones puras
para transformar datos —
exactamente la combinación
que describe la tabla.
Este bloque es de comprensión lectora — no de escritura. El objetivo es leer código OOP real que genera la IA y entender su estructura sin necesidad de conocer Machine Learning. El ejemplo es un Random Forest — un algoritmo de clasificación — implementado en dos versiones: OOP y funcional. Lo que importa no es qué hace el algoritmo sino cómo está organizado el código.
— Qué es un Random Forest — solo lo necesario —
Un Random Forest es un conjunto de árboles de decisión. Cada árbol aprende de una muestra aleatoria de los datos — bootstrap sample. Para clasificar un dato nuevo cada árbol vota y gana la clase más votada. Más árboles — más robusto. Más profundidad — más complejo. Eso es todo lo que necesitamos para leer el código.
— Versión OOP: SimpleRandomForest —
import numpy as np
from sklearn.tree import DecisionTreeClassifier
from collections import Counter
class SimpleRandomForest:
"""
Random Forest simple con interfaz OOP.
Atributos: n_trees, max_depth, sample_size, trees
Métodos: fit(), predict()
Métodos privados: _bootstrap_sample()
"""
def __init__(self, n_trees=10, max_depth=None, sample_size=0.8):
# Atributos de configuración — hiperparámetros
self.n_trees = n_trees # cuántos árboles
self.max_depth = max_depth # profundidad máxima
self.sample_size = sample_size # fracción de datos por árbol
# Atributo que se rellena al entrenar — empieza vacío
self.trees = []
def _bootstrap_sample(self, X, y):
"""
Método PRIVADO — convención: nombre con _ inicial.
Crea una muestra aleatoria con reemplazo.
No se llama desde fuera — solo lo usan fit() y predict().
"""
n_samples = int(self.sample_size * X.shape[0])
indices = np.random.choice(X.shape[0], n_samples, replace=True)
return X[indices], y[indices]
def fit(self, X, y):
"""
Método PÚBLICO — entrena el modelo.
Modifica self.trees — cambia el estado del objeto.
"""
self.trees = []
for _ in range(self.n_trees):
X_muestra, y_muestra = self._bootstrap_sample(X, y)
arbol = DecisionTreeClassifier(max_depth=self.max_depth)
arbol.fit(X_muestra, y_muestra)
self.trees.append(arbol)
def predict(self, X):
"""
Método PÚBLICO — predice clases.
Usa self.trees — lee el estado del objeto.
Devuelve array de predicciones — no modifica.
"""
predicciones = np.array([
arbol.predict(X) for arbol in self.trees
])
return np.apply_along_axis(
lambda x: Counter(x).most_common(1)[0][0],
axis=0,
arr=predicciones
)
— Anatomía: identificar cada parte —
— Crear instancias y entrenar —
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
# Datos de clasificación sintéticos
X, y = make_classification(
n_samples=1000, n_features=10,
n_informative=5, random_state=42
)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=1
)
print("--- Instancia simple ---")
# Crear una instancia — como Pizza("Margarita", [...])
bosque = SimpleRandomForest(n_trees=20, max_depth=5)
print(f"tipo: {type(bosque)}")
print(f"trees antes de fit: {bosque.trees}") # [] — vacío
# fit modifica self.trees — el objeto cambia de estado
bosque.fit(X_train, y_train)
print(f"trees después de fit: {len(bosque.trees)} árboles")
# predict usa self.trees — devuelve sin modificar
y_pred = bosque.predict(X_test)
print(f"Accuracy: {accuracy_score(y_test, y_pred):.3f}")
print("--- Múltiples instancias ---")
# Cada instancia tiene sus propios hiperparámetros
# — igual que margarita y tropical eran pizzas distintas
forest1 = SimpleRandomForest(n_trees=10, max_depth=3)
forest2 = SimpleRandomForest(n_trees=50, max_depth=10)
forest3 = SimpleRandomForest(n_trees=100, max_depth=None)
for nombre, forest in [
("forest1 (10 árboles, depth 3)", forest1),
("forest2 (50 árboles, depth 10)", forest2),
("forest3 (100 árboles, sin límite)", forest3),
]:
forest.fit(X_train, y_train)
pred = forest.predict(X_test)
acc = accuracy_score(y_test, pred)
print(f" {nombre:35} → Accuracy: {acc:.3f}")
print("---")
# Los tres son instancias independientes
print(forest1 is forest2) # False
print(len(forest1.trees)) # 10
print(len(forest2.trees)) # 50
print(len(forest3.trees)) # 100
— Versión funcional: el mismo algoritmo sin clase —
# La misma lógica — sin clase, con funciones
def bootstrap_sample(X, y, sample_size):
n = int(len(X) * sample_size)
indices = np.random.choice(len(X), n, replace=True)
return X[indices], y[indices]
def entrenar_forest(X, y, n_trees=10, max_depth=None, sample_size=0.8):
"""Entrena y devuelve una lista de árboles."""
return [
DecisionTreeClassifier(max_depth=max_depth).fit(
*bootstrap_sample(X, y, sample_size)
)
for _ in range(n_trees)
]
def predecir_forest(trees, X):
"""Predice usando votación por mayoría."""
predicciones = np.array([t.predict(X) for t in trees])
return np.apply_along_axis(
lambda x: Counter(x).most_common(1)[0][0],
axis=0, arr=predicciones
)
# Uso — más explícito pero menos encapsulado
trees1 = entrenar_forest(X_train, y_train, n_trees=10, max_depth=3)
pred1 = predecir_forest(trees1, X_test)
print(f"Funcional: {accuracy_score(y_test, pred1):.3f}")
trees2 = entrenar_forest(X_train, y_train, n_trees=50, max_depth=10)
pred2 = predecir_forest(trees2, X_test)
print(f"Funcional: {accuracy_score(y_test, pred2):.3f}")
print("---")
# La diferencia de uso
# OOP: bosque.fit(X, y) — bosque.predict(X)
# Funcional: trees = entrenar(X, y) — pred = predecir(trees, X)
# En OOP el estado (trees) vive dentro del objeto
# En funcional el estado (trees) hay que pasarlo manualmente
— La comparación: qué cambia y qué no —
OOP — SimpleRandomForest
El estado — la lista de árboles —
vive dentro del objeto.
bosque.fit()
lo modifica,
bosque.predict()
lo usa.
Crear variantes es simple:
SimpleRandomForest(n_trees=50).
La interfaz es limpia
y familiar — igual que
sklearn.
Funcional — entrenar/predecir
El estado — la lista de árboles —
se pasa explícitamente.
entrenar_forest()
lo devuelve,
predecir_forest()
lo recibe.
Más transparente pero
más verboso.
El estado no está
encapsulado en ningún lugar.
__init__
— qué datos guarda el objeto —,
los métodos públicos
— qué puede hacer el objeto —,
los métodos privados con
_
— uso interno —,
y el flujo general:
crear instancia → llamar a
fit()
→ llamar a predict().
Es exactamente el mismo patrón
que Pizza.__init__
→ preparar()
→ calcular_precio().
La estructura es siempre la misma
independientemente del dominio.
SimpleRandomForest:
los atributos de instancia,
los métodos públicos
y el método privado.
¿Cómo distingues un método
privado de uno público?
self.trees
empieza como lista vacía
en __init__
y se rellena en
fit()?
¿Qué pasaría si intentaras
llamar a predict()
antes de fit()?
forest
antes y después de
fit(),
y por qué
forest.trees
cambia entre las dos llamadas?
forest = SimpleRandomForest(n_trees=20, max_depth=5)
print(len(forest.trees)) # ← ¿qué imprime?
forest.fit(X_train, y_train)
print(len(forest.trees)) # ← ¿qué imprime?
y_pred = forest.predict(X_test)
print(type(y_pred)) # ← ¿qué imprime?
Atributos de instancia —
definidos en
__init__
con self:
n_trees,
max_depth,
sample_size,
trees.
Métodos públicos —
sin guion bajo inicial:
fit()
y predict().
Son la interfaz
que usa el código
exterior.
Método privado —
con guion bajo inicial:
_bootstrap_sample().
Por convención indica
que es de uso interno —
solo lo llama
fit().
Python no impide
llamarlo desde fuera
pero el guion bajo
es una señal clara
de que no debes hacerlo.
self.trees = []
en __init__
inicializa el atributo
como lista vacía —
el objeto existe
pero aún no ha aprendido nada.
fit()
lo rellena con los árboles
entrenados.
Es el ciclo de vida
del objeto:
crear → configurar
en __init__
→ entrenar en
fit()
→ usar en
predict().
Si llamas a
predict()
antes de
fit(),
self.trees
está vacío —
el bucle no itera
y devuelve un array vacío
o lanza un error
dependiendo de
cómo numpy maneje
el array vacío.
sklearn lo previene
con una comprobación
explícita.
Con OOP crear variantes
es una sola línea:
SimpleRandomForest(n_trees=50)
— los hiperparámetros
van al constructor
y el objeto los guarda.
Se pueden crear
tantas instancias
como se quieran
con distintas configuraciones
y cada una recuerda
la suya.
Con la versión funcional
hay que pasar
los hiperparámetros
a cada función:
entrenar_forest(X, y, n_trees=50, max_depth=10)
y luego pasar la lista
de árboles devuelta
a predecir_forest.
El estado no está
encapsulado —
hay que gestionarlo
manualmente.
Línea 1: crea una instancia
de SimpleRandomForest
con 20 árboles y profundidad 5.
self.trees = [].
len(forest.trees)
antes de fit:
imprime 0
— lista vacía.
forest.fit(X_train, y_train):
entrena 20 árboles
y los guarda en
self.trees.
El tipo de forest
no cambia —
sigue siendo
SimpleRandomForest
— pero su estado interno sí.
len(forest.trees)
después de fit:
imprime 20.
type(y_pred):
<class 'numpy.ndarray'>
— predict()
devuelve un array numpy
con una predicción
por cada fila de
X_test.
Todo el curso has usado OOP
sin saberlo.
Un DataFrame es un objeto.
Una Figure es un objeto.
Un Logger es un objeto.
Ahora que conoces los conceptos
— clase, instancia, atributos,
métodos, __init__,
self —
puedes leer esas librerías
con otros ojos.
Y lo más importante:
puedes reconocer los patrones
que se repiten
en cualquier librería
que te genere la IA.
— pandas: el DataFrame visto con ojos de OOP —
import pandas as pd
import numpy as np
# pd.DataFrame es una clase
print(type(pd.DataFrame)) # — es una clase
# df es una instancia de esa clase
df = pd.DataFrame({
"ventas": [100, 200, 150, 300],
"categoria": ["A", "B", "A", "B"],
})
print(type(df)) #
print("---")
# Atributos del objeto df
print(f"shape: {df.shape}") # atributo — (4, 2)
print(f"columns: {df.columns.tolist()}") # atributo
print(f"dtypes:\n{df.dtypes}") # atributo
print(f"ndim: {df.ndim}") # atributo
print("---")
# Métodos que DEVUELVEN — no modifican df
print(df.head(2)) # devuelve nuevo DataFrame
print(df.describe()) # devuelve nuevo DataFrame
print(df["ventas"].mean()) # devuelve un número
print("---")
# Métodos que MODIFICAN — cambian el estado del objeto
df2 = df.copy()
df2.rename(columns={"ventas": "Ventas"}, inplace=True)
print(df2.columns.tolist()) # ['Ventas', 'categoria']
print("---")
# El encadenamiento funcional de pandas
# — cada método devuelve un DataFrame nuevo
# — es exactamente el paradigma funcional de la Clase 6c
resultado = (
df
.query("ventas > 100") # devuelve nuevo df
.assign(doble=lambda d: # devuelve nuevo df
d["ventas"] * 2)
.sort_values("doble", # devuelve nuevo df
ascending=False)
.reset_index(drop=True) # devuelve nuevo df
)
print(resultado)
print("---")
# Múltiples instancias — igual que múltiples Alumnos
df_ventas = pd.DataFrame({"mes": ["ene", "feb"], "v": [100, 200]})
df_costes = pd.DataFrame({"mes": ["ene", "feb"], "c": [80, 120]})
df_clientes = pd.DataFrame({"id": [1, 2, 3], "nombre": ["A", "B", "C"]})
# Tres instancias distintas de la misma clase DataFrame
print(isinstance(df_ventas, pd.DataFrame)) # True
print(isinstance(df_costes, pd.DataFrame)) # True
print(isinstance(df_clientes, pd.DataFrame)) # True
print(df_ventas is df_costes) # False
— matplotlib: Figure y Axes como objetos —
import matplotlib.pyplot as plt
import numpy as np
# plt.subplots() devuelve dos objetos
fig, ax = plt.subplots(figsize=(8, 4))
print(f"fig: {type(fig)}") # matplotlib.figure.Figure
print(f"ax: {type(ax)}") # matplotlib.axes._axes.Axes
print("---")
# Atributos de fig
print(f"fig.axes: {fig.axes}") # lista de Axes
print(f"fig tamaño: {fig.get_size_inches()}")
# Atributos de ax
print(f"ax.get_title: '{ax.get_title()}'") # '' — vacío aún
print(f"ax.get_xlim: {ax.get_xlim()}")
print("---")
# Métodos que MODIFICAN el estado del Axes
x = np.linspace(0, 2 * np.pi, 100)
ax.plot(x, np.sin(x), color="steelblue", label="sin")
ax.set_title("Seno")
ax.set_xlabel("x")
ax.legend()
ax.grid(True, alpha=0.3)
# El estado cambió
print(f"título ahora: '{ax.get_title()}'") # 'Seno'
plt.tight_layout()
plt.savefig("seno.png", dpi=100)
plt.close()
print("---")
# Múltiples Axes — igual que múltiples instancias
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
print(f"axes es: {type(axes)}") # numpy.ndarray
print(f"shape: {axes.shape}") # (3,)
print(f"tipo[0]: {type(axes[0])}") # Axes
# Cada Axes es una instancia independiente
axes[0].plot(x, np.sin(x))
axes[0].set_title("sin")
axes[1].plot(x, np.cos(x), color="tomato")
axes[1].set_title("cos")
axes[2].plot(x, np.tan(x), color="mediumseagreen")
axes[2].set_title("tan")
axes[2].set_ylim(-5, 5)
fig.tight_layout()
plt.savefig("trigonometria.png", dpi=100)
plt.close()
— El patrón sklearn: __init__ → fit() → predict() —
sklearn — la librería de Machine Learning
más usada en Python —
sigue un patrón OOP muy estricto
que se repite en
todos sus estimadores.
Una vez que lo reconoces
puedes usar cualquier modelo
de sklearn sin leer
la documentación completa.
Es exactamente el mismo patrón
que SimpleRandomForest:
__init__ con hiperparámetros,
fit() para aprender,
predict() para usar.
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
X, y = make_classification(n_samples=500, n_features=8,
random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=1
)
# El patrón es siempre el mismo
# — independientemente del modelo
modelos = {
"LogisticRegression": LogisticRegression(max_iter=1000),
"DecisionTree": DecisionTreeClassifier(max_depth=5),
"RandomForest": RandomForestClassifier(n_estimators=50),
}
print("Comparación de modelos:")
for nombre, modelo in modelos.items():
# Todos siguen el mismo patrón OOP
modelo.fit(X_train, y_train) # aprende — modifica el objeto
pred = modelo.predict(X_test) # predice — devuelve sin modificar
acc = accuracy_score(y_test, pred)
print(f" {nombre:22} → Accuracy: {acc:.3f}")
print("---")
# El patrón de los transformers — fit() + transform()
scaler = StandardScaler() # instancia — __init__ con parámetros
print(f"tipo: {type(scaler)}")
# fit() aprende la media y desviación de los datos
scaler.fit(X_train)
print(f"media aprendida: {scaler.mean_[:3].round(3)}")
print(f"std aprendida: {scaler.scale_[:3].round(3)}")
# transform() aplica la transformación — devuelve sin modificar X_train
X_train_scaled = scaler.transform(X_train)
X_test_scaled = scaler.transform(X_test)
print(f"media tras escalar: {X_train_scaled.mean():.6f}") # ≈ 0
print(f"std tras escalar: {X_train_scaled.std():.6f}") # ≈ 1
print("---")
# fit_transform() — atajo para fit() + transform()
X_scaled = StandardScaler().fit_transform(X_train)
print(f"fit_transform: media={X_scaled.mean():.4f}")
print("---")
# Reconocer el patrón en cualquier estimador
pasos = {
"__init__(hiperparámetros)": "crea el objeto — configura",
"fit(X_train, y_train)": "aprende — modifica el objeto",
"predict(X_test)": "clasifica — devuelve sin modificar",
"transform(X)": "transforma — devuelve sin modificar",
"score(X_test, y_test)": "evalúa — devuelve un número",
}
for paso, desc in pasos.items():
print(f" {paso:35} → {desc}")
— logging: el Logger como objeto —
import logging
# logging.getLogger() devuelve una instancia de Logger
logger = logging.getLogger("mi_app")
print(f"tipo: {type(logger)}") #
print(f"nombre: {logger.name}") # atributo — 'mi_app'
print(f"nivel: {logger.level}") # atributo — nivel actual
print("---")
# Múltiples loggers — múltiples instancias
# cada módulo tiene el suyo
log_pipeline = logging.getLogger("pipeline")
log_datos = logging.getLogger("datos")
log_modelos = logging.getLogger("modelos")
print(log_pipeline is log_datos) # False — instancias distintas
print(log_pipeline is # True — mismo nombre = misma instancia
logging.getLogger("pipeline"))
print("---")
# El patrón OOP del módulo logging
# Logger.__init__ → logging.getLogger(nombre)
# logger.setLevel → configura el objeto
# logger.addHandler→ añade comportamiento al objeto
# logger.info() → método que opera sobre el objeto
fmt = logging.Formatter(
"%(name)s | %(levelname)s | %(message)s"
)
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
ch.setFormatter(fmt)
log_pipeline.setLevel(logging.DEBUG)
log_pipeline.addHandler(ch)
# Usar el objeto logger
log_pipeline.info("Pipeline iniciado")
log_pipeline.warning("3 nulos detectados")
log_pipeline.debug("Shape: (100, 5)")
__init__ crea la instancia
con su configuración,
los métodos operan sobre ella.
Algunos métodos modifican el estado
— fit(),
addHandler(),
set_title() —
y otros devuelven sin modificar
— predict(),
describe(),
get_title().
Reconocer ese patrón
es suficiente para leer
cualquier código de IA
que use estas librerías.
__init__,
fit()
y predict()
en la clase
AlumnoMAD
que construimos
en el Bloque 1?
¿Qué conceptos
se corresponden?
logging.getLogger("pipeline")
llamado dos veces
devuelve el mismo objeto?
¿Qué patrón de diseño
implementa esto?
scaler = StandardScaler()
scaler.fit(X_train)
X_scaled = scaler.transform(X_train)
modelo = RandomForestClassifier(n_estimators=100)
modelo.fit(X_scaled, y_train)
pred = modelo.predict(X_test)
score = modelo.score(X_test, y_test)
print(score)
SimpleRandomForest
que construimos
y la clase
RandomForestClassifier
de sklearn?
¿Qué diferencias
esperarías encontrar?
__init__
en sklearn corresponde
a los hiperparámetros —
n_estimators,
max_depth —
igual que en
AlumnoMAD.__init__
se guardan nombre, email y notas.
Ambos configuran el objeto
al crearlo.
fit()
en sklearn corresponde
al momento en que el objeto
adquiere su estado útil
— aprende de los datos.
En AlumnoMAD
el equivalente sería
cargar las notas
o actualizar el expediente.
predict()
en sklearn corresponde
a los métodos de consulta
— media_general(),
informe() —
que usan el estado del objeto
para devolver algo
sin modificarlo.
El módulo logging
mantiene un registro
global de loggers
indexado por nombre.
Cuando llamas a
getLogger("pipeline"),
si ya existe un logger
con ese nombre
devuelve el mismo objeto —
no crea uno nuevo.
Es el patrón de diseño
Singleton
— garantiza que existe
una sola instancia
de cada logger por nombre.
Esto evita duplicar
mensajes y permite que
distintos módulos
compartan el mismo logger
sin pasarlo como argumento.
Métodos que
modifican el estado
del objeto:
scaler.fit(X_train)
— aprende media y desviación,
guarda en
scaler.mean_
y scaler.scale_.
modelo.fit(X_scaled, y_train)
— entrena el modelo,
construye los árboles
internamente.
Métodos que
devuelven sin modificar:
scaler.transform(X_train)
— aplica la escala
y devuelve un array nuevo,
scaler no cambia.
modelo.predict(X_test)
— devuelve predicciones,
el modelo no cambia.
modelo.score(X_test, y_test)
— devuelve un número,
el modelo no cambia.
En común:
ambas siguen el mismo patrón OOP
— __init__
con hiperparámetros,
fit()
que modifica el estado,
predict()
que devuelve sin modificar.
El atributo interno
que guarda los árboles
— self.trees
en la nuestra,
self.estimators_
en sklearn —
empieza vacío y se rellena
en fit().
Diferencias esperables:
sklearn tiene muchos más
hiperparámetros,
más métodos
— score(),
predict_proba(),
get_params() —
validación de tipos y errores,
soporte para paralelización,
y está optimizado
para rendimiento.
La nuestra es una versión
simplificada para
entender la estructura.
Conocer la sintaxis de las clases no significa que haya que usarlas en todo. La pregunta relevante no es ¿cómo creo una clase? sino ¿cuándo tiene sentido hacerlo? Y en el otro lado: cuando la IA te genera código OOP, ¿cómo lo lees sin confusión? Este bloque responde ambas preguntas y cierra el curso.
— Las señales de que OOP es la herramienta correcta —
Señales a favor de OOP
Hay estado que persiste
— el objeto recuerda
algo entre llamadas.
Hay múltiples instancias
con distinta configuración
— varios alumnos,
varios modelos.
Los datos y las funciones
que los manejan
van siempre juntos.
El objeto tiene
un ciclo de vida claro
— crear, configurar, usar.
Señales en contra de OOP
Es una
transformación simple
sin estado — una función
es suficiente.
El código es
exploratorio o de un solo uso
— un notebook de análisis.
Solo hay
una instancia posible
— no tiene sentido
crear una clase
para un único objeto.
La clase solo tendría
un método
— usa una función.
# ✓ Tiene sentido usar OOP — estado que persiste
class ConexionBaseDatos:
"""Estado: la conexión abierta."""
def __init__(self, host, puerto):
self.host = host
self.puerto = puerto
self.conexion = None # estado que se rellena en connect()
def conectar(self):
# self.conexion persiste entre llamadas
print(f"Conectando a {self.host}:{self.puerto}")
self.conexion = f"conn_{self.host}"
def ejecutar(self, query):
if not self.conexion:
raise RuntimeError("No hay conexión activa")
print(f"Ejecutando '{query}' en {self.conexion}")
def cerrar(self):
self.conexion = None
print("Conexión cerrada")
db = ConexionBaseDatos("localhost", 5432)
db.conectar()
db.ejecutar("SELECT * FROM ventas")
db.cerrar()
print("---")
# ✗ No tiene sentido usar OOP — transformación simple
class CalculadoraMedia: # ← innecesario
def calcular(self, datos):
return sum(datos) / len(datos)
# ✓ Una función es suficiente
def media(datos):
return sum(datos) / len(datos)
print(media([1, 2, 3, 4, 5]))
— Checklist para leer código OOP de la IA —
Cuando la IA te genera una clase y no sabes por dónde empezar, estos tres pasos te dan la estructura completa en menos de un minuto.
Paso 1 — __init__
¿Qué datos guarda el objeto? ¿Qué atributos tiene? ¿Cuáles se configuran al crear y cuáles se rellenan después?
Paso 2 — métodos
¿Qué puede hacer el objeto? ¿Qué métodos modifican el estado y cuáles devuelven? ¿Hay métodos privados con guion bajo?
Paso 3 — uso
¿Cómo se crea una instancia? ¿En qué orden se llaman los métodos? ¿Qué devuelve cada uno?
# Aplicar el checklist a una clase desconocida
class PipelineAnalisis:
"""Pipeline de análisis con pasos configurables."""
def __init__(self, nombre, verbose=False):
self.nombre = nombre # configura al crear
self.verbose = verbose # configura al crear
self.pasos = [] # se rellena con add_paso()
self.resultado = None # se rellena con ejecutar()
def add_paso(self, nombre, funcion):
"""Añade un paso al pipeline — modifica self.pasos."""
self.pasos.append({"nombre": nombre, "fn": funcion})
return self # permite encadenar: pipeline.add_paso().add_paso()
def ejecutar(self, datos):
"""Ejecuta todos los pasos en orden — modifica self.resultado."""
resultado = datos
for paso in self.pasos:
if self.verbose:
print(f" [{self.nombre}] Ejecutando: {paso['nombre']}")
resultado = paso["fn"](resultado)
self.resultado = resultado
return resultado
def resumen(self):
"""Devuelve un resumen del pipeline — no modifica."""
nombres = [p["nombre"] for p in self.pasos]
return {
"pipeline": self.nombre,
"n_pasos": len(self.pasos),
"pasos": nombres,
"ejecutado": self.resultado is not None,
}
def __str__(self):
n = len(self.pasos)
return f"Pipeline '{self.nombre}' con {n} paso{'s' if n != 1 else ''}"
# PASO 1 — __init__
# Guarda: nombre, verbose, pasos=[], resultado=None
# pasos y resultado se rellenan después
# PASO 2 — métodos
# add_paso() — modifica self.pasos, devuelve self (encadenable)
# ejecutar() — modifica self.resultado, devuelve el resultado
# resumen() — devuelve dict, no modifica
# PASO 3 — uso
import pandas as pd
import numpy as np
df = pd.DataFrame({
"ventas": [100, None, 200, 50, 300],
"precio": [1.5, 2.0, None, 1.0, 2.5]
})
pipeline = PipelineAnalisis("ventas_2024", verbose=True)
# Encadenar pasos
pipeline \
.add_paso("dropna", lambda d: d.dropna()) \
.add_paso("filtrar", lambda d: d[d["ventas"] > 100]) \
.add_paso("ingreso", lambda d: d.assign(
ingreso=d["ventas"] * d["precio"]
))
print(pipeline)
print(pipeline.resumen())
resultado = pipeline.ejecutar(df)
print("\nResultado:")
print(resultado)
— El mensaje de cierre del curso —
Lo que te llevas a casa
Python es un lenguaje orientado a objetos que permite programación funcional. No hay que elegir — hay que saber cuándo cada herramienta es la correcta.
Has usado objetos todo el curso —
DataFrames, Series, Figure, Axes, Loggers.
Ahora sabes qué hay detrás:
una clase con __init__,
atributos y métodos.
Cuando la IA te genere una clase,
sabes leerla — identificar
qué datos guarda,
qué puede hacer
y en qué orden usarla.
El objetivo del curso nunca fue que escribieras código desde cero. Fue que pudieras leerlo, entenderlo y supervisarlo — con criterio y sin miedo. OOP es una herramienta más en ese repertorio.
# El resumen completo en código
# Una clase tiene:
class ResumenOOP:
atributo_de_clase = "compartido por todas las instancias"
def __init__(self, dato):
self.dato = dato # atributo de instancia
def metodo_que_devuelve(self):
return self.dato * 2 # no modifica
def metodo_que_modifica(self, nuevo):
self.dato = nuevo # modifica el estado
def _metodo_privado(self):
return "solo uso interno"
def __str__(self):
return f"ResumenOOP({self.dato})"
def __repr__(self):
return f"ResumenOOP(dato={self.dato!r})"
# Una instancia es un objeto concreto de esa clase
obj = ResumenOOP(42)
print(type(obj)) #
print(isinstance(obj, ResumenOOP)) # True
print(obj) # __str__
print(repr(obj)) # __repr__
print(obj.metodo_que_devuelve()) # 84 — no modifica
obj.metodo_que_modifica(100)
print(obj.dato) # 100 — modificó
print("---")
# El patrón que se repite en toda la IA
# Pizza: Pizza("Margarita", [...]) → preparar() → calcular_precio()
# AlumnoMAD: AlumnoMAD("Ana", ...) → informe() → media_general()
# SimpleRandomForest: SRF(n_trees=20) → fit(X, y) → predict(X)
# sklearn: RandomForest(n_estimators) → fit(X, y) → predict(X)
# pandas: DataFrame({...}) → dropna() → groupby()
# matplotlib: Figure / Axes → plot() → set_title()
# logging: getLogger("nombre") → setLevel → info()
# — el modelo mental es siempre el mismo —
PipelineAnalisis
y describe con tus propias palabras:
qué datos guarda en
__init__,
qué hacen sus métodos
y en qué orden
se usaría.
add_paso()
devuelve self?
¿Qué permite eso?
¿Dónde has visto
ese mismo patrón
en pandas?
p = PipelineAnalisis("test")
print(p.resumen()["n_pasos"])
p.add_paso("paso1", lambda x: x)
p.add_paso("paso2", lambda x: x)
print(p.resumen()["n_pasos"])
print(p.resumen()["ejecutado"])
p.ejecutar([1, 2, 3])
print(p.resumen()["ejecutado"])
Ejemplo donde OOP tiene sentido:
una clase ModeloEconometrico
que guarda los datos,
los coeficientes estimados,
los errores estándar
y los p-valores —
y tiene métodos
estimar(),
predecir(),
summary().
El estado persiste —
los coeficientes estimados
se guardan en el objeto —
y se pueden crear
múltiples instancias
con distintas especificaciones.
Ejemplo donde una función es suficiente: calcular el estadístico t de una prueba de hipótesis. Es una transformación simple sin estado — entra un array de datos, sale un número. No hay razón para crear una clase.
Paso 1 —
__init__:
guarda el nombre del pipeline,
un flag verbose para imprimir
mensajes durante la ejecución,
una lista vacía de pasos
que se irá rellenando,
y el resultado que empieza
como None
hasta que se ejecuta.
Paso 2 — métodos:
add_paso()
modifica la lista de pasos
y devuelve el propio objeto
para encadenar.
ejecutar()
aplica cada paso en orden
y guarda el resultado
en self.resultado.
resumen()
devuelve un diccionario
con información del estado
sin modificar nada.
Paso 3 — uso:
crear instancia →
encadenar add_paso()
para configurar →
llamar a ejecutar(datos)
para procesar →
consultar resumen()
o usar el resultado devuelto.
add_paso()
devuelve self
— el propio objeto —
para permitir encadenar
varias llamadas en una sola expresión:
pipeline.add_paso(...).add_paso(...).add_paso(...).
Es exactamente el mismo patrón
del método encadenado de pandas:
df.dropna().query(...).assign(...).
Cada método devuelve
el objeto sobre el que operar —
en pandas un nuevo DataFrame,
aquí el mismo pipeline.
p.resumen()["n_pasos"]
→ 0:
self.pasos = []
en __init__,
sin pasos todavía.
Tras dos add_paso(),
p.resumen()["n_pasos"]
→ 2:
la lista tiene dos elementos.
p.resumen()["ejecutado"]
→ False:
self.resultado
sigue siendo None
— ejecutar()
no se ha llamado todavía.
Tras p.ejecutar([1, 2, 3]),
p.resumen()["ejecutado"]
→ True:
self.resultado
ya no es None
— ejecutar()
lo rellenó con el resultado
de aplicar los dos pasos.