Módulo 9 · Problemas integrados

Lección 3 de 4

Plataformas externas

En esta páginaEl problema

El problema

La documentación de puntuación de Nitro distingue tareas estándar y hackathons. En las estándar, las predicciones se evalúan con una métrica y una función de puntos propia de cada tarea, y el resultado parcial o público se separa del completo o privado.

La tarea sintética «Cielos» sigue ese esquema y llega como un paquete: consigna, datos de entrenamiento, datos de prueba sin etiqueta, un archivo de muestra y reglas de calificación. Estas son sus reglas:

reglavalor
datosentrenamiento.csv (600 filas), prueba.csv y muestra_envio.csv
objetivocolumna cielo: despejado, nublado o lluvia
métricaF1 macro sobre las tres clases
puntos0 con F1 macro ≤ 0.40, 100 con F1 macro ≥ 0.85, lineal entre ambos
archivoCSV con las columnas de muestra_envio.csv, una fila por id
tabla pública120 de las 400 filas de prueba, sin decir cuáles
tabla privadalas otras 280 filas; define el resultado final
envíos4 en total; un archivo rechazado también cuenta
elecciónal final eliges un envío para la tabla privada
entornonotebook sin internet, con dependencias instaladas y 10 min de ejecución

Las cinco lecturas de sensores (presion, humedad, viento, nubosidad, temperatura) están estandarizadas: sus valores rondan 0 y no tienen unidad.

Paquete de la tarea

La celda plegada simula el paquete que se descarga de la plataforma. Cada archivo es un texto CSV dentro del diccionario paquete. También guarda lo que la plataforma no publica: respuestas_plataforma y filas_publicas. Ninguna decisión de modelado usa esas dos variables; las leen la réplica de la plataforma y, al final, la revelación de la tabla privada.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
import io

import numpy as np
import pandas as pd
from sklearn.datasets import make_classification

X, y = make_classification(
    n_samples=1000,
    n_features=5,
    n_informative=4,
    n_redundant=0,
    n_classes=3,
    weights=[0.6, 0.3, 0.1],
    random_state=7,
)
columnas = ["presion", "humedad", "viento", "nubosidad", "temperatura"]
clases = ["despejado", "nublado", "lluvia"]
rng = np.random.default_rng(0)

tabla = pd.DataFrame(X.round(3), columns=columnas)
# 1000 id distintos entre E-1000 y E-9999, en orden aleatorio
tabla.insert(0, "id", [f"E-{n}" for n in rng.permutation(9000)[:1000] + 1000])
tabla["cielo"] = np.array(clases)[y]

respuestas_plataforma = tabla.iloc[600:][["id", "cielo"]].reset_index(drop=True)
filas_publicas = rng.permutation(400) < 120  # 120 filas públicas al azar

paquete = {
    "entrenamiento.csv": tabla.iloc[:600].to_csv(index=False),
    "prueba.csv": tabla.iloc[600:].drop(columns="cielo").to_csv(index=False),
    "muestra_envio.csv": tabla.iloc[600:][["id"]]
    .assign(cielo="despejado")
    .to_csv(index=False),
}

paquete tiene tres archivos: entrenamiento.csv, prueba.csv y muestra_envio.csv. Cada uno es texto plano: una línea de encabezado y una línea por fila, con valores separados por comas. Estas son las tres primeras líneas de entrenamiento.csv:

  • id,presion,humedad,viento,nubosidad,temperatura,cielo
  • E-8105,0.944,1.174,-1.074,1.635,-0.18,nublado
  • E-2410,0.81,-0.747,-0.344,-2.463,2.209,nublado

leer abre un archivo del paquete. io.StringIO envuelve el texto para que pd.read_csv lo lea como si fuera un archivo; con los archivos en disco, pd.read_csv("muestra_envio.csv") produce la misma tabla. Series.equals es verdadero solo si las dos columnas tienen los mismos valores en el mismo orden.

Pregunta. Escribe la forma de muestra, sus columnas y el valor que esperas en cielo. Decide si sus id están en el mismo orden que los de prueba.

Python 3 · ejemplo reproducible
def leer(nombre):
    return pd.read_csv(io.StringIO(paquete[nombre]))


entrenamiento = leer("entrenamiento.csv")
prueba = leer("prueba.csv")
muestra = leer("muestra_envio.csv")

print(entrenamiento.shape, prueba.shape, muestra.shape)
print(muestra.head(3))
print("mismos id y orden:", muestra["id"].equals(prueba["id"]))
print(entrenamiento["cielo"].value_counts().to_dict())
Salida esperada

La salida esperada está oculta.

