IA para Científicos Sociales

Sesión 5.3: Laboratorio 9 - Modelos locales y auditoría de sesgo

Danilo Freire

Department of Data and Decision Sciences
Emory University

Laboratorio 9: Modelos locales y auditoría de sesgo

Objetivos del laboratorio

Parte 1: un LLM en tu máquina (sesión 5.1)

  1. Verificar Ollama y conversar con Granite desde la terminal y desde R
  2. Codificar datos sensibles (simulados) con quallmer y el modelo local
  3. Validar la codificación contra un gold standard humano

Parte 2: auditoría de equidad (sesión 5.2)

  1. Auditar un modelo de crédito con fairmodels
  2. Calcular e interpretar métricas de equidad por grupo
  3. Discutir mitigación y sus trade-offs

Las dos partes son las dos caras de la ética de hoy: proteger los datos de las personas y verificar que nuestros modelos no las traten de forma desigual

Parte 1: Un LLM en tu máquina

Verificar la instalación

En la terminal (no en R), verificá que Ollama está instalado y bajá el modelo de hoy:

ollama --version
ollama pull granite4.1:3b

IBM Granite (~2,1 GB) responde directo, sin razonar antes, y está afinado para salida estructurada. Para codificar es justo lo que queremos: rápido y estable

Probarlo en la terminal

ollama run granite4.1:3b

Escribí un par de preguntas y salí con /bye

¿Por qué no un modelo con razonamiento?

Hay muchos modelos disponibles, incluso los thinking models, que razonan antes de responder. Para chatear están bien, pero para codificar tienen dos problemas:

  • “Piensan” uno o dos minutos por cada texto
  • En Ollama, apagarles el razonamiento suele romper la salida estructurada que quallmer necesita

Nota

Pista: elegir el modelo correcto es parte del oficio. Un modelo con razonamiento para problemas difíciles; uno directo y afinado para salida estructurada, como Granite, para tareas repetitivas como la codificación

Conectar desde R

El mismo ellmer del lab 7, otro proveedor: chat_ollama() en lugar de chat_openrouter(). Sin API key, sin internet

R> library(tidyverse)
R> library(ellmer)
R> library(quallmer)
R> 
R> chat <- chat_ollama(
+   model = "granite4.1:3b",
+   system_prompt = "Sos un asistente de investigación social. Respondé en español, en una sola oración."
+ )
R> 
R> chat$chat("¿Qué es una encuesta de victimización?")
Una encuesta de victimización es un instrumento utilizado para recopilar 
información sobre las experiencias de delitos y amenazas a la seguridad 
personal o propiedad de los individuos, mediante preguntas específicas que 
evalúan la ocurrencia de tales eventos en un período determinado.

La primera llamada tarda unos segundos: el modelo se está cargando en memoria. Las siguientes son más rápidas

El caso: entrevistas que no pueden salir de la máquina

  • Un equipo entrevistó a víctimas de delitos que no hicieron la denuncia (un problema clásico de las encuestas de victimización)
  • Los fragmentos contienen detalles identificables: barrios, rutinas, conflictos con vecinos
  • El consentimiento informado y el comité de ética prohíben transferir el material a terceros, lo que incluye una API en la nube
  • Queremos codificar el motivo principal por el que cada persona no denunció
Código Significado
desconfianza No cree que la policía o la justicia hagan algo
miedo Teme represalias del agresor
costo El trámite cuesta tiempo o esfuerzo
poca_gravedad No consideró el hecho suficientemente grave
resolucion_propia Lo resolvió por otra vía

Los datos

R> entrevistas <- read_csv("datos/entrevistas_confianza.csv")
R> # Leé el archivo directo desde la web:
R> # entrevistas <- read_csv("https://raw.githubusercontent.com/danilofreire/introduccion-ia-ucu/main/clases/dia-05/datos/entrevistas_confianza.csv")
R> 
R> entrevistas |> count(motivo_humano)
# A tibble: 5 × 2
  motivo_humano         n
  <chr>             <int>
