Saltar a contenido

Laboratorios — Módulo 01: FUNDAMENTOS

AI Master Academy · Módulo 01-FUNDAMENTOS · Julio 2026 Laboratorios prácticos guiados paso a paso. Aquí no se lee: aquí se teclea.


Índice

# Laboratorio Tiempo Dificultad
1 Tu entorno profesional de Python 60-90 min
2 Álgebra lineal en acción 90 min
3 Descenso de gradiente desde cero 90-120 min
4 Análisis de datos reales con Pandas 90 min
5 Mini buscador semántico de juguete 120 min

Nota

Todos los laboratorios asumen que completaste el módulo 00 (Python 3.12+ y Docker instalados). Cada lab es independiente, pero el orden recomendado es 1 → 5.

Consejo profesional

No copies y pegues el código. Escríbelo a mano. El 80% del aprendizaje de programación ocurre en los dedos, no en los ojos. Los errores de tipeo que cometas (y arregles) son parte del entrenamiento.

Advertencia

Si un comando falla, NO saltes al siguiente paso. Lee el mensaje de error completo (en el Lab 1 aprenderás cómo), consulta la tabla de "Errores comunes" del lab, y solo entonces continúa.


LAB 1 — Tu entorno profesional de Python

Objetivo

Al terminar este laboratorio sabrás:

  • Crear un proyecto Python aislado con venv (y conocerás la alternativa moderna uv).
  • Declarar dependencias en requirements.txt y entender qué es un pyproject.toml.
  • Organizar el código con una estructura de carpetas profesional (src/).
  • Versionar tu trabajo con git init y commits con mensajes correctos.
  • Leer un traceback de Python sin entrar en pánico (provocaremos un error a propósito).

Tiempo estimado

60-90 minutos.

Requisitos

  • Python 3.12 o superior instalado (verifícalo con python --version).
  • Git instalado (verifícalo con git --version).
  • Una terminal (PowerShell en Windows, Terminal en macOS/Linux).
  • Un editor: VS Code recomendado.

Nota

En Windows el comando puede ser python o py. En macOS/Linux suele ser python3. En este lab usaremos python; sustituye según tu sistema.


Paso 1 — Crear la carpeta del proyecto

Abre tu terminal y ejecuta:

# Crea la carpeta raíz del proyecto (mkdir = "make directory")
mkdir mi-primer-proyecto-ia

# Entra en la carpeta recién creada (cd = "change directory")
cd mi-primer-proyecto-ia

Resultado esperado: ninguna salida (en Unix, silencio = éxito). Verifica con pwd (o Get-Location en PowerShell) que estás dentro de la carpeta.

Paso 2 — Crear el entorno virtual con venv

Un entorno virtual es una copia aislada de Python solo para este proyecto. Así, las librerías de un proyecto no rompen las de otro.

# Le pedimos a Python que ejecute el módulo "venv" (-m = módulo)
# y cree un entorno virtual en la carpeta ".venv"
python -m venv .venv

Ahora actívalo. El comando depende de tu sistema:

# Windows (PowerShell):
.venv\Scripts\Activate.ps1

# Windows (cmd):
.venv\Scripts\activate.bat

# macOS / Linux (bash/zsh):
source .venv/bin/activate

Resultado esperado: el prompt de la terminal cambia y muestra el prefijo (.venv):

(.venv) C:\Users\ana\mi-primer-proyecto-ia>

Advertencia

En Windows, si PowerShell dice "la ejecución de scripts está deshabilitada", ejecuta una sola vez:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser y vuelve a intentarlo.

Consejo profesional

La alternativa moderna es uv (de Astral), que en 2026 ya es estándar en muchas empresas: uv venv crea el entorno y uv pip install numpy instala paquetes 10-100× más rápido. Todo lo que hagas aquí con pip funciona igual con uv pip. Aprende primero venv + pip (es lo universal) y adopta uv después.

# Alternativa con uv (opcional, si lo tienes instalado):
uv venv            # crea .venv
uv pip install numpy pandas   # instala dependencias a toda velocidad

Paso 3 — Instalar NumPy y Pandas

Con el entorno activado:

# pip es el instalador de paquetes de Python.
# "install" descarga los paquetes desde PyPI (el repositorio oficial)
pip install numpy pandas

Resultado esperado (resumido):

Collecting numpy
  Downloading numpy-2.3.1-cp312-cp312-win_amd64.whl (12.6 MB)
Collecting pandas
  Downloading pandas-2.3.0-cp312-cp312-win_amd64.whl (11.5 MB)
...
Successfully installed numpy-2.3.1 pandas-2.3.0 python-dateutil-2.9.0 pytz-2026.1 six-1.17.0 tzdata-2026.1

Nota

Las versiones exactas variarán. Lo importante es la línea final Successfully installed.

Paso 4 — Congelar las dependencias en requirements.txt

# "pip freeze" lista los paquetes instalados con su versión exacta.
# El símbolo ">" redirige esa salida a un archivo.
pip freeze > requirements.txt

Abre requirements.txt en tu editor. Verás algo como:

numpy==2.3.1
pandas==2.3.0
python-dateutil==2.9.0
pytz==2026.1
six==1.17.0
tzdata==2026.1

Esto permite que cualquier persona (¡o tu yo del futuro!) reproduzca el entorno con un solo comando: pip install -r requirements.txt.

Paso 5 — Conocer pyproject.toml (el estándar moderno)

requirements.txt funciona, pero el estándar profesional actual es pyproject.toml. Crea el archivo en la raíz del proyecto con este contenido:

# pyproject.toml — la "ficha técnica" de tu proyecto

[project]                                # sección de metadatos del proyecto
name = "mi-primer-proyecto-ia"           # nombre del paquete (minúsculas y guiones)
version = "0.1.0"                        # versionado semántico: mayor.menor.parche
description = "Mi primer proyecto del máster de IA"  # descripción corta
requires-python = ">=3.12"               # versión mínima de Python
dependencies = [                         # dependencias con rango de versiones
    "numpy>=2.0",                        # >= 2.0 pero cualquier versión posterior
    "pandas>=2.2",                       # ídem para pandas
]

Nota

Por ahora basta con que exista y entiendas qué declara. Herramientas como uv, pip (moderno) o poetry lo leen automáticamente. En el módulo 03 lo exprimiremos a fondo.

Paso 6 — Crear la estructura de carpetas profesional

# Creamos la carpeta del código fuente
mkdir src

# Dentro, la carpeta del paquete (mismo nombre que el proyecto, con guiones bajos)
mkdir src/mi_proyecto

# Y una carpeta para futuros tests
mkdir tests

Crea también un archivo vacío src/mi_proyecto/__init__.py (indica a Python que la carpeta es un paquete):

# Windows PowerShell:
New-Item src/mi_proyecto/__init__.py -ItemType File

# macOS / Linux:
touch src/mi_proyecto/__init__.py

La estructura final debe ser:

mi-primer-proyecto-ia/
├── .venv/                  # entorno virtual (NUNCA se sube a git)
├── src/
│   └── mi_proyecto/
│       └── __init__.py     # marca la carpeta como paquete Python
├── tests/                  # aquí vivirán los tests (módulo 03)
├── pyproject.toml          # metadatos y dependencias (estándar moderno)
└── requirements.txt        # dependencias congeladas (clásico)

Consejo profesional

¿Por qué src/ y no el código suelto en la raíz? Porque evita que Python importe accidentalmente tu código local en lugar del paquete instalado, un bug clásico y difícil de detectar. Todas las empresas serias usan "src layout".

Paso 7 — Tu primer script

Crea el archivo src/mi_proyecto/hola_ia.py:

# hola_ia.py — Primer script del máster: comprobamos que todo funciona.

# Importamos numpy con su alias universal "np"
import numpy as np

# Importamos pandas con su alias universal "pd"
import pandas as pd


def main():
    """Función principal: comprueba versiones y hace un cálculo mínimo."""
    # f-string: las llaves {} interpolan variables dentro del texto
    print(f"NumPy versión:  {np.__version__}")
    print(f"Pandas versión: {pd.__version__}")

    # Creamos un array (vector) de NumPy con 5 números
    notas = np.array([7.5, 9.0, 6.0, 8.5, 10.0])

    # .mean() calcula la media; .max() el máximo
    print(f"Media de notas: {notas.mean()}")
    print(f"Nota máxima:    {notas.max()}")


# Este "if" hace que main() solo se ejecute al lanzar el script directamente,
# no cuando otro archivo lo importe. Es el idioma estándar de Python.
if __name__ == "__main__":
    main()

