Introducción

Este notebook ofrece una introducción a los Large Language Models (LLMs), centrándose en el modelo Gemini desarrollado por Google. Se exploran los conceptos básicos de los transformers, el mecanismo de self-attention, el prompt engineering y estrategias para interactuar con un LLM.

Como caso de aplicación, se retoma el dataset de reseñas de Amazon del Módulo 4 para clasificarlas en “positivo” o “negativo” utilizando Gemini. Se analizan los resultados y se realiza un análisis de errores para identificar las limitaciones del modelo.

El objetivo principal es comprender cómo funcionan los LLMs, sus potencialidades y cómo aplicarlos a tareas de procesamiento de lenguaje natural en el ámbito de las ciencias sociales.

¿Qué es un transformer?

Un transformer es un tipo de modelo de machine learning diseñado para trabajar con datos secuenciales, como texto, pero con una capacidad mucho mayor que los modelos tradicionales. Su diseño resuelve algunos de los problemas que tenían métodos previos, como los modelos recurrentes (RNNs) o las redes LSTM.

Los transformers son la base de los modelos de lenguaje más avanzados, como los LLMs (e.g., ChatGPT).

Cuando trabajamos con texto, queremos que el modelo entienda: 1. El contexto: La relación entre las palabras dentro de una frase. 2. La relevancia de cada palabra: No todas las palabras tienen el mismo peso en el significado.

Por ejemplo, en la frase:

“El perro no jugó con el niño porque él tenía pulgas.”

Hay cierta ambigüedad: ¿a quién se refiere “él”? ¿Al perro o al niño? El self-attention ayuda al modelo a desambiguar estas relaciones analizando cómo cada palabra interactúa con las demás.

Modelos previos, como las RNNs, procesaban las palabras una por una, pero olvidaban información a medida que avanzaban. Además, eran lentos y no podían mirar toda la frase al mismo tiempo.

¿Cómo funcionan los transformers?

Los transformers usan un mecanismo clave llamado “atención”, específicamente el mecanismo de atención autoconsciente (self-attention). Este mecanismo les permite:

  1. Mirar todas las palabras al mismo tiempo (paralelismo).
  2. Decidir cuáles palabras son más importantes para entender el contexto de cada palabra.

En lugar de procesar palabra por palabra, el transformer calcula relaciones entre todas las palabras en paralelo. Imagina que tienes una matriz donde: - Cada palabra está representada como una fila y una columna. - Los números dentro de la matriz indican cuánto influye cada palabra sobre las demás.

El self-attention permite que cada palabra en una oración analice su relación con todas las demás palabras, identificando cuáles son más importantes para su significado. Esto es crucial para entender frases donde el contexto es ambiguo o complejo.

Volvamos a la frase anterior:

“El perro no jugó con el niño porque él tenía pulgas.”

En nuestro ejemplo: - “él” podría referirse al perro o al niño. - El modelo necesita decidir cuál es más probable en función del contexto.

El self-attention calcula estas relaciones y ajusta la representación de cada palabra para incluir información relevante sobre las palabras relacionadas.

Pasos principales del self-attention

1. Representación inicial: embeddings Cada palabra se convierte en un vector numérico, llamado embedding, que captura información sobre su significado. Por ejemplo: - “perro” → [0.2, 0.5, 0.8] - “niño” → [0.4, 0.1, 0.9] - “él” → [0.7, 0.3, 0.6]

Estos vectores no solo representan las palabras aisladas, sino también su posición relativa en la oración (positional encoding).

2. Generación de Query, Key y Value El modelo transforma los embeddings iniciales de cada palabra en tres nuevas representaciones: - Query (Q): ¿Qué palabras buscan relaciones? - Key (K): ¿Con qué palabras se relacionan? - Value (V): ¿Qué información aporta cada palabra?

Por ejemplo: - Para “él”, el Query puede estar orientado a encontrar qué palabras definen a “él” en el contexto. - Para “pulgas”, el Key puede relacionarse con atributos relevantes (¿Quién tiene pulgas?).

3. Cálculo de las relaciones: matriz de atención El modelo calcula una matriz de similitud entre palabras usando el producto escalar entre los Queries y los Keys.

\[ \text{Atención}_{ij} = \frac{\text{Q}_i \cdot \text{K}_j}{\sqrt{d_k}} \]

Esto da como resultado una matriz que muestra cuánto influye cada palabra en las demás. Por ejemplo:

perro no jugó con niño porque él tenía pulgas
perro 1.0 0.3 0.8 0.2 0.1 0.0 0.4 0.3 0.1
él 0.4 0.0 0.2 0.0 0.9 0.0 1.0 0.7 0.3
  • “él” tiene una conexión fuerte (0.9) con “niño”.
  • También se relaciona con “perro” (0.4), pero menos.

4. Ponderación de los valores El modelo usa la matriz de atención para combinar la información de las palabras relacionadas. Por ejemplo, el vector final de “él” incluirá información de: - “niño” (más relevante). - “perro” (menos relevante).

El resultado es un nuevo vector para “él” que incluye contexto sobre a quién se refiere.

5. Generación de la salida Cada palabra ahora tiene una representación ajustada que incluye información contextual. Por ejemplo: - “él” → nuevo vector que indica que probablemente se refiere al “niño” por la relación fuerte con “pulgas”.

Cómo ayuda el self-attention

El self-attention analiza relaciones complejas en toda la oración. En nuestro ejemplo: - Decide que “él” se refiere al “niño” porque “pulgas” tiene más sentido en ese contexto (el modelo aprende esto durante el entrenamiento).