1 costo                 3
2 desconfianza          4
3 miedo                 3
4 poca_gravedad         3
5 resolucion_propia     3

motivo_humano es el gold standard: la codificación manual del equipo

Advertencia

Los datos son simulados, pero el flujo de trabajo es exactamente el que usarían con entrevistas reales

Paso 1: definir el codebook

El mismo qlm_codebook() del lab 8. La diferencia: la variable es nominal (categorías), no una escala de intervalo

R> codebook_motivos <- qlm_codebook(
+   name = "motivos_no_denuncia",
+   instructions = "Leé el fragmento de entrevista. La persona fue víctima de un delito
+   y no hizo la denuncia. Clasificá el MOTIVO PRINCIPAL en una de estas categorías:
+   - desconfianza: no cree que la policía o la justicia investiguen o resuelvan nada
+   - miedo: teme represalias del agresor o de su entorno
+   - costo: el trámite implica demasiado tiempo, viajes o burocracia
+   - poca_gravedad: considera que el hecho no fue suficientemente grave
+   - resolucion_propia: resolvió el problema por su cuenta o por otra vía
+   Elegí UNA sola categoría, la que mejor capture el motivo central del relato.",
+   schema = type_object(
+     motivo = type_enum(
+       c("desconfianza", "miedo", "costo", "poca_gravedad", "resolucion_propia"),
+       "El motivo principal por el que no denunció"
+     ),
+     justificacion = type_string("Una oración que justifique la categoría elegida")
+   ),
+   role = "Sos un investigador experto en criminología y encuestas de victimización.",
+   levels = list(motivo = "nominal", justificacion = "nominal")
+ )

Paso 2: codificar con el modelo local

El mismo qlm_code() de ayer. Lo único que cambia es el string del modelo: "ollama/..." en lugar de "openrouter/..."

R> codificado <- qlm_code(
+   entrevistas$texto,
+   codebook_motivos,
+   model = "ollama/granite4.1:3b",
+   max_active = 1,
+   params = params(temperature = 0),
+   name = "granite_local"
+ )

Qué está pasando

  • Cada fragmento viaja al servidor local de Ollama, no a internet
  • El modelo devuelve la categoría y una justificación, con el esquema garantizado por type_enum()

Advertencia

max_active = 1 es importante: el servidor local procesa de a un pedido. Con varios pedidos en paralelo, los demás esperan y dan timeout. No hay errores 400 de la API, pero el paralelismo de la nube acá no existe

Inspeccionar los resultados

R> resultados <- as_tibble(codificado) |>
+   mutate(texto = str_trunc(entrevistas$texto, 60), gold = entrevistas$motivo_humano) |>
+   select(texto, motivo, gold)
R> 
R> resultados
# A tibble: 16 × 3
   texto                                                        motivo     gold 
   <chr>                                                        <fct>      <chr>
 1 Me robaron la moto en la puerta de casa, en el Cerro. No ... desconfia… desc…
 2 Mi vecina vio quién fue y todo, pero ¿para qué voy a denu... desconfia… desc…
 3 A mi hijo le sacaron el celular a la salida del liceo. No... desconfia… desc…
 4 Después de lo que salió en la prensa sobre los policías d... desconfia… desc…
 5 Sé perfectamente quiénes entraron a casa, viven a dos cua... miedo      miedo
 6 El que me amenazó es conocido en el barrio, ya estuvo pre... miedo      miedo
 7 Vi todo desde el balcón, pero no voy a declarar nada. Esa... miedo      miedo
 8 Trabajo de ocho a ocho en una fiambrería y la comisaría m... costo      costo
 9 Empecé a hacer la denuncia por internet y el sistema se c... costo      costo
