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 modernauv). - Declarar dependencias en
requirements.txty entender qué es unpyproject.toml. - Organizar el código con una estructura de carpetas profesional (
src/). - Versionar tu trabajo con
git inity 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):
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:
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:
Resultado esperado:
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:
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):
- Lee de ABAJO hacia ARRIBA. La última línea es el diagnóstico:
ZeroDivisionError: division by zero→ tipo de error + explicación. - La línea justo encima (
return a / b, línea 5) es dónde explotó. - Las líneas superiores son la cadena de llamadas (call stack): el script llamó a
calcular_media, que llamó adividir. 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.
- Traceback primero: última línea = qué pasó; línea más profunda de TU código = dónde mirar.
- Print de inspección: antes de la línea sospechosa, imprime las variables implicadas con contexto:
- 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. - 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¶
- Prueba
uvde verdad: instala uv, borra.venv, y recrea todo el entorno conuv venv+uv pip install -r requirements.txt. Cronometra ambas formas. - Sube el proyecto a GitHub: crea un repositorio remoto y haz
git push. Es tu primer portfolio. - Docker: escribe un
Dockerfilemí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:
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:
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¶
- Añade 5 películas más y una quinta característica (p. ej. "comedia 0-10"). ¿Cambian las recomendaciones? ¿Alguna se vuelve contraintuitiva?
- 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). recomendar_top_k(pelicula, k): devuelve las k más parecidas ordenadas, usandonp.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:
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):
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:
- 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.
- 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). - Test del gradiente numérico: verifica tu derivada comparándola con la aproximación por diferencias finitas: Este truco (gradient checking) se usa en la industria para validar backpropagation.
Para ir más lejos¶
- Normaliza los metros (min-max del Lab 2) antes de entrenar y observa cómo ahora
bsí converge rápido. Documenta la diferencia. - Early stopping: para el entrenamiento automáticamente cuando la pérdida mejore menos de 0.01 entre épocas.
- 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:
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:
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:
- 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). - Aísla el grupo problemático: si una métrica de un cliente sale rara, filtra y mira sus filas crudas:
- 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¶
- Detección de atípicos: marca como sospechosa cualquier venta cuyo importe supere
media + 3×desviación estándar. ¿Encuentra alguna? - 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. - Parametriza el script: con
sys.argvacepta 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:
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:
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:
- Imprime el vector de la consulta con nombres: ¿qué palabras activó realmente? Si esperabas ver "cocinar" y no aparece → no está en el vocabulario → misterio resuelto.
- 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]. - 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¶
- 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. - 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. - Vectorización total: sustituye el bucle de similitudes por UNA sola operación matricial: normaliza las filas de
M_tfidfy calculaM_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.
Navegación¶
| Anterior | Módulo | Siguiente |
|---|---|---|
| Ejercicios | 01-FUNDAMENTOS | Proyectos |