Un notebook sin internet solo tiene lo que trae el paquete y las dependencias instaladas. Cualquier descarga de datos o pesos dentro del código falla en ese entorno. Índices como awesome-ioai-tasks reúnen enlaces a tareas; la consigna válida es la que publica la plataforma que mantiene la tarea.

Métrica y puntos

La plataforma no deja ver las respuestas de prueba. Para calificar en local, una parte de entrenamiento hace el papel de prueba: 150 filas estratificadas forman prueba_local, con sus respuestas en respuestas_local. Las otras 450 forman desarrollo. En train_test_split, test_size=150 fija el número de filas apartadas y stratify conserva en ellas la proporción de cada clase.

Python 3 · ejemplo reproducible
from sklearn.model_selection import train_test_split

desarrollo, local = train_test_split(
    entrenamiento, test_size=150, stratify=entrenamiento["cielo"], random_state=0
)
respuestas_local = local[["id", "cielo"]].reset_index(drop=True)
prueba_local = local.drop(columns="cielo").reset_index(drop=True)
print(len(desarrollo), len(prueba_local))
Salida esperada

La salida esperada está oculta.

calificar empareja cada predicción con su respuesta por id, no por posición: merge(on="id") une las dos tablas y suffixes distingue las dos columnas cielo. f1_score con average="macro" promedia el F1 de las tres clases sin pesarlas por su tamaño. puntos aplica la función de la consigna; np.clip acota la fracción entre 0 y 1 antes de multiplicarla por 100. La consigna de Broken es un ejemplo real del mismo esquema: usa F1 macro, publica baselines y umbrales, y divide el problema en subtareas binarias y multiclase que dan puntos parciales.

Python 3 · ejemplo reproducible
from sklearn.metrics import f1_score


def calificar(envio, respuestas):
    unido = respuestas.merge(envio, on="id", suffixes=("_real", "_envio"))
    return f1_score(unido["cielo_real"], unido["cielo_envio"], average="macro")


def puntos(f1):
    # fracción del camino entre los umbrales 0.40 y 0.85
    return round(100 * float(np.clip((f1 - 0.40) / (0.85 - 0.40), 0, 1)), 1)

envios_locales reúne dos envíos sobre prueba_local: uno constante y otro con las predicciones de una LogisticRegression entrenada con desarrollo. max_iter=1000 da al optimizador iteraciones suficientes para converger.

Pregunta. Un envío constante dice despejado en todas las filas. Estima su F1 macro y sus puntos. Después, calcula cuántos puntos vale una mejora de 0.01 en F1 macro entre los dos umbrales.

Python 3 · ejemplo reproducible
from sklearn.linear_model import LogisticRegression

lineal = LogisticRegression(max_iter=1000)
lineal.fit(desarrollo[columnas], desarrollo["cielo"])
envios_locales = {
    "constante": prueba_local[["id"]].assign(cielo="despejado"),
    "lineal": prueba_local[["id"]].assign(cielo=lineal.predict(prueba_local[columnas])),
}

for nombre, envio in envios_locales.items():
    f1 = calificar(envio, respuestas_local)
    print(f"{nombre:10} F1 macro {f1:.3f}  puntos {puntos(f1)}")
Salida esperada

La salida esperada está oculta.

Con average=None, f1_score devuelve un F1 por clase, en el orden que fija labels.

Python 3 · ejemplo reproducible
unido = respuestas_local.merge(envios_locales["lineal"], on="id", suffixes=("_real", "_envio"))
por_clase = f1_score(unido["cielo_real"], unido["cielo_envio"], average=None, labels=clases)
print("F1 por clase del lineal:", dict(zip(clases, por_clase.round(3).tolist())))
Salida esperada

La salida esperada está oculta.

Validador de envío

La plataforma lee el archivo, no el DataFrame que lo produjo. validar_envio lee el texto del CSV igual que la plataforma y devuelve la lista completa de problemas. Una lista vacía significa que el archivo cumple el contrato. Primero compara las columnas con las de muestra. Después cuenta filas, id repetidos con duplicated, id faltantes y desconocidos con diferencias de conjuntos, celdas vacías con isna y clases que no están en clases.