Ejecútalo:

python src/mi_proyecto/hola_ia.py

Resultado esperado:

NumPy versión:  2.3.1
Pandas versión: 2.3.0
Media de notas: 8.2
Nota máxima:    10.0

Paso 8 — Provocar un error A PROPÓSITO (y aprender a leer el traceback)

Esta es la habilidad más subestimada de la programación. Crea src/mi_proyecto/error_a_proposito.py:

# error_a_proposito.py — Vamos a romper cosas con intención pedagógica.

def dividir(a, b):
    """Divide a entre b. Sin protección alguna, a propósito."""
    return a / b            # si b es 0, aquí explotará


def calcular_media(numeros):
    """Media aritmética: suma dividida por cantidad."""
    total = sum(numeros)               # suma todos los elementos
    return dividir(total, len(numeros))  # divide por el número de elementos


# Llamamos con una lista VACÍA: len([]) es 0 → división por cero
print(calcular_media([]))

Ejecútalo y observa:

python src/mi_proyecto/error_a_proposito.py

Resultado esperado (¡el error es el objetivo!):

Traceback (most recent call last):
  File "src/mi_proyecto/error_a_proposito.py", line 15, in <module>
    print(calcular_media([]))
  File "src/mi_proyecto/error_a_proposito.py", line 11, in calcular_media
    return dividir(total, len(numeros))
  File "src/mi_proyecto/error_a_proposito.py", line 5, in dividir
    return a / b
ZeroDivisionError: division by zero

Cómo se lee un traceback (memoriza esto):

  1. Lee de ABAJO hacia ARRIBA. La última línea es el diagnóstico: ZeroDivisionError: division by zero → tipo de error + explicación.
  2. La línea justo encima (return a / b, línea 5) es dónde explotó.
  3. Las líneas superiores son la cadena de llamadas (call stack): el script llamó a calcular_media, que llamó a dividir. El origen real del problema suele estar arriba (pasamos una lista vacía), aunque la explosión ocurra abajo.

Consejo profesional

Cuando un traceback mezcle TU código con código de librerías (rutas con site-packages), busca la línea más profunda que sea tuya: ahí está casi siempre tu bug.

Paso 9 — Versionar con Git

# Inicializa un repositorio git en la carpeta actual
git init

# Crea el archivo .gitignore ANTES del primer commit

Crea .gitignore con este contenido:

# .gitignore — lo que git debe IGNORAR

.venv/            # el entorno virtual: pesado y reproducible, nunca se versiona
__pycache__/      # caché de bytecode que Python genera solo
*.pyc             # archivos compilados individuales
.DS_Store         # basura de macOS
*.csv             # (opcional) datos generados; los datasets grandes no van a git

Ahora los commits:

# Añade TODOS los archivos (respetando .gitignore) al área de preparación
git add .

# Comprueba QUÉ se va a commitear (hazlo siempre antes de commitear)
git status

# Primer commit: mensaje en imperativo, corto y descriptivo
git commit -m "Estructura inicial del proyecto con venv, src/ y dependencias"

# Haz un cambio (p. ej. añade un comentario a hola_ia.py) y commitea de nuevo:
git add src/mi_proyecto/hola_ia.py
git commit -m "Añade comentario explicativo en hola_ia.py"

# Mira tu historial:
git log --oneline

Resultado esperado:

a3f9c21 Añade comentario explicativo en hola_ia.py
8b1d0e4 Estructura inicial del proyecto con venv, src/ y dependencias

Advertencia

Si git commit te pide identidad, configúrala una vez:

git config --global user.name "Tu Nombre" y git config --global user.email "tu@email.com".

Errores comunes

Error Causa Solución
'python' no se reconoce como comando Python no está en el PATH Reinstala marcando "Add Python to PATH", o usa py en Windows
No module named numpy El venv no está activado (instalaste en otro Python) Activa el venv ((.venv) visible en el prompt) y reinstala
cannot be loaded because running scripts is disabled Política de ejecución de PowerShell Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
SyntaxError: invalid syntax señalando un print Tecleaste mal (falta paréntesis, comilla sin cerrar) en la línea anterior Revisa la línea indicada y la anterior
git: command not found Git no está instalado Instálalo desde git-scm.com y reabre la terminal
pip install lentísimo o falla por red Proxy/red corporativa o PyPI caído Reintenta; considera uv pip install o un mirror
Commiteaste .venv/ por accidente Creaste .gitignore después del git add git rm -r --cached .venv y commitea de nuevo

Cómo depurar

Técnica del lab: leer tracebacks + prints estratégicos.

  1. Traceback primero: última línea = qué pasó; línea más profunda de TU código = dónde mirar.
  2. Print de inspección: antes de la línea sospechosa, imprime las variables implicadas con contexto:
    print(f"DEBUG: numeros={numeros!r}, len={len(numeros)}")  # !r muestra el valor "crudo"
    
  3. Print de checkpoint: si no sabes hasta dónde llega el programa, siembra print("LLEGUÉ AQUÍ 1"), print("LLEGUÉ AQUÍ 2")... y mira cuál es el último que aparece.
  4. Reduce el caso: si el error ocurre con datos grandes, reprodúcelo con el ejemplo mínimo posible (una lista de 2 elementos). Un bug que puedes reproducir en 3 líneas está 90% resuelto.

Para ir más lejos

  1. Prueba uv de verdad: instala uv, borra .venv, y recrea todo el entorno con uv venv + uv pip install -r requirements.txt. Cronometra ambas formas.
  2. Sube el proyecto a GitHub: crea un repositorio remoto y haz git push. Es tu primer portfolio.
  3. Docker: escribe un Dockerfile mínimo (FROM python:3.12-slim, COPY, RUN pip install -r requirements.txt) y ejecuta tu script dentro de un contenedor. Conecta con lo aprendido en el módulo 00.

LAB 2 — Álgebra lineal en acción

Objetivo

Construir un recomendador de películas real (de juguete, pero real) usando solo vectores y similitud coseno. Al terminar entenderás en las manos, no solo en la cabeza:

  • Qué significa representar un objeto del mundo (una película) como vector de características.
  • Por qué hay que normalizar las características.
  • Cómo la similitud coseno mide "parecido" entre vectores.
  • Cómo se construye una matriz de similitud — la misma idea que usan Netflix o Spotify a otra escala.

Tiempo estimado

90 minutos.

Requisitos

  • Lab 1 completado (proyecto con venv + NumPy instalado).
  • Haber leído el capítulo de matemáticas del módulo (vectores y coseno).

Paso 1 — Elegir las características

Vamos a describir cada película con 4 números:

Característica Rango original ¿Por qué la elegimos?
Acción (0-10) 0 a 10 Cuánta acción tiene
Romance (0-10) 0 a 10 Cuánto romance tiene
Duración (min) 80 a 210 Películas largas vs. cortas
Año 1970 a 2026 Clásicas vs. modernas

Advertencia

Fíjate en el problema: la duración va de 80 a 210 pero la acción de 0 a 10. Sin normalizar, la duración dominaría cualquier cálculo de distancia. Este es el error nº 1 de los principiantes con vectores de características.

Paso 2 — Crear los vectores en NumPy

Crea src/mi_proyecto/recomendador.py:

# recomendador.py — Recomendador de películas con similitud coseno.

import numpy as np                      # la librería de cálculo numérico

# Nombres de las películas (el orden importa: fila i = película i)
peliculas = [
    "Mad Max: Furia",     # acción pura, moderna
    "Titanic",            # romance épico, larga, años 90
    "John Wick 4",        # acción moderna, larga
    "Antes del amanecer", # romance indie, corta, años 90
    "El Padrino",         # drama clásico, larga, años 70
]

# Matriz de características SIN normalizar: una FILA por película,
# una COLUMNA por característica: [accion, romance, duracion, año]
X_bruto = np.array([
    [9.0, 1.0, 120, 2015],   # Mad Max
    [2.0, 9.0, 195, 1997],   # Titanic
    [9.5, 0.5, 169, 2023],   # John Wick 4
    [0.5, 9.5,  101, 1995],  # Antes del amanecer
    [6.0, 2.0, 175, 1972],   # El Padrino
])

# .shape devuelve (filas, columnas): confirmamos 5 películas × 4 características
print(f"Forma de la matriz: {X_bruto.shape}")

Ejecuta: python src/mi_proyecto/recomendador.py

Resultado esperado:

Forma de la matriz: (5, 4)