10 Por una bicicleta usada no vale la pena el trámite. Son h... costo      costo
11 Me sacaron unos pesos del bolso en el ómnibus, habrán sid... poca_grav… poca…
12 Fue un rayón en el auto en el estacionamiento del súper. ... poca_grav… poca…
13 Me rompieron una maceta y se llevaron unas plantas del fr... poca_grav… poca…
14 Al final lo arreglamos entre nosotros. El padre del mucha... resolucio… reso…
15 Supe quién tenía mi bicicleta porque la publicó para vend... resolucio… reso…
16 En el edificio tenemos un grupo y entre todos identificam... resolucio… reso…

A simple vista: ¿dónde coincide con el equipo humano y dónde no?

Paso 3: validar contra el gold standard

R> gold <- qlm_humancoded(
+   tibble(
+     .id = seq_len(nrow(entrevistas)),
+     motivo = entrevistas$motivo_humano
+   ),
+   name = "humano",
+   codebook = codebook_motivos
+ )
R> 
R> validacion <- qlm_validate(
+   codificado,
+   gold = gold,
+   by = "motivo",
+   level = "nominal"
+ )
R> 
R> as_tibble(validacion) |> select(measure, value)
# A tibble: 5 × 2
  measure   value
  <chr>     <dbl>
1 accuracy      1
2 precision     1
3 recall        1
4 f1            1
5 kappa         1

Cómo leer esto

  • Accuracy: proporción de coincidencias exactas
  • Kappa / alpha: acuerdo corregido por azar. Con 5 categorías, adivinar da ~20%, así que el acuerdo bruto exagera
  • La referencia del lab 8 sigue valiendo: kappa ≥ 0,7 para confiar en el codificador

Pista: si el modelo local rinde cerca del humano, ganamos un codificador gratuito, privado y reproducible. Si no, el ejercicio 1 muestra el primer remedio: mejorar el codebook

¿Dónde se equivoca?

R> resultados |> filter(motivo != gold)
# A tibble: 0 × 3
# ℹ 3 variables: texto <chr>, motivo <fct>, gold <chr>
  • Los errores rara vez son aleatorios: suelen concentrarse en fronteras entre categorías (¿“desconfianza” o “miedo”? ¿“costo” o “poca_gravedad”?)
  • Esas fronteras confusas también confunden a codificadores humanos. La solución clásica es la misma: reglas de decisión explícitas en el codebook

Ejercicio 1: mejorar el codebook

Parte 2: Auditoría de equidad

De los LLMs a nuestros propios modelos

Lo que acabamos de hacer

Auditar la calidad de un codificador automático: ¿acierta?

Lo que sigue

Auditar la equidad de un clasificador como los de los días 1 y 2: ¿acierta igual para todos los grupos?

El caso de estudio

El dataset German Credit, un clásico en estudios de sesgo algorítmico:

  • 1000 solicitudes de crédito
  • Variable objetivo: buen/mal pagador
  • Atributo protegido: sexo
  • La pregunta de la sesión 5.2: ¿el modelo trata igual a hombres y mujeres?

Preparación

R> # Instalar paquetes si no están disponibles
R> if (!require("fairmodels")) install.packages("fairmodels")
R> if (!require("DALEX")) install.packages("DALEX")
R> 
R> # Cargar paquetes
R> library(tidymodels)
R> library(fairmodels)
R> library(DALEX)

fairmodels usa explicadores de DALEX para auditar modelos. Funciona con cualquier modelo de clasificación

Para más informaciones, ver la documentación oficial y el artículo oficial: https://modeloriented.github.io/fairmodels/articles/fairmodels.html y https://arxiv.org/abs/2104.00507

El dataset German Credit

