
Entrada XXXI: METIS, un laboratorio de datos para clase (y por qué hay que curar los datos antes de analizarlos)
Este es un tutorial completo. Al final tendrás funcionando en tu ordenador una aplicación web que lee un archivo de datos, te dice exactamente qué hay dentro y te lo explica en castellano usando un modelo de inteligencia artificial que corre en tu propia máquina, sin enviar nada a internet y sin pagar nada.
Pero antes del código hay que hablar de por qué esta herramienta hace lo que hace, porque si no, la parte importante pasa desapercibida.
Qué es la ciencia de datos
Ciencia de datos es sacar conclusiones defendibles a partir de datos. Nada más, y nada menos.
La palabra “ciencia” no está de adorno: lo que la distingue de simplemente mirar una hoja de cálculo es el método. Se parte de una pregunta, se buscan datos que puedan responderla, se comprueba si esos datos sirven realmente para eso, se analizan, y se dice qué se puede concluir y qué no. Ese último paso es el que más se olvida y el que más importa.
El proceso completo suele describirse en cinco fases:
- Obtener los datos: una encuesta, un sensor, un registro del centro, un archivo público.
- Curarlos: comprobar que son lo que dicen ser, y arreglar lo que no.
- Explorarlos: mirar qué hay, sin conclusiones todavía.
- Analizarlos: calcular, comparar, buscar relaciones.
- Comunicar: contar qué se ha encontrado, con sus límites.
Y aquí viene el dato que sorprende a todo el mundo la primera vez: en un trabajo real de datos, la fase 2 se lleva la mayor parte del tiempo. No la estadística elegante, no las gráficas bonitas, no el aprendizaje automático. Limpiar. Es tan conocido en la profesión que tiene nombre propio, con una expresión que se repite en todos los cursos: la mayoría del trabajo es preparar los datos, no analizarlos.
Cuando alguien empieza, espera lo contrario. Cree que la ciencia de datos consiste en el gráfico final. Y el gráfico final es, con diferencia, la parte más rápida.
Por qué hay que curar los datos
“Curar” datos —el término viene del inglés data curation, y también se dice limpiar o depurar— es comprobar que los datos son lo que parecen y corregir lo que no lo es.
Suena a burocracia. No lo es. Es la diferencia entre una conclusión válida y una conclusión falsa que además parece válida, que es mucho peor.
Un ejemplo con el que cualquiera puede tropezar mañana. Imagina una tabla con las horas que estudia cada alumno y su nota, y quieres saber si estudiar más se relaciona con mejores notas. Pregunta razonable. Abres el archivo, calculas la media de horas de estudio y te sale 0. O te sale un error. O peor: te sale un número que parece correcto pero está mal.
¿Qué ha pasado? Que el archivo venía de un Excel en español, y en español el separador decimal es la coma. Así que donde tú crees que hay un número 4,5, el ordenador ha leído el texto "4,5". Y con texto no se puede calcular una media. Si el programa no te avisa, te devuelve algo raro, o directamente ignora esa columna, y tú sigues adelante sin enterarte.
Otros tres casos igual de comunes:
- Filas duplicadas. Si un registro está metido dos veces, cuenta doble en todas las medias. Un alumno que aparece repetido tira de la media hacia su nota. Nadie lo ve mirando el archivo, porque son 200 filas.
- Valores que faltan. Si el 40 % de una columna está vacía, la media de esa columna es la media de un poco más de la mitad de los casos. Puede seguir siendo útil, pero decir “la nota media es 6,2” sin decir que faltaba el 40 % de las notas es una conclusión mal comunicada.
- Fechas que son texto. Ordenar por fecha una columna de texto ordena alfabéticamente: el 1 de diciembre va antes que el 2 de enero. Las series temporales salen desordenadas y las gráficas no significan nada.
La lección de fondo, y la que de verdad quiero que se lleve el alumnado:
Una gráfica preciosa hecha con datos sucios sigue siendo una gráfica preciosa. Por eso es peligrosa: no parece un error.
Un ordenador no protesta cuando le pides algo absurdo con datos malos. Te da un resultado. Y un resultado con dos decimales y un gráfico de colores tiene una autoridad que no se ha ganado.
De ahí nace METIS.
Qué es METIS
METIS es una aplicación web pequeña. Subes un archivo CSV y te dice qué hay dentro: cuántas filas, cuántas columnas, de qué tipo es cada una, cuántos valores faltan y en qué porcentaje, cuántas filas están duplicadas, y el mínimo, máximo, media y mediana de las columnas numéricas. Y detecta, sin que se lo pidas, los problemas típicos que acabo de contar.
Encima de todo eso, un modelo de lenguaje que corre en tu propio ordenador te lo explica en castellano llano: de qué parecen ir estos datos, y qué conviene revisar primero.
El nombre viene de Metis, la titánide griega de la sabiduría y el consejo, madre de Atenea. Y hay un parentesco etimológico bonito: su nombre está emparentado con la raíz de medir, la misma de la que sale “métrica”.
La regla de arquitectura, que es lo importante
El perfil lo calcula Python. El modelo solo lo explica.
Esto no es un detalle de implementación. Es la decisión central del proyecto, y va en contra de cómo se suelen montar estas herramientas hoy.
Lo habitual sería pedirle al modelo de IA que escriba código de análisis y ejecutarlo. Es lo que hacen muchas herramientas comerciales, y es lo que me proponía la idea original de la que salió METIS. Aquí no se hace, por dos razones.
La primera es de fiabilidad. El modelo que uso es Llama 3.2 de 3.000 millones de parámetros, corriendo en un ordenador de instituto sin tarjeta gráfica. Es un modelo pequeño, y los modelos pequeños se equivocan con los números. En este mismo proyecto ya tengo documentado un caso real: el modelo citó correctamente un artículo de Wikipedia y a la vez añadió, junto a la cita, un dato histórico falso que no estaba en el texto. Si le dejo calcular la media de una columna, puede darme un número plausible y equivocado. Pandas, en cambio, no se equivoca calculando una media. Así que la media la calcula pandas.
La segunda es de seguridad, y es más grave. Ejecutar código generado por un modelo de lenguaje es ejecutar código arbitrario. La propuesta original decía que el servidor “validaría y ejecutaría de forma segura” el código, comprobando la sintaxis. Y comprobar la sintaxis no es seguridad: que un código se pueda interpretar no dice absolutamente nada sobre si borra archivos, abre conexiones de red o se pasea por el disco duro. En mi caso, el ordenador donde corre esto tiene documentos reales del centro, así que la respuesta es sencilla: METIS no ejecuta código generado por el modelo. Ni con eval, ni con exec, ni en un subproceso. El modelo no escribe código en ningún punto de la aplicación.
El resultado de esa decisión es una herramienta más simple, más rápida, más segura y —esto es lo que no esperaba— pedagógicamente mejor, porque separa con nitidez dos cosas que el alumnado tiende a confundir: lo que un ordenador sabe con certeza y lo que una IA cree.
En la propia interfaz están separadas. Arriba, la explicación del modelo. Debajo, con el título “Los números exactos”, la tabla que ha calculado Python. Y en el pie de la página, una frase permanente:
El perfil lo calcula Python, no la IA: esos números son exactos. La explicación la escribe un modelo de lenguaje pequeño, y puede equivocarse al interpretarlos.
El tutorial
Lo que necesitas
- Un ordenador con Python 3.10 o superior. Sirve Windows, Linux o macOS. Yo lo he montado en Windows.
- LM Studio, gratuito, descargable de lmstudio.ai. Es la forma más sencilla de correr un modelo de lenguaje en tu propia máquina.
- Unos 3 GB libres para el modelo.
- No hace falta tarjeta gráfica. Va más lento, pero funciona.
- No hace falta cuenta en ningún sitio, ni clave de API, ni pagar nada.
Paso 1: el modelo local
Instala LM Studio y, desde su propio buscador de modelos, descarga Llama 3.2 3B Instruct en formato cuantizado (yo uso Q4_K_M, que es un buen equilibrio entre tamaño y calidad).
Después, en LM Studio:
- Carga el modelo.
- Ve a la pestaña de servidor local (Developer o Local Server, según la versión).
- Arráncalo.
Debe quedarse escuchando en http://localhost:1234. Ese es el único punto de contacto entre METIS y la IA, y por eso el modelo es intercambiable: si mañana quieres probar otro, lo cargas en LM Studio y METIS ni se enterará, siempre que ajustes el nombre del modelo en la configuración.
Compruébalo con el navegador, entrando en http://localhost:1234/v1/models. Debe responder con un JSON que incluya el modelo cargado.
Paso 2: la carpeta y las dependencias
Crea una carpeta para el proyecto, con una subcarpeta static dentro:
metis/
├── main.py
├── perfil.py
├── requirements.txt
└── static/
└── index.html
Crea el archivo requirements.txt con este contenido:
fastapi
uvicorn[standard]
python-multipart
requests
pandas
E instálalas desde una terminal, dentro de la carpeta metis:
python -m pip install -r requirements.txt
Un detalle que me hizo perder un rato: usa python -m pip y no pip a secas. Si tienes varias versiones de Python instaladas, pip suelto puede instalar los paquetes en un intérprete distinto del que luego ejecuta el programa, y entonces obtienes el desconcertante ModuleNotFoundError: No module named 'pandas' justo después de haber instalado pandas. Con python -m pip el paquete va, por construcción, al mismo Python que va a correr el código.
Paso 3: perfil.py, la pieza fiable
Este es el corazón del proyecto y el archivo que merece leerse con calma. No importa requests, no hace ninguna llamada de red y no sabe que existe ningún modelo de IA. Todo lo que devuelve son hechos calculados con pandas sobre el archivo real.
"""METIS — perfilado determinista de un CSV.
Este modulo es la pieza fiable del proyecto y NO habla con ningun modelo de
IA. Todo lo que devuelve son hechos calculados con pandas sobre el archivo
real: filas, columnas, tipos, nulos, duplicados y estadisticas basicas.
El modelo de lenguaje solo entra despues, en main.py, y unicamente para
EXPLICAR en castellano este perfil ya calculado. Nunca para calcularlo, y
nunca ejecutando codigo. Misma regla que en el resto de la familia PINSAPIA:
si Python puede saberlo con certeza, no se le pregunta al modelo.
Incluye tambien la deteccion de tres problemas muy habituales en los CSV que
llegan a un aula espanola, y que son deterministas (no hace falta un modelo
para verlos): columnas que deberian ser numeros pero han entrado como texto,
columnas que deberian ser fechas pero han entrado como texto, y numeros
escritos con la coma como separador decimal.
"""
import csv
import io
import re
import warnings
import pandas as pd
# --- Limites concretos, fijados a proposito y no "ya se vera" ---
#
# Un CSV de clase no pasa de unos miles de filas. Estos topes existen para
# que un archivo enorme (por error o por gracia) no se lleve por delante la
# memoria de ROCKY, que no es una maquina potente.
MAX_BYTES = 5 * 1024 * 1024 # 5 MB de archivo
MAX_FILAS = 50_000 # filas que se leen como maximo
MAX_COLUMNAS_AL_MODELO = 40 # columnas que se describen al modelo
MUESTRA_HEURISTICAS = 200 # valores por columna para las detecciones
UMBRAL_CONVERSION = 0.80 # 80% de exito para dar por buena una deteccion
# Formas de fecha mas comunes en un CSV espanol, mas la ISO.
PATRON_FECHA = re.compile(
r"^\s*("
r"\d{1,2}[/-]\d{1,2}[/-]\d{2,4}" # 3/9/2026, 03-09-26
r"|\d{4}[/-]\d{1,2}[/-]\d{1,2}" # 2026-09-03
r")\s*$"
)
class ErrorPerfil(Exception):
"""Algo impide perfilar el archivo, con un motivo explicable al usuario."""
def _detectar_separador(cabecera_texto):
"""Adivina el separador de columnas mirando la primera linea.
Importa mas de lo que parece: Excel en espanol exporta CSV con punto y
coma, no con coma, y es el fallo numero uno al abrir un CSV de clase.
Se usa el Sniffer de la biblioteca estandar y, si no se aclara, se cuenta
a mano entre los cuatro candidatos razonables."""
try:
return csv.Sniffer().sniff(cabecera_texto, delimiters=";,\t|").delimiter
except csv.Error:
conteos = {sep: cabecera_texto.count(sep) for sep in (";", ",", "\t", "|")}
mejor = max(conteos, key=conteos.get)
return mejor if conteos[mejor] > 0 else ","
def _leer_csv(contenido_bytes):
"""Lee los bytes de un CSV devolviendo (DataFrame, avisos).
Prueba UTF-8 y, si falla, cp1252 (Windows Europa occidental), que es lo
que suele salir de un Excel espanol. Cada cosa que se decide por el
usuario se devuelve como aviso: nada de arreglar el archivo en silencio."""
avisos = []
if not contenido_bytes:
raise ErrorPerfil("El archivo esta vacio.")
if len(contenido_bytes) > MAX_BYTES:
raise ErrorPerfil(
f"El archivo pesa {len(contenido_bytes) / 1024 / 1024:.1f} MB y el "
f"limite de esta version son {MAX_BYTES // 1024 // 1024} MB."
)
texto = None
for codificacion in ("utf-8-sig", "cp1252"):
try:
texto = contenido_bytes.decode(codificacion)
if codificacion == "cp1252":
avisos.append(
"El archivo no estaba en UTF-8; se ha leido como cp1252 "
"(lo habitual en un CSV exportado desde Excel en Windows). "
"Si ves acentos raros, el origen del problema es ese."
)
break
except UnicodeDecodeError:
continue
if texto is None:
raise ErrorPerfil(
"No se ha podido leer el archivo como texto (ni UTF-8 ni cp1252). "
"Comprueba que es un CSV de verdad y no un Excel .xlsx renombrado."
)
primera_linea = texto.split("\n", 1)[0]
separador = _detectar_separador(primera_linea)
if separador != ",":
nombres = {";": "punto y coma", "\t": "tabulador", "|": "barra vertical"}
avisos.append(
f"Las columnas van separadas por {nombres.get(separador, separador)}, "
"no por coma."
)
try:
marco = pd.read_csv(
io.StringIO(texto),
sep=separador,
nrows=MAX_FILAS,
skip_blank_lines=True,
)
except Exception as error:
raise ErrorPerfil(f"pandas no ha podido interpretar el CSV: {error}")
if marco.shape[1] == 0:
raise ErrorPerfil("El CSV no tiene ninguna columna reconocible.")
if marco.shape[0] == 0:
raise ErrorPerfil("El CSV tiene cabecera pero ninguna fila de datos.")
if marco.shape[0] == MAX_FILAS:
avisos.append(
f"Solo se han leido las primeras {MAX_FILAS:,} filas: el resto se "
"ha ignorado en esta version."
)
return marco, avisos
def _nombre_tipo(serie):
"""Traduce el tipo de pandas a una palabra que entienda un alumno."""
if pd.api.types.is_bool_dtype(serie):
return "si/no"
if pd.api.types.is_integer_dtype(serie):
return "numero entero"
if pd.api.types.is_float_dtype(serie):
return "numero decimal"
if pd.api.types.is_datetime64_any_dtype(serie):
return "fecha"
return "texto"
def _detectar_tipo_mal_leido(serie):
"""Sobre una columna de TEXTO, comprueba si en realidad son numeros o
fechas que han entrado mal.
Devuelve un aviso explicable, o None. Los tres casos que detecta son
reales y muy frecuentes en un CSV de clase; los tres son deterministas,
asi que no hay ninguna razon para preguntarselo a un modelo."""
valores = serie.dropna().astype(str).str.strip()
valores = valores[valores != ""]
if len(valores) == 0:
return None
muestra = valores.head(MUESTRA_HEURISTICAS)
# 1. Numeros con punto decimal, leidos como texto.
convertidos = pd.to_numeric(muestra, errors="coerce")
if convertidos.notna().mean() >= UMBRAL_CONVERSION:
return (
"parece contener numeros pero se ha leido como texto; no podras "
"hacer medias ni graficas con ella hasta convertirla"
)
# 2. Numeros con la coma como separador decimal (muy espanol: "1,5").
con_punto = muestra.str.replace(".", "", regex=False).str.replace(
",", ".", regex=False
)
convertidos_coma = pd.to_numeric(con_punto, errors="coerce")
if convertidos_coma.notna().mean() >= UMBRAL_CONVERSION:
return (
"parece contener numeros escritos con la COMA como separador "
"decimal (por ejemplo 1,5); hay que convertirla antes de calcular "
"nada con ella"
)
# 3. Fechas leidas como texto. Primero el patron (rapido y predecible),
# y solo si cuela se confirma con pandas.
if muestra.str.match(PATRON_FECHA).mean() >= UMBRAL_CONVERSION:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
fechas = pd.to_datetime(muestra, errors="coerce", dayfirst=True)
if fechas.notna().mean() >= UMBRAL_CONVERSION:
return (
"parece contener fechas pero se ha leido como texto; no podras "
"ordenarla por tiempo hasta convertirla"
)
return None
def _perfilar_columna(nombre, serie, total_filas):
"""Todos los hechos de una columna. Sin modelo, sin adivinar nada."""
nulos = int(serie.isna().sum())
ficha = {
"nombre": str(nombre),
"tipo": _nombre_tipo(serie),
"nulos": nulos,
"porcentaje_nulos": round(100 * nulos / total_filas, 1) if total_filas else 0.0,
"valores_distintos": int(serie.nunique(dropna=True)),
"aviso": None,
"estadisticas": None,
"mas_frecuente": None,
}
if nulos == total_filas:
# pandas infiere float64 para una columna entera de nulos. Decir
# "numero decimal" de una columna vacia confundiria a un alumno, asi
# que aqui el tipo honesto es "no se sabe".
ficha["tipo"] = "sin datos"
ficha["aviso"] = "esta completamente vacia: no hay ni un solo valor"
return ficha
if pd.api.types.is_numeric_dtype(serie) and not pd.api.types.is_bool_dtype(serie):
sin_nulos = serie.dropna()
ficha["estadisticas"] = {
"minimo": round(float(sin_nulos.min()), 4),
"maximo": round(float(sin_nulos.max()), 4),
"media": round(float(sin_nulos.mean()), 4),
"mediana": round(float(sin_nulos.median()), 4),
}
else:
conteo = serie.dropna().astype(str).value_counts()
if len(conteo) > 0:
ficha["mas_frecuente"] = {
"valor": str(conteo.index[0])[:60],
"veces": int(conteo.iloc[0]),
}
if ficha["tipo"] == "texto":
ficha["aviso"] = _detectar_tipo_mal_leido(serie)
if ficha["valores_distintos"] == 1 and ficha["aviso"] is None:
ficha["aviso"] = (
"solo tiene un valor distinto en todas las filas: no aporta nada "
"para comparar"
)
return ficha
def perfilar_csv(contenido_bytes, nombre_archivo="datos.csv"):
"""Punto de entrada del modulo. Devuelve el perfil completo como dict.
Lanza ErrorPerfil con un motivo legible si el archivo no se puede
procesar. No lanza nunca una excepcion sin explicacion."""
marco, avisos = _leer_csv(contenido_bytes)
total_filas, total_columnas = marco.shape
duplicadas = int(marco.duplicated().sum())
columnas = [
_perfilar_columna(nombre, marco[nombre], total_filas)
for nombre in marco.columns
]
return {
"archivo": nombre_archivo,
"filas": int(total_filas),
"columnas_total": int(total_columnas),
"filas_duplicadas": duplicadas,
"avisos_lectura": avisos,
"columnas": columnas,
}
def perfil_a_texto(perfil):
"""Convierte el perfil en un texto compacto para pasarselo al modelo.
Se recorta a MAX_COLUMNAS_AL_MODELO columnas porque la ventana de
contexto del modelo de 3B es limitada: un CSV de 300 columnas no cabe. El
recorte se dice en el propio texto, no se hace en silencio."""
lineas = [
f"Archivo: {perfil['archivo']}",
f"Filas: {perfil['filas']}",
f"Columnas: {perfil['columnas_total']}",
f"Filas duplicadas exactas: {perfil['filas_duplicadas']}",
]
for aviso in perfil["avisos_lectura"]:
lineas.append(f"Aviso de lectura: {aviso}")
lineas.append("")
lineas.append("Columnas:")
mostradas = perfil["columnas"][:MAX_COLUMNAS_AL_MODELO]
for columna in mostradas:
partes = [
f"- {columna['nombre']} ({columna['tipo']})",
f"nulos: {columna['nulos']} ({columna['porcentaje_nulos']}%)",
f"valores distintos: {columna['valores_distintos']}",
]
if columna["estadisticas"]:
estadisticas = columna["estadisticas"]
partes.append(
f"min {estadisticas['minimo']}, max {estadisticas['maximo']}, "
f"media {estadisticas['media']}, mediana {estadisticas['mediana']}"
)
if columna["mas_frecuente"]:
frecuente = columna["mas_frecuente"]
partes.append(
f"valor mas repetido: '{frecuente['valor']}' "
f"({frecuente['veces']} veces)"
)
if columna["aviso"]:
partes.append(f"OJO: {columna['aviso']}")
lineas.append(" | ".join(partes))
restantes = perfil["columnas_total"] - len(mostradas)
if restantes > 0:
lineas.append(
f"(Hay {restantes} columnas mas que no se incluyen en este resumen "
f"por no exceder el limite de {MAX_COLUMNAS_AL_MODELO}.)"
)
return "\n".join(lineas)
Paso 4: main.py, la API
Aquí está la única parte que habla con el modelo. Fíjate en dos cosas: el SYSTEM_PROMPT, que es donde se le dice al modelo exactamente qué puede y qué no puede hacer, y el manejo de errores de _explicar_con_modelo, que devuelve un aviso legible en vez de reventar.
"""METIS — API minima para el laboratorio de datos.
Flujo de esta primera version, a proposito lo mas corto posible:
CSV subido -> perfil.py lo analiza con pandas (determinista)
-> el modelo local SOLO explica ese perfil en castellano
-> la pagina muestra las dos cosas juntas
Lo que esta version NO hace, y no por descuido:
- No ejecuta NUNCA codigo generado por el modelo. Ni con eval, ni con exec,
ni en un subproceso. El modelo no escribe codigo en ningun punto de este
archivo. Comprobar la sintaxis de un codigo no es seguridad: ROCKY tiene
los corpus reales del centro, y ejecutar Python generado por un modelo de
3B a peticion de un alumno no es algo que se vaya a hacer aqui.
- No genera graficas. Eso es la fase 2, y con un catalogo cerrado de
analisis escritos por una persona, no improvisados por el modelo.
- No guarda el CSV en ningun sitio. Se procesa en memoria y se descarta.
Arranque (con LM Studio ya arrancado y el modelo cargado):
uvicorn main:app --port 8015
Se queda escuchando solo en el propio ordenador. Abrirlo a la red del centro
es una decision aparte, con sus implicaciones, y no forma parte del MVP.
"""
import requests
from fastapi import FastAPI, File, UploadFile
from fastapi.responses import FileResponse, JSONResponse
import perfil
# --- Configuracion. Mismo patron que PINSAPIA para no inventar otro. ---
LM_STUDIO_URL = "http://localhost:1234/v1/chat/completions"
MODEL_NAME = "llama-3.2-3b-instruct"
TIMEOUT_SEGUNDOS = 120
app = FastAPI(title="METIS - laboratorio de datos")
# El prompt se escribe CON TILDES a proposito. En la primera prueba real
# estaba escrito sin acentos y el modelo copiaba esa grafia en su respuesta
# ("Que hay aqui" en vez de "Qué hay aquí"), mientras acentuaba
# correctamente el resto de su texto. Un modelo pequeno imita lo que ve.
SYSTEM_PROMPT = (
"Eres un ayudante que explica datos a alumnado de secundaria y "
"bachillerato, en castellano de España, con lenguaje llano.\n\n"
"Recibirás el PERFIL YA CALCULADO de un archivo de datos: filas, "
"columnas, tipos, valores que faltan, duplicados y estadísticas. Esos "
"números los ha calculado un programa y son correctos.\n\n"
"Reglas que debes cumplir siempre:\n"
"1. Usa ÚNICAMENTE los números que aparecen en el perfil. No calcules ni "
"estimes ni inventes ningún dato que no esté escrito ahí.\n"
"2. No especules sobre las CAUSAS de lo que ves si el perfil no las "
"dice. Por ejemplo: si hay filas duplicadas, di que las hay y que "
"conviene revisarlas, pero no afirmes por qué se han producido.\n"
"3. No escribas código. Ni Python, ni pandas, ni nada.\n"
"4. Escribe exactamente DOS párrafos separados por una línea en blanco, "
"sin ponerles título ni encabezado de ningún tipo.\n"
" - Primer párrafo: en dos o tres frases, de qué parecen ir estos datos.\n"
" - Segundo párrafo: dos o tres cosas concretas que merece la pena "
"revisar primero, dando prioridad a los avisos marcados con OJO y "
"diciendo el nombre exacto de las columnas afectadas.\n"
"5. Sé breve: entre 100 y 180 palabras en total.\n"
"6. Si no hay nada raro en los datos, dilo con naturalidad en vez de "
"buscar un problema a la fuerza.\n"
"7. No saludes, no te despidas y no anuncies lo que vas a hacer."
)
def _explicar_con_modelo(texto_perfil):
"""Pide al modelo local que explique el perfil ya calculado.
Devuelve (explicacion, error). Si el modelo no esta disponible, se
devuelve un error legible y la explicacion queda a None: el perfil se
muestra igualmente. La parte fiable de METIS nunca depende de que el
modelo este levantado."""
cuerpo_peticion = {
"model": MODEL_NAME,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": texto_perfil},
],
"temperature": 0.3,
"max_tokens": 500,
"stream": False,
}
try:
respuesta = requests.post(
LM_STUDIO_URL, json=cuerpo_peticion, timeout=TIMEOUT_SEGUNDOS
)
respuesta.raise_for_status()
contenido = respuesta.json()["choices"][0]["message"]["content"].strip()
return contenido, None
except requests.exceptions.ConnectionError:
return None, (
"No se ha podido conectar con LM Studio en localhost:1234. "
"Comprueba que esta abierto, con el modelo cargado y el servidor "
"local arrancado. El perfil de los datos que ves abajo es correcto "
"de todas formas: lo ha calculado Python, no el modelo."
)
except requests.exceptions.Timeout:
return None, (
f"El modelo ha tardado mas de {TIMEOUT_SEGUNDOS} segundos y se ha "
"cancelado la espera. El perfil de los datos sigue siendo valido."
)
except (requests.exceptions.RequestException, ValueError, KeyError, IndexError) as error:
return None, (
f"LM Studio ha respondido algo que no se ha podido interpretar "
f"({type(error).__name__}). El perfil de los datos sigue siendo valido."
)
@app.get("/")
def pagina_principal():
return FileResponse("static/index.html")
@app.get("/salud")
def salud():
"""Comprueba si LM Studio responde, sin gastar una generacion completa."""
try:
requests.get("http://localhost:1234/v1/models", timeout=5).raise_for_status()
modelo_disponible = True
except requests.exceptions.RequestException:
modelo_disponible = False
return {"api": "ok", "lm_studio": modelo_disponible}
@app.post("/perfilar")
async def perfilar(archivo: UploadFile = File(...)):
"""Recibe un CSV, lo perfila con Python y pide al modelo que lo explique.
El perfil se devuelve SIEMPRE que el archivo sea legible, aunque el
modelo falle. Son dos cosas independientes a proposito."""
if not archivo.filename.lower().endswith((".csv", ".txt", ".tsv")):
return JSONResponse(
status_code=400,
content={
"error": "Esta version solo acepta archivos .csv (o .txt/.tsv "
"con el mismo formato). Un .xlsx de Excel hay que guardarlo "
"antes como CSV."
},
)
contenido = await archivo.read()
try:
resultado = perfil.perfilar_csv(contenido, archivo.filename)
except perfil.ErrorPerfil as error:
return JSONResponse(status_code=400, content={"error": str(error)})
texto_perfil = perfil.perfil_a_texto(resultado)
explicacion, error_modelo = _explicar_con_modelo(texto_perfil)
return {
"perfil": resultado,
"explicacion": explicacion,
"error_modelo": error_modelo,
}
Paso 5: static/index.html, la interfaz
Un solo archivo, sin ninguna librería externa y sin nada cargado desde internet. El CSS es decorativo y puedes cambiarlo a tu gusto; la parte que importa es el JavaScript del final.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>METIS — laboratorio de datos</title>
<style>
:root {
--fondo: #f4f6f8;
--tarjeta: #ffffff;
--texto: #1c2733;
--suave: #5c6b7a;
--borde: #d9e0e7;
--acento: #1f6f8b;
--acento-claro: #e8f4f8;
--aviso: #b45309;
--aviso-fondo: #fff8ed;
--error: #b42318;
--error-fondo: #fef3f2;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 2rem 1rem 4rem;
background: var(--fondo);
color: var(--texto);
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
main { max-width: 900px; margin: 0 auto; }
header { margin-bottom: 2rem; }
h1 { margin: 0 0 .25rem; font-size: 1.9rem; letter-spacing: -.02em; }
.lema { margin: 0; color: var(--suave); font-size: .95rem; }
.tarjeta {
background: var(--tarjeta);
border: 1px solid var(--borde);
border-radius: 10px;
padding: 1.25rem 1.5rem;
margin-bottom: 1.25rem;
}
h2 { font-size: 1.05rem; margin: 0 0 .75rem; text-transform: uppercase;
letter-spacing: .06em; color: var(--suave); }
/* --- Zona de subida, con sus tres estados visibles --- */
label.zona {
display: block; padding: 1.75rem 1rem; text-align: center;
border: 2px dashed var(--borde); border-radius: 8px; cursor: pointer;
color: var(--suave);
transition: border-color .15s, background .15s, transform .15s;
}
label.zona:hover { border-color: var(--acento); background: #f9fcfd; }
label.zona strong { color: var(--acento); }
/* Arrastrando un archivo por encima: se nota, y mucho. */
body.arrastrando label.zona {
border-color: var(--acento); border-style: solid;
background: var(--acento-claro); transform: scale(1.01);
}
body.arrastrando label.zona .texto-normal { display: none; }
body.arrastrando label.zona .texto-suelta { display: block; }
label.zona .texto-suelta { display: none; font-weight: 600; color: var(--acento); }
/* Trabajando: la zona se desactiva de verdad, no solo de apariencia. */
label.zona.ocupada {
cursor: progress; opacity: .5; pointer-events: none;
border-style: dotted;
}
.nota { font-size: .85rem; color: var(--suave); margin-top: .75rem; }
input[type=file] { display: none; }
/* --- Estado de carga --- */
.trabajando { display: flex; align-items: center; gap: .9rem; }
.girador {
flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%;
border: 3px solid var(--borde); border-top-color: var(--acento);
animation: girar .9s linear infinite;
}
@keyframes girar { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.girador { animation-duration: 3s; }
label.zona { transition: none; }
}
.trabajando .archivo { font-weight: 600; }
.trabajando .detalle { font-size: .85rem; color: var(--suave); }
.cifras { display: flex; flex-wrap: wrap; gap: 1.5rem; margin-bottom: 1rem; }
.cifra strong { display: block; font-size: 1.6rem; line-height: 1.2; }
.cifra span { font-size: .8rem; color: var(--suave);
text-transform: uppercase; letter-spacing: .05em; }
.tabla-scroll { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; font-size: .9rem; }
th, td { text-align: left; padding: .5rem .6rem; border-bottom: 1px solid var(--borde);
vertical-align: top; white-space: nowrap; }
th { font-weight: 600; color: var(--suave); font-size: .78rem;
text-transform: uppercase; letter-spacing: .04em; }
td.aviso { white-space: normal; color: var(--aviso); min-width: 240px; }
.caja-aviso, .caja-error {
border-radius: 8px; padding: .8rem 1rem; margin-bottom: .75rem;
font-size: .9rem; border: 1px solid;
}
.caja-aviso { background: var(--aviso-fondo); border-color: #fed7aa; color: var(--aviso); }
.caja-error { background: var(--error-fondo); border-color: #fecdca; color: var(--error); }
.explicacion p { margin: 0 0 .75rem; }
.explicacion p:last-child { margin-bottom: 0; }
.nombre-analizado { font-size: .85rem; color: var(--suave); margin: 0 0 1rem; }
.oculto { display: none; }
footer { max-width: 900px; margin: 2rem auto 0; font-size: .8rem; color: var(--suave); }
</style>
</head>
<body>
<main>
<header>
<h1>METIS</h1>
<p class="lema">Sube un CSV y mira qué hay dentro antes de sacar conclusiones.</p>
</header>
<div class="tarjeta">
<label class="zona" id="zona" for="archivo">
<span class="texto-normal">
<strong>Elige un archivo CSV</strong><br>
o arrástralo hasta aquí
</span>
<span class="texto-suelta">Suelta el archivo aquí</span>
</label>
<input type="file" id="archivo" accept=".csv,.txt,.tsv">
<p class="nota">
Los datos no salen de este ordenador: se analizan aquí y no se guardan
en ningún sitio. Máximo 5 MB.
</p>
</div>
<div id="estado" class="oculto"></div>
<div id="resultado" class="oculto"></div>
</main>
<footer>
El perfil lo calcula Python, no la IA: esos números son exactos. La
explicación la escribe un modelo de lenguaje pequeño, y puede equivocarse
al interpretarlos.
</footer>
<script>
const entradaArchivo = document.getElementById("archivo");
const zona = document.getElementById("zona");
const zonaEstado = document.getElementById("estado");
const zonaResultado = document.getElementById("resultado");
// Candado: mientras hay una peticion en vuelo no se acepta otro archivo.
// Sin esto, arrastrar cuatro veces lanza cuatro analisis reales al modelo, y
// en una maquina sin GPU eso son varios minutos de cola invisible.
let trabajando = false;
// Contador para el dragover/dragleave: dragleave salta tambien al pasar por
// encima de los elementos hijos, asi que con un simple booleano el resaltado
// parpadea.
let profundidadArrastre = 0;
function escapar(texto) {
const div = document.createElement("div");
div.textContent = texto === null || texto === undefined ? "" : String(texto);
return div.innerHTML;
}
// --- Resaltado al arrastrar ---
document.body.addEventListener("dragenter", (evento) => {
evento.preventDefault();
if (trabajando) return;
profundidadArrastre++;
document.body.classList.add("arrastrando");
});
document.body.addEventListener("dragover", (evento) => evento.preventDefault());
document.body.addEventListener("dragleave", (evento) => {
evento.preventDefault();
profundidadArrastre = Math.max(0, profundidadArrastre - 1);
if (profundidadArrastre === 0) document.body.classList.remove("arrastrando");
});
document.body.addEventListener("drop", (evento) => {
evento.preventDefault();
profundidadArrastre = 0;
document.body.classList.remove("arrastrando");
if (evento.dataTransfer.files.length > 0) enviar(evento.dataTransfer.files[0]);
});
entradaArchivo.addEventListener("change", () => {
if (entradaArchivo.files.length > 0) enviar(entradaArchivo.files[0]);
});
async function enviar(archivo) {
// Si ya hay uno en marcha, se avisa en vez de encolar otro en silencio.
if (trabajando) {
zonaEstado.querySelector(".detalle").textContent =
"Ya hay un archivo analizándose; espera a que termine antes de subir otro.";
zonaEstado.scrollIntoView({ behavior: "smooth", block: "nearest" });
return;
}
trabajando = true;
zona.classList.add("ocupada");
zonaResultado.classList.add("oculto");
zonaResultado.innerHTML = "";
zonaEstado.className = "tarjeta";
zonaEstado.classList.remove("oculto");
zonaEstado.innerHTML =
'<div class="trabajando">' +
'<div class="girador"></div>' +
"<div>" +
'<div class="archivo">' + escapar(archivo.name) + "</div>" +
'<div class="detalle">Analizando el archivo y pidiéndole al modelo que ' +
"lo explique. Puede tardar un poco: ROCKY no tiene tarjeta gráfica.</div>" +
"</div>" +
"</div>";
zonaEstado.scrollIntoView({ behavior: "smooth", block: "nearest" });
const datos = new FormData();
datos.append("archivo", archivo);
try {
const respuesta = await fetch("/perfilar", { method: "POST", body: datos });
const cuerpo = await respuesta.json();
if (!respuesta.ok) {
zonaEstado.innerHTML =
'<div class="caja-error">' +
escapar(cuerpo.error || "Error desconocido.") + "</div>";
} else {
zonaEstado.classList.add("oculto");
pintar(cuerpo, archivo.name);
}
} catch (error) {
zonaEstado.innerHTML =
'<div class="caja-error">No se ha podido contactar con la API de METIS. ' +
"¿Sigue arrancada la ventana de uvicorn?</div>";
} finally {
trabajando = false;
zona.classList.remove("ocupada");
// Sin esto, volver a elegir EL MISMO archivo no dispara el evento change.
entradaArchivo.value = "";
}
}
function pintar(cuerpo, nombreArchivo) {
const perfil = cuerpo.perfil;
const trozos = [];
// --- Explicación del modelo ---
trozos.push('<section class="tarjeta">');
trozos.push("<h2>Lo que parece haber aquí</h2>");
trozos.push('<p class="nombre-analizado">' + escapar(nombreArchivo) + "</p>");
if (cuerpo.explicacion) {
const parrafos = cuerpo.explicacion
.split(/\n\s*\n/)
.map((t) => t.trim())
.filter((t) => t.length > 0);
trozos.push('<div class="explicacion">');
parrafos.forEach((t) => trozos.push("<p>" + escapar(t) + "</p>"));
trozos.push("</div>");
} else {
trozos.push('<div class="caja-aviso">' + escapar(cuerpo.error_modelo) + "</div>");
}
trozos.push("</section>");
// --- Perfil determinista ---
trozos.push('<section class="tarjeta">');
trozos.push("<h2>Los números exactos</h2>");
trozos.push('<div class="cifras">');
trozos.push('<div class="cifra"><strong>' + perfil.filas.toLocaleString("es-ES") +
"</strong><span>filas</span></div>");
trozos.push('<div class="cifra"><strong>' + perfil.columnas_total +
"</strong><span>columnas</span></div>");
trozos.push('<div class="cifra"><strong>' + perfil.filas_duplicadas +
"</strong><span>filas duplicadas</span></div>");
trozos.push("</div>");
perfil.avisos_lectura.forEach((aviso) => {
trozos.push('<div class="caja-aviso">' + escapar(aviso) + "</div>");
});
trozos.push('<div class="tabla-scroll"><table><thead><tr>');
["Columna", "Tipo", "Faltan", "Distintos", "Resumen", "Aviso"].forEach((titulo) => {
trozos.push("<th>" + titulo + "</th>");
});
trozos.push("</tr></thead><tbody>");
perfil.columnas.forEach((columna) => {
let resumen = "—";
if (columna.estadisticas) {
const e = columna.estadisticas;
resumen = "mín " + e.minimo + " · máx " + e.maximo +
" · media " + e.media + " · mediana " + e.mediana;
} else if (columna.mas_frecuente) {
resumen = "más repetido: “" + columna.mas_frecuente.valor + "” (" +
columna.mas_frecuente.veces + ")";
}
trozos.push("<tr>");
trozos.push("<td><strong>" + escapar(columna.nombre) + "</strong></td>");
trozos.push("<td>" + escapar(columna.tipo) + "</td>");
trozos.push("<td>" + columna.nulos + " (" + columna.porcentaje_nulos + "%)</td>");
trozos.push("<td>" + columna.valores_distintos + "</td>");
trozos.push("<td>" + escapar(resumen) + "</td>");
trozos.push('<td class="aviso">' + (columna.aviso ? escapar(columna.aviso) : "") + "</td>");
trozos.push("</tr>");
});
trozos.push("</tbody></table></div>");
trozos.push("</section>");
zonaResultado.innerHTML = trozos.join("");
zonaResultado.classList.remove("oculto");
}
</script>
</body>
</html>
Paso 6: arrancar
Con LM Studio funcionando, desde la carpeta metis:
uvicorn main:app --port 8015
Y abre http://localhost:8015 en el navegador.
Fíjate en que no lleva --host 0.0.0.0. Eso significa que METIS solo escucha en tu propio ordenador y no es accesible desde el resto de la red. Es deliberado: abrir un servicio a la red de un centro educativo es una decisión con implicaciones de seguridad y de protección de datos que merece pensarse aparte, no algo que se activa por descuido copiando un comando de un tutorial.
Si quieres tocar el SYSTEM_PROMPT varias veces seguidas, arráncalo con --reload y se recargará solo al guardar el archivo.
Paso 7: probarlo con un archivo que esté mal a propósito
Crea un archivo ejemplo_notas.csv con este contenido exacto, incluyendo los punto y coma y las comas decimales:
alumno;horas_estudio;nota;fecha;observaciones
Ana Ruiz;4,5;7,5;15/09/2026;
Luis Gomez;2,0;5,0;16/09/2026;
Marta Diaz;6,5;9,25;15/09/2026;
Pedro Sanz;;4,0;18/09/2026;
Ana Ruiz;4,5;7,5;15/09/2026;
Ese archivo lleva, a propósito, seis problemas a la vez. Súbelo y deberías ver los seis detectados:
| Problema | Lo que dice METIS |
|---|---|
| Separador punto y coma | Aviso de lectura: las columnas van separadas por punto y coma |
| Comas decimales | horas_estudio y nota marcadas: números con la coma como separador decimal |
| Fechas como texto | fecha marcada: parece contener fechas pero se ha leído como texto |
| Columna vacía | observaciones, tipo “sin datos”, 100 % faltantes |
| Fila duplicada | 1 fila duplicada (Ana Ruiz aparece dos veces) |
| Valor que falta | horas_estudio, 1 faltante (20 %) |
Si ves esos seis, funciona.
Las detecciones, una a una
Esta es la parte del código que más me gusta, porque resuelve con reglas sencillas y explicables lo que mucha gente intentaría resolver preguntándole a una IA.
Separador de columnas. Se usa el Sniffer de la biblioteca estándar de Python sobre la primera línea, con los cuatro candidatos razonables (;, ,, tabulador, |). Si no se aclara, se cuenta a mano cuál aparece más veces.
Codificación del archivo. Se intenta UTF-8 y, si falla, cp1252, que es lo que produce Excel en Windows en Europa occidental. Si se usa la segunda, se avisa, porque de ahí vienen los acentos rotos.
Números leídos como texto. Sobre una muestra de hasta 200 valores no vacíos de una columna de texto, se intenta convertirlos a número. Si lo consigue el 80 % o más, se marca la columna.
Coma decimal. Igual, pero quitando primero los puntos (separador de miles) y cambiando la coma por punto. Si tras esa transformación convierte el 80 % o más, se marca como coma decimal española.
Fechas leídas como texto. Aquí van dos pasos: primero una expresión regular con las formas de fecha habituales (3/9/2026, 03-09-26, 2026-09-03), que es rápida y predecible, y solo si esa pasa el umbral se confirma con pd.to_datetime. Hacerlo en dos pasos evita que pandas intente interpretar como fecha cosas que no lo son.
Columnas inútiles. Si una columna está entera vacía, se dice. Si tiene un solo valor distinto en todas las filas, también, porque no sirve para comparar nada.
Los umbrales están escritos como constantes con nombre al principio del archivo, no repartidos por el código: UMBRAL_CONVERSION = 0.80, MUESTRA_HEURISTICAS = 200. Si quieres afinarlos, están en un solo sitio.
Y un detalle honesto sobre los tipos
Cuando una columna está completamente vacía, pandas la clasifica como float64 —número decimal— porque no tiene nada con lo que decidir otra cosa. Es correcto desde el punto de vista de pandas, pero mostrarle a un alumno que una columna vacía es de tipo “número decimal” es confundirle. Así que en ese caso METIS dice “sin datos”, que es la verdad.
Es un cambio de tres líneas y ejemplifica algo que aparece constantemente al construir herramientas educativas: lo técnicamente correcto y lo comprensible no siempre coinciden, y hay que elegir a conciencia.
Cómo se comprueba que funciona
El archivo test_perfil.py (que no incluyo entero aquí para no alargar más, pero que se construye igual que las demás baterías de este proyecto) tiene 35 comprobaciones sobre perfil.py: los seis problemas del CSV de ejemplo, los archivos en cp1252, los archivos vacíos, los que solo tienen cabecera, los bytes que no son texto, los que superan el tamaño máximo, y —esto es importante— que una columna de texto normal no reciba avisos falsos.
Esa última categoría es la que más falta suele hacer. Es fácil escribir un detector que avise mucho; lo difícil es que no avise cuando no toca.
Se ejecuta así:
python test_perfil.py
Y debe terminar con TOTAL: todo correcto, 0 fallos.
Tres cosas que aprendí montándolo
Las cuento porque son el tipo de detalle que no aparece en los tutoriales y que te cuesta una tarde descubrir.
1. El modelo copia tu ortografía. Escribí el SYSTEM_PROMPT sin tildes, por comodidad al teclear. En la primera prueba real, el modelo escribió “Que hay aqui” sin acentos… mientras acentuaba perfectamente el resto de su respuesta (“empezaría”, “también”, “análisis”). Estaba imitando mi grafía justo en las palabras que copiaba de mis instrucciones. Un modelo pequeño imita lo que ve. Ahora el prompt va con tildes.
2. El modelo especula sobre causas que no puede conocer. Ante la fila duplicada, escribió que “sugiere que dos estudiantes tienen las mismas notas”. No: lo que hay es a Ana Ruiz introducida dos veces, un duplicado de captura de datos, que es la explicación aburrida y correcta. El perfil incluso lo insinuaba (la columna alumno tenía 4 valores distintos en 5 filas), y el modelo no ató los cabos. No era una alucinación numérica —los números los citó todos bien—, era inventarse un por qué. La solución fue una regla explícita en el prompt: di que hay duplicados y que conviene revisarlos, pero no afirmes por qué se han producido.
3. Un fallo de interfaz puede convertirse en un problema de rendimiento. La primera versión no daba ninguna señal visual al arrastrar un archivo. Al probarla, arrastré el archivo cuatro veces creyendo que no se había cargado… y lancé cuatro análisis reales al modelo, en cola, en un ordenador sin tarjeta gráfica. La respuesta tardó siglos y no era culpa del modelo.
El arreglo tiene tres partes, y las tres hacían falta: resaltar la zona al arrastrar por encima (con un contador de profundidad, porque dragleave también se dispara al pasar sobre los elementos hijos y con un simple booleano el resaltado parpadea), mostrar de inmediato el nombre del archivo con un indicador girando, y —la importante— un candado que ignora archivos nuevos mientras hay uno en proceso, avisando en vez de encolar en silencio.
De paso apareció un fallo que no había notado: volver a elegir el mismo archivo con el botón no hacía nada, porque el evento change de un <input type="file"> no se dispara si el valor no cambia. Se arregla limpiando entradaArchivo.value al terminar.
Lo que METIS no hace, y por qué
Ser explícito sobre esto es parte del diseño, no una disculpa.
No genera gráficas todavía. Esa es la fase 2, y va a hacerse con un catálogo cerrado de análisis parametrizados —dispersión de X frente a Y, histograma, media agrupada— con el código escrito por una persona. El modelo elegirá cuál encaja con la pregunta y comentará el resultado, pero no escribirá el análisis.
No genera código Python. Sería una fase 3 que exigiría un aislamiento serio: proceso separado, funciones básicas restringidas, sin acceso al sistema de archivos, con límite de tiempo y de memoria. Es un trabajo de ingeniería considerable y no hace falta para nada de lo que quiero enseñar. Mi recomendación, a mí mismo del futuro incluido, es no llegar nunca ahí.
No hace de tutor socrático. La idea de que el modelo, en vez de decir qué limpiar, haga preguntas guía para que el alumno lo descubra es atractiva sobre el papel. Pero ya tengo comprobado en este proyecto que un modelo de 3.000 millones de parámetros no sostiene de forma fiable la restricción de “no dar nunca la respuesta directa” cuando el alumno insiste: se le escapa. Prometerlo sería prometer algo que no cumple.
El modelo puede equivocarse interpretando. Los números son exactos porque los calcula Python. La interpretación es de una IA pequeña y hay que leerla como lo que es. Está escrito en el pie de la página, de forma permanente, y en clase es exactamente el punto que quiero que se discuta.
Para llevarlo al aula
La actividad que tengo en mente no es “aprended a usar METIS”. Es esta:
Se reparte a cada grupo un archivo de datos con problemas metidos a propósito, distintos en cada archivo. Antes de tocar METIS, tienen que abrirlo, mirarlo y apuntar qué creen que está mal. Después lo pasan por METIS y comparan: qué habían visto, qué se les había escapado, y —el momento bueno— si METIS ha marcado algo que ellos consideran que no es un problema.
Porque ahí se aprende de verdad: un umbral del 80 % puede equivocarse, y un código postal que empieza por cero es un texto, no un número, aunque un detector automático diga lo contrario. La herramienta no es la autoridad final. Es un ayudante que se equivoca de formas predecibles, y entender cómo se equivoca es más valioso que confiar en ella.
Y después, la pregunta que da sentido a todo: con estos datos ya limpios, ¿qué se puede concluir de verdad, y qué no?
Próximo paso
La fase 2: el catálogo cerrado de gráficas. Subes un CSV, eliges “relación entre estas dos columnas”, y METIS dibuja el diagrama de dispersión con código escrito de antemano, no improvisado. Y el modelo, al lado, plantea la pregunta que toca:
Esta correlación, ¿implica causalidad?
Pero eso será otra entrada.