De esta forma, este mecanismo tiene varias ventajas:

  1. Relaciones a largo plazo: Captura conexiones como la de “él” con “niño” y “pulgas”, aunque estén separadas en la frase.
  2. Ambigüedad resuelta: La matriz de atención da más peso a las palabras relevantes, ayudando a desambiguar “él”.
  3. Contexto global: Considera toda la frase simultáneamente, en lugar de procesarla palabra por palabra.
  4. Escalabilidad: Procesan información en paralelo, lo que los hace mucho más rápidos que las RNNs.
  5. Contexto global: Consideran toda la frase, no solo una parte limitada.
  6. Capacidad de generalización: Pueden adaptarse a tareas complejas como traducción, resumen y generación de texto.

Enseguida vamos a ver un ejemplo concreto de uso.

Pero antes tenemos que introducir una última cuestión. ¿Cómo interactuamos con un LLM?

Cómo usar un LLM y qué es el prompt engineering

Ya mencionamos que un LLM es una herramienta sumamente útil que permite realizar diversas tareas basadas en texto: desde resúmenes y clasificaciones hasta generación de contenido y análisis de sentimientos. Sin embargo, su eficacia depende de cómo interactuemos con él. Aquí entra en juego el concepto de prompt engineering. Es la tarea que consiste en diseñar instrucciones claras y efectivas para maximizar la calidad de las respuestas del modelo.

Un LLM, como Gemini, está entrenado en vastos conjuntos de datos para reconocer patrones en el lenguaje. Aunque no “piensa” como un humano, puede generar respuestas coherentes y contextuales basadas en entradas específicas. El modelo procesa instrucciones llamadas prompts, que son preguntas, textos o comandos diseñados para guiar su comportamiento.

Por ejemplo, si queremos traducir una frase: - Mal prompt: “Traducción al inglés.” - Buen prompt: “Traduce la frase ‘El perro juega en el parque’ al inglés.”

Un buen prompt debe ser claro, estructurado y, cuando sea necesario, incluir ejemplos o contexto. Para usar en LLM tenemos que seguir algunos pasos:

  1. Definir la tarea: Antes de usar un LLM, aclarar qué se busca lograr: ¿Resumir? ¿Clasificar? ¿Analizar sentimientos? Esto determinará cómo diseñar el prompt.

  2. Diseñar el prompt: Asegurarse de que la instrucción:

    • Sea específica.
    • Proporcione contexto relevante.
    • Indique el formato esperado de la respuesta.
  3. Usar la herramienta adecuada:

    • Interfaz directa: Plataformas como Gemini permiten interactuar con el modelo escribiendo prompts directamente.
    • API programática: Si es necesario integrar el LLM en proyectos, es poisble usar lenguajes como Python o R para enviar solicitudes al modelo.
  4. Revisar e iterar: Evaluar las respuestas y ajustar el prompt según sea necesario. La iteración es clave para obtener resultados óptimos.

Estrategias de prompt engineering

Hay varias formas de diseñar un prompt.

  1. Zero-shot prompting Pide al modelo realizar una tarea sin ningún ejemplo previo. Es útil para tareas simples. Por ejemplo,
Traduce al inglés: 'El perro juega en el parque
  1. One-shot prompting Proporciona un ejemplo para guiar al modelo.
Genera una pregunta para una encuesta sobre satisfacción laboral.
Ejemplo: '¿Qué tan satisfecho está con su horario de trabajo?'
Nueva pregunta:
  1. Few-shot prompting Incluye varios ejemplos para ayudar al modelo a generalizar mejor.
Clasifica las siguientes frases según la emoción:
- "Estoy feliz hoy." → Alegría
- "Estoy cansado." → Tristeza
- "Estoy ansioso por mañana." → Ansiedad

Frase: "No puedo creer que gané la lotería." → Emoción:
  1. Chain-of-thought prompting Solicita que el modelo muestre su razonamiento paso a paso antes de dar una respuesta final.
Pregunta: Si Juan tiene 3 manzanas y compra 5 más, ¿cuántas tiene en total? Razonamiento:

1. Juan tiene 3 manzanas.
2. Compra 5 más, así que ahora tiene 3 + 5.

Respuesta final:
    ```

5. **Contextual prompting**
Proporciona al modelo un contexto para que las respuestas sean más relevantes.

Eres un experto en marketing. Crea un eslogan para una marca de café sostenible. ```

  1. Instruction-based prompting Diseña el prompt como una instrucción directa y detallada.
Resume el siguiente texto en tres frases que incluyan las ideas principales: [texto].

Pese a lo bien que funcionan los LLMs es necesario tener en cuenta algunas limitaciones a los fines de evitar malos entendidos.

En primer lugar, más allá del marketing alrededor de estas herramientas es importante mencionar que no razonan como humanos. Se limitan a generar respuestas basadas en patrones estadísticos, de allí el término “loros aleatorios”. ¿Esto es un problema? Depende. Si los queremos usar para resolver tareas concretas como las que hemos mencionado hasta aquí como la que vamos a atacar en breve, no. En otros contextos, puede ser.

A su vez, los LLMS (al igual que cualquier otro algoritmo, modelo o método que se basa en datos de entrada y trata de aprender patrones subyacentes) tienen sesgos. Hay una amplísima bibliografía Pueden reflejar sesgos presentes en los datos de entrenamiento. Esto hace que sea necesario tener cuidado al momento de seleccionar la tarea que vamos a solicitarle. Por otro lado, tienen memoria limitada, no recuerdan interacciones previas a menos que se incluyan en el contexto.

Gemini, el modelo que vamos a usar

Google DeepMind ha desarrollado Gemini, una familia de modelos de inteligencia artificial con capacidades multimodales. Esto implica que Gemini puede procesar y comprender diversos tipos de información, como texto e imágenes.

Gemini es un modelo de lenguaje grande (LLM) que utiliza una arquitectura de red neuronal Transformer. Esta arquitectura le permite procesar información de manera eficiente y comprender las relaciones entre las palabras en un texto.

