Skip to main content
El clasificador de PsicoScan opera en dos modos según la presencia del archivo ml-api/models/modelo.pkl:

Modo reglas (fallback)

Sin modelo entrenado. ClasificadorML aplica umbrales heurísticos sobre los índices T del SENA. Versión 0.1.0-reglas. Disponible desde el primer arranque sin datos históricos.

Modo ML (Random Forest)

Con modelo.pkl presente. El clasificador usa el vector de 25 features calculado desde las 188 respuestas brutas. Versión 1.0.0-ml. Proporciona una puntuación de confianza por predicción.

Vector de features

La función _features_desde_respuestas() en clasificador.py convierte la cadena de 188 dígitos en un vector de 25 valores numéricos:
def _features_desde_respuestas(cadena: str, edad: int | None = None, sexo: str | None = None) -> list[float]:
    """Convierte cadena de 188 dígitos en el vector de 25 features (23 + edad + sexo_n)."""
    digitos = [int(c) for c in cadena if c.isdigit()][:188]
    if len(digitos) < 188:
        digitos += [0] * (188 - len(digitos))
    feats = [float(sum(digitos[i - 1] for i in items if 1 <= i <= 188))
             for items in _ESCALAS.values()]
    feats.append(float(sum(1 for v in digitos if v >= 4)))        # items_criticos_globales
    feats.append(float(sum(1 for i in _CRITICOS if 1 <= i <= 188 and digitos[i - 1] >= 3)))  # items_criticos_count
    feats.append(float(edad) if edad is not None else 15.0)       # edad
    feats.append(float(_SEXO_MAP.get(sexo or "", 0)))             # sexo_n
    return feats

Composición del vector (25 features)

PosiciónNombreDescripción
0depSuma de respuestas en los 16 ítems de Depresión
1ansSuma en los 14 ítems de Ansiedad
2ascSuma en los 7 ítems de Ansiedad social
3somSuma en los 10 ítems de Somatización
4pstSuma en los 8 ítems de Estrés postraumático
5obsSuma en los 7 ítems de Obsesivo-compulsivo
6ateSuma en los 10 ítems de Inatención
7hipSuma en los 10 ítems de Hiperactividad
8iraSuma en los 12 ítems de Ira
9agrSuma en los 11 ítems de Agresión
10desSuma en los 8 ítems de Conducta desafiante
11antSuma en los 10 ítems de Conducta antisocial
12susSuma en los 6 ítems de Consumo de sustancias
13esqSuma en los 9 ítems de Esquizotipia
14aliSuma en los 8 ítems de Problemas alimentarios
15famSuma en los 7 ítems de Problemas familiares
16escSuma en los 7 ítems de Problemas escolares
17comSuma en los 5 ítems de Problemas comunitarios
18autSuma en los 9 ítems de Autoestima
19socSuma en los 9 ítems de Integración social
20cncSuma en los 4 ítems de Conciencia de cambio
21total_altosNúmero de ítems (de 188) con respuesta ≥ 4
22criticos_countNúmero de ítems críticos con respuesta ≥ 3
23edadEdad del estudiante en años (default 15 si no se provee)
24sexo_nSexo codificado: MASCULINO=0, FEMENINO=1, OTRO=2
Se usan sumas brutas de ítems en lugar de puntuaciones T porque las T requieren tablas de baremos externas y varían por edad y sexo. Las sumas brutas capturan la misma información y hacen el modelo portable sin dependencias externas.

Datos de entrenamiento

El corpus de entrenamiento se almacena en la tabla HistoricoSENA de PostgreSQL. El script consulta únicamente los registros que tienen la cadena de 188 respuestas brutas:
SELECT "respuestas", "edad", "sexo", "semaforo"
FROM "HistoricoSENA"
WHERE "respuestas" IS NOT NULL

Modelo de datos HistoricoSENA

