---
title: "IA para Científicos Sociales"
subtitle: "Sesión 5.3: Laboratorio 9 - Modelos locales y auditoría de sesgo"
author:
- name: Danilo Freire
orcid: 0000-0002-4712-6810
email: danilofreire@gmail.com
affiliations: "Department of Data and Decision Sciences
Emory University"
format:
clean-revealjs:
self-contained: true
footer: "[Sesión 5.3](https://danilofreire.github.io/introduccion-ia-ucu/clases/dia-05/19-laboratorio-09.html)"
transition: slide
transition-speed: default
scrollable: true
revealjs-plugins:
- multimodal
engine: knitr
editor:
render-on-save: true
lang: es
execute:
echo: true
---
```{r setup, include=FALSE}
options(htmltools.dir.version = FALSE)
library(knitr)
opts_chunk$set(
prompt = T,
fig.align = "center",
dpi = 300,
cache = T,
message = FALSE,
warning = FALSE,
engine.opts = list(bash = "-l")
)
knit_hooks$set(
prompt = function(before, options, envir) {
options(
prompt = if (options$engine %in% c("sh", "bash", "zsh")) "$ " else "R> ",
continue = if (options$engine %in% c("sh", "bash", "zsh")) "$ " else "+ "
)
}
)
options(repos = c(CRAN = "https://cran.rstudio.com/"))
options(cli.progress_show_after = Inf)
if (!require("fontawesome", character.only = TRUE)) {
install.packages("fontawesome", dependencies = TRUE)
library(fontawesome, character.only = TRUE)
}
```
# Laboratorio 9: Modelos locales y auditoría de sesgo {background-color="#2d4563"}
## Objetivos del laboratorio
:::{style="margin-top: 30px; font-size: 24px;"}
:::{.columns}
:::{.column width=50%}
**Parte 1: un LLM en tu máquina (sesión 5.1)**
1. Verificar Ollama y conversar con [Granite](https://ollama.com/library/granite4.1) desde la terminal y desde R
2. Codificar datos sensibles (simulados) con [`quallmer`]{.alert} y el modelo local
3. [Validar]{.alert} la codificación contra un gold standard humano
:::
:::{.column width=50%}
**Parte 2: auditoría de equidad (sesión 5.2)**
4. Auditar un modelo de crédito con [`fairmodels`](https://modeloriented.github.io/fairmodels/index.html)
5. Calcular e interpretar [métricas de equidad]{.alert} por grupo
6. Discutir [mitigación]{.alert} y sus trade-offs
:::
:::
:::{style="margin-top: 20px; text-align: center; font-size: 24px;"}
[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]{.alert}
:::
:::
# Parte 1: Un LLM en tu máquina {background-color="#2d4563"}
## Verificar la instalación
:::{style="margin-top: 30px; font-size: 23px;"}
:::{.columns}
:::{.column width=52%}
En la terminal (no en R), verificá que Ollama está instalado y bajá el modelo de hoy:
```bash
ollama --version
ollama pull granite4.1:3b
```
[IBM Granite](https://www.ibm.com/granite) (~2,1 GB) responde directo, [sin razonar antes]{.alert}, y está afinado para [salida estructurada]{.alert}. Para codificar es justo lo que queremos: rápido y estable
**Probarlo en la terminal**
```bash
ollama run granite4.1:3b
```
Escribí un par de preguntas y salí con `/bye`
:::
:::{.column width=48%}
:::{style="font-size: 21px;"}
**¿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]{.alert}
- En Ollama, apagarles el razonamiento suele romper la [salida estructurada]{.alert} que `quallmer` necesita
:::{.callout-note}
**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
:::{style="margin-top: 30px; font-size: 22px;"}
El mismo `ellmer` del lab 7, otro proveedor: `chat_ollama()` en lugar de `chat_openrouter()`. Sin API key, sin internet
```{r}
#| echo: true
#| eval: true
#| cache: true
library(tidyverse)
library(ellmer)
library(quallmer)
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."
)
chat$chat("¿Qué es una encuesta de victimización?")
```
:::{style="margin-top: 15px;"}
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
:::{style="margin-top: 30px; font-size: 21px;"}
:::{.columns}
:::{.column width=55%}
- Un equipo entrevistó a [víctimas de delitos que no hicieron la denuncia]{.alert} (un problema clásico de las encuestas de victimización)
- Los fragmentos contienen [detalles identificables]{.alert}: barrios, rutinas, conflictos con vecinos
- El consentimiento informado y el comité de ética [prohíben transferir]{.alert} el material a terceros, lo que incluye una API en la nube
- Queremos codificar el [motivo principal]{.alert} 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 |
:::
:::{.column width=45%}
:::{style="font-size: 21px;"}
**Los datos**
```{r}
#| echo: true
#| eval: true
#| cache: true
entrevistas <- read_csv("datos/entrevistas_confianza.csv")
# Leé el archivo directo desde la web:
# entrevistas <- read_csv("https://raw.githubusercontent.com/danilofreire/introduccion-ia-ucu/main/clases/dia-05/datos/entrevistas_confianza.csv")
entrevistas |> count(motivo_humano)
```
`motivo_humano` es el gold standard: la codificación manual del equipo
:::{.callout-warning}
Los datos son simulados, pero el flujo de trabajo es exactamente el que usarían con entrevistas reales
:::
:::
:::
:::
:::
## Paso 1: definir el codebook
:::{style="margin-top: 20px; font-size: 20px;"}
El mismo `qlm_codebook()` del lab 8. La diferencia: la variable es [nominal]{.alert} (categorías), no una escala de intervalo
```{r}
#| echo: true
#| eval: true
#| cache: true
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
:::{style="margin-top: 30px; font-size: 21px;"}
:::{.columns}
:::{.column width=55%}
El mismo `qlm_code()` de ayer. Lo único que cambia es el string del modelo: `"ollama/..."` en lugar de `"openrouter/..."`
```{r}
#| echo: true
#| eval: true
#| cache: true
codificado <- qlm_code(
entrevistas$texto,
codebook_motivos,
model = "ollama/granite4.1:3b",
max_active = 1,
params = params(temperature = 0),
name = "granite_local"
)
```
:::
:::{.column width=45%}
:::{style="font-size: 21px;"}
**Qué está pasando**
- Cada fragmento viaja al servidor local de Ollama, [no a internet]{.alert}
- El modelo devuelve la categoría y una justificación, con el esquema garantizado por `type_enum()`
:::{.callout-warning}
`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
:::{style="margin-top: 30px; font-size: 21px;"}
```{r}
#| echo: true
#| eval: true
#| cache: true
resultados <- as_tibble(codificado) |>
mutate(texto = str_trunc(entrevistas$texto, 60), gold = entrevistas$motivo_humano) |>
select(texto, motivo, gold)
resultados
```
:::{style="margin-top: 10px;"}
A simple vista: ¿dónde coincide con el equipo humano y dónde no?
:::
:::
## Paso 3: validar contra el gold standard
:::{style="margin-top: 30px; font-size: 21px;"}
:::{.columns}
:::{.column width=55%}
```{r}
#| echo: true
#| eval: true
#| cache: true
gold <- qlm_humancoded(
tibble(
.id = seq_len(nrow(entrevistas)),
motivo = entrevistas$motivo_humano
),
name = "humano",
codebook = codebook_motivos
)
validacion <- qlm_validate(
codificado,
gold = gold,
by = "motivo",
level = "nominal"
)
as_tibble(validacion) |> select(measure, value)
```
:::
:::{.column width=45%}
:::{style="font-size: 21px;"}
**Cómo leer esto**
- [Accuracy]{.alert}: proporción de coincidencias exactas
- [Kappa / alpha]{.alert}: 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
:::{.callout-note appearance="simple" icon=false}
**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?
:::{style="margin-top: 30px; font-size: 21px;"}
```{r}
#| echo: true
#| eval: true
#| cache: true
resultados |> filter(motivo != gold)
```
:::{style="margin-top: 15px;"}
- Los errores rara vez son aleatorios: suelen concentrarse en [fronteras entre categorías]{.alert} (¿"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]{.alert} en el codebook
:::
[[Ejercicio 1: mejorar el codebook]{.button}](#sec:exercise01)
:::
# Parte 2: Auditoría de equidad {background-color="#2d4563"}
## De los LLMs a nuestros propios modelos
:::{style="margin-top: 30px; font-size: 23px;"}
:::{.columns}
:::{.column width=50%}
**Lo que acabamos de hacer**
Auditar la [calidad]{.alert} de un codificador automático: ¿acierta?
**Lo que sigue**
Auditar la [equidad]{.alert} de un clasificador como los de los días 1 y 2: ¿acierta [igual para todos los grupos]{.alert}?
:::
:::{.column width=50%}
:::{style="font-size: 23px;"}
**El caso de estudio**
El dataset [German Credit](https://archive.ics.uci.edu/dataset/144/statlog+german+credit+data), 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
:::{style="margin-top: 30px; font-size: 24px;"}
```{r}
#| echo: true
#| eval: true
# Instalar paquetes si no están disponibles
if (!require("fairmodels")) install.packages("fairmodels")
if (!require("DALEX")) install.packages("DALEX")
# Cargar paquetes
library(tidymodels)
library(fairmodels)
library(DALEX)
```
[`fairmodels`](https://modeloriented.github.io/fairmodels/index.html) usa explicadores de DALEX para auditar modelos. Funciona con cualquier modelo de clasificación
Para más informaciones, ver la [documentación oficial]{.alert} y el [artículo oficial]{.alert}: y
:::
## El dataset German Credit
:::{style="margin-top: 30px; font-size: 22px;"}
:::{.columns}
:::{.column width=50%}
```{r}
#| echo: true
#| eval: true
#| cache: true
# Cargar datos incluidos en fairmodels
data("german", package = "fairmodels")
# Explorar estructura
glimpse(german)
```
:::
:::{.column width=50%}
**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]{.alert}. Documentación completa:
:::
:::
:::
## Explorar el atributo protegido
:::{style="margin-top: 30px; font-size: 22px;"}
```{r}
#| echo: true
#| eval: true
#| fig-width: 10
#| fig-height: 4
#| cache: true
# Distribución de Risk por Sex
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)
```
:::{style="margin-top: 10px;"}
Las [tasas base]{.alert} son similares entre grupos, lo que facilita la comparación de métricas
:::
:::
## Preparar los datos
:::{style="margin-top: 30px; font-size: 22px;"}
```{r}
#| echo: true
#| eval: true
#| cache: true
# Dividir en train/test (estratificado por el resultado)
set.seed(123)
split <- initial_split(german, prop = 0.7, strata = Risk)
train_data <- training(split)
test_data <- testing(split)
cat("Entrenamiento:", nrow(train_data), "filas\n")
cat("Test:", nrow(test_data), "filas\n")
```
Usaremos el conjunto de [test]{.alert} para evaluar equidad, ya que representa datos no vistos
:::
## Entrenar una regresión logística
:::{style="margin-top: 30px; font-size: 22px;"}
```{r}
#| echo: true
#| eval: true
#| cache: true
# Resultado binario y datos sin Sex (el atributo protegido)
datos_modelo <- train_data |>
mutate(buen_pagador = ifelse(Risk == "good", 1, 0)) |>
select(-Risk, -Sex)
# Regresión logística: el clasificador clásico del día 2
modelo <- glm(buen_pagador ~ ., data = datos_modelo, family = binomial)
# Probabilidad de "good" en test y decisión a 0.5
pred_probs <- predict(modelo, test_data, type = "response")
pred_class <- ifelse(pred_probs > 0.5, "good", "bad")
# Accuracy global
accuracy <- mean(pred_class == test_data$Risk)
cat("Accuracy global:", round(accuracy, 3), "\n")
```
[El modelo no usa Sex como variable]{.alert}, pero ¿eso garantiza que sea justo? La sesión 5.2 sugiere que no: los proxies existen
:::
## Crear el explicador DALEX
:::{style="margin-top: 30px; font-size: 22px;"}
:::{.columns}
:::{.column width=55%}
```{r}
#| echo: true
#| eval: true
#| cache: true
# Crear explicador DALEX
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)](https://arxiv.org/abs/2104.00507) para más detalles sobre los métodos de clasificación de sesgos
:::
:::{.column width=45%}
:::{style="font-size: 22px;"}
Un [explicador]{.alert} 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]{.alert}
Es el paso intermedio: modelo → explicador → auditoría
:::
:::
:::
:::
## La auditoría: fairness_check()
:::{style="margin-top: 30px; font-size: 22px;"}
```{r}
#| echo: true
#| eval: true
#| cache: true
# Crear objeto fairness con Sex como atributo protegido
fobject <- fairness_check(
explainer,
protected = test_data$Sex,
privileged = "male", # grupo de referencia
cutoff = 0.5, # umbral de decisión
verbose = TRUE
)
# Ver resumen
print(fobject)
```
El grupo [privilegiado]{.alert} es el de referencia. Las métricas comparan otros grupos con este
:::
## Visualizar métricas de equidad
:::{style="margin-top: 30px; font-size: 22px;"}
La zona verde es la [regla del 80%]{.alert} de la sesión 5.2: un ratio entre 0,8 y 1,25 se considera aceptable. Fuera de ella hay disparidad
```{r}
#| echo: true
#| eval: true
#| fig-width: 10
#| fig-height: 5
#| cache: true
plot(fobject)
```
:::
## Interpretar las métricas {#sec:interpretar-metricas}
:::{style="margin-top: 20px; font-size: 20px;"}
Cada fila de la salida (y cada barra del gráfico anterior) es un [ratio entre grupos]{.alert} (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]{.alert} (que el modelo prediga `good`)
| Nombre en la salida | Fórmula | Qué pregunta, en crédito | En una frase |
|---|---|---|---|
| [Accuracy equality]{.alert} (ACC) | (TP+TN)/total | ¿Acierta con la misma frecuencia en cada grupo? | Mismo nivel de aciertos |
| [Equal opportunity]{.alert} (TPR) | TP/(TP+FN) | De quienes [sí]{.alert} pagarían, ¿qué proporción recibe el crédito? | Igualdad de oportunidades |
| [Predictive equality]{.alert} (FPR) | FP/(FP+TN) | De quienes [no]{.alert} pagarían, ¿qué proporción se aprueba por error? | Mismo nivel de errores costosos |
| [Predictive parity]{.alert} (PPV) | TP/(TP+FP) | De quienes el modelo [aprueba]{.alert}, ¿qué proporción sí paga? | Calibración: "aprobado" significa lo mismo |
| [Statistical parity]{.alert} (STP) | (TP+FP)/total | ¿Qué proporción de cada grupo recibe un "aprobado"? | Paridad demográfica |
:::{style="margin-top: 10px;"}
La tabla de confusión, en crédito: [TP]{.alert} aprobado y paga · [FP]{.alert} aprobado y no paga · [TN]{.alert} rechazado y no pagaría · [FN]{.alert} rechazado pero sí pagaría
:::
:::
## Mitigación: umbrales diferenciados {#sec:mitigacion}
:::{style="margin-top: 30px; font-size: 20px;"}
:::{.columns}
:::{.column width=70%}
Una estrategia de [post-procesamiento]{.alert} (sesión 5.2): umbrales distintos por grupo para igualar un tipo de error
```{r}
#| echo: true
#| eval: true
#| cache: true
# Un umbral por grupo: 0.5 para hombres, 0.4 para mujeres
umbral <- ifelse(test_data$Sex == "male", 0.5, 0.4)
mitigated_pred <- ifelse(pred_probs > umbral, "good", "bad")
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)))
```
:::
:::{.column width=30%}
:::{style="font-size: 21px;"}
**Consideraciones**
- ¿Es justo usar [umbrales diferentes]{.alert} por grupo? Algunos lo llaman discriminación inversa; otros, corrección de un sesgo sistémico
- La mitigación [mueve]{.alert} el problema: mejora una métrica y empeora otra
:::{style="margin-top: 15px; background: rgba(45, 69, 99, 0.1); padding: 15px; border-radius: 10px;"}
[La decisión de usar umbrales diferenciados es política, no técnica]{.alert}
:::
[[Apéndice: explorar umbrales en detalle]{.button}](#sec:apendice-umbrales)
:::
:::
:::
:::
# Discusión {background-color="#2d4563"}
## Actividad: recomendación al banco
:::{style="margin-top: 30px; font-size: 22px;"}
:::{.columns}
:::{.column width=50%}
**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]{.alert}: más mujeres buenas pagadoras son rechazadas
- [Accuracy similar]{.alert} entre grupos
- [Calibración correcta]{.alert}: las probabilidades significan lo mismo
El banco les pide una recomendación
:::
:::{.column width=50%}
**Preguntas para discutir (5 min):**
1. ¿Deberían usar [umbrales diferenciados]{.alert} por sexo?
2. Si lo hacen, ¿es [discriminación positiva]{.alert} o simplemente [corrección de sesgo]{.alert}?
3. ¿Qué [métrica de equidad]{.alert} priorizarían y por qué?
4. ¿Cómo [comunicarían]{.alert} la decisión a los clientes?
5. ¿La ley uruguaya permite trato diferenciado por sexo en decisiones de crédito?
:::{style="margin-top: 15px; background: rgba(45, 69, 99, 0.1); padding: 15px; border-radius: 10px;"}
**Divídanse en grupos de 4**
:::
:::
:::
:::
# Ejercicios {background-color="#2d4563"}
## Ejercicio 1: mejorar el codebook {#sec:exercise01}
:::{style="margin-top: 30px; font-size: 22px;"}
:::{.columns}
:::{.column width=55%}
**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]{.alert} 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
:::
:::{.column width=45%}
:::{style="font-size: 22px;"}
**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]{.button}](#sec:appendix01)
:::
:::
:::
:::
## Ejercicio 2: auditar con otra variable protegida {#sec:exercise02}
:::{style="margin-top: 30px; font-size: 22px;"}
:::{.columns}
:::{.column width=55%}
**Tarea:**
Repitan la auditoría de la Parte 2 usando [edad]{.alert} 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}
#| echo: true
#| eval: false
#| cache: true
test_data <- test_data |>
mutate(
Age_group = case_when(
Age < 30 ~ "joven",
Age < 50 ~ "adulto",
TRUE ~ "mayor"
)
)
fobject_age <- fairness_check(
explainer,
protected = test_data$Age_group,
privileged = "adulto",
cutoff = 0.5,
verbose = FALSE
)
plot(fobject_age)
```
:::
:::{.column width=45%}
**Preguntas a responder:**
1. ¿Qué grupo de edad tiene peor rendimiento?
2. ¿En qué métricas hay mayor disparidad?
3. ¿Tiene sentido que [adultos]{.alert} sean el grupo privilegiado?
4. ¿Cómo se compara el sesgo por edad con el sesgo por sexo?
:::{style="margin-top: 20px; background: rgba(230, 57, 70, 0.1); padding: 15px; border-radius: 10px;"}
Trabajen individualmente o en parejas. Pueden consultar la documentación de fairmodels
:::
[[Solución]{.button}](#sec:appendix02)
:::
:::
:::
## Resumen del laboratorio
:::{style="margin-top: 30px; font-size: 23px;"}
:::{.columns}
:::{.column width=50%}
**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]{.alert}
- El codebook con [reglas de decisión]{.alert} es la herramienta para mejorar al codificador
- Validar contra gold standard sigue siendo [obligatorio]{.alert}, local o nube
:::
:::{.column width=50%}
**Parte 2: la auditoría**
- Un modelo [sin variables sensibles]{.alert} puede seguir siendo sesgado
- `fairness_check()` compara métricas entre grupos; la zona verde es la regla del 80%
- La [mitigación]{.alert} implica trade-offs y las decisiones de equidad son políticas, no sólo técnicas
:::
:::
:::{style="margin-top: 20px; text-align: center;"}
[En la próxima sesión: mini-propuestas de investigación y cierre del curso]{.alert}
:::
:::
# Apéndice {background-color="#2d4563"}
## Solución Ejercicio 1 {#sec:appendix01}
:::{style="margin-top: 20px; font-size: 19px;"}
Un codebook con reglas de decisión para los casos límite:
```{r}
#| echo: true
#| eval: true
#| cache: true
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)
:::{style="margin-top: 30px; font-size: 21px;"}
```{r}
#| echo: true
#| eval: true
#| cache: true
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"
)
validacion_v2 <- qlm_validate(
codificado_v2,
gold = gold,
by = "motivo",
level = "nominal"
)
as_tibble(validacion_v2) |> select(measure, value)
```
:::{style="margin-top: 15px;"}
- Comparen con la validación original. Con 16 textos, cada caso vale [6 puntos]{.alert} 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]{.alert} entre corridas
- La lección metodológica: cuando el codificador falla, el primer sospechoso es el [codebook]{.alert}, no el modelo
:::
[[Volver al ejercicio]{.button}](#sec:exercise01)
:::
## Solución Ejercicio 2 {#sec:appendix02}
:::{style="margin-top: 30px; font-size: 20px;"}
```{r}
#| echo: true
#| eval: true
#| fig-width: 10
#| fig-height: 4
#| cache: true
# Crear grupos de edad
test_data <- test_data |>
mutate(
Age_group = case_when(
Age < 30 ~ "joven",
Age < 50 ~ "adulto",
TRUE ~ "mayor"
)
)
# Crear objeto fairness por edad
fobject_age <- fairness_check(
explainer,
protected = test_data$Age_group,
privileged = "adulto",
cutoff = 0.5,
verbose = FALSE
)
# Visualizar
plot(fobject_age)
```
[[Volver al ejercicio]{.button}](#sec:exercise02)
:::
## Apéndice: explorar umbrales en detalle {#sec:apendice-umbrales}
:::{style="margin-top: 30px; font-size: 21px;"}
```{r}
#| echo: true
#| eval: true
#| cache: true
# Función para calcular métricas con umbral personalizado
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"
)
}
# Probar varios umbrales
thresholds <- c(0.3, 0.4, 0.5, 0.6, 0.7)
results <- map_dfr(thresholds, ~calc_metrics_threshold(test_data, pred_probs, .x))
results |>
arrange(Sex, threshold) |>
mutate(across(where(is.numeric), ~round(., 3)))
```
:::
[[Volver a mitigación]{.button}](#sec:mitigacion)
## Apéndice: visualizar el trade-off
:::{style="margin-top: 30px; font-size: 22px;"}
```{r}
#| echo: true
#| eval: true
#| fig-width: 10
#| fig-height: 4
#| cache: true
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")
```
:::{style="margin-top: 10px;"}
[Diferentes umbrales afectan a los grupos de manera diferente.]{.alert} 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]{.button}](#sec:mitigacion)
:::
## Apéndice: ¿cuánta calidad cuesta la privacidad?
:::{style="margin-top: 30px; font-size: 21px;"}
:::{.columns}
:::{.column width=55%}
Si los datos [no fueran]{.alert} sensibles, podríamos comparar el codificador local contra uno de la nube. `qlm_replicate()` acepta un modelo distinto:
```{r}
#| echo: true
#| eval: false
#| cache: true
codificado_nube <- qlm_replicate(
codificado,
model = "openrouter/nvidia/nemotron-3-super-120b-a12b:free",
max_active = 3,
name = "nemotron_nube"
)
qlm_compare(
codificado, codificado_nube,
by = "motivo", level = "nominal"
)
qlm_validate(
codificado_nube,
gold = gold, by = "motivo", level = "nominal"
)
```
:::
:::{.column width=45%}
:::{style="font-size: 21px;"}
**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?]{.alert}
:::{.callout-warning}
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 😊 {background-color="#2d4563"}