R> # Cargar datos incluidos en fairmodels
R> data("german", package = "fairmodels")
R> 
R> # Explorar estructura
R> glimpse(german)
Rows: 1,000
Columns: 10
$ Risk             <fct> good, bad, good, good, bad, good, good, good, good, b…
$ Sex              <fct> male, female, male, male, male, male, male, male, mal…
$ Job              <int> 2, 2, 1, 2, 2, 1, 2, 3, 1, 3, 2, 2, 2, 1, 2, 1, 2, 2,…
$ Housing          <fct> own, own, own, free, free, free, own, rent, own, own,…
$ Saving.accounts  <fct> not_known, little, little, little, little, not_known,…
$ Checking.account <fct> little, moderate, not_known, little, little, not_know…
$ Credit.amount    <int> 1169, 5951, 2096, 7882, 4870, 9055, 2835, 6948, 3059,…
$ Duration         <int> 6, 48, 12, 42, 24, 36, 24, 36, 12, 30, 12, 48, 12, 24…
$ Purpose          <fct> radio/TV, radio/TV, education, furniture/equipment, c…
$ Age              <int> 67, 22, 49, 45, 53, 35, 53, 35, 61, 28, 25, 24, 22, 6…

Variables clave:

  • Risk: variable objetivo (good/bad)
  • Sex: atributo protegido
  • Age: otro atributo sensible
  • Variables de crédito: monto, duración, historial

El dataset es de los años 1990 y tiene sesgos históricos conocidos. Documentación completa: https://archive.ics.uci.edu/dataset/144/statlog+german+credit+data

Explorar el atributo protegido

R> # Distribución de Risk por Sex
R> german |>
+   count(Sex, Risk) |>
+   group_by(Sex) |>
+   mutate(prop = n / sum(n)) |>
+   ggplot(aes(x = Sex, y = prop, fill = Risk)) +
+   geom_col(position = "dodge") +
+   scale_fill_manual(values = c("good" = "#2d4563", "bad" = "#e63946")) +
+   scale_y_continuous(labels = scales::percent) +
+   labs(title = "Distribución de riesgo crediticio por sexo",
+        y = "Proporción", x = "Sexo", fill = "Riesgo") +
+   theme_minimal(base_size = 14)

Las tasas base son similares entre grupos, lo que facilita la comparación de métricas

Preparar los datos

R> # Dividir en train/test (estratificado por el resultado)
R> set.seed(123)
R> split <- initial_split(german, prop = 0.7, strata = Risk)
R> train_data <- training(split)
R> test_data <- testing(split)
R> 
R> cat("Entrenamiento:", nrow(train_data), "filas\n")
Entrenamiento: 699 filas
R> cat("Test:", nrow(test_data), "filas\n")
Test: 301 filas

Usaremos el conjunto de test para evaluar equidad, ya que representa datos no vistos

Entrenar una regresión logística

R> # Resultado binario y datos sin Sex (el atributo protegido)
R> datos_modelo <- train_data |>
+   mutate(buen_pagador = ifelse(Risk == "good", 1, 0)) |>
+   select(-Risk, -Sex)
R> 
R> # Regresión logística: el clasificador clásico del día 2
R> modelo <- glm(buen_pagador ~ ., data = datos_modelo, family = binomial)
R> 
R> # Probabilidad de "good" en test y decisión a 0.5
R> pred_probs <- predict(modelo, test_data, type = "response")
R> pred_class <- ifelse(pred_probs > 0.5, "good", "bad")
R> 
R> # Accuracy global
R> accuracy <- mean(pred_class == test_data$Risk)
R> cat("Accuracy global:", round(accuracy, 3), "\n")
Accuracy global: 0.691 

El modelo no usa Sex como variable, pero ¿eso garantiza que sea justo? La sesión 5.2 sugiere que no: los proxies existen

Crear el explicador DALEX

R> # Crear explicador DALEX
R> explainer <- DALEX::explain(
+   model = modelo,
+   data = test_data |> select(-Risk, -Sex),
+   y = as.numeric(test_data$Risk == "good"),
+   label = "Regresión logística",
+   verbose = FALSE
+ )