Gemini fue entrenado por Google utilizando una gran cantidad de datos de texto y código. Este proceso de entrenamiento implica ajustar los parámetros de la red neuronal para que pueda predecir la siguiente palabra o token en una secuencia. El entrenamiento se realiza en varias etapas, con conjuntos de datos cada vez más grandes y complejos. Esto permite a Gemini aprender patrones y relaciones en el lenguaje, mejorando su capacidad para comprender y generar texto.

El caso de aplicación

Vamos a volver sobre el dataset de reseñas de Amazon de la semana pasada y a retomar la tarea de clasificación de las mismas en “positivo” o “negativo”. En realidad, vamos a trabajar sobre una muestra de unas 1200 reseñas, dado que la API de Gemini solamente permite un número limitado de requests gratis en un día.

Primero vamos a instalar la librería que nos va a servir de interface para trabajar con Gemini. Se llama, como no podía ser de otra forma, gemini.R

#install.packages("gemini.R")

Cargamos, ahora, los paquetes…

library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr     1.1.4     ✔ readr     2.1.5
## ✔ forcats   1.0.0     ✔ stringr   1.5.1
## ✔ ggplot2   3.5.2     ✔ tibble    3.2.1
## ✔ lubridate 1.9.4     ✔ tidyr     1.3.1
## ✔ purrr     1.0.4     
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag()    masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(gemini.R)
library(jsonlite)
## 
## Attaching package: 'jsonlite'
## 
## The following object is masked from 'package:purrr':
## 
##     flatten

Primer paso, autenticación

Como ya hemos visto a lo largo de este curso, las APIs actúan como intermediarios que permiten la comunicación e intercambio de datos entre diferentes sistemas. Por este motivo, la autenticación es crucial para asegurar la seguridad y controlar el acceso a la información.