Paso 3 — Normalizar con min-max

Llevamos cada columna al rango [0, 1] con la fórmula (x - mín) / (máx - mín):

# --- Añade esto al final de recomendador.py ---

def normalizar_minmax(X):
    """Escala cada COLUMNA de X al rango [0, 1]."""
    # axis=0 significa "opera por columnas": mínimo de cada característica
    minimos = X.min(axis=0)
    # máximo de cada característica
    maximos = X.max(axis=0)
    # Broadcasting de NumPy: la resta y división se aplican
    # a cada fila automáticamente, sin bucles
    return (X - minimos) / (maximos - minimos)


# Aplicamos la normalización a la matriz bruta
X = normalizar_minmax(X_bruto)

# np.round redondea para imprimir bonito; 2 = dos decimales
print("Matriz normalizada:")
print(np.round(X, 2))

Resultado esperado:

Matriz normalizada:
[[0.94 0.06 0.2  0.84]
 [0.17 0.94 1.   0.49]
 [1.   0.   0.72 1.  ]
 [0.   1.   0.   0.45]
 [0.61 0.17 0.79 0.  ]]

Nota

Ahora todas las columnas "pesan" lo mismo. Mad Max tiene acción 0.94 (casi el máximo del grupo) y Antes del amanecer 0.0 (el mínimo).

Paso 4 — Implementar la similitud coseno

La similitud coseno mide el ángulo entre dos vectores: 1 = misma dirección (muy parecidas), 0 = perpendiculares (nada que ver).

Fórmula: cos(a, b) = (a · b) / (‖a‖ × ‖b‖)

# --- Añade al final de recomendador.py ---

def similitud_coseno(a, b):
    """Similitud coseno entre dos vectores a y b."""
    # np.dot = producto escalar: suma de productos elemento a elemento
    producto_punto = np.dot(a, b)
    # np.linalg.norm = norma (longitud) del vector: sqrt(suma de cuadrados)
    norma_a = np.linalg.norm(a)
    norma_b = np.linalg.norm(b)
    # Dividimos el producto escalar por el producto de las normas
    return producto_punto / (norma_a * norma_b)


# Probamos con las dos primeras películas
sim = similitud_coseno(X[0], X[1])
print(f"\nSimilitud Mad Max vs Titanic: {sim:.3f}")

Resultado esperado:

Similitud Mad Max vs Titanic: 0.489

Paso 5 — Construir la matriz de similitud completa

# --- Añade al final de recomendador.py ---

# n = número de películas (primera dimensión de X)
n = X.shape[0]

# Matriz n×n llena de ceros donde guardaremos cada par de similitudes
S = np.zeros((n, n))

# Doble bucle: comparamos cada película i con cada película j
for i in range(n):
    for j in range(n):
        # Guardamos la similitud del par (i, j) en la celda [i, j]
        S[i, j] = similitud_coseno(X[i], X[j])

# --- Impresión formateada de la matriz ---

# Cabecera: nombres abreviados a 12 caracteres, alineados
print("\nMATRIZ DE SIMILITUD COSENO")
print(" " * 14 + "".join(f"{p[:12]:>14}" for p in peliculas))

# Una fila por película: nombre + similitudes con 3 decimales
for i, nombre in enumerate(peliculas):
    fila = "".join(f"{S[i, j]:>14.3f}" for j in range(n))
    print(f"{nombre[:12]:>14}{fila}")

Resultado esperado:

MATRIZ DE SIMILITUD COSENO
              Mad Max: Fur       Titanic    John Wick 4  Antes del am    El Padrino
  Mad Max: Fur         1.000         0.489         0.965         0.334         0.663
       Titanic         0.489         1.000         0.564         0.780         0.700
   John Wick 4         0.965         0.564         1.000         0.269         0.756
  Antes del am         0.334         0.780         0.269         1.000         0.183
    El Padrino         0.663         0.700         0.756         0.183         1.000

Nota

Observa dos propiedades: la diagonal es 1.000 (toda película es idéntica a sí misma) y la matriz es simétrica (sim(A,B) = sim(B,A)). Si tu matriz no cumple esto, hay un bug.

Paso 6 — La función recomendar()

# --- Añade al final de recomendador.py ---

def recomendar(pelicula):
    """Devuelve la película más parecida a la dada (sin contarla a ella misma)."""
    # .index() encuentra la posición del nombre en la lista
    idx = peliculas.index(pelicula)
    # Copiamos su fila de similitudes para no modificar S
    sims = S[idx].copy()
    # Truco: ponemos su propia similitud a -1 para que nunca se recomiende a sí misma
    sims[idx] = -1.0
    # np.argmax devuelve el ÍNDICE del valor máximo
    mejor = int(np.argmax(sims))
    # Devolvemos el nombre y la puntuación de similitud
    return peliculas[mejor], sims[mejor]


# Probamos el recomendador con todas las películas
print("\nRECOMENDACIONES:")
for p in peliculas:
    recomendada, puntuacion = recomendar(p)
    print(f"  Si te gustó '{p}' → te recomiendo '{recomendada}' (sim={puntuacion:.3f})")

Resultado esperado:

RECOMENDACIONES:
  Si te gustó 'Mad Max: Furia' → te recomiendo 'John Wick 4' (sim=0.965)
  Si te gustó 'Titanic' → te recomiendo 'Antes del amanecer' (sim=0.780)
  Si te gustó 'John Wick 4' → te recomiendo 'Mad Max: Furia' (sim=0.965)
  Si te gustó 'Antes del amanecer' → te recomiendo 'Titanic' (sim=0.780)
  Si te gustó 'El Padrino' → te recomiendo 'John Wick 4' (sim=0.756)

Consejo profesional

Acabas de implementar la esencia de un sistema de recomendación basado en contenido. Los sistemas reales usan miles de dimensiones (embeddings) en lugar de 4 características a mano, pero la matemática del coseno es EXACTAMENTE la misma. Recuerda este lab cuando llegues al módulo 06 (RAG).

Paso 7 — Commit del trabajo

# Añadimos el nuevo archivo al repositorio
git add src/mi_proyecto/recomendador.py

# Commit descriptivo
git commit -m "Añade recomendador de películas con similitud coseno"

Errores comunes

Error Causa Solución
ValueError: shapes not aligned Vectores de distinta longitud en np.dot Verifica a.shape == b.shape antes de operar
Diagonal de S distinta de 1.0 Bug en similitud_coseno o datos sin normalizar bien Prueba similitud_coseno(X[0], X[0]) → debe dar 1.0
RuntimeWarning: invalid value encountered in divide División por norma 0 (vector de todo ceros) Con min-max puede pasar si una película es mínima en todo; añade + 1e-9 al denominador
Similitudes todas ≈ 0.99 Olvidaste normalizar: la columna "año" (valores ~2000) domina Aplica normalizar_minmax ANTES de calcular similitudes
ValueError: 'X' is not in list en recomendar Nombre mal escrito (tildes, mayúsculas) Copia el nombre exacto de la lista peliculas
La matriz no es simétrica Modificaste X entre cálculos, o indexaste [j, i] Recalcula S de una vez; revisa índices del doble bucle

Cómo depurar

Técnica del lab: verificar invariantes matemáticos.

El código numérico se depura comprobando propiedades que DEBEN cumplirse:

# 1. La similitud de un vector consigo mismo debe ser 1.0 (con margen flotante)
assert abs(similitud_coseno(X[0], X[0]) - 1.0) < 1e-9, "¡cos(a,a) debe ser 1!"

# 2. La matriz debe ser simétrica: S == S.T (transpuesta), con tolerancia
assert np.allclose(S, S.T), "¡La matriz de similitud debe ser simétrica!"

# 3. Los datos normalizados deben estar en [0, 1]
assert X.min() >= 0 and X.max() <= 1, "¡Normalización fuera de rango!"

Si un assert falla, imprime los ingredientes uno a uno: print(np.dot(a, b)), print(np.linalg.norm(a))... El bug siempre está en el ingrediente cuyo valor te sorprenda.

Para ir más lejos

  1. Añade 5 películas más y una quinta característica (p. ej. "comedia 0-10"). ¿Cambian las recomendaciones? ¿Alguna se vuelve contraintuitiva?
  2. Compara con distancia euclídea: implementa np.linalg.norm(a - b) y compara los rankings. ¿Cuándo coinciden coseno y euclídea? (pista: con vectores normalizados a norma 1, siempre).
  3. recomendar_top_k(pelicula, k): devuelve las k más parecidas ordenadas, usando np.argsort.