Ver Wiśniewski y Biecek (2022) para más detalles sobre los métodos de clasificación de sesgos

Un explicador es un objeto que envuelve todo lo que fairmodels necesita para auditar:

  • El modelo entrenado
  • Los datos de test
  • La variable objetivo (0/1)

Para un glm, DALEX ya sabe cómo pedir las predicciones: no hace falta una función a medida

Es el paso intermedio: modelo → explicador → auditoría

La auditoría: fairness_check()

R> # Crear objeto fairness con Sex como atributo protegido
R> fobject <- fairness_check(
+   explainer,
+   protected = test_data$Sex,
+   privileged = "male",  # grupo de referencia
+   cutoff = 0.5,         # umbral de decisión
+   verbose = TRUE
+ )
Creating fairness classification object
-> Privileged subgroup      : character ( Ok  )
-> Protected variable       : factor ( Ok  ) 
-> Cutoff values for explainers : 0.5 ( for all subgroups )
-> Fairness objects     : 0 objects 
-> Checking explainers      : 1 in total (  compatible  )
-> Metric calculation       : 13/13 metrics calculated for all models
 Fairness object created succesfully  
R> # Ver resumen
R> print(fobject)

Fairness check for models: Regresión logística 

Regresión logística passes 4/5 metrics
Total loss :  0.4831363 

El grupo privilegiado es el de referencia. Las métricas comparan otros grupos con este

Visualizar métricas de equidad

La zona verde es la regla del 80% de la sesión 5.2: un ratio entre 0,8 y 1,25 se considera aceptable. Fuera de ella hay disparidad

R> plot(fobject)

Interpretar las métricas

Cada fila de la salida (y cada barra del gráfico anterior) es un ratio entre grupos (mujeres ÷ hombres): 1,0 es igualdad perfecta y la zona verde (0,8 a 1,25) es la regla del 80%. El resultado “positivo” acá es recibir el crédito (que el modelo prediga good)

Nombre en la salida Fórmula Qué pregunta, en crédito En una frase
Accuracy equality (ACC) (TP+TN)/total ¿Acierta con la misma frecuencia en cada grupo? Mismo nivel de aciertos
Equal opportunity (TPR) TP/(TP+FN) De quienes pagarían, ¿qué proporción recibe el crédito? Igualdad de oportunidades
Predictive equality (FPR) FP/(FP+TN) De quienes no pagarían, ¿qué proporción se aprueba por error? Mismo nivel de errores costosos
Predictive parity (PPV) TP/(TP+FP) De quienes el modelo aprueba, ¿qué proporción sí paga? Calibración: “aprobado” significa lo mismo
Statistical parity (STP) (TP+FP)/total ¿Qué proporción de cada grupo recibe un “aprobado”? Paridad demográfica

La tabla de confusión, en crédito: TP aprobado y paga · FP aprobado y no paga · TN rechazado y no pagaría · FN rechazado pero sí pagaría

Mitigación: umbrales diferenciados

Una estrategia de post-procesamiento (sesión 5.2): umbrales distintos por grupo para igualar un tipo de error

R> # Un umbral por grupo: 0.5 para hombres, 0.4 para mujeres
R> umbral <- ifelse(test_data$Sex == "male", 0.5, 0.4)
R> mitigated_pred <- ifelse(pred_probs > umbral, "good", "bad")
R> 
R> test_data |>
+   mutate(orig = pred_class, mitig = mitigated_pred) |>
+   group_by(Sex) |>
+   summarise(
+     fpr_orig = sum(orig == "good" & Risk == "bad") / sum(Risk == "bad"),
+     fpr_mitig = sum(mitig == "good" & Risk == "bad") / sum(Risk == "bad"),
+     acc_orig = mean(orig == Risk),
+     acc_mitig = mean(mitig == Risk),
+     .groups = "drop"
+   ) |>
+   mutate(across(where(is.numeric), ~round(., 3)))
# A tibble: 2 × 5
  Sex    fpr_orig fpr_mitig acc_orig acc_mitig
  <fct>     <dbl>     <dbl>    <dbl>     <dbl>
