library(ellmer)
library(tidyverse)
library(jsonlite)
set.seed(2026)
stopifnot(Sys.getenv("OPENROUTER_API_KEY") != "")
textos <- read_csv("datos/textos_politicos.csv",
show_col_types = FALSE)
MODELO <- "nvidia/nemotron-3-super-120b-a12b:free"Tarea 7: Primeros pasos con ellmer – Respuestas
IA para Científicos Sociales - UCU
1 Instrucciones
Esta es la clave de respuestas de la Tarea 7. Cada pregunta incluye el código R completo y una respuesta escrita. Los bloques que llaman a la API están marcados con eval=FALSE para no gastar peticiones al renderizar. Para correrlos hace falta una API key de OpenRouter configurada en OPENROUTER_API_KEY.
1.1 Configuración
2 Configuración y chat básico
2.1 Pregunta 1: El system prompt como formato
chat <- chat_openrouter(
model = MODELO,
system_prompt = "Respondé SIEMPRE con exactamente tres viñetas que
empiecen con '- '. Usá lenguaje sencillo, sin jerga técnica, como si
le explicaras el tema a alguien que nunca estudió estadística."
)
chat$chat("¿Qué es el sesgo de selección?")Respuesta: El system prompt se pasa como argumento del constructor chat_openrouter() y permanece activo durante toda la sesión del chat. En general el modelo respeta la instrucción de formato (tres viñetas), pero no siempre: a veces agrega una introducción, una viñeta de más, o se le escapa un término técnico (“muestra no aleatoria”). La lección es que una instrucción de formato es una restricción que hay que verificar, no una garantía. Para salidas que deben tener una estructura exacta, la herramienta correcta es chat_structured() (Pregunta 5), no el system prompt.
2.2 Pregunta 2: Historial de conversación
chat$chat("¿Y cómo se puede evitar en una encuesta?")Respuesta: La segunda respuesta se refiere al sesgo de selección sin que tengamos que repetir el término. ellmer guarda el historial completo dentro del objeto chat y lo reenvía al modelo en cada llamada. Eso permite que el modelo mantenga el hilo, pero también significa que el costo en tokens crece conversación tras conversación.
2.3 Pregunta 3: El system prompt como barrera
guardia <- chat_openrouter(
model = MODELO,
system_prompt = "Sos el asistente de un curso de política latinoamericana.
Respondé SOLO preguntas sobre política latinoamericana. Si te preguntan
sobre cualquier otro tema, rechazá amablemente en una oración y sugerí
volver a los temas del curso."
)
cat("--- Dentro del dominio ---\n",
guardia$chat("¿Qué fue la tercera ola de democratización en América Latina?"),
"\n\n")
cat("--- Fuera del dominio ---\n",
guardia$chat("¿Cómo se hace un risotto de hongos?"),
"\n\n")
cat("--- Fronteriza ---\n",
guardia$chat("¿Qué países de América Latina producen más arroz?"),
"\n")Respuesta: El modelo suele respetar la barrera en los casos claros: responde la pregunta sobre democratización y rechaza la receta. Lo interesante es la pregunta fronteriza: la producción de arroz menciona a América Latina pero es un tema agrícola, y según la corrida el modelo la responde (interpretando “política” en sentido amplio, como economía política) o la rechaza. La barrera no es una regla formal sino una interpretación del modelo, y por eso es blanda: funciona para guiar el uso normal, pero no resiste casos límite ni usuarios insistentes. Una herramienta de investigación seria combina el system prompt con validación de entradas y salidas.
3 Clasificación y extracción
3.1 Pregunta 4: Clasificar una dimensión sin etiquetas
clasificar_tono <- function(texto) {
chat <- chat_openrouter(
model = MODELO,
system_prompt = "Clasificá el TONO del siguiente texto político en
EXACTAMENTE una de estas etiquetas: positivo, negativo, neutral.
Reglas:
- 'positivo' si destaca logros, mejoras u oportunidades
- 'negativo' si destaca problemas, riesgos o retrocesos
- 'neutral' si describe o anuncia sin valorar
- Responder SOLO con la etiqueta, en minúsculas, sin explicación"
)
trimws(tolower(chat$chat(texto)))
}
textos |> slice(c(2, 21, 41)) |> select(id, texto)
clasificar_tono(textos$texto[2])
clasificar_tono(textos$texto[21])
clasificar_tono(textos$texto[41])Respuesta: Los tres textos tienen tonos bien marcados y el modelo suele acertar: el 2 (la inflación como “problema grave”) es negativo, el 21 (los programas de alimentación escolar “han mejorado” la asistencia) es positivo y el 41 (el gasto de bolsillo “empuja a millones de familias a la pobreza”) es negativo. Igual que en el laboratorio, la función crea un chat nuevo en cada llamada para que una clasificación no contamine la siguiente. La diferencia importante con clasificar por tema es que acá no hay columna de referencia: el dataset no trae el tono “verdadero”, así que el único control de calidad es leer los textos y juzgar nosotros. Ese es exactamente el problema que el Lab 8 resuelve de forma sistemática con un gold standard codificado a mano.
3.2 Pregunta 5: Salida estructurada
tipo_registro <- type_object(
tema = type_enum(c("economia", "educacion", "seguridad",
"salud", "medioambiente"),
"El tema principal del texto"),
menciona_gobierno = type_boolean("¿El texto menciona al gobierno o al Estado?"),
urgencia = type_enum(c("baja", "media", "alta"),
"La urgencia que transmite el texto")
)
extraer <- function(texto) {
chat <- chat_openrouter(
model = MODELO,
system_prompt = "Extraés información estructurada de textos políticos."
)
chat$chat_structured(texto, type = tipo_registro)
}
textos |>
slice(c(1, 25, 50)) |>
mutate(registro = map(texto, extraer)) |>
unnest_wider(registro) |>
select(tema, menciona_gobierno, urgencia)Respuesta: chat_structured() no devuelve texto libre sino una lista con los campos del esquema, y un type_object() admite campos de distinto tipo (type_enum, type_boolean, type_integer). Así pasamos de un texto a una fila de datos tipada en una sola llamada, que es exactamente lo que necesitamos para construir un dataset a partir de documentos. Es la misma idea que usa qlm_code() en el Lab 8. Como siempre, conviene validar: el modelo puede equivocarse en un campo aunque el formato sea correcto.
4 Robustez y sensibilidad
4.1 Pregunta 6: ¿El modelo da siempre lo mismo?
# El texto 36 mezcla señales: una mejora ("han mejorado la atención")
# dentro de un tema negativo ("víctimas de delitos")
texto_ambiguo <- textos$texto[36]
repeticiones <- map_chr(1:5, \(i) clasificar_tono(texto_ambiguo))
table(repeticiones)Respuesta: con un texto de tono claro, las cinco corridas suelen coincidir; con uno que mezcla señales (como el 36, que reporta una mejora en la atención a víctimas de delitos) pueden aparecer dos o tres etiquetas distintas. La consecuencia metodológica es que el resultado de un LLM no es perfectamente reproducible: hay que fijar y reportar el modelo y su versión, considerar una temperatura baja (0), y para los casos límite correr la clasificación varias veces y tomar la mayoría. Esta variabilidad es justamente lo que las medidas de confiabilidad del Lab 8 buscan cuantificar.
4.2 Pregunta 7: Sensibilidad al prompt
muestra <- textos |> group_by(tema) |> slice_sample(n = 1) |> ungroup()
clasificar_breve <- function(texto) {
chat <- chat_openrouter(model = MODELO,
system_prompt = "Clasificá en: economia, educacion, seguridad, salud,
medioambiente. Respondé solo con la categoría.")
trimws(tolower(chat$chat(texto)))
}
clasificar_detallado <- function(texto) {
chat <- chat_openrouter(model = MODELO,
system_prompt = "Clasificá en: economia, educacion, seguridad, salud,
medioambiente. Regla: si el texto toca dos temas, elegí el más concreto
y central. Respondé solo con la categoría, en minúsculas y sin tildes.")
trimws(tolower(chat$chat(texto)))
}
comparacion <- muestra |>
mutate(breve = map_chr(texto, clasificar_breve),
detallado = map_chr(texto, clasificar_detallado))
comparacion |> filter(breve != detallado) |> select(tema, breve, detallado)Respuesta: las dos versiones coinciden en los textos claros y difieren en los ambiguos; la regla extra del prompt detallado resuelve algunos de esos empates. La redacción del prompt es un “grado de libertad” del investigador: distintas formulaciones razonables pueden dar resultados distintos. Por eso hay que reportar el prompt exacto y, en lo posible, mostrar que el resultado es robusto a reformulaciones (como acá, comparando dos versiones).
4.3 Pregunta 8: Sensibilidad a los ejemplos
muestra8 <- textos |> group_by(tema) |> slice_sample(n = 2) |> ungroup()
clasificar_tipico <- function(texto) {
chat <- chat_openrouter(model = MODELO,
system_prompt = "Clasificá en: economia, educacion, seguridad, salud, medioambiente.
Ejemplos:
- 'La inflación volvió a subir este mes' -> economia
- 'Los docentes piden mejores salarios' -> educacion
- 'Aumentaron los robos en la capital' -> seguridad
- 'Se inauguró un nuevo hospital' -> salud
- 'Los incendios forestales se extienden' -> medioambiente
Responder SOLO con la categoría.")
trimws(tolower(chat$chat(texto)))
}
clasificar_ambiguo <- function(texto) {
chat <- chat_openrouter(model = MODELO,
system_prompt = "Clasificá en: economia, educacion, seguridad, salud, medioambiente.
Ejemplos:
- 'El presupuesto del sector creció 8 puntos' -> economia
- 'El nuevo currículo prioriza el pensamiento crítico' -> educacion
- 'Las cámaras de vigilancia llegaron a los barrios' -> seguridad
- 'Las listas de espera aumentaron tras la reforma' -> salud
- 'La sequía obliga a racionar el agua potable' -> medioambiente
Responder SOLO con la categoría.")
trimws(tolower(chat$chat(texto)))
}
res8 <- muestra8 |>
mutate(tipico = map_chr(texto, clasificar_tipico),
ambiguo = map_chr(texto, clasificar_ambiguo))
c(tipico = mean(res8$tema == res8$tipico),
ambiguo = mean(res8$tema == res8$ambiguo))Respuesta: la accuracy con ejemplos típicos suele ser algo mayor que con ejemplos ambiguos. Los ejemplos prototípicos le enseñan al modelo la categoría “pura”; los ambiguos le enseñan a no sobre-confiar en palabras clave. Ninguna estrategia es siempre mejor: depende del corpus. La lección es que el few-shot no es neutral, los ejemplos son una decisión de diseño que cambia los resultados y hay que documentarla.
5 Más allá de la clasificación
5.1 Pregunta 9: Extracción de entidades
tipo_entidades <- type_object(
personas = type_array(type_string(), "nombres de personas"),
organizaciones = type_array(type_string(), "organizaciones o instituciones"),
lugares = type_array(type_string(), "lugares geográficos")
)
chat <- chat_openrouter(
model = MODELO,
system_prompt = "Extraés entidades nombradas de un texto."
)
texto <- "El presidente Lacalle Pou se reunió en Montevideo con representantes
del Mercosur para discutir un acuerdo con la Unión Europea."
entidades <- chat$chat_structured(texto, type = tipo_entidades)
entidadesRespuesta: la extracción de entidades nombradas (NER) tradicionalmente requería un modelo entrenado por idioma; un LLM lo hace zero-shot con solo un esquema. type_array(type_string()) maneja un número variable de entidades (cero, una o varias). Sirve para armar redes de actores (quién se reunió con quién), bases de eventos o índices de organizaciones. Como con la clasificación, hay que validar: el modelo puede inventar una entidad o pasar por alto otra.
5.2 Pregunta 10: Razonamiento paso a paso
texto_dificil <- textos$texto[33]
# (a) clasificación directa (la función breve de la Pregunta 7)
directa <- clasificar_breve(texto_dificil)
# (b) con razonamiento paso a paso (cadena de pensamiento)
chat <- chat_openrouter(
model = MODELO,
system_prompt = "Clasificá el texto en: economia, educacion, seguridad,
salud, medioambiente. Primero explicá tu razonamiento en una o dos
oraciones. Terminá con una línea: 'Categoría: <categoria>'."
)
con_cot <- chat$chat(texto_dificil)
directa
cat(con_cot)Respuesta: en textos claros, la cadena de pensamiento rara vez cambia la respuesta y es gasto extra. En textos ambiguos o que tocan dos temas, pedir el razonamiento puede cambiar (y mejorar) la decisión, y deja una justificación que se puede auditar. El costo es más tokens y más tiempo. La idea de la clase 14 (“Chain-of-thought: pensar paso a paso”): el razonamiento paso a paso ayuda en tareas que requieren inferencia, no en clasificaciones simples donde el modelo ya “sabe” la respuesta.
6 Reflexión
6.1 Pregunta 11: Costo de procesar el dataset completo
n_textos <- 60
tokens_entrada_por_texto <- 200
tokens_salida_por_texto <- 5
precio_entrada <- 0.80 # USD por millón
precio_salida <- 4.00 # USD por millón
costo <- (n_textos * tokens_entrada_por_texto * precio_entrada +
n_textos * tokens_salida_por_texto * precio_salida) / 1e6
costo[1] 0.0108
Respuesta: El costo aproximado es de USD 0,01 para los 60 textos (un centavo). Esto es órdenes de magnitud más barato que un anotador humano (~USD 0,10 por texto en plataformas tipo Mechanical Turk, ~USD 1 por texto si es un investigador). Para 5.000 textos, el costo escalaría a ~USD 0,90, todavía trivial.
6.2 Pregunta 12: Recomendación para una investigación real
Respuesta: Recomendaría un híbrido. Pasos: (1) usar el LLM (Claude 3.5 Haiku o similar) para anotar 500 tweets con el mejor prompt few-shot posible, costo aproximado USD 5; (2) validar contra una muestra de 100 tweets anotados a mano por el colega, calcular kappa de Cohen; (3) si kappa > 0,7, entrenar un clasificador clásico (regresión logística + TF-IDF o un modelo en tidymodels) sobre las 500 etiquetas del LLM; (4) usar el clasificador clásico para etiquetar los 4.500 restantes, gratis y reproducible. Esto combina la precisión del LLM, el costo controlado y la reproducibilidad de los métodos clásicos. Documentar el modelo exacto, el prompt y la fecha de uso del LLM en el material suplementario.