--- 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"}