LAB 3 — Descenso de gradiente desde cero

Objetivo

Implementar el algoritmo que entrena todas las redes neuronales del mundo: el descenso de gradiente. Sin librerías de ML, solo Python y NumPy:

  • Minimizar una función simple f(x) = x² + 2x + 1 paso a paso.
  • Entrenar una regresión lineal (precio de pisos) con gradiente, viendo la pérdida bajar época a época.
  • Experimentar con el learning rate y documentar los tres regímenes: convergencia, lentitud y explosión.

Tiempo estimado

90-120 minutos.

Requisitos

  • Labs 1 y 2 completados.
  • Capítulo de gradiente del módulo leído (no hace falta dominarlo: este lab ES la forma de dominarlo).

Nota

Derivada = pendiente = "hacia dónde sube la función". El gradiente descendente camina en la dirección CONTRARIA a la pendiente. Eso es todo. Ahora vamos a verlo con números.


Paso 1 — La función y su derivada

f(x) = x² + 2x + 1. Su derivada (pendiente en cada punto) es f'(x) = 2x + 2. El mínimo está donde la pendiente es 0: x = -1.

Crea src/mi_proyecto/gradiente.py:

# gradiente.py — Descenso de gradiente desde cero absoluto.

import numpy as np


def f(x):
    """La función que queremos minimizar: una parábola con mínimo en x=-1."""
    return x**2 + 2*x + 1       # x² + 2x + 1 = (x+1)² → mínimo es 0 en x=-1


def derivada_f(x):
    """La derivada de f: nos dice la PENDIENTE en el punto x."""
    return 2*x + 2              # regla de derivación: d(x²)=2x, d(2x)=2, d(1)=0


# Comprobación manual: en x=3 la pendiente debe ser 2*3+2 = 8 (subida fuerte)
print(f"f(3) = {f(3)}")             # valor de la función en 3
print(f"f'(3) = {derivada_f(3)}")   # pendiente en 3 (positiva → el mínimo está a la izquierda)

Resultado esperado:

f(3) = 16
f'(3) = 8

Paso 2 — El bucle de descenso

La regla de oro: x_nuevo = x_viejo - learning_rate × pendiente

# --- Añade al final de gradiente.py ---

def descenso_gradiente(x_inicial, learning_rate, pasos):
    """Camina cuesta abajo por f durante `pasos` iteraciones."""
    x = x_inicial                            # punto de partida
    print(f"{'paso':>5} {'x':>10} {'f(x)':>10} {'pendiente':>10}")
    for paso in range(pasos):
        pendiente = derivada_f(x)            # ¿hacia dónde sube la función aquí?
        x = x - learning_rate * pendiente    # damos un paso EN CONTRA de la subida
        # Imprimimos cada 2 pasos para no saturar la consola
        if paso % 2 == 0 or paso == pasos - 1:
            print(f"{paso:>5} {x:>10.4f} {f(x):>10.4f} {pendiente:>10.4f}")
    return x                                 # devolvemos donde acabamos


# Empezamos en x=3 (lejos del mínimo), lr=0.1, 20 pasos
resultado = descenso_gradiente(x_inicial=3.0, learning_rate=0.1, pasos=20)
print(f"\nMínimo encontrado en x = {resultado:.4f} (el real es x = -1)")

Resultado esperado:

 paso          x       f(x)  pendiente
    0     2.2000    10.2400     8.0000
    2     1.0480     4.1943     5.1200
    4     0.3107     1.7180     3.2768
    6    -0.1611     0.7037     2.0972
    8    -0.4631     0.2882     1.3422
   10    -0.6564     0.1181     0.8590
   12    -0.7801     0.0484     0.5498
   14    -0.8593     0.0198     0.3518
   16    -0.9099     0.0081     0.2252
   18    -0.9424     0.0033     0.1441
   19    -0.9539     0.0021     0.1153

Mínimo encontrado en x = -0.9539 (el real es x = -1)

Nota

Observa tres cosas: (1) x se acerca a -1, (2) f(x) baja siempre, (3) la pendiente se hace pequeña cerca del mínimo → los pasos se acortan solos. El gradiente descendente "frena" automáticamente al llegar.

Paso 3 — Generar datos sintéticos de pisos

Ahora el salto a Machine Learning real: una regresión lineal precio = w × metros + b entrenada con gradiente.

# --- Añade al final de gradiente.py ---

# Semilla fija: los números "aleatorios" serán los mismos en cada ejecución
# (imprescindible para poder reproducir y depurar)
rng = np.random.default_rng(42)

# 50 pisos con superficies entre 40 y 150 m²
metros = rng.uniform(40, 150, size=50)

# Verdad oculta que el modelo debe descubrir: precio = 3000·m² + 20000 (+ruido)
# El ruido gaussiano simula la variabilidad del mundo real
precios = 3000 * metros + 20000 + rng.normal(0, 15000, size=50)

# Escalamos a "miles de euros" para que los números sean manejables
precios_k = precios / 1000     # ahora la verdad es: precio_k = 3·m² + 20

print(f"\nEjemplo de datos: {metros[0]:.1f} m² → {precios_k[0]:.1f} mil €")

Resultado esperado (con semilla 42):

Ejemplo de datos: 125.2 m² → 397.1 mil €

Paso 4 — Modelo, pérdida y gradientes

# --- Añade al final de gradiente.py ---

def predecir(m2, w, b):
    """El modelo: una recta. w = precio por m², b = precio base."""
    return w * m2 + b


def perdida_mse(y_real, y_pred):
    """Error Cuadrático Medio: promedio de (error)². Grande = modelo malo."""
    return np.mean((y_real - y_pred) ** 2)


def gradientes(m2, y_real, w, b):
    """Derivadas de la pérdida respecto a w y b (calculadas con cálculo, módulo mates)."""
    y_pred = predecir(m2, w, b)          # predicciones actuales
    error = y_pred - y_real              # cuánto nos pasamos en cada piso
    grad_w = 2 * np.mean(error * m2)     # ∂MSE/∂w: el error "pesado" por los metros
    grad_b = 2 * np.mean(error)          # ∂MSE/∂b: el error medio a secas
    return grad_w, grad_b

Nota

No hay magia: grad_w y grad_b salen de aplicar la regla de la cadena al MSE (lo viste en el capítulo de matemáticas). Si el error medio es positivo (predecimos de más), el gradiente empuja w y b hacia abajo.

Paso 5 — El bucle de entrenamiento

# --- Añade al final de gradiente.py ---

def entrenar(m2, y_real, learning_rate=0.0001, epocas=200):
    """Entrena la regresión lineal con descenso de gradiente."""
    w, b = 0.0, 0.0                        # empezamos con un modelo "tonto": todo cero
    for epoca in range(epocas):
        gw, gb = gradientes(m2, y_real, w, b)   # ¿hacia dónde sube la pérdida?
        w = w - learning_rate * gw               # paso cuesta abajo en w
        b = b - learning_rate * gb               # paso cuesta abajo en b
        # Informe de progreso cada 25 épocas
        if epoca % 25 == 0 or epoca == epocas - 1:
            perdida = perdida_mse(y_real, predecir(m2, w, b))
            print(f"época {epoca:>4} | pérdida = {perdida:>12.2f} | w = {w:.4f} | b = {b:.4f}")
    return w, b


print("\n=== ENTRENAMIENTO (lr = 0.0001) ===")
w_final, b_final = entrenar(metros, precios_k)
print(f"\nModelo aprendido: precio ≈ {w_final:.2f}·m² + {b_final:.2f}")
print("Verdad oculta:    precio =  3.00·m² + 20.00")

Resultado esperado (valores aproximados):

=== ENTRENAMIENTO (lr = 0.0001) ===
época    0 | pérdida =     40693.86 | w = 0.0621 | b = 0.0006
época   25 | pérdida =       960.23 | w = 2.9028 | b = 0.0417
época   50 | pérdida =       230.09 | w = 3.1876 | b = 0.0575
época   75 | pérdida =       222.63 | w = 3.2158 | b = 0.0656
época  100 | pérdida =       222.48 | w = 3.2183 | b = 0.0726
época  125 | pérdida =       222.44 | w = 3.2181 | b = 0.0795
época  150 | pérdida =       222.40 | w = 3.2178 | b = 0.0864
época  175 | pérdida =       222.36 | w = 3.2176 | b = 0.0932
época  199 | pérdida =       222.32 | w = 3.2173 | b = 0.0998