Para usar gemini.R, debemos autenticarnos con una clave personal que debemos crear en [https://makersuite.google.com/app/apikey]. La función setAPI en gemini.R nos permite configurar esta clave.

api <- "" ## Acá introducir la api key 
setAPI(api)
## ✖ API key must be a non-empty string.

Si no quieren que su API key sea pública (cosa que suele ser una buena práctica, en general) hay varias formas de hacer esto un poquito más seguro. Vamos a ver una muy simple:

setAPI(read_lines("../gemini_credentials.txt"))
## ℹ API key ...T7Ts is set.
## → You may try `gemini_chat('What is CRAN?')`

Acá guardamos la key en un archivo de texto y simplemente la cargamos mediante read_lines(). De esta forma no se hace visible en el script. Hay otros paquetes y otras formas para hacer esto de forma más sistemática, pero para los efectos de este ejercicio con esto nos alcanza.

Probemos si ha funcionado:

test <- gemini("¿Cómo puedo escribir un buen prompt?")
## Gemini is answering...
cat(test)
## Escribir un buen prompt es fundamental para obtener resultados precisos y útiles de modelos de lenguaje como yo. Un prompt bien diseñado guía al modelo para entender la tarea, el contexto y el tipo de respuesta que esperas. Aquí te dejo una guía completa con consejos y ejemplos:
## 
## **I. Componentes Clave de un Buen Prompt:**
## 
## *   **Claridad y Especificidad:**
##     *   **Sé preciso sobre lo que quieres:** Evita ambigüedades y lenguaje vago.
##     *   **Define el objetivo:** ¿Qué quieres lograr con la respuesta? (informar, persuadir, crear, etc.)
##     *   **Especifica el formato:** ¿Necesitas una lista, un párrafo, un código, una tabla?
## 
## *   **Contexto:**
##     *   **Proporciona la información de fondo necesaria:** Asegúrate de que el modelo entienda la situación.
##     *   **Define el rol del modelo (opcional):** Puedes pedirle que actúe como un experto, un personaje, etc.
## 
## *   **Instrucciones:**
##     *   **Usa verbos de acción claros:** "Escribe", "Resume", "Compara", "Traduce", "Explica", "Crea", "Analiza", etc.
##     *   **Establece límites y restricciones:** Define la longitud, el tono, el estilo, etc.
##     *   **Incluye ejemplos (si es necesario):** Un ejemplo de la respuesta deseada puede ser muy útil.
## 
## *   **Entrada (Input):**
##     *   **Proporciona la información o texto base:** Si necesitas un resumen, análisis, traducción, etc., proporciona el texto original.
##     *   **Utiliza marcadores claros:** Si tienes varios elementos de entrada, sepáralos con delimitadores (por ejemplo, "***" o "---").
## 
## **II. Estrategias y Técnicas Avanzadas:**
## 
## *   **"Few-Shot Learning":**
##     *   **Proporciona algunos ejemplos de entrada y salida esperada.** Esto ayuda al modelo a aprender el patrón y a generalizar a nuevas entradas.
##     *   **Útil para tareas complejas con requisitos específicos.**
## 
## *   **"Chain-of-Thought Prompting":**
##     *   **Pide al modelo que explique su razonamiento paso a paso.** Esto puede mejorar la precisión y la transparencia, especialmente en tareas complejas.
##     *   **Ejemplo:** "Primero, identifica los puntos clave. Luego, analiza la relación entre ellos. Finalmente, escribe un resumen."
## 
## *   **"Role-Playing":**
##     *   **Asigna un rol al modelo.** Esto puede influir en el tono, el estilo y el enfoque de la respuesta.
##     *   **Ejemplo:** "Actúa como un experto en historia del arte y explica el significado del cuadro 'La noche estrellada'."
## 
## *   **Usa palabras clave específicas:**
##     *   **Palabras clave relevantes para el tema o dominio.** Ayudan a enfocar la respuesta.
## 
## *   **Iteración y Refinamiento:**
##     *   **Experimenta con diferentes prompts y evalúa los resultados.**
##     *   **Refina el prompt basándote en la respuesta del modelo.**
##     *   **Considera la temperatura (aleatoriedad) del modelo si la opción está disponible.** Una temperatura más baja generalmente produce respuestas más deterministas.
## 
## **III. Ejemplos de Buenos y Malos Prompts:**
## 
## *   **Mal Prompt:** "Escribe sobre la Revolución Francesa." (Demasiado vago)
## *   **Buen Prompt:** "Escribe un ensayo de 500 palabras sobre las causas y consecuencias de la Revolución Francesa, incluyendo un análisis del papel de la Ilustración y el impacto en la monarquía europea."
## 
## *   **Mal Prompt:** "Resume este texto." (Falta contexto)
## *   **Buen Prompt:** "Resume el siguiente texto, enfocándote en los principales argumentos del autor y omitiendo ejemplos específicos: [TEXTO]"
## 
## *   **Mal Prompt:** "Explica la física cuántica." (Demasiado amplio)
## *   **Buen Prompt:** "Explica el principio de superposición en la física cuántica, utilizando una analogía comprensible para alguien sin conocimientos previos en física."
## 
## *   **Ejemplo Few-Shot:**
##     ```
##     Texto: "El gato es negro." Traducción al francés: "Le chat est noir."
##     Texto: "El perro es blanco." Traducción al francés: "Le chien est blanc."
##     Texto: "El pájaro es azul." Traducción al francés:
##     ```
## 
## **IV. Errores Comunes que Debes Evitar:**
## 
## *   **Ambigüedad:** Usa lenguaje claro y preciso.
## *   **Instrucciones contradictorias:** Asegúrate de que las instrucciones sean coherentes.
## *   **Falta de contexto:** Proporciona suficiente información de fondo.
## *   **Suposiciones:** No asumas que el modelo sabe algo que no le has dicho.
## *   **Expectativas poco realistas:** Recuerda que el modelo tiene limitaciones.
## 
## **V. Herramientas Útiles:**
## 
## *   **Playground de OpenAI (si usas la API de OpenAI):** Te permite experimentar con diferentes prompts y configuraciones.
## *   **Otras plataformas de IA:** Muchas plataformas de IA tienen interfaces que facilitan la creación de prompts.
## 
## **En resumen:**
## 
## Un buen prompt es específico, claro, proporciona contexto e incluye instrucciones precisas.  No tengas miedo de experimentar y refinar tus prompts hasta obtener los resultados deseados. La práctica es la clave para dominar el arte de escribir buenos prompts.  Cuanto más practiques, mejor entenderás cómo funciona el modelo y cómo obtener las respuestas que necesitas.

Segundo paso, diseño del prompt

Perfecto. Vamos ahora a diseñar un prompt simple para nuestra tarea.

prompt <- "A continuación vas a recibir un texto de una reseña de compra online.
Quisiera que la clasifiques positiva o negativa usando las siguientes categorías:

- Positiva: la reseña es positiva
- Negativa: la reseña es negativa

Quisiera que expliques paso a paso tu razonamiento.

La salida debería tener el siguiente formato:

clasif: seguido de la clasificación | expl: seguido de la explicación

Texto:
"

Cargamos el dataset. En realidad, vamos a cargar unos 1259 datos del dataset original para hacer esta prueba.

df_list <- read_rds("../data/reviews_amazon_listsplit.rds")[[1]]
head(df_list)
## # A tibble: 6 × 4
##   review_id  review_body                                  stars product_category
##   <chr>      <chr>                                        <chr> <chr>           
## 1 es_0179810 Fácil de instalar gracias al marco de posic… Post… wireless        
## 2 es_0506233 De momento y llevo ya media botellita,no he… Nega… beauty          
## 3 es_0627963 El producto al primer día dio problemas ser… Nega… wireless        
## 4 es_0629229 Se rompen con mirarlos                       Nega… sports          
## 5 es_0776446 Bonitos los colores. Relación calidad preci… Post… wireless        
## 6 es_0327599 Las pegatinas son mates y traslúcidas. Se v… Nega… office_product

Tercer paso, hacemos las request, llamamos al LLM

Y vamos a iterar por cada una de las filas del dataset y para cada fila, vamos a hacer una request a la API de Gemini con dos inputs:

  1. el prompt que diseñamos más arriba
  2. el texto de la reseña (review_body)
rtas <- list()
for (i in 1:nrow(df_list)){
                id <- df_list$review_id[i]
                Sys.sleep(4.5)

                cat("Procesando comentario", i, "de", nrow(df_list), "\n")
                rta <- gemini(paste0(prompt, df_list$review_body[i]))
                cat(rta)
                rtas[[id]] <- rta
                }

El código anterior itera sobre el dataframe de reseñas de productos (almacenadas en df_list), hace la consulta a la API de Gemini, usando el prompt

Vamos paso a paso:

  1. Inicialización: Se crea una lista vacía llamada rtas que almacenará las respuestas de Gemini.

  2. Bucle for: Se itera a través de las dos primeras reseñas en df_list (índice i de 1 a 2).

  3. Extracción de ID: En cada iteración, se extrae el ID de la reseña actual (review_id) y se almacena en la variable id.

  4. Pausa: Se introduce una pausa de 4.5 segundos utilizando Sys.sleep(4.5) para evitar sobrecargar la API de Gemini.

  5. Mensaje de progreso: Se imprime un mensaje en la consola indicando el progreso del procesamiento: “Procesando comentario [i] de [total de reseñas]”.

  6. Llamada a Gemini: Se utiliza la función gemini() para enviar una consulta al modelo de lenguaje.

  • La consulta se construye concatenando un prompt predefinido con el texto de la reseña actual (df_list$review_body[i]).
  • El resultado de la consulta (la respuesta de Gemini) se almacena en la variable rta.
  1. Impresión de la respuesta: Se imprime la respuesta de Gemini en la consola utilizando cat(rta).

  2. Almacenamiento de la respuesta: La respuesta de Gemini (rta) se almacena en la lista rtas, utilizando el ID de la reseña como clave.

  3. Fin del bucle: El proceso se repite para las dos primeras reseñas.

Para ganaer tiempo, ya hemos corrido las consultas al LLM. Vamos a cargar los resultados.

rtas_llms <- read_rds('../data/1_reviews_llms_prompt1.rds')

head(rtas_llms)
## $es_0179810
##                                                                                                                                                                                                                                                                           text 
## "clasif: Positiva | expl: La reseña destaca aspectos positivos como la facilidad de instalación, el centrado perfecto y la similitud con el producto original de Apple. Aunque menciona la falta de información sobre la durabilidad, no la considera un aspecto negativo. \n" 
## 
## $es_0506233
##                                                                                                                                                                                                                                                    text 
## "clasif: Negativa | expl: La reseña menciona que \"no he notado ningún cambio\" después de usar media botellita del producto. Esto indica que el producto no ha cumplido con las expectativas del usuario, lo que sugiere una experiencia negativa. \n" 
## 
## $es_0627963
##                                                                                                                                                                                                                                                                                               text 
## "clasif: Negativa | expl: La reseña comienza describiendo un problema serio con el producto al primer día de uso, lo que indica una experiencia negativa. Aunque el vendedor ofreció un reembolso, la reseña enfatiza la mala señal que representa un fallo tan temprano en un producto nuevo. \n" 
## 
## $es_0629229
##                                                                                                                                                                                                                                                                text 
## "clasif: Negativa | expl: La frase \"Se rompen con mirarlos\" es una expresión negativa que implica fragilidad y falta de calidad.  Sugiere que el producto es tan débil que se rompe con solo mirarlo, lo que indica una experiencia negativa con el producto. \n" 
## 
## $es_0776446
##                                                                                                                                                                                                                                                                                                                                                                                                                        text 
## "clasif: Positiva | expl: La reseña destaca aspectos positivos como la estética (\"Bonitos los colores\"), la relación calidad-precio (\"Relación calidad precio adecuada\"), la rapidez del envío (\"Fue muy rápido en envío\") y la utilidad del producto (\"se adapta a lo que necesitaba\"). La frase final \"Contenta con la compra\" confirma la satisfacción general con el producto y la experiencia de compra. \n" 
## 
## $es_0327599
##                                                                                                                                                                                                                                          text 
## "clasif: Negativa | expl: La reseña menciona que las pegatinas son \"blanquecinas\" y que no eran lo que el usuario buscaba. Esto indica que el producto no cumplió con las expectativas del comprador, por lo que la reseña es negativa. \n"

Cuarto paso, formateando los resultados

Como habrán notado en la salida anterior, las respuestas del modelo son básicamente un largo character. Toda la respuesta (la etiqueta y la explicación) están juntas. No obstante, hemos diseñado el prompt para que aparezca con algunos delimitadores que nos hagan más fácil la extracció de la información. Para ello vamos a definir dos funciones:

parse_clasif <- function(string){
        clasif <- str_extract(string, "(?<=clasif: ).*?(?= \\|)")
        return(clasif)
}

La función parse_clasif está diseñada para extraer la clasificación de una reseña de un texto, específicamente la parte que indica si la reseña es “Positiva” o “Negativa”.

El objetivo principal de la función es identificar y extraer la clasificación (“Positiva” o “Negativa”) de una cadena de texto que contiene la respuesta de un modelo de lenguaje.

¿Cómo funciona?

1. Entrada: Recibe una cadena de texto (string) que se espera que contenga la clasificación de una reseña en un formato específico, por ejemplo: clasif: Positiva | expl: El usuario expresó satisfacción con el producto.

2. str_extract: Utiliza la función str_extract de la librería stringr para extraer la parte de la cadena que coincide con un patrón específico.

3. Patrón: El patrón utilizado es una expresión regular: (?<=clasif: ).*?(?= \\|). Vamos a desglosarlo:

  • (?<=clasif: ): Esta parte busca la cadena “clasif:” y asegura que la coincidencia comience justo después de ella. Se conoce como una “aserción lookbehind positiva”.
  • .*?: Esta parte coincide con cualquier caracter (.) cero o más veces (*), pero de la manera más perezosa posible (?). Esto significa que intentará encontrar la coincidencia más corta posible.
  • (?= \\|): Esta parte busca el caracter “|” (que debe escaparse con una barra invertida \\ en la expresión regular) y asegura que la coincidencia termine justo antes de él. Se conoce como una “aserción lookahead positiva”.

4. Extracción: str_extract devuelve la parte de la cadena que coincide con el patrón completo. En este caso, sería “Positiva” o “Negativa”.

5. Retorno: La función devuelve el valor extraído, que representa la clasificación de la reseña.

parse_expl <- function(string){
        expl <- str_trim(str_replace_all(str_extract(string, "(?<=expl).*"), ":|'", ""))
        return(expl)
}

Ahora bien, parse_expl está diseñada para extraer la explicación de una reseña de un texto, específicamente la parte que explica por qué la reseña fue clasificada como “Positiva” o “Negativa”.

1. Entrada: Recibe una cadena de texto (string) que se espera que contenga la explicación de una reseña en un formato específico, por ejemplo: “clasif: Positiva | expl: El usuario expresó satisfacción con el producto”. 2. str_extract: Similar a parse_clasif, utiliza str_extract de la librería stringr para extraer la parte de la cadena que coincide con un patrón específico. 3. Patrón: El patrón utilizado es (?<=expl).*. - (?<=expl): Es una aserción “lookbehind positiva” que busca la cadena “expl” y asegura que la coincidencia comience justo después de ella. - .*: Esta parte coincide con cualquier caracter (.) cero o más veces (*), lo que significa que selecciona todo el texto después de “expl:”.

4. str_replace_all: Se utiliza para limpiar la explicación extraída. Reemplaza todos los caracteres “:” y “|” por un espacio vacío. Esto ayuda a eliminar cualquier rastro del formato original. 5. str_trim: Se aplica para eliminar cualquier espacio en blanco al principio y al final de la explicación, asegurando que la salida esté limpia. 6. Retorno: La función devuelve la explicación extraída y limpia, que representa el razonamiento detrás de la clasificación de la reseña.

Ejemplo:

Si la entrada es “clasif: Positiva | expl: El usuario expresó satisfacción con el producto.”, la función parse_expl devolverá “El usuario expresó satisfacción con el producto”.

rtas_df <- rtas_llms %>%
        enframe(name = "review_id", value = "resp") %>%
        unnest(cols = c(resp)) %>%
        mutate(llm_expl = parse_expl(resp),
               llm_stars = parse_clasif(resp))
rtas_df
## # A tibble: 1,248 × 4
##    review_id  resp                                            llm_expl llm_stars
##    <chr>      <chr>                                           <chr>    <chr>    
##  1 es_0179810 "clasif: Positiva | expl: La reseña destaca as… "La res… Positiva 
##  2 es_0506233 "clasif: Negativa | expl: La reseña menciona q… "La res… Negativa 
##  3 es_0627963 "clasif: Negativa | expl: La reseña comienza d… "La res… Negativa 
##  4 es_0629229 "clasif: Negativa | expl: La frase \"Se rompen… "La fra… Negativa 
##  5 es_0776446 "clasif: Positiva | expl: La reseña destaca as… "La res… Positiva 
##  6 es_0327599 "clasif: Negativa | expl: La reseña menciona q… "La res… Negativa 
##  7 es_0481564 "clasif: Negativa | expl: La reseña es negativ… "La res… Negativa 
##  8 es_0842471 "clasif: Positiva | expl: El usuario destaca l… "El usu… Positiva 
##  9 es_0240232 "clasif: Negativa | expl: El texto menciona qu… "El tex… Negativa 
## 10 es_0774932 "clasif: Negativa | expl: La reseña menciona q… "La res… Negativa 
## # ℹ 1,238 more rows

Este código toma una lista de respuestas, la transforma en un dataframe estructurado y utiliza las funciones parse_expl y parse_clasif para extraer la explicación y la clasificación de cada reseña, almacenando esta información en nuevas columnas del dataframe. El resultado final es un dataframe llamado rtas_df que contiene la información de las reseñas de forma organizada.

1. rtas %>%: Esto inicia una secuencia de operaciones con el operador %>% (pipe) de la librería magrittr. El operador %>% toma el resultado de la expresión anterior y lo pasa como primer argumento a la siguiente función. En este caso, rtas es la lista de respuestas que se procesará.

2. enframe(name = "review_id", value = "resp"): Esta función de la librería tibble transforma la lista rtas en un dataframe. Cada elemento de la lista se convierte en una fila del dataframe. Los nombres de los elementos de la lista se almacenan en una columna llamada “review_id”, y los valores de los elementos se almacenan en una columna llamada “resp”.

3. unnest(cols = c(resp)): Esta función de la librería tidyr se utiliza para “desanidar” la columna “resp”, que presumiblemente contiene una lista de respuestas para cada “review_id”. unnest expande la columna “resp” de modo que cada elemento de la lista se convierta en una fila separada en el dataframe.

**4. mutate(...):** Esta función de la librería dplyr crea nuevas columnas en el dataframe basándose en los valores de las columnas existentes. Dentro de mutate se realizan dos operaciones:

  • llm_expl = parse_expl(resp): Se crea una nueva columna llamada “llm_expl” aplicando la función parse_expl a la columna “resp”. Esto extrae la explicación de la reseña del texto de la respuesta y la almacena en la nueva columna.
  • llm_stars = parse_clasif(resp): Se crea otra nueva columna llamada “llm_stars” aplicando la función parse_clasif a la columna “resp”. Esto extrae la clasificación de la reseña (Positiva o Negativa) del texto de la respuesta y la almacena en la nueva columna.
rtas_df <- rtas_df %>%
        left_join(df_list)  %>%
        mutate(stars = case_when(
                stars == "Postiva" ~ "Positiva",
                TRUE ~ stars
        )) %>%
        mutate(across(c(stars, llm_stars), as.factor))
## Joining with `by = join_by(review_id)`

Este código combina el dataframe rtas_df con información adicional del dataframe df_list a través de un left_join. Además, corrige un posible error tipográfico en la columna “stars”, cambiando “Postiva” por “Positiva”. El resultado es un dataframe rtas_df actualizado y enriquecido con información de df_list y con una corrección en la columna “stars”.

1. left_join(df_list): - Esta función, proveniente de la librería dplyr, realiza una unión (join) entre el dataframe rtas_df y el dataframe df_list. - Se trata de un left_join, lo que significa que todas las filas de rtas_df se conservarán en el resultado final. - La unión se realiza buscando coincidencias entre las columnas que tienen el mismo nombre en ambos dataframes. Si hay una columna llamada, por ejemplo, “review_id” en ambos dataframes, se utilizará para unir las filas correspondientes. - El resultado de esta operación es un nuevo dataframe que contiene todas las columnas de rtas_df y todas las columnas de df_list, combinadas según las coincidencias encontradas.

**2. mutate(stars = case_when(…)):** - Esta función, también dedplyr, se utiliza para modificar o crear nuevas columnas en el dataframe. - En este caso, se está modificando la columna "stars". - La funcióncase_whense utiliza para realizar una corrección en los valores de la columna "stars". - Específicamente, si el valor de "stars" es igual a "Postiva" (con una 'v' en lugar de una 'i'), se cambiará a "Positiva" (con una 'i'). - La condiciónTRUE ~ stars` indica que si ninguna de las condiciones anteriores se cumple (es decir, si “stars” no es igual a “Postiva”), se mantendrá el valor original de “stars”.

rtas_df
## # A tibble: 1,248 × 7
##    review_id  resp         llm_expl llm_stars review_body stars product_category
##    <chr>      <chr>        <chr>    <fct>     <chr>       <fct> <chr>           
##  1 es_0179810 "clasif: Po… "La res… Positiva  Fácil de i… Posi… wireless        
##  2 es_0506233 "clasif: Ne… "La res… Negativa  De momento… Nega… beauty          
##  3 es_0627963 "clasif: Ne… "La res… Negativa  El product… Nega… wireless        
##  4 es_0629229 "clasif: Ne… "La fra… Negativa  Se rompen … Nega… sports          
##  5 es_0776446 "clasif: Po… "La res… Positiva  Bonitos lo… Posi… wireless        
##  6 es_0327599 "clasif: Ne… "La res… Negativa  Las pegati… Nega… office_product  
##  7 es_0481564 "clasif: Ne… "La res… Negativa  Compré est… Nega… kitchen         
##  8 es_0842471 "clasif: Po… "El usu… Positiva  Con estas … Nega… home            
##  9 es_0240232 "clasif: Ne… "El tex… Negativa  mantiene l… Nega… home            
## 10 es_0774932 "clasif: Ne… "La res… Negativa  La histori… Nega… book            
## # ℹ 1,238 more rows

Usando formatos estructurados

Muchas veces, una forma más consistente de trabajar con LLMs es requerir outputs estructurados para las respuestas. En el caso de gemini.r, esto se logra mediante la función gemini_structured(). La misma es prácticamente igual a la que usamos antes, pero tiene un argumento más: un schema, es decir, un formato de salida estructurado.

Veamos como se usa. Primero generamos un objeto schema que, a los efectos, de R es una lista; indica al modelo que debe devolver una lista de objetos con dos propiedades de tipo texto.

schema <- list(
  type = "ARRAY",
  items = list(
    type = "OBJECT",
    properties = list(
      clasif = list(type = "STRING"),
      expl   = list(type = "STRING")
    ),
    propertyOrdering = c("clasif", "expl")
  )
)

Luego, hacemos las consultas, al LLM usando la función gemini_structured:

rtas <- list()

for (i in 1:5){
  id  <- df_list$review_id[i]
  Sys.sleep(4.5)  # pausa para evitar límite de tasa

  cat("Procesando comentario", i, "de", nrow(df_list), "\n")

  rta <- gemini_structured(
    paste0(prompt, df_list$review_body[i]),
    schema = schema
  )

  cat(rta)  # muestra el JSON crudo
  rtas[[id]] <- fromJSON(rta)
}
## Procesando comentario 1 de 1259
## Gemini is generating a structured response...
## [
##   {
##     "clasif": "Positiva",
##     "expl": "Paso 1: La reseña destaca la facilidad de instalación y el centrado perfecto del cristal, lo cual son puntos muy positivos. Paso 2: A pesar de mencionar un pequeño levantamiento de 1mm en los laterales, se califica como 'casi inapreciable', minimizando su impacto negativo. Paso 3: Se compara favorablemente con el producto original de Apple, lo que refuerza la percepción positiva. Paso 4: La mención sobre la durabilidad es una observación futura y no un problema actual. Por lo tanto, el sentimiento general de la reseña es claramente positivo."
##   }
## ]Procesando comentario 2 de 1259
## Gemini is generating a structured response...
## [
##   {
##     "clasif": "Negativa",
##     "expl": "El usuario indica claramente que 'no he notado ningún cambio' después de usar la mitad del producto. Esto sugiere que el producto no está cumpliendo con sus expectativas o no está produciendo los efectos deseados, lo cual es una experiencia negativa para el consumidor."
##   }
## ]Procesando comentario 3 de 1259
## Gemini is generating a structured response...
## [{"clasif":"Negativa","expl":"Paso 1: El usuario expresa que el producto 'dio problemas serios' al primer día de uso. Paso 2: Esta falla inicial fue considerada una 'muy mala señal' para un producto nuevo, lo que llevó a la decisión de devolverlo. Paso 3: Aunque se destaca positivamente la gestión del vendedor al no presentar inconvenientes con el reembolso, el foco principal de la reseña sobre el *producto* es su mal funcionamiento y la necesidad de devolución. Por lo tanto, la experiencia global con el producto es negativa."}]Procesando comentario 4 de 1259
## Gemini is generating a structured response...
## [{"clasif": "Negativa", "expl": "Paso 1: La frase \"Se rompen con mirarlos\" es una expresión idiomática que denota que el producto es sumamente frágil y de baja calidad. Paso 2: Esta observación implica una experiencia muy insatisfactoria con la durabilidad del artículo. Paso 3: Por lo tanto, la reseña se clasifica como negativa."}]Procesando comentario 5 de 1259
## Gemini is generating a structured response...
## [
##   {
##     "clasif": "Positiva",
##     "expl": "El texto presenta múltiples frases con connotación positiva: 'Bonitos los colores' valora la estética, 'Relación calidad precio adecuada' indica satisfacción con el valor, 'muy rápido en envío' elogia el servicio de entrega y 'se adapta a lo que necesitaba' resalta la utilidad del producto. La reseña culmina con la declaración 'Contenta con la compra', confirmando una experiencia globalmente positiva. Todos los comentarios son favorables, sin incluir aspectos negativos."
##   }
## ]

Cada iteración:

  • envía una reseña al modelo,
  • recibe una respuesta estructurada,
  • convierte el JSON a objeto R,
  • y lo almacena bajo su review_id.

Finalmente, unificamos todo en una tibble

test <- rtas %>%
  map_dfr(as_tibble, .id = "name")

test
## # A tibble: 5 × 3
##   name       clasif   expl                                                      
##   <chr>      <chr>    <chr>                                                     
## 1 es_0179810 Positiva "Paso 1: La reseña destaca la facilidad de instalación y …
## 2 es_0506233 Negativa "El usuario indica claramente que 'no he notado ningún ca…
## 3 es_0627963 Negativa "Paso 1: El usuario expresa que el producto 'dio problema…
## 4 es_0629229 Negativa "Paso 1: La frase \"Se rompen con mirarlos\" es una expre…
## 5 es_0776446 Positiva "El texto presenta múltiples frases con connotación posit…

El resultado es un tibble con columnas:

  • name: identificador (review_id)
  • clasif: etiqueta asignada
  • expl: explicación textual

Resultados

Vemamos ahora, entonces, cómo funcionó nuestro modelo. Vamos a calcular las métricas habituales.

library(tidymodels)
## ── Attaching packages ────────────────────────────────────── tidymodels 1.3.0 ──
## ✔ broom        1.0.8     ✔ rsample      1.3.0
## ✔ dials        1.4.0     ✔ tune         1.3.0
## ✔ infer        1.0.8     ✔ workflows    1.2.0
## ✔ modeldata    1.4.0     ✔ workflowsets 1.1.0
## ✔ parsnip      1.3.1     ✔ yardstick    1.3.2
## ✔ recipes      1.3.0
## ── Conflicts ───────────────────────────────────────── tidymodels_conflicts() ──
## ✖ scales::discard()   masks purrr::discard()
## ✖ dplyr::filter()     masks stats::filter()
## ✖ recipes::fixed()    masks stringr::fixed()
## ✖ jsonlite::flatten() masks purrr::flatten()
## ✖ dplyr::lag()        masks stats::lag()
## ✖ yardstick::spec()   masks readr::spec()
## ✖ recipes::step()     masks stats::step()
accuracy(rtas_df, stars, llm_stars) %>%
bind_rows(precision(rtas_df, stars, llm_stars)) %>%
bind_rows(recall(rtas_df, stars, llm_stars)) %>%
bind_rows(f_meas(rtas_df, stars, llm_stars))
## # A tibble: 4 × 3
##   .metric   .estimator .estimate
##   <chr>     <chr>          <dbl>
## 1 accuracy  binary         0.900
## 2 precision binary         0.830
## 3 recall    binary         0.993
## 4 f_meas    binary         0.904
conf_mat(rtas_df, stars, llm_stars)
##           Truth
## Prediction Negativa Positiva
##   Negativa      589      121
##   Positiva        4      532

Análisis de errores

Un punto importante del trabajo con LLMs (y con cualquier modelo de clasificación de texto o no) es tratar de entender de alguna forma los errores que cometen.

Examinar las instancias mal clasificadas permite identificar patrones de error y comprender las limitaciones del modelo. Esto puede revelar sesgos en los datos de entrenamiento, características mal interpretadas o la necesidad de ajustes en el preprocesamiento del texto. Un análisis de errores riguroso proporciona información valiosa para refinar el modelo, mejorar su precisión y adaptarlo a casos de uso específicos.

Para ello vamos a realizar un análisis de casos individuales. Vamos a examinar manualmente los ejemplos mal clasificados para comprender por qué el modelo se equivocó. Puede revelar patrones de error específicos o problemas con el preprocesamiento del texto. Es útil para identificar sesgos en los datos de entrenamiento.

Veamos primero una muestra de 5 “falsos negativos”:

rtas_df %>%
        filter(stars == "Positiva" & llm_stars == "Negativa") %>%
        sample_n(5) %>%
        select(review_body, llm_expl)
## # A tibble: 5 × 2
##   review_body                                                           llm_expl
##   <chr>                                                                 <chr>   
## 1 pará los huesos y dolores                                             "La fra…
## 2 Interesante sin embargo a veces tedioso para leer                     "La res…
## 3 Es muy bonito, como todos los del autor, pero me parece que no es ad… "La res…
## 4 La sensación en la mano es de endebles. Diría que no van a durar dem… "La res…
## 5 Cumple su función pero la calidad de enlace con la radio genera ruid… "La res…

Ahora, los “falsos positivos”, según la matriz de confusión solamente tenemos 4, así que podemos analizarlos todos

rtas_df %>%
        filter(stars == "Negativa" & llm_stars == "Positiva") %>%
        select(review_body, llm_expl)
## # A tibble: 4 × 2
##   review_body                                                           llm_expl
##   <chr>                                                                 <chr>   
## 1 Con estas cubiteras no tengo este problema, ya que por su diseño, no… "El usu…
## 2 Fue un regalo para navidades desconozco las opciones pero visto cali… "La res…
## 3 Ha llegado bien , aun no lo he visto con atencio. Pues es un regalo … "El usu…
## 4 Se calienta muy rápido.                                               "El tex…

¿Qué pueden decir de los errores que comete el modelo?

Actividad

  • Comparar la perfomance de los LLMs con la de los modelos de clasificación que vimos la semana pasada. ¿Cuál funciona mejor?
  • Pensar cómo podrían reformular el prompt para hacerlo más eficiente.