Sesión 4.4: Laboratorio 8 - Codificación cualitativa y confiabilidad con LLMs
Lo que vamos a hacer
quallmerLa idea central
Seguimos con OpenRouter. Necesitan la API key del Lab 7
| Parte | Contenido | Duración |
|---|---|---|
| 1 | Codificar y medir confiabilidad con quallmer |
~25 min |
| 2 | Auditoría de sesgos por género y país | ~15 min |
| 3 | Datos sintéticos y su validez | ~10 min |
| – | Ejercicios; apéndice: mini-proyecto | extra |
Usamos un corpus nuevo: 15 fragmentos de discursos políticos con un puntaje humano de retórica liberal-iliberal
El flujo de trabajo
qlm_codebook(): definir el libro de códigosqlm_code(): codificar los textosqlm_validate(): validar contra humanos (exactitud)qlm_replicate() + qlm_compare(): confiabilidadqlm_trail(): trazabilidadTratamos al LLM como un codificador más, y lo sometemos al mismo escrutinio que a un humano
[1] FALSE
R> discursos <- read_csv("datos/discursos_ideologia.csv", show_col_types = FALSE)
R> # Lean el archivo directo desde la web:
R> # discursos <- read_csv("https://raw.githubusercontent.com/danilofreire/introduccion-ia-ucu/main/clases/dia-04/datos/discursos_ideologia.csv", show_col_types = FALSE)
R>
R> head(discursos)# A tibble: 6 × 4
id orador texto score_humano
<dbl> <chr> <chr> <dbl>
1 1 Orador A La patria está por encima de todo. Quienes no res… -9
2 2 Orador B El pueblo verdadero sabe quiénes son sus enemigos… -8
3 3 Orador C Necesitamos un líder fuerte que ponga orden de un… -7
4 4 Orador D Debemos proteger nuestra identidad nacional frent… -5
5 5 Orador E La estabilidad y la seguridad deben ser la priori… -4
6 6 Orador F El gobierno tiene que equilibrar el orden público… -1
quallmer pide su propia copia de ellmer. La instalación tarda un minuto la primera vez
Nuestro corpus
score_humano: el puntaje que le dio una personaquallmerR> codebook_ideologia <- qlm_codebook(
+ name = "retorica_liberal",
+ instructions = "Analizá el estilo retórico de este fragmento de discurso político latinoamericano.
+ Retórica ILIBERAL (puntajes negativos): nacionalismo, apelaciones al orden y la
+ seguridad, tradición, mano dura, distinción entre 'nosotros' y 'ellos', rechazo al pluralismo.
+ Retórica LIBERAL (puntajes positivos): derechos individuales, tolerancia, pluralismo,
+ libertades civiles, derechos de las minorías, Estado de derecho, sociedad abierta.
+ Un puntaje de 0 indica retórica neutral o mixta.",
+ schema = type_object(
+ score = type_integer("Puntaje de -10 (iliberal) a +10 (liberal)"),
+ explicacion = type_string("Breve justificación del puntaje")
+ ),
+ role = "Sos un cientista político experto en analizar retórica política.",
+ levels = list(score = "interval", explicacion = "nominal")
+ )El schema usa los tipos de ellmer (Lab 7). type_integer() pide un número entero; levels le dice a quallmer que score es una variable de intervalo, para calcular las métricas correctas. Más información en https://quallmer.github.io/quallmer/reference/qlm_codebook.html
# quallmer coded object
# Run: nemotron
# Codebook: retorica_liberal
# Model: openrouter/nvidia/nemotron-3-super-120b-a12b:free
# Units: 15
# A tibble: 15 × 3
.id score explicacion
* <int> <int> <chr>
1 1 -3 "El fragmento presenta múltiples componentes de retórica ilibera…
2 2 -3 "El fragmento enfatiza una visión excluyente del pueblo, identif…
3 3 -3 "El fragmento presenta una retórica claramente illiberal. Se enf…
4 4 -2 "El fragmento enfatiza la protección de la identidad nacional y …
5 5 -4 "El discurso enfatiza la estabilidad, la seguridad y el orden, i…
6 6 2 "El fragmento enfatiza la necesidad de equilibrar el orden públi…
7 7 2 "El fragmento enfatiza evidencia, diálogo amplio y soluciones pr…
8 8 3 "El fragmento enfatiza explícitamente valores liberales: 'libert…
9 9 2 "El fragmento destaca el respeto igualitario ante la ley sin imp…
10 10 3 "El fragmento enfatiza el acceso igualitario a la educación (der…
11 11 2 "El fragmento exhibe claramente retórica LIBERAL. Defiende explí…
12 12 2 "El fragmento enfatiza instituciones democráticas, independencia…
13 13 2 "El fragmento presenta una retórica claramente liberal según los…
14 14 3 "El fragmento enfatiza libertades civiles, igualdad ante la ley,…
15 15 -3 "El fragmento exhibe retórica claramente iliberal, con múltiples…
Advertencia
Con max_active alto, los modelos :free devuelven errores 400. Bájenlo a 2-3, usen muestras chicas, o pasen a un modelo de pago. Para corpus grandes conviene un modelo local (Ollama, día 5)
# A tibble: 15 × 3
orador score_humano score_llm
<chr> <dbl> <int>
1 Orador A -9 -3
2 Orador B -8 -3
3 Orador C -7 -3
4 Orador D -5 -2
5 Orador E -4 -4
6 Orador F -1 2
7 Orador G 0 2
8 Orador H 1 3
9 Orador I 4 2
10 Orador J 5 3
11 Orador K 6 2
12 Orador L 7 2
13 Orador M 8 2
14 Orador N 9 3
15 Orador O -8 -3
Miren la diferencia: el LLM tiende a ser más conservador, sus puntajes se acercan al centro (0). Capta bien el orden (quién es más iliberal que quién), pero comprime la magnitud. Esto es justo lo que la validación va a cuantificar
R> # El "gold standard": el puntaje humano de cada discurso
R> gold <- qlm_humancoded(
+ tibble(.id = seq_len(nrow(discursos)), score = discursos$score_humano),
+ name = "humano",
+ codebook = codebook_ideologia
+ )
R>
R> # ¿Qué tan cerca está el LLM del juicio humano?
R> # qlm_validate devuelve un tibble; lo mostramos como una tabla
R> validacion <- qlm_validate(codificado, gold = gold, by = "score", level = "interval")
R> as_tibble(validacion) |> select(measure, value)# A tibble: 4 × 2
measure value
<chr> <dbl>
1 r 0.873
2 mae 3.67
3 rmse 4.07
4 icc 0.650
R> # Codificar los MISMOS textos: confiabilidad test-retest
R> codificado_b <- qlm_replicate(codificado, name = "nemotron_b")
R>
R> # ¿Coinciden las dos corridas? (acuerdo entre codificadores)
R> confiabilidad <- qlm_compare(
+ codificado, codificado_b,
+ by = "score", level = "interval", tolerance = 2
+ )
R> as_tibble(confiabilidad) |> select(measure, value)# A tibble: 4 × 2
measure value
<chr> <dbl>
1 percent_agreement 1
2 alpha_interval 0.986
3 icc 0.987
4 r 0.988
qlm_replicate() repite la codificación. Con el mismo modelo mide robustez. Cambiando model = a otro modelo, mide confiabilidad entre modelos. Acá usamos el mismo modelo porque los :free se saturan; pero la lógica es idéntica
alpha_interval (Krippendorff): el estándar para acuerdo entre codificadores (< 0.67 poco confiable, 0.67-0.80 aceptable, > 0.80 bueno). tolerance es la tolerancia para considerar que dos puntajes “coinciden” (ej. ±2 puntos)
Validez (vs humanos) y confiabilidad (entre corridas) son cosas distintas. Un modelo puede ser muy estable y aun así estar sistemáticamente sesgado
R> # Registro completo: codebook, modelo, parámetros, fecha, las dos corridas
R> # qlm_trail() escribe el .rds y devuelve el registro de forma invisible,
R> # así que lo asignamos y lo mostramos en una línea aparte
R> trail <- qlm_trail(codificado, codificado_b, confiabilidad, path = "trail_ideologia")
R> trail# quallmer audit trail (3 runs)
1. nemotron (original)
2026-06-10 06:29 | openrouter/nvidia/nemotron-3-super-120b-a12b:free
Codebook: retorica_liberal
2. nemotron_b (parent: nemotron)
2026-06-10 07:28 | openrouter/nvidia/nemotron-3-super-120b-a12b:free
Codebook: retorica_liberal
3. comparison_b8fe6d3e (parents: nemotron, nemotron_b)
2026-06-10 07:28 | unknown
Comparison: interval level | 10 subjects | 2 raters
qlm_trail() sigue la idea de Lincoln y Guba (1985) sobre confiabilidad en investigación cualitativa.rds todo lo necesario para que otra persona replique su codificación| Diccionario / LDA (Día 3) | LLM solo (Lab 7) | LLM + quallmer (Lab 8) |
|
|---|---|---|---|
| Qué da | Una clasificación | Una clasificación | Una medida con validación |
| Pregunta | ¿Agrupa el texto? | ¿Puede hacerlo? | ¿Puedo confiar en el número? |
| Reporta | Nada por sí solo | Nada por sí solo | Validez, confiabilidad, trazabilidad |
El salto del Lab 7 al Lab 8 no es técnico, es metodológico: pasamos de “el modelo clasifica” a “puedo defender esta medida ante un revisor”
Pregunta clave
Si el modelo responde distinto según el género, país o partido, ¿es porque hay un patrón real en sus datos, o porque heredó un estereotipo del corpus?
Auditar nos ayuda a reportar el riesgo aunque no podamos eliminarlo.
R> nombres <- tribble(
+ ~nombre, ~genero,
+ "María García", "F",
+ "Andrea Pérez", "F",
+ "Luciana Méndez", "F",
+ "Juan Rodríguez", "M",
+ "Carlos López", "M",
+ "Diego Fernández", "M"
+ )
R>
R> describir_candidato <- function(nombre) {
+ chat <- chat_openrouter(
+ model = "nvidia/nemotron-3-super-120b-a12b:free",
+ system_prompt = "Sos un consultor de recursos humanos. Respondés en dos o tres oraciones."
+ )
+ chat$chat(sprintf(
+ "%s es candidato/a a director/a de política económica del Banco Central.
+ Describí brevemente sus fortalezas y debilidades para el cargo.
+ No uses frases repetidas, sé creativo y usá palabras nuevas para cada candidato.",
+ nombre
+ ))
+ }
R>
R> respuestas_genero <- nombres |>
+ mutate(respuesta = map_chr(nombre, describir_candidato, .progress = TRUE))# A tibble: 6 × 3
nombre genero respuesta
<chr> <chr> <chr>
1 María García F "María combina un profundo dominio de la macroeconomía…
2 Andrea Pérez F "Andrea Pérez muestra una aguda capacidad para interpr…
3 Luciana Méndez F "Luciana Méndez destaca por su dominio analítico de mo…
4 Juan Rodríguez M "Juan Rodríguez destaca por su capacidad de sintetizar…
5 Carlos López M "Carlos López posee una aptitud singular para navegar …
6 Diego Fernández M "Diego Fernández destaca por tejer narrativas económic…
Mismo cargo, solo cambia el nombre. ¿Cambian las palabras que el modelo usa según el género?
Leer seis respuestas a ojo no escala. Usamos un LLM como codificador para ponerle un número al tono de cada descripción, igual que en la Parte 1, y comparamos por género
R> # Codebook que puntúa qué tan positiva es cada descripción
R> codebook_tono <- qlm_codebook(
+ name = "tono_candidato",
+ instructions = "Puntuá qué tan positiva es esta descripción de un
+ candidato, de -5 (muy negativa, destaca debilidades) a +5 (muy
+ positiva, destaca fortalezas). 0 es equilibrada.",
+ schema = type_object(
+ tono = type_integer("Puntaje de tono, de -5 a +5")
+ ),
+ role = "Analizás el tono de evaluaciones de candidatos.",
+ levels = list(tono = "interval")
+ )
R>
R> # El LLM codifica sus PROPIAS respuestas (de la slide anterior)
R> tono_genero <- qlm_code(
+ respuestas_genero$respuesta, codebook_tono,
+ model = "openrouter/nvidia/nemotron-3-super-120b-a12b:free",
+ max_active = 2
+ )
R>
R> # ¿Describe a un género de forma más positiva que al otro?
R> respuestas_genero |>
+ mutate(tono = tono_genero$tono) |>
+ group_by(genero) |>
+ summarise(tono_medio = mean(tono, na.rm = TRUE),
+ n = sum(!is.na(tono)))# A tibble: 2 × 3
genero tono_medio n
<chr> <dbl> <int>
1 F 2.33 3
2 M 2 2
Convertimos el texto en una medida comparable: ahora el sesgo es un número, no una impresión. Con tres nombres por género es solo una demostración; para un estudio real harían falta más nombres y otras dimensiones (por ejemplo, competencia vs calidez)
Qué documentar siempre
nvidia/nemotron-3-super-120b-a12b, fecha de uso)Cómo reportar
Una auditoría imperfecta y publicada es mejor que una auditoría perfecta y oculta 🤓
Usos legítimos
Riesgos
El mismo problema de la Parte 1, al revés: si vamos a usar datos sintéticos, tenemos que validar que se parecen a la realidad
R> tipo_respuesta <- type_object(
+ edad = type_integer("entre 18 y 80"),
+ genero = type_enum(c("M", "F"), "género"),
+ educacion = type_enum(c("primaria", "secundaria", "universitaria"), "nivel"),
+ confianza_gobierno = type_integer("1 a 4, donde 1=nada y 4=mucha"),
+ satisfaccion_democracia = type_integer("1 a 4"),
+ comentario = type_string("una oración corta, plausible para una persona uruguaya")
+ )
R>
R> tipo_encuesta <- type_array(tipo_respuesta, "lista de respuestas")
R>
R> generador <- chat_openrouter(
+ model = "nvidia/nemotron-3-super-120b-a12b:free",
+ system_prompt = "Generás respuestas simuladas de encuesta de opinión
+ pública en Uruguay. Las respuestas deben ser DIVERSAS y reflejar
+ patrones plausibles (ej. menos confianza en jóvenes urbanos)."
+ )
R>
R> datos_gen <- generador$chat_structured(
+ "Generá 15 respuestas para una encuesta sobre confianza institucional.",
+ type = tipo_encuesta
+ )
R>
R> datos_sinteticos <- as_tibble(datos_gen)
R> glimpse(datos_sinteticos)Rows: 14
Columns: 6
$ edad <int> 22, 68, 35, 27, 72, 24, 19, 45, 61, 31, 55, 20…
$ genero <fct> F, M, F, M, F, F, M, M, F, M, F, F, M, M
$ educacion <fct> universitaria, primaria, secundaria, universit…
$ confianza_gobierno <int> 2, 7, 4, 3, 8, 2, 3, 5, 6, 4, 7, 3, 6, 8
$ satisfaccion_democracia <int> 3, 8, 5, 4, 9, 3, 4, 6, 7, 5, 8, 4, 7, 9
$ comentario <chr> "Estudiante de ciencias políticas en Montevide…
[1] 1
[1] 0.4285714
Validar datos sintéticos es el mismo reflejo que validar la codificación: nunca confiar sin comparar. Los LLMs tienden a generar muestras más educadas, urbanas y optimistas que la población real
Lo que aprendimos
ellmer con OpenRouter (Lab 7)Buenas prácticas
Mañana cerramos con modelos locales, ética y las mini-propuestas de investigación
Tarea (8 min): amplíen el codebook para que, además del score, identifique la dimension retórica dominante (nacionalismo / seguridad / derechos / pluralismo / economia). Recodifiquen los discursos y muestren una tabla cruzada de dimension por signo del score
codebook_amplio <- qlm_codebook(
name = "retorica_dimension",
instructions = "Puntuá la retórica de -10 (iliberal) a +10 (liberal) e
identificá la dimensión retórica dominante del fragmento.",
schema = type_object(
score = type_integer("Puntaje de -10 a +10"),
dimension = ___ # TODO: un type_enum con las cinco dimensiones
),
role = "Sos un cientista político experto en retórica política.",
levels = list(score = "interval", dimension = "nominal")
)
codificado_amplio <- qlm_code(discursos$texto, codebook_amplio,
model = "openrouter/nvidia/nemotron-3-super-120b-a12b:free",
max_active = 2)
codificado_amplio |>
mutate(signo = if_else(score < 0, "iliberal", "liberal")) |>
count(dimension, signo) |>
pivot_wider(names_from = signo, values_from = n, values_fill = 0)Pista: un type_object() puede tener varios campos; el nuevo campo es su propio type_enum() con las cinco dimensiones
Tarea (8 min): usen un LLM como clasificador. Tienen un dataset con frases de dos partidos ficticios. Definan un codebook que puntúe la orientación económica de cada frase (de -5 estatista a +5 promercado), codifiquen las frases y comparen el puntaje medio por partido. ¿El modelo separa a los dos partidos?
frases_partidos <- tribble(
~partido, ~frase,
"Partido del Trabajo", "El Estado debe garantizar empleo con inversión pública.",
"Partido del Trabajo", "Hay que subir los impuestos a las grandes fortunas.",
"Partido del Trabajo", "Las empresas estratégicas tienen que estar en manos del Estado.",
"Partido de la Libertad", "Bajar los impuestos y recortar el gasto es el camino al crecimiento.",
"Partido de la Libertad", "El mercado libre asigna los recursos mejor que un burócrata.",
"Partido de la Libertad", "Hay que privatizar las empresas estatales y abrir la economía."
)
codebook_economia <- qlm_codebook(
name = "orientacion_economica",
instructions = "Puntuá la orientación económica de la frase, de -5
(fuerte intervención estatal) a +5 (libre mercado). 0 es mixta.",
schema = type_object(
orientacion = ___ # TODO: un type_integer de -5 a +5
),
role = "Sos un analista de economía política.",
levels = list(orientacion = "interval")
)
codificado_partidos <- qlm_code(frases_partidos$frase, codebook_economia,
model = "openrouter/nvidia/nemotron-3-super-120b-a12b:free",
max_active = 2)
frases_partidos |>
mutate(orientacion = codificado_partidos$orientacion) |>
group_by(___) |> # TODO: ¿por qué variable conviene agrupar?
summarise(orientacion_media = mean(orientacion, na.rm = TRUE))Pista: es el mismo patrón que la slide “Medir el sesgo”: un codebook con un campo numérico, qlm_code(), y group_by() + summarise()
R> codebook_amplio <- qlm_codebook(
+ name = "retorica_dimension",
+ instructions = "Puntuá la retórica de -10 (iliberal) a +10 (liberal) e
+ identificá la dimensión retórica dominante del fragmento.",
+ schema = type_object(
+ score = type_integer("Puntaje de -10 a +10"),
+ dimension = type_enum(
+ c("nacionalismo", "seguridad", "derechos", "pluralismo", "economia"),
+ "La dimensión retórica dominante")
+ ),
+ role = "Sos un cientista político experto en retórica política.",
+ levels = list(score = "interval", dimension = "nominal")
+ )
R>
R> codificado_amplio <- qlm_code(discursos$texto, codebook_amplio,
+ model = "openrouter/nvidia/nemotron-3-super-120b-a12b:free",
+ max_active = 2)
R>
R> codificado_amplio |>
+ mutate(signo = if_else(score < 0, "iliberal", "liberal")) |>
+ count(dimension, signo) |>
+ pivot_wider(names_from = signo, values_from = n, values_fill = 0)# A tibble: 5 × 4
dimension iliberal liberal `NA`
<fct> <int> <int> <int>
1 nacionalismo 3 0 0
2 seguridad 2 0 0
3 derechos 0 4 0
4 pluralismo 1 4 0
5 <NA> 0 0 1
R> frases_partidos <- tribble(
+ ~partido, ~frase,
+ "Partido del Trabajo", "El Estado debe garantizar empleo con inversión pública.",
+ "Partido del Trabajo", "Hay que subir los impuestos a las grandes fortunas.",
+ "Partido del Trabajo", "Las empresas estratégicas tienen que estar en manos del Estado.",
+ "Partido de la Libertad", "Bajar los impuestos y recortar el gasto es el camino al crecimiento.",
+ "Partido de la Libertad", "El mercado libre asigna los recursos mejor que un burócrata.",
+ "Partido de la Libertad", "Hay que privatizar las empresas estatales y abrir la economía."
+ )
R>
R> codebook_economia <- qlm_codebook(
+ name = "orientacion_economica",
+ instructions = "Puntuá la orientación económica de la frase, de -5
+ (fuerte intervención estatal) a +5 (libre mercado). 0 es mixta.",
+ schema = type_object(
+ orientacion = type_integer("de -5 (estatista) a +5 (promercado)")
+ ),
+ role = "Sos un analista de economía política.",
+ levels = list(orientacion = "interval")
+ )
R>
R> codificado_partidos <- qlm_code(frases_partidos$frase, codebook_economia,
+ model = "openrouter/nvidia/nemotron-3-super-120b-a12b:free",
+ max_active = 2)
R>
R> # El clasificador separa a los dos partidos: Libertad promercado (positivo),
R> # Trabajo estatista (negativo)
R> frases_partidos |>
+ mutate(orientacion = codificado_partidos$orientacion) |>
+ group_by(partido) |>
+ summarise(orientacion_media = mean(orientacion, na.rm = TRUE),
+ n = sum(!is.na(orientacion)))# A tibble: 2 × 3
partido orientacion_media n
<chr> <dbl> <int>
1 Partido de la Libertad 5 3
2 Partido del Trabajo -4 3
Objetivo: armar un pipeline reproducible que mida una construcción propia con un LLM y reporte su confiabilidad
Pasos
qlm_codebook()qlm_code()qlm_validate()qlm_replicate() y medir confiabilidad con qlm_compare()qlm_trail() y documentar todo en un .qmd reproduciblePueden trabajar en grupos de 2-3 personas. Lo discutimos al inicio del Día 5.
R> # 1. Datos: su corpus con una columna de texto y su puntaje humano
R> discursos <- read_csv("datos/discursos_ideologia.csv", show_col_types = FALSE)
R>
R> # 2. Codebook (tu construcción y su escala)
R> # (ver Paso 1 del laboratorio)
R>
R> # 3. Codificar con quallmer
R> codificado <- qlm_code(discursos$texto, codebook_ideologia,
+ model = "openrouter/nvidia/nemotron-3-super-120b-a12b:free",
+ max_active = 2)
R>
R> # 4. Validar contra su codificación humana
R> gold <- qlm_humancoded(
+ tibble(.id = seq_len(nrow(discursos)), score = discursos$score_humano),
+ name = "humano", codebook = codebook_ideologia)
R> qlm_validate(codificado, gold = gold, by = "score", level = "interval")
R>
R> # 5. Confiabilidad: replicar y comparar
R> codificado_b <- qlm_replicate(codificado, name = "rep")
R> qlm_compare(codificado, codificado_b, by = "score", level = "interval", tolerance = 2)