Modelo aprendido: precio ≈ 3.22·m² + 0.10
Verdad oculta:    precio =  3.00·m² + 20.00

Nota

El w se acerca a 3 rápido, pero el b avanza lentísimo hacia 20. ¿Por qué? Porque los metros (valores ~100) generan gradientes grandes para w, mientras b recibe gradientes pequeños. Esta asimetría es EXACTAMENTE la razón por la que en ML real se normalizan las entradas (como en el Lab 2). Pruébalo en "Para ir más lejos".

Paso 6 — Experimento con el learning rate

Este es el corazón del lab. Ejecuta el entrenamiento con tres learning rates y documenta:

# --- Añade al final de gradiente.py ---

print("\n=== EXPERIMENTO A: lr DEMASIADO BAJO (0.000001) ===")
entrenar(metros, precios_k, learning_rate=0.000001, epocas=200)

print("\n=== EXPERIMENTO B: lr DEMASIADO ALTO (0.001) ===")
entrenar(metros, precios_k, learning_rate=0.001, epocas=200)

Resultado esperado (A — lento):

época    0 | pérdida =     81398.75 | w = 0.0006 | b = 0.0000
época   25 | pérdida =     78832.63 | w = 0.0161 | b = 0.0002
...
época  199 | pérdida =     62468.34 | w = 0.1153 | b = 0.0011

La pérdida baja, pero a este ritmo necesitaría decenas de miles de épocas. Diagnóstico: lr demasiado bajo = entrenamiento eterno.

Resultado esperado (B — explosión):

época    0 | pérdida =    655080.20 | w = 0.6215 | b = 0.0056
época   25 | pérdida = 668434029.71 | w = -624.61 | b = -5.72
época   50 | pérdida = 691200474981.53 | w = 20077.94 | b = 183.87
...
época  199 | pérdida =          inf | w = nan | b = nan

Advertencia

¡nan e inf! Cada paso salta AL OTRO LADO del valle, cada vez más lejos, hasta desbordar los números flotantes. Diagnóstico: lr demasiado alto = divergencia. Cuando en el futuro veas loss: nan entrenando una red neuronal, tu primer sospechoso será siempre el learning rate.

Paso 7 — Documentar las conclusiones

Crea una tabla en un archivo NOTAS-LAB3.md (o en tu cuaderno) con tus resultados reales:

Learning rate Pérdida final (época 199) Comportamiento Veredicto
0.000001 ~62.000 Baja lentísimo Demasiado bajo
0.0001 ~222 Converge suave Correcto
0.001 nan Explota y diverge Demasiado alto

Y commitea:

git add src/mi_proyecto/gradiente.py
git commit -m "Implementa descenso de gradiente y regresión lineal desde cero"

Errores comunes

Error Causa Solución
Pérdida = nan desde la época 0 Learning rate altísimo o datos sin escalar Baja el lr ×10; usa precios en miles, no en unidades
La pérdida SUBE en vez de bajar Signo invertido: x + lr*grad en vez de x - lr*grad Revisa el signo del paso de actualización
w correcto pero b no avanza Gradientes desbalanceados (ver nota del Paso 5) Normal en este lab; normaliza los metros para arreglarlo
Resultados distintos en cada ejecución No fijaste la semilla Usa np.random.default_rng(42)
TypeError: unsupported operand con listas Usaste listas de Python en vez de arrays Convierte con np.array(...); las listas no hacen álgebra
Pérdida se estanca alto y no baja de ahí Comparas contra el ruido: el MSE nunca será 0 con datos ruidosos Es correcto: ~222 ES el suelo del ruido (15²≈225)

Cómo depurar

Técnica del lab: monitorizar una métrica por iteración.

El código de entrenamiento no explota con un traceback: falla en silencio (la pérdida no baja). Por eso se depura mirando curvas de números:

  1. Imprime la pérdida en CADA época (no cada 25) cuando algo va mal. La forma de la curva es el diagnóstico: baja-y-se-estanca, sube, oscila, nan.
  2. Imprime también los gradientes: print(f"gw={gw:.4f} gb={gb:.4f}"). Gradientes gigantes (>1000) → lr o escala de datos mal. Gradientes ≈ 0 → ya convergiste (o tu derivada está mal).
  3. Test del gradiente numérico: verifica tu derivada comparándola con la aproximación por diferencias finitas:
    h = 1e-5                                      # un desplazamiento diminuto
    aprox = (f(3 + h) - f(3 - h)) / (2 * h)       # pendiente medida "a mano"
    print(aprox, derivada_f(3))                   # deben coincidir: 8.0 y 8
    
    Este truco (gradient checking) se usa en la industria para validar backpropagation.

Para ir más lejos

  1. Normaliza los metros (min-max del Lab 2) antes de entrenar y observa cómo ahora b sí converge rápido. Documenta la diferencia.
  2. Early stopping: para el entrenamiento automáticamente cuando la pérdida mejore menos de 0.01 entre épocas.
  3. Regresión con 2 variables: añade "número de habitaciones" como segunda característica: precio = w1·m² + w2·hab + b. Solo necesitas un gradiente más.

LAB 4 — Análisis de datos reales con Pandas

Objetivo

Vivir el flujo de trabajo completo de un analista de datos: cargar → limpiar → analizar → informar. Con un dataset de ventas sucio a propósito (nulos, duplicados, tipos incorrectos):

  • Cargar un CSV con Pandas y diagnosticar su estado.
  • Limpiar nulos y duplicados con criterio (no a lo loco).
  • Calcular métricas de negocio: ticket medio, top clientes, tendencia mensual.
  • Exportar un informe a CSV y a Markdown.

Tiempo estimado

90 minutos.

Requisitos

  • Labs 1-3 completados. Pandas instalado.

Paso 1 — Crear el dataset (sucio a propósito)

Crea el archivo datos/ventas.csv. Primero la carpeta:

mkdir datos

Y ahora el CSV. Cópialo EXACTO (los huecos y repeticiones son intencionados):

fecha,cliente,producto,cantidad,precio_unitario
2026-01-05,Café Central,Espresso Blend 1kg,10,18.50
2026-01-07,La Esquina,Descafeinado 500g,5,12.00
2026-01-09,Café Central,Espresso Blend 1kg,10,18.50
2026-01-12,Brunch & Co,Cold Brew Pack,8,22.00
2026-01-15,La Esquina,,3,12.00
2026-01-18,Donut House,Espresso Blend 1kg,4,18.50
2026-01-21,Café Central,Cold Brew Pack,6,22.00
2026-01-25,Brunch & Co,Descafeinado 500g,,12.00
2026-02-02,Café Central,Espresso Blend 1kg,12,18.50
2026-02-04,La Esquina,Espresso Blend 1kg,6,18.50
2026-02-08,Donut House,Cold Brew Pack,5,22.00
2026-02-08,Donut House,Cold Brew Pack,5,22.00
2026-02-11,Brunch & Co,Espresso Blend 1kg,9,18.50
2026-02-14,Café Central,Descafeinado 500g,7,
2026-02-18,La Esquina,Cold Brew Pack,4,22.00
2026-02-21,Nuevo Bar,Espresso Blend 1kg,3,18.50
2026-02-25,Café Central,Espresso Blend 1kg,11,18.50
2026-03-01,Brunch & Co,Cold Brew Pack,10,22.00
2026-03-04,La Esquina,Descafeinado 500g,6,12.00
2026-03-07,Donut House,Espresso Blend 1kg,5,18.50
2026-03-07,Donut House,Espresso Blend 1kg,5,18.50
2026-03-10,Café Central,Cold Brew Pack,8,22.00
2026-03-14,Nuevo Bar,,2,12.00
2026-03-17,Brunch & Co,Espresso Blend 1kg,10,18.50
2026-03-20,La Esquina,Espresso Blend 1kg,7,18.50
2026-03-23,Café Central,Espresso Blend 1kg,13,18.50
2026-03-26,Donut House,Descafeinado 500g,4,12.00
2026-03-28,Brunch & Co,Cold Brew Pack,,22.00
2026-03-30,Café Central,Cold Brew Pack,9,22.00
2026-03-31,Nuevo Bar,Espresso Blend 1kg,4,18.50

Nota

Este CSV esconde: 2 productos vacíos, 2 cantidades vacías, 1 precio vacío y 2 filas duplicadas exactas. Un dataset real de empresa está así (o peor).

Paso 2 — Cargar y diagnosticar

Crea src/mi_proyecto/analisis_ventas.py:

# analisis_ventas.py — Flujo completo: cargar, limpiar, analizar, informar.