1 female    0.545     0.667    0.685     0.708
2 male      0.737     0.737    0.693     0.693

Consideraciones

  • ¿Es justo usar umbrales diferentes por grupo? Algunos lo llaman discriminación inversa; otros, corrección de un sesgo sistémico
  • La mitigación mueve el problema: mejora una métrica y empeora otra

La decisión de usar umbrales diferenciados es política, no técnica

Apéndice: explorar umbrales en detalle

Discusión

Actividad: recomendación al banco

Escenario:

Son consultores de un banco uruguayo que quiere implementar un modelo de scoring crediticio

El análisis muestra que el modelo tiene:

  • FPR más alto para mujeres: más mujeres buenas pagadoras son rechazadas
  • Accuracy similar entre grupos
  • Calibración correcta: las probabilidades significan lo mismo

El banco les pide una recomendación

Preguntas para discutir (5 min):

  1. ¿Deberían usar umbrales diferenciados por sexo?
  2. Si lo hacen, ¿es discriminación positiva o simplemente corrección de sesgo?
  3. ¿Qué métrica de equidad priorizarían y por qué?
  4. ¿Cómo comunicarían la decisión a los clientes?
  5. ¿La ley uruguaya permite trato diferenciado por sexo en decisiones de crédito?

Divídanse en grupos de 4

Ejercicios

Ejercicio 1: mejorar el codebook

Tarea (5 min):

Vimos que los errores del modelo se concentran en fronteras entre categorías. Mejoren el codebook con reglas de decisión explícitas y midan si la validación mejora

  1. Copien codebook_motivos y agreguen a instructions reglas para los casos límite. Por ejemplo: si hay amenazas o temor a represalias, es miedo aunque también haya desconfianza
  2. Re-codifiquen con qlm_code() y el modelo local
  3. Vuelvan a correr qlm_validate() y comparen las métricas

Pistas

  • Las reglas de decisión y el orden de prioridad entre categorías son práctica estándar en codebooks cualitativos. El LLM las aprovecha igual que un asistente humano
  • Revisen los errores de la diapositiva “¿Dónde se equivoca?” para decidir qué reglas escribir

Solución

Ejercicio 2: auditar con otra variable protegida

Tarea:

Repitan la auditoría de la Parte 2 usando edad como variable protegida.

  1. Crear grupos de edad (jóvenes < 30, adultos 30-50, mayores > 50)
  2. Crear un nuevo fairness_check() con esos grupos
  3. Visualizar y identificar qué grupo es más perjudicado

Código inicial:

R> test_data <- test_data |>
+   mutate(
+     Age_group = case_when(
+       Age < 30 ~ "joven",
+       Age < 50 ~ "adulto",
+       TRUE ~ "mayor"
+     )
+   )
R> 
R> fobject_age <- fairness_check(
+   explainer,
+   protected = test_data$Age_group,
+   privileged = "adulto",
+   cutoff = 0.5,
+   verbose = FALSE
+ )
R> 
R> plot(fobject_age)

Preguntas a responder:

  1. ¿Qué grupo de edad tiene peor rendimiento?

  2. ¿En qué métricas hay mayor disparidad?

  3. ¿Tiene sentido que adultos sean el grupo privilegiado?

  4. ¿Cómo se compara el sesgo por edad con el sesgo por sexo?

Trabajen individualmente o en parejas. Pueden consultar la documentación de fairmodels

Solución

Resumen del laboratorio

Parte 1: el modelo local

  • chat_ollama() y model = "ollama/...": el flujo de los labs 7 y 8, sin que los datos salgan de la máquina
  • El codebook con reglas de decisión es la herramienta para mejorar al codificador
  • Validar contra gold standard sigue siendo obligatorio, local o nube