model HistoricoSENA {
  id         String   @id @default(cuid())
  origen     String   @default("importacion")
  fecha      DateTime
  edad       Int
  sexo       String
  baremo     String?
  respuestas String?  // cadena de 188 dígitos
  semaforo   Semaforo // VERDE | AMARILLO | ROJO | ROJO_URGENTE
  // ... 31 campos de escalas T ...
}
El script requiere un mínimo de 50 registros con respuestas IS NOT NULL para iniciar el entrenamiento. Para resultados confiables en validación cruzada estratificada se recomienda al menos 200 registros por clase (VERDE, AMARILLO, ROJO, ROJO_URGENTE), es decir, un corpus de aproximadamente 800 tamizajes con cadena de respuestas completa.

Parámetros del modelo

El algoritmo elegido es RandomForestClassifier de scikit-learn con los siguientes parámetros fijos en entrenar_sena.py:
clf = RandomForestClassifier(
    n_estimators=300,       # 300 árboles de decisión
    max_depth=None,         # Sin límite de profundidad
    class_weight="balanced", # Compensación de clases desbalanceadas
    random_state=42,        # Reproducibilidad
    n_jobs=-1,              # Usa todos los núcleos de CPU
)
ParámetroValorMotivo
n_estimators300Balance entre estabilidad y velocidad de entrenamiento
max_depthNoneÁrboles completos; el bosque compensa el sobreajuste individual
class_weight"balanced"Evita que el modelo ignore clases raras como ROJO_URGENTE
random_state42Resultados reproducibles con los mismos datos
n_jobs-1Paralelización en todos los núcleos disponibles
El README del proyecto indica que XGBoost está en la hoja de ruta para reemplazar Random Forest como clasificador principal. El cambio de algoritmo no requiere modificar la función de features ni el esquema de base de datos; solo el bloque de entrenamiento en entrenar_sena.py y la dependencia en requirements.txt.

Pipeline de entrenamiento

1

Cargar datos desde PostgreSQL

cargar_datos() conecta a la BD usando DIRECT_URL o DATABASE_URL del .env, ejecuta el SELECT sobre HistoricoSENA y devuelve un DataFrame con columnas respuestas, edad, sexo y semaforo.
2

Calcular el vector de features

preparar_datos() aplica calcular_features() a cada cadena de 188 dígitos y concatena las columnas edad y sexo_n para formar la matriz X de dimensión (N, 25). El vector objetivo y contiene las etiquetas de semáforo.
3

Split estratificado 75/25

train_test_split() divide los datos en 75% entrenamiento y 25% prueba (train_size=0.75), usando stratify=y para mantener la proporción de clases en ambas particiones.
4

Validación cruzada estratificada

StratifiedKFold con hasta 5 folds (limitado por la clase menos frecuente) evalúa el modelo sobre el conjunto de entrenamiento. Se reportan cv_mean y cv_std de accuracy.
5

Entrenamiento y evaluación final

El modelo se ajusta sobre X_train, se evalúa con accuracy_score y classification_report sobre X_test, y luego se re-entrena sobre todos los datos (clf.fit(X, y)) para producción.
6

Guardar artefactos

Se serializan tres archivos en ml-api/models/: modelo.pkl (el RandomForest completo), metrics.json (métricas y reporte) y feature_importance.json (importancia de cada feature).

Ejecutar el entrenamiento

cd ml-api
python scripts/entrenar_sena.py
El script requiere que las variables DIRECT_URL o DATABASE_URL estén definidas en el archivo .env en la raíz del proyecto. Salida esperada:
============================================================
PsicoScan ML — Entrenamiento Random Forest
============================================================
Conectando a la base de datos…
Registros con respuestas: 5302
Distribución de clases:
VERDE          4231
AMARILLO        892
ROJO            156
ROJO_URGENTE     23

Calculando features desde respuestas brutas…
Matriz de features: 5302 muestras × 25 features

Split 75/25: 3976 entrenamiento, 1326 prueba
Ejecutando 5-fold StratifiedKFold CV…
CV accuracy: 0.9820 ± 0.0032
Entrenando modelo final sobre conjunto de entrenamiento…