import pandas as pd

# read_csv carga el archivo en un DataFrame (tabla con superpoderes).
# parse_dates convierte la columna "fecha" de texto a fechas reales.
df = pd.read_csv("datos/ventas.csv", parse_dates=["fecha"])

# --- Diagnóstico inicial: SIEMPRE se hace antes de tocar nada ---

# .shape → (filas, columnas)
print(f"Dimensiones: {df.shape[0]} filas × {df.shape[1]} columnas")

# .dtypes → tipo de dato de cada columna (¡vigila los 'object' inesperados!)
print("\nTipos de columna:")
print(df.dtypes)

# .isna().sum() → cuenta los nulos por columna
print("\nNulos por columna:")
print(df.isna().sum())

# .duplicated().sum() → cuenta filas duplicadas exactas
print(f"\nFilas duplicadas: {df.duplicated().sum()}")

Ejecuta: python src/mi_proyecto/analisis_ventas.py

Resultado esperado:

Dimensiones: 30 filas × 5 columnas

Tipos de columna:
fecha              datetime64[ns]
cliente                    object
producto                   object
cantidad                  float64
precio_unitario           float64
dtype: object

Nulos por columna:
fecha              0
cliente            0
producto           2
cantidad           2
precio_unitario    1
dtype: int64

Filas duplicadas: 2

Nota

¿Por qué cantidad es float64 si son unidades enteras? Porque hay nulos, y el entero clásico de NumPy no admite NaN. Es una pista de diagnóstico clásica: columna entera que llega como float = probablemente tiene nulos.

Paso 3 — Limpiar con criterio

Cada tipo de suciedad merece una decisión distinta. Regla profesional: documenta cada decisión de limpieza.

# --- Añade al final de analisis_ventas.py ---

print("\n=== LIMPIEZA ===")
filas_antes = len(df)                        # guardamos el total para el informe

# DECISIÓN 1: duplicados exactos → fuera. Son errores de registro casi seguro.
df = df.drop_duplicates()
print(f"Duplicados eliminados: {filas_antes - len(df)}")

# DECISIÓN 2: precio nulo → lo RECUPERAMOS. El mismo producto tiene precio
# en otras filas, así que rellenamos con el precio mediano de ese producto.
# groupby + transform aplica la mediana DEL GRUPO a cada fila del grupo.
df["precio_unitario"] = df.groupby("producto")["precio_unitario"] \
                          .transform(lambda p: p.fillna(p.median()))

# DECISIÓN 3: producto o cantidad nulos → fila fuera. Sin producto no sabemos
# qué se vendió; sin cantidad no podemos calcular importes. No son recuperables.
filas_antes = len(df)
df = df.dropna(subset=["producto", "cantidad"])
print(f"Filas sin producto/cantidad eliminadas: {filas_antes - len(df)}")

# DECISIÓN 4: cantidad ya no tiene nulos → la devolvemos a entero
df["cantidad"] = df["cantidad"].astype(int)

# Creamos la columna clave del negocio: el importe de cada venta
df["importe"] = df["cantidad"] * df["precio_unitario"]

print(f"\nDataset limpio: {len(df)} filas")
print(f"Nulos restantes: {df.isna().sum().sum()}")   # doble sum: total global

Resultado esperado:

=== LIMPIEZA ===
Duplicados eliminados: 2
Filas sin producto/cantidad eliminadas: 4
Dataset limpio: 24 filas
Nulos restantes: 0

Advertencia

Fíjate que el precio nulo de "Descafeinado 500g" NO costó una fila: lo recuperamos con la mediana del producto (12.00). Borrar filas es la ÚLTIMA opción, no la primera. Cada fila borrada es información perdida.

Paso 4 — Métricas de negocio

# --- Añade al final de analisis_ventas.py ---

print("\n=== MÉTRICAS DE NEGOCIO ===")

# Facturación total: suma de todos los importes
total = df["importe"].sum()
print(f"Facturación total: {total:,.2f} €")

# Ticket medio: importe promedio por venta
ticket_medio = df["importe"].mean()
print(f"Ticket medio: {ticket_medio:.2f} €")

# TOP CLIENTES: agrupamos por cliente, sumamos importes, ordenamos descendente
top_clientes = (
    df.groupby("cliente")["importe"]   # agrupa filas por cliente, mira la columna importe
      .sum()                           # suma los importes de cada grupo
      .sort_values(ascending=False)    # ordena de mayor a menor
)
print("\nTop clientes por facturación:")
print(top_clientes.to_string())        # to_string imprime la Serie completa

# TENDENCIA MENSUAL: extraemos el mes de la fecha y agrupamos
# .dt.to_period("M") convierte 2026-01-05 → 2026-01
df["mes"] = df["fecha"].dt.to_period("M")
tendencia = df.groupby("mes")["importe"].sum()
print("\nFacturación por mes:")
print(tendencia.to_string())

# Producto estrella: unidades totales por producto
top_producto = df.groupby("producto")["cantidad"].sum().sort_values(ascending=False)
print("\nUnidades por producto:")
print(top_producto.to_string())

Resultado esperado:

=== MÉTRICAS DE NEGOCIO ===
Facturación total: 3,376.00 €
Ticket medio: 140.67 €

Top clientes por facturación:
cliente
Café Central    1,463.50
Brunch & Co       747.50
La Esquina        625.50
Donut House       410.00
Nuevo Bar         129.50

Facturación por mes:
mes
2026-01     684.00
2026-02    1088.00
2026-03    1604.00

Unidades por producto:
producto
Espresso Blend 1kg    109
Cold Brew Pack         42
Descafeinado 500g      22

Nota

Los valores exactos pueden variar en ±1 fila según tus decisiones de limpieza; lo importante es que el ORDEN de los rankings y la tendencia creciente ene-feb-mar coincidan.

Paso 5 — Exportar el informe a CSV y Markdown

# --- Añade al final de analisis_ventas.py ---

# Exportamos el ranking de clientes a CSV (para Excel / otras herramientas)
top_clientes.to_csv("datos/informe_clientes.csv")

# Construimos un informe Markdown a mano con f-strings
informe = f"""# Informe de ventas — Q1 2026

## Resumen ejecutivo

| Métrica | Valor |
|---------|-------|
| Facturación total | {total:,.2f} € |
| Ticket medio | {ticket_medio:.2f} € |
| Ventas analizadas | {len(df)} |
| Mejor cliente | {top_clientes.index[0]} |
| Mes más fuerte | {tendencia.idxmax()} |

## Facturación mensual

{tendencia.to_markdown()}

## Top clientes

{top_clientes.to_markdown()}
"""

# Escribimos el informe a disco; encoding utf-8 para las tildes y el símbolo €
with open("datos/informe_ventas.md", "w", encoding="utf-8") as f:
    f.write(informe)

print("\nInforme exportado a datos/informe_ventas.md y datos/informe_clientes.csv")

Nota

.to_markdown() necesita el paquete tabulate: instálalo con pip install tabulate y añádelo a tus dependencias.

Resultado esperado:

Informe exportado a datos/informe_ventas.md y datos/informe_clientes.csv

Abre datos/informe_ventas.md en VS Code (vista previa con Ctrl+Shift+V) y admira tu primer informe generado por código.

Paso 6 — Commit

git add src/mi_proyecto/analisis_ventas.py datos/ventas.csv
git commit -m "Añade análisis de ventas con limpieza y export de informe"

Errores comunes

Error Causa Solución
FileNotFoundError: datos/ventas.csv Ejecutas desde otra carpeta Lanza el script desde la RAÍZ del proyecto, o usa rutas absolutas
KeyError: 'importe' Intentas usar la columna antes de crearla Respeta el orden de los pasos: limpieza → columna importe → métricas
Fechas como texto (object en dtypes) Olvidaste parse_dates=["fecha"] Añádelo a read_csv, o convierte después con pd.to_datetime
IntCastingNaNError al hacer astype(int) Todavía hay NaN en cantidad Haz el dropna ANTES del astype
ImportError: tabulate con .to_markdown() Falta el paquete tabulate pip install tabulate y actualiza requirements.txt
El € o las tildes salen mal en el archivo Encoding por defecto de Windows (cp1252) Añade encoding="utf-8" al open()
SettingWithCopyWarning Modificas una "vista" de un DataFrame filtrado Usa .copy() tras filtrar, o asigna con .loc[...]

Cómo depurar

Técnica del lab: inspección incremental del DataFrame.

