UdelaR  ·  Máster en Análisis de Datos

Introducción a Python

Clase Extra — Introducción a la Programación Orientada a Objetos

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

Qué es una clase y por qué existe

El problema que resuelve OOP: agrupar datos y comportamiento teoría

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
El mensaje central: OOP no es una sintaxis nueva — es una forma de organizar el código. En lugar de tener datos sueltos y funciones sueltas, los agrupamos en objetos que saben qué son y qué pueden hacer. Ya has usado objetos todo el curso — DataFrames, Series, Figure, Axes. Ahora aprenderás a crearlos tú mismo.
  1. ¿Cuál es el problema principal del código sin OOP del primer ejemplo? ¿Qué pasaría si tuvieras 50 alumnos?
  2. Identifica al menos tres objetos que hayas usado en el curso sin saber que eran instancias de una clase. ¿Qué clase es cada uno?
  3. ¿Cuál es la diferencia entre una clase y una instancia? Usa la analogía del molde para explicarlo con tus propias palabras.
  4. En el ejemplo con OOP: ¿por qué ana.informe() no necesita recibir el nombre ni el email como argumentos si la versión sin OOP sí los necesitaba?
Respuesta orientativa
a

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.

b

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.

c

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.

d

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.

Clase, instancia, objeto: las tres palabras clave teoría

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
Conexión con la Clase 6b: cada instancia que creas — 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
  1. ¿Cuál es la diferencia entre type(ana) e isinstance(ana, Alumno)? ¿Cuándo usarías cada uno?
  2. ¿Por qué este código modifica ana aunque solo se asignó a copia?
    copia = ana
    copia.nombre = "Ana Otra"
    print(ana.nombre)   # ¿qué imprime?
  3. ¿Qué tipo devuelve type(Alumno)? ¿Por qué una clase es también un objeto?
  4. Crea tres instancias de la clase Alumno con tus propios datos y verifica con isinstance que son instancias de Alumno y también de object. ¿Por qué ambas devuelven True?
Respuesta orientativa
a

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.

b

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()).

c

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.

d
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.

Parte II

Anatomía de una clase: atributos, métodos y self

__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']}
  1. ¿Por qué __init__ recibe self como primer parámetro pero al crear el objeto no se pasa ningún self?
  2. ¿Cuál es la diferencia entre un atributo de instancia y un atributo de clase? ¿Qué pasa si cambias un atributo de clase?
  3. Crea una clase 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.
  4. ¿Qué devuelve margarita.__dict__? ¿Por qué no incluye los atributos de clase?
Respuesta orientativa
a

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.

b

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.

c
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
d

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.

Métodos y dunder methods: el comportamiento del objeto teoría

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())
El modelo mental completo: una clase tiene atributos — los datos, definidos en __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ú.
  1. ¿Cuál es la diferencia entre __str__ y __repr__? ¿Cuándo usa Python cada uno automáticamente?
  2. ¿Por qué margarita.calcular_precio() no necesita argumentos pero calcular_precio_fn([...], 1.5) sí los necesita?
  3. Añade a la clase Pizza un método es_vegetariana() que devuelva True si ningún ingrediente es carne — define tú qué ingredientes son carne.
  4. ¿Qué dunder method implementarías para que este código funcione correctamente? ¿Qué debería devolver?
    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)
Respuesta orientativa
a

__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.

b

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.

c
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
d

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
Parte III

OOP en la práctica: datos, análisis y Random Forest

Una clase para análisis de datos: 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)
El patrón OOP → DataFrame: crear instancias con sus datos, operar sobre ellas con métodos, y cuando se necesita análisis convertir la lista de objetos a DataFrame con una list comprehension. Es uno de los patrones más frecuentes en código de IA de análisis de datos — ahora sabes leerlo y entender qué hace cada parte.
  1. ¿Por qué el contador total_alumnos es un atributo de clase y no de instancia? ¿Qué pasaría si fuera self.total_alumnos?
  2. ¿Qué hace exactamente el operador **a.notas dentro del diccionario de la list comprehension? ¿Por qué es útil aquí?
  3. Añade a la clase AlumnoMAD un método materias_suspensas() que devuelva la lista de materias con nota inferior a 5. Pruébalo con bob.
  4. ¿Qué imprime este código y por qué?
    print(AlumnoMAD.total_alumnos)
    nuevo = AlumnoMAD("Diana", "d@uvigo.es", {})
    print(AlumnoMAD.total_alumnos)
    print(ana.total_alumnos)
    print(nuevo.total_alumnos)