Python 3 · ejemplo reproducible
def validar_envio(texto, muestra, clases):
    envio = pd.read_csv(io.StringIO(texto))
    if envio.columns.tolist() != muestra.columns.tolist():
        return [f"columnas: {envio.columns.tolist()}"]
    problemas = []
    if len(envio) != len(muestra):
        problemas.append(f"filas: {len(envio)} de {len(muestra)}")
    revisiones = {
        "id repetidos": envio["id"].duplicated().sum(),
        "id faltantes": len(set(muestra["id"]) - set(envio["id"])),
        "id desconocidos": len(set(envio["id"]) - set(muestra["id"])),
        "celdas vacías": envio["cielo"].isna().sum(),
    }
    for nombre, cantidad in revisiones.items():
        if cantidad:
            problemas.append(f"{nombre}: {cantidad}")
    invalidas = sorted(set(envio["cielo"].dropna()) - set(clases))
    if invalidas:
        problemas.append(f"clases no válidas: {invalidas}")
    return problemas

Las cuatro variantes parten de un mismo envío sobre prueba: la escritura correcta, una escritura sin index=False, una clase escrita con mayúscula y una tabla a la que le faltan dos filas, con una fila repetida y una celda vacía.

Pregunta. Para cada variante, escribe qué problemas debería listar validar_envio.

Python 3 · ejemplo reproducible
envio_prueba = prueba[["id"]].assign(cielo=lineal.predict(prueba[columnas]))
# 398 filas más una copia de la primera: faltan dos id y uno se repite
incompleto = pd.concat([envio_prueba.iloc[:398], envio_prueba.iloc[:1]])
incompleto.iloc[5, 1] = None  # fila 5, columna cielo

variantes = {
    "correcto": envio_prueba.to_csv(index=False),
    "con índice": envio_prueba.to_csv(),
    "mayúscula": envio_prueba.replace({"cielo": {"nublado": "Nublado"}}).to_csv(index=False),
    "incompleto": incompleto.to_csv(index=False),
}
for nombre, texto in variantes.items():
    print(f"{nombre}: {validar_envio(texto, muestra, clases)}")
Salida esperada

La salida esperada está oculta.

Alineación de identificadores

La consigna de The Lost Laboratory Journal exige un esquema exacto y filas alineadas con los targets que conserva la plataforma. En «Cielos», la plataforma empareja por id, así que el orden de las filas no importa. Lo que importa es que cada id lleve la predicción de su propia fila.

En la celda siguiente, sort_values("humedad") ordena prueba_local por humedad antes de predecir; cada fila conserva su id. desalineado toma los id de la tabla original y las predicciones de la tabla ordenada. alineado toma ambos de la tabla ordenada. muestra_local hace el papel de muestra para las 150 filas locales.

Pregunta. ¿validar_envio encuentra algún problema en desalineado? ¿Su F1 macro local quedará más cerca del lineal (0.576) o del constante (0.243)?

Python 3 · ejemplo reproducible
ordenada = prueba_local.sort_values("humedad")
muestra_local = prueba_local[["id"]].assign(cielo="despejado")

envios_orden = {
    "desalineado": pd.DataFrame(
        {"id": prueba_local["id"], "cielo": lineal.predict(ordenada[columnas])}
    ),
    "alineado": pd.DataFrame(
        {"id": ordenada["id"], "cielo": lineal.predict(ordenada[columnas])}
    ),
}
for nombre, envio in envios_orden.items():
    texto = envio.to_csv(index=False)
    problemas = validar_envio(texto, muestra_local, clases)
    f1 = calificar(pd.read_csv(io.StringIO(texto)), respuestas_local)
    print(f"{nombre:12} problemas {problemas}  F1 macro {f1:.3f}")
Salida esperada

La salida esperada está oculta.

Para que el envío real y el local salgan del mismo código, generar_envio toma id y predicciones de la misma tabla. Se aplica igual a prueba_local que a prueba.

Python 3 · ejemplo reproducible
def generar_envio(modelo, tabla):
    envio = pd.DataFrame({"id": tabla["id"], "cielo": modelo.predict(tabla[columnas])})
    return envio.to_csv(index=False)

Envíos y tabla pública

La celda plegada define Plataforma, que replica las reglas de la consigna: cuenta cada envío, rechaza un archivo que no cumple el contrato y, si es válido, devuelve el F1 macro de las 120 filas públicas. Las respuestas de las otras 280 filas quedan dentro de respuestas, sin usarse.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
class Plataforma:
    def __init__(self, respuestas, publicas, limite):
        self.publica = respuestas[publicas]  # la máscara deja solo las filas públicas
        self.limite = limite
        self.usados = 0

    def enviar(self, texto):
        if self.usados == self.limite:
            return "límite alcanzado"
        self.usados += 1  # cuenta antes de validar: un rechazo también gasta
        if validar_envio(texto, muestra, clases):
            return "rechazado"
        return round(calificar(pd.read_csv(io.StringIO(texto)), self.publica), 3)