Con Pandas casi ningún error da traceback: los errores son datos incorrectos silenciosos. La defensa es inspeccionar tras CADA transformación:

  1. La tríada de oro tras cada paso: print(df.shape), print(df.head()), print(df.dtypes). Si las filas cayeron de 30 a 3, algo hiciste mal (y sin inspección no lo verías hasta el final).
  2. Aísla el grupo problemático: si una métrica de un cliente sale rara, filtra y mira sus filas crudas:
    print(df[df["cliente"] == "Nuevo Bar"])   # solo las filas de ese cliente
    
  3. Cuadre de totales: verifica que la suma de las partes iguala el total: assert abs(top_clientes.sum() - df["importe"].sum()) < 0.01. En análisis de datos de negocio, el cuadre de totales es LA prueba reina.

Para ir más lejos

  1. Detección de atípicos: marca como sospechosa cualquier venta cuyo importe supere media + 3×desviación estándar. ¿Encuentra alguna?
  2. Pivot table: crea con df.pivot_table(index="cliente", columns="mes", values="importe", aggfunc="sum") la matriz cliente×mes. Es el formato favorito de los directivos.
  3. Parametriza el script: con sys.argv acepta la ruta del CSV como argumento. Este es exactamente el punto de partida del Mini Proyecto del módulo (ver PROYECTO.md).

LAB 5 — Mini buscador semántico de juguete

Objetivo

El gran final del módulo: construir un buscador de texto completo SIN ninguna API, sin modelos preentrenados, sin internet. Solo NumPy y las matemáticas del módulo:

  • Convertir frases en vectores con bag-of-words (vocabulario + conteo).
  • Mejorar la representación con TF-IDF (las palabras raras importan más).
  • Buscar la frase más parecida a una consulta con similitud coseno (¡Lab 2!).
  • Entender con precisión qué le falta a este enfoque frente a los embeddings reales → puente directo al módulo 06-RAG.

Tiempo estimado

120 minutos.

Requisitos

  • Labs 1-4 completados (usaremos coseno del Lab 2 y NumPy con soltura).

Nota

Este lab es el más importante del módulo. Cuando en el módulo 06 uses una base de datos vectorial, cada pieza (vectorizar → indexar → consultar → rankear por coseno) te sonará porque la habrás construido TÚ con 100 líneas de Python.


Paso 1 — El corpus: 10 frases

Crea src/mi_proyecto/buscador.py:

# buscador.py — Buscador semántico de juguete: bag-of-words + TF-IDF + coseno.

import numpy as np

# Nuestro "corpus": la colección de documentos donde buscaremos.
# 10 frases sobre dos temas (cocina y programación) para ver si el buscador
# distingue los temas sin que nadie se los explique.
corpus = [
    "el gato duerme sobre el teclado del ordenador",
    "la receta de pasta lleva tomate y albahaca fresca",
    "python es un lenguaje de programación muy popular",
    "el horno debe estar caliente antes de meter la pizza",
    "los errores de programación se llaman bugs",
    "añade sal y pimienta al gusto en la sartén",
    "el ordenador ejecuta el programa línea por línea",
    "la pizza con albahaca y tomate es la margarita",
    "aprender python requiere práctica y paciencia",
    "el chef prepara la salsa de tomate a fuego lento",
]

Paso 2 — Tokenizar y construir el vocabulario

# --- Añade al final de buscador.py ---

def tokenizar(texto):
    """Divide un texto en palabras (tokens) en minúsculas."""
    # .lower() unifica mayúsculas/minúsculas: "Python" y "python" son lo mismo
    # .split() corta por espacios: "hola mundo" → ["hola", "mundo"]
    return texto.lower().split()


# Construimos el VOCABULARIO: el conjunto de todas las palabras distintas.
# Un set elimina repeticiones automáticamente.
vocabulario_set = set()
for frase in corpus:
    # .update añade todos los tokens de la frase al conjunto
    vocabulario_set.update(tokenizar(frase))

# Lo ordenamos alfabéticamente para que cada ejecución dé el mismo orden
vocabulario = sorted(vocabulario_set)

# Diccionario palabra → posición: nos dice qué DIMENSIÓN ocupa cada palabra
palabra_a_indice = {palabra: i for i, palabra in enumerate(vocabulario)}

print(f"Tamaño del vocabulario: {len(vocabulario)} palabras")
print(f"Primeras 10: {vocabulario[:10]}")

Resultado esperado:

Tamaño del vocabulario: 49 palabras
Primeras 10: ['a', 'al', 'albahaca', 'antes', 'añade', 'aprender', 'caliente', 'chef', 'de', 'del']

Nota

Acabas de decidir la dimensionalidad de tus vectores: cada frase será un vector de 49 números, uno por palabra del vocabulario. Los embeddings modernos usan 768 o 1536 dimensiones que NO corresponden a palabras — esa es la gran diferencia que veremos al final.

Paso 3 — Bag-of-words: frases como vectores de conteo

# --- Añade al final de buscador.py ---

def vectorizar_bow(texto):
    """Convierte un texto en su vector bag-of-words (conteo de palabras)."""
    # Empezamos con un vector de ceros: una posición por palabra del vocabulario
    vector = np.zeros(len(vocabulario))
    # Por cada token del texto...
    for token in tokenizar(texto):
        # ...si la palabra está en el vocabulario (las desconocidas se ignoran)...
        if token in palabra_a_indice:
            # ...sumamos 1 en SU posición. "Bolsa de palabras": solo cuenta,
            # ignora el orden ("perro muerde hombre" == "hombre muerde perro")
            vector[palabra_a_indice[token]] += 1
    return vector


# Matriz del corpus: apilamos el vector de cada frase (fila i = frase i)
M = np.array([vectorizar_bow(frase) for frase in corpus])

print(f"Matriz de conteos: {M.shape}")            # (10 frases, 49 dimensiones)
# ¿Cuántas veces aparece "tomate" (y en qué frases)? Miramos su columna:
col_tomate = palabra_a_indice["tomate"]
print(f"Columna 'tomate': {M[:, col_tomate]}")

Resultado esperado:

Matriz de conteos: (10, 49)
Columna 'tomate': [0. 1. 0. 0. 0. 0. 0. 1. 0. 1.]

Nota

La columna dice: "tomate" aparece en las frases 1, 7 y 9 — las tres de cocina con salsa. Los vectores ya capturan algo de tema.

Paso 4 — El problema de las palabras vacías → TF-IDF

Problema: "el", "de", "la" aparecen en casi todas las frases y dominan los conteos sin aportar significado. Solución clásica: TF-IDF — multiplicar cada conteo por un peso que castiga a las palabras que están en todas partes.

  • TF (term frequency): el conteo que ya tenemos.
  • IDF (inverse document frequency): log(N / df) donde N = nº de documentos y df = en cuántos documentos aparece la palabra. Palabra en TODOS los docs → IDF = log(1) = 0 → se anula.
# --- Añade al final de buscador.py ---

# N = número de documentos del corpus
N = len(corpus)

# df de cada palabra: en CUÁNTOS documentos aparece (no cuántas veces).
# (M > 0) convierte conteos en booleanos; sum(axis=0) suma por columnas.
df = (M > 0).sum(axis=0)

# IDF con la fórmula suavizada estándar: log(N / df) + 1
# El +1 evita que las palabras presentes en todos los docs se anulen del todo,
# y np.log es el logaritmo natural.
idf = np.log(N / df) + 1

# Matriz TF-IDF: cada conteo multiplicado por el peso de su palabra.
# Broadcasting: idf (49,) se aplica a cada fila de M (10, 49) automáticamente.
M_tfidf = M * idf

# Comparemos pesos: una palabra común vs. una específica
print(f"IDF de 'el' (común):      {idf[palabra_a_indice['el']]:.3f}")
print(f"IDF de 'python' (rara):   {idf[palabra_a_indice['python']]:.3f}")
print(f"IDF de 'albahaca' (rara): {idf[palabra_a_indice['albahaca']]:.3f}")

Resultado esperado:

IDF de 'el' (común):      1.511
IDF de 'python' (rara):   2.609
IDF de 'albahaca' (rara): 2.609

Nota

"python" pesa casi el doble que "el". Cuando la consulta contenga "python", esa dimensión mandará. Eso es exactamente lo que queremos: que decidan las palabras informativas.

Paso 5 — La función de búsqueda con coseno

# --- Añade al final de buscador.py ---