Parte 2: la auditoría

  • Un modelo sin variables sensibles puede seguir siendo sesgado
  • fairness_check() compara métricas entre grupos; la zona verde es la regla del 80%
  • La mitigación implica trade-offs y las decisiones de equidad son políticas, no sólo técnicas

En la próxima sesión: mini-propuestas de investigación y cierre del curso

Apéndice

Solución Ejercicio 1

Un codebook con reglas de decisión para los casos límite:

R> codebook_motivos_v2 <- qlm_codebook(
+   name = "motivos_no_denuncia_v2",
+   instructions = "Leé el fragmento de entrevista. La persona fue víctima de un delito
+   y no hizo la denuncia. Clasificá el MOTIVO PRINCIPAL en una de estas categorías:
+   - desconfianza: no cree que la policía o la justicia investiguen o resuelvan nada
+   - miedo: teme represalias del agresor o de su entorno
+   - costo: el trámite implica demasiado tiempo, viajes o burocracia
+   - poca_gravedad: considera que el hecho no fue suficientemente grave
+   - resolucion_propia: resolvió el problema por su cuenta o por otra vía
+   Reglas de decisión, en orden de prioridad:
+   1. Si menciona amenazas, represalias o temor por su seguridad o la de su familia,
+      es 'miedo', aunque también exprese desconfianza en la policía.
+   2. Si el problema ya se resolvió por otra vía (acuerdo, devolución, gestión propia),
+      es 'resolucion_propia', aunque el hecho fuera menor.
+   3. Si el obstáculo es el trámite (tiempo, distancia, burocracia, sistemas caídos),
+      es 'costo', aunque el monto perdido sea chico.
+   4. 'poca_gravedad' sólo cuando el argumento central es que el hecho no era serio.
+   Elegí UNA sola categoría.",
+   schema = type_object(
+     motivo = type_enum(
+       c("desconfianza", "miedo", "costo", "poca_gravedad", "resolucion_propia"),
+       "El motivo principal por el que no denunció"
+     ),
+     justificacion = type_string("Una oración que justifique la categoría elegida")
+   ),
+   role = "Sos un investigador experto en criminología y encuestas de victimización.",
+   levels = list(motivo = "nominal", justificacion = "nominal")
+ )

Solución Ejercicio 1 (continuación)

R> codificado_v2 <- qlm_code(
+   entrevistas$texto,
+   codebook_motivos_v2,
+   model = "ollama/granite4.1:3b",
+   max_active = 1,
+   params = params(temperature = 0),
+   name = "granite_local_v2"
+ )
R> 
R> validacion_v2 <- qlm_validate(
+   codificado_v2,
+   gold = gold,
+   by = "motivo",
+   level = "nominal"
+ )
R> 
R> as_tibble(validacion_v2) |> select(measure, value)
# A tibble: 5 × 2
  measure   value
  <chr>     <dbl>
1 accuracy      1
2 precision     1
3 recall        1
4 f1            1
5 kappa         1
  • Comparen con la validación original. Con 16 textos, cada caso vale 6 puntos de accuracy, así que la mejora puede ser chica o nula en esta muestra. El efecto de las reglas se nota en corpus grandes y en la estabilidad entre corridas
  • La lección metodológica: cuando el codificador falla, el primer sospechoso es el codebook, no el modelo

Volver al ejercicio

Solución Ejercicio 2

R> # Crear grupos de edad
R> test_data <- test_data |>
+   mutate(
+     Age_group = case_when(
+       Age < 30 ~ "joven",
+       Age < 50 ~ "adulto",
+       TRUE ~ "mayor"
+     )
+   )
R> 
R> # Crear objeto fairness por edad
R> fobject_age <- fairness_check(
+   explainer,
+   protected = test_data$Age_group,
+   privileged = "adulto",
+   cutoff = 0.5,
+   verbose = FALSE
+ )
R> 
R> # Visualizar
R> plot(fobject_age)