Test accuracy (25% holdout): 0.9949
Re-entrenando sobre todos los datos para producción…

Modelo guardado: ml-api/models/modelo.pkl
Métricas guardadas: ml-api/models/metrics.json
Feature importance guardada: ml-api/models/feature_importance.json

✓ Entrenamiento completado.

Artefactos de salida

ArchivoContenido
ml-api/models/modelo.pklRandomForest serializado con joblib (300 árboles)
ml-api/models/metrics.jsonaccuracy, cv_mean, cv_std, train_size, n_registros, report, features, n_estimators
ml-api/models/feature_importance.jsonLista ordenada de {feature, importance}

Ejemplo de metrics.json

{
  "accuracy": 0.9949,
  "cv_mean": 0.9820,
  "cv_std": 0.0032,
  "train_size": 0.75,
  "n_registros": 5302,
  "n_estimators": 300,
  "features": ["dep", "ans", "asc", "som", "pst", "obs", "ate", "hip",
               "ira", "agr", "des", "ant", "sus", "esq", "ali", "fam",
               "esc", "com", "aut", "soc", "cnc",
               "total_altos", "criticos_count", "edad", "sexo_n"],
  "report": {
    "VERDE":        { "precision": 0.99, "recall": 0.99, "f1-score": 0.99, "support": 1058 },
    "AMARILLO":     { "precision": 0.97, "recall": 0.96, "f1-score": 0.97, "support": 223 },
    "ROJO":         { "precision": 0.98, "recall": 0.99, "f1-score": 0.98, "support": 39 },
    "ROJO_URGENTE": { "precision": 1.00, "recall": 1.00, "f1-score": 1.00, "support": 6 }
  }
}

Registro de entrenamientos (EntrenamientoML)

Cada ejecución exitosa del script puede registrarse en la tabla EntrenamientoML de PostgreSQL para trazabilidad:
model EntrenamientoML {
  id          String   @id @default(cuid())
  fecha       DateTime @default(now())
  nRegistros  Int
  trainSize   Float
  accuracy    Float
  cvMean      Float
  cvStd       Float
  nEstimators Int
  report      Json     // classification_report por clase
  importance  Json     // [{ feature, importance }, ...]
}

Carga del modelo en FastAPI

Al arrancar FastAPI, ClasificadorML.__init__() llama a _cargar_modelo(), que verifica la existencia de modelo.pkl y lo carga si está disponible:
def _cargar_modelo(self):
    base = os.path.dirname(__file__)
    ruta_modelo   = os.path.join(base, "../models/modelo.pkl")
    ruta_metricas = os.path.join(base, "../models/metrics.json")

    if os.path.exists(ruta_modelo):
        self.modelo  = joblib.load(ruta_modelo)
        self.version = "1.0.0-ml"

    if os.path.exists(ruta_metricas):
        with open(ruta_metricas, "r", encoding="utf-8") as f:
            self.metricas = json.load(f)
Si modelo.pkl no existe al momento del arranque, el clasificador opera con las reglas heurísticas (version = "0.1.0-reglas") sin interrupciones.

Fallback al motor de reglas

Hay dos situaciones en que el clasificador recurre al motor de reglas aunque el modelo esté cargado:
  1. Sin cadena de respuestas: el campo respuestas del TamizajeInput es None. En ese caso _predecir_ml() delega a _predecir_reglas() automáticamente.
  2. Sin modelo: modelo.pkl no existe en disco; self.modelo es None.
def _predecir_ml(self, datos: TamizajeInput) -> TamizajeOutput:
    if not datos.respuestas:
        return self._predecir_reglas(datos)  # fallback
    features = _features_desde_respuestas(datos.respuestas, datos.edad, datos.sexo)
    semaforo = self.modelo.predict([features])[0]
    proba    = self.modelo.predict_proba([features])[0].max()
    # ...
Las peticiones al endpoint POST /api/v1/clasificar que incluyen únicamente índices T (sin respuestas) siempre usarán el motor de reglas, con o sin modelo entrenado.

Build docs developers (and LLMs) love