def similitud_coseno(a, b):
    """Similitud coseno (idéntica a la del Lab 2)."""
    norma_a = np.linalg.norm(a)              # longitud del vector a
    norma_b = np.linalg.norm(b)              # longitud del vector b
    if norma_a == 0 or norma_b == 0:         # protección: vector de todo ceros
        return 0.0                           # (consulta sin palabras conocidas)
    return np.dot(a, b) / (norma_a * norma_b)


def buscar(consulta, top_k=3):
    """Devuelve las top_k frases del corpus más parecidas a la consulta."""
    # 1. Vectorizamos la consulta con el MISMO proceso que el corpus:
    #    mismo vocabulario, mismos pesos IDF. Esto es crítico.
    q = vectorizar_bow(consulta) * idf
    # 2. Similitud de la consulta contra CADA frase del corpus
    sims = np.array([similitud_coseno(q, M_tfidf[i]) for i in range(N)])
    # 3. argsort ordena ascendente; [::-1] lo invierte; [:top_k] toma los mejores
    orden = np.argsort(sims)[::-1][:top_k]
    # 4. Devolvemos pares (frase, puntuación)
    return [(corpus[i], sims[i]) for i in orden]


# --- ¡A buscar! ---
for consulta in ["cómo cocinar pasta con tomate",
                 "quiero aprender a programar en python",
                 "animales domésticos"]:
    print(f"\n Consulta: '{consulta}'")
    for frase, punt in buscar(consulta):
        print(f"   {punt:.3f} | {frase}")

Resultado esperado:

 Consulta: 'cómo cocinar pasta con tomate'
   0.353 | la receta de pasta lleva tomate y albahaca fresca
   0.181 | el chef prepara la salsa de tomate a fuego lento
   0.139 | la pizza con albahaca y tomate es la margarita

 Consulta: 'quiero aprender a programar en python'
   0.394 | aprender python requiere práctica y paciencia
   0.185 | python es un lenguaje de programación muy popular
   0.031 | el chef prepara la salsa de tomate a fuego lento

 Consulta: 'animales domésticos'
   0.000 | el gato duerme sobre el teclado del ordenador
   0.000 | la receta de pasta lleva tomate y albahaca fresca
   0.000 | python es un lenguaje de programación muy popular

Paso 6 — Autopsia del resultado: ¿dónde falla?

Mira la tercera consulta: "animales domésticos" devuelve 0.000 para TODO, ¡incluida la frase del gato! El buscador no sabe que un gato ES un animal doméstico, porque ninguna de esas dos palabras aparece literalmente en el corpus.

Ejecuta también esta consulta y anota el resultado:

# --- Añade al final de buscador.py ---

print(f"\n Consulta: 'felino descansando'")
for frase, punt in buscar("felino descansando"):
    print(f"   {punt:.3f} | {frase}")

Resultado esperado:

 Consulta: 'felino descansando'
   0.000 | el gato duerme sobre el teclado del ordenador
   0.000 | la receta de pasta lleva tomate y albahaca fresca
   0.000 | python es un lenguaje de programación muy popular

Cero otra vez. "Felino" y "gato" son sinónimos para ti, pero para bag-of-words son dimensiones distintas y perpendiculares.

Paso 7 — Reflexión final: de TF-IDF a embeddings (puente al módulo 06)

Completa esta tabla en tu cuaderno con lo que acabas de comprobar EN TU PROPIA CONSOLA:

Aspecto Tu buscador TF-IDF Embeddings reales (módulo 06)
¿Entiende sinónimos? "felino" ≠ "gato" (lo probaste) Vectores cercanos porque significan lo mismo
¿Entiende el orden? Bolsa de palabras: el orden no existe El contexto completo moldea el vector
Dimensiones 49 (una por palabra, interpretables) 768-1536 (abstractas, aprendidas)
¿Cómo se obtienen? Contando (tú escribiste el código) Red neuronal entrenada con billones de palabras
Consulta fuera de vocabulario Devuelve 0.0 (lo probaste) Funciona: el modelo conoce (casi) todas las palabras
Lo que NO cambia La similitud coseno y el ranking: idénticos La similitud coseno y el ranking: idénticos

Consejo profesional

Léelo otra vez: la última fila es la clave del máster. Un sistema RAG de producción hace EXACTAMENTE lo que hiciste hoy — vectorizar documentos, vectorizar la consulta, rankear por coseno — cambiando solo la función de vectorización (embeddings neuronales en vez de conteos) y la escala (millones de documentos con índices aproximados en vez de 10 frases con un bucle). La arquitectura ya la sabes construir.

git add src/mi_proyecto/buscador.py
git commit -m "Añade buscador semántico de juguete con TF-IDF y coseno"

Errores comunes

Error Causa Solución
KeyError: 'palabra' al vectorizar Buscas la palabra directamente sin comprobar si existe Usa el if token in palabra_a_indice del código
Todas las similitudes dan 0 Consulta sin ninguna palabra del vocabulario Correcto y esperado (Paso 6); es la limitación del método
RuntimeWarning: divide by zero en el coseno Vector de consulta de todo ceros La protección if norma == 0: return 0.0 del Paso 5
Resultados distintos entre ejecuciones Vocabulario sin ordenar (los sets no tienen orden fijo) El sorted() del Paso 2 es obligatorio
"Pizza" no encuentra "pizza." Puntuación pegada: tokenizador demasiado simple Extiende tokenizar quitando signos (ver extensión 1)
IDF negativo Fórmula sin suavizar con palabra en todos los docs y variante de fórmula Usa log(N/df) + 1 como en el Paso 4
La consulta encuentra frases irrelevantes con puntuación alta Palabras vacías dominando (sin TF-IDF o IDF mal aplicado) Verifica que multiplicas por idf TANTO el corpus COMO la consulta

Cómo depurar

Técnica del lab: inspeccionar la representación intermedia.

Cuando un buscador devuelve resultados raros, el bug está casi siempre en los VECTORES, no en el coseno. Ábrelos:

  1. Imprime el vector de la consulta con nombres: ¿qué palabras activó realmente?
    q = vectorizar_bow("cocinar pasta con tomate") * idf
    # np.nonzero devuelve los índices con valor distinto de cero
    for i in np.nonzero(q)[0]:
        print(f"  {vocabulario[i]}: {q[i]:.2f}")   # palabra activa y su peso
    
    Si esperabas ver "cocinar" y no aparece → no está en el vocabulario → misterio resuelto.
  2. Compara dos frases dimensión a dimensión: las palabras compartidas son las únicas que aportan al producto escalar. Imprímelas: compartidas = np.nonzero((a > 0) & (b > 0))[0].
  3. Reduce el corpus a 2 frases cuyo resultado puedas calcular a mano con calculadora. Si el mini-caso cuadra, tu código es correcto y el "problema" son los datos o tus expectativas.

Para ir más lejos

  1. Tokenizador decente: quita puntuación y tildes con re.sub(r"[^\wáéíóúñü]", " ", texto), y filtra una lista de stopwords en español ("el", "la", "de"...). Mide si mejora el ranking.
  2. Escala el corpus: carga 100+ frases desde un archivo de texto (una por línea) y cronometra la búsqueda con time.perf_counter(). ¿Sigue siendo instantáneo? ¿Y con 100.000? Piensa por qué las bases de datos vectoriales existen.
  3. Vectorización total: sustituye el bucle de similitudes por UNA sola operación matricial: normaliza las filas de M_tfidf y calcula M_norm @ q_norm. Mismo resultado, una línea, mucho más rápido — así se hace en producción.

Checklist de cierre del módulo (laboratorios)

  • [ ] Lab 1: tengo un proyecto con venv, estructura src/, git con ≥2 commits, y sé leer un traceback de abajo arriba.
  • [ ] Lab 2: mi matriz de similitud tiene diagonal 1.0 y es simétrica; recomendar() funciona.
  • [ ] Lab 3: vi converger el gradiente, y provoqué (y entendí) tanto la lentitud como la explosión con nan.
  • [ ] Lab 4: limpié nulos y duplicados con criterio documentado y exporté un informe en Markdown.
  • [ ] Lab 5: mi buscador rankea bien las consultas con vocabulario conocido, y sé explicar POR QUÉ falla con sinónimos.

Consejo profesional

Antes de pasar al proyecto, sube los 5 labs a un repositorio de GitHub. Dentro de 6 meses, cuando construyas sistemas RAG en producción, volver a leer TU PROPIO buscador de juguete te dará una perspectiva que ningún tutorial puede dar.


Anterior Módulo Siguiente
Ejercicios 01-FUNDAMENTOS Proyectos