Se prueban tres candidatos. make_pipeline encadena pasos que se entrenan juntos con un solo fit. StandardScaler estandariza cada columna con la media y la desviación de entrenamiento. KNeighborsClassifier(n_neighbors=3) predice la clase mayoritaria entre las tres filas de entrenamiento más cercanas. PolynomialFeatures(2) agrega los cuadrados y los productos de pares de columnas, con lo que la regresión logística puede trazar fronteras curvas.

Python 3 · ejemplo reproducible
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures, StandardScaler

candidatos = {
    "lineal": LogisticRegression(max_iter=1000),
    "vecinos_3": make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=3)),
    "cuadratico": make_pipeline(
        PolynomialFeatures(2), StandardScaler(), LogisticRegression(max_iter=2000)
    ),
}

La réplica admite cuatro envíos. El primero se escribe sin index=False.

Pregunta. ¿Qué responde la plataforma al primer envío y al quinto? ¿El orden de los tres candidatos en la tabla pública coincidirá con su orden en local?

Python 3 · ejemplo reproducible
plataforma = Plataforma(respuestas_plataforma, filas_publicas, limite=4)
print("envío 1:", plataforma.enviar(envio_prueba.to_csv()))
Salida esperada

La salida esperada está oculta.

Cada candidato se califica primero en local, entrenado con desarrollo, y después se envía entrenado con las 600 filas de entrenamiento. textos guarda el archivo enviado de cada candidato y registro, sus dos F1.

Python 3 · ejemplo reproducible
registro, textos = [], {}
for nombre, modelo in candidatos.items():
    modelo.fit(desarrollo[columnas], desarrollo["cielo"])
    texto_local = generar_envio(modelo, prueba_local)
    f1_local = calificar(pd.read_csv(io.StringIO(texto_local)), respuestas_local)
    modelo.fit(entrenamiento[columnas], entrenamiento["cielo"])  # reentrena con las 600 filas
    textos[nombre] = generar_envio(modelo, prueba)
    f1_publica = plataforma.enviar(textos[nombre])
    registro.append({"modelo": nombre, "f1_local": round(f1_local, 3), "f1_publica": f1_publica})
registro = pd.DataFrame(registro)
print(registro)
Salida esperada

La salida esperada está oculta.

Después de los tres candidatos, se envía otra vez el archivo de lineal.

Ver resultado

La respuesta de plataforma.enviar es un F1 macro público, como el 0.613 de lineal, si califica el archivo, o un mensaje si no.

Al quinto archivo, la réplica responde «límite alcanzado» y no lo califica.

Lectura. El archivo rechazado y los tres candidatos ya gastaron los cuatro envíos: el quinto ya no se califica, aunque el archivo es válido. Con la regla «un archivo rechazado también cuenta», olvidar index=False dejó solo tres envíos para los modelos.

La consigna pide elegir un envío para la tabla privada. Por la tabla pública, el elegido sería vecinos_3; por el F1 local, cuadratico. La plataforma revela la tabla privada solo al terminar la competencia; la réplica ya puede calcularla con las 280 filas que no son públicas.

Pregunta. ¿Cuál de los dos esperas que tenga mejor F1 privado?

Ver resultado

Cada fila es un candidato. «F1 local» es su F1 macro en las 150 filas de prueba_local, entrenado con desarrollo. «F1 público» y «F1 privado» son su F1 macro en las 120 filas públicas y en las 280 privadas de prueba, entrenado con las 600 filas de entrenamiento. Por ejemplo, la fila de cuadratico dice que saca 0.741 en local, 0.779 en la tabla pública y 0.854 en la privada.

modeloF1 localF1 públicoF1 privado
lineal0.5760.6130.603
vecinos_30.7180.8680.784
cuadratico0.7410.7790.854
Lectura. Elegir por la tabla pública envía vecinos_3; elegir por el F1 local envía cuadratico, que resulta mejor en la privada. En la privada, vecinos_3 cae por debajo de su puntaje público y cuadratico sube. La diferencia local entre ambos también era pequeña: con 150 filas, 0.023 no demuestra una ventaja, y en este caso el orden local coincidió con el privado. Lo que sí queda claro es que la tabla pública es una muestra de 120 filas y no el resultado final.

Práctica

Traza guiada

Antes de procesar, escribe la lista que devolverá validar_envio para este archivo frente a una muestra de tres id:

Python 3 · ejemplo reproducible
mini_muestra = pd.DataFrame({"id": ["E-1001", "E-1002", "E-1003"], "cielo": "despejado"})
mini_texto = "id,cielo\nE-1002,nublado\nE-1002,lluvia\nE-1004,Despejado\n"