Volver al ejercicio

Apéndice: explorar umbrales en detalle

R> # Función para calcular métricas con umbral personalizado
R> calc_metrics_threshold <- function(data, probs, threshold) {
+   pred <- ifelse(probs > threshold, "good", "bad")
+   data |>
+     mutate(pred = pred) |>
+     group_by(Sex) |>
+     summarise(
+       threshold = threshold,
+       accuracy = mean(pred == Risk),
+       tpr = sum(pred == "good" & Risk == "good") / sum(Risk == "good"),
+       fpr = sum(pred == "good" & Risk == "bad") / sum(Risk == "bad"),
+       .groups = "drop"
+     )
+ }
R> 
R> # Probar varios umbrales
R> thresholds <- c(0.3, 0.4, 0.5, 0.6, 0.7)
R> results <- map_dfr(thresholds, ~calc_metrics_threshold(test_data, pred_probs, .x))
R> 
R> results |>
+   arrange(Sex, threshold) |>
+   mutate(across(where(is.numeric), ~round(., 3)))
# A tibble: 10 × 5
   Sex    threshold accuracy   tpr   fpr
   <fct>      <dbl>    <dbl> <dbl> <dbl>
 1 female       0.3    0.674 0.982 0.848
 2 female       0.4    0.708 0.929 0.667
 3 female       0.5    0.685 0.821 0.545
 4 female       0.6    0.708 0.714 0.303
 5 female       0.7    0.663 0.607 0.242
 6 male         0.3    0.759 0.994 0.877
 7 male         0.4    0.731 0.935 0.825
 8 male         0.5    0.693 0.852 0.737
 9 male         0.6    0.679 0.748 0.509
10 male         0.7    0.651 0.671 0.404

Volver a mitigación

Apéndice: visualizar el trade-off

R> results |>
+   pivot_longer(cols = c(accuracy, tpr, fpr), names_to = "metric", values_to = "value") |>
+   ggplot(aes(x = factor(threshold), y = value, color = Sex, group = Sex)) +
+   geom_line(linewidth = 1) +
+   geom_point(size = 3) +
+   facet_wrap(~metric, scales = "free_y") +
+   scale_color_manual(values = c("female" = "#e63946", "male" = "#2d4563")) +
+   labs(title = "Efecto del umbral en métricas por grupo",
+        x = "Umbral", y = "Valor") +
+   theme_minimal(base_size = 14) +
+   theme(legend.position = "bottom")

Diferentes umbrales afectan a los grupos de manera diferente. En crédito: un falso positivo es un préstamo que no se paga; un falso negativo, un buen cliente rechazado

Volver a mitigación

Apéndice: ¿cuánta calidad cuesta la privacidad?

Si los datos no fueran sensibles, podríamos comparar el codificador local contra uno de la nube. qlm_replicate() acepta un modelo distinto:

R> codificado_nube <- qlm_replicate(
+   codificado,
+   model = "openrouter/nvidia/nemotron-3-super-120b-a12b:free",
+   max_active = 3,
+   name = "nemotron_nube"
+ )
R> 
R> qlm_compare(
+   codificado, codificado_nube,
+   by = "motivo", level = "nominal"
+ )
R> 
R> qlm_validate(
+   codificado_nube,
+   gold = gold, by = "motivo", level = "nominal"
+ )

Para qué sirve

  • qlm_compare() mide cuánto coinciden los dos codificadores
  • Las dos validaciones contra el gold standard responden la pregunta de la sesión 5.1: ¿cuánta precisión cuesta correr local?

Advertencia

Requiere la API key de OpenRouter del lab 7, por eso no se ejecuta en estas diapositivas. Pruébenlo en casa con los datos simulados (nunca con datos sensibles reales)

Nos vemos en la sesión 5.4 😊