Respuesta orientativa
a

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.

b

**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.

c
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
d

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.

OOP vs paradigma funcional: la misma Pizza dos veces debate

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
El mensaje de las slides: Python es un lenguaje OOP que permite programación funcional. No hay que elegir — hay que saber cuándo cada estilo es más apropiado. El código de IA mezcla los dos constantemente: usa clases para modelar entidades con estado — modelos, conexiones, configuraciones — y funciones puras para transformar datos. Reconocer los dos estilos es suficiente para leerlo y supervisarlo con criterio.
  1. ¿Cuál es la diferencia principal entre la versión OOP y la versión funcional de la clase Pizza? ¿Cuál modifica el objeto original y cuál crea uno nuevo?
  2. ¿Qué genera automáticamente el decorador @dataclass? ¿Qué ventaja tiene sobre escribir la clase manualmente?
  3. ¿Por qué dataclass(frozen=True) usa tuple para los ingredientes en lugar de list? ¿Qué pasaría si usara una lista?
  4. Lee este fragmento de código de IA e identifica qué estilo usa — OOP, funcional o mezcla — y justifica por qué:
    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]))
Respuesta orientativa
a

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.

b

@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.

c

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.

d

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.

El Random Forest: leer código OOP de la IA leer

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 —

SimpleRandomForest — la clase
__init__ — constructor: guarda hiperparámetros, inicializa trees=[]
Atributos de instancia: n_trees, max_depth, sample_size, trees
_bootstrap_sample — método PRIVADO (convención: _) — solo uso interno
fit — método PÚBLICO — entrena — modifica self.trees
predict — método PÚBLICO — predice — devuelve, no modifica

— 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.

Lo que importa llevarse a casa: leer una clase de IA no requiere entender el algoritmo. Requiere identificar __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.
  1. Identifica en la clase 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?
  2. ¿Por qué 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()?
  3. ¿Cuál es la ventaja de la versión OOP para crear múltiples variantes del modelo? ¿Cómo se compara con la versión funcional?
  4. Lee este fragmento de código de IA y responde: ¿qué hace cada línea, qué tipo tiene 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?
Respuesta orientativa
a

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.

b

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.

c

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.

d

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.

Parte IV

Cuándo usar clases: OOP vs paradigma funcional

OOP en las librerías que ya conoces: DataFrame, Figure, Logger leer

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)")
El patrón que se repite: en pandas, matplotlib, sklearn y logging el modelo mental es siempre el mismo — una clase define el tipo de objeto, __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.
  1. ¿Cuál es el equivalente en sklearn de __init__, fit() y predict() en la clase AlumnoMAD que construimos en el Bloque 1? ¿Qué conceptos se corresponden?
  2. ¿Por qué logging.getLogger("pipeline") llamado dos veces devuelve el mismo objeto? ¿Qué patrón de diseño implementa esto?
  3. Observa este fragmento de código de IA. Identifica qué métodos modifican el estado del objeto y cuáles devuelven un valor nuevo sin modificar:
    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)
  4. ¿Qué tienen en común la clase SimpleRandomForest que construimos y la clase RandomForestClassifier de sklearn? ¿Qué diferencias esperarías encontrar?
Respuesta orientativa
a

__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.

b

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.

c

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.

d

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.

Cuándo usar clases: señales, checklist y mensaje de cierre debate

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 —
  1. ¿Cuándo tiene sentido usar una clase? Da un ejemplo concreto de tu área — econometría, análisis de datos, investigación — donde OOP sería la herramienta correcta y otro donde una función sería suficiente.
  2. Aplica el checklist de tres pasos a la clase PipelineAnalisis y describe con tus propias palabras: qué datos guarda en __init__, qué hacen sus métodos y en qué orden se usaría.
  3. ¿Por qué add_paso() devuelve self? ¿Qué permite eso? ¿Dónde has visto ese mismo patrón en pandas?
  4. Sin ejecutar el código, predice qué imprime cada línea y justifica por qué:
    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"])
Respuesta orientativa
a

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.

b

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.

c

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.

d

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.