Comprueba con validar_envio(mini_texto, mini_muestra, clases). Resultado esperado: ['id repetidos: 1', 'id faltantes: 2', 'id desconocidos: 1', "clases no válidas: ['Despejado']"]. El número de filas coincide, así que no aparece un problema de filas. Si tu lista no coincide, revisa qué cuenta cada entrada de revisiones.

Regla de orden

Supón que la consigna cambia: las filas deben seguir el orden de muestra_envio.csv. Agrega esa revisión a validar_envio con un mensaje "orden de id distinto", solo cuando el conjunto de id ya coincide. Antes de procesar, predice qué dice la función nueva de desalineado y de alineado. Resultado esperado: alineado, que tiene las predicciones correctas, ahora se rechaza; desalineado sigue pasando. Reordena alineado con muestra_local[["id"]].merge(envios_orden["alineado"], on="id") y comprueba que ahora pasa y conserva F1 macro 0.576.

Réplica de una tarea de regresión

Construye la réplica local de otra consigna. Datos: make_regression(n_samples=500, n_features=4, noise=10.0, random_state=3); las primeras 400 filas forman entrenamiento y las últimas 100, prueba, con id de R-001 a R-500. Métrica: RMSE. Puntos: 0 con RMSE ≥ 60 y 100 con RMSE ≤ 5, lineal entre ambos. Archivo: columnas id,valor, un número por fila. Produce:

  1. el diccionario paquete con los tres archivos
  2. validar_envio_regresion, que además detecte valores no numéricos con pd.to_numeric(..., errors="coerce")
  3. calificar_rmse y puntos_rmse, con el cuidado de que un RMSE menor dé más puntos
  4. tres archivos rotos con errores distintos y la lista de problemas de cada uno
  5. los puntos locales de LinearRegression con un holdout de 100 filas de entrenamiento

Resultado esperado: el RMSE de LinearRegression queda cerca de 10, el ruido de los datos, y da alrededor de 90 puntos; un envío constante con la media de entrenamiento supera 60 de RMSE y da 0.

Reto opcional

Sortea 1000 tablas públicas distintas de 120 filas: crea rng_sorteo = np.random.default_rng(1) y, en cada sorteo, usa rng_sorteo.permutation(400) < 120 como máscara. En cada tabla, filtra respuestas_plataforma con la máscara, como hace Plataforma, y califica los archivos textos["vecinos_3"] y textos["cuadratico"], leídos con pd.read_csv(io.StringIO(...)), con calificar para obtener su F1 macro público. Reporta en qué fracción de los sorteos vecinos_3 queda por encima y compara el resultado con la tabla pública que tocó en «Cielos». Resultado esperado: vecinos_3 supera a cuadratico en cerca de 28 % de las tablas públicas.

Errores que revisar

  • Si la plataforma rechaza un archivo que en pantalla se ve bien, léelo como texto con pd.read_csv y pásalo por validar_envio: una columna Unnamed: 0 indica que se escribió con el índice.
  • Si el archivo pasa el validador pero la puntuación queda muy por debajo del F1 local, revisa que id y predicción salgan de la misma tabla, como en generar_envio.
  • Si aparecen clases no válidas, compara mayúsculas, espacios y acentos con las clases escritas en la consigna.
  • Si el mejor envío público baja en la tabla privada, compara la diferencia pública con el tamaño de la tabla pública y con la diferencia en local.
  • Si el notebook falla en la plataforma con un error de conexión, busca descargas de datos o de pesos en el código: sin internet, todo debe venir del paquete o de las dependencias instaladas.

Cierre

Explica por qué desalineado pasa validar_envio y aun así obtiene un F1 macro mucho menor que alineado, y qué comprobación local lo detecta antes de gastar un envío.

Lista de comprobación

  • ¿Qué parte del contrato fija muestra_envio.csv y qué parte fija la consigna?
  • ¿Por qué un F1 macro de 0.243 da 0 puntos y cuánto vale una mejora de 0.01 entre los umbrales?
  • ¿Qué errores detecta validar_envio y cuál no puede detectar?
  • ¿Por qué 120 filas públicas no bastan para elegir entre vecinos_3 y cuadratico?

Documentación oficial

  • Puntuación en Nitro: tareas estándar y hackathons, funciones de puntos y resultados públicos y privados.
  • Broken: consigna con F1 macro, baselines, umbrales y subtareas binarias y multiclase.
  • The Lost Laboratory Journal: consigna que exige reconstruir filas sintéticas con un esquema exacto, alinearlas con los targets y resolver una regresión evaluada con RMSE.
  • f1_score: F1 por clase y el promedio average="macro".
Cargando progreso…