---
title: "IA para Científicos Sociales"
subtitle: "Sesión 4.3: Laboratorio 7 - Primeros pasos con ellmer"
author:
- name: Danilo Freire
orcid: 0000-0002-4712-6810
email: danilofreire@gmail.com
affiliations: "Department of Data and Decision Sciences
Emory University"
format:
clean-revealjs:
self-contained: true
footer: "[Sesión 4.3](https://danilofreire.github.io/introduccion-ia-ucu/clases/dia-04/15-laboratorio-07.html)"
transition: slide
transition-speed: default
scrollable: true
revealjs-plugins:
- multimodal
engine: knitr
editor:
render-on-save: true
lang: es
execute:
echo: true
---
```{r setup, include=FALSE}
options(htmltools.dir.version = FALSE)
library(knitr)
opts_chunk$set(
prompt = T,
fig.align = "center",
dpi = 300,
cache = T,
engine.opts = list(bash = "-l")
)
knit_hooks$set(
prompt = function(before, options, envir) {
options(
prompt = if (options$engine %in% c("sh", "bash", "zsh")) "$ " else "R> ",
continue = if (options$engine %in% c("sh", "bash", "zsh")) "$ " else "+ "
)
}
)
options(repos = c(CRAN = "https://cran.rstudio.com/"))
if (!require("fontawesome", character.only = TRUE)) {
install.packages("fontawesome", dependencies = TRUE)
library(fontawesome, character.only = TRUE)
}
```
# Laboratorio 7: Primeros pasos con ellmer {background-color="#2d4563"}
## Objetivos del laboratorio
:::{style="margin-top: 30px; font-size: 26px;"}
:::{.columns}
:::{.column width=50%}
**Lo que vamos a hacer**
1. Configurar [ellmer]{.alert} con [OpenRouter]{.alert}
2. Crear el primer chat y experimentar con system prompts
3. Clasificar textos políticos con LLMs (zero-shot y few-shot)
4. Comparar la accuracy del LLM con [LDA del Día 3]{.alert}
:::
:::{.column width=50%}
**Lo que vamos a aprender**
- El paquete [ellmer]{.alert} y su filosofía: misma interfaz para todos los proveedores
- Cómo un [system prompt]{.alert} cambia el comportamiento del modelo
- Cuándo el LLM gana y cuándo pierde frente a métodos clásicos
:::
:::
:::
## Estructura del laboratorio
:::{style="margin-top: 40px; font-size: 28px;"}
| Parte | Contenido | Duración |
|-------|-----------|----------|
| 1 | Configuración: API key, primer chat, system prompt | ~15 min |
| 2 | Clasificación de textos políticos | ~25 min |
| 3 | Comparación con LDA del Día 3 | ~15 min |
| -- | Apéndices: ejercicios, few-shot avanzado, NER | extra |
[Reusamos el dataset `textos_politicos.csv` del Día 3 para que la comparación sea directa]{.alert}
:::
# Parte 1: Configuración 🤖 {background-color="#2d4563"}
## Instalar `ellmer`
:::{style="margin-top: 30px; font-size: 23px;"}
- [ellmer](https://ellmer.tidyverse.org/) es un paquete oficial de Posit
- Usa la misma interfaz para OpenAI, Anthropic, OpenRouter, Gemini y otros proveedores
- Cambiar de proveedor es cambiar una línea, sin reescribir el código de procesamiento
```{r}
#| label: instalar-ellmer
#| eval: false
#| echo: true
#| cache: true
# Instalar (solo la primera vez)
install.packages(c("ellmer", "jsonlite"))
```
- En este laboratorio usamos [OpenRouter](https://openrouter.ai/), que tiene modelos gratuitos y no requiere instalar nada
```{r}
#| label: cargar-paquetes
#| eval: true
#| echo: true
#| cache: true
library(tidyverse)
library(ellmer)
library(jsonlite)
packageVersion("ellmer")
```
:::
## Obtener una API key de OpenRouter
:::{style="margin-top: 30px; font-size: 22px;"}
:::{.columns}
:::{.column width=55%}
1. Crear cuenta en [openrouter.ai](https://openrouter.ai/) (basta con email)
2. Ir a [openrouter.ai/keys](https://openrouter.ai/keys) y generar una nueva key
3. Ponerle un nombre (ej. "Laboratorio 7") y $0.00 de límite de gasto
4. Copiar y [guardar la key]{.alert} (empieza con `sk-or-...`): se muestra una sola vez
- [Modelos con sufijo `:free` no cuestan]{.alert} (con límites de rate). Para este laboratorio basta con eso
- Para seleccionarlos, vayan a [openrouter.ai/models](https://openrouter.ai/models), hagan clic en "Prompt Pricing" y pónganlo en "Free". Los nombres de los modelos son los que terminan en `:free`
:::{.callout-warning}
**Nunca** pegar la API key en un script que se sube a GitHub. Usar variables de entorno
:::
:::
:::{.column width=45%}
:::{style="font-size: 22px;"}
**Modelos gratuitos recomendados**
| Modelo | Para qué |
|---|---|
| `openai/gpt-oss-20b:free` | Default del lab. Rápido y confiable |
| `google/gemma-4-31b-it:free` | Buen español; ideal para salida estructurada (Lab 8) |
| `nvidia/nemotron-3-super-120b-a12b:free` | Potente, con buenos límites de NVIDIA |
| `nvidia/nemotron-nano-9b-v2:free` | Chico y veloz |
Para producción, modelos pagos baratos (Claude Haiku 4.5, GPT-5 mini) cuestan centavos por mil textos. Para lo más difícil, los tope de gama (Claude Opus 4.8, GPT-5.5) cuestan más, pero razonan mejor
:::
:::
:::
:::
## Configurar la API key en R
:::{style="margin-top: 30px; font-size: 22px;"}
`ellmer` busca la key en una [variable de entorno]{.alert}, no en el código. Hay dos formas de definirla:
- [Opción 1 (rápida)]{.alert}: `Sys.setenv()` la define solo para esta sesión de R; al cerrar R se borra
- [Opción 2 (recomendada)]{.alert}: guardarla en `.Renviron`, un archivo personal que R lee al arrancar, así queda disponible siempre
- `usethis::edit_r_environ()` abre ese archivo: agregan la línea, guardan y reinician R
```{r}
#| label: configurar-api
#| eval: false
#| echo: true
#| cache: true
# Opción 1: solo para esta sesión de R
Sys.setenv(OPENROUTER_API_KEY = "sk-or-...")
# Opción 2 (recomendada): guardar en .Renviron permanentemente
# Editar el archivo con:
usethis::edit_r_environ()
# Agregar la línea (sin comillas):
# OPENROUTER_API_KEY=sk-or-...
# Guardar y reiniciar R
# Verificar
Sys.getenv("OPENROUTER_API_KEY") != "" # TRUE si está bien
```
:::{.callout-note appearance="simple" icon=false}
**Pista**: la variable `OPENROUTER_API_KEY` es la que `ellmer` busca por defecto cuando llaman a `chat_openrouter()`. No hace falta pasarla como argumento
:::
:::
## El primer chat
:::{style="margin-top: 30px; font-size: 22px;"}
- `chat_openrouter()` crea el objeto de conversación con el modelo elegido
- `$chat()` envía un mensaje y devuelve la respuesta como texto
- El objeto [guarda el historial]{.alert}: cada llamada reenvía todo lo anterior
```{r}
#| label: primer-chat
#| eval: true
#| echo: true
#| cache: true
chat <- chat_openrouter(model = "openai/gpt-oss-20b:free")
# Mensaje simple
respuesta <- chat$chat("Hola, ¿qué podés hacer?")
# El historial se mantiene dentro del objeto chat
chat$chat("¿Cuál fue mi primera pregunta?")
```
[Cada llamada a `$chat()` envía todo el historial previo. Por eso el modelo "recuerda" la conversación]{.alert}
:::
## System prompt: fijar un rol
:::{style="margin-top: 30px; font-size: 20px;"}
- El [system prompt]{.alert} no aparece en las respuestas, pero condiciona [todas]{.alert} las salidas del modelo
- Es la [forma más limpia de fijar el comportamiento]{.alert} para procesar muchos documentos
```{r}
#| label: system-prompt
#| eval: true
#| echo: true
#| cache: true
analista <- chat_openrouter(
model = "openai/gpt-oss-20b:free",
system_prompt = "Sos un analista político con experiencia en América Latina.
Respondés de forma breve y académica, en español,
citando fuentes cuando es posible. Si no estás seguro, lo decís explícitamente."
)
analista$chat("¿Cuáles son los principales desafíos democráticos de Uruguay en los últimos diez años?")
```
:::
# Parte 2: Clasificación de textos {background-color="#2d4563"}
## Cargar los textos del Día 3
:::{style="margin-top: 30px; font-size: 22px;"}
- El archivo está [ordenado por tema]{.alert}: tomar las primeras filas daría una sola categoría
- Por eso armamos una [muestra balanceada]{.alert} (4 por tema) y mezclamos el orden
- `set.seed()` hace la muestra reproducible: siempre la misma
```{r}
#| label: cargar-textos
#| eval: true
#| echo: true
#| cache: true
textos <- read_csv("datos/textos_politicos.csv")
# Lean el archivo directo desde la web:
# textos <- read_csv("https://raw.githubusercontent.com/danilofreire/introduccion-ia-ucu/main/clases/dia-04/datos/textos_politicos.csv", show_col_types = FALSE)
glimpse(textos)
# El dataset está ordenado por tema: armamos una muestra balanceada
set.seed(2026)
muestra <- textos |>
group_by(tema) |>
slice_sample(n = 4) |> # 4 por tema = 20 textos, las 5 categorías
ungroup() |>
slice_sample(prop = 1) # mezclar el orden
muestra |> count(tema)
```
[Ayer (Día 3) usamos LDA y diccionarios sobre este mismo dataset. Hoy le pedimos lo mismo al LLM y comparamos]{.alert}
:::
## Construir el clasificador (zero-shot)
:::{style="margin-top: 30px; font-size: 18px;"}
- [Zero-shot]{.alert}: pedimos la categoría sin dar ejemplos, solo las reglas
- Creamos un chat nuevo por texto, así ninguno arrastra el historial del anterior
- `trimws(tolower())` limpia la respuesta para poder compararla con la etiqueta real
```{r}
#| label: clasificador-zero
#| eval: true
#| echo: true
#| cache: true
clasificar_zero <- function(texto) {
chat <- chat_openrouter(
model = "openai/gpt-oss-20b:free",
system_prompt = "Clasificá el siguiente texto político en EXACTAMENTE una de estas categorías:
economia, educacion, seguridad, salud, medioambiente.
Reglas estrictas:
- Responder SOLO con el nombre de la categoría, en minúsculas
- Sin tildes, sin puntuación, sin explicación
- Si dudás, elegir la categoría más probable"
)
trimws(tolower(chat$chat(texto))) # trim whitespaces, converter para minúsculas
}
# Probar con un texto
muestra$texto[1]
clasificar_zero(muestra$texto[1])
```
[Creamos un chat nuevo dentro de la función para que cada texto se clasifique [sin historial previo]{.alert} que lo sesgue]{.alert}
:::
## Clasificar la muestra
:::{style="margin-top: 30px; font-size: 20px;"}
- `map_chr()` aplica la función a cada texto y junta las respuestas en un vector
- `map_chr()` es de [`purrr`](https://purrr.tidyverse.org/reference/map.html), parte del tidyverse, la syntaxis es `map_tipo(vector, función)`
- `mutate()` agrega ese vector como una nueva columna al dataset
```{r}
#| label: clasificar-batch
#| eval: true
#| echo: true
#| cache: true
#| results: "hide"
# Clasificar los 20 textos de la muestra balanceada
resultados_zero <- muestra |> mutate(tema_llm = map_chr(texto, clasificar_zero))
```
```{r}
#| label: clasificar-batch-mostrar
#| eval: true
#| echo: true
#| cache: true
#|
resultados_zero |>
select(tema, tema_llm, texto) |>
head(8)
```
:::{.callout-warning}
**Costo y rate limits**: con modelos `:free` hay límites de peticiones por minuto. Si los bloquea, esperen 30 segundos. Para corpus grandes, pasar a modelos pagos
:::
:::
## Evaluar la accuracy
:::{style="margin-top: 30px; font-size: 22px;"}
- Marcamos cada texto como correcto si la etiqueta del LLM coincide con la real
- La [matriz de confusión]{.alert} muestra en qué categorías se confunde el modelo
```{r}
#| label: evaluar-zero
#| eval: true
#| echo: true
#| cache: true
evaluacion <- resultados_zero |>
mutate(correcto = tema == tema_llm)
# Accuracy global
mean(evaluacion$correcto)
# Matriz de confusión
table(real = evaluacion$tema, llm = evaluacion$tema_llm)
# ¿Dónde se equivoca más?
evaluacion |>
filter(!correcto) |>
count(tema, tema_llm, sort = TRUE)
```
[Anoten la accuracy obtenida. La vamos a comparar con LDA al final]{.alert}
:::
## Few-shot: mejorar con ejemplos
:::{style="margin-top: 30px; font-size: 21px;"}
```{r}
#| label: clasificador-few
#| eval: true
#| echo: true
#| cache: true
#| results: "hide"
clasificar_few <- function(texto) {
chat <- chat_openrouter(
model = "openai/gpt-oss-20b:free",
system_prompt = "Clasificá textos políticos 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, sin tildes ni explicación."
)
trimws(tolower(chat$chat(texto)))
}
resultados_few <- muestra |>
mutate(tema_llm = map_chr(texto, clasificar_few, .progress = TRUE))
```
```{r}
#| label: clasificador-few-mostrar
#| eval: true
#| echo: true
#| cache: true
mean(resultados_few$tema == resultados_few$tema_llm)
```
:::
## ¿Qué modelo conviene?
:::{style="margin-top: 30px; font-size: 18px;"}
```{r}
#| label: comparar-modelos
#| eval: true
#| echo: true
#| cache: true
#| results: "hide"
# La misma tarea con dos modelos: ¿cambian velocidad y resultados?
clasificar_con <- function(texto, modelo) {
chat <- chat_openrouter(
model = modelo,
system_prompt = "Clasificá en: economia, educacion, seguridad, salud, medioambiente. Responder SOLO con la categoría, en minúsculas y sin tildes."
)
trimws(tolower(chat$chat(texto)))
}
prueba <- muestra |> slice(1:8)
# Un modelo rápido vs uno más cuidadoso
prueba <- prueba |>
mutate(
gpt_oss = map_chr(texto, clasificar_con, "openai/gpt-oss-20b:free"),
nemotron = map_chr(texto, clasificar_con, "nvidia/nemotron-nano-9b-v2:free")
)
```
```{r}
#| label: comparar-modelos-mostrar
#| eval: true
#| echo: true
#| cache: true
# ¿Coinciden entre sí los dos modelos?
mean(prueba$gpt_oss == prueba$nemotron)
# Guardar los resultados para reusarlos después (CSV)
write_csv(prueba, "clasificacion_lab7.csv")
```
`gpt-oss-20b` es más grande y preciso; `nemotron-nano` es chico y veloz. Coinciden en la mayoría de los textos, pero no en todos: [el más rápido no siempre es el más preciso]{.alert}, por eso conviene comparar antes de elegir
:::
# Parte 3: Comparar con LDA del Día 3 {background-color="#2d4563"}
## El experimento
:::{style="margin-top: 30px; font-size: 24px;"}
Vamos a clasificar los [20 textos de la muestra]{.alert} con tres métodos y comparar su accuracy contra las etiquetas reales (`tema`):
1. [LDA]{.alert} (Día 3): topic modeling [no supervisado]{.alert}
2. [gpt-oss-20b]{.alert}: un LLM más grande
3. [nemotron-nano-9b]{.alert}: un LLM chico
**Preguntas para responder**
- ¿Cuánto mejor clasifica un LLM que LDA, que no conoce las categorías?
- ¿El LLM chico se acerca al grande?
- ¿Vale la pena el costo y la lentitud de los LLMs?
[Cada método tiene su propio chunk con `cache: true`: si los limitan, re-rendericen y sigue desde donde quedó]{.alert}
:::
## Clasificador 1: LDA (ajustado en 60, medido en la muestra)
:::{style="margin-top: 30px; font-size: 16px;"}
- LDA es [no supervisado]{.alert}: descubre 5 tópicos sin conocer nuestras categorías
- Lo ajustamos sobre los [60 textos]{.alert} (necesita el corpus), pero medimos su accuracy sobre los [20 de la muestra]{.alert}
- Para medir accuracy, mapeamos cada tópico al [tema real más frecuente]{.alert}
```{r}
#| label: lda-corpus
#| eval: true
#| echo: true
#| cache: true
library(tidytext)
library(topicmodels)
# Si falta algún paquete: install.packages(c("tidytext", "topicmodels", "tm"))
# 1. Tokenizar y quitar stopwords (igual que el Día 3)
extra_stop <- c("sigue", "siendo", "sido", "ser", "hacer", "ha", "han",
"hay", "más", "país", "países", "región", "regiones", "gobierno",
"toda", "todos", "todas")
stop_es <- tibble(palabra = unique(c(tm::stopwords("spanish"), extra_stop)))
tokens_limpios <- textos |>
select(id, tema, texto) |>
unnest_tokens(palabra, texto) |>
filter(!str_detect(palabra, "^[0-9]+$")) |>
anti_join(stop_es, by = "palabra")
# 2. Matriz documento-término y LDA con k = 5
dtm <- tokens_limpios |> count(id, palabra) |> cast_dtm(id, palabra, n)
set.seed(123)
modelo_lda <- LDA(dtm, k = 5, control = list(seed = 123))
# 3. Tópico dominante de cada documento, con su tema real al lado
doc_topic <- tidy(modelo_lda, matrix = "gamma") |>
mutate(id = as.integer(document)) |>
slice_max(gamma, n = 1, by = id, with_ties = FALSE) |>
left_join(select(textos, id, tema), by = "id") |>
select(id, topic, tema)
# 4. Cada tópico se mapea al tema real más frecuente
mapa <- doc_topic |>
count(topic, tema, name = "casos") |>
slice_max(casos, n = 1, by = topic, with_ties = FALSE) |>
select(topic, tema_lda = tema)
# 5. Accuracy sobre los 20 textos de la muestra (mismo set que los LLMs)
accuracy_lda <- doc_topic |>
left_join(mapa, by = "topic") |>
filter(id %in% muestra$id) |>
summarise(acc = mean(tema == tema_lda)) |>
pull(acc)
accuracy_lda
```
:::{.callout-note}
Como LDA no conoce las categorías, suele [colapsar dos temas en uno]{.alert} y dejar otros sin tópico. Por eso su accuracy es baja
:::
:::
## Clasificadores 2 y 3: gpt-oss y nemotron
:::{style="margin-top: 30px; font-size: 23px;"}
- gpt-oss [ya clasificó la muestra]{.alert} en la Parte 2 (zero-shot): reusamos ese resultado, sin gastar rate limit
- nemotron es el único paso con [nuevas llamadas]{.alert}: clasifica los 20 textos de la muestra (~20 llamadas; `cache: true` guarda lo que salga)
```{r}
#| label: clasif-gptoss-corpus
#| eval: true
#| echo: true
#| cache: true
# resultados_zero (Parte 2) = gpt-oss-20b zero-shot sobre la muestra
accuracy_gptoss <- mean(resultados_zero$tema == resultados_zero$tema_llm)
accuracy_gptoss
```
```{r}
#| label: clasif-nemotron-corpus
#| eval: true
#| echo: true
#| cache: true
#| results: "hide"
clasif_nemotron <- muestra |>
mutate(tema_llm = map_chr(texto, clasificar_con, "nvidia/nemotron-nano-9b-v2:free"))
```
```{r}
#| label: clasif-nemotron-corpus-mostrar
#| eval: true
#| echo: true
#| cache: true
accuracy_nemotron <- mean(clasif_nemotron$tema == clasif_nemotron$tema_llm)
accuracy_nemotron
```
:::
## Tabla comparativa
:::{style="margin-top: 30px; font-size: 23px;"}
```{r}
#| label: comparar
#| eval: true
#| echo: true
#| cache: true
comparacion <- tribble(
~metodo, ~accuracy, ~tiempo, ~costo,
"LDA (no supervisado)", accuracy_lda, "~2 seg", "$0",
"gpt-oss-20b", accuracy_gptoss, "~2 minutos", "<$0.01",
"nemotron-nano-9b", accuracy_nemotron, "~2 minutos", "<$0.01"
)
comparacion |> arrange(desc(accuracy))
```
:::{.callout-note appearance="simple" icon=false}
**Lección**: el LLM, que [conoce las categorías]{.alert}, supera por lejos a LDA, que solo agrupa palabras. El modelo chico suele acercarse al grande a una fracción del costo
:::
:::
## Discusión: cuándo gana cada uno
:::{style="margin-top: 30px; font-size: 22px;"}
:::{.columns}
:::{.column width=50%}
**Ventajas del LLM**
- Mayor [precisión]{.alert} en tareas matizadas
- Entiende [contexto]{.alert} y [negaciones]{.alert}
- No requiere [preprocesamiento]{.alert} ni reentrenar para cambiar de tema
- Multilingüe sin esfuerzo
**Ventajas de LDA**
- [Gratis]{.alert} y rápido
- Totalmente [reproducible]{.alert}
- [Interpretable]{.alert}: sabemos qué palabras definen cada tema
:::
:::{.column width=50%}
**Otros usos de los LLMs en ciencias sociales**
- [Codificar texto a escala]{.alert}: respuestas abiertas de encuestas, entrevistas, redes sociales ([Gilardi et al., 2023](https://www.pnas.org/doi/10.1073/pnas.2305016120))
- [Extraer datos estructurados]{.alert} de documentos: leyes, fallos judiciales, actas parlamentarias
- [Event data]{.alert}: detectar protestas, conflictos o eventos en noticias
- [Encuestados sintéticos]{.alert}: simular respuestas de subgrupos para pilotos ([Argyle et al., 2023](https://doi.org/10.1017/pan.2023.2))
- [Revisión de literatura]{.alert}: filtrar abstracts en revisiones sistemáticas
- [Análisis multilingüe]{.alert}: traducir y analizar corpus en varios idiomas
:::
:::
:::
## Resumen del laboratorio
:::{style="margin-top: 30px; font-size: 24px;"}
:::{.columns}
:::{.column width=50%}
**Lo que practicamos**
- `chat_openrouter()` y `$chat()`
- System prompts para fijar comportamiento
- Zero-shot y few-shot
- `map_chr()` para procesar en batch
- Comparación con LDA del Día 3
:::
:::{.column width=50%}
**Próximo laboratorio (Lab 8)**
- [Codificación cualitativa]{.alert} con `quallmer`
- [Validar]{.alert} contra etiquetas humanas (accuracy, kappa)
- [Auditoría de sesgos]{.alert} del modelo
[Material extra (few-shot avanzado, NER) en los apéndices]{.alert}
:::
:::
:::
# Ejercicios para practicar {background-color="#2d4563"}
## Ejercicio 1: variar el system prompt {#sec:exercise01}
:::{style="margin-top: 30px; font-size: 20px;"}
**Tarea (5 min):** completen los tres system prompts con personalidades distintas y comparen sus respuestas a la misma pregunta
```{r}
#| label: ej1-skeleton
#| eval: false
#| echo: true
#| prompt: false
academico <- chat_openrouter(
model = "openai/gpt-oss-20b:free",
system_prompt = ___ # TODO: un profesor universitario, formal y académico
)
periodista <- chat_openrouter(
model = "openai/gpt-oss-20b:free",
system_prompt = ___ # TODO: un periodista, claro y accesible
)
activista <- chat_openrouter(
model = "openai/gpt-oss-20b:free",
system_prompt = ___ # TODO: un activista social
)
pregunta <- "¿Qué opinás sobre la desigualdad en América Latina?"
academico$chat(pregunta)
periodista$chat(pregunta)
activista$chat(pregunta)
```
:::{.callout-note appearance="simple" icon=false}
**Pista**: solo cambia el `system_prompt` de cada chat; el modelo y la pregunta son los mismos
:::
[[Apéndice: Solución]{.button}](#sec:appendix01)
:::
## Ejercicio 2: resumir un texto largo con gpt-oss {#sec:exercise02}
:::{style="margin-top: 30px; font-size: 18px;"}
**Tarea (5 min):** los textos del corpus tienen una sola oración, así que acá hay dos párrafos más largos. Escriban una función `resumir()` que le pida a gpt-oss un resumen de una oración bien corta, y aplíquenla a los dos párrafos
```{r}
#| label: ej2-skeleton
#| eval: false
#| echo: true
#| prompt: false
parrafo1 <- "El gobierno anunció un paquete de medidas económicas que combina
recortes en el gasto público con incentivos a la inversión privada. La oposición
sostiene que el ajuste recaerá sobre los más vulnerables, mientras que los
empresarios celebran la previsibilidad fiscal."
parrafo2 <- "La reforma educativa propone extender la jornada escolar y actualizar
los contenidos en ciencia y tecnología. Los sindicatos docentes reclaman que
llega sin presupuesto para infraestructura ni para mejorar los salarios. Varias
universidades ofrecieron colaborar en la formación de profesores."
resumir <- function(texto) {
chat <- chat_openrouter(
model = "openai/gpt-oss-20b:free",
system_prompt = ___ # TODO: resumir el texto
)
chat$chat(___) # TODO: ¿qué texto le pasamos?
}
resumir(parrafo1)
resumir(parrafo2)
```
:::{.callout-note}
**Pista**: el `system_prompt` fija la tarea (resumir en una oración); `$chat()` recibe el texto a resumir
:::
[[Apéndice: Solución]{.button}](#sec:appendix02)
:::
# Apéndice: Soluciones de los ejercicios {background-color="#2d4563"}
## Solución Ejercicio 1 {#sec:appendix01}
:::{style="margin-top: 30px; font-size: 18px;"}
```{r}
#| label: ej1-sol
#| eval: true
#| echo: true
#| cache: true
academico <- chat_openrouter(
model = "openai/gpt-oss-20b:free",
system_prompt = "Sos un profesor universitario. Respondé de forma formal y académica, con referencias a literatura."
)
periodista <- chat_openrouter(
model = "openai/gpt-oss-20b:free",
system_prompt = "Sos un periodista de un diario nacional. Respondé de forma clara y accesible para el público general."
)
activista <- chat_openrouter(
model = "openai/gpt-oss-20b:free",
system_prompt = "Sos un activista social. Respondé con énfasis en la justicia social y los derechos humanos."
)
pregunta <- "¿Qué opinás sobre la desigualdad en América Latina?"
academico$chat(pregunta)
periodista$chat(pregunta)
activista$chat(pregunta)
```
[Discusión: ¿la diferencia refleja conocimiento real o el modelo "actuando" cada rol?]{.alert}
[[Volver al ejercicio]{.button}](#sec:exercise01)
:::
## Solución Ejercicio 2 {#sec:appendix02}
:::{style="margin-top: 30px; font-size: 20px;"}
```{r}
#| label: ej2-sol
#| eval: true
#| echo: true
#| cache: true
parrafo1 <- "El gobierno anunció un paquete de medidas económicas que combina
recortes en el gasto público con incentivos a la inversión privada. La oposición
sostiene que el ajuste recaerá sobre los más vulnerables, mientras que los
empresarios celebran la previsibilidad fiscal."
parrafo2 <- "La reforma educativa propone extender la jornada escolar y actualizar
los contenidos en ciencia y tecnología. Los sindicatos docentes reclaman que
llega sin presupuesto para infraestructura ni para mejorar los salarios. Varias
universidades ofrecieron colaborar en la formación de profesores."
resumir <- function(texto) {
chat <- chat_openrouter(
model = "openai/gpt-oss-20b:free",
system_prompt = "Resumí el texto en UNA sola oración bien corta, en español, sin opinar."
)
chat$chat(texto)
}
resumir(parrafo1)
resumir(parrafo2)
```
[[Volver al ejercicio]{.button}](#sec:exercise02)
:::
# Apéndice: Few-shot avanzado y errores {background-color="#2d4563"}
## Inspeccionar los errores del LLM
:::{style="margin-top: 30px; font-size: 20px;"}
```{r}
#| label: analizar-errores
#| eval: true
#| echo: true
#| cache: true
errores <- evaluacion |>
filter(!correcto) |>
select(texto, tema, tema_llm)
errores
# ¿Son errores "razonables"? A veces el LLM elige un tema
# distinto pero igual de defendible (ej. salud vs medioambiente
# en un texto sobre contaminación que afecta la salud)
```
:::{.callout-note appearance="simple" icon=false}
**Pista**: cuando un error es defendible, el problema puede estar en las [etiquetas originales]{.alert}, no en el modelo. Esto pasa en datasets reales y es parte del trabajo de codificación
:::
:::
## Few-shot con razonamiento (chain-of-thought)
:::{style="margin-top: 30px; font-size: 18px;"}
```{r}
#| label: cot
#| eval: true
#| echo: true
#| cache: true
clasificar_cot <- function(texto) {
chat <- chat_openrouter(
model = "openai/gpt-oss-20b:free",
system_prompt = "Clasificá textos políticos en: economia,
educacion, seguridad, salud, medioambiente.
Razoná paso a paso (qué palabras clave aparecen, qué institución
se menciona, qué política se debate) y al final escribí en una
línea separada:
CATEGORIA: "
)
respuesta <- chat$chat(texto)
# Extraer la línea final
cat_line <- str_extract(respuesta, "CATEGORIA:\\s*\\w+")
trimws(tolower(str_remove(cat_line, "CATEGORIA:\\s*")))
}
clasificar_cot(textos$texto[1])
```
[Más lento, pero suele mejorar la accuracy en textos ambiguos. Costo: respuestas más largas]{.alert}
:::
# Apéndice: Extracción de entidades (NER) {background-color="#2d4563"}
## Extraer personas, organizaciones y lugares
:::{style="margin-top: 30px; font-size: 20px;"}
```{r}
#| label: ner
#| eval: true
#| echo: true
#| cache: true
ner <- chat_openrouter(
model = "openai/gpt-oss-20b:free",
system_prompt = 'Extraé entidades nombradas del texto.
Respondé en JSON estricto con esta estructura:
{
"personas": [],
"organizaciones": [],
"lugares": []
}
NO incluir texto fuera del JSON.'
)
respuesta <- ner$chat(textos$texto[5])
```
[Este patrón funciona, pero a veces el modelo agrega texto fuera del JSON y rompe el parsing. La solución limpia (`$chat_structured()` con tipos, base del paquete `quallmer`) la vemos en el Lab 8]{.alert}
:::
# Nos vemos en el Lab 8 {background-color="#2d4563"}