#@title Estilo de la clase (ejecutar, no hace falta leer) {display-mode: "form"}
from IPython.display import HTML, display
display(HTML(r"""
<style>
@import url('https://fonts.googleapis.com/css2?family=Work+Sans:wght@400;600&family=Amiri:wght@400;700&display=swap');
.rendered_html, .markdown, .cell .text_cell_render { font-family:'Work Sans',system-ui,sans-serif; color:#122535; }
.rendered_html h1,.rendered_html h2,.rendered_html h3 { font-family:'Amiri',Georgia,serif; color:#00529B; }
.rendered_html h2 { border-bottom:2px solid #00529B; padding-bottom:.2em; }
.rendered_html a { color:#00529B; }
.rendered_html table th { background:#00529B; color:#fff; }
.caja { background:#f3f5f7; border-left:4px solid #00529B; padding:.7em 1em; border-radius:4px; }
.ojo { background:#fdf3ec; border-left:4px solid #C8651B; padding:.7em 1em; border-radius:4px; }
</style>
"""))Clase 12 · Modelos de lenguaje (LLMs)
Analítica de Datos · Maestría en Ciencias del Comportamiento · Universidad de San Andrés
Primavera 2026 · 24/10/2026
En las slides vimos qué hace un LLM por dentro: parte el texto en tokens, los convierte en vectores, los deja mirarse entre sí con la atención y, al final, da una probabilidad para cada palabra que puede venir. Hoy lo usamos como instrumento de medición: le damos una consigna y un comentario de la encuesta de Nimbus, y nos devuelve una etiqueta, igual que lo haría una persona que anota.
Al terminar esta notebook vas a poder:
- pedirle algo a un LLM desde Python, por una API, y leer su respuesta;
- ver qué cambia con la temperatura;
- escribir una consigna de anotación con definiciones y un formato de respuesta fijo;
- anotar el sentimiento y el tema de comentarios reales de Nimbus;
- validar esas anotaciones contra una referencia, como cualquier otro instrumento.
Contenido
| # | Tema | La idea en una línea |
|---|---|---|
| 1 | Conectarse a un LLM | un pedido por internet, una respuesta |
| 2 | La temperatura | cuánto se sortea la próxima palabra |
| 3 | Una consigna de anotación | definir como para un juez humano |
| 4 | Anotar el sentimiento | y compararlo con la clase 11 |
| 5 | Anotar el tema | lo que la clase 11 no podía hacer sin etiquetas |
| 6 | Tu propia dimensión | inventar algo para medir y validarlo |
1. Conectarse a un LLM
Hasta ahora todos los modelos corrían en nuestra computadora (o en la de Colab). Un LLM grande no entra ahí: corre en los servidores de una empresa, y le mandamos el texto por internet. Esa forma de pedirle algo a un programa que está en otro lado se llama API.
Vamos a usar OpenRouter, un servicio que da acceso a muchos modelos con una sola cuenta, y un modelo gratis: minimax/minimax-m3.1-flash:free.
La clave de OpenRouter. Para usar la API hace falta una clave (empieza con sk-or-), que identifica quién hace el pedido. Hoy usamos una clave compartida de la materia, que ya está puesta en la notebook: no tenés que hacer nada para arrancar.
Tutorial: armarte tu propia clave (opcional; para seguir usando LLMs después de la materia, o si la compartida se queda sin pedidos)
- Entrá a openrouter.ai y tocá Sign up (podés entrar con tu cuenta de Google). Es gratis y no pide tarjeta.
- Arriba a la derecha, abrí el menú de tu cuenta y andá a Keys. Tocá Create API Key, ponele un nombre (por ejemplo
analitica-udesa) y dejá vacío el límite de crédito. - Copiá la clave en ese momento: OpenRouter la muestra una sola vez. Si la perdés, borrala y creá otra.
- Andá a Settings → Privacy y habilitá los modelos gratuitos (las opciones de free endpoints). Sin esto, los pedidos al modelo
:freedevuelven un error que habla de data policy. - En Colab, tocá el ícono de la llave en la barra de la izquierda (Secrets) → Add new secret. En Name escribí
OPENROUTER_API_KEY, en Value pegá la clave, y activá Notebook access. - En la celda de la clave, más abajo, cambiá
USAR_MI_CLAVE = FalseporUSAR_MI_CLAVE = Truey volvé a correrla.
Tu clave personal es como una contraseña: guardala en los Secrets, no la pegues en una celda ni la compartas. La de la materia es la excepción: tiene un tope de gasto y se da de baja al terminar el cuatrimestre.
Los modelos gratis tienen límites por clave: 20 pedidos por minuto, y por día 50 (o 1000 si la cuenta alguna vez cargó crédito). La clave de la materia la compartimos entre todos, así que hoy trabajamos con una muestra de comentarios y la notebook guarda cada respuesta para no gastar pedidos si volvés a correr una celda. Si un pedido devuelve el error 429 (too many requests), esperá un minuto y volvé a correr la celda, o pasate a tu propia clave.
import json
import os
import sys
import time
import numpy as np
import pandas as pd
import requests
SEED = 12
BASE = "https://raw.githubusercontent.com/tomdamelio/analitica_de_datos_alumnos/main/data/toy-nimbus/"
comentarios = pd.read_csv(BASE + "nimbus_comentarios.csv")
patrones = pd.read_csv(BASE + "nimbus_patrones.csv")
temas_reales = pd.read_csv(BASE + "nimbus_comentarios_temas.csv")
pd.set_option("display.max_colwidth", 100)
comentarios.head()| empleado_id | comentario | |
|---|---|---|
| 0 | 1 | La verdad, tengo la agenda llena de reuniones. |
| 1 | 2 | sinceramente, falta liderazgo y nadie toma decisiones. me escriben a cualquier hora, incluso de ... |
| 2 | 3 | estoy agotado, la carga es enorme. mi equipo quedó desarmado, se fue la mitad |
| 3 | 4 | Sin comentarios. |
| 4 | 5 | La verdad, mi lider me apoya y me da feedback. |
Además de los comentarios y la nota de recomendación de la clase pasada, hoy tenemos una tabla nueva: nimbus_comentarios_temas.csv. Como Nimbus es simulada, sabemos de qué tema habla cada comentario y qué tono tiene, porque así se generó. Es la referencia contra la que vamos a validar al LLM. Con datos reales, esa referencia la arma una persona anotando a mano una parte de los textos.
temas_reales.head()| empleado_id | temas | tono | ironia | |
|---|---|---|---|---|
| 0 | 1 | reuniones | negativo | 0 |
| 1 | 2 | carga;liderazgo | negativo | 0 |
| 2 | 3 | carga;equipo | negativo | 0 |
| 3 | 4 | NaN | sin contenido | 0 |
| 4 | 5 | liderazgo | positivo | 0 |
Ahora la clave. Por defecto se usa la clave de la materia. Si te armaste la tuya (ver el tutorial de arriba), poné USAR_MI_CLAVE = True: en Colab se lee de los Secrets; en una computadora propia, de una variable de entorno con el mismo nombre. Mostramos solo el principio, para comprobar que se cargó.
USAR_MI_CLAVE = False
CLAVE_MATERIA = "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"
if USAR_MI_CLAVE and "google.colab" in sys.modules:
from google.colab import userdata
CLAVE = userdata.get("OPENROUTER_API_KEY")
elif USAR_MI_CLAVE:
CLAVE = os.environ["OPENROUTER_API_KEY"]
else:
CLAVE = CLAVE_MATERIA
print("clave cargada:", CLAVE[:8] + "...")clave cargada: sk-or-v1...
Un pedido a la API es un mensaje con cuatro partes:
- la dirección a la que se manda (
URL); - el modelo que queremos usar;
- los mensajes: lo que le escribimos, con su rol (
useres quien pregunta); - los parámetros, como la
temperature.
La respuesta vuelve en un formato de datos llamado JSON, que Python lee como un diccionario. El texto que escribió el modelo está en ["choices"][0]["message"]["content"]. Probemos con el ejemplo de las slides: el Mundial 2022.
URL = "https://openrouter.ai/api/v1/chat/completions"
MODELO = "minimax/minimax-m3.1-flash:free"
pedido = {
"model": MODELO,
"temperature": 0,
"messages": [
{"role": "user", "content": "¿Qué selección ganó el Mundial de 2022? Respondé en cinco palabras o menos."},
],
}
respuesta = requests.post(URL, headers={"Authorization": "Bearer " + CLAVE}, json=pedido, timeout=180)
respuesta.raise_for_status()
contenido = respuesta.json()
print(contenido["choices"][0]["message"]["content"])
print("tokens usados:", contenido["usage"])Argentina.
tokens usados: {'prompt_tokens': 220, 'completion_tokens': 35, 'total_tokens': 255, 'cost': 0, 'is_byok': False, 'prompt_tokens_details': {'cached_tokens': 195, 'cache_write_tokens': 0, 'audio_tokens': 0, 'video_tokens': 0}, 'cost_details': {'upstream_inference_cost': 0, 'upstream_inference_prompt_cost': 0, 'upstream_inference_completions_cost': 0}, 'completion_tokens_details': {'reasoning_tokens': 33, 'image_tokens': 0, 'audio_tokens': 0}}
raise_for_status() frena la celda con un error si el pedido falló (una clave mal cargada, el límite del día agotado), así no seguimos con una respuesta vacía.
De acá en adelante vamos a hacer muchos pedidos iguales a este, así que los envolvemos en una función, preguntar. Hace tres cosas además del pedido:
- Guarda cada respuesta en un archivo (
respuestas_llm.json). Si volvés a hacer la misma pregunta, la lee del archivo y no gasta un pedido. - Espera unos segundos después de cada pedido nuevo, para no pasar los 20 por minuto.
- Acepta un rol (
system): un texto que le dice al modelo quién es y qué tiene que hacer, aparte de la pregunta.
El argumento repeticion sirve para pedir la misma pregunta varias veces a propósito (lo usamos con la temperatura): cada repetición se guarda por separado.
ARCHIVO_RESPUESTAS = "respuestas_llm.json"
ESPERA_SEGUNDOS = 3.5
if os.path.exists(ARCHIVO_RESPUESTAS):
with open(ARCHIVO_RESPUESTAS) as f:
GUARDADAS = json.load(f)
else:
GUARDADAS = {}
def preguntar(sistema, texto, temperatura=0, repeticion=0, formato_json=False):
"""Le manda un pedido al LLM y devuelve el texto de su respuesta (guardado en disco)."""
clave = json.dumps([MODELO, sistema, texto, temperatura, repeticion, formato_json])
if clave in GUARDADAS:
return GUARDADAS[clave]
pedido = {
"model": MODELO,
"temperature": temperatura,
"messages": [
{"role": "system", "content": sistema},
{"role": "user", "content": texto},
],
}
if formato_json:
pedido["response_format"] = {"type": "json_object"}
respuesta = requests.post(URL, headers={"Authorization": "Bearer " + CLAVE}, json=pedido, timeout=180)
respuesta.raise_for_status()
contenido = respuesta.json()["choices"][0]["message"]["content"]
GUARDADAS[clave] = contenido
with open(ARCHIVO_RESPUESTAS, "w") as f:
json.dump(GUARDADAS, f, ensure_ascii=False, indent=1)
time.sleep(ESPERA_SEGUNDOS)
return contenido2. La temperatura
En las slides vimos que el modelo no elige una palabra: da una probabilidad para cada una, y la palabra se sortea. La temperatura decide cuánto:
- con temperatura 0 se elige siempre la más probable, y la respuesta no debería cambiar;
- con temperaturas más altas, las palabras menos probables tienen más chances, y cada respuesta sale distinta.
Le pedimos dos veces lo mismo con cada temperatura (son 4 pedidos).
SISTEMA_LIBRE = "Respondé en castellano rioplatense, en una sola oración."
PEDIDO_LIBRE = "Escribí una oración sobre los lunes en la oficina."
for temperatura in [0, 1.2]:
for repeticion in [0, 1]:
texto = preguntar(SISTEMA_LIBRE, PEDIDO_LIBRE, temperatura=temperatura, repeticion=repeticion)
print("T =", temperatura, "| vez", repeticion + 1, "|", texto.strip())T = 0 | vez 1 | Los lunes en la oficina arrancan con un café y meetings que parecen no terminar nunca.
T = 0 | vez 2 | Los lunes en la oficina arrancan con café y un Infosys.
T = 1.2 | vez 1 | Los lunes en la oficina arrancan con café y una agenda llena de reuniones.
T = 1.2 | vez 2 | Los lunes en la oficina suelen ser larguísimos, entre reuniones y cafecito.
Con temperatura alta, lo esperable es que las dos oraciones salgan distintas. Con temperatura 0 deberían salir iguales, pero no está garantizado: cuando armamos esta notebook, las dos de temperatura 0 salieron distintas. En los servidores grandes las cuentas se reparten entre muchas máquinas y aparecen diferencias mínimas; si dos palabras están casi empatadas, alcanza para que gane otra, y desde ahí el texto sigue por otro camino. Por eso, aun con temperatura 0, conviene guardar cada respuesta (es lo que hace preguntar) y no asumir que se va a repetir igual. Para anotar queremos que el resultado dependa del texto, no de un sorteo, así que vamos a usar temperatura 0: no elimina la variación, pero la achica mucho.
Otra opción, la del paper de las slides. En Bruno et al. (2026) se usó temperatura 1, repitiendo cada anotación diez veces y promediando, y eso coincidió más con las personas que una sola anotación con temperatura 0. Las dos estrategias son válidas; la segunda cuesta diez veces más pedidos, así que hoy usamos la primera.
3. Una consigna de anotación
Para anotar, la consigna se escribe como se la daríamos a una persona que tiene que leer los comentarios y etiquetarlos: qué etiquetas hay, qué significa cada una y en qué formato contestar. Tres reglas que vimos con el paper:
- Definiciones explícitas, las mismas que usaríamos con un juez humano.
- Un comentario por pedido, sin historial: así uno no contamina al siguiente.
- Un formato fijo de respuesta. Pedimos JSON, que Python convierte directo en un diccionario, y activamos
formato_json=True, que obliga al modelo a devolver JSON válido.
Los temas y sus definiciones son el libro de códigos de la anotación. Los escribimos en una tabla, para poder leerlos y cambiarlos fácil.
TEMAS = {
"carga": "volumen de trabajo, horas, horarios, fines de semana, cansancio o descanso",
"reuniones": "cantidad, duración o utilidad de las reuniones",
"crecimiento": "carrera, ascensos, sentirse valorado, sentido o motivación con el trabajo",
"aprendizaje": "onboarding, capacitación, cursos, mentores, ser nuevo",
"equipo": "compañeros, clima del equipo o entre áreas, competencia interna, el proyecto",
"liderazgo": "jefes o líderes, feedback, cómo se toman y se comunican las decisiones",
"salario": "sueldo, aumentos, beneficios",
"flexibilidad": "home office, organizar los propios horarios, ir a la oficina",
}
lineas_temas = ""
for tema in TEMAS:
lineas_temas = lineas_temas + "- " + tema + ": " + TEMAS[tema] + "\n"
print(lineas_temas)- carga: volumen de trabajo, horas, horarios, fines de semana, cansancio o descanso
- reuniones: cantidad, duración o utilidad de las reuniones
- crecimiento: carrera, ascensos, sentirse valorado, sentido o motivación con el trabajo
- aprendizaje: onboarding, capacitación, cursos, mentores, ser nuevo
- equipo: compañeros, clima del equipo o entre áreas, competencia interna, el proyecto
- liderazgo: jefes o líderes, feedback, cómo se toman y se comunican las decisiones
- salario: sueldo, aumentos, beneficios
- flexibilidad: home office, organizar los propios horarios, ir a la oficina
SISTEMA = "Sos una persona experta en anotar respuestas abiertas de encuestas de clima laboral. Respondés solo con JSON."
CONSIGNA = """Leé el comentario de un empleado y anotalo.
Sentimiento: "positivo" si el comentario elogia a la empresa o a su trabajo; "negativo" si se
queja. La ironía cuenta por lo que quiere decir, no por las palabras que usa.
Temas (uno o más, solo de esta lista):
LISTA_DE_TEMAS
Formato de la respuesta:
{"sentimiento": "positivo" o "negativo", "temas": ["tema", ...]}
Comentario: "COMENTARIO_AQUI"
"""
def consigna_para(comentario):
"""La consigna completa para un comentario."""
texto = CONSIGNA.replace("LISTA_DE_TEMAS", lineas_temas)
return texto.replace("COMENTARIO_AQUI", comentario)
print(consigna_para("Genial, otra reunión que podría haber sido un mail."))Leé el comentario de un empleado y anotalo.
Sentimiento: "positivo" si el comentario elogia a la empresa o a su trabajo; "negativo" si se
queja. La ironía cuenta por lo que quiere decir, no por las palabras que usa.
Temas (uno o más, solo de esta lista):
- carga: volumen de trabajo, horas, horarios, fines de semana, cansancio o descanso
- reuniones: cantidad, duración o utilidad de las reuniones
- crecimiento: carrera, ascensos, sentirse valorado, sentido o motivación con el trabajo
- aprendizaje: onboarding, capacitación, cursos, mentores, ser nuevo
- equipo: compañeros, clima del equipo o entre áreas, competencia interna, el proyecto
- liderazgo: jefes o líderes, feedback, cómo se toman y se comunican las decisiones
- salario: sueldo, aumentos, beneficios
- flexibilidad: home office, organizar los propios horarios, ir a la oficina
Formato de la respuesta:
{"sentimiento": "positivo" o "negativo", "temas": ["tema", ...]}
Comentario: "Genial, otra reunión que podría haber sido un mail."
consigna_para solo reemplaza dos marcas del texto: la lista de temas y el comentario. Probemos con el comentario irónico de las slides. json.loads convierte la respuesta en un diccionario.
respuesta = preguntar(SISTEMA, consigna_para("Genial, otra reunión que podría haber sido un mail."), formato_json=True)
print(respuesta)
anotacion = json.loads(respuesta)
print("sentimiento:", anotacion["sentimiento"])
print("temas:", anotacion["temas"]){"sentimiento":"negativo","temas":["reuniones"]}
sentimiento: negativo
temas: ['reuniones']
4. Anotar el sentimiento
Armamos la tabla de trabajo: el comentario, la nota de recomendación y la referencia de temas y tono. Como en la clase 11, sacamos las respuestas sin contenido y definimos recomienda como una nota de 7 o más.
datos = comentarios.merge(patrones[["empleado_id", "recomendaria_0a10"]], on="empleado_id")
datos = datos.merge(temas_reales, on="empleado_id")
datos = datos[datos["tono"] != "sin contenido"]
datos["recomienda"] = (datos["recomendaria_0a10"] >= 7).astype(int)
print(len(datos), "comentarios con contenido")
datos["tono"].value_counts()551 comentarios con contenido
tono
positivo 208
negativo 205
mixto 138
Name: count, dtype: int64
El tono de la referencia puede ser positivo, negativo o mixto (un comentario con una parte buena y otra mala, como “El sueldo está bien. Pero no veo posibilidades de crecer”).
Para no agotar los pedidos de la clave compartida, anotamos una muestra de 25 comentarios: 20 al azar y 5 irónicos elegidos a propósito, porque al azar casi no saldría ninguno (son 50 de 551) y queremos ver qué hace el modelo con la ironía. Por eso los porcentajes de esta muestra no representan a todos los comentarios. Con tu propia clave podés subir N_AL_AZAR y anotar más.
N_AL_AZAR = 20
N_IRONICOS = 5
sin_ironia = datos[datos["ironia"] == 0]
con_ironia = datos[datos["ironia"] == 1]
muestra = pd.concat([
sin_ironia.sample(N_AL_AZAR, random_state=SEED),
con_ironia.sample(N_IRONICOS, random_state=SEED),
])
N_MUESTRA = len(muestra)
print(N_MUESTRA, "comentarios en la muestra, irónicos:", muestra["ironia"].sum())
muestra[["comentario", "recomendaria_0a10", "tono", "temas"]].head()25 comentarios en la muestra, irónicos: 5
| comentario | recomendaria_0a10 | tono | temas | |
|---|---|---|---|---|
| 329 | La verdad, no veo posibilidades de crecer. El clima en el equipo es malo. | 3 | negativo | crecimiento;equipo |
| 465 | Pagan poco para lo que exigen. Nunca tuve que quedarme hasta tarde. | 5 | mixto | carga;salario |
| 204 | En general, el sueldo está bien para el mercado. Mi líder me apoya y me da feedback. Trabajo con... | 8 | positivo | equipo;liderazgo;salario |
| 44 | La verdad, terminé el año fundido. El clima en el equipo es malo. | 3 | negativo | carga;equipo |
| 508 | nada, el sueldo esta bien para el mercado. tengo un mentor que me ayuda mucho | 8 | positivo | aprendizaje;salario |
Ahora sí, un pedido por comentario (tarda unos minutos: el modelo piensa un poco antes de contestar, y esperamos entre pedido y pedido). Guardamos el sentimiento y la lista de temas de cada respuesta.
sentimientos = []
temas_llm = []
numero = 0
for comentario in muestra["comentario"]:
respuesta = preguntar(SISTEMA, consigna_para(comentario), formato_json=True)
anotacion = json.loads(respuesta)
sentimientos.append(anotacion["sentimiento"])
temas_llm.append(anotacion["temas"])
numero = numero + 1
print(numero, "de", N_MUESTRA, "|", anotacion["sentimiento"], "|", comentario[:70])
muestra["sentimiento_llm"] = sentimientos
muestra["temas_llm"] = temas_llm1 de 25 | negativo | La verdad, no veo posibilidades de crecer. El clima en el equipo es ma
2 de 25 | negativo | Pagan poco para lo que exigen. Nunca tuve que quedarme hasta tarde.
3 de 25 | positivo | En general, el sueldo está bien para el mercado. Mi líder me apoya y m
4 de 25 | negativo | La verdad, terminé el año fundido. El clima en el equipo es malo.
5 de 25 | positivo | nada, el sueldo esta bien para el mercado. tengo un mentor que me ayud
6 de 25 | negativo | En general, tengo la agenda llena de reuniones. Pero mi trabajo tiene
7 de 25 | positivo | Nada, nunca tuve que quedarme hasta tarde.
8 de 25 | negativo | Sinceramente, aprendo un montón todos los días. Pero mi equipo quedó d
9 de 25 | negativo | En general, trabajo casi todos los fines de semana. Mi equipo quedó de
10 de 25 | negativo | Estoy desmotivado. Pero las weeklys son utiles.
11 de 25 | negativo | En general, cero flexibilidad con los horarios. Estoy quemado.
12 de 25 | positivo | me ascendieron este año. trabajo con gente muy capaz
13 de 25 | negativo | Nunca me faltó apoyo del equipo. Las reuniones son cortas y sirven. Ha
14 de 25 | negativo | La verdad, no me siento valorado. Pero el sueldo está bien para el mer
15 de 25 | negativo | La verdad, trabajo casi todos los fines de semana. Demasiadas reunione
16 de 25 | positivo | En general, me encanta el proyecto en el que estoy.
17 de 25 | negativo | en general, nos sacaron el home office. no hay buen clima entre las ar
18 de 25 | positivo | Aprendo un monton todos los dias. Siempre hay alguien para darte una m
19 de 25 | negativo | La verdad, el clima en el equipo es malo.
20 de 25 | negativo | No hay buen clima entre las areas.
21 de 25 | negativo | Genial enterarme de los cambios por el chat. No me siento valorado.
22 de 25 | negativo | sinceramente, paso el dia en reuniones que no sirven para nada. excele
23 de 25 | negativo | La verdad, buenísimo hacer exactamente lo mismo hace cinco años. No me
24 de 25 | negativo | Hay mucha competencia interna. Me encanta viajar una hora para hacer z
25 de 25 | negativo | El onboarding fue muy bueno. Mi equipo es excelente. Pero me encanta v
¿Cuánto coincide? Lo comparamos contra dos cosas distintas:
- el tono del comentario, que es lo que le pedimos que mida. Solo en los que no son mixtos, porque en un comentario mixto las dos respuestas son defendibles;
- la nota de recomendación, que es lo que intentaba predecir el clasificador de la clase 11.
no_mixtos = muestra[muestra["tono"] != "mixto"]
coincide_tono = (no_mixtos["sentimiento_llm"] == no_mixtos["tono"]).mean()
positivo_llm = (muestra["sentimiento_llm"] == "positivo").astype(int)
coincide_nota = (positivo_llm == muestra["recomienda"]).mean()
print("coincide con el tono:", round(coincide_tono, 3), "en", len(no_mixtos), "comentarios no mixtos")
print("coincide con recomendar:", round(coincide_nota, 3), "en", len(muestra), "comentarios")coincide con el tono: 1.0 en 17 comentarios no mixtos
coincide con recomendar: 0.84 en 25 comentarios
Para comparar con la clase 11 necesitamos el clasificador de bolsa de palabras sobre los mismos comentarios. Lo entrenamos con todos los comentarios que no están en la muestra (así nunca vio los que va a predecir) y predecimos la muestra. Es el mismo modelo de la clase pasada, sin la limpieza de stopwords para hacerlo corto.
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
entrenamiento = datos.drop(muestra.index)
bolsa = make_pipeline(CountVectorizer(), LogisticRegression(max_iter=1000))
bolsa.fit(entrenamiento["comentario"], entrenamiento["recomienda"])
pred_bolsa = bolsa.predict(muestra["comentario"])
coincide_bolsa = (pred_bolsa == muestra["recomienda"]).mean()
vara = max(muestra["recomienda"].mean(), 1 - muestra["recomienda"].mean())
print("bolsa de palabras, acierto en recomendar:", round(coincide_bolsa, 3))
print("LLM, acierto en recomendar: ", round(coincide_nota, 3))
print("vara (la clase más común): ", round(vara, 3))bolsa de palabras, acierto en recomendar: 0.8
LLM, acierto en recomendar: 0.84
vara (la clase más común): 0.6
Ojo con leer estos números de más. Son 25 comentarios: un solo acierto mueve el porcentaje 4 puntos. Además, el LLM y la bolsa no miden lo mismo. La bolsa aprendió a predecir la nota a partir de más de 500 comentarios con su nota; el LLM no vio ninguna nota, mide el tono del texto. Hay gente que se queja y aun así pone un 8, y eso el LLM no tiene forma de saberlo.
Miremos los desacuerdos con el tono: ahí se aprende qué entiende y qué no. Si en tu corrida no hay ninguno (en la nuestra coincidió en los 17), no es que el modelo sea perfecto: los comentarios de Nimbus son simulados, armados con frases cortas y claras. Con comentarios reales, más largos y ambiguos, los desacuerdos aparecen siempre, y son lo más informativo de la validación.
desacuerdos = no_mixtos[no_mixtos["sentimiento_llm"] != no_mixtos["tono"]]
print(len(desacuerdos), "desacuerdos con el tono")
desacuerdos[["comentario", "tono", "sentimiento_llm", "ironia"]]0 desacuerdos con el tono
| comentario | tono | sentimiento_llm | ironia |
|---|
ironicos = muestra[muestra["ironia"] == 1]
ironicos[["comentario", "tono", "sentimiento_llm"]]| comentario | tono | sentimiento_llm | |
|---|---|---|---|
| 357 | Genial enterarme de los cambios por el chat. No me siento valorado. | negativo | negativo |
| 517 | sinceramente, paso el dia en reuniones que no sirven para nada. excelente el aumento, me alcanza... | negativo | negativo |
| 120 | La verdad, buenísimo hacer exactamente lo mismo hace cinco años. No me puedo quejar del sueldo. | mixto | negativo |
| 487 | Hay mucha competencia interna. Me encanta viajar una hora para hacer zooms desde la oficina. | negativo | negativo |
| 427 | El onboarding fue muy bueno. Mi equipo es excelente. Pero me encanta viajar una hora para hacer ... | mixto | negativo |
5. Anotar el tema
Lo que la clase 11 no podía hacer: con la bolsa de palabras, para clasificar el tema hubiéramos necesitado cientos de comentarios ya etiquetados por tema. El LLM lo hace con la lista de definiciones y nada más.
En la misma respuesta ya vinieron los temas. Los comparamos con la referencia de a un comentario: qué temas tenía de verdad, cuáles marcó el modelo, y cuáles coinciden.
acertados = 0
reales_total = 0
marcados_total = 0
filas = []
for indice, fila in muestra.iterrows():
reales = fila["temas"].split(";")
marcados = fila["temas_llm"]
en_comun = []
for tema in marcados:
if tema in reales:
en_comun.append(tema)
acertados = acertados + len(en_comun)
reales_total = reales_total + len(reales)
marcados_total = marcados_total + len(marcados)
filas.append({"reales": reales, "marcados": marcados, "en común": en_comun})
print("de los temas reales, encontró:", round(acertados / reales_total, 3), f"({acertados} de {reales_total})")
print("de los que marcó, eran reales:", round(acertados / marcados_total, 3), f"({acertados} de {marcados_total})")
comparacion = pd.DataFrame(filas, index=muestra.index)
comparacion["comentario"] = muestra["comentario"]
comparacion.head(10)de los temas reales, encontró: 1.0 (53 de 53)
de los que marcó, eran reales: 1.0 (53 de 53)
| reales | marcados | en común | comentario | |
|---|---|---|---|---|
| 329 | [crecimiento, equipo] | [crecimiento, equipo] | [crecimiento, equipo] | La verdad, no veo posibilidades de crecer. El clima en el equipo es malo. |
| 465 | [carga, salario] | [salario, carga] | [salario, carga] | Pagan poco para lo que exigen. Nunca tuve que quedarme hasta tarde. |
| 204 | [equipo, liderazgo, salario] | [salario, liderazgo, equipo] | [salario, liderazgo, equipo] | En general, el sueldo está bien para el mercado. Mi líder me apoya y me da feedback. Trabajo con... |
| 44 | [carga, equipo] | [carga, equipo] | [carga, equipo] | La verdad, terminé el año fundido. El clima en el equipo es malo. |
| 508 | [aprendizaje, salario] | [salario, aprendizaje] | [salario, aprendizaje] | nada, el sueldo esta bien para el mercado. tengo un mentor que me ayuda mucho |
| 47 | [reuniones, crecimiento, liderazgo] | [reuniones, crecimiento, liderazgo] | [reuniones, crecimiento, liderazgo] | En general, tengo la agenda llena de reuniones. Pero mi trabajo tiene sentido. Las decisiones ba... |
| 306 | [carga] | [carga] | [carga] | Nada, nunca tuve que quedarme hasta tarde. |
| 496 | [aprendizaje, equipo] | [aprendizaje, equipo] | [aprendizaje, equipo] | Sinceramente, aprendo un montón todos los días. Pero mi equipo quedó desarmado, se fue la mitad. |
| 7 | [carga, equipo] | [carga, equipo] | [carga, equipo] | En general, trabajo casi todos los fines de semana. Mi equipo quedó desarmado, se fue la mitad. |
| 196 | [reuniones, crecimiento] | [crecimiento, reuniones] | [crecimiento, reuniones] | Estoy desmotivado. Pero las weeklys son utiles. |
Las dos medidas responden preguntas distintas, igual que la sensibilidad y la precisión de la clase 6:
- de los temas reales, cuántos encontró: si es bajo, se le escapan temas;
- de los que marcó, cuántos eran reales: si es bajo, marca temas de más.
En nuestra corrida las dos dieron perfecto. De nuevo, es una señal de que los comentarios simulados son fáciles, no de que el modelo nunca se equivoque: un comentario real mezcla temas, los menciona de costado o no nombra ninguno. Cuando el modelo marca un tema “de más”, conviene leer el comentario antes de culparlo: a veces la definición del libro de códigos era ambigua (¿“me encanta el proyecto” es sobre el equipo o sobre el crecimiento?). Mejorar las definiciones es parte del trabajo, igual que con anotadores humanos.
6. Tu propia dimensión
✏️ Consigna 1: tus comentarios
Escribí 3 comentarios que podría haber escrito un empleado de Nimbus, y anotalos con la misma consigna. Buscá uno difícil: una ironía, una frase hecha (“estoy hecho pomada”), o algo como “no me puedo quejar”. ¿Acierta?
Pista: preguntar(SISTEMA, consigna_para(comentario), formato_json=True) devuelve el texto, y json.loads lo convierte en diccionario.
# TODO: escribí tres comentarios
mios = [
___,
]
for comentario in mios:
anotacion = json.loads(preguntar(SISTEMA, consigna_para(comentario), formato_json=___))
print(anotacion["sentimiento"], anotacion["temas"], "|", comentario)✏️ Consigna 2: una dimensión nueva
Lo potente de anotar con una consigna es que la dimensión la elegís vos. Inventá una pregunta que te interese sobre los comentarios, por ejemplo: ¿la queja es sobre algo que la empresa podría cambiar?, ¿el comentario habla de una persona concreta?, ¿está escrito con enojo?.
- Escribí la definición en
MI_CONSIGNA, con las respuestas posibles y el formato JSON. - Anotá los primeros 8 comentarios de la muestra (son 8 pedidos).
- Validá: leé vos esos 8 y anotá a mano qué hubieras respondido. ¿En cuántos coinciden?
Es exactamente lo que hicimos en el paper, en chico.
# TODO: escribí tu pregunta, sus respuestas posibles y el formato
MI_CONSIGNA = """Leé el comentario de un empleado.
Pregunta: ___
Formato de la respuesta:
{"___": ___}
Comentario: "COMENTARIO_AQUI"
"""
respuestas_mias = []
for comentario in muestra["comentario"].head(8):
texto = MI_CONSIGNA.replace("COMENTARIO_AQUI", comentario)
anotacion = json.loads(preguntar(SISTEMA, texto, formato_json=True))
respuestas_mias.append(anotacion["___"])
pd.DataFrame({"comentario": muestra["comentario"].head(8), "LLM": respuestas_mias})Para el paso 3, escribí tus 8 respuestas en el mismo orden y contá las coincidencias.
# TODO: tus 8 respuestas, en el mismo orden que la tabla de arriba
mis_respuestas_a_mano = [___]
coinciden = 0
for i in range(len(mis_respuestas_a_mano)):
if respuestas_mias[i] == mis_respuestas_a_mano[i]:
coinciden = coinciden + 1
print("coinciden", coinciden, "de", len(mis_respuestas_a_mano))Cierre
Lo que te llevás
- Un LLM se usa desde código con un pedido a una API: modelo, mensajes (
systemyuser) y parámetros. La respuesta vuelve como JSON. - La temperatura decide cuánto se sortea la próxima palabra. Para anotar, temperatura 0, o varias repeticiones promediadas.
- La consigna es el instrumento: definiciones explícitas, como para un juez humano, un texto por pedido y un formato de respuesta fijo.
- Se valida siempre contra una referencia: una muestra anotada a mano. Y se mira dónde se equivoca, no solo cuánto.
- Puede medir cosas que la bolsa de palabras no podía sin etiquetas, como el tema, o cualquier dimensión que definas.
Si lo usás en tu trabajo integrador, reportá: el modelo exacto (minimax/minimax-m3.1-flash:free), la fecha, la consigna completa, la temperatura, cuántos textos anotaste y cuánto coincidió con tu propia anotación de una muestra. Los modelos cambian y desaparecen: sin esos datos, nadie puede repetir lo que hiciste.
Y antes de mandar textos a una API, pensá si podés: el texto viaja a los servidores de una empresa. Con datos sensibles (historias clínicas, entrevistas identificables), no.